Compare commits

..

117 Commits

Author SHA1 Message Date
CI
e9b475c0a8 chore: release version v1.77.0 2025-08-27 21:18:31 +00:00
Dmitry Popov
7752010092 feat(Core): Reduced DB calls to check existing system jumps 2025-08-27 23:17:58 +02:00
CI
d3705b3ed7 chore: [skip ci] 2025-08-27 20:46:18 +00:00
CI
1394e2897e chore: release version v1.76.13 2025-08-27 20:46:18 +00:00
Dmitry Popov
5117a1c5af fix(Core): Fixed maps start timeout 2025-08-27 22:42:29 +02:00
CI
3c62403f33 chore: [skip ci] 2025-08-20 14:37:18 +00:00
CI
a4760f5162 chore: release version v1.76.12 2025-08-20 14:37:18 +00:00
Dmitry Popov
b071070431 fix(Core): Reduced ESI api calls to update character corp/ally info 2025-08-20 16:36:46 +02:00
CI
3bcb9628e7 chore: [skip ci] 2025-08-20 07:53:27 +00:00
CI
e62c4cf5bf chore: release version v1.76.11 2025-08-20 07:53:27 +00:00
Dmitry Popov
af46962ce4 Merge pull request #503 from wanderer-industries/revert-501-guarzo/sigsfix
Revert "fix: default signature types not being shown"
2025-08-20 11:53:00 +04:00
Dmitry Popov
0b0967830b Revert "fix: default signature types not being shown" 2025-08-20 11:52:34 +04:00
CI
172251a208 chore: [skip ci] 2025-08-18 23:28:33 +00:00
CI
8a6fb63d55 chore: release version v1.76.10 2025-08-18 23:28:33 +00:00
Dmitry Popov
9652959e5e fix(Core): Added character trackers start queue 2025-08-19 01:27:58 +02:00
CI
825ef46d41 chore: [skip ci] 2025-08-18 11:42:47 +00:00
CI
ad9f7c6b95 chore: release version v1.76.9 2025-08-18 11:42:47 +00:00
Dmitry Popov
b960b5c149 Merge pull request #501 from guarzo/guarzo/sigsfix
fix: default signature types not being shown
2025-08-18 15:42:14 +04:00
CI
0f092d21f9 chore: [skip ci] 2025-08-17 21:28:20 +00:00
CI
031576caa6 chore: release version v1.76.8 2025-08-17 21:28:20 +00:00
Dmitry Popov
7a97a96c42 fix(Core): added DB connection default timeouts 2025-08-17 23:27:21 +02:00
CI
2efb2daba0 chore: [skip ci] 2025-08-16 22:17:44 +00:00
CI
4374c39924 chore: release version v1.76.7 2025-08-16 22:17:44 +00:00
Dmitry Popov
15711495c7 fix(Core): Fixed auth redirect URL 2025-08-17 00:17:17 +02:00
guarzo
236f803427 fix: default signature types not being shown 2025-08-15 23:03:22 +00:00
CI
6772130f2a chore: [skip ci] 2025-08-15 15:27:07 +00:00
CI
ddd72f3fac chore: release version v1.76.6 2025-08-15 15:27:07 +00:00
Dmitry Popov
6e262835ef Merge pull request #500 from guarzo/guarzo/moressefix
fix: empty subscriptions for sse
2025-08-15 19:26:34 +04:00
guarzo
2f3b8ddc5f fix: empty subscriptions for sse 2025-08-15 11:08:40 -04:00
CI
cea3a74b34 chore: [skip ci] 2025-08-15 10:29:11 +00:00
CI
867941a233 chore: release version v1.76.5 2025-08-15 10:29:11 +00:00
Dmitry Popov
3ff388a16d fix(Core): fixed tracking paused issues, fixed user activity data 2025-08-15 12:28:36 +02:00
CI
f4248e9ab9 chore: [skip ci] 2025-08-14 23:40:20 +00:00
CI
507b3289c7 chore: release version v1.76.4 2025-08-14 23:40:20 +00:00
Dmitry Popov
9e1dfc48d5 Merge pull request #499 from guarzo/guarzo/relfixes 2025-08-15 03:39:50 +04:00
guarzo
518cbc7b5d fix: timestamp errors for sse and tracking 2025-08-14 19:22:30 -04:00
CI
ccc8db0620 chore: [skip ci] 2025-08-14 21:41:56 +00:00
CI
7cfb663efd chore: release version v1.76.3 2025-08-14 21:41:56 +00:00
Dmitry Popov
e5103cc925 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-14 23:41:30 +02:00
Dmitry Popov
26458f5a19 chore: Get rid of tracking pauses 2025-08-14 23:41:26 +02:00
CI
79d5ec6caf chore: [skip ci] 2025-08-14 20:56:39 +00:00
CI
034d461ab6 chore: release version v1.76.2 2025-08-14 20:56:39 +00:00
Dmitry Popov
2e9c1c170c chore: Get rid of tracking pauses 2025-08-14 22:56:08 +02:00
Dmitry Popov
24ad3b2c61 chore: [skip ci] 2025-08-14 11:28:56 +02:00
CI
288f55dc2f chore: [skip ci] 2025-08-13 16:15:29 +00:00
CI
78dbea6267 chore: release version v1.76.1 2025-08-13 16:15:29 +00:00
Dmitry Popov
6a9e53141d Merge pull request #498 from wanderer-industries/reselect-systems-after-init
fix(Map): Fix problem when systems was deselected after change tab
2025-08-13 20:15:05 +04:00
DanSylvest
05e6994520 fix(Map): Fix problem when systems was deselected after change tab 2025-08-13 18:58:24 +03:00
CI
1a4dc67eb9 chore: [skip ci] 2025-08-12 11:39:44 +00:00
CI
31d87a116b chore: release version v1.76.0 2025-08-12 11:39:44 +00:00
Dmitry Popov
c47796d590 Merge pull request #497 from wanderer-industries/sig-temp-names
Sig temp names
2025-08-12 15:39:07 +04:00
Dmitry Popov
c7138a41ee feat(Signatures): Sync signature temporary name with system on link signature to system 2025-08-12 13:20:03 +02:00
Dmitry Popov
96f04c70a9 Merge branch 'main' into sig-temp-names 2025-08-11 19:20:46 +02:00
Dmitry Popov
87a8bc09ab chore: [skip ci] 2025-08-11 19:20:37 +02:00
Dmitry Popov
5f5661d559 chore: [skip ci] 2025-08-11 19:20:13 +02:00
CI
35ca87790e chore: [skip ci] 2025-08-11 15:57:51 +00:00
CI
ae43e4a57c chore: release version v1.75.23 2025-08-11 15:57:51 +00:00
Dmitry Popov
b91712a01a chore: updated deps 2025-08-11 17:54:23 +02:00
CI
b20007b341 chore: [skip ci] 2025-08-11 14:47:44 +00:00
CI
6a24e1188b chore: release version v1.75.22 2025-08-11 14:47:44 +00:00
Dmitry Popov
5894efc1aa chore: release version v1.75.21 2025-08-11 16:47:11 +02:00
CI
a05612d243 chore: [skip ci] 2025-08-11 12:02:06 +00:00
CI
48de874d6b chore: release version v1.75.21 2025-08-11 12:02:06 +00:00
Dmitry Popov
91e6da316f Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 14:01:41 +02:00
Dmitry Popov
fa60bd81a1 chore: release version v1.75.19 2025-08-11 14:01:33 +02:00
CI
a08a69c5be chore: [skip ci] 2025-08-11 11:55:22 +00:00
CI
18d450a41a chore: release version v1.75.20 2025-08-11 11:55:22 +00:00
Dmitry Popov
36cdee61c0 chore: release version v1.75.19 2025-08-11 13:54:51 +02:00
Dmitry Popov
797e188259 fix: Fixed docs 2025-08-11 13:49:22 +02:00
Dmitry Popov
91b581668a Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:44:17 +02:00
Dmitry Popov
ad01fec28f Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:44:13 +02:00
CI
357d3a0df6 chore: release version v1.75.19 2025-08-11 11:25:52 +00:00
CI
5ce6022761 chore: release version v1.75.18 2025-08-11 11:25:17 +00:00
CI
235a0c5aea chore: release version v1.75.17 2025-08-11 11:24:44 +00:00
CI
9b81fa6ebb chore: release version v1.75.16 2025-08-11 11:24:16 +00:00
Dmitry Popov
8792d5ab0e Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:23:40 +02:00
Dmitry Popov
d46ed0c078 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:23:36 +02:00
CI
73c433fcd2 chore: release version v1.75.15 2025-08-11 11:23:28 +00:00
CI
02b5239220 chore: release version v1.75.14 2025-08-11 11:22:57 +00:00
CI
0ed3bdfcb0 chore: release version v1.75.13 2025-08-11 11:22:29 +00:00
CI
bdeb89011f chore: release version v1.75.12 2025-08-11 11:22:00 +00:00
CI
1523b625bc chore: release version v1.75.11 2025-08-11 11:21:27 +00:00
CI
fb91eeb692 chore: release version v1.75.10 2025-08-11 11:20:57 +00:00
CI
601d2e02cb chore: release version v1.75.9 2025-08-11 11:20:24 +00:00
Dmitry Popov
0a662d34eb Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:19:52 +02:00
Dmitry Popov
5cd4693e9d Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:19:49 +02:00
CI
f3f0f860e3 chore: release version v1.75.8 2025-08-11 11:04:18 +00:00
Dmitry Popov
93a5cf8a79 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:03:42 +02:00
Dmitry Popov
7cf15cbc21 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 13:03:39 +02:00
CI
30bc6d20b2 chore: release version v1.75.7 2025-08-11 10:55:27 +00:00
Dmitry Popov
b39f99fde4 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-08-11 12:55:02 +02:00
Dmitry Popov
0e8aa9efa4 chore: release version v1.75.5 2025-08-11 12:54:58 +02:00
CI
e1fcde36e3 chore: release version v1.75.6 2025-08-11 10:51:27 +00:00
Dmitry Popov
7aafe077d3 chore: release version v1.75.5 2025-08-11 12:50:59 +02:00
CI
5b8cab5e76 chore: release version v1.75.5 2025-08-11 10:48:40 +00:00
Dmitry Popov
4ab56af40a chore: release version v1.75.4 2025-08-11 12:48:09 +02:00
CI
e8cea86a76 chore: release version v1.75.4 2025-08-11 07:52:21 +00:00
Dmitry Popov
d0a6e0b358 Merge pull request #496 from guarzo/guarzo/secaudit 2025-08-11 11:51:26 +04:00
guarzo
8831b3e970 fix: restore security audit 2025-08-11 03:37:33 +00:00
CI
f6db6f0914 chore: release version v1.75.3
Some checks failed
Flaky Test Detection / 🔍 Detect Flaky Tests (push) Has been cancelled
Flaky Test Detection / 📊 Analyze Test History (push) Has been cancelled
Build / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build / merge (push) Has been cancelled
Build / 🏷 Create Release (push) Has been cancelled
2025-08-10 22:17:25 +00:00
Dmitry Popov
ab8baeedd1 fix(core): Fixed character tracking issues 2025-08-11 00:16:52 +02:00
CI
eccee5e72e chore: release version v1.75.2 2025-08-10 16:00:40 +00:00
Dmitry Popov
4d93055bda Merge pull request #480 from wanderer-industries/default-settings
Default settings
2025-08-10 19:56:58 +04:00
Dmitry Popov
c60c16e56a chore: Fix issues with ash resources 2025-08-10 17:45:29 +02:00
Dmitry Popov
99b1de5647 chore: Fix issues with ash resources 2025-08-10 17:44:56 +02:00
Dmitry Popov
7efe11a421 chore: Fix issues with ash resources 2025-08-10 17:21:36 +02:00
Dmitry Popov
954108856a chore: Fix issues with ash resources 2025-08-10 16:55:45 +02:00
Dmitry Popov
cbca745ec4 Merge branch 'main' into default-settings 2025-08-10 16:19:59 +02:00
DanSylvest
e15e7c8f8d fix(Map): Fix indents for ally logos in list "On the map" 2025-08-10 12:51:15 +03:00
DanSylvest
65e8a520e5 fix(Map): Fix cancelling ping from system context menu 2025-08-10 12:00:05 +03:00
DanSylvest
3926af5a6d fix(Map): Hide admin settings tab 2025-08-10 10:02:29 +03:00
DanSylvest
556fb33223 fix(Map): Remote map setting refactoring 2025-08-10 09:57:50 +03:00
Dmitry Popov
82295adeab Merge pull request #477 from guarzo/guarzo/settings
feature: provide default settings interface
2025-07-31 12:21:06 +04:00
guarzo
9d7d4fad2e feature: provide default settings interface 2025-07-27 14:27:45 -04:00
Dmitry Popov
74f7ad155d Merge branch 'develop' into sig-temp-names 2025-07-18 13:52:07 +02:00
DanSylvest
f58ebad0ec fix(Map): Add Temp name field 2025-07-08 18:43:49 +03:00
Dmitry Popov
7ca4eb3b8f feat(Signatures): add support for signature temp names 2025-07-08 14:03:22 +02:00
190 changed files with 9257 additions and 14922 deletions

View File

@@ -5,7 +5,7 @@ on:
branches:
- main
- develop
- "releases/*"
env:
MIX_ENV: prod
GH_TOKEN: ${{ github.token }}
@@ -53,6 +53,7 @@ jobs:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ssh-key: "${{ secrets.COMMIT_KEY }}"
fetch-depth: 0
- name: 😅 Cache deps
id: cache-deps
@@ -95,9 +96,10 @@ jobs:
git config --global user.name 'CI'
git config --global user.email 'ci@users.noreply.github.com'
mix git_ops.release --force-patch --yes
git commit --allow-empty -m 'chore: [skip ci]'
git push --follow-tags
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Set commit hash for develop
id: set-commit-develop
if: github.ref == 'refs/heads/develop'
@@ -106,11 +108,9 @@ jobs:
docker:
name: 🛠 Build Docker Images
if: github.ref == 'refs/heads/develop'
needs: build
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
@@ -137,19 +137,6 @@ jobs:
ref: ${{ needs.build.outputs.commit_hash }}
fetch-depth: 0
- name: Prepare Changelog
if: github.ref == 'refs/heads/main'
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Get Release Tag
id: get-latest-tag
if: github.ref == 'refs/heads/main'
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
@@ -198,26 +185,6 @@ jobs:
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
if: github.ref == 'refs/heads/main'
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
if: github.ref == 'refs/heads/main'
with:
stringToTruncate: |
📣 Wanderer new release available 🎉
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
@@ -248,9 +215,6 @@ jobs:
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}},enable=${{ github.ref == 'refs/heads/main' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.ref == 'refs/heads/main' }}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=develop,enable=${{ github.ref == 'refs/heads/develop' }}
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
@@ -267,19 +231,25 @@ jobs:
create-release:
name: 🏷 Create Release
runs-on: ubuntu-22.04
needs: [docker, merge]
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
needs: build
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: 🏷 Create Draft Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.docker.outputs.release-tag }}
name: Release ${{ needs.docker.outputs.release-tag }}
tag_name: ${{ steps.get-latest-tag.outputs.tag }}
name: Release ${{ steps.get-latest-tag.outputs.tag }}
body: |
## Info
Commit ${{ github.sha }} was deployed to `staging`. [See code diff](${{ github.event.compare }}).
@@ -289,10 +259,3 @@ jobs:
## How to Promote?
In order to promote this to prod, edit the draft and press **"Publish release"**.
draft: true
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
if: github.ref == 'refs/heads/main'
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}

187
.github/workflows/docker-arm.yml vendored Normal file
View File

@@ -0,0 +1,187 @@
name: Build Docker ARM Image
on:
push:
tags:
- '**'
env:
MIX_ENV: prod
GH_TOKEN: ${{ github.token }}
REGISTRY_IMAGE: wandererltd/community-edition-arm
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
docker:
name: 🛠 Build Docker Images
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
packages: write
attestations: write
id-token: write
pull-requests: write
repository-projects: write
strategy:
fail-fast: false
matrix:
platform:
- linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ steps.get-latest-tag.outputs.tag }}
fetch-depth: 0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./Dockerfile
cache-from: type=gha
cache-to: type=gha,mode=max
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
MIX_ENV=prod
BUILD_METADATA=${{ steps.meta.outputs.json }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer **ARM** release available 🎉
**Version**: :${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
- docker
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
notify:
name: 🏷 Notify about release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}

187
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,187 @@
name: Build Docker Image
on:
push:
tags:
- '**'
env:
MIX_ENV: prod
GH_TOKEN: ${{ github.token }}
REGISTRY_IMAGE: wandererltd/community-edition
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
docker:
name: 🛠 Build Docker Images
runs-on: ubuntu-22.04
outputs:
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
release-notes: ${{ steps.get-content.outputs.string }}
permissions:
checks: write
contents: write
packages: write
attestations: write
id-token: write
pull-requests: write
repository-projects: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Get Release Tag
id: get-latest-tag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
fallback: 1.0.0
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
with:
ref: ${{ steps.get-latest-tag.outputs.tag }}
fetch-depth: 0
- name: Prepare Changelog
run: |
yes | cp -rf CHANGELOG.md priv/changelog/CHANGELOG.md
sed -i '1i%{title: "Change Log"}\n\n---\n' priv/changelog/CHANGELOG.md
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: true
context: .
file: ./Dockerfile
cache-from: type=gha
cache-to: type=gha,mode=max
labels: ${{ steps.meta.outputs.labels }}
platforms: ${{ matrix.platform }}
outputs: type=image,"name=${{ env.REGISTRY_IMAGE }}",push-by-digest=true,name-canonical=true,push=true
build-args: |
MIX_ENV=prod
BUILD_METADATA=${{ steps.meta.outputs.json }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- uses: markpatterson27/markdown-to-output@v1
id: extract-changelog
with:
filepath: CHANGELOG.md
- name: Get content
uses: 2428392/gh-truncate-string-action@v1.3.0
id: get-content
with:
stringToTruncate: |
📣 Wanderer new release available 🎉
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
${{ steps.extract-changelog.outputs.body }}
maxLength: 500
truncationSymbol: "…"
merge:
runs-on: ubuntu-latest
needs:
- docker
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.WANDERER_DOCKER_USER }}
password: ${{ secrets.WANDERER_DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY_IMAGE }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{version}},value=${{ needs.docker.outputs.release-tag }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
notify:
name: 🏷 Notify about release
runs-on: ubuntu-22.04
needs: [docker, merge]
steps:
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v5.3.0
with:
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
content: ${{ needs.docker.outputs.release-notes }}

File diff suppressed because it is too large Load Diff

View File

@@ -21,21 +21,17 @@ RUN mkdir config
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
COPY priv priv
COPY lib lib
COPY assets assets
RUN mix compile
RUN mix assets.deploy
RUN mix compile
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# start a new build stage so that the final image will only contain

View File

@@ -1,18 +1,13 @@
// import './tailwind.css';
//@import 'primereact/resources/themes/bootstrap4-dark-blue/theme.css';
//@import 'primereact/resources/themes/lara-dark-purple/theme.css';
//@import "prime-fixes";
@import 'primereact/resources/primereact.min.css';
//@import 'primeflex/primeflex.css';
@import 'primeicons/primeicons.css';
//@import 'primereact/resources/primereact.css';
@use 'primereact/resources/primereact.min.css';
@use 'primeicons/primeicons.css';
@import "fixes";
@import "prime-fixes";
@import "custom-scrollbar";
@import "tooltip";
@import "context-menu";
@use "fixes";
@use "prime-fixes";
@use "custom-scrollbar";
@use "tooltip";
@use "context-menu";
.fixedImportant {

View File

@@ -1,7 +1,7 @@
.vertical-tabs-container {
display: flex;
width: 100%;
min-height: 300px;
min-height: 400px;
.p-tabview {
width: 100%;
@@ -68,6 +68,28 @@
}
}
&.color-warn {
@apply bg-yellow-600/5 border-r-yellow-600/20;
&:hover {
@apply bg-yellow-600/10 border-r-yellow-600/40;
}
&.p-tabview-selected {
@apply bg-yellow-600/10 border-r-yellow-600;
.p-tabview-nav-link {
@apply text-yellow-600;
}
&:hover {
@apply bg-yellow-600/10 border-r-yellow-600;
}
}
}
}
}

