mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-11-30 04:53:24 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507b3289c7 | ||
|
|
9e1dfc48d5 | ||
|
|
518cbc7b5d | ||
|
|
ccc8db0620 | ||
|
|
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 | ||
|
|
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,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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -- */
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ConfirmPopup } from 'primereact/confirmpopup';
|
||||
import { useCallback, useRef } 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,11 +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 { useConfirmPopup } from '@/hooks/Mapper/hooks';
|
||||
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 {
|
||||
@@ -41,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),
|
||||
@@ -118,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]);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -830,7 +830,8 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
expires_at,
|
||||
scopes
|
||||
) do
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at, :second)
|
||||
expires_at_datetime = DateTime.from_unix!(expires_at)
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)
|
||||
|
||||
Logger.warning("TOKEN_REFRESH_FAILED: Invalid grant error during token refresh",
|
||||
character_id: character_id,
|
||||
@@ -857,7 +858,8 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
expires_at,
|
||||
scopes
|
||||
) do
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at, :second)
|
||||
expires_at_datetime = DateTime.from_unix!(expires_at)
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)
|
||||
|
||||
Logger.warning("TOKEN_REFRESH_FAILED: Connection refused during token refresh",
|
||||
character_id: character_id,
|
||||
|
||||
@@ -51,7 +51,7 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
def new(map_id, event_type, payload) when is_binary(map_id) and is_map(payload) do
|
||||
if valid_event_type?(event_type) do
|
||||
%__MODULE__{
|
||||
id: Ulid.generate(System.system_time(:millisecond)),
|
||||
id: Ecto.ULID.generate(System.system_time(:millisecond)),
|
||||
map_id: map_id,
|
||||
type: event_type,
|
||||
payload: payload,
|
||||
@@ -97,7 +97,7 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
:locked,
|
||||
# ADD
|
||||
:temporary_name,
|
||||
# ADD
|
||||
# ADD
|
||||
:labels,
|
||||
# ADD
|
||||
:description,
|
||||
|
||||
@@ -448,7 +448,7 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
|
||||
"connected" ->
|
||||
%{
|
||||
"type" => "connection_status",
|
||||
"id" => event["id"] || Ulid.generate(),
|
||||
"id" => event["id"] || Ecto.ULID.generate(),
|
||||
"attributes" => %{
|
||||
"status" => "connected",
|
||||
"server_time" => payload["server_time"],
|
||||
@@ -465,7 +465,7 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
|
||||
# Use existing payload structure but wrap it in JSON:API format
|
||||
%{
|
||||
"type" => "events",
|
||||
"id" => event["id"] || Ulid.generate(),
|
||||
"id" => event["id"] || Ecto.ULID.generate(),
|
||||
"attributes" => payload,
|
||||
"relationships" => %{
|
||||
"map" => %{
|
||||
|
||||
@@ -248,6 +248,6 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
|
||||
defp datetime_to_ulid(datetime) do
|
||||
timestamp = DateTime.to_unix(datetime, :millisecond)
|
||||
# Create a ULID with the timestamp (rest will be zeros for comparison)
|
||||
Ulid.generate(timestamp)
|
||||
Ecto.ULID.generate(timestamp)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -82,7 +82,7 @@ defmodule WandererAppWeb.Api.EventsController do
|
||||
send_event(
|
||||
conn,
|
||||
%{
|
||||
id: Ulid.generate(),
|
||||
id: Ecto.ULID.generate(),
|
||||
event: "connected",
|
||||
data: %{
|
||||
map_id: map_id,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
4
mix.exs
4
mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
|
||||
|
||||
@source_url "https://github.com/wanderer-industries/wanderer"
|
||||
|
||||
@version "1.75.2"
|
||||
@version "1.76.4"
|
||||
|
||||
def project do
|
||||
[
|
||||
@@ -105,7 +105,7 @@ defmodule WandererApp.MixProject do
|
||||
{:ash_postgres, "~> 2.4"},
|
||||
{:exsync, "~> 0.4", only: :dev},
|
||||
{:nimble_csv, "~> 1.2.0"},
|
||||
{:ulid, "~> 0.2.0"},
|
||||
{:ecto_ulid_next, "~> 1.0.2"},
|
||||
{:cachex, "~> 3.6"},
|
||||
{:live_select, "~> 1.5"},
|
||||
{:nebulex, "~> 2.6"},
|
||||
|
||||
2
mix.lock
2
mix.lock
@@ -35,6 +35,7 @@
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"},
|
||||
"ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
|
||||
"ecto_ulid_next": {:hex, :ecto_ulid_next, "1.0.2", "8372f3c589c8fa50ea7b127dabe008528837b11781f65bfc72d96259d49b44c5", [:mix], [{:ecto, "~> 3.2", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "61c9c2c531f87ce7e2e9e57fc60d533fe97b3a62a43c21b632b0824f0773bcbe"},
|
||||
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
|
||||
"error_tracker": {:hex, :error_tracker, "0.2.2", "7635f5ed6016df10d8e63348375acb2ca411e2f6f9703ee90cc2d4262af5faec", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "b975978f64d27373d3486d7de477a699e735f8c0b1c74a7370ecb80e7ae97903"},
|
||||
"eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"},
|
||||
@@ -43,6 +44,7 @@
|
||||
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"},
|
||||
"ex_rated": {:hex, :ex_rated, "2.1.0", "d40e6fe35097b10222df2db7bb5dd801d57211bac65f29063de5f201c2a6aebc", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "936c155337253ed6474f06d941999dd3a9cf0fe767ec99a59f2d2989dc2cc13f"},
|
||||
"ex_ulid": {:hex, :ex_ulid, "0.1.0", "e6e717c57344f6e500d0190ccb4edc862b985a3680f15834af992ec065d4dcff", [:mix], [], "hexpm", "a2befd477aebc4639563de7e233e175cacf8a8f42c8f6778c88d60c13bf20860"},
|
||||
"excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"},
|
||||
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
|
||||
"exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
defmodule WandererApp.Repo.Migrations.AddSignatureTempName do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:map_system_signatures_v1) do
|
||||
add :temporary_name, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:map_system_signatures_v1) do
|
||||
remove :temporary_name
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
defmodule WandererApp.Repo.Migrations.AddSecurityAuditIndexes do
|
||||
use Ecto.Migration
|
||||
@disable_ddl_transaction true
|
||||
@disable_migration_lock true
|
||||
|
||||
def up do
|
||||
# Add indexes for security audit queries
|
||||
create_if_not_exists index(:user_activity_v1, [:entity_type, :event_type, :inserted_at],
|
||||
concurrently: true
|
||||
)
|
||||
|
||||
create_if_not_exists index(:user_activity_v1, [:user_id, :inserted_at], concurrently: true)
|
||||
create_if_not_exists index(:user_activity_v1, [:event_type], concurrently: true)
|
||||
|
||||
# Partial index for security events only - for better performance
|
||||
create_if_not_exists index(:user_activity_v1, [:user_id, :inserted_at],
|
||||
where: "entity_type = 'security_event'",
|
||||
name: :user_activity_v1_security_events_idx,
|
||||
concurrently: true
|
||||
)
|
||||
|
||||
# Index for entity_id queries (used by Map.Audit)
|
||||
create_if_not_exists index(:user_activity_v1, [:entity_id, :inserted_at], concurrently: true)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists index(:user_activity_v1, [:entity_id, :inserted_at], concurrently: true)
|
||||
|
||||
drop_if_exists index(:user_activity_v1, [:user_id, :inserted_at],
|
||||
name: :user_activity_v1_security_events_idx,
|
||||
concurrently: true
|
||||
)
|
||||
|
||||
drop_if_exists index(:user_activity_v1, [:event_type], concurrently: true)
|
||||
drop_if_exists index(:user_activity_v1, [:user_id, :inserted_at], concurrently: true)
|
||||
|
||||
drop_if_exists index(:user_activity_v1, [:entity_type, :event_type, :inserted_at],
|
||||
concurrently: true
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "eve_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "character_eve_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "temporary_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "linked_system_id",
|
||||
"type": "bigint"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "kind",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "group",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "custom_info",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "deleted",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "update_forced_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "map_system_signatures_v1_system_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "map_system_v1"
|
||||
},
|
||||
"size": null,
|
||||
"source": "system_id",
|
||||
"type": "uuid"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "D1885311D35F70BB9117EB170BD2E07D0CFEEB9E6AE4D971C7DE8DBF9CCDED10",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "map_system_signatures_v1_uniq_system_eve_id_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "system_id"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "eve_id"
|
||||
}
|
||||
],
|
||||
"name": "uniq_system_eve_id",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.WandererApp.Repo",
|
||||
"schema": null,
|
||||
"table": "map_system_signatures_v1"
|
||||
}
|
||||
Reference in New Issue
Block a user