mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-11-29 20:43:23 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cfb663efd | ||
|
|
e5103cc925 | ||
|
|
26458f5a19 | ||
|
|
79d5ec6caf | ||
|
|
034d461ab6 | ||
|
|
2e9c1c170c | ||
|
|
24ad3b2c61 | ||
|
|
288f55dc2f | ||
|
|
78dbea6267 | ||
|
|
6a9e53141d | ||
|
|
05e6994520 | ||
|
|
1a4dc67eb9 | ||
|
|
31d87a116b | ||
|
|
c47796d590 | ||
|
|
c7138a41ee | ||
|
|
96f04c70a9 | ||
|
|
87a8bc09ab | ||
|
|
5f5661d559 | ||
|
|
35ca87790e | ||
|
|
ae43e4a57c | ||
|
|
b91712a01a | ||
|
|
b20007b341 | ||
|
|
6a24e1188b | ||
|
|
5894efc1aa | ||
|
|
a05612d243 | ||
|
|
48de874d6b | ||
|
|
91e6da316f | ||
|
|
fa60bd81a1 | ||
|
|
a08a69c5be | ||
|
|
18d450a41a | ||
|
|
36cdee61c0 | ||
|
|
797e188259 | ||
|
|
91b581668a | ||
|
|
ad01fec28f | ||
|
|
357d3a0df6 | ||
|
|
5ce6022761 | ||
|
|
235a0c5aea | ||
|
|
9b81fa6ebb | ||
|
|
8792d5ab0e | ||
|
|
d46ed0c078 | ||
|
|
73c433fcd2 | ||
|
|
02b5239220 | ||
|
|
0ed3bdfcb0 | ||
|
|
bdeb89011f | ||
|
|
1523b625bc | ||
|
|
fb91eeb692 | ||
|
|
601d2e02cb | ||
|
|
0a662d34eb | ||
|
|
5cd4693e9d | ||
|
|
f3f0f860e3 | ||
|
|
93a5cf8a79 | ||
|
|
7cf15cbc21 | ||
|
|
30bc6d20b2 | ||
|
|
b39f99fde4 | ||
|
|
0e8aa9efa4 | ||
|
|
e1fcde36e3 | ||
|
|
7aafe077d3 | ||
|
|
5b8cab5e76 | ||
|
|
4ab56af40a | ||
|
|
e8cea86a76 | ||
|
|
d0a6e0b358 | ||
|
|
8831b3e970 | ||
|
|
f6db6f0914 | ||
|
|
ab8baeedd1 | ||
|
|
eccee5e72e | ||
|
|
4d93055bda | ||
|
|
c60c16e56a | ||
|
|
99b1de5647 | ||
|
|
7efe11a421 | ||
|
|
954108856a | ||
|
|
cbca745ec4 | ||
|
|
e15e7c8f8d | ||
|
|
65e8a520e5 | ||
|
|
3926af5a6d | ||
|
|
556fb33223 | ||
|
|
82295adeab | ||
|
|
9d7d4fad2e | ||
|
|
74f7ad155d | ||
|
|
f58ebad0ec | ||
|
|
7ca4eb3b8f |
65
.github/workflows/build.yml
vendored
65
.github/workflows/build.yml
vendored
@@ -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
187
.github/workflows/docker-arm.yml
vendored
Normal 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
187
.github/workflows/docker.yml
vendored
Normal 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 }}
|
||||
1729
CHANGELOG.md
1729
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MapUserSettings, SettingsWithVersion } from '@/hooks/Mapper/mapRootProvider/types.ts';
|
||||
|
||||
const REQUIRED_KEYS = [
|
||||
export const REQUIRED_KEYS = [
|
||||
'widgets',
|
||||
'interface',
|
||||
'onTheMap',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './useSystemInfo';
|
||||
export * from './useGetOwnOnlineCharacters';
|
||||
export * from './useElementWidth';
|
||||
export * from './useDetectSettingsChanged';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import './SolarSystemNodeDefault.module.scss';
|
||||
@use './SolarSystemNodeDefault.module.scss';
|
||||
|
||||
/* ---------------------------------------------
|
||||
Only override what's different from the base
|
||||
|
||||
@@ -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++);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
};
|
||||
@@ -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);
|
||||
}, []);
|
||||
};
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@import './eve-common-variables';
|
||||
@import './eve-common';
|
||||
@use './eve-common-variables';
|
||||
@use './eve-common';
|
||||
|
||||
.default-theme {
|
||||
--rf-bg-color: #0C0A09;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import './eve-common-variables';
|
||||
@use './eve-common-variables';
|
||||
|
||||
|
||||
.eve-wh-effect-color-pulsar {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
@import './default-theme.scss';
|
||||
@import './pathfinder-theme.scss';
|
||||
@use './default-theme.scss';
|
||||
@use './pathfinder-theme.scss';
|
||||
@@ -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 -- */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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: '' };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './sortWHClasses';
|
||||
export * from './parseSignatures';
|
||||
export * from './getSystemById';
|
||||
export * from './getEveImageUrl';
|
||||
export * from './toastHelpers';
|
||||
|
||||
28
assets/js/hooks/Mapper/helpers/toastHelpers.ts
Normal file
28
assets/js/hooks/Mapper/helpers/toastHelpers.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -3,3 +3,4 @@ export * from './useHotkey';
|
||||
export * from './usePageVisibility';
|
||||
export * from './useSkipContextMenu';
|
||||
export * from './useThrottle';
|
||||
export * from './useConfirmPopup';
|
||||
|
||||
10
assets/js/hooks/Mapper/hooks/useConfirmPopup.ts
Normal file
10
assets/js/hooks/Mapper/hooks/useConfirmPopup.ts
Normal 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 };
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -85,3 +85,7 @@ export type MapUserSettings = {
|
||||
export type MapUserSettingsStructure = {
|
||||
[mapId: string]: MapUserSettings;
|
||||
};
|
||||
|
||||
export type WdResponse<T> = T;
|
||||
|
||||
export type RemoteAdminSettingsResponse = { default_settings?: string };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -48,6 +48,7 @@ export type SystemSignature = {
|
||||
inserted_at?: string;
|
||||
updated_at?: string;
|
||||
deleted?: boolean;
|
||||
temporary_name?: string;
|
||||
};
|
||||
|
||||
export interface ExtendedSystemSignature extends SystemSignature {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
BIN
assets/static/images/news/2025/07-27-settings/admin_settings.png
Normal file
BIN
assets/static/images/news/2025/07-27-settings/admin_settings.png
Normal file
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 |
5377
assets/yarn.lock
5377
assets/yarn.lock
File diff suppressed because it is too large
Load Diff
82
clean_changelog.py
Normal file
82
clean_changelog.py
Normal 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!")
|
||||
@@ -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",
|
||||
|
||||
@@ -27,5 +27,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
145
lib/wanderer_app/api/map_default_settings.ex
Normal file
145
lib/wanderer_app/api/map_default_settings.ex
Normal 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
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
150
lib/wanderer_app/audit/request_context.ex
Normal file
150
lib/wanderer_app/audit/request_context.ex
Normal 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
|
||||
@@ -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(),
|
||||
|
||||
@@ -100,7 +100,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
duration = DateTime.diff(DateTime.utc_now(), error_time, :millisecond)
|
||||
|
||||
if duration >= timeout do
|
||||
pause_tracking(character_id)
|
||||
# pause_tracking(character_id)
|
||||
|
||||
:ok
|
||||
else
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -97,7 +97,7 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
:locked,
|
||||
# ADD
|
||||
:temporary_name,
|
||||
# ADD
|
||||
# ADD
|
||||
:labels,
|
||||
# ADD
|
||||
:description,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
898
lib/wanderer_app/security_audit.ex
Normal file
898
lib/wanderer_app/security_audit.ex
Normal 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
|
||||
246
lib/wanderer_app/security_audit/async_processor.ex
Normal file
246
lib/wanderer_app/security_audit/async_processor.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,25 +80,73 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
|
||||
current_user: %{id: current_user_id},
|
||||
main_character_id: main_character_id,
|
||||
has_tracked_characters?: true,
|
||||
map_user_settings: map_user_settings,
|
||||
user_permissions: %{delete_connection: true}
|
||||
}
|
||||
} =
|
||||
socket
|
||||
)
|
||||
when not is_nil(main_character_id) do
|
||||
solar_system_source_id = solar_system_source_id |> String.to_integer()
|
||||
solar_system_target_id = solar_system_target_id |> String.to_integer()
|
||||
|
||||
map_id
|
||||
|> WandererApp.Map.Server.delete_connection(%{
|
||||
solar_system_source_id: solar_system_source_id |> String.to_integer(),
|
||||
solar_system_target_id: solar_system_target_id |> String.to_integer()
|
||||
solar_system_source_id: solar_system_source_id,
|
||||
solar_system_target_id: solar_system_target_id
|
||||
})
|
||||
|
||||
delete_connection_with_sigs =
|
||||
map_user_settings
|
||||
|> WandererApp.MapUserSettingsRepo.to_form_data!()
|
||||
|> WandererApp.MapUserSettingsRepo.get_boolean_setting("delete_connection_with_sigs")
|
||||
|
||||
if delete_connection_with_sigs do
|
||||
target_system =
|
||||
WandererApp.Map.find_system_by_location(
|
||||
map_id,
|
||||
%{solar_system_id: solar_system_target_id}
|
||||
)
|
||||
|
||||
if not is_nil(target_system.linked_sig_eve_id) do
|
||||
{:ok, signatures} =
|
||||
WandererApp.Api.MapSystemSignature.by_linked_system_id(solar_system_target_id)
|
||||
|
||||
signatures
|
||||
|> Enum.each(fn s ->
|
||||
if not is_nil(s.temporary_name) && s.temporary_name == target_system.temporary_name do
|
||||
map_id
|
||||
|> WandererApp.Map.Server.update_system_temporary_name(%{
|
||||
solar_system_id: solar_system_target_id,
|
||||
temporary_name: nil
|
||||
})
|
||||
end
|
||||
|
||||
map_id
|
||||
|> WandererApp.Map.Server.update_system_linked_sig_eve_id(%{
|
||||
solar_system_id: solar_system_target_id,
|
||||
linked_sig_eve_id: nil
|
||||
})
|
||||
|
||||
s
|
||||
|> WandererApp.Api.MapSystemSignature.destroy!()
|
||||
end)
|
||||
|
||||
WandererApp.Map.Server.Impl.broadcast!(
|
||||
map_id,
|
||||
:signatures_updated,
|
||||
solar_system_source_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, _} =
|
||||
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
|
||||
character_id: main_character_id,
|
||||
user_id: current_user_id,
|
||||
map_id: map_id,
|
||||
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
|
||||
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer()
|
||||
solar_system_source_id: solar_system_source_id,
|
||||
solar_system_target_id: solar_system_target_id
|
||||
})
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
@@ -227,6 +227,61 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
})}
|
||||
end
|
||||
|
||||
def handle_ui_event(
|
||||
"save_default_settings",
|
||||
%{"settings" => settings},
|
||||
%{
|
||||
assigns: %{
|
||||
map_id: map_id,
|
||||
current_user: current_user,
|
||||
user_permissions: user_permissions
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
# Check if user is map admin
|
||||
if user_permissions.admin_map do
|
||||
case save_default_settings(map_id, settings, current_user) do
|
||||
{:ok, _default_settings} ->
|
||||
{:reply, %{success: true}, socket}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to save default settings: #{inspect(reason)}")
|
||||
|
||||
error_message =
|
||||
case reason do
|
||||
%Ash.Error.Invalid{} = error ->
|
||||
errors = Ash.Error.to_error_class(error)
|
||||
"Validation error: #{inspect(errors)}"
|
||||
|
||||
:no_character ->
|
||||
"No character found for user"
|
||||
|
||||
_ ->
|
||||
"Failed to save default settings: #{inspect(reason)}"
|
||||
end
|
||||
|
||||
{:reply, %{success: false, error: error_message},
|
||||
socket |> put_flash(:error, error_message)}
|
||||
end
|
||||
else
|
||||
{:reply, %{success: false, error: "unauthorized"}, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_ui_event(
|
||||
"get_default_settings",
|
||||
_,
|
||||
%{assigns: %{map_id: map_id}} = socket
|
||||
) do
|
||||
case WandererApp.Api.MapDefaultSettings.get_by_map_id(%{map_id: map_id}) do
|
||||
{:ok, [default_settings | _]} ->
|
||||
{:reply, %{default_settings: default_settings.settings}, socket}
|
||||
|
||||
_ ->
|
||||
{:reply, %{default_settings: nil}, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_ui_event("noop", _, socket), do: {:noreply, socket}
|
||||
|
||||
def handle_ui_event(
|
||||
@@ -262,6 +317,38 @@ defmodule WandererAppWeb.MapCoreEventHandler do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp save_default_settings(map_id, settings, current_user) do
|
||||
# Find the character to use as actor
|
||||
actor =
|
||||
case current_user.characters do
|
||||
[character | _] -> character
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
if actor do
|
||||
case WandererApp.Api.MapDefaultSettings.get_by_map_id(%{map_id: map_id}) do
|
||||
{:ok, [existing | _]} ->
|
||||
result =
|
||||
WandererApp.Api.MapDefaultSettings.update(existing, %{settings: settings},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
error ->
|
||||
result =
|
||||
WandererApp.Api.MapDefaultSettings.create(%{map_id: map_id, settings: settings},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
result
|
||||
end
|
||||
else
|
||||
Logger.error("No character found for user #{current_user.id}")
|
||||
{:error, :no_character}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_start_map(map_id) do
|
||||
{:ok, map_server_started} = WandererApp.Cache.lookup("map_#{map_id}:started", false)
|
||||
|
||||
|
||||
@@ -179,42 +179,50 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
} = socket
|
||||
)
|
||||
when not is_nil(main_character_id) do
|
||||
solar_system_source = get_integer(solar_system_source)
|
||||
solar_system_target = get_integer(solar_system_target)
|
||||
with solar_system_source <- get_integer(solar_system_source),
|
||||
solar_system_target <- get_integer(solar_system_target),
|
||||
{:ok, source_system} <-
|
||||
WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_source
|
||||
}),
|
||||
signature <-
|
||||
WandererApp.Api.MapSystemSignature.by_system_id!(source_system.id)
|
||||
|> Enum.find(fn s -> s.eve_id == signature_eve_id end),
|
||||
target_system <-
|
||||
WandererApp.Map.find_system_by_location(
|
||||
map_id,
|
||||
%{solar_system_id: solar_system_target}
|
||||
) do
|
||||
if not is_nil(signature) do
|
||||
signature
|
||||
|> WandererApp.Api.MapSystemSignature.update_group!(%{group: "Wormhole"})
|
||||
|> WandererApp.Api.MapSystemSignature.update_linked_system(%{
|
||||
linked_system_id: solar_system_target
|
||||
})
|
||||
|
||||
case WandererApp.Api.MapSystem.read_by_map_and_solar_system(%{
|
||||
map_id: map_id,
|
||||
solar_system_id: solar_system_source
|
||||
}) do
|
||||
{:ok, system} ->
|
||||
WandererApp.Api.MapSystemSignature.by_system_id!(system.id)
|
||||
|> Enum.filter(fn s -> s.eve_id == signature_eve_id end)
|
||||
|> Enum.each(fn s ->
|
||||
s
|
||||
|> WandererApp.Api.MapSystemSignature.update_group!(%{group: "Wormhole"})
|
||||
|> WandererApp.Api.MapSystemSignature.update_linked_system(%{
|
||||
linked_system_id: solar_system_target
|
||||
})
|
||||
end)
|
||||
|
||||
map_system =
|
||||
WandererApp.Map.find_system_by_location(
|
||||
map_id,
|
||||
%{solar_system_id: solar_system_target}
|
||||
)
|
||||
|
||||
if not is_nil(map_system) && is_nil(map_system.linked_sig_eve_id) do
|
||||
if not is_nil(target_system) &&
|
||||
is_nil(target_system.linked_sig_eve_id) do
|
||||
map_id
|
||||
|> WandererApp.Map.Server.update_system_linked_sig_eve_id(%{
|
||||
solar_system_id: solar_system_target,
|
||||
linked_sig_eve_id: signature_eve_id
|
||||
})
|
||||
|
||||
if not is_nil(signature.temporary_name) do
|
||||
map_id
|
||||
|> WandererApp.Map.Server.update_system_temporary_name(%{
|
||||
solar_system_id: solar_system_target,
|
||||
temporary_name: signature.temporary_name
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_source)
|
||||
|
||||
{:noreply, socket}
|
||||
WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_source)
|
||||
|
||||
{:noreply, socket}
|
||||
else
|
||||
_ ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
@@ -320,6 +328,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
||||
:eve_id,
|
||||
:character_eve_id,
|
||||
:name,
|
||||
:temporary_name,
|
||||
:description,
|
||||
:kind,
|
||||
:group,
|
||||
|
||||
@@ -153,7 +153,7 @@ defmodule WandererAppWeb.MapAuditLive do
|
||||
} =
|
||||
socket.assigns
|
||||
|
||||
query = WandererApp.Map.Audit.get_activity_query(map_id, period, activity)
|
||||
query = WandererApp.Map.Audit.get_map_activity_query(map_id, period, activity)
|
||||
|
||||
AshPagify.validate_and_run(query, params, opts)
|
||||
|> case do
|
||||
|
||||
@@ -51,146 +51,148 @@
|
||||
<.icon name="hero-user-group-solid" class="w-6 h-6" />
|
||||
</button>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<.modal
|
||||
:if={@show_topup}
|
||||
title="Map Subscription Info"
|
||||
class="!min-w-[700px]"
|
||||
id="map-topup-modal"
|
||||
show
|
||||
on_cancel={JS.navigate(~p"/#{@map_slug}")}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="verticalTabsContainer">
|
||||
<div class="p-tabview p-component" data-pc-name="tabview" data-pc-section="root">
|
||||
<div class="p-tabview-nav-container" data-pc-section="navcontainer">
|
||||
<div class="p-tabview-nav-content" data-pc-section="navcontent">
|
||||
<ul class="p-tabview-nav" role="tablist" data-pc-section="nav">
|
||||
<li
|
||||
class={[
|
||||
"p-unselectable-text",
|
||||
classes(
|
||||
"p-tabview-selected p-highlight": @active_subscription_tab == "balance"
|
||||
)
|
||||
]}
|
||||
role="presentation"
|
||||
data-pc-name=""
|
||||
data-pc-section="header"
|
||||
>
|
||||
<a
|
||||
role="tab"
|
||||
class="p-tabview-nav-link flex p-[10px]"
|
||||
tabindex="-1"
|
||||
aria-controls="pr_id_332_content"
|
||||
aria-selected="false"
|
||||
aria-disabled="false"
|
||||
data-pc-section="headeraction"
|
||||
phx-click="change_subscription_tab"
|
||||
phx-value-tab="balance"
|
||||
<.modal
|
||||
:if={@show_topup}
|
||||
title="Map Subscription Info"
|
||||
class="!min-w-[700px] !z-[10000]"
|
||||
id="map-topup-modal"
|
||||
show
|
||||
on_cancel={JS.navigate(~p"/#{@map_slug}")}
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="verticalTabsContainer">
|
||||
<div class="p-tabview p-component" data-pc-name="tabview" data-pc-section="root">
|
||||
<div class="p-tabview-nav-container" data-pc-section="navcontainer">
|
||||
<div class="p-tabview-nav-content" data-pc-section="navcontent">
|
||||
<ul class="p-tabview-nav" role="tablist" data-pc-section="nav">
|
||||
<li
|
||||
class={[
|
||||
"p-unselectable-text",
|
||||
classes(
|
||||
"p-tabview-selected p-highlight": @active_subscription_tab == "balance"
|
||||
)
|
||||
]}
|
||||
role="presentation"
|
||||
data-pc-name=""
|
||||
data-pc-section="header"
|
||||
>
|
||||
<span class="p-tabview-title" data-pc-section="headertitle">
|
||||
<.icon name="hero-banknotes-solid" class="w-4 h-4" /> Balance
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
:if={@map_subscriptions_enabled?}
|
||||
class={[
|
||||
"p-unselectable-text",
|
||||
classes(
|
||||
"p-tabview-selected p-highlight": @active_subscription_tab == "subscription"
|
||||
)
|
||||
]}
|
||||
role="presentation"
|
||||
data-pc-name=""
|
||||
data-pc-section="header"
|
||||
>
|
||||
<a
|
||||
role="tab"
|
||||
class="p-tabview-nav-link flex p-[10px]"
|
||||
tabindex="-1"
|
||||
aria-controls="pr_id_334_content"
|
||||
aria-selected="false"
|
||||
aria-disabled="false"
|
||||
data-pc-section="headeraction"
|
||||
phx-click="change_subscription_tab"
|
||||
phx-value-tab="subscription"
|
||||
<a
|
||||
role="tab"
|
||||
class="p-tabview-nav-link flex p-[10px]"
|
||||
tabindex="-1"
|
||||
aria-controls="pr_id_332_content"
|
||||
aria-selected="false"
|
||||
aria-disabled="false"
|
||||
data-pc-section="headeraction"
|
||||
phx-click="change_subscription_tab"
|
||||
phx-value-tab="balance"
|
||||
>
|
||||
<span class="p-tabview-title" data-pc-section="headertitle">
|
||||
<.icon name="hero-banknotes-solid" class="w-4 h-4" /> Balance
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
:if={@map_subscriptions_enabled?}
|
||||
class={[
|
||||
"p-unselectable-text",
|
||||
classes(
|
||||
"p-tabview-selected p-highlight":
|
||||
@active_subscription_tab == "subscription"
|
||||
)
|
||||
]}
|
||||
role="presentation"
|
||||
data-pc-name=""
|
||||
data-pc-section="header"
|
||||
>
|
||||
<span class="p-tabview-title" data-pc-section="headertitle">
|
||||
<.icon name="hero-check-badge-solid" class="w-4 h-4" /> Subscription
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
class={[
|
||||
"p-unselectable-text",
|
||||
classes("p-tabview-selected p-highlight": false)
|
||||
]}
|
||||
role="presentation"
|
||||
data-pc-name=""
|
||||
data-pc-section="header"
|
||||
>
|
||||
<a
|
||||
role="tab"
|
||||
class="p-tabview-nav-link flex p-[10px]"
|
||||
tabindex="-1"
|
||||
aria-controls="pr_id_332_content"
|
||||
aria-selected="false"
|
||||
aria-disabled="false"
|
||||
data-pc-section="headeraction"
|
||||
phx-click="change_settings_tab"
|
||||
phx-value-tab="balance"
|
||||
<a
|
||||
role="tab"
|
||||
class="p-tabview-nav-link flex p-[10px]"
|
||||
tabindex="-1"
|
||||
aria-controls="pr_id_334_content"
|
||||
aria-selected="false"
|
||||
aria-disabled="false"
|
||||
data-pc-section="headeraction"
|
||||
phx-click="change_subscription_tab"
|
||||
phx-value-tab="subscription"
|
||||
>
|
||||
<span class="p-tabview-title" data-pc-section="headertitle">
|
||||
<.icon name="hero-check-badge-solid" class="w-4 h-4" /> Subscription
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
class={[
|
||||
"p-unselectable-text",
|
||||
classes("p-tabview-selected p-highlight": false)
|
||||
]}
|
||||
role="presentation"
|
||||
data-pc-name=""
|
||||
data-pc-section="header"
|
||||
>
|
||||
<span class="p-tabview-title" data-pc-section="headertitle">
|
||||
<.icon name="hero-arrow-up-solid" class="w-4 h-4" /> Top Donators
|
||||
<span class="badge">coming soon</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
role="tab"
|
||||
class="p-tabview-nav-link flex p-[10px]"
|
||||
tabindex="-1"
|
||||
aria-controls="pr_id_332_content"
|
||||
aria-selected="false"
|
||||
aria-disabled="false"
|
||||
data-pc-section="headeraction"
|
||||
phx-click="change_settings_tab"
|
||||
phx-value-tab="balance"
|
||||
>
|
||||
<span class="p-tabview-title" data-pc-section="headertitle">
|
||||
<.icon name="hero-arrow-up-solid" class="w-4 h-4" /> Top Donators
|
||||
<span class="badge">coming soon</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-tabview-panels" data-pc-section="panelcontainer">
|
||||
<div
|
||||
id="pr_id_330_content"
|
||||
class="p-tabview-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="pr_id_33_header_0"
|
||||
data-pc-name=""
|
||||
data-pc-section="content"
|
||||
>
|
||||
<.live_component
|
||||
:if={
|
||||
@active_subscription_tab == "balance" && not is_nil(assigns |> Map.get(:map_id))
|
||||
}
|
||||
module={WandererAppWeb.Maps.MapBalanceComponent}
|
||||
id="map-balance-component"
|
||||
map_id={@map_id}
|
||||
notify_to={self()}
|
||||
event_name="balance_event"
|
||||
current_user={@current_user}
|
||||
/>
|
||||
<div class="p-tabview-panels" data-pc-section="panelcontainer">
|
||||
<div
|
||||
id="pr_id_330_content"
|
||||
class="p-tabview-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="pr_id_33_header_0"
|
||||
data-pc-name=""
|
||||
data-pc-section="content"
|
||||
>
|
||||
<.live_component
|
||||
:if={
|
||||
@active_subscription_tab == "balance" &&
|
||||
not is_nil(assigns |> Map.get(:map_id))
|
||||
}
|
||||
module={WandererAppWeb.Maps.MapBalanceComponent}
|
||||
id="map-balance-component"
|
||||
map_id={@map_id}
|
||||
notify_to={self()}
|
||||
event_name="balance_event"
|
||||
current_user={@current_user}
|
||||
/>
|
||||
|
||||
<.live_component
|
||||
:if={@active_subscription_tab == "subscription"}
|
||||
module={WandererAppWeb.Maps.MapSubscriptionsComponent}
|
||||
id="map-subscriptions-component"
|
||||
map_id={@map_id}
|
||||
notify_to={self()}
|
||||
event_name="subscriptions_event"
|
||||
current_user={@current_user}
|
||||
readonly={
|
||||
(@user_permissions || %{}) |> Map.get(:delete_map, false) |> Kernel.not()
|
||||
}
|
||||
/>
|
||||
<.live_component
|
||||
:if={@active_subscription_tab == "subscription"}
|
||||
module={WandererAppWeb.Maps.MapSubscriptionsComponent}
|
||||
id="map-subscriptions-component"
|
||||
map_id={@map_id}
|
||||
notify_to={self()}
|
||||
event_name="subscriptions_event"
|
||||
current_user={@current_user}
|
||||
readonly={
|
||||
(@user_permissions || %{}) |> Map.get(:delete_map, false) |> Kernel.not()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action"></div>
|
||||
</.modal>
|
||||
<div class="modal-action"></div>
|
||||
</.modal>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
||||
import Plug.Conn
|
||||
|
||||
alias WandererApp.SecurityAudit
|
||||
alias WandererApp.Audit.RequestContext
|
||||
|
||||
@supported_versions ["1"]
|
||||
@default_version "1"
|
||||
@@ -260,14 +261,13 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
||||
|
||||
defp log_deprecation_usage(conn, version) do
|
||||
user_id = get_user_id(conn)
|
||||
request_details = RequestContext.build_request_details(conn)
|
||||
|
||||
SecurityAudit.log_event(:deprecated_api_usage, user_id, %{
|
||||
version: version,
|
||||
path: conn.request_path,
|
||||
method: conn.method,
|
||||
user_agent: get_user_agent(conn),
|
||||
ip_address: get_peer_ip(conn)
|
||||
})
|
||||
SecurityAudit.log_event(
|
||||
:deprecated_api_usage,
|
||||
user_id,
|
||||
Map.put(request_details, :version, version)
|
||||
)
|
||||
|
||||
conn
|
||||
end
|
||||
@@ -316,12 +316,15 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
||||
|
||||
# Error handling
|
||||
defp handle_version_error(conn, reason, _opts) do
|
||||
SecurityAudit.log_event(:api_version_error, get_user_id(conn), %{
|
||||
reason: reason,
|
||||
path: conn.request_path,
|
||||
method: conn.method,
|
||||
headers: get_version_headers(conn)
|
||||
})
|
||||
request_details = RequestContext.build_request_details(conn)
|
||||
|
||||
SecurityAudit.log_event(
|
||||
:api_version_error,
|
||||
get_user_id(conn),
|
||||
request_details
|
||||
|> Map.put(:reason, reason)
|
||||
|> Map.put(:headers, get_version_headers(conn))
|
||||
)
|
||||
|
||||
conn
|
||||
|> send_version_error(400, "Invalid API version", %{
|
||||
@@ -376,35 +379,6 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
||||
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_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_version_headers(conn) do
|
||||
%{
|
||||
"api-version" => get_req_header(conn, "api-version"),
|
||||
@@ -429,8 +403,6 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
||||
end
|
||||
|
||||
defp get_breaking_changes(from_version, to_version) do
|
||||
# Define breaking changes between versions
|
||||
# Since we've consolidated to v1, most legacy versions are no longer supported
|
||||
%{
|
||||
{"1.0", "1"} => [
|
||||
"All API endpoints now use /api/v1/ prefix",
|
||||
|
||||
@@ -14,6 +14,7 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
||||
import Plug.Conn
|
||||
|
||||
alias WandererApp.SecurityAudit
|
||||
alias WandererApp.Audit.RequestContext
|
||||
|
||||
# 10MB
|
||||
@max_request_size 10 * 1024 * 1024
|
||||
@@ -344,13 +345,13 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
||||
# Log security threat
|
||||
user_id = get_user_id(conn)
|
||||
|
||||
SecurityAudit.log_event(:security_alert, user_id, %{
|
||||
threats: threats,
|
||||
ip_address: get_peer_ip(conn),
|
||||
user_agent: get_user_agent(conn),
|
||||
request_path: conn.request_path,
|
||||
method: conn.method
|
||||
})
|
||||
request_details = RequestContext.build_request_details(conn)
|
||||
|
||||
SecurityAudit.log_event(
|
||||
:security_alert,
|
||||
user_id,
|
||||
Map.put(request_details, :threats, threats)
|
||||
)
|
||||
|
||||
conn
|
||||
|> send_validation_error(400, "Malicious content detected", %{
|
||||
@@ -457,35 +458,6 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
||||
end
|
||||
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 send_validation_error(conn, status, message, details) do
|
||||
error_response = %{
|
||||
error: message,
|
||||
@@ -504,14 +476,15 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
||||
# Log the validation error
|
||||
user_id = get_user_id(conn)
|
||||
|
||||
SecurityAudit.log_event(:security_alert, user_id, %{
|
||||
error: "validation_error",
|
||||
message: Exception.message(error),
|
||||
ip_address: get_peer_ip(conn),
|
||||
user_agent: get_user_agent(conn),
|
||||
request_path: conn.request_path,
|
||||
method: conn.method
|
||||
})
|
||||
request_details = RequestContext.build_request_details(conn)
|
||||
|
||||
SecurityAudit.log_event(
|
||||
:security_alert,
|
||||
user_id,
|
||||
request_details
|
||||
|> Map.put(:error, "validation_error")
|
||||
|> Map.put(:message, Exception.message(error))
|
||||
)
|
||||
|
||||
conn
|
||||
|> send_validation_error(500, "Request validation failed", %{
|
||||
|
||||
@@ -31,8 +31,6 @@ defmodule WandererAppWeb.Presence do
|
||||
character_id
|
||||
end)
|
||||
|
||||
WandererApp.Cache.insert("map_#{map_id}:presence_updated", true)
|
||||
|
||||
WandererApp.Cache.insert(
|
||||
"map_#{map_id}:presence_character_ids",
|
||||
presence_tracked_character_ids
|
||||
@@ -43,6 +41,8 @@ defmodule WandererAppWeb.Presence do
|
||||
presence_data
|
||||
)
|
||||
|
||||
WandererApp.Cache.insert("map_#{map_id}:presence_updated", true)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
2
mix.exs
2
mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
|
||||
|
||||
@source_url "https://github.com/wanderer-industries/wanderer"
|
||||
|
||||
@version "1.75.1"
|
||||
@version "1.76.3"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
||||
@@ -21,8 +21,9 @@ As part of the Wanderer platform, a public API has been introduced to help users
|
||||
## Authentication
|
||||
|
||||
Each request to the Wanderer APIs that being with /api/map must include a valid API key in the `Authorization` header. The format is:
|
||||
|
||||
Authorization: Bearer <YOUR_MAP_API_KEY>
|
||||
```
|
||||
Authorization: Bearer <YOUR_MAP_API_KEY>
|
||||
```
|
||||
|
||||
If the API key is missing or incorrect, you'll receive a `401 Unauthorized` response.
|
||||
|
||||
|
||||
136
priv/posts/2025/07-27-map-default-settings.md
Normal file
136
priv/posts/2025/07-27-map-default-settings.md
Normal file
@@ -0,0 +1,136 @@
|
||||
%{
|
||||
title: "New Feature: Map Default Settings",
|
||||
author: "Wanderer Team",
|
||||
cover_image_uri: "/images/news/2025/07-27-settings/common_settings.png",
|
||||
tags: ~w(feature settings maps customization admin),
|
||||
description: "Map administrators can now configure default settings for new users, providing a customized initial experience for each map."
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
# Introducing Map Default Settings
|
||||
|
||||
## A Better First Experience for Your Map Users
|
||||
|
||||
We're excited to announce a new feature that gives map administrators more control over the user experience: **Map Default Settings**. This feature allows map admins to configure the default settings that new users will receive when they first access a map.
|
||||
|
||||
---
|
||||
|
||||
## The Challenge
|
||||
|
||||
Previously, all new users would start with the same hardcoded default settings, regardless of the map they were joining. This one-size-fits-all approach meant that:
|
||||
|
||||
- New users often had to spend time configuring settings to match the map's intended use
|
||||
- Map administrators couldn't optimize the initial experience for their specific mapping needs
|
||||
- Training new members required explaining which settings to change
|
||||
|
||||
Different mapping groups have different needs. A wormhole mapping corporation might want different default widget layouts than a nullsec alliance. A small gang PvP group might prioritize different information than an exploration-focused team.
|
||||
|
||||
---
|
||||
|
||||
## The Solution: Customizable Default Settings
|
||||
|
||||
With Map Default Settings, administrators can now:
|
||||
|
||||
1. **Configure their ideal settings** - Set up the map interface exactly as they want new users to experience it
|
||||
2. **Save as defaults** - With a single click, save these settings as the default for all new users
|
||||
3. **Provide consistency** - Ensure all new members start with the same optimized configuration
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### For Map Administrators
|
||||
|
||||
Setting up default settings is simple:
|
||||
|
||||
1. Configure your map settings to your preferred state
|
||||
2. Open the Settings dialog and navigate to the 'Admin Settings' tab
|
||||
4. Click "Save as Map Default"
|
||||
|
||||
That's it! Your current settings are now saved as the default for any new user accessing your map.
|
||||
|
||||

|
||||
|
||||
### For New Users
|
||||
|
||||
When a user accesses your map for the first time:
|
||||
|
||||
1. The system checks if they have existing settings for this map
|
||||
2. If not, it automatically loads the admin-configured defaults
|
||||
3. The user starts with an optimized configuration from day one
|
||||
4. They can still customize settings to their personal preferences
|
||||
|
||||
---
|
||||
|
||||
## Sync with Default Settings
|
||||
|
||||
We've also added a complementary feature: **Sync with Default Settings**. Any user can now reset their settings back to the map's default configuration:
|
||||
|
||||
1. Open Settings → Server Settings tab
|
||||
2. Click "Sync with Default Settings"
|
||||
3. Confirm the action
|
||||
4. Settings are restored to:
|
||||
- Admin-configured defaults (if available)
|
||||
|
||||
This is perfect for users who want to start fresh or have accidentally misconfigured their settings.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## What Can Be Configured?
|
||||
|
||||
Map default settings include all user interface preferences:
|
||||
|
||||
- **Widget Settings** - Which widgets are visible and their default positions
|
||||
- **Kill Tracking** - Default kill display options and filters
|
||||
- **Local Characters** - How local pilots are displayed
|
||||
- **Signatures** - Default signature widget configuration
|
||||
- **Routes** - Route planning preferences
|
||||
- **Map Display** - Visual preferences for the map itself
|
||||
- **Interface Options** - General UI preferences
|
||||
|
||||
---
|
||||
|
||||
## Security and Permissions
|
||||
|
||||
- Only map owners and administrators can save default settings
|
||||
- All users can read and apply default settings
|
||||
- Settings are validated to ensure they contain only UI preferences
|
||||
- No sensitive data is stored in default settings
|
||||
|
||||
---
|
||||
|
||||
## Coming From Other Mapping Tools?
|
||||
|
||||
If you're migrating from another mapping tool, you can now configure Wanderer to feel familiar to your members:
|
||||
1. Set up the interface to match your previous tool's layout
|
||||
2. Save as default settings
|
||||
3. New members will find a familiar interface waiting for them
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Get Started Today
|
||||
|
||||
Map administrators can start using this feature immediately:
|
||||
|
||||
1. Configure your ideal settings
|
||||
2. Save them as defaults
|
||||
3. Share your map with confidence that new users will have a great first experience
|
||||
|
||||
We hope this feature helps you create more cohesive and efficient mapping teams. As always, we welcome your feedback and suggestions for future improvements.
|
||||
|
||||
---
|
||||
|
||||
## Thank You
|
||||
|
||||
Special thanks to our community members who suggested this feature and helped test it during development. Your feedback continues to shape Wanderer into the best mapping tool for EVE Online.
|
||||
|
||||
----
|
||||
|
||||
Fly safe,
|
||||
**The Wanderer Team**
|
||||
|
||||
----
|
||||
@@ -254,7 +254,7 @@ groupID,categoryID,groupName,iconID,useBasePrice,anchored,anchorable,fittableNon
|
||||
364,23,Mobile Storage,0,0,0,1,0,0
|
||||
365,23,Control Tower,0,1,0,1,0,1
|
||||
366,2,Warp Gate,0,0,1,0,0,0
|
||||
367,7,Ballistic Control system,0,0,0,0,0,1
|
||||
367,7,Ballistic Control System,0,0,0,0,0,1
|
||||
368,2,Global Warp Disruptor,0,0,1,0,0,0
|
||||
369,17,Ship Logs,0,1,0,0,0,1
|
||||
370,17,Criminal Tags,0,1,0,0,0,1
|
||||
@@ -1505,7 +1505,7 @@ groupID,categoryID,groupName,iconID,useBasePrice,anchored,anchorable,fittableNon
|
||||
4757,25,Ueganite,15,0,1,0,0,1
|
||||
4758,25,Hezorime,15,0,1,0,0,1
|
||||
4759,25,Griemeer,15,0,1,0,0,1
|
||||
4768,39,Sovereignty Hub Anomaly Detection Upgrades,None,1,0,0,0,1
|
||||
4768,39,Sovereignty Hub Site Detection Upgrades,None,1,0,0,0,1
|
||||
4769,7,Capital Mobility Modules,None,0,0,0,0,1
|
||||
4771,11,Homefront Operations Noncombatant,None,0,0,0,0,0
|
||||
4772,39,Sovereignty Hub Service Infrastructure Upgrade,None,1,0,0,0,1
|
||||
@@ -1533,8 +1533,11 @@ groupID,categoryID,groupName,iconID,useBasePrice,anchored,anchorable,fittableNon
|
||||
4825,2,Local Beacon,None,0,1,0,0,0
|
||||
4827,17,EDENCOM Data,None,1,0,0,0,1
|
||||
4828,2,Pirate Spawners,None,0,0,0,0,0
|
||||
4838,39,Sovereignty Hub Colony Resources Management Upgrades,None,1,0,0,0,1
|
||||
4839,39,Sovereignty Hub System Effect Generator Upgrades,None,1,0,0,0,1
|
||||
4843,17,Limited Rarities,None,1,0,0,0,1
|
||||
4857,25,Tyranite,15,0,1,0,0,1
|
||||
4900,17,Stability Telemetry,None,1,0,0,0,1
|
||||
350858,350001,Infantry Weapons,None,1,0,0,0,0
|
||||
351064,350001,Infantry Dropsuits,None,1,0,0,0,0
|
||||
351121,350001,Infantry Modules,None,1,0,0,0,0
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user