View File

@@ -1,6 +1,3 @@
@import "fix-dialog";
@import "fix-popup";
@import "fix-tabs";
//@import "fix-input";
//@import "theme";
@use "fix-dialog";
@use "fix-popup";
@use "fix-tabs";

View File

@@ -19,7 +19,7 @@ export interface ContextMenuSystemProps {
onSystemStatus(val: number): void;
onSystemLabels(val: string): void;
onCustomLabelDialog(): void;
onTogglePing(type: PingType, solar_system_id: string, hasPing: boolean): void;
onTogglePing(type: PingType, solar_system_id: string, ping_id: string | undefined, hasPing: boolean): void;
onWaypointSet: WaypointSetContextHandler;
}

View File

@@ -109,7 +109,7 @@ export const useContextMenuSystemItems = ({
{ separator: true },
{
command: () => onTogglePing(PingType.Rally, systemId, hasPing),
command: () => onTogglePing(PingType.Rally, systemId, ping?.id, hasPing),
disabled: !isShowPingBtn,
template: () => {
const iconClasses = clsx({

View File

@@ -1,17 +1,24 @@
import { Node } from 'reactflow';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ContextMenu } from 'primereact/contextmenu';
import { SolarSystemRawType } from '@/hooks/Mapper/types';
import { ctxManager } from '@/hooks/Mapper/utils/contextManager.ts';
import { NodeSelectionMouseHandler } from '@/hooks/Mapper/components/contexts/types.ts';
import { useDeleteSystems } from '@/hooks/Mapper/components/contexts/hooks';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
export const useContextMenuSystemMultipleHandlers = () => {
const {
data: { pings },
} = useMapRootState();
const contextMenuRef = useRef<ContextMenu | null>(null);
const [systems, setSystems] = useState<Node<SolarSystemRawType>[]>();
const { deleteSystems } = useDeleteSystems();
const ping = useMemo(() => (pings.length === 1 ? pings[0] : undefined), [pings]);
const handleSystemMultipleContext: NodeSelectionMouseHandler = (ev, systems_) => {
setSystems(systems_);
ev.preventDefault();
@@ -24,13 +31,17 @@ export const useContextMenuSystemMultipleHandlers = () => {
return;
}
const sysToDel = systems.filter(x => !x.data.locked).map(x => x.id);
const sysToDel = systems
.filter(x => !x.data.locked)
.filter(x => x.id !== ping?.solar_system_id)
.map(x => x.id);
if (sysToDel.length === 0) {
return;
}
deleteSystems(sysToDel);
}, [deleteSystems, systems]);
}, [deleteSystems, systems, ping]);
return {
handleSystemMultipleContext,

View File

@@ -1,6 +1,6 @@
import { MapUserSettings, SettingsWithVersion } from '@/hooks/Mapper/mapRootProvider/types.ts';
const REQUIRED_KEYS = [
export const REQUIRED_KEYS = [
'widgets',
'interface',
'onTheMap',

View File

@@ -1,3 +1,4 @@
export * from './useSystemInfo';
export * from './useGetOwnOnlineCharacters';
export * from './useElementWidth';
export * from './useDetectSettingsChanged';

View File

@@ -0,0 +1,23 @@
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useEffect, useState } from 'react';
export const useDetectSettingsChanged = () => {
const {
storedSettings: {
interfaceSettings,
settingsRoutes,
settingsLocal,
settingsSignatures,
settingsOnTheMap,
settingsKills,
},
} = useMapRootState();
const [counter, setCounter] = useState(0);
useEffect(
() => setCounter(x => x + 1),
[interfaceSettings, settingsRoutes, settingsLocal, settingsSignatures, settingsOnTheMap, settingsKills],
);
return counter;
};

View File

@@ -1,4 +1,4 @@
@import '@/hooks/Mapper/components/map/styles/eve-common-variables';
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
.ConnectionTimeEOL {
background-image: linear-gradient(207deg, transparent, var(--conn-time-eol));

View File

@@ -1,4 +1,4 @@
@import '@/hooks/Mapper/components/map/styles/eve-common-variables';
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
.EdgePathBack {
fill: none;

View File

@@ -1,4 +1,5 @@
@import '@/hooks/Mapper/components/map/styles/eve-common-variables';
@use "sass:color";
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
$pastel-blue: #5a7d9a;
$pastel-pink: rgb(30, 161, 255);
@@ -34,7 +35,7 @@ $neon-color-3: rgba(27, 132, 236, 0.40);
color: var(--rf-text-color, #ffffff);
box-shadow: 0 0 5px rgba($dark-bg, 0.5);
border: 1px solid darken($pastel-blue, 10%);
border: 1px solid color.adjust($pastel-blue, $lightness: -10%);
border-radius: 5px;
position: relative;
z-index: 3;

View File

@@ -1,4 +1,4 @@
@import './SolarSystemNodeDefault.module.scss';
@use './SolarSystemNodeDefault.module.scss';
/* ---------------------------------------------
Only override what's different from the base

View File

@@ -21,7 +21,9 @@ import { KillsCounter } from '@/hooks/Mapper/components/map/components/KillsCoun
export const SolarSystemNodeTheme = memo((props: NodeProps<MapSolarSystemType>) => {
const nodeVars = useSolarSystemNode(props);
const { localCounterCharacters } = useLocalCounter(nodeVars);
const { killsCount: localKillsCount, killsActivityType: localKillsActivityType } = useNodeKillsCount(nodeVars.solarSystemId);
const { killsCount: localKillsCount, killsActivityType: localKillsActivityType } = useNodeKillsCount(
nodeVars.solarSystemId,
);
// console.log('JOipP', `render ${nodeVars.id}`, render++);

View File

@@ -1,4 +1,4 @@
@import '@/hooks/Mapper/components/map/styles/eve-common-variables';
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
.Signature {
position: relative;

View File

@@ -6,5 +6,5 @@ export * from './useCommandsCharacters';
export * from './useCommandsConnections';
export * from './useCommandsConnections';
export * from './useCenterSystem';
export * from './useSelectSystem';
export * from './useSelectSystems';
export * from './useMapCommands';

View File

@@ -1,21 +0,0 @@
import { useReactFlow } from 'reactflow';
import { useCallback, useRef } from 'react';
import { CommandSelectSystem } from '@/hooks/Mapper/types';
export const useSelectSystem = () => {
const rf = useReactFlow();
const ref = useRef({ rf });
ref.current = { rf };
return useCallback((systemId: CommandSelectSystem) => {
ref.current.rf.setNodes(nds =>
nds.map(node => {
return {
...node,
selected: node.id === systemId,
};
}),
);
}, []);
};

View File

@@ -0,0 +1,31 @@
import { useReactFlow } from 'reactflow';
import { useCallback, useRef } from 'react';
import { CommandSelectSystems } from '@/hooks/Mapper/types';
import { OnMapSelectionChange } from '@/hooks/Mapper/components/map/map.types.ts';
export const useSelectSystems = (onSelectionChange: OnMapSelectionChange) => {
const rf = useReactFlow();
const ref = useRef({ rf, onSelectionChange });
ref.current = { rf, onSelectionChange };
return useCallback(({ systems, delay }: CommandSelectSystems) => {
const run = () => {
ref.current.rf.setNodes(nds =>
nds.map(node => {
return {
...node,
selected: systems.includes(node.id),
};
}),
);
};
if (delay == null || delay === 0) {
run();
return;
}
setTimeout(run, delay);
}, []);
};

View File

@@ -14,6 +14,7 @@ import {
CommandRemoveSystems,
Commands,
CommandSelectSystem,
CommandSelectSystems,
CommandUpdateConnection,
CommandUpdateSystems,
MapHandlers,
@@ -28,7 +29,7 @@ import {
useMapRemoveSystems,
useMapUpdateSystems,
useCenterSystem,
useSelectSystem,
useSelectSystems,
} from './api';
import { OnMapSelectionChange } from '@/hooks/Mapper/components/map/map.types.ts';
@@ -38,7 +39,7 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
const mapUpdateSystems = useMapUpdateSystems();
const removeSystems = useMapRemoveSystems(onSelectionChange);
const centerSystem = useCenterSystem();
const selectSystem = useSelectSystem();
const selectSystems = useSelectSystems(onSelectionChange);
const selectRef = useRef({ onSelectionChange });
selectRef.current = { onSelectionChange };
@@ -105,14 +106,11 @@ export const useMapHandlers = (ref: ForwardedRef<MapHandlers>, onSelectionChange
break;
case Commands.selectSystem:
setTimeout(() => {
const systemId = `${data}`;
selectRef.current.onSelectionChange({
systems: [systemId],
connections: [],
});
selectSystem(systemId as CommandSelectSystem);
}, 500);
selectSystems({ systems: [data as string], delay: 500 });
break;
case Commands.selectSystems:
selectSystems(data as CommandSelectSystems);
break;
case Commands.pingAdded:

View File

@@ -1,5 +1,5 @@
@import './eve-common-variables';
@import './eve-common';
@use './eve-common-variables';
@use './eve-common';
.default-theme {
--rf-bg-color: #0C0A09;

View File

@@ -1,18 +1,19 @@
@use "sass:color";
$friendlyBase: #3bbd39;
$friendlyAlpha: #3bbd3952;
$friendlyDark20: darken($friendlyBase, 20%);
$friendlyDark30: darken($friendlyBase, 30%);
$friendlyDark5: darken($friendlyBase, 5%);
$friendlyDark20: color.adjust($friendlyBase, $lightness: -20%);
$friendlyDark30: color.adjust($friendlyBase, $lightness: -30%);
$friendlyDark5: color.adjust($friendlyBase, $lightness: -5%);
$lookingForBase: #43c2fd;
$lookingForAlpha: rgba(67, 176, 253, 0.48);
$lookingForDark15: darken($lookingForBase, 15%);
$lookingForDark15: color.adjust($lookingForBase, $lightness: -15%);
$homeBase: rgb(179, 253, 67);
$homeAlpha: rgba(186, 248, 48, 0.32);
$homeBackground: #a0fa5636;
$homeDark30: darken($homeBase, 30%);
$homeDark30: color.adjust($homeBase, $lightness: -30%);
:root {
--pastel-blue: #5a7d9a;

View File

@@ -1,4 +1,4 @@
@import './eve-common-variables';
@use './eve-common-variables';
.eve-wh-effect-color-pulsar {

View File

@@ -1,2 +1,2 @@
@import './default-theme.scss';
@import './pathfinder-theme.scss';
@use './default-theme.scss';
@use './pathfinder-theme.scss';

View File

@@ -1,10 +1,11 @@
@import './eve-common-variables';
@import './eve-common';
@use "sass:color";
@use './eve-common-variables';
@use './eve-common';
@import url('https://fonts.googleapis.com/css2?family=Oxygen:wght@300;400;700&display=swap');
$homeBase: rgb(197, 253, 67);
$homeAlpha: rgba(197, 253, 67, 0.32);
$homeDark30: darken($homeBase, 30%);
$homeDark30: color.adjust($homeBase, $lightness: -30%);
.pathfinder-theme {
/* -- Override values from the default theme -- */

View File

@@ -14,6 +14,7 @@ import { PrimeIcons } from 'primereact/api';
import { ConfirmPopup } from 'primereact/confirmpopup';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand } from '@/hooks/Mapper/types';
import { useConfirmPopup } from '@/hooks/Mapper/hooks';
const TOOLTIP_PROPS = { content: 'Remove comment', position: TooltipPosition.top };
@@ -28,8 +29,7 @@ export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownComm
const char = useGetCacheCharacter(characterEveId);
const [hovered, setHovered] = useState(false);
const cpRemoveBtnRef = useRef<HTMLElement>();
const [cpRemoveVisible, setCpRemoveVisible] = useState(false);
const { cfShow, cfHide, cfVisible, cfRef } = useConfirmPopup();
const { outCommand } = useMapRootState();
const ref = useRef({ outCommand, id });
@@ -45,9 +45,6 @@ export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownComm
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
const handleShowCP = useCallback(() => setCpRemoveVisible(true), []);
const handleHideCP = useCallback(() => setCpRemoveVisible(false), []);
return (
<>
<InfoDrawer
@@ -68,11 +65,11 @@ export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownComm
{!hovered && <TimeAgo timestamp={time} />}
{hovered && (
// @ts-ignore
<div ref={cpRemoveBtnRef}>
<div ref={cfRef}>
<WdImgButton
className={clsx(PrimeIcons.TRASH, 'hover:text-red-400')}
tooltip={TOOLTIP_PROPS}
onClick={handleShowCP}
onClick={cfShow}
/>
</div>
)}
@@ -85,9 +82,9 @@ export const MarkdownComment = ({ text, time, characterEveId, id }: MarkdownComm
</InfoDrawer>
<ConfirmPopup
target={cpRemoveBtnRef.current}
visible={cpRemoveVisible}
onHide={handleHideCP}
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete?"
icon="pi pi-exclamation-triangle"
accept={handleDelete}

View File

@@ -16,8 +16,9 @@ import { PrimeIcons } from 'primereact/api';
import { Button } from 'primereact/button';
import { ConfirmPopup } from 'primereact/confirmpopup';
import { Toast } from 'primereact/toast';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import useRefState from 'react-usestateref';
import { useConfirmPopup } from '@/hooks/Mapper/hooks';
const PING_PLACEMENT_MAP = {
[PingsPlacement.rightTop]: 'top-right',
@@ -78,9 +79,7 @@ export interface PingsInterfaceProps {
export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
const toast = useRef<Toast>(null);
const [isShow, setIsShow, isShowRef] = useRefState(false);
const cpRemoveBtnRef = useRef<HTMLElement>();
const [cpRemoveVisible, setCpRemoveVisible] = useState(false);
const { cfShow, cfHide, cfVisible, cfRef } = useConfirmPopup();
const {
storedSettings: { interfaceSettings },
@@ -98,9 +97,6 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
const ping = useMemo(() => (pings.length === 1 ? pings[0] : null), [pings]);
const handleShowCP = useCallback(() => setCpRemoveVisible(true), []);
const handleHideCP = useCallback(() => setCpRemoveVisible(false), []);
const navigateTo = useCallback(() => {
if (!ping) {
return;
@@ -242,11 +238,11 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
/>
{/*@ts-ignore*/}
<div ref={cpRemoveBtnRef}>
<div ref={cfRef}>
<WdImgButton
className={clsx('pi-trash', 'text-red-400 hover:text-red-300')}
tooltip={DELETE_TOOLTIP_PROPS}
onClick={handleShowCP}
onClick={cfShow}
/>
</div>
{/* TODO ADD solar system menu*/}
@@ -272,9 +268,9 @@ export const PingsInterface = ({ hasLeftOffset }: PingsInterfaceProps) => {
/>
<ConfirmPopup
target={cpRemoveBtnRef.current}
visible={cpRemoveVisible}
onHide={handleHideCP}
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Are you sure you want to delete ping?"
icon="pi pi-exclamation-triangle text-orange-400"
accept={removePing}

View File

@@ -28,12 +28,12 @@ import {
renderInfoColumn,
renderUpdatedTimeLeft,
} from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
import { SETTINGS_KEYS, SIGNATURE_WINDOW_ID, SignatureSettingsType } from '@/hooks/Mapper/constants/signatures.ts';
import { useClipboard, useHotkey } from '@/hooks/Mapper/hooks';
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { getSignatureRowClass } from '../helpers/rowStyles';
import { useSystemSignaturesData } from '../hooks/useSystemSignaturesData';
import { SETTINGS_KEYS, SIGNATURE_WINDOW_ID, SignatureSettingsType } from '@/hooks/Mapper/constants/signatures.ts';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
const renderColIcon = (sig: SystemSignature) => renderIcon(sig);
@@ -157,9 +157,18 @@ export const SystemSignaturesContent = ({
[onSelect, selectable, setSelectedSignatures, deletedSignatures],
);
const { showDescriptionColumn, showUpdatedColumn, showCharacterColumn, showCharacterPortrait } = useMemo(
const {
showGroupColumn,
showDescriptionColumn,
showAddedColumn,
showUpdatedColumn,
showCharacterColumn,
showCharacterPortrait,
} = useMemo(
() => ({
showGroupColumn: settings[SETTINGS_KEYS.SHOW_GROUP_COLUMN] as boolean,
showDescriptionColumn: settings[SETTINGS_KEYS.SHOW_DESCRIPTION_COLUMN] as boolean,
showAddedColumn: settings[SETTINGS_KEYS.SHOW_ADDED_COLUMN] as boolean,
showUpdatedColumn: settings[SETTINGS_KEYS.SHOW_UPDATED_COLUMN] as boolean,
showCharacterColumn: settings[SETTINGS_KEYS.SHOW_CHARACTER_COLUMN] as boolean,
showCharacterPortrait: settings[SETTINGS_KEYS.SHOW_CHARACTER_PORTRAIT] as boolean,
@@ -309,15 +318,17 @@ export const SystemSignaturesContent = ({
style={{ maxWidth: 72, minWidth: 72, width: 72 }}
sortable
/>
<Column
field="group"
header="Group"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: 110, minWidth: 110, width: 110 }}
body={sig => sig.group ?? ''}
hidden={isCompact}
sortable
/>
{showGroupColumn && (
<Column
field="group"
header="Group"
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
style={{ maxWidth: 110, minWidth: 110, width: 110 }}
body={sig => sig.group ?? ''}
hidden={isCompact}
sortable
/>
)}
<Column
field="info"
header="Info"
@@ -336,15 +347,17 @@ export const SystemSignaturesContent = ({
sortable
/>
)}
<Column
field="inserted_at"
header="Added"
dataType="date"
body={renderAddedTimeLeft}
style={{ minWidth: 70, maxWidth: 80 }}
bodyClassName="ssc-header text-ellipsis overflow-hidden whitespace-nowrap"
sortable
/>
{showAddedColumn && (
<Column
field="inserted_at"
header="Added"
dataType="date"
body={renderAddedTimeLeft}
style={{ minWidth: 70, maxWidth: 80 }}
bodyClassName="ssc-header text-ellipsis overflow-hidden whitespace-nowrap"
sortable
/>
)}
{showUpdatedColumn && (
<Column
field="updated_at"

View File

@@ -1,3 +1,4 @@
import { SETTINGS_KEYS, SIGNATURES_DELETION_TIMING, SignatureSettingsType } from '@/hooks/Mapper/constants/signatures';
import {
GroupType,
SignatureGroup,
@@ -11,7 +12,6 @@ import {
SignatureKindFR,
SignatureKindRU,
} from '@/hooks/Mapper/types';
import { SETTINGS_KEYS, SIGNATURES_DELETION_TIMING, SignatureSettingsType } from '@/hooks/Mapper/constants/signatures';
export const TIME_ONE_MINUTE = 1000 * 60;
export const TIME_TEN_MINUTES = TIME_ONE_MINUTE * 10;
@@ -130,6 +130,8 @@ export const SIGNATURE_SETTINGS = {
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.COMBAT_SITE, name: 'Show Combat Sites' },
],
uiFlags: [
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.SHOW_GROUP_COLUMN, name: 'Show Group Column' },
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.SHOW_ADDED_COLUMN, name: 'Show Added Column' },
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.SHOW_UPDATED_COLUMN, name: 'Show Updated Column' },
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.SHOW_DESCRIPTION_COLUMN, name: 'Show Description Column' },
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.SHOW_CHARACTER_COLUMN, name: 'Show Character Column' },

View File

@@ -3,7 +3,7 @@ import { Dialog } from 'primereact/dialog';
import { useCallback, useRef, useState } from 'react';
import { TabPanel, TabView } from 'primereact/tabview';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OutCommand } from '@/hooks/Mapper/types';
import { OutCommand, UserPermission } from '@/hooks/Mapper/types';
import { CONNECTIONS_CHECKBOXES_PROPS, SIGNATURES_CHECKBOXES_PROPS, SYSTEMS_CHECKBOXES_PROPS } from './constants.ts';
import {
MapSettingsProvider,
@@ -12,7 +12,10 @@ import {
import { WidgetsSettings } from './components/WidgetsSettings';
import { CommonSettings } from './components/CommonSettings';
import { SettingsListItem } from './types.ts';
import { ImportExport } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/components/ImportExport.tsx';
import { ImportExport } from './components/ImportExport.tsx';
import { ServerSettings } from './components/ServerSettings.tsx';
import { AdminSettings } from './components/AdminSettings.tsx';
import { useMapCheckPermissions } from '@/hooks/Mapper/mapRootProvider/hooks/api';
export interface MapSettingsProps {
visible: boolean;
@@ -24,6 +27,7 @@ export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => {
const { outCommand } = useMapRootState();
const { renderSettingItem, setUserRemoteSettings } = useMapSettings();
const isAdmin = useMapCheckPermissions([UserPermission.ADMIN_MAP]);
const refVars = useRef({ outCommand, onHide, visible });
refVars.current = { outCommand, onHide, visible };
@@ -58,7 +62,7 @@ export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => {
header="Map user settings"
visible
draggable={false}
style={{ width: '550px' }}
style={{ width: '600px' }}
onShow={handleShow}
onHide={handleHide}
>
@@ -92,6 +96,16 @@ export const MapSettingsComp = ({ visible, onHide }: MapSettingsProps) => {
<TabPanel header="Import/Export" className="h-full" headerClassName={styles.verticalTabHeader}>
<ImportExport />
</TabPanel>
<TabPanel header="Server Settings" className="h-full" headerClassName="color-warn">
<ServerSettings />
</TabPanel>
{isAdmin && (
<TabPanel header="Admin Settings" className="h-full" headerClassName="color-warn">
<AdminSettings />
</TabPanel>
)}
</TabView>
</div>
</div>

View File

@@ -0,0 +1,128 @@
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Toast } from 'primereact/toast';
import { Button } from 'primereact/button';
import { callToastError, callToastSuccess, callToastWarn } from '@/hooks/Mapper/helpers';
import { OutCommand } from '@/hooks/Mapper/types';
import { ConfirmPopup } from 'primereact/confirmpopup';
import { useConfirmPopup } from '@/hooks/Mapper/hooks';
import { MapUserSettings, RemoteAdminSettingsResponse } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { parseMapUserSettings } from '@/hooks/Mapper/components/helpers';
import fastDeepEqual from 'fast-deep-equal';
import { useDetectSettingsChanged } from '@/hooks/Mapper/components/hooks';
export const AdminSettings = () => {
const {
storedSettings: { getSettingsForExport },
outCommand,
} = useMapRootState();
const settingsChanged = useDetectSettingsChanged();
const [currentRemoteSettings, setCurrentRemoteSettings] = useState<MapUserSettings | null>(null);
const { cfShow, cfHide, cfVisible, cfRef } = useConfirmPopup();
const toast = useRef<Toast | null>(null);
const hasSettingsForExport = useMemo(() => !!getSettingsForExport(), [getSettingsForExport]);
const refVars = useRef({ currentRemoteSettings, getSettingsForExport });
refVars.current = { currentRemoteSettings, getSettingsForExport };
useEffect(() => {
const load = async () => {
let res: RemoteAdminSettingsResponse | undefined;
try {
res = await outCommand({ type: OutCommand.getDefaultSettings, data: null });
} catch (error) {
// do nothing
}
if (!res || res.default_settings == null) {
return;
}
setCurrentRemoteSettings(parseMapUserSettings(res.default_settings));
};
load();
}, [outCommand]);
const isDirty = useMemo(() => {
const { currentRemoteSettings, getSettingsForExport } = refVars.current;
const localCurrent = parseMapUserSettings(getSettingsForExport());
return !fastDeepEqual(currentRemoteSettings, localCurrent);
// eslint-disable-next-line
}, [settingsChanged, currentRemoteSettings]);
const handleSync = useCallback(async () => {
const settings = getSettingsForExport();
if (!settings) {
callToastWarn(toast.current, 'No settings to save');
return;
}
let response: { success: boolean } | undefined;
try {
response = await outCommand({
type: OutCommand.saveDefaultSettings,
data: { settings },
});
} catch (err) {
callToastError(toast.current, 'Something went wrong while saving settings');
console.error('ERROR: ', err);
return;
}
if (!response || !response.success) {
callToastError(toast.current, 'Settings not saved - dont not why it');
return;
}
setCurrentRemoteSettings(parseMapUserSettings(settings));
callToastSuccess(toast.current, 'Settings saved successfully');
}, [getSettingsForExport, outCommand]);
return (
<div className="w-full h-full flex flex-col gap-5">
<div className="flex flex-col gap-1">
<div>
<Button
// @ts-ignore
ref={cfRef}
onClick={cfShow}
icon="pi pi-save"
size="small"
severity="danger"
label="Save as Map Default"
className="py-[4px]"
disabled={!hasSettingsForExport || !isDirty}
/>
</div>
{!isDirty && <span className="text-red-500/70 text-[12px]">*Local and remote are identical.</span>}
<span className="text-stone-500 text-[12px]">
*Will save your current settings as the default for all new users of this map. This action will overwrite any
existing default settings.
</span>
</div>
<Toast ref={toast} />
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="Your settings will overwrite default. Sure?."
icon="pi pi-exclamation-triangle"
accept={handleSync}
/>
</div>
);
};

View File

@@ -7,9 +7,14 @@ import {
import { useMapSettings } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/MapSettingsProvider.tsx';
import { SettingsListItem } from '@/hooks/Mapper/components/mapRootContent/components/MapSettings/types.ts';
import { useCallback } from 'react';
import { Button } from 'primereact/button';
import { TooltipPosition, WdTooltipWrapper } from '@/hooks/Mapper/components/ui-kit';
import { ConfirmPopup } from 'primereact/confirmpopup';
import { useConfirmPopup } from '@/hooks/Mapper/hooks';
export const CommonSettings = () => {
const { renderSettingItem } = useMapSettings();
const { cfShow, cfHide, cfVisible, cfRef } = useConfirmPopup();
const renderSettingsList = useCallback(
(list: SettingsListItem[]) => {
@@ -18,6 +23,8 @@ export const CommonSettings = () => {
[renderSettingItem],
);
const handleResetSettings = () => {};
return (
<div className="flex flex-col h-full gap-1">
<div>
@@ -29,6 +36,33 @@ export const CommonSettings = () => {
<div className="grid grid-cols-[1fr_auto]">{renderSettingItem(MINI_MAP_PLACEMENT)}</div>
<div className="grid grid-cols-[1fr_auto]">{renderSettingItem(PINGS_PLACEMENT)}</div>
<div className="grid grid-cols-[1fr_auto]">{renderSettingItem(THEME_SETTING)}</div>
<div className="border-b-2 border-dotted border-stone-700/50 h-px my-3" />
<div className="grid grid-cols-[1fr_auto]">
<div />
<WdTooltipWrapper content="This dangerous action. And can not be undone" position={TooltipPosition.top}>
<Button
// @ts-ignore
ref={cfRef}
className="py-[4px]"
onClick={cfShow}
outlined
size="small"
severity="danger"
label="Reset Settings"
/>
</WdTooltipWrapper>
</div>
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="All settings for this map will be reset to default."
icon="pi pi-exclamation-triangle"
accept={handleResetSettings}
/>
</div>
);
};

View File

@@ -0,0 +1,90 @@
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useCallback, useRef, useState } from 'react';
import { Toast } from 'primereact/toast';
import { Button } from 'primereact/button';
import { OutCommand } from '@/hooks/Mapper/types';
import { Divider } from 'primereact/divider';
import { callToastError, callToastSuccess, callToastWarn } from '@/hooks/Mapper/helpers';
type SaveDefaultSettingsReturn = { success: boolean; error: string };
export const DefaultSettings = () => {
const {
outCommand,
storedSettings: { getSettingsForExport },
data: { userPermissions },
} = useMapRootState();
const [loading, setLoading] = useState(false);
const toast = useRef<Toast | null>(null);
const refVars = useRef({ getSettingsForExport, outCommand });
refVars.current = { getSettingsForExport, outCommand };
const handleSaveAsDefault = useCallback(async () => {
const settings = refVars.current.getSettingsForExport();
if (!settings) {
callToastWarn(toast.current, 'No settings to save');
return;
}
setLoading(true);
let response: SaveDefaultSettingsReturn;
try {
response = await refVars.current.outCommand({
type: OutCommand.saveDefaultSettings,
data: { settings },
});
} catch (error) {
console.error('Save default settings error:', error);
callToastError(toast.current, 'Failed to save default settings');
setLoading(false);
return;
}
if (response.success) {
callToastSuccess(toast.current, 'Default settings saved successfully');
setLoading(false);
return;
}
callToastError(toast.current, response.error || 'Failed to save default settings');
setLoading(false);
}, []);
if (!userPermissions?.admin_map) {
return null;
}
return (
<>
<Divider />
<div className="w-full h-full flex flex-col gap-5">
<h3 className="text-lg font-semibold">Default Settings (Admin Only)</h3>
<div className="flex flex-col gap-1">
<div>
<Button
onClick={handleSaveAsDefault}
icon="pi pi-save"
size="small"
severity="danger"
label="Save as Map Default"
className="py-[4px]"
loading={loading}
disabled={loading}
/>
</div>
<span className="text-stone-500 text-[12px]">
*Will save your current settings as the default for all new users of this map. This action will overwrite
any existing default settings.
</span>
</div>
<Toast ref={toast} />
</div>
</>
);
};

View File

@@ -0,0 +1,97 @@
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Toast } from 'primereact/toast';
import { parseMapUserSettings } from '@/hooks/Mapper/components/helpers';
import { Button } from 'primereact/button';
import { OutCommand } from '@/hooks/Mapper/types';
import { createDefaultWidgetSettings } from '@/hooks/Mapper/mapRootProvider/helpers/createDefaultWidgetSettings.ts';
import { callToastSuccess } from '@/hooks/Mapper/helpers';
import { ConfirmPopup } from 'primereact/confirmpopup';
import { useConfirmPopup } from '@/hooks/Mapper/hooks';
import { RemoteAdminSettingsResponse } from '@/hooks/Mapper/mapRootProvider/types.ts';
export const ServerSettings = () => {
const {
storedSettings: { applySettings },
outCommand,
} = useMapRootState();
const [hasSettings, setHasSettings] = useState(false);
const { cfShow, cfHide, cfVisible, cfRef } = useConfirmPopup();
const toast = useRef<Toast | null>(null);
const handleSync = useCallback(async () => {
let res: RemoteAdminSettingsResponse | undefined;
try {
res = await outCommand({ type: OutCommand.getDefaultSettings, data: null });
} catch (error) {
// do nothing
}
if (res?.default_settings == null) {
applySettings(createDefaultWidgetSettings());
return;
}
try {
applySettings(parseMapUserSettings(res.default_settings));
callToastSuccess(toast.current, 'Settings synchronized successfully');
} catch (error) {
applySettings(createDefaultWidgetSettings());
}
}, [applySettings, outCommand]);
useEffect(() => {
const load = async () => {
let res: RemoteAdminSettingsResponse | undefined;
try {
res = await outCommand({ type: OutCommand.getDefaultSettings, data: null });
} catch (error) {
// do nothing
}
if (res?.default_settings == null) {
return;
}
setHasSettings(true);
};
load();
}, [outCommand]);
return (
<div className="w-full h-full flex flex-col gap-5">
<div className="flex flex-col gap-1">
<div>
<Button
// @ts-ignore
ref={cfRef}
onClick={cfShow}
icon="pi pi-file-import"
size="small"
severity="warning"
label="Sync with Default Settings"
className="py-[4px]"
disabled={!hasSettings}
/>
</div>
{!hasSettings && (
<span className="text-red-500/70 text-[12px]">*Default settings was not set by map administrator.</span>
)}
<span className="text-stone-500 text-[12px]">*Will apply admin settings which set as Default for map.</span>
</div>
<Toast ref={toast} />
<ConfirmPopup
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="You lost your current settings. Sure?."
icon="pi pi-exclamation-triangle"
accept={handleSync}
/>
</div>
);
};

View File

@@ -28,6 +28,9 @@ export const WidgetsSettings = ({}: WidgetsSettingsProps) => {
/>
))}
</div>
<div className="border-b-2 border-dotted border-stone-700/50 h-px my-3" />
<div className="grid grid-cols-[1fr_auto]">
<div />
<Button className="py-[4px]" onClick={resetWidgets} outlined size="small" label="Reset Widgets"></Button>

View File

@@ -1,8 +1,6 @@
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { ConfirmPopup } from 'primereact/confirmpopup';
import { useCallback, useRef, useState } from 'react';
import { MapUserSettings } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { DEFAULT_SIGNATURE_SETTINGS } from '@/hooks/Mapper/constants/signatures.ts';
import { useConfirmPopup } from '@/hooks/Mapper/hooks';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import {
DEFAULT_KILLS_WIDGET_SETTINGS,
DEFAULT_ON_THE_MAP_SETTINGS,
@@ -11,10 +9,13 @@ import {
getDefaultWidgetProps,
STORED_INTERFACE_DEFAULT_VALUES,
} from '@/hooks/Mapper/mapRootProvider/constants.ts';
import { DEFAULT_SIGNATURE_SETTINGS } from '@/hooks/Mapper/constants/signatures.ts';
import { Toast } from 'primereact/toast';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { MapUserSettings } from '@/hooks/Mapper/mapRootProvider/types.ts';
import { saveTextFile } from '@/hooks/Mapper/utils';
import { Button } from 'primereact/button';
import { ConfirmPopup } from 'primereact/confirmpopup';
import { Dialog } from 'primereact/dialog';
import { Toast } from 'primereact/toast';
import { useCallback, useRef } from 'react';
const createSettings = function <T>(lsSettings: string | null, defaultValues: T) {
return {
@@ -24,10 +25,7 @@ const createSettings = function <T>(lsSettings: string | null, defaultValues: T)
};
export const OldSettingsDialog = () => {
const cpRemoveBtnRef = useRef<HTMLElement>();
const [cpRemoveVisible, setCpRemoveVisible] = useState(false);
const handleShowCP = useCallback(() => setCpRemoveVisible(true), []);
const handleHideCP = useCallback(() => setCpRemoveVisible(false), []);
const { cfShow, cfHide, cfVisible, cfRef } = useConfirmPopup();
const toast = useRef<Toast | null>(null);
const {
@@ -43,7 +41,7 @@ export const OldSettingsDialog = () => {
const widgetKills = localStorage.getItem('kills:widget:settings');
const onTheMapOld = localStorage.getItem('window:onTheMap:settings');
const widgetsOld = localStorage.getItem('windows:settings:v2');
const signatures = localStorage.getItem('wanderer_system_signature_settings_v6_5');
const signatures = localStorage.getItem('wanderer_system_signature_settings_v6_6');
const out: MapUserSettings = {
killsWidget: createSettings(widgetKills, DEFAULT_KILLS_WIDGET_SETTINGS),
@@ -120,7 +118,7 @@ export const OldSettingsDialog = () => {
localStorage.removeItem('kills:widget:settings');
localStorage.removeItem('window:onTheMap:settings');
localStorage.removeItem('windows:settings:v2');
localStorage.removeItem('wanderer_system_signature_settings_v6_5');
localStorage.removeItem('wanderer_system_signature_settings_v6_6');
checkOldSettings();
}, [checkOldSettings]);
@@ -143,8 +141,8 @@ export const OldSettingsDialog = () => {
<div className="flex items-center justify-end">
<Button
// @ts-ignore
ref={cpRemoveBtnRef}
onClick={handleShowCP}
ref={cfRef}
onClick={cfShow}
icon="pi pi-exclamation-triangle"
size="small"
severity="warning"
@@ -192,9 +190,9 @@ export const OldSettingsDialog = () => {
</Dialog>
<ConfirmPopup
target={cpRemoveBtnRef.current}
visible={cpRemoveVisible}
onHide={handleHideCP}
target={cfRef.current}
visible={cfVisible}
onHide={cfHide}
message="After click dialog will disappear. Ready?"
icon="pi pi-exclamation-triangle"
accept={handleProceed}

View File

@@ -13,6 +13,8 @@ import { InputText } from 'primereact/inputtext';
import { IconField } from 'primereact/iconfield';
const itemTemplate = (item: CharacterTypeRaw & WithIsOwnCharacter, options: VirtualScrollerTemplateOptions) => {
const showAllyLogoPlaceholder = options.props.items?.some(x => x.alliance_id != null);
return (
<div
className={clsx(classes.CharacterRow, 'w-full box-border px-2 py-1', {
@@ -22,7 +24,15 @@ const itemTemplate = (item: CharacterTypeRaw & WithIsOwnCharacter, options: Virt
})}
style={{ height: options.props.itemSize + 'px' }}
>
<CharacterCard showCorporationLogo showAllyLogo showSystem showTicker showShip {...item} />
<CharacterCard
showCorporationLogo
showAllyLogo
showAllyLogoPlaceholder={showAllyLogoPlaceholder}
showSystem
showTicker
showShip
{...item}
/>
</div>
);
};

View File

@@ -94,6 +94,10 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
out = { ...out, type: values.type };
}
if (values.temporary_name != null) {
out = { ...out, temporary_name: values.temporary_name };
}
if (signatureData.group !== SignatureGroup.Wormhole) {
out = { ...out, name: '' };
}

View File

@@ -4,6 +4,7 @@ import { SignatureWormholeTypeSelect } from '@/hooks/Mapper/components/mapRootCo
import { SignatureK162TypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect';
import { SignatureEOLCheckbox } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureEOLCheckbox';
import { SignatureTempName } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureTempName.tsx';
export const SignatureGroupContentWormholes = () => {
const { watch } = useFormContext<SystemSignature>();
@@ -32,6 +33,11 @@ export const SignatureGroupContentWormholes = () => {
<span>EOL:</span>
<SignatureEOLCheckbox name="isEOL" />
</label>
<label className="grid grid-cols-[100px_250px_1fr] gap-2 items-center text-[14px]">
<span>Temp. Name:</span>
<SignatureTempName />
</label>
</>
);
};

View File

@@ -0,0 +1,15 @@
import { Controller, useFormContext } from 'react-hook-form';
import { InputText } from 'primereact/inputtext';
import { SystemSignature } from '@/hooks/Mapper/types';
export const SignatureTempName = () => {
const { control } = useFormContext<SystemSignature>();
return (
<Controller
name="temporary_name"
control={control}
render={({ field }) => <InputText placeholder="Temporary Name" value={field.value} onChange={field.onChange} />}
/>
);
};

View File

@@ -1,6 +1,6 @@
import { Map, MAP_ROOT_ID } from '@/hooks/Mapper/components/map/Map.tsx';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { OutCommand, OutCommandHandler, SolarSystemConnection } from '@/hooks/Mapper/types';
import { CommandSelectSystems, OutCommand, OutCommandHandler, SolarSystemConnection } from '@/hooks/Mapper/types';
import { MapRootData, useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import { OnMapAddSystemCallback, OnMapSelectionChange } from '@/hooks/Mapper/components/map/map.types.ts';
import isEqual from 'lodash.isequal';
@@ -88,6 +88,18 @@ export const MapWrapper = () => {
useMapEventListener(event => {
runCommand(event);
if (event.name === Commands.init) {
const { selectedSystems } = ref.current;
if (selectedSystems.length === 0) {
return;
}
runCommand({
name: Commands.selectSystems,
data: { systems: selectedSystems } as CommandSelectSystems,
});
}
});
const onSelectionChange: OnMapSelectionChange = useCallback(
@@ -181,24 +193,20 @@ export const MapWrapper = () => {
ref.current.systemContextProps.systemId && setOpenSettings(ref.current.systemContextProps.systemId);
}, []);
const handleTogglePing = useCallback(async (type: PingType, solar_system_id: string, hasPing: boolean) => {
if (hasPing) {
// Find the ping for this solar system to get its ID
const ping = pings.find(p => p.solar_system_id === solar_system_id);
if (!ping) {
console.error('Cannot find ping for solar system:', solar_system_id);
const handleTogglePing = useCallback(
async (type: PingType, solar_system_id: string, ping_id: string | undefined, hasPing: boolean) => {
if (hasPing) {
await outCommand({
type: OutCommand.cancelPing,
data: { type, id: ping_id },
});
return;
}
await outCommand({
type: OutCommand.cancelPing,
data: { type, id: ping.id },
});
return;
}
setOpenPing({ type, solar_system_id });
}, [pings, outCommand]);
setOpenPing({ type, solar_system_id });
},
[],
);
const handleCustomLabelDialog = useCallback(() => {
const { systemContextProps } = ref.current;

View File

@@ -24,6 +24,7 @@ export type CharacterCardProps = {
useSystemsCache?: boolean;
showCorporationLogo?: boolean;
showAllyLogo?: boolean;
showAllyLogoPlaceholder?: boolean;
simpleMode?: boolean;
} & WithIsOwnCharacter &
WithClassName;
@@ -47,6 +48,7 @@ export const CharacterCard = ({
showShipName,
showCorporationLogo,
showAllyLogo,
showAllyLogoPlaceholder,
showTicker,
useSystemsCache,
className,
@@ -217,6 +219,18 @@ export const CharacterCard = ({
/>
</WdTooltipWrapper>
)}
{showAllyLogo && showAllyLogoPlaceholder && !char.alliance_id && (
<WdTooltipWrapper position={TooltipPosition.top} content="No alliance">
<span
className={clsx(
'min-w-[33px] min-h-[33px] w-[33px] h-[33px]',
'flex transition-[border-color,opacity] duration-250 rounded-none',
'wd-bg-default',
)}
/>
</WdTooltipWrapper>
)}
</div>
<div className="flex flex-col flex-grow overflow-hidden w-[50px]">

View File

@@ -12,14 +12,16 @@ export enum SETTINGS_KEYS {
SORT_FIELD = 'sortField',
SORT_ORDER = 'sortOrder',
SHOW_DESCRIPTION_COLUMN = 'show_description_column',
SHOW_UPDATED_COLUMN = 'show_updated_column',
SHOW_ADDED_COLUMN = 'show_added_column',
SHOW_CHARACTER_COLUMN = 'show_character_column',
SHOW_CHARACTER_PORTRAIT = 'show_character_portrait',
SHOW_DESCRIPTION_COLUMN = 'show_description_column',
SHOW_GROUP_COLUMN = 'show_group_column',
SHOW_UPDATED_COLUMN = 'show_updated_column',
LAZY_DELETE_SIGNATURES = 'lazy_delete_signatures',
KEEP_LAZY_DELETE = 'keep_lazy_delete_enabled',
DELETION_TIMING = 'deletion_timing',
COLOR_BY_TYPE = 'color_by_type',
SHOW_CHARACTER_PORTRAIT = 'show_character_portrait',
// From SignatureKind
COSMIC_ANOMALY = SignatureKind.CosmicAnomaly,
@@ -45,6 +47,8 @@ export const DEFAULT_SIGNATURE_SETTINGS: SignatureSettingsType = {
[SETTINGS_KEYS.SORT_FIELD]: 'inserted_at',
[SETTINGS_KEYS.SORT_ORDER]: -1,
[SETTINGS_KEYS.SHOW_GROUP_COLUMN]: true,
[SETTINGS_KEYS.SHOW_ADDED_COLUMN]: true,
[SETTINGS_KEYS.SHOW_UPDATED_COLUMN]: true,
[SETTINGS_KEYS.SHOW_DESCRIPTION_COLUMN]: true,
[SETTINGS_KEYS.SHOW_CHARACTER_COLUMN]: true,

View File

@@ -2,3 +2,4 @@ export * from './sortWHClasses';
export * from './parseSignatures';
export * from './getSystemById';
export * from './getEveImageUrl';
export * from './toastHelpers';

View File

@@ -0,0 +1,28 @@
import { Toast } from 'primereact/toast';
export const callToastWarn = (toast: Toast | null, msg: string, life = 3000) => {
toast?.show({
severity: 'warn',
summary: 'Warning',
detail: msg,
life,
});
};
export const callToastError = (toast: Toast | null, msg: string, life = 3000) => {
toast?.show({
severity: 'error',
summary: 'Error',
detail: msg,
life,
});
};
export const callToastSuccess = (toast: Toast | null, msg: string, life = 3000) => {
toast?.show({
severity: 'success',
summary: 'Success',
detail: msg,
life,
});
};

View File

@@ -3,3 +3,4 @@ export * from './useHotkey';
export * from './usePageVisibility';
export * from './useSkipContextMenu';
export * from './useThrottle';
export * from './useConfirmPopup';

View File

@@ -0,0 +1,10 @@
import { useCallback, useRef, useState } from 'react';
export const useConfirmPopup = () => {
const cfRef = useRef<HTMLElement>();
const [cfVisible, setCfVisible] = useState(false);
const cfShow = useCallback(() => setCfVisible(true), []);
const cfHide = useCallback(() => setCfVisible(false), []);
return { cfRef, cfVisible, cfShow, cfHide };
};

View File

@@ -131,6 +131,7 @@ export interface MapRootContextProps {
hasOldSettings: boolean;
getSettingsForExport(): string | undefined;
applySettings(settings: MapUserSettings): boolean;
resetSettings(settings: MapUserSettings): void;
checkOldSettings(): void;
};
}
@@ -175,6 +176,7 @@ const MapRootContext = createContext<MapRootContextProps>({
hasOldSettings: false,
getSettingsForExport: () => '',
applySettings: () => false,
resetSettings: () => null,
checkOldSettings: () => null,
},
});
@@ -196,7 +198,7 @@ const MapRootHandlers = forwardRef(({ children }: WithChildren, fwdRef: Forwarde
export const MapRootProvider = ({ children, fwdRef, outCommand }: MapRootProviderProps) => {
const { update, ref } = useContextStore<MapRootData>({ ...INITIAL_DATA });
const storedSettings = useMapUserSettings(ref);
const storedSettings = useMapUserSettings(ref, outCommand);
const { windowsSettings, toggleWidgetVisibility, updateWidgetSettings, resetWidgets } =
useStoreWidgets(storedSettings);

View File

@@ -0,0 +1,30 @@
import { MapUserSettings } from '@/hooks/Mapper/mapRootProvider/types.ts';
import {
DEFAULT_KILLS_WIDGET_SETTINGS,
DEFAULT_ON_THE_MAP_SETTINGS,
DEFAULT_ROUTES_SETTINGS,
DEFAULT_WIDGET_LOCAL_SETTINGS,
getDefaultWidgetProps,
STORED_INTERFACE_DEFAULT_VALUES,
} from '@/hooks/Mapper/mapRootProvider/constants.ts';
import { DEFAULT_SIGNATURE_SETTINGS } from '@/hooks/Mapper/constants/signatures.ts';
// TODO - we need provide and compare version
const createWidgetSettingsWithVersion = <T>(settings: T) => {
return {
version: 0,
settings,
};
};
export const createDefaultWidgetSettings = (): MapUserSettings => {
return {
killsWidget: createWidgetSettingsWithVersion(DEFAULT_KILLS_WIDGET_SETTINGS),
localWidget: createWidgetSettingsWithVersion(DEFAULT_WIDGET_LOCAL_SETTINGS),
widgets: createWidgetSettingsWithVersion(getDefaultWidgetProps()),
routes: createWidgetSettingsWithVersion(DEFAULT_ROUTES_SETTINGS),
onTheMap: createWidgetSettingsWithVersion(DEFAULT_ON_THE_MAP_SETTINGS),
signaturesWidget: createWidgetSettingsWithVersion(DEFAULT_SIGNATURE_SETTINGS),
interface: createWidgetSettingsWithVersion(STORED_INTERFACE_DEFAULT_VALUES),
};
};

View File

@@ -0,0 +1,66 @@
import { OutCommand, OutCommandHandler } from '@/hooks/Mapper/types';
import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from 'react';
import {
MapUserSettings,
MapUserSettingsStructure,
RemoteAdminSettingsResponse,
} from '@/hooks/Mapper/mapRootProvider/types.ts';
import { createDefaultWidgetSettings } from '@/hooks/Mapper/mapRootProvider/helpers/createDefaultWidgetSettings.ts';
import { parseMapUserSettings } from '@/hooks/Mapper/components/helpers';
interface UseActualizeRemoteMapSettingsProps {
outCommand: OutCommandHandler;
mapUserSettings: MapUserSettingsStructure;
applySettings: (val: MapUserSettings) => void;
setMapUserSettings: Dispatch<SetStateAction<MapUserSettingsStructure>>;
map_slug: string | null;
}
export const useActualizeRemoteMapSettings = ({
outCommand,
mapUserSettings,
setMapUserSettings,
applySettings,
map_slug,
}: UseActualizeRemoteMapSettingsProps) => {
const refVars = useRef({ applySettings, mapUserSettings, setMapUserSettings, map_slug });
refVars.current = { applySettings, mapUserSettings, setMapUserSettings, map_slug };
const actualizeRemoteMapSettings = useCallback(async () => {
const { applySettings } = refVars.current;
let res: RemoteAdminSettingsResponse | undefined;
try {
res = await outCommand({ type: OutCommand.getDefaultSettings, data: null });
} catch (error) {
// do nothing
}
if (res?.default_settings == null) {
applySettings(createDefaultWidgetSettings());
return;
}
try {
applySettings(parseMapUserSettings(res.default_settings));
} catch (error) {
applySettings(createDefaultWidgetSettings());
}
}, [outCommand]);
useEffect(() => {
const { mapUserSettings } = refVars.current;
// INFO: Do nothing if slug is not set
if (map_slug == null) {
return;
}
// INFO: Do nothing if user have already data
if (map_slug in mapUserSettings) {
return;
}
actualizeRemoteMapSettings();
}, [actualizeRemoteMapSettings, map_slug]);
};

View File

@@ -1,44 +1,16 @@
import useLocalStorageState from 'use-local-storage-state';
import { MapUserSettings, MapUserSettingsStructure } from '@/hooks/Mapper/mapRootProvider/types.ts';
import {
DEFAULT_KILLS_WIDGET_SETTINGS,
DEFAULT_ON_THE_MAP_SETTINGS,
DEFAULT_ROUTES_SETTINGS,
DEFAULT_WIDGET_LOCAL_SETTINGS,
getDefaultWidgetProps,
STORED_INTERFACE_DEFAULT_VALUES,
} from '@/hooks/Mapper/mapRootProvider/constants.ts';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DEFAULT_SIGNATURE_SETTINGS } from '@/hooks/Mapper/constants/signatures';
import { MapRootData } from '@/hooks/Mapper/mapRootProvider';
import { useSettingsValueAndSetter } from '@/hooks/Mapper/mapRootProvider/hooks/useSettingsValueAndSetter.ts';
import fastDeepEqual from 'fast-deep-equal';
// import { actualizeSettings } from '@/hooks/Mapper/mapRootProvider/helpers';
// TODO - we need provide and compare version
const createWidgetSettingsWithVersion = <T>(settings: T) => {
return {
version: 0,
settings,
};
};
const createDefaultWidgetSettings = (): MapUserSettings => {
return {
killsWidget: createWidgetSettingsWithVersion(DEFAULT_KILLS_WIDGET_SETTINGS),
localWidget: createWidgetSettingsWithVersion(DEFAULT_WIDGET_LOCAL_SETTINGS),
widgets: createWidgetSettingsWithVersion(getDefaultWidgetProps()),
routes: createWidgetSettingsWithVersion(DEFAULT_ROUTES_SETTINGS),
onTheMap: createWidgetSettingsWithVersion(DEFAULT_ON_THE_MAP_SETTINGS),
signaturesWidget: createWidgetSettingsWithVersion(DEFAULT_SIGNATURE_SETTINGS),
interface: createWidgetSettingsWithVersion(STORED_INTERFACE_DEFAULT_VALUES),
};
};
import { OutCommandHandler } from '@/hooks/Mapper/types';
import { useActualizeRemoteMapSettings } from '@/hooks/Mapper/mapRootProvider/hooks/useActualizeRemoteMapSettings.ts';
import { createDefaultWidgetSettings } from '@/hooks/Mapper/mapRootProvider/helpers/createDefaultWidgetSettings.ts';
const EMPTY_OBJ = {};
export const useMapUserSettings = ({ map_slug }: MapRootData) => {
export const useMapUserSettings = ({ map_slug }: MapRootData, outCommand: OutCommandHandler) => {
const [isReady, setIsReady] = useState(false);
const [hasOldSettings, setHasOldSettings] = useState(false);
@@ -49,19 +21,25 @@ export const useMapUserSettings = ({ map_slug }: MapRootData) => {
const ref = useRef({ mapUserSettings, setMapUserSettings, map_slug });
ref.current = { mapUserSettings, setMapUserSettings, map_slug };
useEffect(() => {
const { mapUserSettings, setMapUserSettings } = ref.current;
if (map_slug === null) {
return;
const applySettings = useCallback((settings: MapUserSettings) => {
const { map_slug, mapUserSettings, setMapUserSettings } = ref.current;
if (map_slug == null) {
return false;
}
if (!(map_slug in mapUserSettings)) {
setMapUserSettings({
...mapUserSettings,
[map_slug]: createDefaultWidgetSettings(),
});
if (fastDeepEqual(settings, mapUserSettings[map_slug])) {
return false;
}
}, [map_slug]);
setMapUserSettings(old => ({
...old,
[map_slug]: settings,
}));
return true;
}, []);
useActualizeRemoteMapSettings({ outCommand, applySettings, mapUserSettings, setMapUserSettings, map_slug });
const [interfaceSettings, setInterfaceSettings] = useSettingsValueAndSetter(
mapUserSettings,
@@ -178,23 +156,9 @@ export const useMapUserSettings = ({ map_slug }: MapRootData) => {
return JSON.stringify(ref.current.mapUserSettings[map_slug]);
}, []);
const applySettings = useCallback((settings: MapUserSettings) => {
const { map_slug, mapUserSettings, setMapUserSettings } = ref.current;
if (map_slug == null) {
return false;
}
if (fastDeepEqual(settings, mapUserSettings[map_slug])) {
return false;
}
setMapUserSettings(old => ({
...old,
[map_slug]: settings,
}));
return true;
}, []);
const resetSettings = useCallback(() => {
applySettings(createDefaultWidgetSettings());
}, [applySettings]);
return {
isReady,
@@ -217,6 +181,7 @@ export const useMapUserSettings = ({ map_slug }: MapRootData) => {
getSettingsForExport,
applySettings,
resetSettings,
checkOldSettings,
};
};

View File

@@ -85,3 +85,7 @@ export type MapUserSettings = {
export type MapUserSettingsStructure = {
[mapId: string]: MapUserSettings;
};
export type WdResponse<T> = T;
export type RemoteAdminSettingsResponse = { default_settings?: string };

View File

@@ -27,6 +27,7 @@ export enum Commands {
userRoutes = 'user_routes',
centerSystem = 'center_system',
selectSystem = 'select_system',
selectSystems = 'select_systems',
linkSignatureToSystem = 'link_signature_to_system',
signaturesUpdated = 'signatures_updated',
systemCommentAdded = 'system_comment_added',
@@ -60,6 +61,7 @@ export type Command =
| Commands.routes
| Commands.userRoutes
| Commands.selectSystem
| Commands.selectSystems
| Commands.centerSystem
| Commands.linkSignatureToSystem
| Commands.signaturesUpdated
@@ -118,6 +120,10 @@ export type CommandUserRoutes = RoutesList;
export type CommandKillsUpdated = Kill[];
export type CommandDetailedKillsUpdated = Record<string, DetailedKill[]>;
export type CommandSelectSystem = string | undefined;
export type CommandSelectSystems = {
systems: string[];
delay?: number;
};
export type CommandCenterSystem = string | undefined;
export type CommandLinkSignatureToSystem = {
solar_system_source: number;
@@ -187,6 +193,7 @@ export interface CommandData {
[Commands.killsUpdated]: CommandKillsUpdated;
[Commands.detailedKillsUpdated]: CommandDetailedKillsUpdated;
[Commands.selectSystem]: CommandSelectSystem;
[Commands.selectSystems]: CommandSelectSystems;
[Commands.centerSystem]: CommandCenterSystem;
[Commands.linkSignatureToSystem]: CommandLinkSignatureToSystem;
[Commands.signaturesUpdated]: CommandLinkSignaturesUpdated;
@@ -269,6 +276,8 @@ export enum OutCommand {
showTracking = 'show_tracking',
getUserSettings = 'get_user_settings',
updateUserSettings = 'update_user_settings',
saveDefaultSettings = 'save_default_settings',
getDefaultSettings = 'get_default_settings',
unlinkSignature = 'unlink_signature',
searchSystems = 'search_systems',
undoDeleteSignatures = 'undo_delete_signatures',

View File

@@ -48,6 +48,7 @@ export type SystemSignature = {
inserted_at?: string;
updated_at?: string;
deleted?: boolean;
temporary_name?: string;
};
export interface ExtendedSystemSignature extends SystemSignature {

View File

@@ -79,7 +79,7 @@
"sass-loader": "^14.2.1",
"ts-jest": "^29.1.2",
"typescript": "^5.2.2",
"vite": "^5.0.5",
"vite": "^6.3.5",
"vite-plugin-cdn-import": "^1.0.1"
},
"peerDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because it is too large Load Diff

82
clean_changelog.py Normal file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Script to clean up CHANGELOG.md by removing empty version entries.
An empty version entry has only a version header followed by empty lines,
without any actual content (### Bug Fixes: or ### Features: sections).
"""
import re
def clean_changelog():
with open('./CHANGELOG.md', 'r') as f:
content = f.read()
# Split content into sections based on version headers
version_pattern = r'^## \[v\d+\.\d+\.\d+\].*?\([^)]+\)$'
# Find all version headers with their positions
matches = list(re.finditer(version_pattern, content, re.MULTILINE))
# Build new content by keeping only non-empty versions
new_content = ""
# Keep the header (everything before first version)
if matches:
new_content += content[:matches[0].start()]
else:
# No versions found, keep original
return content
for i, match in enumerate(matches):
version_start = match.start()
# Find the end of this version section (start of next version or end of file)
if i + 1 < len(matches):
version_end = matches[i + 1].start()
else:
version_end = len(content)
version_section = content[version_start:version_end]
# Check if this version has actual content
# Look for ### Bug Fixes: or ### Features: followed by actual content
has_content = False
# Split the section into lines
lines = version_section.split('\n')
# Look for content sections
in_content_section = False
for line in lines:
line_stripped = line.strip()
# Check if we're entering a content section
if line_stripped.startswith('### Bug Fixes:') or line_stripped.startswith('### Features:'):
in_content_section = True
continue
# If we're in a content section and find non-empty content
if in_content_section:
if line_stripped and not line_stripped.startswith('###') and not line_stripped.startswith('##'):
# This is actual content (not just another header)
if line_stripped.startswith('*') or len(line_stripped) > 0:
has_content = True
break
elif line_stripped.startswith('##'):
# We've reached the next version, stop looking
break
# Only keep versions with actual content
if has_content:
new_content += version_section
return new_content
if __name__ == "__main__":
cleaned_content = clean_changelog()
# Write the cleaned content back to the file
with open('./CHANGELOG.md', 'w') as f:
f.write(cleaned_content)
print("CHANGELOG.md has been cleaned up successfully!")

View File

@@ -102,6 +102,23 @@ config :error_tracker,
repo: WandererApp.Repo,
otp_app: :wanderer_app
# Security Audit Configuration
config :wanderer_app, WandererApp.SecurityAudit,
enabled: true,
# Set to true in production for better performance
async: false,
batch_size: 100,
flush_interval: 5000,
log_level: :info,
threat_detection: %{
enabled: true,
max_failed_attempts: 5,
max_permission_denials: 10,
window_seconds: 300,
bulk_operation_threshold: 10000
},
retention_days: 90
config :git_ops,
mix_project: Mix.Project.get!(),
changelog_file: "CHANGELOG.md",

View File

@@ -11,11 +11,13 @@ config :wanderer_app, WandererAppWeb.Endpoint,
config :wanderer_app, WandererApp.Repo,
ssl: false,
stacktrace: true,
show_sensitive_data_on_connection_error: true,
show_sensitive_data_on_connection_error: false,
pool_size: 15,
migration_timestamps: [type: :utc_datetime_usec],
migration_lock: nil,
queue_target: 5000
queue_target: 5000,
queue_interval: 1000,
checkout_timeout: 15000
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: WandererApp.Finch
@@ -27,5 +29,8 @@ config :swoosh, local: false
config :logger,
level: :info
# Enable async security audit processing in production
config :wanderer_app, WandererApp.SecurityAudit, async: true
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

View File

@@ -28,6 +28,7 @@ defmodule WandererApp.Api do
resource WandererApp.Api.MapSubscription
resource WandererApp.Api.MapTransaction
resource WandererApp.Api.MapUserSettings
resource WandererApp.Api.MapDefaultSettings
resource WandererApp.Api.User
resource WandererApp.Api.ShipTypeInfo
resource WandererApp.Api.UserActivity

View File

@@ -124,7 +124,7 @@ defmodule WandererApp.Api.Character do
update :update_corporation do
require_atomic? false
accept([:corporation_id, :corporation_name, :corporation_ticker, :alliance_id])
accept([:corporation_id, :corporation_name, :corporation_ticker])
end
update :update_alliance do

View File

@@ -0,0 +1,145 @@
defmodule WandererApp.Api.MapDefaultSettings do
@moduledoc """
Resource for storing default map settings that admins can configure.
These settings will be applied to new users when they first access the map.
"""
use Ash.Resource,
domain: WandererApp.Api,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource]
postgres do
repo(WandererApp.Repo)
table("map_default_settings")
end
json_api do
type "map_default_settings"
includes([
:map,
:created_by,
:updated_by
])
routes do
base("/map_default_settings")
get(:read)
index(:read)
post(:create)
patch(:update)
delete(:destroy)
end
end
code_interface do
define(:create, action: :create)
define(:update, action: :update)
define(:destroy, action: :destroy)
define(:get_by_map_id, action: :get_by_map_id)
end
actions do
default_accept [
:map_id,
:settings
]
defaults [:read, :destroy]
create :create do
primary?(true)
accept [:map_id, :settings]
change relate_actor(:created_by)
change relate_actor(:updated_by)
change fn changeset, _context ->
changeset
|> validate_json_settings()
end
end
update :update do
primary?(true)
accept [:settings]
# Required for managing relationships
require_atomic? false
change relate_actor(:updated_by)
change fn changeset, _context ->
changeset
|> validate_json_settings()
end
end
read :get_by_map_id do
argument :map_id, :uuid, allow_nil?: false
filter expr(map_id == ^arg(:map_id))
prepare fn query, _context ->
Ash.Query.limit(query, 1)
end
end
end
attributes do
uuid_primary_key :id
attribute :settings, :string do
allow_nil? false
constraints min_length: 2
description "JSON string containing the default map settings"
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end
relationships do
belongs_to :map, WandererApp.Api.Map do
primary_key? false
allow_nil? false
public? true
end
belongs_to :created_by, WandererApp.Api.Character do
allow_nil? true
public? true
end
belongs_to :updated_by, WandererApp.Api.Character do
allow_nil? true
public? true
end
end
identities do
identity :unique_map_settings, [:map_id]
end
defp validate_json_settings(changeset) do
case Ash.Changeset.get_attribute(changeset, :settings) do
nil ->
changeset
settings ->
case Jason.decode(settings) do
{:ok, _} ->
changeset
{:error, _} ->
Ash.Changeset.add_error(
changeset,
field: :settings,
message: "must be valid JSON"
)
end
end
end
end

View File

@@ -31,6 +31,8 @@ defmodule WandererApp.Api.MapSubscription do
end
code_interface do
define(:create, action: :create)
define(:by_id,
get_by: [:id],
action: :read
@@ -39,6 +41,15 @@ defmodule WandererApp.Api.MapSubscription do
define(:all_active, action: :all_active)
define(:all_by_map, action: :all_by_map)
define(:active_by_map, action: :active_by_map)
define(:destroy, action: :destroy)
define(:cancel, action: :cancel)
define(:expire, action: :expire)
define(:update_plan, action: :update_plan)
define(:update_characters_limit, action: :update_characters_limit)
define(:update_hubs_limit, action: :update_hubs_limit)
define(:update_active_till, action: :update_active_till)
define(:update_auto_renew, action: :update_auto_renew)
end
actions do
@@ -51,7 +62,7 @@ defmodule WandererApp.Api.MapSubscription do
:auto_renew?
]
defaults [:read]
defaults [:create, :read, :update, :destroy]
read :all_active do
prepare build(sort: [updated_at: :asc])

View File

@@ -31,6 +31,9 @@ defmodule WandererApp.Api.MapSystemComment do
end
code_interface do
define(:create, action: :create)
define(:destroy, action: :destroy)
define(:by_id,
get_by: [:id],
action: :read
@@ -46,7 +49,7 @@ defmodule WandererApp.Api.MapSystemComment do
:text
]
defaults [:read]
defaults [:read, :destroy]
create :create do
primary? true

View File

@@ -30,6 +30,7 @@ defmodule WandererApp.Api.MapSystemSignature do
code_interface do
define(:all_active, action: :all_active)
define(:create, action: :create)
define(:destroy, action: :destroy)
define(:update, action: :update)
define(:update_linked_system, action: :update_linked_system)
define(:update_type, action: :update_type)
@@ -62,6 +63,7 @@ defmodule WandererApp.Api.MapSystemSignature do
:eve_id,
:character_eve_id,
:name,
:temporary_name,
:description,
:kind,
:group,
@@ -101,6 +103,7 @@ defmodule WandererApp.Api.MapSystemSignature do
:eve_id,
:character_eve_id,
:name,
:temporary_name,
:description,
:kind,
:group,
@@ -120,6 +123,7 @@ defmodule WandererApp.Api.MapSystemSignature do
:eve_id,
:character_eve_id,
:name,
:temporary_name,
:description,
:kind,
:group,
@@ -195,6 +199,10 @@ defmodule WandererApp.Api.MapSystemSignature do
allow_nil? true
end
attribute :temporary_name, :string do
allow_nil? true
end
attribute :type, :string do
allow_nil? true
end
@@ -241,6 +249,7 @@ defmodule WandererApp.Api.MapSystemSignature do
:eve_id,
:character_eve_id,
:name,
:temporary_name,
:description,
:type,
:linked_system_id,

View File

@@ -29,19 +29,7 @@ defmodule WandererApp.Api.MapTransaction do
:amount
]
defaults [:create]
read :read do
primary?(true)
pagination offset?: true,
default_limit: 25,
max_page_size: 100,
countable: true,
required?: false
prepare build(sort: [inserted_at: :desc])
end
defaults [:create, :read, :update, :destroy]
read :by_map do
argument(:map_id, :string, allow_nil?: false)

View File

@@ -40,6 +40,7 @@ defmodule WandererApp.Api.MapUserSettings do
action: :read
)
define(:update_hubs, action: :update_hubs)
define(:update_settings, action: :update_settings)
define(:update_following_character, action: :update_following_character)
define(:update_main_character, action: :update_main_character)
@@ -52,7 +53,7 @@ defmodule WandererApp.Api.MapUserSettings do
:settings
]
defaults [:create, :read]
defaults [:create, :read, :update, :destroy]
update :update_settings do
accept [:settings]

View File

@@ -145,7 +145,12 @@ defmodule WandererApp.Api.UserActivity do
:admin_action,
:config_change,
:bulk_operation,
:security_alert
:security_alert,
# Subscription events
:subscription_created,
:subscription_updated,
:subscription_deleted,
:subscription_unknown
]
)

View File

@@ -45,6 +45,9 @@ defmodule WandererApp.Application do
Supervisor.child_spec({Cachex, name: :tracked_characters},
id: :tracked_characters_cache_worker
),
Supervisor.child_spec({Cachex, name: :wanderer_app_cache},
id: :wanderer_app_cache_worker
),
{Registry, keys: :unique, name: WandererApp.MapRegistry},
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
{PartitionSupervisor,
@@ -60,6 +63,14 @@ defmodule WandererApp.Application do
if Application.get_env(:wanderer_app, :environment) == :test do
[]
else
security_audit_children =
if Application.get_env(:wanderer_app, WandererApp.SecurityAudit, [])
|> Keyword.get(:async, false) do
[WandererApp.SecurityAudit.AsyncProcessor]
else
[]
end
[
WandererApp.Esi.InitClientsTask,
WandererApp.Scheduler,
@@ -68,7 +79,7 @@ defmodule WandererApp.Application do
{WandererApp.Character.TrackerPoolSupervisor, []},
WandererApp.Character.TrackerManager,
WandererApp.Map.Manager
]
] ++ security_audit_children
end
children =

View File

@@ -0,0 +1,150 @@
defmodule WandererApp.Audit.RequestContext do
@moduledoc """
Provides utilities for extracting request context information
for audit logging purposes.
"""
require Logger
@doc """
Extract the client's IP address from the connection.
Simply returns the remote_ip from the connection.
"""
def get_ip_address(conn) do
conn.remote_ip
|> :inet.ntoa()
|> to_string()
rescue
error ->
Logger.warning("Failed to get IP address: #{inspect(error)}",
error: error,
stacktrace: __STACKTRACE__
)
"unknown"
end
@doc """
Extract the user agent from the request headers.
"""
def get_user_agent(conn) do
get_header(conn, "user-agent") || "unknown"
end
@doc """
Extract or generate a session ID for the request.
"""
def get_session_id(conn) do
# Try to get from session
session_id = get_session(conn, :session_id)
# Fall back to request ID
session_id || get_request_id(conn)
end
@doc """
Extract or generate a request ID for correlation.
"""
def get_request_id(conn) do
# Try standard request ID headers
get_header(conn, "x-request-id") ||
get_header(conn, "x-correlation-id") ||
Logger.metadata()[:request_id] ||
generate_request_id()
end
@doc """
Build a complete request metadata map for audit logging.
"""
def build_request_metadata(conn) do
%{
ip_address: get_ip_address(conn),
user_agent: get_user_agent(conn),
session_id: get_session_id(conn),
request_id: get_request_id(conn),
request_path: conn.request_path,
method: conn.method |> to_string() |> String.upcase(),
host: conn.host,
port: conn.port,
scheme: conn.scheme |> to_string()
}
end
@doc """
Extract user information from the connection.
Returns a map with user_id and any additional user context.
"""
def get_user_info(conn) do
case conn.assigns[:current_user] do
%{id: user_id} = user ->
%{
user_id: user_id,
username: Map.get(user, :username),
email: Map.get(user, :email)
}
nil ->
%{user_id: nil}
end
end
@doc """
Build a minimal request details map for audit events.
This is used by existing audit calls that expect specific fields.
"""
def build_request_details(conn) do
metadata = build_request_metadata(conn)
%{
ip_address: metadata.ip_address,
user_agent: metadata.user_agent,
session_id: metadata.session_id,
request_path: metadata.request_path,
method: metadata.method
}
end
@doc """
Set request context in the process dictionary for async logging.
"""
def set_request_context(conn) do
context = %{
metadata: build_request_metadata(conn),
user_info: get_user_info(conn),
timestamp: DateTime.utc_now()
}
Process.put(:audit_request_context, context)
conn
end
@doc """
Get request context from the process dictionary.
"""
def get_request_context do
Process.get(:audit_request_context)
end
# Private functions
defp get_header(conn, header) do
case Plug.Conn.get_req_header(conn, header) do
[value | _] -> value
[] -> nil
end
end
defp get_session(conn, key) do
conn
|> Plug.Conn.get_session(key)
rescue
_ -> nil
end
defp generate_request_id do
"req_#{:crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)}"
end
end

View File

@@ -113,6 +113,59 @@ defmodule WandererApp.CachedInfo do
end
end
def get_solar_system_jumps() do
case WandererApp.Cache.lookup(:solar_system_jumps) do
{:ok, nil} ->
data = WandererApp.EveDataService.get_solar_system_jumps_data()
cache_items(data, :solar_system_jumps)
{:ok, data}
{:ok, data} ->
{:ok, data}
end
end
def get_solar_system_jump(from_solar_system_id, to_solar_system_id) do
# Create normalized cache key (smaller ID first for bidirectional lookup)
{id1, id2} = if from_solar_system_id < to_solar_system_id do
{from_solar_system_id, to_solar_system_id}
else
{to_solar_system_id, from_solar_system_id}
end
cache_key = "jump_#{id1}_#{id2}"
case WandererApp.Cache.lookup(cache_key) do
{:ok, nil} ->
# Build jump index if not exists
build_jump_index()
WandererApp.Cache.lookup(cache_key)
result -> result
end
end
defp build_jump_index() do
case get_solar_system_jumps() do
{:ok, jumps} ->
jumps
|> Enum.each(fn jump ->
{id1, id2} = if jump.from_solar_system_id < jump.to_solar_system_id do
{jump.from_solar_system_id, jump.to_solar_system_id}
else
{jump.to_solar_system_id, jump.from_solar_system_id}
end
cache_key = "jump_#{id1}_#{id2}"
WandererApp.Cache.put(cache_key, jump)
end)
_ -> :error
end
end
def get_wormhole_types!() do
case get_wormhole_types() do
{:ok, wormhole_types} ->

View File

@@ -28,7 +28,7 @@ defmodule WandererApp.Character do
Cachex.put(:character_cache, character_id, character)
{:ok, character}
_ ->
error ->
{:error, :not_found}
end
@@ -283,39 +283,44 @@ defmodule WandererApp.Character do
|> case do
{:ok, settings} when not is_nil(settings) ->
character
|> Map.put(:online, false)
|> Map.merge(settings)
|> Map.merge(%{
solar_system_id: settings.solar_system_id,
structure_id: settings.structure_id,
station_id: settings.station_id,
ship: settings.ship,
ship_name: settings.ship_name,
ship_item_id: settings.ship_item_id
})
_ ->
character
|> Map.put(:online, false)
|> Map.merge(@default_character_tracking_data)
end
|> Map.merge(%{tracking_paused: tracking_paused})
|> Map.merge(%{online: false, tracking_paused: tracking_paused})
end
defp prepare_search_results(result) do
{:ok, characters} =
_load_eve_info(Map.get(result, "character"), :get_character_info, &_map_character_info/1)
load_eve_info(Map.get(result, "character"), :get_character_info, &map_character_info/1)
{:ok, corporations} =
_load_eve_info(
load_eve_info(
Map.get(result, "corporation"),
:get_corporation_info,
&_map_corporation_info/1
&map_corporation_info/1
)
{:ok, alliances} =
_load_eve_info(Map.get(result, "alliance"), :get_alliance_info, &_map_alliance_info/1)
load_eve_info(Map.get(result, "alliance"), :get_alliance_info, &map_alliance_info/1)
[[characters | corporations] | alliances] |> List.flatten()
end
defp _load_eve_info(nil, _, _), do: {:ok, []}
defp load_eve_info(nil, _, _), do: {:ok, []}
defp _load_eve_info([], _, _), do: {:ok, []}
defp load_eve_info([], _, _), do: {:ok, []}
defp _load_eve_info(eve_ids, method, map_function),
defp load_eve_info(eve_ids, method, map_function),
do:
{:ok,
Enum.map(eve_ids, fn eve_id ->
@@ -331,7 +336,7 @@ defmodule WandererApp.Character do
end)
|> Enum.filter(fn result -> not is_nil(result) end)}
defp _map_alliance_info(info) do
defp map_alliance_info(info) do
%{
label: info["name"],
value: info["eve_id"] |> to_string(),
@@ -339,7 +344,7 @@ defmodule WandererApp.Character do
}
end
defp _map_character_info(info) do
defp map_character_info(info) do
%{
label: info["name"],
value: info["eve_id"] |> to_string(),
@@ -347,7 +352,7 @@ defmodule WandererApp.Character do
}
end
defp _map_corporation_info(info) do
defp map_corporation_info(info) do
%{
label: info["name"],
value: info["eve_id"] |> to_string(),

View File

@@ -49,11 +49,13 @@ defmodule WandererApp.Character.Activity do
"""
def process_character_activity(map_id, current_user) do
with {:ok, map_user_settings} <- get_map_user_settings(map_id, current_user.id),
raw_activity <- WandererApp.Map.get_character_activity(map_id),
{:ok, raw_activity} <- WandererApp.Map.get_character_activity(map_id),
{:ok, user_characters} <-
WandererApp.Api.Character.active_by_user(%{user_id: current_user.id}) do
result = process_activity_data(raw_activity, map_user_settings, user_characters)
result
process_activity_data(raw_activity, map_user_settings, user_characters)
else
_ ->
[]
end
end

View File

@@ -7,6 +7,7 @@ defmodule WandererApp.Character.Tracker do
defstruct [
:character_id,
:alliance_id,
:corporation_id,
:opts,
server_online: true,
start_time: nil,
@@ -21,6 +22,8 @@ defmodule WandererApp.Character.Tracker do
@type t :: %__MODULE__{
character_id: integer,
alliance_id: integer,
corporation_id: integer,
opts: map,
server_online: boolean,
start_time: DateTime.t(),
@@ -34,10 +37,10 @@ defmodule WandererApp.Character.Tracker do
}
@pause_tracking_timeout :timer.minutes(60 * 10)
@offline_timeout :timer.minutes(5)
@online_error_timeout :timer.minutes(2)
@ship_error_timeout :timer.minutes(2)
@location_error_timeout :timer.minutes(2)
@offline_timeout :timer.minutes(10)
@online_error_timeout :timer.minutes(10)
@ship_error_timeout :timer.minutes(10)
@location_error_timeout :timer.minutes(10)
@online_forbidden_ttl :timer.seconds(7)
@online_limit_ttl :timer.seconds(7)
@forbidden_ttl :timer.seconds(5)
@@ -49,8 +52,15 @@ defmodule WandererApp.Character.Tracker do
def new(args), do: __struct__(args)
def init(args) do
character_id = args[:character_id]
{:ok, %{corporation_id: corporation_id, alliance_id: alliance_id}} =
WandererApp.Character.get_character(character_id)
%{
character_id: args[:character_id],
character_id: character_id,
corporation_id: corporation_id,
alliance_id: alliance_id,
start_time: DateTime.utc_now(),
opts: args
}
@@ -101,6 +111,7 @@ defmodule WandererApp.Character.Tracker do
if duration >= timeout do
pause_tracking(character_id)
WandererApp.Cache.delete("character:#{character_id}:#{type}_error_time")
:ok
else
@@ -113,15 +124,14 @@ defmodule WandererApp.Character.Tracker do
if WandererApp.Character.can_pause_tracking?(character_id) &&
not WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused") do
# Log character tracking statistics before pausing
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
Logger.debug(fn ->
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
Logger.warning(
"CHARACTER_TRACKING_PAUSED: Character tracking paused due to sustained errors",
character_id: character_id,
"CHARACTER_TRACKING_PAUSED: Character tracking paused due to sustained errors: #{inspect(character_id: character_id,
active_maps: length(character_state.active_maps),
is_online: character_state.is_online,
tracking_duration_minutes: get_tracking_duration_minutes(character_id)
)
tracking_duration_minutes: get_tracking_duration_minutes(character_id))}"
end)
WandererApp.Cache.delete("character:#{character_id}:online_forbidden")
WandererApp.Cache.delete("character:#{character_id}:online_error_time")
@@ -193,7 +203,7 @@ defmodule WandererApp.Character.Tracker do
access_token: access_token,
character_id: character_id
) do
{:ok, online} ->
{:ok, online} when is_map(online) ->
online = get_online(online)
if online.online == true do
@@ -258,7 +268,7 @@ defmodule WandererApp.Character.Tracker do
character_id: character_id
})
Logger.warning("ESI_ERROR: Character online tracking failed",
Logger.warning("ESI_ERROR: Character online tracking failed #{inspect(error)}",
character_id: character_id,
tracking_pool: tracking_pool,
error_type: error,
@@ -388,12 +398,21 @@ defmodule WandererApp.Character.Tracker do
{:ok, %{eve_id: eve_id, tracking_pool: tracking_pool}} =
WandererApp.Character.get_character(character_id)
case WandererApp.Esi.get_character_info(eve_id) do
{:ok, _info} ->
character_eve_id = eve_id |> String.to_integer()
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
{:ok, [character_aff_info]} when not is_nil(character_aff_info) ->
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
update = maybe_update_corporation(character_state, eve_id |> String.to_integer())
WandererApp.Character.update_character_state(character_id, update)
alliance_id = character_aff_info |> Map.get("alliance_id")
corporation_id = character_aff_info |> Map.get("corporation_id")
updated_state =
character_state
|> maybe_update_corporation(corporation_id)
|> maybe_update_alliance(alliance_id)
WandererApp.Character.update_character_state(character_id, updated_state)
:ok
@@ -975,7 +994,38 @@ defmodule WandererApp.Character.Tracker do
end
end
defp update_alliance(%{character_id: character_id} = state, alliance_id) do
defp maybe_update_alliance(
%{character_id: character_id, alliance_id: old_alliance_id} = state,
alliance_id
)
when old_alliance_id != alliance_id and is_nil(alliance_id) do
{:ok, character} = WandererApp.Character.get_character(character_id)
character_update = %{
alliance_id: nil,
alliance_name: nil,
alliance_ticker: nil
}
{:ok, _character} =
Character.update_alliance(character, character_update)
WandererApp.Character.update_character(character_id, character_update)
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character_id}:alliance",
{:character_alliance, {character_id, character_update}}
)
state
end
defp maybe_update_alliance(
%{character_id: character_id, alliance_id: old_alliance_id} = state,
alliance_id
)
when old_alliance_id != alliance_id do
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|> case do
@@ -1015,7 +1065,13 @@ defmodule WandererApp.Character.Tracker do
end
end
defp update_corporation(%{character_id: character_id} = state, corporation_id) do
defp maybe_update_alliance(state, _alliance_id), do: state
defp maybe_update_corporation(
%{character_id: character_id, corporation_id: old_corporation_id} = state,
corporation_id
)
when old_corporation_id != corporation_id do
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|> case do
@@ -1027,16 +1083,13 @@ defmodule WandererApp.Character.Tracker do
|> WandererApp.Esi.get_corporation_info()
|> case do
{:ok, %{"name" => corporation_name, "ticker" => corporation_ticker} = corporation_info} ->
alliance_id = Map.get(corporation_info, "alliance_id")
{:ok, character} =
WandererApp.Character.get_character(character_id)
character_update = %{
corporation_id: corporation_id,
corporation_name: corporation_name,
corporation_ticker: corporation_ticker,
alliance_id: alliance_id
corporation_ticker: corporation_ticker
}
{:ok, _character} =
@@ -1057,8 +1110,7 @@ defmodule WandererApp.Character.Tracker do
)
state
|> Map.merge(%{alliance_id: alliance_id, corporation_id: corporation_id})
|> maybe_update_alliance()
|> Map.merge(%{corporation_id: corporation_id})
error ->
Logger.warning(
@@ -1072,6 +1124,8 @@ defmodule WandererApp.Character.Tracker do
end
end
defp maybe_update_corporation(state, _corporation_id), do: state
defp maybe_update_ship(
%{
character_id: character_id
@@ -1153,58 +1207,6 @@ defmodule WandererApp.Character.Tracker do
structure_id != new_structure_id ||
station_id != new_station_id
defp maybe_update_corporation(
state,
character_eve_id
)
when not is_nil(character_eve_id) and is_integer(character_eve_id) do
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
{:ok, [character_aff_info]} when not is_nil(character_aff_info) ->
update_corporation(state, character_aff_info |> Map.get("corporation_id"))
_error ->
state
end
end
defp maybe_update_corporation(
state,
_info
),
do: state
defp maybe_update_alliance(
%{character_id: character_id, alliance_id: alliance_id} =
state
) do
case alliance_id do
nil ->
{:ok, character} = WandererApp.Character.get_character(character_id)
character_update = %{
alliance_id: nil,
alliance_name: nil,
alliance_ticker: nil
}
{:ok, _character} =
Character.update_alliance(character, character_update)
WandererApp.Character.update_character(character_id, character_update)
@pubsub_client.broadcast(
WandererApp.PubSub,
"character:#{character_id}:alliance",
{:character_alliance, {character_id, character_update}}
)
state
_ ->
update_alliance(state, alliance_id)
end
end
defp maybe_update_wallet(
%{character_id: character_id} =
state,

View File

@@ -12,6 +12,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
opts: map
}
@check_start_queue_interval :timer.seconds(1)
@garbage_collection_interval :timer.minutes(15)
@untrack_characters_interval :timer.minutes(1)
@inactive_character_timeout :timer.minutes(10)
@@ -23,6 +24,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
def new(args), do: __struct__(args)
def init(args) do
Process.send_after(self(), :check_start_queue, @check_start_queue_interval)
Process.send_after(self(), :garbage_collect, @garbage_collection_interval)
Process.send_after(self(), :untrack_characters, @untrack_characters_interval)
@@ -46,25 +48,19 @@ defmodule WandererApp.Character.TrackerManager.Impl do
end
def start_tracking(state, character_id, opts) do
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
false <- Enum.member?(characters, character_id) do
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
if not WandererApp.Cache.has_key?("#{character_id}:track_requested") do
WandererApp.Cache.insert(
"#{character_id}:track_requested",
true
)
tracked_characters = [character_id | characters] |> Enum.uniq()
WandererApp.Cache.insert("tracked_characters", tracked_characters)
WandererApp.Character.update_character(character_id, %{online: false})
WandererApp.Character.update_character_state(character_id, %{
is_online: false
})
WandererApp.Character.TrackerPoolDynamicSupervisor.start_tracking(character_id)
WandererApp.TaskWrapper.start_link(WandererApp.Character, :update_character_state, [
character_id,
%{opts: opts}
])
WandererApp.Cache.insert_or_update(
"track_characters_queue",
[character_id],
fn existing ->
[character_id | existing] |> Enum.uniq()
end
)
end
state
@@ -178,6 +174,21 @@ defmodule WandererApp.Character.TrackerManager.Impl do
end
end
def handle_info(
:check_start_queue,
state
) do
Process.send_after(self(), :check_start_queue, @check_start_queue_interval)
{:ok, track_characters_queue} = WandererApp.Cache.lookup("track_characters_queue", [])
track_characters_queue
|> Enum.each(fn character_id ->
track_character(character_id, %{})
end)
state
end
def handle_info(
:garbage_collect,
state
@@ -294,8 +305,56 @@ defmodule WandererApp.Character.TrackerManager.Impl do
state
end
def handle_info(_event, state),
do: state
def track_character(character_id, opts) do
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
false <- Enum.member?(characters, character_id) do
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
WandererApp.Cache.insert_or_update(
"tracked_characters",
[character_id],
fn existing ->
[character_id | existing] |> Enum.uniq()
end
)
WandererApp.Cache.insert_or_update(
"track_characters_queue",
[],
fn existing ->
existing
|> Enum.reject(fn c_id -> c_id == character_id end)
end
)
WandererApp.Cache.delete("#{character_id}:track_requested")
WandererApp.Character.update_character(character_id, %{online: false})
WandererApp.Character.update_character_state(character_id, %{
is_online: false
})
WandererApp.Character.TrackerPoolDynamicSupervisor.start_tracking(character_id)
WandererApp.TaskWrapper.start_link(WandererApp.Character, :update_character_state, [
character_id,
%{opts: opts}
])
else
_ ->
WandererApp.Cache.insert_or_update(
"track_characters_queue",
[],
fn existing ->
existing
|> Enum.reject(fn c_id -> c_id == character_id end)
end
)
WandererApp.Cache.delete("#{character_id}:track_requested")
end
end
def character_is_present(map_id, character_id) do
{:ok, presence_character_ids} =

View File

@@ -23,7 +23,7 @@ defmodule WandererApp.Character.TrackerPool do
@check_ship_errors_interval :timer.minutes(1)
@check_location_errors_interval :timer.minutes(1)
@update_ship_interval :timer.seconds(2)
@update_info_interval :timer.minutes(1)
@update_info_interval :timer.minutes(2)
@update_wallet_interval :timer.minutes(1)
@logger Application.compile_env(:wanderer_app, :logger)
@@ -124,7 +124,7 @@ defmodule WandererApp.Character.TrackerPool do
Process.send_after(self(), :check_online_errors, :timer.seconds(60))
Process.send_after(self(), :check_ship_errors, :timer.seconds(90))
Process.send_after(self(), :check_location_errors, :timer.seconds(120))
Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
# Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
Process.send_after(self(), :update_location, 300)
Process.send_after(self(), :update_ship, 500)
Process.send_after(self(), :update_info, 1500)

View File

@@ -287,8 +287,8 @@ defmodule WandererApp.Esi.ApiClient do
opts: [ttl: @ttl]
)
def get_alliance_info(eve_id, opts \\ []) do
case _get_alliance_info(eve_id, "", opts) do
{:ok, result} -> {:ok, result |> Map.put("eve_id", eve_id)}
case get_alliance_info(eve_id, "", opts) do
{:ok, result} when is_map(result) -> {:ok, result |> Map.put("eve_id", eve_id)}
{:error, error} -> {:error, error}
error -> error
end
@@ -309,8 +309,8 @@ defmodule WandererApp.Esi.ApiClient do
opts: [ttl: @ttl]
)
def get_corporation_info(eve_id, opts \\ []) do
case _get_corporation_info(eve_id, "", opts) do
{:ok, result} -> {:ok, result |> Map.put("eve_id", eve_id)}
case get_corporation_info(eve_id, "", opts) do
{:ok, result} when is_map(result) -> {:ok, result |> Map.put("eve_id", eve_id)}
{:error, error} -> {:error, error}
error -> error
end
@@ -327,7 +327,7 @@ defmodule WandererApp.Esi.ApiClient do
opts,
@cache_opts
) do
{:ok, result} -> {:ok, result |> Map.put("eve_id", eve_id)}
{:ok, result} when is_map(result) -> {:ok, result |> Map.put("eve_id", eve_id)}
{:error, error} -> {:error, error}
error -> error
end
@@ -434,7 +434,7 @@ defmodule WandererApp.Esi.ApiClient do
defp get_auth_opts(opts), do: [auth: {:bearer, opts[:access_token]}]
defp _get_alliance_info(alliance_eve_id, info_path, opts),
defp get_alliance_info(alliance_eve_id, info_path, opts),
do:
get(
"/alliances/#{alliance_eve_id}/#{info_path}",
@@ -442,7 +442,7 @@ defmodule WandererApp.Esi.ApiClient do
@cache_opts
)
defp _get_corporation_info(corporation_eve_id, info_path, opts),
defp get_corporation_info(corporation_eve_id, info_path, opts),
do:
get(
"/corporations/#{corporation_eve_id}/#{info_path}",
@@ -830,7 +830,8 @@ defmodule WandererApp.Esi.ApiClient do
expires_at,
scopes
) do
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at, :second)
expires_at_datetime = DateTime.from_unix!(expires_at)
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)
Logger.warning("TOKEN_REFRESH_FAILED: Invalid grant error during token refresh",
character_id: character_id,
@@ -857,7 +858,8 @@ defmodule WandererApp.Esi.ApiClient do
expires_at,
scopes
) do
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at, :second)
expires_at_datetime = DateTime.from_unix!(expires_at)
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)
Logger.warning("TOKEN_REFRESH_FAILED: Connection refused during token refresh",
character_id: character_id,

View File

@@ -51,7 +51,7 @@ defmodule WandererApp.ExternalEvents.Event do
def new(map_id, event_type, payload) when is_binary(map_id) and is_map(payload) do
if valid_event_type?(event_type) do
%__MODULE__{
id: Ulid.generate(System.system_time(:millisecond)),
id: Ecto.ULID.generate(System.system_time(:millisecond)),
map_id: map_id,
type: event_type,
payload: payload,
@@ -97,7 +97,7 @@ defmodule WandererApp.ExternalEvents.Event do
:locked,
# ADD
:temporary_name,
# ADD
# ADD
:labels,
# ADD
:description,

View File

@@ -448,7 +448,7 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
"connected" ->
%{
"type" => "connection_status",
"id" => event["id"] || Ulid.generate(),
"id" => event["id"] || Ecto.ULID.generate(),
"attributes" => %{
"status" => "connected",
"server_time" => payload["server_time"],
@@ -465,7 +465,7 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
# Use existing payload structure but wrap it in JSON:API format
%{
"type" => "events",
"id" => event["id"] || Ulid.generate(),
"id" => event["id"] || Ecto.ULID.generate(),
"attributes" => payload,
"relationships" => %{
"map" => %{

View File

@@ -248,6 +248,6 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
defp datetime_to_ulid(datetime) do
timestamp = DateTime.to_unix(datetime, :millisecond)
# Create a ULID with the timestamp (rest will be zeros for comparison)
Ulid.generate(timestamp)
Ecto.ULID.generate(timestamp)
end
end

View File

@@ -1,31 +1,28 @@
defmodule WandererApp.Map.Audit do
@moduledoc """
Manager map subscription plans
This module now delegates to SecurityAudit for consistency.
It maintains backward compatibility while using the centralized audit system.
"""
require Ash.Query
require Logger
alias WandererApp.SecurityAudit
@week_seconds :timer.hours(24 * 7)
@month_seconds @week_seconds * 4
@audit_expired_seconds @month_seconds * 3
def track_map_subscription_event(event_type, metadata) do
case event_type do
"subscription.created" ->
track_map_event(event_type, metadata)
mapped_type =
case event_type do
"subscription.created" -> :subscription_created
"subscription.updated" -> :subscription_updated
"subscription.deleted" -> :subscription_deleted
_ -> :subscription_unknown
end
"subscription.updated" ->
track_map_event(event_type, metadata)
"subscription.deleted" ->
track_map_event(event_type, metadata)
_ ->
{:ok, nil}
end
track_map_event(mapped_type, metadata)
end
def archive() do
@@ -39,192 +36,14 @@ defmodule WandererApp.Map.Audit do
:ok
end
def get_activity_query(map_id, period, activity) do
{from, to} = period |> get_period()
defdelegate get_map_activity_query(map_id, period, activity),
to: WandererApp.SecurityAudit
query =
WandererApp.Api.UserActivity
|> Ash.Query.filter(
and: [
[entity_id: map_id],
[inserted_at: [greater_than_or_equal: from]],
[inserted_at: [less_than_or_equal: to]]
]
)
defdelegate track_acl_event(event_type, metadata),
to: WandererApp.SecurityAudit
query =
activity
|> case do
"all" ->
query
activity ->
query
|> Ash.Query.filter(event_type: activity)
end
query
|> Ash.Query.sort(inserted_at: :desc)
end
@doc """
Get combined activity including security events for a map.
"""
def get_combined_activity_query(map_id, period, activity) do
{from, to} = period |> get_period()
# Get regular map activity
map_query = get_activity_query(map_id, period, activity)
# Get security events related to this map
security_query =
WandererApp.Api.UserActivity
|> Ash.Query.filter(entity_type: :security_event)
|> Ash.Query.filter(inserted_at: [greater_than_or_equal: from])
|> Ash.Query.filter(inserted_at: [less_than_or_equal: to])
|> Ash.Query.sort(inserted_at: :desc)
# Execute both queries and combine results
case {Ash.read(map_query), Ash.read(security_query)} do
{{:ok, map_activities}, {:ok, security_activities}} ->
# Combine and sort by timestamp
combined =
(map_activities ++ security_activities)
|> Enum.sort_by(& &1.inserted_at, {:desc, DateTime})
{:ok, combined}
{{:error, _} = error, _} ->
error
{_, {:error, _} = error} ->
error
end
end
@doc """
Get security events for a specific map.
"""
def get_security_events_for_map(map_id, period \\ "1D") do
{from, to} = period |> get_period()
# Get security events that might be related to this map
# This could include data access events, permission denied events, etc.
SecurityAudit.get_events_in_range(from, to)
|> Enum.filter(fn event ->
case Jason.decode(event.event_data || "{}") do
{:ok, data} ->
# Check if the event data contains references to this map
data["resource_id"] == map_id ||
data["entity_id"] == map_id ||
data["map_id"] == map_id
_ ->
false
end
end)
end
def track_acl_event(
event_type,
%{user_id: user_id, acl_id: acl_id} = metadata
)
when not is_nil(user_id) and not is_nil(acl_id),
do:
WandererApp.Api.UserActivity.new(%{
user_id: user_id,
entity_type: :access_list,
entity_id: acl_id,
event_type: event_type,
event_data: metadata |> Map.drop([:user_id, :acl_id]) |> Jason.encode!()
})
def track_acl_event(_event_type, _metadata), do: {:ok, nil}
def track_map_event(
event_type,
%{character_id: character_id, user_id: user_id, map_id: map_id} = metadata
)
when not is_nil(character_id) and not is_nil(user_id) and not is_nil(map_id) do
# Log regular map activity
result =
WandererApp.Api.UserActivity.new(%{
character_id: character_id,
user_id: user_id,
entity_type: :map,
entity_id: map_id,
event_type: event_type,
event_data: metadata |> Map.drop([:character_id, :user_id, :map_id]) |> Jason.encode!()
})
# Also log security-relevant map events
if security_relevant_event?(event_type) do
SecurityAudit.log_data_access(
"map",
map_id,
user_id,
event_type,
metadata
)
end
result
end
def track_map_event(_event_type, _metadata), do: {:ok, nil}
defp get_period("1H") do
now = DateTime.utc_now()
start_date = now |> DateTime.add(-1 * 3600, :second)
{start_date, now}
end
defp get_period("1D") do
now = DateTime.utc_now()
start_date = now |> DateTime.add(-24 * 3600, :second)
{start_date, now}
end
defp get_period("1W") do
now = DateTime.utc_now()
start_date = now |> DateTime.add(-24 * 3600 * 7, :second)
{start_date, now}
end
defp get_period("1M") do
now = DateTime.utc_now()
start_date = now |> DateTime.add(-24 * 3600 * 31, :second)
{start_date, now}
end
defp get_period("2M") do
now = DateTime.utc_now()
start_date = now |> DateTime.add(-24 * 3600 * 31 * 2, :second)
{start_date, now}
end
defp get_period("3M") do
now = DateTime.utc_now()
start_date = now |> DateTime.add(-24 * 3600 * 31 * 3, :second)
{start_date, now}
end
defp get_period(_), do: get_period("1H")
defdelegate track_map_event(event_type, metadata),
to: WandererApp.SecurityAudit
defp get_expired_at(), do: DateTime.utc_now() |> DateTime.add(-@audit_expired_seconds, :second)
defp security_relevant_event?(event_type) do
# Define which map events should also be logged as security events
event_type in [
:map_acl_added,
:map_acl_removed,
:map_acl_updated,
:map_acl_member_added,
:map_acl_member_removed,
:map_acl_member_updated,
:map_removed,
:character_added,
:character_removed
]
end
end

View File

@@ -8,6 +8,19 @@ defmodule WandererApp.Map.Manager do
require Logger
alias WandererApp.Map.Server
alias WandererApp.Map.ServerSupervisor
alias WandererApp.Api.MapSystemSignature
@maps_start_per_second 10
@maps_start_interval 1000
@maps_queue :maps_queue
@garbage_collection_interval :timer.hours(1)
@check_maps_queue_interval :timer.seconds(1)
@signatures_cleanup_interval :timer.minutes(30)
@delete_after_minutes 30
@pings_cleanup_interval :timer.minutes(10)
@pings_expire_minutes 60
# Test-aware async task runner
defp safe_async_task(fun) do
@@ -25,20 +38,6 @@ defmodule WandererApp.Map.Manager do
end
end
alias WandererApp.Map.ServerSupervisor
alias WandererApp.Api.MapSystemSignature
@maps_start_per_second 5
@maps_start_interval 1000
@maps_queue :maps_queue
@garbage_collection_interval :timer.hours(1)
@check_maps_queue_interval :timer.seconds(1)
@signatures_cleanup_interval :timer.minutes(30)
@delete_after_minutes 30
@pings_cleanup_interval :timer.minutes(10)
@pings_expire_minutes 60
def start_map(map_id) when is_binary(map_id),
do: WandererApp.Queue.push_uniq(@maps_queue, map_id)
@@ -247,22 +246,29 @@ defmodule WandererApp.Map.Manager do
Logger.debug(fn -> "All maps started" end)
else
# In production, run async as normal
tasks =
for chunk <- chunks do
task =
Task.async(fn ->
chunk
|> Enum.map(&start_map_server/1)
end)
chunks
|> Task.async_stream(
fn chunk ->
chunk
|> Enum.map(&start_map_server/1)
:timer.sleep(@maps_start_interval)
end,
max_concurrency: System.schedulers_online(),
on_timeout: :kill_task,
timeout: :timer.seconds(60)
)
|> Enum.each(fn result ->
case result do
{:ok, _} ->
:ok
task
_ ->
:ok
end
end)
Logger.debug(fn -> "Waiting for maps to start" end)
Task.await_many(tasks)
Logger.debug(fn -> "All maps started" end)
Logger.info(fn -> "All maps started" end)
end
end

View File

@@ -373,6 +373,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
{:ok, character} =
WandererApp.Character.get_map_character(map_id, character_id, not_present: true)
WandererApp.Cache.delete("character:#{character.id}:tracking_paused")
add_character(%{map_id: map_id}, character, true)
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{

View File

@@ -527,33 +527,30 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
def is_connection_valid(scope, from_solar_system_id, to_solar_system_id)
when not is_nil(from_solar_system_id) and not is_nil(to_solar_system_id) do
{:ok, known_jumps} =
WandererApp.Api.MapSolarSystemJumps.find(%{
before_system_id: from_solar_system_id,
current_system_id: to_solar_system_id
})
{:ok, from_system_static_info} = get_system_static_info(from_solar_system_id)
{:ok, to_system_static_info} = get_system_static_info(to_solar_system_id)
case scope do
:wormholes ->
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id)) and
known_jumps |> Enum.empty?()
:stargates ->
# For stargates, we need to check:
# 1. Both systems are in known space (HS, LS, NS)
# 2. There is a known jump between them
# 3. Neither system is prohibited
from_system_static_info.system_class in @known_space and
to_system_static_info.system_class in @known_space and
with {:ok, known_jumps} <- find_solar_system_jump(from_solar_system_id, to_solar_system_id),
{:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
case scope do
:wormholes ->
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (known_jumps |> Enum.empty?())
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id)) and
known_jumps |> Enum.empty?()
:stargates ->
# For stargates, we need to check:
# 1. Both systems are in known space (HS, LS, NS)
# 2. There is a known jump between them
# 3. Neither system is prohibited
from_system_static_info.system_class in @known_space and
to_system_static_info.system_class in @known_space and
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (known_jumps |> Enum.empty?())
end
else
_ -> false
end
end
@@ -570,6 +567,13 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
end
end
defp find_solar_system_jump(from_solar_system_id, to_solar_system_id) do
case WandererApp.CachedInfo.get_solar_system_jump(from_solar_system_id, to_solar_system_id) do
{:ok, jump} when not is_nil(jump) -> {:ok, [jump]}
_ -> {:ok, []}
end
end
defp get_system_static_info(solar_system_id) do
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system_static_info} when not is_nil(system_static_info) ->

View File

@@ -114,6 +114,7 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
deleted_sig,
Map.take(sig, [
:name,
:temporary_name,
:description,
:kind,
:group,
@@ -239,6 +240,7 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
system_id: system_id,
eve_id: sig["eve_id"],
name: sig["name"],
temporary_name: sig["temporary_name"],
description: Map.get(sig, "description"),
kind: sig["kind"],
group: sig["group"],

View File

@@ -0,0 +1,898 @@
defmodule WandererApp.SecurityAudit do
@moduledoc """
Comprehensive security audit logging system.
This module provides centralized logging for security-related events including:
- Authentication events (login, logout, failures)
- Authorization events (permission denied, privilege escalation)
- Data access events (sensitive queries, bulk exports)
- Configuration changes and admin actions
"""
require Logger
require Ash.Query
alias WandererApp.Api.UserActivity
@doc """
Log a security event with structured data.
## Examples
iex> WandererApp.SecurityAudit.log_event(:auth_success, user_id, %{
...> ip_address: "192.168.1.100",
...> user_agent: "Mozilla/5.0...",
...> auth_method: "session"
...> })
:ok
"""
def log_event(event_type, user_id, details \\ %{}) do
audit_entry = %{
event_type: event_type,
user_id: user_id,
timestamp: DateTime.utc_now(),
details: details,
severity: determine_severity(event_type),
session_id: details[:session_id],
ip_address: details[:ip_address],
user_agent: details[:user_agent]
}
# Store in database
# store_audit_entry(audit_entry)
# Send to telemetry for monitoring
emit_telemetry_event(audit_entry)
# Log to application logs
log_to_application_log(audit_entry)
# Check for security alerts
check_security_alerts(audit_entry)
:ok
end
@doc """
Log authentication events.
"""
def log_auth_event(event_type, user_id, request_details) do
# Start with the basic required fields
details = %{
ip_address: request_details[:ip_address],
user_agent: request_details[:user_agent],
auth_method: request_details[:auth_method],
session_id: request_details[:session_id]
}
# Merge any additional fields from request_details
details = Map.merge(details, request_details)
log_event(event_type, user_id, details)
end
@doc """
Log data access events.
"""
def log_data_access(resource_type, resource_id, user_id, action, request_details \\ %{}) do
details = %{
resource_type: resource_type,
resource_id: resource_id,
action: action,
ip_address: request_details[:ip_address],
user_agent: request_details[:user_agent],
session_id: request_details[:session_id]
}
log_event(:data_access, user_id, details)
end
@doc """
Log permission denied events.
"""
def log_permission_denied(
resource_type,
resource_id,
user_id,
attempted_action,
request_details \\ %{}
) do
details = %{
resource_type: resource_type,
resource_id: resource_id,
attempted_action: attempted_action,
ip_address: request_details[:ip_address],
user_agent: request_details[:user_agent],
session_id: request_details[:session_id]
}
log_event(:permission_denied, user_id, details)
end
@doc """
Log admin actions.
"""
def log_admin_action(action, user_id, target_resource, request_details \\ %{}) do
details = %{
action: action,
target_resource: target_resource,
ip_address: request_details[:ip_address],
user_agent: request_details[:user_agent],
session_id: request_details[:session_id]
}
log_event(:admin_action, user_id, details)
end
@doc """
Log configuration changes.
"""
def log_config_change(config_key, old_value, new_value, user_id, request_details \\ %{}) do
details = %{
config_key: config_key,
old_value: sanitize_sensitive_data(old_value),
new_value: sanitize_sensitive_data(new_value),
ip_address: request_details[:ip_address],
user_agent: request_details[:user_agent],
session_id: request_details[:session_id]
}
log_event(:config_change, user_id, details)
end
@doc """
Log bulk data operations.
"""
def log_bulk_operation(operation_type, record_count, user_id, request_details \\ %{}) do
details = %{
operation_type: operation_type,
record_count: record_count,
ip_address: request_details[:ip_address],
user_agent: request_details[:user_agent],
session_id: request_details[:session_id]
}
log_event(:bulk_operation, user_id, details)
end
@doc """
Get audit events for a specific user.
"""
def get_user_audit_events(user_id, limit \\ 100) do
UserActivity
|> Ash.Query.filter(user_id: user_id)
|> Ash.Query.filter(entity_type: :security_event)
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(limit)
|> Ash.read!()
end
@doc """
Get recent security events.
"""
def get_recent_events(limit \\ 50) do
UserActivity
|> Ash.Query.filter(entity_type: :security_event)
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(limit)
|> Ash.read!()
end
@doc """
Get security events by type.
"""
def get_events_by_type(event_type, limit \\ 50) do
UserActivity
|> Ash.Query.filter(entity_type: :security_event)
|> Ash.Query.filter(event_type: event_type)
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(limit)
|> Ash.read!()
end
@doc """
Get security events within a time range.
"""
def get_events_in_range(from_datetime, to_datetime, limit \\ 100) do
UserActivity
|> Ash.Query.filter(entity_type: :security_event)
|> Ash.Query.filter(inserted_at: [greater_than_or_equal: from_datetime])
|> Ash.Query.filter(inserted_at: [less_than_or_equal: to_datetime])
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.Query.limit(limit)
|> Ash.read!()
end
@doc """
Track map-related events (compatibility with Map.Audit).
"""
def track_map_event(
event_type,
%{character_id: character_id, user_id: user_id, map_id: map_id} = metadata
)
when not is_nil(character_id) and not is_nil(user_id) and not is_nil(map_id) do
# Sanitize and prepare metadata
sanitized_metadata =
metadata
|> Map.drop([:character_id, :user_id, :map_id])
|> sanitize_metadata()
attrs = %{
character_id: character_id,
user_id: user_id,
entity_type: :map,
entity_id: map_id,
event_type: normalize_event_type(event_type),
event_data: Jason.encode!(sanitized_metadata)
}
case UserActivity.new(attrs) do
{:ok, activity} ->
{:ok, activity}
{:error, error} ->
Logger.error("Failed to track map event",
error: inspect(error),
event_type: event_type,
map_id: map_id
)
{:error, error}
end
end
def track_map_event(_event_type, _metadata), do: {:ok, nil}
@doc """
Track ACL-related events (compatibility with Map.Audit).
"""
def track_acl_event(
event_type,
%{user_id: user_id, acl_id: acl_id} = metadata
)
when not is_nil(user_id) and not is_nil(acl_id) do
# Sanitize and prepare metadata
sanitized_metadata =
metadata
|> Map.drop([:user_id, :acl_id])
|> sanitize_metadata()
attrs = %{
user_id: user_id,
entity_type: :access_list,
entity_id: acl_id,
event_type: normalize_event_type(event_type),
event_data: Jason.encode!(sanitized_metadata)
}
case UserActivity.new(attrs) do
{:ok, activity} ->
{:ok, activity}
{:error, error} ->
Logger.error("Failed to track ACL event",
error: inspect(error),
event_type: event_type,
acl_id: acl_id
)
{:error, error}
end
end
def track_acl_event(_event_type, _metadata), do: {:ok, nil}
@doc """
Get activity query for maps (compatibility with Map.Audit).
"""
def get_map_activity_query(map_id, period, activity \\ "all") do
{from, to} = get_period(period)
query =
UserActivity
|> Ash.Query.filter(
and: [
[entity_id: map_id],
[inserted_at: [greater_than_or_equal: from]],
[inserted_at: [less_than_or_equal: to]]
]
)
query =
case activity do
"all" ->
query
activity ->
query
|> Ash.Query.filter(event_type: normalize_event_type(activity))
end
query
|> Ash.Query.sort(inserted_at: :desc)
end
defp get_period("1H") do
now = DateTime.utc_now()
start_date = DateTime.add(now, -1 * 3600, :second)
{start_date, now}
end
defp get_period("1D") do
now = DateTime.utc_now()
start_date = DateTime.add(now, -24 * 3600, :second)
{start_date, now}
end
defp get_period("1W") do
now = DateTime.utc_now()
start_date = DateTime.add(now, -24 * 3600 * 7, :second)
{start_date, now}
end
defp get_period("1M") do
now = DateTime.utc_now()
start_date = DateTime.add(now, -24 * 3600 * 31, :second)
{start_date, now}
end
defp get_period("2M") do
now = DateTime.utc_now()
start_date = DateTime.add(now, -24 * 3600 * 31 * 2, :second)
{start_date, now}
end
defp get_period("3M") do
now = DateTime.utc_now()
start_date = DateTime.add(now, -24 * 3600 * 31 * 3, :second)
{start_date, now}
end
defp get_period(_), do: get_period("1H")
@doc """
Check for suspicious patterns in user activity.
"""
def analyze_user_behavior(user_id, time_window \\ 3600) do
now = DateTime.utc_now()
from_time = DateTime.add(now, -time_window, :second)
# Get recent activities
activities =
UserActivity
|> Ash.Query.filter(user_id: user_id)
|> Ash.Query.filter(entity_type: :security_event)
|> Ash.Query.filter(inserted_at: [greater_than_or_equal: from_time])
|> Ash.Query.sort(inserted_at: :desc)
|> Ash.read!()
# Analyze patterns
patterns = analyze_patterns(activities)
risk_score = calculate_risk_score(patterns)
recommendations = generate_recommendations(patterns, risk_score)
%{
risk_score: risk_score,
suspicious_patterns: patterns,
recommendations: recommendations,
activities_analyzed: length(activities),
time_window_seconds: time_window
}
end
defp analyze_patterns(activities) do
patterns = []
# Count by event type
event_counts = Enum.frequencies_by(activities, & &1.event_type)
# Check for multiple auth failures
auth_failures = Map.get(event_counts, :auth_failure, 0)
patterns =
if auth_failures >= 3 do
[{:multiple_auth_failures, auth_failures} | patterns]
else
patterns
end
# Check for permission denied spikes
permission_denied = Map.get(event_counts, :permission_denied, 0)
patterns =
if permission_denied >= 5 do
[{:excessive_permission_denials, permission_denied} | patterns]
else
patterns
end
# Check for rapid activity (more than 100 events in time window)
patterns =
if length(activities) > 100 do
[{:high_activity_volume, length(activities)} | patterns]
else
patterns
end
# Check for geographic anomalies by analyzing unique IPs
unique_ips =
activities
|> Enum.map(fn activity ->
case Jason.decode(activity.event_data || "{}") do
{:ok, data} -> data["ip_address"]
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> length()
patterns =
if unique_ips > 5 do
[{:multiple_ip_addresses, unique_ips} | patterns]
else
patterns
end
patterns
end
defp calculate_risk_score(patterns) do
score =
Enum.reduce(patterns, 0, fn
{:multiple_auth_failures, count}, acc -> acc + count * 2
{:excessive_permission_denials, count}, acc -> acc + count * 1.5
{:high_activity_volume, _}, acc -> acc + 5
{:multiple_ip_addresses, count}, acc -> acc + count * 3
_, acc -> acc
end)
cond do
score >= 20 -> :critical
score >= 10 -> :high
score >= 5 -> :medium
true -> :low
end
end
defp generate_recommendations(patterns, risk_score) do
base_recommendations =
case risk_score do
:critical -> ["Immediate review required", "Consider blocking user temporarily"]
:high -> ["Monitor user activity closely", "Review recent actions"]
:medium -> ["Keep user under observation"]
:low -> []
end
pattern_recommendations =
Enum.flat_map(patterns, fn
{:multiple_auth_failures, _} ->
["Reset user password", "Enable MFA"]
{:excessive_permission_denials, _} ->
["Review user permissions", "Check for compromised account"]
{:high_activity_volume, _} ->
["Check for automated activity", "Review API usage"]
{:multiple_ip_addresses, _} ->
["Verify user location changes", "Check for account sharing"]
_ ->
[]
end)
Enum.uniq(base_recommendations ++ pattern_recommendations)
end
# Private functions
defp store_audit_entry(audit_entry) do
# Handle async processing if enabled
# if async_enabled?() do
# WandererApp.SecurityAudit.AsyncProcessor.log_event(audit_entry)
# else
# do_store_audit_entry(audit_entry)
# end
end
@doc false
def do_store_audit_entry(audit_entry) do
# Ensure event_type is properly formatted
event_type = normalize_event_type(audit_entry.event_type)
attrs = %{
user_id: audit_entry.user_id,
character_id: nil,
entity_id: hash_identifier(audit_entry.session_id),
entity_type: :security_event,
event_type: event_type,
event_data: encode_event_data(audit_entry)
}
case UserActivity.new(attrs) do
{:ok, _activity} ->
:ok
{:error, error} ->
Logger.error("Failed to store security audit entry",
error: inspect(error),
event_type: event_type,
user_id: audit_entry.user_id
)
# Emit telemetry for monitoring
:telemetry.execute(
[:wanderer_app, :security_audit, :storage_error],
%{count: 1},
%{event_type: event_type, error: error}
)
# Don't block the request, but track the failure
{:error, :storage_failed}
end
end
defp hash_identifier(identifier) when is_binary(identifier) do
secret_salt =
Application.get_env(:wanderer_app, :secret_key_base) ||
raise "SECRET_KEY_BASE not configured"
:crypto.hash(:sha256, secret_salt <> identifier)
|> Base.encode16(case: :lower)
end
defp hash_identifier(nil), do: generate_entity_id()
defp normalize_event_type(event_type) when is_atom(event_type), do: event_type
defp normalize_event_type(event_type) when is_binary(event_type) do
try do
String.to_existing_atom(event_type)
rescue
ArgumentError -> :security_alert
end
end
defp normalize_event_type(_), do: :security_alert
defp encode_event_data(audit_entry) do
sanitized_details = sanitize_for_json(audit_entry.details)
data =
Map.merge(sanitized_details, %{
timestamp: convert_datetime(audit_entry.timestamp),
severity: to_string(audit_entry.severity),
ip_address: audit_entry.ip_address,
user_agent: audit_entry.user_agent
})
case Jason.encode(data) do
{:ok, json} -> json
{:error, _} -> Jason.encode!(%{error: "Failed to encode audit data"})
end
end
defp sanitize_for_json(data) when is_map(data) do
data
|> Enum.reduce(%{}, fn {key, value}, acc ->
sanitized_key = to_string(key)
# Skip sensitive fields
if sanitized_key in ~w(password secret token private_key api_key) do
acc
else
Map.put(acc, sanitized_key, sanitize_value(value))
end
end)
end
defp sanitize_for_json(data), do: sanitize_value(data)
defp sanitize_metadata(metadata) do
# List of sensitive keys to remove from metadata
sensitive_keys = [:password, :token, :secret, :api_key, :private_key, :auth_token]
metadata
|> Map.drop(sensitive_keys)
|> Enum.map(fn {k, v} ->
# Ensure keys are strings or atoms
key = if is_binary(k), do: k, else: to_string(k)
{key, sanitize_value(v)}
end)
|> Enum.into(%{})
end
defp sanitize_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp sanitize_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp sanitize_value(%Date{} = date), do: Date.to_iso8601(date)
defp sanitize_value(%Time{} = time), do: Time.to_iso8601(time)
defp sanitize_value(atom) when is_atom(atom) and not is_nil(atom) and not is_boolean(atom),
do: to_string(atom)
defp sanitize_value(list) when is_list(list), do: Enum.map(list, &sanitize_value/1)
defp sanitize_value(map) when is_map(map), do: sanitize_for_json(map)
defp sanitize_value(value), do: value
defp convert_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp convert_datetime(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp convert_datetime(value), do: value
defp generate_entity_id do
"audit_#{DateTime.utc_now() |> DateTime.to_unix(:microsecond)}_#{System.unique_integer([:positive])}"
end
defp async_enabled? do
Application.get_env(:wanderer_app, __MODULE__, [])
|> Keyword.get(:async, false)
end
defp emit_telemetry_event(audit_entry) do
:telemetry.execute(
[:wanderer_app, :security_audit],
%{count: 1},
%{
event_type: audit_entry.event_type,
severity: audit_entry.severity,
user_id: audit_entry.user_id
}
)
end
defp log_to_application_log(audit_entry) do
log_level =
case audit_entry.severity do
:critical -> :error
:high -> :warning
:medium -> :info
:low -> :debug
end
Logger.log(log_level, "Security audit: #{audit_entry.event_type}",
user_id: audit_entry.user_id,
timestamp: audit_entry.timestamp,
details: audit_entry.details
)
end
defp check_security_alerts(audit_entry) do
case audit_entry.event_type do
:auth_failure ->
check_failed_login_attempts(audit_entry)
:permission_denied ->
check_privilege_escalation_attempts(audit_entry)
:bulk_operation ->
check_bulk_data_access(audit_entry)
:security_alert ->
# Already a security alert, don't double-check
:ok
_ ->
:ok
end
end
defp check_failed_login_attempts(audit_entry) do
config = threat_detection_config()
if config[:enabled] do
ip_address = audit_entry.ip_address || "unknown"
cache_key = "auth_failures:#{ip_address}"
window = config[:window_seconds] || 300
max_attempts = config[:max_failed_attempts] || 5
# Increment counter in Cachex with TTL
count =
case Cachex.incr(:wanderer_app_cache, cache_key) do
{:ok, count} ->
# Set TTL on first increment
if count == 1 do
Cachex.expire(:wanderer_app_cache, cache_key, :timer.seconds(window))
end
count
{:error, :no_key} ->
# Key doesn't exist, initialize it with TTL
case Cachex.put(:wanderer_app_cache, cache_key, 1, ttl: :timer.seconds(window)) do
{:ok, _} ->
1
{:error, error} ->
Logger.error("Failed to initialize auth failure counter",
error: inspect(error),
cache_key: cache_key
)
1
end
{:error, error} ->
# Other errors - log and return safe default
Logger.error("Failed to increment auth failure counter",
error: inspect(error),
cache_key: cache_key
)
1
end
if count >= max_attempts do
Logger.warning("Potential brute force attack detected",
ip_address: ip_address,
attempts: count,
user_id: audit_entry.user_id
)
# Emit security alert
:telemetry.execute(
[:wanderer_app, :security_audit, :threat_detected],
%{count: 1},
%{threat_type: :brute_force, ip_address: ip_address}
)
# Log a security alert event
log_event(:security_alert, audit_entry.user_id, %{
threat_type: "brute_force",
ip_address: ip_address,
failed_attempts: count,
window_seconds: window
})
end
end
:ok
end
defp check_privilege_escalation_attempts(audit_entry) do
config = threat_detection_config()
if config[:enabled] && audit_entry.user_id do
cache_key = "privilege_escalation:#{audit_entry.user_id}"
window = config[:window_seconds] || 300
max_denials = config[:max_permission_denials] || 10
count =
case Cachex.incr(:wanderer_app_cache, cache_key) do
{:ok, count} ->
if count == 1 do
Cachex.expire(:wanderer_app_cache, cache_key, :timer.seconds(window))
end
count
{:error, :no_key} ->
# Key doesn't exist, initialize it with TTL
case Cachex.put(:wanderer_app_cache, cache_key, 1, ttl: :timer.seconds(window)) do
{:ok, _} ->
1
{:error, error} ->
Logger.error("Failed to initialize privilege escalation counter",
error: inspect(error),
cache_key: cache_key
)
1
end
{:error, error} ->
# Other errors - log and return safe default
Logger.error("Failed to increment privilege escalation counter",
error: inspect(error),
cache_key: cache_key
)
1
end
if count >= max_denials do
Logger.warning("Potential privilege escalation attempt detected",
user_id: audit_entry.user_id,
denials: count,
resource_type: audit_entry.details[:resource_type]
)
:telemetry.execute(
[:wanderer_app, :security_audit, :threat_detected],
%{count: 1},
%{threat_type: :privilege_escalation, user_id: audit_entry.user_id}
)
end
end
:ok
end
defp check_bulk_data_access(audit_entry) do
config = threat_detection_config()
if config[:enabled] && audit_entry.user_id do
record_count = audit_entry.details[:record_count] || 0
threshold = config[:bulk_operation_threshold] || 10000
if record_count > threshold do
Logger.warning("Large bulk operation detected",
user_id: audit_entry.user_id,
operation_type: audit_entry.details[:operation_type],
record_count: record_count
)
:telemetry.execute(
[:wanderer_app, :security_audit, :bulk_operation],
%{record_count: record_count},
%{user_id: audit_entry.user_id, operation_type: audit_entry.details[:operation_type]}
)
end
end
:ok
end
defp threat_detection_config do
Application.get_env(:wanderer_app, __MODULE__, [])
|> Keyword.get(:threat_detection, %{})
end
defp determine_severity(event_type) do
case event_type do
:auth_failure -> :medium
:permission_denied -> :high
:privilege_escalation -> :critical
:config_change -> :high
:admin_action -> :medium
:bulk_operation -> :medium
:data_access -> :low
:auth_success -> :low
_ -> :medium
end
end
defp sanitize_sensitive_data(value) when is_binary(value) do
# Patterns to detect sensitive data
sensitive_patterns = [
~r/password/i,
~r/token/i,
~r/secret/i,
~r/api[_-]?key/i,
~r/private[_-]?key/i,
~r/access[_-]?key/i,
~r/auth/i,
~r/bearer\s+[a-zA-Z0-9\-_]+/i,
# Long hex strings (potential tokens)
~r/[a-f0-9]{32,}/i
]
# Check if value contains sensitive patterns
is_sensitive = Enum.any?(sensitive_patterns, &Regex.match?(&1, value))
cond do
is_sensitive -> "[REDACTED]"
String.length(value) > 200 -> String.slice(value, 0, 200) <> "..."
true -> value
end
end
defp sanitize_sensitive_data(value) when is_map(value) do
# Recursively sanitize map values
Map.new(value, fn {k, v} ->
key_str = to_string(k)
if Regex.match?(~r/password|token|secret|key|auth/i, key_str) do
{k, "[REDACTED]"}
else
{k, sanitize_sensitive_data(v)}
end
end)
end
defp sanitize_sensitive_data(value) when is_list(value) do
Enum.map(value, &sanitize_sensitive_data/1)
end
defp sanitize_sensitive_data(value), do: value
end

View File

@@ -0,0 +1,246 @@
defmodule WandererApp.SecurityAudit.AsyncProcessor do
@moduledoc """
GenServer for asynchronous batch processing of security audit events.
This server buffers audit events in memory and periodically flushes them
to the database in batches for improved performance.
"""
use GenServer
require Logger
alias WandererApp.SecurityAudit
@default_batch_size 100
# 5 seconds
@default_flush_interval 5_000
@max_buffer_size 1_000
defstruct [
:batch_size,
:flush_interval,
:buffer,
:timer_ref,
:stats
]
# Client API
@doc """
Start the async processor.
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Log an event asynchronously.
"""
def log_event(audit_entry) do
GenServer.cast(__MODULE__, {:log_event, audit_entry})
end
@doc """
Force a flush of the buffer.
"""
def flush do
GenServer.call(__MODULE__, :flush)
end
@doc """
Get current processor statistics.
"""
def get_stats do
GenServer.call(__MODULE__, :get_stats)
end
# Server callbacks
@impl true
def init(opts) do
config = Application.get_env(:wanderer_app, WandererApp.SecurityAudit, [])
batch_size = Keyword.get(opts, :batch_size, config[:batch_size] || @default_batch_size)
flush_interval =
Keyword.get(opts, :flush_interval, config[:flush_interval] || @default_flush_interval)
state = %__MODULE__{
batch_size: batch_size,
flush_interval: flush_interval,
buffer: [],
timer_ref: nil,
stats: %{
events_processed: 0,
batches_flushed: 0,
errors: 0,
last_flush: nil
}
}
# Schedule first flush
state = schedule_flush(state)
{:ok, state}
end
@impl true
def handle_cast({:log_event, audit_entry}, state) do
# Add to buffer
buffer = [audit_entry | state.buffer]
# Update stats
stats = Map.update!(state.stats, :events_processed, &(&1 + 1))
# Check if we need to flush
cond do
length(buffer) >= state.batch_size ->
# Flush immediately if batch size reached
{:noreply, do_flush(%{state | buffer: buffer, stats: stats})}
length(buffer) >= @max_buffer_size ->
# Force flush if max buffer size reached
Logger.warning("Security audit buffer overflow, forcing flush",
buffer_size: length(buffer),
max_size: @max_buffer_size
)
{:noreply, do_flush(%{state | buffer: buffer, stats: stats})}
true ->
# Just add to buffer
{:noreply, %{state | buffer: buffer, stats: stats}}
end
end
@impl true
def handle_call(:flush, _from, state) do
new_state = do_flush(state)
{:reply, :ok, new_state}
end
@impl true
def handle_call(:get_stats, _from, state) do
stats = Map.put(state.stats, :current_buffer_size, length(state.buffer))
{:reply, stats, state}
end
@impl true
def handle_info(:flush_timer, state) do
state =
if length(state.buffer) > 0 do
do_flush(state)
else
state
end
# Schedule next flush
state = schedule_flush(state)
{:noreply, state}
end
@impl true
def terminate(_reason, state) do
# Flush any remaining events on shutdown
if length(state.buffer) > 0 do
do_flush(state)
end
:ok
end
# Private functions
defp schedule_flush(state) do
# Cancel existing timer if any
if state.timer_ref do
Process.cancel_timer(state.timer_ref)
end
# Schedule new timer
timer_ref = Process.send_after(self(), :flush_timer, state.flush_interval)
%{state | timer_ref: timer_ref}
end
defp do_flush(state) when length(state.buffer) == 0 do
state
end
defp do_flush(state) do
# Take events to flush (reverse to maintain order)
events = Enum.reverse(state.buffer)
# Attempt to store events
case bulk_store_events(events) do
{:ok, count} ->
Logger.debug("Flushed #{count} security audit events")
# Update stats
stats =
state.stats
|> Map.update!(:batches_flushed, &(&1 + 1))
|> Map.put(:last_flush, DateTime.utc_now())
# Clear buffer
%{state | buffer: [], stats: stats}
{:error, reason} ->
Logger.error("Failed to flush security audit events",
reason: inspect(reason),
event_count: length(events)
)
# Update error stats
stats = Map.update!(state.stats, :errors, &(&1 + 1))
# Implement backoff - keep events in buffer but don't grow indefinitely
buffer =
if length(state.buffer) > @max_buffer_size do
Logger.warning("Dropping oldest audit events due to repeated flush failures")
Enum.take(state.buffer, @max_buffer_size)
else
state.buffer
end
%{state | buffer: buffer, stats: stats}
end
end
defp bulk_store_events(events) do
# Process events in smaller chunks if necessary
events
# Ash bulk operations work better with smaller chunks
|> Enum.chunk_every(50)
|> Enum.reduce_while({:ok, 0}, fn chunk, {:ok, count} ->
case store_event_chunk(chunk) do
{:ok, chunk_count} ->
{:cont, {:ok, count + chunk_count}}
{:error, _} = error ->
{:halt, error}
end
end)
end
defp store_event_chunk(events) do
# Transform events to Ash attributes
records =
Enum.map(events, fn event ->
SecurityAudit.do_store_audit_entry(event)
end)
# Count successful stores
successful =
Enum.count(records, fn
:ok -> true
_ -> false
end)
{:ok, successful}
rescue
error ->
{:error, error}
end
end

View File

@@ -195,7 +195,7 @@ defmodule WandererApp.Ueberauth.Strategy.Eve do
tracking_pool = WandererApp.Character.TrackingConfigUtils.get_active_pool!()
base_options = [
redirect_uri: callback_url(conn),
redirect_uri: "#{WandererApp.Env.base_url()}/auth/eve/callback",
with_wallet: with_wallet,
is_admin?: is_admin?,
tracking_pool: tracking_pool

View File

@@ -65,8 +65,7 @@ defmodule WandererAppWeb.CoreComponents do
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="relative z-50 hidden overflow-visible"
class=""
class="relative z-[1000] hidden overflow-visible"
>
<div id={"#{@id}-bg"} class="overflow-visible p-dialog-resizable" aria-hidden="true" />
<div

View File

@@ -52,11 +52,7 @@ defmodule WandererAppWeb.Api.EventsController do
defp establish_sse_connection(conn, map_id, api_key, params) do
# Parse event filter if provided
event_filter =
case Map.get(params, "events") do
nil -> :all
events -> EventFilter.parse(events)
end
event_filter = EventFilter.parse(Map.get(params, "events"))
# Parse format parameter
event_format = Map.get(params, "format", "legacy")
@@ -82,7 +78,7 @@ defmodule WandererAppWeb.Api.EventsController do
send_event(
conn,
%{
id: Ulid.generate(),
id: Ecto.ULID.generate(),
event: "connected",
data: %{
map_id: map_id,

View File

@@ -113,7 +113,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
def index(conn, params) do
with {:ok, map_id} <- APIUtils.fetch_map_id(params),
{:ok, period} <- APIUtils.require_param(params, "period"),
query <- WandererApp.Map.Audit.get_activity_query(map_id, period, "all"),
query <- WandererApp.Map.Audit.get_map_activity_query(map_id, period, "all"),
{:ok, data} <-
Ash.read(query) do
data = Enum.map(data, &map_audit_event_to_json/1)

View File

@@ -2,14 +2,20 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
@moduledoc """
Plug for authenticating JSON:API v1 endpoints.
Supports both session-based authentication (for web clients) and
Supports both session-based authentication (for web clients) and
Bearer token authentication (for API clients).
Currently, Bearer token authentication only supports map API keys.
When a valid map API key is provided, the map owner is set as the
authenticated user and the map is made available in conn.assigns.
"""
import Plug.Conn
alias WandererApp.Api.User
alias WandererApp.SecurityAudit
alias WandererApp.Audit.RequestContext
def init(opts), do: opts
@@ -57,7 +63,8 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|> assign(:current_user, user)
|> assign(:current_user_role, get_user_role(user))
{:error, reason} ->
{:error, reason} when is_binary(reason) ->
# Legacy error handling for simple string errors
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
@@ -82,6 +89,36 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: reason}))
|> halt()
{:error, external_message, internal_reason} ->
# New error handling with separate internal and external messages
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
# Log failed authentication with detailed internal reason
request_details = extract_request_details(conn)
SecurityAudit.log_auth_event(
:auth_failure,
nil,
Map.merge(request_details, %{
failure_reason: internal_reason,
external_message: external_message
})
)
# Emit failed authentication event
:telemetry.execute(
[:wanderer_app, :json_api, :auth],
%{count: 1, duration: duration},
%{auth_type: get_auth_type(conn), result: "failure"}
)
conn
|> put_status(:unauthorized)
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: external_message}))
|> halt()
end
end
@@ -103,8 +140,6 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
defp authenticate_bearer_token(conn) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] ->
# For now, use a simple approach - validate token format
# In the future, this could be extended to support JWT or other token types
validate_api_token(token)
_ ->
@@ -113,48 +148,23 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
end
defp validate_api_token(token) do
# For test environment, accept test API keys
if Application.get_env(:wanderer_app, :env) == :test and
(String.starts_with?(token, "test_") or String.starts_with?(token, "test_api_key_")) do
# For test tokens, look up the actual map by API key
case find_map_by_api_key(token) do
{:ok, map} when not is_nil(map) ->
# Use the actual map owner as the user
user = %User{
id: map.owner_id || Ecto.UUID.generate(),
name: "Test User",
hash: "test_hash_#{System.unique_integer([:positive])}"
}
# Look up the map by its public API key
case find_map_by_api_key(token) do
{:ok, map} when not is_nil(map) ->
# Get the actual owner of the map
case User.by_id(map.owner_id, load: :characters) do
{:ok, user} ->
# Return the map owner as the authenticated user
{:ok, user, map}
{:ok, user, map}
{:error, _} ->
# Return generic error with specific reason for internal logging
{:error, "Authentication failed", :map_owner_not_found}
end
_ ->
# If no map found with this test token, create a test user without a map
user = %User{
id: Ecto.UUID.generate(),
name: "Test User",
hash: "test_hash_#{System.unique_integer([:positive])}"
}
{:ok, user}
end
else
# Look up the map by its public API key
case find_map_by_api_key(token) do
{:ok, map} when not is_nil(map) ->
# Create a user representing API access for this map
# In a real implementation, you might want to track the actual user who created the API key
user = %User{
id: map.owner_id || Ecto.UUID.generate(),
name: "API User for #{map.name}",
hash: "api_hash_#{map.id}"
}
{:ok, user, map}
_ ->
{:error, "Invalid API key"}
end
_ ->
# Return generic error with specific reason for internal logging
{:error, "Authentication failed", :invalid_api_key}
end
end
@@ -192,50 +202,8 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
end
defp extract_request_details(conn) do
%{
ip_address: get_peer_ip(conn),
user_agent: get_user_agent(conn),
auth_method: get_auth_type(conn),
session_id: get_session_id(conn),
request_path: conn.request_path,
method: conn.method
}
end
defp get_peer_ip(conn) do
case get_req_header(conn, "x-forwarded-for") do
[forwarded_for] ->
forwarded_for
|> String.split(",")
|> List.first()
|> String.trim()
[] ->
case get_req_header(conn, "x-real-ip") do
[real_ip] ->
real_ip
[] ->
case conn.remote_ip do
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
_ -> "unknown"
end
end
end
end
defp get_user_agent(conn) do
case get_req_header(conn, "user-agent") do
[user_agent] -> user_agent
[] -> "unknown"
end
end
defp get_session_id(conn) do
case get_session(conn, :session_id) do
nil -> conn.assigns[:request_id] || "unknown"
session_id -> session_id
end
RequestContext.build_request_details(conn)
|> Map.put(:auth_method, get_auth_type(conn))
end
defp maybe_assign_map(conn, nil), do: conn

View File

@@ -15,30 +15,6 @@ defmodule WandererAppWeb.Endpoint do
max_age: 24 * 60 * 60 * 180
]
# @impl SiteEncrypt
# def certification do
# SiteEncrypt.configure(
# client: :native,
# mode: :auto,
# days_to_renew: 30,
# domains: ["dev.wanderer.deadly-w.space"],
# emails: ["dmitriypopovsamara@gmail.com"],
# db_folder: System.get_env("SITE_ENCRYPT_DB", Path.join("tmp", "site_encrypt_db")),
# backup: Path.join(Path.join("tmp", "site_encrypt_db"), "site_encrypt_backup.tgz"),
# directory_url:
# case System.get_env("CERT_MODE", "local") do
# "local" ->
# {:internal, port: 4001}
# "staging" ->
# "https://acme-staging-v02.api.letsencrypt.org/directory"
# "production" ->
# "https://acme-v02.api.letsencrypt.org/directory"
# end
# )
# end
socket "/live", Phoenix.LiveView.Socket,
websocket: [compress: true, connect_info: [session: @session_options]]

Some files were not shown because too many files have changed in this diff Show More