mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-12-07 08:15:35 +00:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
74f7ad155d | ||
|
|
f58ebad0ec | ||
|
|
7ca4eb3b8f |
65
.github/workflows/build.yml
vendored
65
.github/workflows/build.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- develop
|
- develop
|
||||||
- "releases/*"
|
|
||||||
env:
|
env:
|
||||||
MIX_ENV: prod
|
MIX_ENV: prod
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -53,6 +53,7 @@ jobs:
|
|||||||
- name: ⬇️ Checkout repo
|
- name: ⬇️ Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
|
ssh-key: "${{ secrets.COMMIT_KEY }}"
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: 😅 Cache deps
|
- name: 😅 Cache deps
|
||||||
id: cache-deps
|
id: cache-deps
|
||||||
@@ -95,9 +96,10 @@ jobs:
|
|||||||
git config --global user.name 'CI'
|
git config --global user.name 'CI'
|
||||||
git config --global user.email 'ci@users.noreply.github.com'
|
git config --global user.email 'ci@users.noreply.github.com'
|
||||||
mix git_ops.release --force-patch --yes
|
mix git_ops.release --force-patch --yes
|
||||||
|
git commit --allow-empty -m 'chore: [skip ci]'
|
||||||
git push --follow-tags
|
git push --follow-tags
|
||||||
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
echo "commit_hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Set commit hash for develop
|
- name: Set commit hash for develop
|
||||||
id: set-commit-develop
|
id: set-commit-develop
|
||||||
if: github.ref == 'refs/heads/develop'
|
if: github.ref == 'refs/heads/develop'
|
||||||
@@ -106,11 +108,9 @@ jobs:
|
|||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: 🛠 Build Docker Images
|
name: 🛠 Build Docker Images
|
||||||
|
if: github.ref == 'refs/heads/develop'
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
outputs:
|
|
||||||
release-tag: ${{ steps.get-latest-tag.outputs.tag }}
|
|
||||||
release-notes: ${{ steps.get-content.outputs.string }}
|
|
||||||
permissions:
|
permissions:
|
||||||
checks: write
|
checks: write
|
||||||
contents: write
|
contents: write
|
||||||
@@ -137,19 +137,6 @@ jobs:
|
|||||||
ref: ${{ needs.build.outputs.commit_hash }}
|
ref: ${{ needs.build.outputs.commit_hash }}
|
||||||
fetch-depth: 0
|
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
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
@@ -198,26 +185,6 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
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:
|
merge:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
@@ -248,9 +215,6 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=ref,event=pr
|
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,enable=${{ github.ref == 'refs/heads/develop' }}
|
||||||
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
|
type=raw,value=develop-{{sha}},enable=${{ github.ref == 'refs/heads/develop' }}
|
||||||
|
|
||||||
@@ -267,19 +231,25 @@ jobs:
|
|||||||
create-release:
|
create-release:
|
||||||
name: 🏷 Create Release
|
name: 🏷 Create Release
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
needs: [docker, merge]
|
|
||||||
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||||
|
needs: build
|
||||||
steps:
|
steps:
|
||||||
- name: ⬇️ Checkout repo
|
- name: ⬇️ Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
- name: 🏷 Create Draft Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.docker.outputs.release-tag }}
|
tag_name: ${{ steps.get-latest-tag.outputs.tag }}
|
||||||
name: Release ${{ needs.docker.outputs.release-tag }}
|
name: Release ${{ steps.get-latest-tag.outputs.tag }}
|
||||||
body: |
|
body: |
|
||||||
## Info
|
## Info
|
||||||
Commit ${{ github.sha }} was deployed to `staging`. [See code diff](${{ github.event.compare }}).
|
Commit ${{ github.sha }} was deployed to `staging`. [See code diff](${{ github.event.compare }}).
|
||||||
@@ -289,10 +259,3 @@ jobs:
|
|||||||
## How to Promote?
|
## How to Promote?
|
||||||
In order to promote this to prod, edit the draft and press **"Publish release"**.
|
In order to promote this to prod, edit the draft and press **"Publish release"**.
|
||||||
draft: true
|
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** (https://hub.docker.com/repository/docker/wandererltd/community-edition-arm/general) 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: Release notes ${{ 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: Release notes ${{ needs.docker.outputs.release-notes }}
|
||||||
1698
CHANGELOG.md
1698
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 ensure any relevant config change will trigger the dependencies
|
||||||
# to be re-compiled.
|
# to be re-compiled.
|
||||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||||
|
|
||||||
COPY priv priv
|
COPY priv priv
|
||||||
|
|
||||||
COPY lib lib
|
COPY lib lib
|
||||||
|
|
||||||
COPY assets assets
|
COPY assets assets
|
||||||
|
|
||||||
RUN mix compile
|
|
||||||
|
|
||||||
RUN mix assets.deploy
|
RUN mix assets.deploy
|
||||||
|
RUN mix compile
|
||||||
|
|
||||||
# Changes to config/runtime.exs don't require recompiling the code
|
# Changes to config/runtime.exs don't require recompiling the code
|
||||||
COPY config/runtime.exs config/
|
COPY config/runtime.exs config/
|
||||||
|
|
||||||
COPY rel rel
|
COPY rel rel
|
||||||
|
|
||||||
RUN mix release
|
RUN mix release
|
||||||
|
|
||||||
# start a new build stage so that the final image will only contain
|
# start a new build stage so that the final image will only contain
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
// import './tailwind.css';
|
// import './tailwind.css';
|
||||||
//@import 'primereact/resources/themes/bootstrap4-dark-blue/theme.css';
|
@use 'primereact/resources/primereact.min.css';
|
||||||
//@import 'primereact/resources/themes/lara-dark-purple/theme.css';
|
@use 'primeicons/primeicons.css';
|
||||||
//@import "prime-fixes";
|
|
||||||
@import 'primereact/resources/primereact.min.css';
|
|
||||||
//@import 'primeflex/primeflex.css';
|
|
||||||
@import 'primeicons/primeicons.css';
|
|
||||||
//@import 'primereact/resources/primereact.css';
|
|
||||||
|
|
||||||
|
|
||||||
@import "fixes";
|
@use "fixes";
|
||||||
@import "prime-fixes";
|
@use "prime-fixes";
|
||||||
@import "custom-scrollbar";
|
@use "custom-scrollbar";
|
||||||
@import "tooltip";
|
@use "tooltip";
|
||||||
@import "context-menu";
|
@use "context-menu";
|
||||||
|
|
||||||
|
|
||||||
.fixedImportant {
|
.fixedImportant {
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
@import "fix-dialog";
|
@use "fix-dialog";
|
||||||
@import "fix-popup";
|
@use "fix-popup";
|
||||||
@import "fix-tabs";
|
@use "fix-tabs";
|
||||||
//@import "fix-input";
|
|
||||||
|
|
||||||
//@import "theme";
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import '@/hooks/Mapper/components/map/styles/eve-common-variables';
|
@use '@/hooks/Mapper/components/map/styles/eve-common-variables';
|
||||||
|
|
||||||
.ConnectionTimeEOL {
|
.ConnectionTimeEOL {
|
||||||
background-image: linear-gradient(207deg, transparent, var(--conn-time-eol));
|
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 {
|
.EdgePathBack {
|
||||||
fill: none;
|
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-blue: #5a7d9a;
|
||||||
$pastel-pink: rgb(30, 161, 255);
|
$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);
|
color: var(--rf-text-color, #ffffff);
|
||||||
|
|
||||||
box-shadow: 0 0 5px rgba($dark-bg, 0.5);
|
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;
|
border-radius: 5px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import './SolarSystemNodeDefault.module.scss';
|
@use './SolarSystemNodeDefault.module.scss';
|
||||||
|
|
||||||
/* ---------------------------------------------
|
/* ---------------------------------------------
|
||||||
Only override what's different from the base
|
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 {
|
.Signature {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import './eve-common-variables';
|
@use './eve-common-variables';
|
||||||
@import './eve-common';
|
@use './eve-common';
|
||||||
|
|
||||||
.default-theme {
|
.default-theme {
|
||||||
--rf-bg-color: #0C0A09;
|
--rf-bg-color: #0C0A09;
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
|
@use "sass:color";
|
||||||
|
|
||||||
$friendlyBase: #3bbd39;
|
$friendlyBase: #3bbd39;
|
||||||
$friendlyAlpha: #3bbd3952;
|
$friendlyAlpha: #3bbd3952;
|
||||||
$friendlyDark20: darken($friendlyBase, 20%);
|
$friendlyDark20: color.adjust($friendlyBase, $lightness: -20%);
|
||||||
$friendlyDark30: darken($friendlyBase, 30%);
|
$friendlyDark30: color.adjust($friendlyBase, $lightness: -30%);
|
||||||
$friendlyDark5: darken($friendlyBase, 5%);
|
$friendlyDark5: color.adjust($friendlyBase, $lightness: -5%);
|
||||||
|
|
||||||
$lookingForBase: #43c2fd;
|
$lookingForBase: #43c2fd;
|
||||||
$lookingForAlpha: rgba(67, 176, 253, 0.48);
|
$lookingForAlpha: rgba(67, 176, 253, 0.48);
|
||||||
$lookingForDark15: darken($lookingForBase, 15%);
|
$lookingForDark15: color.adjust($lookingForBase, $lightness: -15%);
|
||||||
|
|
||||||
$homeBase: rgb(179, 253, 67);
|
$homeBase: rgb(179, 253, 67);
|
||||||
$homeAlpha: rgba(186, 248, 48, 0.32);
|
$homeAlpha: rgba(186, 248, 48, 0.32);
|
||||||
$homeBackground: #a0fa5636;
|
$homeBackground: #a0fa5636;
|
||||||
$homeDark30: darken($homeBase, 30%);
|
$homeDark30: color.adjust($homeBase, $lightness: -30%);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--pastel-blue: #5a7d9a;
|
--pastel-blue: #5a7d9a;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import './eve-common-variables';
|
@use './eve-common-variables';
|
||||||
|
|
||||||
|
|
||||||
.eve-wh-effect-color-pulsar {
|
.eve-wh-effect-color-pulsar {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
@import './default-theme.scss';
|
@use './default-theme.scss';
|
||||||
@import './pathfinder-theme.scss';
|
@use './pathfinder-theme.scss';
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
@import './eve-common-variables';
|
@use "sass:color";
|
||||||
@import './eve-common';
|
@use './eve-common-variables';
|
||||||
|
@use './eve-common';
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Oxygen:wght@300;400;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Oxygen:wght@300;400;700&display=swap');
|
||||||
|
|
||||||
$homeBase: rgb(197, 253, 67);
|
$homeBase: rgb(197, 253, 67);
|
||||||
$homeAlpha: rgba(197, 253, 67, 0.32);
|
$homeAlpha: rgba(197, 253, 67, 0.32);
|
||||||
$homeDark30: darken($homeBase, 30%);
|
$homeDark30: color.adjust($homeBase, $lightness: -30%);
|
||||||
|
|
||||||
.pathfinder-theme {
|
.pathfinder-theme {
|
||||||
/* -- Override values from the default theme -- */
|
/* -- Override values from the default theme -- */
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ import {
|
|||||||
renderInfoColumn,
|
renderInfoColumn,
|
||||||
renderUpdatedTimeLeft,
|
renderUpdatedTimeLeft,
|
||||||
} from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/renders';
|
} 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 { useClipboard, useHotkey } from '@/hooks/Mapper/hooks';
|
||||||
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth';
|
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth';
|
||||||
|
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||||
import { getSignatureRowClass } from '../helpers/rowStyles';
|
import { getSignatureRowClass } from '../helpers/rowStyles';
|
||||||
import { useSystemSignaturesData } from '../hooks/useSystemSignaturesData';
|
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);
|
const renderColIcon = (sig: SystemSignature) => renderIcon(sig);
|
||||||
|
|
||||||
@@ -157,9 +157,18 @@ export const SystemSignaturesContent = ({
|
|||||||
[onSelect, selectable, setSelectedSignatures, deletedSignatures],
|
[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,
|
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,
|
showUpdatedColumn: settings[SETTINGS_KEYS.SHOW_UPDATED_COLUMN] as boolean,
|
||||||
showCharacterColumn: settings[SETTINGS_KEYS.SHOW_CHARACTER_COLUMN] as boolean,
|
showCharacterColumn: settings[SETTINGS_KEYS.SHOW_CHARACTER_COLUMN] as boolean,
|
||||||
showCharacterPortrait: settings[SETTINGS_KEYS.SHOW_CHARACTER_PORTRAIT] 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 }}
|
style={{ maxWidth: 72, minWidth: 72, width: 72 }}
|
||||||
sortable
|
sortable
|
||||||
/>
|
/>
|
||||||
<Column
|
{showGroupColumn && (
|
||||||
field="group"
|
<Column
|
||||||
header="Group"
|
field="group"
|
||||||
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
|
header="Group"
|
||||||
style={{ maxWidth: 110, minWidth: 110, width: 110 }}
|
bodyClassName="text-ellipsis overflow-hidden whitespace-nowrap"
|
||||||
body={sig => sig.group ?? ''}
|
style={{ maxWidth: 110, minWidth: 110, width: 110 }}
|
||||||
hidden={isCompact}
|
body={sig => sig.group ?? ''}
|
||||||
sortable
|
hidden={isCompact}
|
||||||
/>
|
sortable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Column
|
<Column
|
||||||
field="info"
|
field="info"
|
||||||
header="Info"
|
header="Info"
|
||||||
@@ -336,15 +347,17 @@ export const SystemSignaturesContent = ({
|
|||||||
sortable
|
sortable
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Column
|
{showAddedColumn && (
|
||||||
field="inserted_at"
|
<Column
|
||||||
header="Added"
|
field="inserted_at"
|
||||||
dataType="date"
|
header="Added"
|
||||||
body={renderAddedTimeLeft}
|
dataType="date"
|
||||||
style={{ minWidth: 70, maxWidth: 80 }}
|
body={renderAddedTimeLeft}
|
||||||
bodyClassName="ssc-header text-ellipsis overflow-hidden whitespace-nowrap"
|
style={{ minWidth: 70, maxWidth: 80 }}
|
||||||
sortable
|
bodyClassName="ssc-header text-ellipsis overflow-hidden whitespace-nowrap"
|
||||||
/>
|
sortable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showUpdatedColumn && (
|
{showUpdatedColumn && (
|
||||||
<Column
|
<Column
|
||||||
field="updated_at"
|
field="updated_at"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SETTINGS_KEYS, SIGNATURES_DELETION_TIMING, SignatureSettingsType } from '@/hooks/Mapper/constants/signatures';
|
||||||
import {
|
import {
|
||||||
GroupType,
|
GroupType,
|
||||||
SignatureGroup,
|
SignatureGroup,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
SignatureKindFR,
|
SignatureKindFR,
|
||||||
SignatureKindRU,
|
SignatureKindRU,
|
||||||
} from '@/hooks/Mapper/types';
|
} 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_ONE_MINUTE = 1000 * 60;
|
||||||
export const TIME_TEN_MINUTES = TIME_ONE_MINUTE * 10;
|
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' },
|
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.COMBAT_SITE, name: 'Show Combat Sites' },
|
||||||
],
|
],
|
||||||
uiFlags: [
|
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_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_DESCRIPTION_COLUMN, name: 'Show Description Column' },
|
||||||
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.SHOW_CHARACTER_COLUMN, name: 'Show Character Column' },
|
{ type: SettingsTypes.flag, key: SETTINGS_KEYS.SHOW_CHARACTER_COLUMN, name: 'Show Character Column' },
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Dialog } from 'primereact/dialog';
|
import { DEFAULT_SIGNATURE_SETTINGS } from '@/hooks/Mapper/constants/signatures.ts';
|
||||||
import { Button } from 'primereact/button';
|
import { useConfirmPopup } from '@/hooks/Mapper/hooks';
|
||||||
import { ConfirmPopup } from 'primereact/confirmpopup';
|
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
||||||
import { useCallback, useRef } from 'react';
|
|
||||||
import { MapUserSettings } from '@/hooks/Mapper/mapRootProvider/types.ts';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_KILLS_WIDGET_SETTINGS,
|
DEFAULT_KILLS_WIDGET_SETTINGS,
|
||||||
DEFAULT_ON_THE_MAP_SETTINGS,
|
DEFAULT_ON_THE_MAP_SETTINGS,
|
||||||
@@ -11,11 +9,13 @@ import {
|
|||||||
getDefaultWidgetProps,
|
getDefaultWidgetProps,
|
||||||
STORED_INTERFACE_DEFAULT_VALUES,
|
STORED_INTERFACE_DEFAULT_VALUES,
|
||||||
} from '@/hooks/Mapper/mapRootProvider/constants.ts';
|
} from '@/hooks/Mapper/mapRootProvider/constants.ts';
|
||||||
import { DEFAULT_SIGNATURE_SETTINGS } from '@/hooks/Mapper/constants/signatures.ts';
|
import { MapUserSettings } from '@/hooks/Mapper/mapRootProvider/types.ts';
|
||||||
import { Toast } from 'primereact/toast';
|
|
||||||
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
|
|
||||||
import { saveTextFile } from '@/hooks/Mapper/utils';
|
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) {
|
const createSettings = function <T>(lsSettings: string | null, defaultValues: T) {
|
||||||
return {
|
return {
|
||||||
@@ -41,7 +41,7 @@ export const OldSettingsDialog = () => {
|
|||||||
const widgetKills = localStorage.getItem('kills:widget:settings');
|
const widgetKills = localStorage.getItem('kills:widget:settings');
|
||||||
const onTheMapOld = localStorage.getItem('window:onTheMap:settings');
|
const onTheMapOld = localStorage.getItem('window:onTheMap:settings');
|
||||||
const widgetsOld = localStorage.getItem('windows:settings:v2');
|
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 = {
|
const out: MapUserSettings = {
|
||||||
killsWidget: createSettings(widgetKills, DEFAULT_KILLS_WIDGET_SETTINGS),
|
killsWidget: createSettings(widgetKills, DEFAULT_KILLS_WIDGET_SETTINGS),
|
||||||
@@ -118,7 +118,7 @@ export const OldSettingsDialog = () => {
|
|||||||
localStorage.removeItem('kills:widget:settings');
|
localStorage.removeItem('kills:widget:settings');
|
||||||
localStorage.removeItem('window:onTheMap:settings');
|
localStorage.removeItem('window:onTheMap:settings');
|
||||||
localStorage.removeItem('windows:settings:v2');
|
localStorage.removeItem('windows:settings:v2');
|
||||||
localStorage.removeItem('wanderer_system_signature_settings_v6_5');
|
localStorage.removeItem('wanderer_system_signature_settings_v6_6');
|
||||||
|
|
||||||
checkOldSettings();
|
checkOldSettings();
|
||||||
}, [checkOldSettings]);
|
}, [checkOldSettings]);
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export const SignatureSettings = ({ systemId, show, onHide, signatureData }: Map
|
|||||||
out = { ...out, type: values.type };
|
out = { ...out, type: values.type };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (values.temporary_name != null) {
|
||||||
|
out = { ...out, temporary_name: values.temporary_name };
|
||||||
|
}
|
||||||
|
|
||||||
if (signatureData.group !== SignatureGroup.Wormhole) {
|
if (signatureData.group !== SignatureGroup.Wormhole) {
|
||||||
out = { ...out, name: '' };
|
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 { SignatureK162TypeSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureK162TypeSelect';
|
||||||
import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect';
|
import { SignatureLeadsToSelect } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureLeadsToSelect';
|
||||||
import { SignatureEOLCheckbox } from '@/hooks/Mapper/components/mapRootContent/components/SignatureSettings/components/SignatureEOLCheckbox';
|
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 = () => {
|
export const SignatureGroupContentWormholes = () => {
|
||||||
const { watch } = useFormContext<SystemSignature>();
|
const { watch } = useFormContext<SystemSignature>();
|
||||||
@@ -32,6 +33,11 @@ export const SignatureGroupContentWormholes = () => {
|
|||||||
<span>EOL:</span>
|
<span>EOL:</span>
|
||||||
<SignatureEOLCheckbox name="isEOL" />
|
<SignatureEOLCheckbox name="isEOL" />
|
||||||
</label>
|
</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} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,14 +12,16 @@ export enum SETTINGS_KEYS {
|
|||||||
SORT_FIELD = 'sortField',
|
SORT_FIELD = 'sortField',
|
||||||
SORT_ORDER = 'sortOrder',
|
SORT_ORDER = 'sortOrder',
|
||||||
|
|
||||||
SHOW_DESCRIPTION_COLUMN = 'show_description_column',
|
SHOW_ADDED_COLUMN = 'show_added_column',
|
||||||
SHOW_UPDATED_COLUMN = 'show_updated_column',
|
|
||||||
SHOW_CHARACTER_COLUMN = 'show_character_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',
|
LAZY_DELETE_SIGNATURES = 'lazy_delete_signatures',
|
||||||
KEEP_LAZY_DELETE = 'keep_lazy_delete_enabled',
|
KEEP_LAZY_DELETE = 'keep_lazy_delete_enabled',
|
||||||
DELETION_TIMING = 'deletion_timing',
|
DELETION_TIMING = 'deletion_timing',
|
||||||
COLOR_BY_TYPE = 'color_by_type',
|
COLOR_BY_TYPE = 'color_by_type',
|
||||||
SHOW_CHARACTER_PORTRAIT = 'show_character_portrait',
|
|
||||||
|
|
||||||
// From SignatureKind
|
// From SignatureKind
|
||||||
COSMIC_ANOMALY = SignatureKind.CosmicAnomaly,
|
COSMIC_ANOMALY = SignatureKind.CosmicAnomaly,
|
||||||
@@ -45,6 +47,8 @@ export const DEFAULT_SIGNATURE_SETTINGS: SignatureSettingsType = {
|
|||||||
[SETTINGS_KEYS.SORT_FIELD]: 'inserted_at',
|
[SETTINGS_KEYS.SORT_FIELD]: 'inserted_at',
|
||||||
[SETTINGS_KEYS.SORT_ORDER]: -1,
|
[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_UPDATED_COLUMN]: true,
|
||||||
[SETTINGS_KEYS.SHOW_DESCRIPTION_COLUMN]: true,
|
[SETTINGS_KEYS.SHOW_DESCRIPTION_COLUMN]: true,
|
||||||
[SETTINGS_KEYS.SHOW_CHARACTER_COLUMN]: true,
|
[SETTINGS_KEYS.SHOW_CHARACTER_COLUMN]: true,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type SystemSignature = {
|
|||||||
inserted_at?: string;
|
inserted_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
|
temporary_name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ExtendedSystemSignature extends SystemSignature {
|
export interface ExtendedSystemSignature extends SystemSignature {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
"sass-loader": "^14.2.1",
|
"sass-loader": "^14.2.1",
|
||||||
"ts-jest": "^29.1.2",
|
"ts-jest": "^29.1.2",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.5",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-cdn-import": "^1.0.1"
|
"vite-plugin-cdn-import": "^1.0.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"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,
|
repo: WandererApp.Repo,
|
||||||
otp_app: :wanderer_app
|
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,
|
config :git_ops,
|
||||||
mix_project: Mix.Project.get!(),
|
mix_project: Mix.Project.get!(),
|
||||||
changelog_file: "CHANGELOG.md",
|
changelog_file: "CHANGELOG.md",
|
||||||
|
|||||||
@@ -27,5 +27,8 @@ config :swoosh, local: false
|
|||||||
config :logger,
|
config :logger,
|
||||||
level: :info
|
level: :info
|
||||||
|
|
||||||
|
# Enable async security audit processing in production
|
||||||
|
config :wanderer_app, WandererApp.SecurityAudit, async: true
|
||||||
|
|
||||||
# Runtime production configuration, including reading
|
# Runtime production configuration, including reading
|
||||||
# of environment variables, is done on config/runtime.exs.
|
# of environment variables, is done on config/runtime.exs.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ defmodule WandererApp.Api.MapSystemSignature do
|
|||||||
code_interface do
|
code_interface do
|
||||||
define(:all_active, action: :all_active)
|
define(:all_active, action: :all_active)
|
||||||
define(:create, action: :create)
|
define(:create, action: :create)
|
||||||
|
define(:destroy, action: :destroy)
|
||||||
define(:update, action: :update)
|
define(:update, action: :update)
|
||||||
define(:update_linked_system, action: :update_linked_system)
|
define(:update_linked_system, action: :update_linked_system)
|
||||||
define(:update_type, action: :update_type)
|
define(:update_type, action: :update_type)
|
||||||
@@ -62,6 +63,7 @@ defmodule WandererApp.Api.MapSystemSignature do
|
|||||||
:eve_id,
|
:eve_id,
|
||||||
:character_eve_id,
|
:character_eve_id,
|
||||||
:name,
|
:name,
|
||||||
|
:temporary_name,
|
||||||
:description,
|
:description,
|
||||||
:kind,
|
:kind,
|
||||||
:group,
|
:group,
|
||||||
@@ -101,6 +103,7 @@ defmodule WandererApp.Api.MapSystemSignature do
|
|||||||
:eve_id,
|
:eve_id,
|
||||||
:character_eve_id,
|
:character_eve_id,
|
||||||
:name,
|
:name,
|
||||||
|
:temporary_name,
|
||||||
:description,
|
:description,
|
||||||
:kind,
|
:kind,
|
||||||
:group,
|
:group,
|
||||||
@@ -120,6 +123,7 @@ defmodule WandererApp.Api.MapSystemSignature do
|
|||||||
:eve_id,
|
:eve_id,
|
||||||
:character_eve_id,
|
:character_eve_id,
|
||||||
:name,
|
:name,
|
||||||
|
:temporary_name,
|
||||||
:description,
|
:description,
|
||||||
:kind,
|
:kind,
|
||||||
:group,
|
:group,
|
||||||
@@ -195,6 +199,10 @@ defmodule WandererApp.Api.MapSystemSignature do
|
|||||||
allow_nil? true
|
allow_nil? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attribute :temporary_name, :string do
|
||||||
|
allow_nil? true
|
||||||
|
end
|
||||||
|
|
||||||
attribute :type, :string do
|
attribute :type, :string do
|
||||||
allow_nil? true
|
allow_nil? true
|
||||||
end
|
end
|
||||||
@@ -241,6 +249,7 @@ defmodule WandererApp.Api.MapSystemSignature do
|
|||||||
:eve_id,
|
:eve_id,
|
||||||
:character_eve_id,
|
:character_eve_id,
|
||||||
:name,
|
:name,
|
||||||
|
:temporary_name,
|
||||||
:description,
|
:description,
|
||||||
:type,
|
:type,
|
||||||
:linked_system_id,
|
:linked_system_id,
|
||||||
|
|||||||
@@ -145,7 +145,12 @@ defmodule WandererApp.Api.UserActivity do
|
|||||||
:admin_action,
|
:admin_action,
|
||||||
:config_change,
|
:config_change,
|
||||||
:bulk_operation,
|
: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},
|
Supervisor.child_spec({Cachex, name: :tracked_characters},
|
||||||
id: :tracked_characters_cache_worker
|
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.MapRegistry},
|
||||||
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
|
{Registry, keys: :unique, name: WandererApp.Character.TrackerRegistry},
|
||||||
{PartitionSupervisor,
|
{PartitionSupervisor,
|
||||||
@@ -60,6 +63,14 @@ defmodule WandererApp.Application do
|
|||||||
if Application.get_env(:wanderer_app, :environment) == :test do
|
if Application.get_env(:wanderer_app, :environment) == :test do
|
||||||
[]
|
[]
|
||||||
else
|
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.Esi.InitClientsTask,
|
||||||
WandererApp.Scheduler,
|
WandererApp.Scheduler,
|
||||||
@@ -68,7 +79,7 @@ defmodule WandererApp.Application do
|
|||||||
{WandererApp.Character.TrackerPoolSupervisor, []},
|
{WandererApp.Character.TrackerPoolSupervisor, []},
|
||||||
WandererApp.Character.TrackerManager,
|
WandererApp.Character.TrackerManager,
|
||||||
WandererApp.Map.Manager
|
WandererApp.Map.Manager
|
||||||
]
|
] ++ security_audit_children
|
||||||
end
|
end
|
||||||
|
|
||||||
children =
|
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
|
||||||
@@ -97,7 +97,7 @@ defmodule WandererApp.ExternalEvents.Event do
|
|||||||
:locked,
|
:locked,
|
||||||
# ADD
|
# ADD
|
||||||
:temporary_name,
|
:temporary_name,
|
||||||
# ADD
|
# ADD
|
||||||
:labels,
|
:labels,
|
||||||
# ADD
|
# ADD
|
||||||
:description,
|
:description,
|
||||||
|
|||||||
@@ -1,31 +1,28 @@
|
|||||||
defmodule WandererApp.Map.Audit do
|
defmodule WandererApp.Map.Audit do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Manager map subscription plans
|
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 Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
# alias WandererApp.SecurityAudit
|
|
||||||
|
|
||||||
@week_seconds :timer.hours(24 * 7)
|
@week_seconds :timer.hours(24 * 7)
|
||||||
@month_seconds @week_seconds * 4
|
@month_seconds @week_seconds * 4
|
||||||
@audit_expired_seconds @month_seconds * 3
|
@audit_expired_seconds @month_seconds * 3
|
||||||
|
|
||||||
def track_map_subscription_event(event_type, metadata) do
|
def track_map_subscription_event(event_type, metadata) do
|
||||||
case event_type do
|
mapped_type =
|
||||||
"subscription.created" ->
|
case event_type do
|
||||||
track_map_event(event_type, metadata)
|
"subscription.created" -> :subscription_created
|
||||||
|
"subscription.updated" -> :subscription_updated
|
||||||
|
"subscription.deleted" -> :subscription_deleted
|
||||||
|
_ -> :subscription_unknown
|
||||||
|
end
|
||||||
|
|
||||||
"subscription.updated" ->
|
track_map_event(mapped_type, metadata)
|
||||||
track_map_event(event_type, metadata)
|
|
||||||
|
|
||||||
"subscription.deleted" ->
|
|
||||||
track_map_event(event_type, metadata)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
{:ok, nil}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def archive() do
|
def archive() do
|
||||||
@@ -39,193 +36,14 @@ defmodule WandererApp.Map.Audit do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_activity_query(map_id, period, activity) do
|
defdelegate get_map_activity_query(map_id, period, activity),
|
||||||
{from, to} = period |> get_period()
|
to: WandererApp.SecurityAudit
|
||||||
|
|
||||||
query =
|
defdelegate track_acl_event(event_type, metadata),
|
||||||
WandererApp.Api.UserActivity
|
to: WandererApp.SecurityAudit
|
||||||
|> Ash.Query.filter(
|
|
||||||
and: [
|
|
||||||
[entity_id: map_id],
|
|
||||||
[inserted_at: [greater_than_or_equal: from]],
|
|
||||||
[inserted_at: [less_than_or_equal: to]]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
query =
|
defdelegate track_map_event(event_type, metadata),
|
||||||
activity
|
to: WandererApp.SecurityAudit
|
||||||
|> 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")
|
|
||||||
|
|
||||||
defp get_expired_at(), do: DateTime.utc_now() |> DateTime.add(-@audit_expired_seconds, :second)
|
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
|
end
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|
|||||||
deleted_sig,
|
deleted_sig,
|
||||||
Map.take(sig, [
|
Map.take(sig, [
|
||||||
:name,
|
:name,
|
||||||
|
:temporary_name,
|
||||||
:description,
|
:description,
|
||||||
:kind,
|
:kind,
|
||||||
:group,
|
:group,
|
||||||
@@ -239,6 +240,7 @@ defmodule WandererApp.Map.Server.SignaturesImpl do
|
|||||||
system_id: system_id,
|
system_id: system_id,
|
||||||
eve_id: sig["eve_id"],
|
eve_id: sig["eve_id"],
|
||||||
name: sig["name"],
|
name: sig["name"],
|
||||||
|
temporary_name: sig["temporary_name"],
|
||||||
description: Map.get(sig, "description"),
|
description: Map.get(sig, "description"),
|
||||||
kind: sig["kind"],
|
kind: sig["kind"],
|
||||||
group: sig["group"],
|
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
|
||||||
@@ -113,7 +113,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
|
|||||||
def index(conn, params) do
|
def index(conn, params) do
|
||||||
with {:ok, map_id} <- APIUtils.fetch_map_id(params),
|
with {:ok, map_id} <- APIUtils.fetch_map_id(params),
|
||||||
{:ok, period} <- APIUtils.require_param(params, "period"),
|
{: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} <-
|
{:ok, data} <-
|
||||||
Ash.read(query) do
|
Ash.read(query) do
|
||||||
data = Enum.map(data, &map_audit_event_to_json/1)
|
data = Enum.map(data, &map_audit_event_to_json/1)
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
|
|
||||||
Supports both session-based authentication (for web clients) and
|
Supports both session-based authentication (for web clients) and
|
||||||
Bearer token authentication (for API clients).
|
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
|
import Plug.Conn
|
||||||
|
|
||||||
alias WandererApp.Api.User
|
alias WandererApp.Api.User
|
||||||
# alias WandererApp.SecurityAudit
|
alias WandererApp.SecurityAudit
|
||||||
|
alias WandererApp.Audit.RequestContext
|
||||||
|
|
||||||
def init(opts), do: opts
|
def init(opts), do: opts
|
||||||
|
|
||||||
@@ -23,7 +29,7 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
|
|
||||||
# Log successful authentication
|
# Log successful authentication
|
||||||
request_details = extract_request_details(conn)
|
request_details = extract_request_details(conn)
|
||||||
# SecurityAudit.log_auth_event(:auth_success, user.id, request_details)
|
SecurityAudit.log_auth_event(:auth_success, user.id, request_details)
|
||||||
|
|
||||||
# Emit successful authentication event
|
# Emit successful authentication event
|
||||||
:telemetry.execute(
|
:telemetry.execute(
|
||||||
@@ -44,7 +50,7 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
|
|
||||||
# Log successful authentication
|
# Log successful authentication
|
||||||
request_details = extract_request_details(conn)
|
request_details = extract_request_details(conn)
|
||||||
# SecurityAudit.log_auth_event(:auth_success, user.id, request_details)
|
SecurityAudit.log_auth_event(:auth_success, user.id, request_details)
|
||||||
|
|
||||||
# Emit successful authentication event
|
# Emit successful authentication event
|
||||||
:telemetry.execute(
|
:telemetry.execute(
|
||||||
@@ -57,18 +63,19 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
|> assign(:current_user, user)
|
|> assign(:current_user, user)
|
||||||
|> assign(:current_user_role, get_user_role(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)
|
end_time = System.monotonic_time(:millisecond)
|
||||||
duration = end_time - start_time
|
duration = end_time - start_time
|
||||||
|
|
||||||
# Log failed authentication
|
# Log failed authentication
|
||||||
request_details = extract_request_details(conn)
|
request_details = extract_request_details(conn)
|
||||||
|
|
||||||
# SecurityAudit.log_auth_event(
|
SecurityAudit.log_auth_event(
|
||||||
# :auth_failure,
|
:auth_failure,
|
||||||
# nil,
|
nil,
|
||||||
# Map.put(request_details, :failure_reason, reason)
|
Map.put(request_details, :failure_reason, reason)
|
||||||
# )
|
)
|
||||||
|
|
||||||
# Emit failed authentication event
|
# Emit failed authentication event
|
||||||
:telemetry.execute(
|
:telemetry.execute(
|
||||||
@@ -82,6 +89,36 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
|> put_resp_content_type("application/json")
|
|> put_resp_content_type("application/json")
|
||||||
|> send_resp(401, Jason.encode!(%{error: reason}))
|
|> send_resp(401, Jason.encode!(%{error: reason}))
|
||||||
|> halt()
|
|> 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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -103,8 +140,6 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
defp authenticate_bearer_token(conn) do
|
defp authenticate_bearer_token(conn) do
|
||||||
case get_req_header(conn, "authorization") do
|
case get_req_header(conn, "authorization") do
|
||||||
["Bearer " <> token] ->
|
["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)
|
validate_api_token(token)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
@@ -113,48 +148,23 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp validate_api_token(token) do
|
defp validate_api_token(token) do
|
||||||
# For test environment, accept test API keys
|
# Look up the map by its public API key
|
||||||
if Application.get_env(:wanderer_app, :env) == :test and
|
case find_map_by_api_key(token) do
|
||||||
(String.starts_with?(token, "test_") or String.starts_with?(token, "test_api_key_")) do
|
{:ok, map} when not is_nil(map) ->
|
||||||
# For test tokens, look up the actual map by API key
|
# Get the actual owner of the map
|
||||||
case find_map_by_api_key(token) do
|
case User.by_id(map.owner_id, load: :characters) do
|
||||||
{:ok, map} when not is_nil(map) ->
|
{:ok, user} ->
|
||||||
# Use the actual map owner as the user
|
# Return the map owner as the authenticated user
|
||||||
user = %User{
|
{:ok, user, map}
|
||||||
id: map.owner_id || Ecto.UUID.generate(),
|
|
||||||
name: "Test User",
|
|
||||||
hash: "test_hash_#{System.unique_integer([:positive])}"
|
|
||||||
}
|
|
||||||
|
|
||||||
{: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
|
# Return generic error with specific reason for internal logging
|
||||||
user = %User{
|
{:error, "Authentication failed", :invalid_api_key}
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -192,50 +202,8 @@ defmodule WandererAppWeb.Plugs.CheckJsonApiAuth do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp extract_request_details(conn) do
|
defp extract_request_details(conn) do
|
||||||
%{
|
RequestContext.build_request_details(conn)
|
||||||
ip_address: get_peer_ip(conn),
|
|> Map.put(:auth_method, get_auth_type(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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_assign_map(conn, nil), do: conn
|
defp maybe_assign_map(conn, nil), do: conn
|
||||||
|
|||||||
@@ -80,25 +80,73 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
|
|||||||
current_user: %{id: current_user_id},
|
current_user: %{id: current_user_id},
|
||||||
main_character_id: main_character_id,
|
main_character_id: main_character_id,
|
||||||
has_tracked_characters?: true,
|
has_tracked_characters?: true,
|
||||||
|
map_user_settings: map_user_settings,
|
||||||
user_permissions: %{delete_connection: true}
|
user_permissions: %{delete_connection: true}
|
||||||
}
|
}
|
||||||
} =
|
} =
|
||||||
socket
|
socket
|
||||||
)
|
)
|
||||||
when not is_nil(main_character_id) do
|
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
|
map_id
|
||||||
|> WandererApp.Map.Server.delete_connection(%{
|
|> WandererApp.Map.Server.delete_connection(%{
|
||||||
solar_system_source_id: solar_system_source_id |> String.to_integer(),
|
solar_system_source_id: solar_system_source_id,
|
||||||
solar_system_target_id: solar_system_target_id |> String.to_integer()
|
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, _} =
|
{:ok, _} =
|
||||||
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
|
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
|
||||||
character_id: main_character_id,
|
character_id: main_character_id,
|
||||||
user_id: current_user_id,
|
user_id: current_user_id,
|
||||||
map_id: map_id,
|
map_id: map_id,
|
||||||
solar_system_source_id: "#{solar_system_source_id}" |> String.to_integer(),
|
solar_system_source_id: solar_system_source_id,
|
||||||
solar_system_target_id: "#{solar_system_target_id}" |> String.to_integer()
|
solar_system_target_id: solar_system_target_id
|
||||||
})
|
})
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|||||||
@@ -179,42 +179,50 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
|||||||
} = socket
|
} = socket
|
||||||
)
|
)
|
||||||
when not is_nil(main_character_id) do
|
when not is_nil(main_character_id) do
|
||||||
solar_system_source = get_integer(solar_system_source)
|
with solar_system_source <- get_integer(solar_system_source),
|
||||||
solar_system_target = get_integer(solar_system_target)
|
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(%{
|
if not is_nil(target_system) &&
|
||||||
map_id: map_id,
|
is_nil(target_system.linked_sig_eve_id) do
|
||||||
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
|
|
||||||
map_id
|
map_id
|
||||||
|> WandererApp.Map.Server.update_system_linked_sig_eve_id(%{
|
|> WandererApp.Map.Server.update_system_linked_sig_eve_id(%{
|
||||||
solar_system_id: solar_system_target,
|
solar_system_id: solar_system_target,
|
||||||
linked_sig_eve_id: signature_eve_id
|
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
|
||||||
|
end
|
||||||
|
|
||||||
WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_source)
|
WandererApp.Map.Server.Impl.broadcast!(map_id, :signatures_updated, solar_system_source)
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
else
|
||||||
_ ->
|
_ ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
@@ -320,6 +328,7 @@ defmodule WandererAppWeb.MapSignaturesEventHandler do
|
|||||||
:eve_id,
|
:eve_id,
|
||||||
:character_eve_id,
|
:character_eve_id,
|
||||||
:name,
|
:name,
|
||||||
|
:temporary_name,
|
||||||
:description,
|
:description,
|
||||||
:kind,
|
:kind,
|
||||||
:group,
|
:group,
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ defmodule WandererAppWeb.MapAuditLive do
|
|||||||
} =
|
} =
|
||||||
socket.assigns
|
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)
|
AshPagify.validate_and_run(query, params, opts)
|
||||||
|> case do
|
|> case do
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
|||||||
|
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
||||||
# alias WandererApp.SecurityAudit
|
alias WandererApp.SecurityAudit
|
||||||
|
alias WandererApp.Audit.RequestContext
|
||||||
|
|
||||||
@supported_versions ["1"]
|
@supported_versions ["1"]
|
||||||
@default_version "1"
|
@default_version "1"
|
||||||
@@ -260,14 +261,13 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
|||||||
|
|
||||||
defp log_deprecation_usage(conn, version) do
|
defp log_deprecation_usage(conn, version) do
|
||||||
user_id = get_user_id(conn)
|
user_id = get_user_id(conn)
|
||||||
|
request_details = RequestContext.build_request_details(conn)
|
||||||
|
|
||||||
# SecurityAudit.log_event(:deprecated_api_usage, user_id, %{
|
SecurityAudit.log_event(
|
||||||
# version: version,
|
:deprecated_api_usage,
|
||||||
# path: conn.request_path,
|
user_id,
|
||||||
# method: conn.method,
|
Map.put(request_details, :version, version)
|
||||||
# user_agent: get_user_agent(conn),
|
)
|
||||||
# ip_address: get_peer_ip(conn)
|
|
||||||
# })
|
|
||||||
|
|
||||||
conn
|
conn
|
||||||
end
|
end
|
||||||
@@ -316,12 +316,15 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
|||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
defp handle_version_error(conn, reason, _opts) do
|
defp handle_version_error(conn, reason, _opts) do
|
||||||
# SecurityAudit.log_event(:api_version_error, get_user_id(conn), %{
|
request_details = RequestContext.build_request_details(conn)
|
||||||
# reason: reason,
|
|
||||||
# path: conn.request_path,
|
SecurityAudit.log_event(
|
||||||
# method: conn.method,
|
:api_version_error,
|
||||||
# headers: get_version_headers(conn)
|
get_user_id(conn),
|
||||||
# })
|
request_details
|
||||||
|
|> Map.put(:reason, reason)
|
||||||
|
|> Map.put(:headers, get_version_headers(conn))
|
||||||
|
)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> send_version_error(400, "Invalid API version", %{
|
|> send_version_error(400, "Invalid API version", %{
|
||||||
@@ -376,35 +379,6 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
|||||||
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_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
|
defp get_version_headers(conn) do
|
||||||
%{
|
%{
|
||||||
"api-version" => get_req_header(conn, "api-version"),
|
"api-version" => get_req_header(conn, "api-version"),
|
||||||
@@ -429,8 +403,6 @@ defmodule WandererAppWeb.Plugs.ApiVersioning do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp get_breaking_changes(from_version, to_version) do
|
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"} => [
|
{"1.0", "1"} => [
|
||||||
"All API endpoints now use /api/v1/ prefix",
|
"All API endpoints now use /api/v1/ prefix",
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
|||||||
|
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
||||||
# alias WandererApp.SecurityAudit
|
alias WandererApp.SecurityAudit
|
||||||
|
alias WandererApp.Audit.RequestContext
|
||||||
|
|
||||||
# 10MB
|
# 10MB
|
||||||
@max_request_size 10 * 1024 * 1024
|
@max_request_size 10 * 1024 * 1024
|
||||||
@@ -344,13 +345,13 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
|||||||
# Log security threat
|
# Log security threat
|
||||||
user_id = get_user_id(conn)
|
user_id = get_user_id(conn)
|
||||||
|
|
||||||
# SecurityAudit.log_event(:security_alert, user_id, %{
|
request_details = RequestContext.build_request_details(conn)
|
||||||
# threats: threats,
|
|
||||||
# ip_address: get_peer_ip(conn),
|
SecurityAudit.log_event(
|
||||||
# user_agent: get_user_agent(conn),
|
:security_alert,
|
||||||
# request_path: conn.request_path,
|
user_id,
|
||||||
# method: conn.method
|
Map.put(request_details, :threats, threats)
|
||||||
# })
|
)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> send_validation_error(400, "Malicious content detected", %{
|
|> send_validation_error(400, "Malicious content detected", %{
|
||||||
@@ -457,35 +458,6 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
|||||||
end
|
end
|
||||||
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
|
defp send_validation_error(conn, status, message, details) do
|
||||||
error_response = %{
|
error_response = %{
|
||||||
error: message,
|
error: message,
|
||||||
@@ -504,14 +476,15 @@ defmodule WandererAppWeb.Plugs.RequestValidator do
|
|||||||
# Log the validation error
|
# Log the validation error
|
||||||
user_id = get_user_id(conn)
|
user_id = get_user_id(conn)
|
||||||
|
|
||||||
# SecurityAudit.log_event(:security_alert, user_id, %{
|
request_details = RequestContext.build_request_details(conn)
|
||||||
# error: "validation_error",
|
|
||||||
# message: Exception.message(error),
|
SecurityAudit.log_event(
|
||||||
# ip_address: get_peer_ip(conn),
|
:security_alert,
|
||||||
# user_agent: get_user_agent(conn),
|
user_id,
|
||||||
# request_path: conn.request_path,
|
request_details
|
||||||
# method: conn.method
|
|> Map.put(:error, "validation_error")
|
||||||
# })
|
|> Map.put(:message, Exception.message(error))
|
||||||
|
)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> send_validation_error(500, "Request validation failed", %{
|
|> send_validation_error(500, "Request validation failed", %{
|
||||||
|
|||||||
2
mix.exs
2
mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
|
|||||||
|
|
||||||
@source_url "https://github.com/wanderer-industries/wanderer"
|
@source_url "https://github.com/wanderer-industries/wanderer"
|
||||||
|
|
||||||
@version "1.75.3"
|
@version "1.76.0"
|
||||||
|
|
||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ As part of the Wanderer platform, a public API has been introduced to help users
|
|||||||
## Authentication
|
## 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:
|
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.
|
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