diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 000000000..4e1c91b02
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,14 @@
+{
+ "enabledPlugins": {
+ "frontend-design@claude-plugins-official": true,
+ "superpowers@claude-plugins-official": true,
+ "github@claude-plugins-official": true,
+ "playwright@claude-plugins-official": true,
+ "typescript-lsp@claude-plugins-official": true,
+ "semgrep@claude-plugins-official": true,
+ "pr-review-toolkit@claude-plugins-official": true
+ },
+ "worktree": {
+ "bgIsolation": "none"
+ }
+}
\ No newline at end of file
diff --git a/.github/renovate.json b/.github/renovate.json
index 953c304ae..e48f8482c 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -27,7 +27,8 @@
"ignoreDeps": [
"github.com/gorilla/websocket",
- "github.com/golang-jwt/jwt/v5"
+ "github.com/golang-jwt/jwt/v5",
+ "js-yaml"
],
"minimumReleaseAge": null,
@@ -413,16 +414,6 @@
]
},
"packageRules": [
- {
- "description": "Pin js-yaml to v4.x — transitive dependents (@eslint/eslintrc, markdownlint-cli2) use v4 API internally; v5 is a breaking rewrite incompatible with their pinned calls",
- "matchDatasources": [
- "npm"
- ],
- "matchPackageNames": [
- "js-yaml"
- ],
- "allowedVersions": "<5.0.0"
- },
{
"description": "Group GitHub Actions non-major updates into one PR",
"matchManagers": [
@@ -753,6 +744,23 @@
"github.com/gin-contrib/sse"
],
"sourceUrl": "https://github.com/gin-contrib/sse"
+ },
+ {
+ "description": "Fix Renovate lookup for prometheus/client_golang",
+ "matchDatasources": [
+ "go"
+ ],
+ "matchPackageNames": [
+ "github.com/prometheus/client_golang"
+ ],
+ "sourceUrl": "https://github.com/prometheus/client_golang"
+ },
+ {
+ "description": "Disable js-yaml updates — pinned in overrides for security; managed manually",
+ "matchPackageNames": [
+ "js-yaml"
+ ],
+ "enabled": false
}
]
}
diff --git a/.github/skills/examples/gorm-scanner-ci-workflow.yml b/.github/skills/examples/gorm-scanner-ci-workflow.yml
index 3494d876f..49b9f88b1 100644
--- a/.github/skills/examples/gorm-scanner-ci-workflow.yml
+++ b/.github/skills/examples/gorm-scanner-ci-workflow.yml
@@ -23,7 +23,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Setup Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.26.4"
diff --git a/.github/skills/security-scan-docker-image-scripts/run.sh b/.github/skills/security-scan-docker-image-scripts/run.sh
index 47e3b9e46..fe376c928 100755
--- a/.github/skills/security-scan-docker-image-scripts/run.sh
+++ b/.github/skills/security-scan-docker-image-scripts/run.sh
@@ -35,7 +35,7 @@ fi
# Check Grype
if ! command -v grype >/dev/null 2>&1; then
log_error "Grype not found - install from: https://github.com/anchore/grype"
- log_error "Installation: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.114.0"
+ log_error "Installation: curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.115.0"
error_exit "Grype is required for vulnerability scanning" 2
fi
@@ -50,8 +50,8 @@ SYFT_INSTALLED_VERSION=$(syft version | grep -oP 'Version:\s*\Kv?[0-9]+\.[0-9]+\
GRYPE_INSTALLED_VERSION=$(grype version | grep -oP 'Version:\s*\Kv?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown")
# Set defaults matching CI workflow
-set_default_env "SYFT_VERSION" "v1.45.1"
-set_default_env "GRYPE_VERSION" "v0.114.0"
+set_default_env "SYFT_VERSION" "v1.46.0"
+set_default_env "GRYPE_VERSION" "v0.115.0"
set_default_env "IMAGE_TAG" "charon:local"
set_default_env "FAIL_ON_SEVERITY" "Critical,High"
diff --git a/.github/workflows/auto-changelog.yml b/.github/workflows/auto-changelog.yml
index c0c698d0d..4664df1c6 100644
--- a/.github/workflows/auto-changelog.yml
+++ b/.github/workflows/auto-changelog.yml
@@ -24,6 +24,6 @@ jobs:
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Draft Release
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7
+ uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index 64fcb7624..be332d981 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -39,7 +39,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha || github.event.workflow_run.head_sha || github.sha }}
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: backend/go.mod
diff --git a/.github/workflows/codecov-upload.yml b/.github/workflows/codecov-upload.yml
index b8c7b468d..8cdf824fb 100644
--- a/.github/workflows/codecov-upload.yml
+++ b/.github/workflows/codecov-upload.yml
@@ -24,7 +24,7 @@ concurrency:
env:
GO_VERSION: '1.26.4'
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
GOTOOLCHAIN: local
permissions:
@@ -45,7 +45,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: backend/go.mod
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index fe0ab2df1..37890166b 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -63,7 +63,7 @@ jobs:
- name: Setup Go
if: matrix.language == 'go'
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
diff --git a/.github/workflows/docs-to-issues.yml b/.github/workflows/docs-to-issues.yml
index a71c14032..82db37eb6 100644
--- a/.github/workflows/docs-to-issues.yml
+++ b/.github/workflows/docs-to-issues.yml
@@ -23,7 +23,7 @@ concurrency:
cancel-in-progress: false
env:
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
permissions:
contents: write
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index f419c2bb0..597885590 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -18,7 +18,7 @@ concurrency:
cancel-in-progress: false
env:
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
jobs:
build:
diff --git a/.github/workflows/e2e-tests-split.yml b/.github/workflows/e2e-tests-split.yml
index 59baf93f9..82e1d702d 100644
--- a/.github/workflows/e2e-tests-split.yml
+++ b/.github/workflows/e2e-tests-split.yml
@@ -82,7 +82,7 @@ on:
pull_request:
env:
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
GO_VERSION: '1.26.4'
GOTOOLCHAIN: local
DOCKERHUB_REGISTRY: docker.io
@@ -142,7 +142,7 @@ jobs:
- name: Set up Go
if: steps.resolve-image.outputs.image_source == 'build'
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: backend/go.mod
@@ -158,7 +158,7 @@ jobs:
- name: Cache npm dependencies
if: steps.resolve-image.outputs.image_source == 'build'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml
index ee96d9c2f..9419a56a3 100644
--- a/.github/workflows/nightly-build.yml
+++ b/.github/workflows/nightly-build.yml
@@ -16,7 +16,7 @@ on:
env:
GO_VERSION: '1.26.4'
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
GOTOOLCHAIN: local
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
@@ -298,7 +298,7 @@ jobs:
echo "Primary SBOM generation failed or produced missing/invalid output; using deterministic Syft fallback"
- SYFT_VERSION="v1.45.1"
+ SYFT_VERSION="v1.46.0"
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH="$(uname -m)"
case "$ARCH" in
diff --git a/.github/workflows/propagate-changes.yml b/.github/workflows/propagate-changes.yml
index 85058dfe6..3fed2d43a 100644
--- a/.github/workflows/propagate-changes.yml
+++ b/.github/workflows/propagate-changes.yml
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: false
env:
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
permissions:
contents: write
diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml
index 884124db3..20aaf1b42 100644
--- a/.github/workflows/quality-checks.yml
+++ b/.github/workflows/quality-checks.yml
@@ -17,7 +17,7 @@ permissions:
env:
GO_VERSION: '1.26.4'
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
GOTOOLCHAIN: local
jobs:
@@ -31,7 +31,7 @@ jobs:
ref: ${{ github.sha }}
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: backend/go.mod
@@ -138,7 +138,7 @@ jobs:
} >> "$GITHUB_ENV"
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: backend/go.mod
diff --git a/.github/workflows/release-goreleaser.yml b/.github/workflows/release-goreleaser.yml
index 36408fba6..f4892d5a3 100644
--- a/.github/workflows/release-goreleaser.yml
+++ b/.github/workflows/release-goreleaser.yml
@@ -11,7 +11,7 @@ concurrency:
env:
GO_VERSION: '1.26.4'
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
GOTOOLCHAIN: local
permissions:
@@ -45,7 +45,7 @@ jobs:
fi
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: backend/go.mod
diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml
index d59e600ce..6bf9cfa60 100644
--- a/.github/workflows/renovate.yml
+++ b/.github/workflows/renovate.yml
@@ -28,12 +28,12 @@ jobs:
fetch-depth: 1
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: backend/go.mod
- name: Run Renovate
- uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
+ uses: renovatebot/github-action@6d859fc95779be83a0335ca704879b47e5d79641 # v46.1.16
with:
configurationFile: .github/renovate.json
token: ${{ secrets.RENOVATE_TOKEN || secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/supply-chain-pr.yml b/.github/workflows/supply-chain-pr.yml
index 56a28cb67..c8a51247c 100644
--- a/.github/workflows/supply-chain-pr.yml
+++ b/.github/workflows/supply-chain-pr.yml
@@ -288,7 +288,7 @@ jobs:
- name: Install Grype
if: steps.set-target.outputs.image_name != ''
run: |
- curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.114.0
+ curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.115.0
- name: Scan for vulnerabilities
if: steps.set-target.outputs.image_name != ''
diff --git a/.github/workflows/weekly-nightly-promotion.yml b/.github/workflows/weekly-nightly-promotion.yml
index e69b38a1d..7514ebb15 100644
--- a/.github/workflows/weekly-nightly-promotion.yml
+++ b/.github/workflows/weekly-nightly-promotion.yml
@@ -25,7 +25,7 @@ concurrency:
cancel-in-progress: false
env:
- NODE_VERSION: '24.17.0'
+ NODE_VERSION: '24.18.0'
SOURCE_BRANCH: 'nightly'
TARGET_BRANCH: 'main'
@@ -339,7 +339,7 @@ jobs:
1. **Review** the commit summary above
2. **Approve** if changes look correct
- 3. **Merge** using "Merge commit" to preserve history
+ 3. **Merge** using "Squash merge" or "Rebase merge" — do **NOT** use "Merge commit" (merge commits cause divergence that breaks the next weekly promotion)
---
@@ -430,6 +430,8 @@ jobs:
steps:
- name: Dispatch missing required workflows on nightly head
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
+ env:
+ PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
with:
script: |
const owner = context.repo.owner;
@@ -443,7 +445,7 @@ jobs:
const nightlyHeadSha = nightlyBranch.commit.sha;
core.info(`Current nightly HEAD for dispatch fallback: ${nightlyHeadSha}`);
- const prNumber = '${{ needs.create-promotion-pr.outputs.pr_number }}';
+ const prNumber = process.env.PR_NUMBER;
const requiredWorkflows = [
{ id: 'e2e-tests-split.yml' },
{ id: 'codeql.yml' },
diff --git a/.gitignore b/.gitignore
index c38365c28..590c1106e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -332,3 +332,4 @@ backend/***_cov.txt
charon-scan.tar
.claude/scheduled_tasks.lock
.claude/worktrees/
+scripts/tempCodeRunnerFile.sh
diff --git a/.grype.yaml b/.grype.yaml
index fa44fbc38..64fc1fdff 100644
--- a/.grype.yaml
+++ b/.grype.yaml
@@ -767,6 +767,79 @@ ignore:
# 2. If migrated: rebuild, confirm clean, remove this entry AND GHSA-jqcq-xjh3-6g23 / CVE-2026-4427 / CVE-2026-32286
# 3. If not: Extend expiry by 30 days
+ # GO-2026-5024 / CVE-2026-39824: golang.org/x/sys/windows NewNTUnicodeString integer overflow
+ # Severity: HIGH — Package: golang.org/x/sys v0.46.0 (embedded in /app/charon)
+ # Published: 2026-05-22
+ #
+ # Vulnerability Details:
+ # - NewNTUnicodeString in golang.org/x/sys/windows does not validate that the input string
+ # fits within the 16-bit byte-length field of NTUnicodeString. Oversized strings are
+ # silently truncated rather than returning an error (CWE-190: Integer Overflow).
+ # - Affected function lives exclusively in the /windows sub-package; Charon is a Linux-only
+ # application and this code path is never compiled into nor executed by the charon binary.
+ #
+ # Root Cause of Suppression (Database Discrepancy):
+ # - Go vulndb (pkg.go.dev/vuln/GO-2026-5024) marks this FIXED at golang.org/x/sys v0.44.0.
+ # Charon ships v0.46.0 — above the fix threshold. govulncheck reports clean.
+ # - NVD currently shows "no confirmed patch", causing Trivy v0.71.2+ to flag v0.46.0 as
+ # vulnerable. Local Trivy v0.52.2 with fresh DB (2026-06-23) finds no issue.
+ # - No golang.org/x/sys version > v0.46.0 (current latest) exists to upgrade to.
+ #
+ # Risk Assessment: ACCEPTED (Windows-only code path; database discrepancy; no upgrade path)
+ # - Attack surface is zero on Linux: the vulnerable function is in the windows package which
+ # Go's build constraints exclude from Linux builds entirely.
+ # - Charon does not ship Windows builds; no Windows runtime is present in the Docker image.
+ # - Per Go's own advisory, we are already running the patched version.
+ #
+ # Mitigation:
+ # - Monitor golang.org/x/sys releases; upgrade as soon as a version > v0.46.0 is available.
+ # - Monitor NVD for database update that aligns fixed version with Go vulndb (v0.44.0).
+ # - Remove suppression and upgrade once NVD converges or x/sys v0.47.0+ ships.
+ #
+ # Review:
+ # - Initial suppression 2026-06-23: NVD/Trivy discrepancy; no upgrade path exists.
+ # - Next review: 2026-09-23. Remove if x/sys > v0.46.0 is available OR NVD/Trivy divergence
+ # is resolved and Trivy no longer flags v0.46.0.
+ #
+ # Removal Criteria:
+ # - golang.org/x/sys v0.47.0+ is released: upgrade go.mod and verify clean scan
+ # - OR NVD updates fixed version to v0.44.0+, Trivy stops flagging v0.46.0
+ #
+ # References:
+ # - GO-2026-5024: https://pkg.go.dev/vuln/GO-2026-5024
+ # - CVE-2026-39824: https://nvd.nist.gov/vuln/detail/CVE-2026-39824
+ # - Go issue: https://go.dev/issue/78916
+ # - Fix CL: https://go.dev/cl/770080
+ - vulnerability: GO-2026-5024
+ package:
+ name: golang.org/x/sys
+ version: "v0.46.0"
+ type: go-module
+ reason: |
+ HIGH — Integer overflow in golang.org/x/sys/windows.NewNTUnicodeString v0.46.0 in /app/charon.
+ Go vulndb considers fixed at v0.44.0; we ship v0.46.0 (above threshold). The affected
+ function is Windows-only; Charon is Linux-only and this code path is never executed.
+ NVD database discrepancy causes Trivy v0.71.2+ to flag despite Go vulndb reporting clean.
+ No x/sys > v0.46.0 (current latest) exists to upgrade to. govulncheck reports clean.
+ expiry: "2026-09-23" # Review: Initial suppression 2026-06-23. Next review: 2026-09-23.
+
+ # Action items when this suppression expires:
+ # 1. Check if golang.org/x/sys v0.47.0+ is available: upgrade go.mod and run Trivy v0.71.2+
+ # 2. Check NVD CVE-2026-39824: if fixed version now shows v0.44.0, Trivy will stop flagging
+ # 3. If neither: extend expiry 30 days
+ - vulnerability: CVE-2026-39824
+ package:
+ name: golang.org/x/sys
+ version: "v0.46.0"
+ type: go-module
+ reason: |
+ HIGH — CVE alias for GO-2026-5024. Integer overflow in golang.org/x/sys/windows.NewNTUnicodeString.
+ Go vulndb considers fixed at v0.44.0; we ship v0.46.0. Windows-only function; Charon is
+ Linux-only. NVD/Trivy discrepancy; no upgrade path (v0.46.0 is latest). govulncheck clean.
+ expiry: "2026-09-23" # Review: Initial suppression 2026-06-23. Remove when Trivy/NVD converge.
+
+ # Action items: same as GO-2026-5024 entry above.
+
# Match exclusions (patterns to ignore during scanning)
# Use sparingly - prefer specific CVE suppressions above
match:
diff --git a/.trivyignore b/.trivyignore
index e8bae6b25..9109c8bed 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -151,3 +151,15 @@ CVE-2024-21495
# See also: .grype.yaml for full justification
# exp: 2026-09-05
CVE-2024-21492
+
+# GO-2026-5024 / CVE-2026-39824: golang.org/x/sys/windows NewNTUnicodeString integer overflow
+# Severity: HIGH — Package: golang.org/x/sys v0.46.0 in /app/charon
+# Go vulndb (pkg.go.dev/vuln/GO-2026-5024) considers this fixed at v0.44.0; we ship v0.46.0 (above
+# threshold). The affected function (windows.NewNTUnicodeString) is Windows-only and is not reachable
+# in Charon's Linux-only deployment. NVD shows "no confirmed patch", causing a discrepancy with
+# Trivy v0.71.2+; govulncheck and Trivy v0.52.2 (fresh DB 2026-06-23) both report clean. No
+# golang.org/x/sys version > v0.46.0 (current latest) exists to upgrade to at time of suppression.
+# Review by: 2026-09-23
+# See also: .grype.yaml for full justification
+# exp: 2026-09-23
+CVE-2026-39824
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9ce100ba4..bf8cd56bf 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -148,6 +148,7 @@ graph TB
| **Internationalization** | i18next | Latest | 5 language support |
| **Unit Testing** | Vitest | 4.1.0-beta.6 | Fast unit test runner |
| **E2E Testing** | Playwright | 1.58.2 | Browser automation |
+| **Theme System** | CSS Custom Properties + data-theme | N/A | `data-theme` attribute on `` drives 5 built-in themes, custom colors, system mode, and logo customization |
### Infrastructure
@@ -212,7 +213,7 @@ graph TB
│ │ │ └── layout/ # Layout components
│ │ ├── api/ # API client functions
│ │ ├── hooks/ # Custom React hooks
-│ │ ├── context/ # React context providers
+│ │ ├── context/ # React context providers (ThemeContext, AuthContext)
│ │ ├── locales/ # i18n translation files
│ │ ├── App.tsx # Root component
│ │ └── main.tsx # Application entry point
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e00c76962..0ff5796f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -69,6 +69,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
+- chore(security): verify CVE-2026-39824 (golang.org/x/sys) is not present — golang.org/x/sys already at v0.46.0, exceeding the v0.44.0 fix; no action required
+
- **CVE-2026-44982 / GHSA-rw47-hm26-6wr7**: Resolved high-severity CrowdSec AppSec vulnerability where HTTP request bodies were silently dropped for chunked/HTTP-2 requests, allowing WAF bypass
- Upgraded `CROWDSEC_VERSION` to `v1.7.8` in the Dockerfile
- Upgraded `caddy-crowdsec-bouncer` to `v0.12.1` to align with the updated crowdsec API
diff --git a/Dockerfile b/Dockerfile
index fac5c7c6b..188faff96 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -94,7 +94,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# ---- Frontend Builder ----
# Build the frontend using the BUILDPLATFORM to avoid arm64 musl Rollup native issues
# renovate: datasource=docker depName=node
-FROM --platform=$BUILDPLATFORM node:24.17.0-alpine3.24@sha256:156b55f92e98ccd5ef49578a8cea0df4679826564bad1c9d4ef04462b9f0ded6 AS frontend-builder
+FROM --platform=$BUILDPLATFORM node:24.18.0-alpine3.24@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontend-builder
WORKDIR /app/frontend
# Copy frontend package files
@@ -118,6 +118,11 @@ RUN apk upgrade --no-cache && \
# hadolint ignore=DL3059
RUN npm install -g picomatch@4.0.4 --no-fund --no-audit
+# Patch CVE-2026-12151: undici DoS via unbounded memory (fixed in 6.27.0) — bundled in Node.js 24.17.0 npm.
+# Remove when a patched Node.js 24 image ships undici >=6.27.0.
+# hadolint ignore=DL3059
+RUN npm install -g undici@6.27.0 --no-fund --no-audit
+
RUN npm ci --ignore-scripts
# Copy frontend source and build
@@ -171,7 +176,7 @@ RUN set -eux; \
# When dlv IS needed, we build it inside a temporary module that pins
# golang.org/x/sys to the patched version used by the rest of the project.
# renovate: datasource=go depName=github.com/go-delve/delve
-ARG DLV_VERSION=1.26.3
+ARG DLV_VERSION=1.27.0
# hadolint ignore=DL3059,DL4006
RUN if [ "$BUILD_DEBUG" = "1" ]; then \
echo "DEBUG build: installing Delve v${DLV_VERSION} with patched golang.org/x/sys..."; \
@@ -480,7 +485,7 @@ RUN go get github.com/expr-lang/expr@v${EXPR_LANG_VERSION} && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.13 && \
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
- go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.77.0 && \
+ go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.78.0 && \
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.7 && \
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.102.1 && \
# CVE-2026-32952: go-ntlmssp DoS via malicious NTLM challenge response
@@ -593,7 +598,7 @@ SHELL ["/bin/ash", "-o", "pipefail", "-c"]
# Note: In production, users should provide their own MaxMind license key
# This uses the publicly available GeoLite2 database
# In CI, timeout quickly rather than retrying to save build time
-ARG GEOLITE2_COUNTRY_SHA256=6e9212f23d3279a2454404d3b2a7ac30159fddbb9870ba33763014877296455c
+ARG GEOLITE2_COUNTRY_SHA256=1522faf7b5f6a96c3a0128bca813bd4b0ae24dce38e9d37acdff0efaa75fcdd9
RUN mkdir -p /app/data/geoip && \
if [ "$CI" = "true" ] || [ "$CI" = "1" ]; then \
echo "⏱️ CI detected - quick download (10s timeout, no retries)"; \
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
index 07f01ee9b..263cb98db 100644
--- a/backend/cmd/api/main.go
+++ b/backend/cmd/api/main.go
@@ -240,7 +240,7 @@ func main() {
}
logger.Log().Info("Plugin system initialized")
- router := server.NewRouter(cfg.FrontendDir)
+ router := server.NewRouter(cfg.FrontendDir, filepath.Dir(cfg.DatabasePath))
// Initialize structured logger with same writer as stdlib log so both capture logs
logger.Init(cfg.Debug, mw)
// Request ID middleware must run before recovery so the recover logs include the request id
diff --git a/backend/go.mod b/backend/go.mod
index af1d25221..c17831067 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -10,7 +10,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/yamux v0.1.2
- github.com/mattn/go-sqlite3 v1.14.46
+ github.com/mattn/go-sqlite3 v1.14.47
github.com/moby/moby/client v0.5.0
github.com/oschwald/geoip2-golang/v2 v2.2.0
github.com/prometheus/client_golang v1.23.2
@@ -24,8 +24,8 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.6.0
- gorm.io/gorm v1.31.1
- software.sslmate.com/src/go-pkcs12 v0.7.2
+ gorm.io/gorm v1.31.2
+ software.sslmate.com/src/go-pkcs12 v0.7.3
)
require (
@@ -70,11 +70,11 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.4.0 // indirect
- github.com/pelletier/go-toml/v2 v2.4.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.4.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.69.0 // indirect
- github.com/prometheus/procfs v0.20.1 // indirect
+ github.com/prometheus/procfs v0.21.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -90,8 +90,8 @@ require (
golang.org/x/arch v0.28.0 // indirect
golang.org/x/sys v0.46.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
- modernc.org/libc v1.73.4 // indirect
+ modernc.org/libc v1.73.5 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
- modernc.org/sqlite v1.52.0 // indirect
+ modernc.org/sqlite v1.53.0 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index 3c619e9d1..403cdac1f 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -93,8 +93,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
-github.com/mattn/go-sqlite3 v1.14.46 h1:ZfaNcYO/CGNMRxkN1vvG9qf+Y+uvXfgT9a6MlEw+HmU=
-github.com/mattn/go-sqlite3 v1.14.46/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
+github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
@@ -118,8 +118,8 @@ github.com/oschwald/geoip2-golang/v2 v2.2.0 h1:gdkhpnHQMiH9ymOI+zSB0QKFGH+n4TntN
github.com/oschwald/geoip2-golang/v2 v2.2.0/go.mod h1:xW4tCeQiNU1gqMD1x7zEH2CDNM3d796Ls50yxYDaX0U=
github.com/oschwald/maxminddb-golang/v2 v2.4.0 h1:3ftnrR1/XwiQ788bWIRhsE1DK3GOgJ6tm6S2qTktLm8=
github.com/oschwald/maxminddb-golang/v2 v2.4.0/go.mod h1:7jcFtmhWVDEV+UopVv9NjcPm200uMyEHN14LIVV4hW8=
-github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
-github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
+github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -128,8 +128,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
-github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
-github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/prometheus/procfs v0.21.0 h1:Qh/e6TlBjZf+XLLqNCqFGmCU6Kj/2Bu7kj3oAc0UnXc=
+github.com/prometheus/procfs v0.21.0/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -187,8 +187,8 @@ golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
@@ -199,8 +199,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
-golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
-golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
+golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -213,24 +213,24 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
-gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
-gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
-modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
-modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
-modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
+modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
-modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
-modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
+modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
+modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -239,13 +239,13 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
-modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
+modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
-software.sslmate.com/src/go-pkcs12 v0.7.2 h1:Rh9FoMaI5k7Oo6EOS+2/BnoZ+JFIS+XHjM0VGkSPXLM=
-software.sslmate.com/src/go-pkcs12 v0.7.2/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
+software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
diff --git a/backend/internal/api/handlers/banner_handler.go b/backend/internal/api/handlers/banner_handler.go
new file mode 100644
index 000000000..680c5e82d
--- /dev/null
+++ b/backend/internal/api/handlers/banner_handler.go
@@ -0,0 +1,37 @@
+package handlers
+
+import (
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// BannerHandler wraps ImageUploadHandler for the banner asset.
+type BannerHandler struct {
+ inner *ImageUploadHandler
+}
+
+// NewBannerHandler creates a new BannerHandler.
+func NewBannerHandler(db *gorm.DB, dataDir string) *BannerHandler {
+ cfg := AssetConfig{
+ FormField: "banner",
+ URLSettingKey: "ui.banner_url",
+ TypeSettingKey: "ui.banner_type",
+ FileBaseName: "banner",
+ }
+ return &BannerHandler{inner: NewImageUploadHandler(db, dataDir, cfg)}
+}
+
+// UploadBanner handles POST /api/v1/settings/banner.
+// Accepts multipart form with field "banner" (image/png, image/jpeg, image/webp only).
+// SVG uploads are explicitly rejected — too many XSS vectors to sanitize inline.
+// Validates MIME via server-side byte sniffing (does NOT trust multipart Content-Type).
+// Max size 2MB enforced via MaxBytesReader before any bytes are read.
+func (h *BannerHandler) UploadBanner(c *gin.Context) {
+ h.inner.UploadAsset(c)
+}
+
+// DeleteBanner handles DELETE /api/v1/settings/banner.
+// Clears banner settings from DB and removes the uploaded file.
+func (h *BannerHandler) DeleteBanner(c *gin.Context) {
+ h.inner.DeleteAsset(c)
+}
diff --git a/backend/internal/api/handlers/banner_handler_test.go b/backend/internal/api/handlers/banner_handler_test.go
new file mode 100644
index 000000000..c6fc43561
--- /dev/null
+++ b/backend/internal/api/handlers/banner_handler_test.go
@@ -0,0 +1,314 @@
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// minimalWebP is a 1×1 pixel valid WebP (RIFF header).
+var minimalWebP = []byte{
+ 0x52, 0x49, 0x46, 0x46, // "RIFF"
+ 0x24, 0x00, 0x00, 0x00, // file size (little-endian)
+ 0x57, 0x45, 0x42, 0x50, // "WEBP"
+ 0x56, 0x50, 0x38, 0x4C, // "VP8L"
+ 0x18, 0x00, 0x00, 0x00, // chunk size
+ 0x2F, 0x00, 0x00, 0x00, // VP8L signature
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
+}
+
+func setupBannerTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ dsn := fmt.Sprintf("file:banner_test_%s?mode=memory&cache=shared", t.Name())
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.Setting{}))
+ t.Cleanup(func() {
+ sqlDB, _ := db.DB()
+ _ = sqlDB.Close()
+ })
+ return db
+}
+
+func buildBannerRouter(db *gorm.DB, dataDir, role string) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ if role != "" {
+ c.Set("role", role)
+ c.Set("userID", uint(1))
+ }
+ c.Next()
+ })
+ h := NewBannerHandler(db, dataDir)
+ r.POST("/settings/banner", h.UploadBanner)
+ r.DELETE("/settings/banner", h.DeleteBanner)
+ return r
+}
+
+func buildBannerUploadRequest(t *testing.T, filename string, content []byte, contentType string) *http.Request {
+ t.Helper()
+ const fieldName = "banner"
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ var (
+ partWriter io.Writer
+ err error
+ )
+ if contentType != "" {
+ h := make(map[string][]string)
+ h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, filename)}
+ h["Content-Type"] = []string{contentType}
+ partWriter, err = writer.CreatePart(h)
+ } else {
+ partWriter, err = writer.CreateFormFile(fieldName, filename)
+ }
+ require.NoError(t, err)
+
+ _, err = partWriter.Write(content)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ req, err := http.NewRequest(http.MethodPost, "/settings/banner", body)
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ return req
+}
+
+// BN-01: Valid PNG upload returns 200 with url field.
+func TestBannerHandler_UploadBanner_ValidPNG(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ req := buildBannerUploadRequest(t, "mybanner.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), `"url"`)
+ assert.Contains(t, w.Body.String(), `/uploads/banner.png`)
+
+ // File must exist on disk
+ assert.FileExists(t, filepath.Join(dataDir, "uploads", "banner.png"))
+
+ // Settings must be persisted
+ var urlSetting models.Setting
+ require.NoError(t, db.Where("key = ?", "ui.banner_url").First(&urlSetting).Error)
+ assert.Equal(t, "/uploads/banner.png", urlSetting.Value)
+
+ var typeSetting models.Setting
+ require.NoError(t, db.Where("key = ?", "ui.banner_type").First(&typeSetting).Error)
+ assert.Equal(t, "upload", typeSetting.Value)
+}
+
+// BN-02: Valid WebP upload returns 200.
+func TestBannerHandler_UploadBanner_ValidWebP(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ req := buildBannerUploadRequest(t, "mybanner.webp", minimalWebP, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), `/uploads/banner.webp`)
+}
+
+// BN-03: File > 2MB returns 413.
+func TestBannerHandler_UploadBanner_FileTooLarge(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ large := make([]byte, maxImageSize+1024)
+ copy(large, minimalPNG)
+
+ req := buildBannerUploadRequest(t, "big.png", large, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
+}
+
+// BN-04: SVG file returns 400 (byte-sniff detection).
+func TestBannerHandler_UploadBanner_SVGRejected(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ req := buildBannerUploadRequest(t, "banner.svg", minimalSVG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// BN-05: SVG bytes with image/png Content-Type header returns 400.
+func TestBannerHandler_UploadBanner_SpoofedContentType(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ req := buildBannerUploadRequest(t, "totally-a-png.png", minimalSVG, "image/png")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// BN-06: Missing "banner" field returns 400.
+func TestBannerHandler_UploadBanner_MissingField(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ w2, _ := writer.CreateFormFile("file", "banner.png")
+ _, _ = w2.Write(minimalPNG)
+ _ = writer.Close()
+
+ req, _ := http.NewRequest(http.MethodPost, "/settings/banner", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "missing banner field")
+}
+
+// BN-07: DELETE removes file and clears ui.banner_url and ui.banner_type settings.
+func TestBannerHandler_DeleteBanner(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ // First upload
+ req := buildBannerUploadRequest(t, "banner.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ require.Equal(t, http.StatusOK, w.Code)
+
+ bannerPath := filepath.Join(dataDir, "uploads", "banner.png")
+ require.FileExists(t, bannerPath)
+
+ // Now delete
+ delReq, err := http.NewRequest(http.MethodDelete, "/settings/banner", http.NoBody)
+ require.NoError(t, err)
+ delW := httptest.NewRecorder()
+ r.ServeHTTP(delW, delReq)
+
+ assert.Equal(t, http.StatusOK, delW.Code)
+ assert.Contains(t, delW.Body.String(), "banner deleted")
+
+ // File must be gone
+ _, statErr := os.Stat(bannerPath)
+ assert.True(t, os.IsNotExist(statErr), "banner file should be removed after delete")
+
+ // Settings must be gone
+ var s models.Setting
+ assert.Error(t, db.Where("key = ?", "ui.banner_url").First(&s).Error)
+ assert.Error(t, db.Where("key = ?", "ui.banner_type").First(&s).Error)
+}
+
+// BN-08: Unauthenticated POST returns 401.
+func TestBannerHandler_UploadBanner_Unauthenticated(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "")
+
+ req := buildBannerUploadRequest(t, "banner.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+// BN-09: Non-admin POST returns 403.
+func TestBannerHandler_UploadBanner_NonAdmin(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "user")
+
+ req := buildBannerUploadRequest(t, "banner.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+// BN-11: Valid JPEG upload returns 200 (covers acceptedMIME JPEG branch).
+func TestBannerHandler_UploadBanner_ValidJPEG(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ // Minimal JPEG magic bytes: SOI marker (FF D8) + APP0 marker (FF E0)
+ minimalJPEG := []byte{
+ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, // SOI + APP0 marker + length
+ 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
+ 0x01, 0x01, 0x00, 0x00, 0x01, // version, aspect ratio units, X density
+ 0x00, 0x01, 0x00, 0x00, // Y density, thumbnail dimensions
+ }
+
+ req := buildBannerUploadRequest(t, "banner.jpg", minimalJPEG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), `/uploads/banner.jpg`)
+}
+
+// BN-12: Upload with closed DB returns 500 (upsertSetting failure).
+func TestBannerHandler_UploadBanner_DBClosed(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "admin")
+
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDB.Close())
+
+ req := buildBannerUploadRequest(t, "banner.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+}
+
+// BN-10: Unauthenticated DELETE returns 401.
+func TestBannerHandler_DeleteBanner_Unauthenticated(t *testing.T) {
+ db := setupBannerTestDB(t)
+ dataDir := t.TempDir()
+ r := buildBannerRouter(db, dataDir, "")
+
+ req, _ := http.NewRequest(http.MethodDelete, "/settings/banner", http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
diff --git a/backend/internal/api/handlers/custom_theme_handler.go b/backend/internal/api/handlers/custom_theme_handler.go
new file mode 100644
index 000000000..8b8ec9195
--- /dev/null
+++ b/backend/internal/api/handlers/custom_theme_handler.go
@@ -0,0 +1,149 @@
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// CustomThemeHandler handles CRUD for user-created named themes.
+type CustomThemeHandler struct {
+ db *gorm.DB
+}
+
+// NewCustomThemeHandler creates a new CustomThemeHandler.
+func NewCustomThemeHandler(db *gorm.DB) *CustomThemeHandler {
+ return &CustomThemeHandler{db: db}
+}
+
+type createThemeRequest struct {
+ Name string `json:"name" binding:"required,max=100"`
+ Colors string `json:"colors" binding:"required"`
+}
+
+type updateThemeRequest struct {
+ Name *string `json:"name"`
+ Colors *string `json:"colors"`
+}
+
+// ListThemes handles GET /api/v1/themes.
+// Returns all user-created themes ordered by created_at ASC.
+// Always returns a JSON array (never null) — empty array when no themes exist.
+func (h *CustomThemeHandler) ListThemes(c *gin.Context) {
+ result := []models.CustomTheme{}
+ if err := h.db.Order("created_at ASC").Find(&result).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list themes"})
+ return
+ }
+ if result == nil {
+ result = []models.CustomTheme{}
+ }
+ c.JSON(http.StatusOK, result)
+}
+
+// CreateTheme handles POST /api/v1/themes.
+// Body: { "name": string, "colors": string (JSON) }
+// Returns: 201 with the created CustomTheme record.
+func (h *CustomThemeHandler) CreateTheme(c *gin.Context) {
+ var req createThemeRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if !json.Valid([]byte(req.Colors)) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "colors must be valid JSON"})
+ return
+ }
+
+ theme := models.CustomTheme{
+ Name: req.Name,
+ Colors: req.Colors,
+ }
+
+ if err := h.db.Create(&theme).Error; err != nil {
+ if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE constraint failed") {
+ c.JSON(http.StatusConflict, gin.H{"error": "a theme with that name already exists"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create theme"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, theme)
+}
+
+// UpdateTheme handles PUT /api/v1/themes/:id.
+// Body: { "name"?: string, "colors"?: string (JSON) }
+// Returns: 200 with the updated record.
+func (h *CustomThemeHandler) UpdateTheme(c *gin.Context) {
+ id := c.Param("id")
+
+ var theme models.CustomTheme
+ if err := h.db.First(&theme, "id = ?", id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "theme not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch theme"})
+ return
+ }
+
+ var req updateThemeRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if req.Name != nil {
+ if len(*req.Name) == 0 || len(*req.Name) > 100 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name cannot be empty"})
+ return
+ }
+ theme.Name = *req.Name
+ }
+
+ if req.Colors != nil {
+ if !json.Valid([]byte(*req.Colors)) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "colors must be valid JSON"})
+ return
+ }
+ theme.Colors = *req.Colors
+ }
+
+ if err := h.db.Save(&theme).Error; err != nil {
+ if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE constraint failed") {
+ c.JSON(http.StatusConflict, gin.H{"error": "a theme with that name already exists"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update theme"})
+ return
+ }
+
+ c.JSON(http.StatusOK, theme)
+}
+
+// DeleteTheme handles DELETE /api/v1/themes/:id.
+// Returns: 200 { "message": "theme deleted" }
+func (h *CustomThemeHandler) DeleteTheme(c *gin.Context) {
+ id := c.Param("id")
+
+ result := h.db.Delete(&models.CustomTheme{}, "id = ?", id)
+ if result.Error != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete theme"})
+ return
+ }
+
+ if result.RowsAffected == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "theme not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "theme deleted"})
+}
diff --git a/backend/internal/api/handlers/custom_theme_handler_test.go b/backend/internal/api/handlers/custom_theme_handler_test.go
new file mode 100644
index 000000000..2a86a08e0
--- /dev/null
+++ b/backend/internal/api/handlers/custom_theme_handler_test.go
@@ -0,0 +1,540 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+func setupCustomThemeHandlerDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ dsn := fmt.Sprintf("file:custom_theme_handler_test_%s?mode=memory&cache=shared", t.Name())
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.CustomTheme{}))
+ t.Cleanup(func() {
+ sqlDB, _ := db.DB()
+ _ = sqlDB.Close()
+ })
+ return db
+}
+
+// buildThemeRouter creates a test router that simulates the auth middleware.
+// If authenticated is false, all requests return 401.
+func buildThemeRouter(db *gorm.DB, authenticated bool) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ if !authenticated {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
+ return
+ }
+ c.Set("userID", uint(1))
+ c.Set("role", "user")
+ c.Next()
+ })
+ h := NewCustomThemeHandler(db)
+ r.GET("/themes", h.ListThemes)
+ r.POST("/themes", h.CreateTheme)
+ r.PUT("/themes/:id", h.UpdateTheme)
+ r.DELETE("/themes/:id", h.DeleteTheme)
+ return r
+}
+
+func jsonBody(t *testing.T, v any) *bytes.Buffer {
+ t.Helper()
+ b, err := json.Marshal(v)
+ require.NoError(t, err)
+ return bytes.NewBuffer(b)
+}
+
+// UT-01: GET returns empty array [] (not null) when no themes exist.
+func TestCustomThemeHandler_ListThemes_EmptyArray(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ req, _ := http.NewRequest(http.MethodGet, "/themes", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Equal(t, "[]", w.Body.String())
+}
+
+// UT-02: POST creates with non-empty UUID id and returns 201.
+func TestCustomThemeHandler_CreateTheme_Success(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ body := jsonBody(t, map[string]string{
+ "name": "My Dark Theme",
+ "colors": `{"bgBase":"15 23 42"}`,
+ })
+ req, _ := http.NewRequest(http.MethodPost, "/themes", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code)
+
+ var resp models.CustomTheme
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ assert.NotEmpty(t, resp.ID)
+ assert.Equal(t, "My Dark Theme", resp.Name)
+ assert.Len(t, resp.ID, 36, "ID should be a UUID")
+}
+
+// UT-03: POST duplicate name returns 409.
+func TestCustomThemeHandler_CreateTheme_DuplicateName(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ body1 := jsonBody(t, map[string]string{"name": "Duplicate", "colors": `{"bgBase":"0 0 0"}`})
+ req1, _ := http.NewRequest(http.MethodPost, "/themes", body1)
+ req1.Header.Set("Content-Type", "application/json")
+ w1 := httptest.NewRecorder()
+ r.ServeHTTP(w1, req1)
+ require.Equal(t, http.StatusCreated, w1.Code)
+
+ body2 := jsonBody(t, map[string]string{"name": "Duplicate", "colors": `{"bgBase":"255 255 255"}`})
+ req2, _ := http.NewRequest(http.MethodPost, "/themes", body2)
+ req2.Header.Set("Content-Type", "application/json")
+ w2 := httptest.NewRecorder()
+ r.ServeHTTP(w2, req2)
+
+ assert.Equal(t, http.StatusConflict, w2.Code)
+ assert.Contains(t, w2.Body.String(), "a theme with that name already exists")
+}
+
+// UT-04: POST empty name returns 400.
+func TestCustomThemeHandler_CreateTheme_EmptyName(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ body := jsonBody(t, map[string]string{"name": "", "colors": `{"bgBase":"0 0 0"}`})
+ req, _ := http.NewRequest(http.MethodPost, "/themes", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// UT-05: POST invalid JSON in colors returns 400.
+func TestCustomThemeHandler_CreateTheme_InvalidColorsJSON(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ body := jsonBody(t, map[string]string{"name": "Bad Colors", "colors": `{invalid json}`})
+ req, _ := http.NewRequest(http.MethodPost, "/themes", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "colors must be valid JSON")
+}
+
+// UT-06: PUT updates name and/or colors, returns 200 with updated record.
+func TestCustomThemeHandler_UpdateTheme_Success(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ // Create first
+ createBody := jsonBody(t, map[string]string{"name": "Original", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ var created models.CustomTheme
+ require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &created))
+
+ // Update name
+ newName := "Updated Name"
+ updateBody := jsonBody(t, map[string]any{"name": newName})
+ updateReq, _ := http.NewRequest(http.MethodPut, "/themes/"+created.ID, updateBody)
+ updateReq.Header.Set("Content-Type", "application/json")
+ updateW := httptest.NewRecorder()
+ r.ServeHTTP(updateW, updateReq)
+
+ assert.Equal(t, http.StatusOK, updateW.Code)
+
+ var updated models.CustomTheme
+ require.NoError(t, json.Unmarshal(updateW.Body.Bytes(), &updated))
+ assert.Equal(t, newName, updated.Name)
+ assert.Equal(t, `{"bgBase":"0 0 0"}`, updated.Colors)
+}
+
+// UT-06b: PUT updates colors only.
+func TestCustomThemeHandler_UpdateTheme_ColorsOnly(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ createBody := jsonBody(t, map[string]string{"name": "Color Test", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ var created models.CustomTheme
+ require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &created))
+
+ newColors := `{"bgBase":"255 255 255"}`
+ updateBody := jsonBody(t, map[string]any{"colors": newColors})
+ updateReq, _ := http.NewRequest(http.MethodPut, "/themes/"+created.ID, updateBody)
+ updateReq.Header.Set("Content-Type", "application/json")
+ updateW := httptest.NewRecorder()
+ r.ServeHTTP(updateW, updateReq)
+
+ assert.Equal(t, http.StatusOK, updateW.Code)
+
+ var updated models.CustomTheme
+ require.NoError(t, json.Unmarshal(updateW.Body.Bytes(), &updated))
+ assert.Equal(t, "Color Test", updated.Name)
+ assert.Equal(t, newColors, updated.Colors)
+}
+
+// UT-07: PUT nonexistent id returns 404.
+func TestCustomThemeHandler_UpdateTheme_NotFound(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ newName := "Ghost"
+ body := jsonBody(t, map[string]any{"name": &newName})
+ req, _ := http.NewRequest(http.MethodPut, "/themes/nonexistent-uuid", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+// UT-06c: PUT with empty name returns 400.
+func TestCustomThemeHandler_UpdateTheme_EmptyName(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ createBody := jsonBody(t, map[string]string{"name": "Some Theme", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ var created models.CustomTheme
+ require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &created))
+
+ // Empty name in PUT
+ body := jsonBody(t, map[string]string{"name": ""})
+ req, _ := http.NewRequest(http.MethodPut, "/themes/"+created.ID, body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "name cannot be empty")
+}
+
+// UT-08: DELETE removes record and returns 200 {"message":"theme deleted"}.
+func TestCustomThemeHandler_DeleteTheme_Success(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ // Create first
+ createBody := jsonBody(t, map[string]string{"name": "To Delete", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ var created models.CustomTheme
+ require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &created))
+
+ // Delete
+ delReq, _ := http.NewRequest(http.MethodDelete, "/themes/"+created.ID, nil)
+ delW := httptest.NewRecorder()
+ r.ServeHTTP(delW, delReq)
+
+ assert.Equal(t, http.StatusOK, delW.Code)
+ assert.Contains(t, delW.Body.String(), "theme deleted")
+
+ // Verify deleted from DB
+ var theme models.CustomTheme
+ assert.Error(t, db.First(&theme, "id = ?", created.ID).Error)
+}
+
+// UT-09: DELETE nonexistent returns 404.
+func TestCustomThemeHandler_DeleteTheme_NotFound(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ req, _ := http.NewRequest(http.MethodDelete, "/themes/nonexistent-uuid", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+// UT-10: Unauthenticated calls return 401.
+func TestCustomThemeHandler_Unauthenticated(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, false)
+
+ tests := []struct {
+ method string
+ path string
+ }{
+ {http.MethodGet, "/themes"},
+ {http.MethodPost, "/themes"},
+ {http.MethodPut, "/themes/some-id"},
+ {http.MethodDelete, "/themes/some-id"},
+ }
+
+ for _, tt := range tests {
+ req, _ := http.NewRequest(tt.method, tt.path, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusUnauthorized, w.Code, "expected 401 for %s %s", tt.method, tt.path)
+ }
+}
+
+// UT-11: ListThemes returns 500 when DB is unavailable.
+func TestCustomThemeHandler_ListThemes_DBError(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDB.Close())
+
+ req, _ := http.NewRequest(http.MethodGet, "/themes", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "failed to list themes")
+}
+
+// UT-12: CreateTheme with malformed request body returns 400.
+func TestCustomThemeHandler_CreateTheme_MalformedBody(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ req, _ := http.NewRequest(http.MethodPost, "/themes", bytes.NewBufferString("not json {{{"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// UT-13: CreateTheme returns 500 when DB is unavailable (non-duplicate error).
+func TestCustomThemeHandler_CreateTheme_DBError(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDB.Close())
+
+ body := jsonBody(t, map[string]string{"name": "Theme", "colors": `{"bgBase":"0 0 0"}`})
+ req, _ := http.NewRequest(http.MethodPost, "/themes", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+}
+
+// UT-14: UpdateTheme with malformed JSON body returns 400.
+func TestCustomThemeHandler_UpdateTheme_MalformedBody(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ createBody := jsonBody(t, map[string]string{"name": "Original2", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ var created models.CustomTheme
+ require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &created))
+
+ req, _ := http.NewRequest(http.MethodPut, "/themes/"+created.ID, bytes.NewBufferString("not json"))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// UT-15: UpdateTheme with name > 100 chars returns 400.
+func TestCustomThemeHandler_UpdateTheme_NameTooLong(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ createBody := jsonBody(t, map[string]string{"name": "Short Name", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ var created models.CustomTheme
+ require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &created))
+
+ longName := string(make([]byte, 101))
+ for i := range longName {
+ longName = longName[:i] + "a" + longName[i+1:]
+ }
+ body := jsonBody(t, map[string]any{"name": longName})
+ req, _ := http.NewRequest(http.MethodPut, "/themes/"+created.ID, body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "name cannot be empty")
+}
+
+// UT-16: UpdateTheme with invalid colors JSON returns 400.
+func TestCustomThemeHandler_UpdateTheme_InvalidColorsJSON(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ createBody := jsonBody(t, map[string]string{"name": "ColorTest2", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ var created models.CustomTheme
+ require.NoError(t, json.Unmarshal(createW.Body.Bytes(), &created))
+
+ badColors := `{invalid json}`
+ body := jsonBody(t, map[string]any{"colors": badColors})
+ req, _ := http.NewRequest(http.MethodPut, "/themes/"+created.ID, body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "colors must be valid JSON")
+}
+
+// UT-17: UpdateTheme returns 500 when DB is unavailable on fetch.
+func TestCustomThemeHandler_UpdateTheme_DBFetchError(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDB.Close())
+
+ newName := "Ghost"
+ body := jsonBody(t, map[string]any{"name": newName})
+ req, _ := http.NewRequest(http.MethodPut, "/themes/some-uuid", body)
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "failed to fetch theme")
+}
+
+// UT-18: UpdateTheme with duplicate name on rename returns 409.
+func TestCustomThemeHandler_UpdateTheme_DuplicateName(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ // Create theme A
+ bodyA := jsonBody(t, map[string]string{"name": "Theme A", "colors": `{"bgBase":"0 0 0"}`})
+ reqA, _ := http.NewRequest(http.MethodPost, "/themes", bodyA)
+ reqA.Header.Set("Content-Type", "application/json")
+ wA := httptest.NewRecorder()
+ r.ServeHTTP(wA, reqA)
+ require.Equal(t, http.StatusCreated, wA.Code)
+
+ // Create theme B
+ bodyB := jsonBody(t, map[string]string{"name": "Theme B", "colors": `{"bgBase":"255 255 255"}`})
+ reqB, _ := http.NewRequest(http.MethodPost, "/themes", bodyB)
+ reqB.Header.Set("Content-Type", "application/json")
+ wB := httptest.NewRecorder()
+ r.ServeHTTP(wB, reqB)
+ require.Equal(t, http.StatusCreated, wB.Code)
+
+ var themeB models.CustomTheme
+ require.NoError(t, json.Unmarshal(wB.Body.Bytes(), &themeB))
+
+ // Try to rename B → "Theme A" (already exists)
+ nameA := "Theme A"
+ updateBody := jsonBody(t, map[string]any{"name": nameA})
+ updateReq, _ := http.NewRequest(http.MethodPut, "/themes/"+themeB.ID, updateBody)
+ updateReq.Header.Set("Content-Type", "application/json")
+ updateW := httptest.NewRecorder()
+ r.ServeHTTP(updateW, updateReq)
+
+ assert.Equal(t, http.StatusConflict, updateW.Code)
+ assert.Contains(t, updateW.Body.String(), "a theme with that name already exists")
+}
+
+// UT-19: DeleteTheme returns 500 when DB is unavailable.
+func TestCustomThemeHandler_DeleteTheme_DBError(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDB.Close())
+
+ req, _ := http.NewRequest(http.MethodDelete, "/themes/some-uuid", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "failed to delete theme")
+}
+
+// Verify ListThemes returns populated array after create.
+func TestCustomThemeHandler_ListThemes_AfterCreate(t *testing.T) {
+ db := setupCustomThemeHandlerDB(t)
+ r := buildThemeRouter(db, true)
+
+ createBody := jsonBody(t, map[string]string{"name": "Listed Theme", "colors": `{"bgBase":"0 0 0"}`})
+ createReq, _ := http.NewRequest(http.MethodPost, "/themes", createBody)
+ createReq.Header.Set("Content-Type", "application/json")
+ createW := httptest.NewRecorder()
+ r.ServeHTTP(createW, createReq)
+ require.Equal(t, http.StatusCreated, createW.Code)
+
+ listReq, _ := http.NewRequest(http.MethodGet, "/themes", nil)
+ listW := httptest.NewRecorder()
+ r.ServeHTTP(listW, listReq)
+
+ assert.Equal(t, http.StatusOK, listW.Code)
+
+ var themes []models.CustomTheme
+ require.NoError(t, json.Unmarshal(listW.Body.Bytes(), &themes))
+ assert.Len(t, themes, 1)
+ assert.Equal(t, "Listed Theme", themes[0].Name)
+}
diff --git a/backend/internal/api/handlers/image_upload_handler.go b/backend/internal/api/handlers/image_upload_handler.go
new file mode 100644
index 000000000..2e01e2e76
--- /dev/null
+++ b/backend/internal/api/handlers/image_upload_handler.go
@@ -0,0 +1,177 @@
+package handlers
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+const (
+ maxImageSize = 2 * 1024 * 1024 // 2MB
+ maxLogoSize = maxImageSize // backward-compat alias used by logo_handler_test.go
+ mimeSniffBytes = 512
+ logoFilePerm = 0o644
+ uploadsDirPerm = 0o755
+)
+
+// AssetConfig parameterizes a specific image asset type.
+type AssetConfig struct {
+ FormField string // multipart field name, e.g. "logo" or "banner"
+ URLSettingKey string // e.g. "ui.logo_url" or "ui.banner_url"
+ TypeSettingKey string // e.g. "ui.logo_type" or "ui.banner_type"
+ FileBaseName string // e.g. "logo" or "banner" (extension appended at runtime)
+}
+
+// ImageUploadHandler handles generic image asset upload and deletion.
+// Both UploadAsset and DeleteAsset enforce requireAuthenticatedAdmin internally.
+type ImageUploadHandler struct {
+ db *gorm.DB
+ dataDir string
+ cfg AssetConfig
+}
+
+// NewImageUploadHandler creates a new ImageUploadHandler.
+func NewImageUploadHandler(db *gorm.DB, dataDir string, cfg AssetConfig) *ImageUploadHandler {
+ return &ImageUploadHandler{db: db, dataDir: dataDir, cfg: cfg}
+}
+
+// UploadAsset handles POST for any image asset type.
+// Calls requireAuthenticatedAdmin(c) at the top — returns 401/403 and aborts if not satisfied.
+func (h *ImageUploadHandler) UploadAsset(c *gin.Context) {
+ if !requireAuthenticatedAdmin(c) {
+ return
+ }
+
+ // Apply size limit BEFORE reading any bytes
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxImageSize+1)
+
+ if err := c.Request.ParseMultipartForm(maxImageSize); err != nil {
+ c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds 2 MB limit"})
+ return
+ }
+
+ file, _, err := c.Request.FormFile(h.cfg.FormField)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "missing " + h.cfg.FormField + " field"})
+ return
+ }
+ defer func() { _ = file.Close() }()
+
+ // Read first 512 bytes for MIME detection
+ sniff := make([]byte, mimeSniffBytes)
+ n, err := io.ReadFull(file, sniff)
+ if err != nil && err != io.ErrUnexpectedEOF {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file"})
+ return
+ }
+ sniff = sniff[:n]
+
+ // Server-side MIME detection — do NOT trust multipart Content-Type header
+ detected := http.DetectContentType(sniff)
+
+ ext, ok := acceptedMIME(detected)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("unsupported file type: %s", detected)})
+ return
+ }
+
+ // Read the remaining bytes and reconstruct full file content
+ rest, err := io.ReadAll(file)
+ if err != nil {
+ c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds 2 MB limit"})
+ return
+ }
+
+ fullContent := append(sniff, rest...) //nolint:gocritic // intentional concat
+
+ // Ensure uploads directory exists
+ uploadsDir := filepath.Join(h.dataDir, "uploads")
+ // #nosec G301 -- uploads directory needs to be accessible by web server
+ if err := os.MkdirAll(uploadsDir, uploadsDirPerm); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
+ return
+ }
+
+ // Filename is always normalized — no user input in filename (prevents path traversal)
+ filename := h.cfg.FileBaseName + ext
+ destPath := filepath.Join(uploadsDir, filename)
+
+ // Write file with fixed permissions
+ // #nosec G306 -- asset file needs to be world-readable for web serving
+ if err := os.WriteFile(destPath, fullContent, logoFilePerm); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write " + h.cfg.FileBaseName + " file"})
+ return
+ }
+
+ assetURL := "/uploads/" + filename
+
+ // Save settings to DB
+ if err := h.upsertSetting(h.cfg.URLSettingKey, assetURL, "ui", "string"); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save " + h.cfg.FileBaseName + " url setting"})
+ return
+ }
+ if err := h.upsertSetting(h.cfg.TypeSettingKey, "upload", "ui", "string"); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save " + h.cfg.FileBaseName + " type setting"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"url": assetURL})
+}
+
+// DeleteAsset handles DELETE for any image asset type.
+// Calls requireAuthenticatedAdmin(c) at the top — returns 401/403 and aborts if not satisfied.
+func (h *ImageUploadHandler) DeleteAsset(c *gin.Context) {
+ if !requireAuthenticatedAdmin(c) {
+ return
+ }
+
+ // Delete DB settings
+ h.db.Where("key IN ?", []string{h.cfg.URLSettingKey, h.cfg.TypeSettingKey}).Delete(&models.Setting{})
+
+ // Remove any asset file (try all accepted extensions)
+ if h.dataDir != "" {
+ uploadsDir := filepath.Join(h.dataDir, "uploads")
+ for _, ext := range []string{".png", ".jpg", ".webp"} {
+ path := filepath.Join(uploadsDir, h.cfg.FileBaseName+ext)
+ _ = os.Remove(path) // ignore not-found errors
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": h.cfg.FileBaseName + " deleted"})
+}
+
+// acceptedMIME maps a detected MIME type to a file extension.
+// Returns ("", false) for disallowed types (including SVG).
+func acceptedMIME(mime string) (string, bool) {
+ switch mime {
+ case "image/png":
+ return ".png", true
+ case "image/jpeg":
+ return ".jpg", true
+ case "image/webp":
+ return ".webp", true
+ default:
+ return "", false
+ }
+}
+
+// upsertSetting creates or updates a single Setting row.
+func (h *ImageUploadHandler) upsertSetting(key, value, category, settingType string) error {
+ s := models.Setting{
+ Key: key,
+ Value: value,
+ Category: category,
+ Type: settingType,
+ }
+ if err := h.db.Where(models.Setting{Key: key}).Assign(s).FirstOrCreate(&s).Error; err != nil {
+ return fmt.Errorf("upsert setting %s: %w", key, err)
+ }
+ return nil
+}
diff --git a/backend/internal/api/handlers/logo_handler.go b/backend/internal/api/handlers/logo_handler.go
new file mode 100644
index 000000000..9891b54fc
--- /dev/null
+++ b/backend/internal/api/handlers/logo_handler.go
@@ -0,0 +1,37 @@
+package handlers
+
+import (
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// LogoHandler wraps ImageUploadHandler for the logo asset.
+type LogoHandler struct {
+ inner *ImageUploadHandler
+}
+
+// NewLogoHandler creates a new LogoHandler.
+func NewLogoHandler(db *gorm.DB, dataDir string) *LogoHandler {
+ cfg := AssetConfig{
+ FormField: "logo",
+ URLSettingKey: "ui.logo_url",
+ TypeSettingKey: "ui.logo_type",
+ FileBaseName: "logo",
+ }
+ return &LogoHandler{inner: NewImageUploadHandler(db, dataDir, cfg)}
+}
+
+// UploadLogo handles POST /api/v1/settings/logo.
+// Accepts multipart form with field "logo" (image/png, image/jpeg, image/webp only).
+// SVG uploads are explicitly rejected — too many XSS vectors to sanitize inline.
+// Validates MIME via server-side byte sniffing (does NOT trust multipart Content-Type).
+// Max size 2MB enforced via MaxBytesReader before any bytes are read.
+func (h *LogoHandler) UploadLogo(c *gin.Context) {
+ h.inner.UploadAsset(c)
+}
+
+// DeleteLogo handles DELETE /api/v1/settings/logo.
+// Clears logo settings from DB and removes the uploaded file.
+func (h *LogoHandler) DeleteLogo(c *gin.Context) {
+ h.inner.DeleteAsset(c)
+}
diff --git a/backend/internal/api/handlers/logo_handler_test.go b/backend/internal/api/handlers/logo_handler_test.go
new file mode 100644
index 000000000..a6be8e5c5
--- /dev/null
+++ b/backend/internal/api/handlers/logo_handler_test.go
@@ -0,0 +1,356 @@
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+)
+
+// minimalPNG is a 1×1 pixel valid PNG (67 bytes).
+var minimalPNG = []byte{
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
+ 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length + type
+ 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // width=1, height=1
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // bitDepth=8, colorType=RGB
+ 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
+ 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
+ 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC,
+ 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E,
+ 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND
+}
+
+// minimalSVG bytes — XML-based, DetectContentType returns "text/xml; charset=utf-8"
+var minimalSVG = []byte(``)
+
+func setupLogoTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ dsn := fmt.Sprintf("file:logo_test_%s?mode=memory&cache=shared", t.Name())
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.Setting{}))
+ t.Cleanup(func() {
+ sqlDB, _ := db.DB()
+ _ = sqlDB.Close()
+ })
+ return db
+}
+
+func buildLogoRouter(db *gorm.DB, dataDir, role string) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ if role != "" {
+ c.Set("role", role)
+ c.Set("userID", uint(1))
+ }
+ c.Next()
+ })
+ h := NewLogoHandler(db, dataDir)
+ r.POST("/settings/logo", h.UploadLogo)
+ r.DELETE("/settings/logo", h.DeleteLogo)
+ return r
+}
+
+func buildUploadRequest(t *testing.T, filename string, content []byte, contentType string) *http.Request {
+ t.Helper()
+ const fieldName = "logo"
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ var (
+ partWriter io.Writer
+ err error
+ )
+ if contentType != "" {
+ h := make(map[string][]string)
+ h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, filename)}
+ h["Content-Type"] = []string{contentType}
+ partWriter, err = writer.CreatePart(h)
+ } else {
+ partWriter, err = writer.CreateFormFile(fieldName, filename)
+ }
+ require.NoError(t, err)
+
+ _, err = partWriter.Write(content)
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ req, err := http.NewRequest(http.MethodPost, "/settings/logo", body)
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ return req
+}
+
+// BL-01: Valid PNG upload writes file and returns HTTP 200 with url field.
+func TestLogoHandler_UploadLogo_ValidPNG(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ req := buildUploadRequest(t,"mylogo.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), `"url"`)
+ assert.Contains(t, w.Body.String(), `/uploads/logo.png`)
+
+ // File must exist on disk
+ assert.FileExists(t, filepath.Join(dataDir, "uploads", "logo.png"))
+
+ // Settings must be persisted
+ var urlSetting models.Setting
+ require.NoError(t, db.Where("key = ?", "ui.logo_url").First(&urlSetting).Error)
+ assert.Equal(t, "/uploads/logo.png", urlSetting.Value)
+
+ var typeSetting models.Setting
+ require.NoError(t, db.Where("key = ?", "ui.logo_type").First(&typeSetting).Error)
+ assert.Equal(t, "upload", typeSetting.Value)
+}
+
+// BL-02: File exceeds 2MB → HTTP 413.
+func TestLogoHandler_UploadLogo_FileTooLarge(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ // Generate > 2MB of data with PNG header so MIME detection doesn't trip first
+ large := make([]byte, maxLogoSize+1024)
+ copy(large, minimalPNG)
+
+ req := buildUploadRequest(t,"big.png", large, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
+}
+
+// BL-03: Non-image MIME (text/html) → HTTP 400.
+func TestLogoHandler_UploadLogo_NonImageMIME(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ htmlBytes := []byte("
not an image")
+ req := buildUploadRequest(t,"evil.html", htmlBytes, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// BL-04: SVG file → HTTP 400 (byte detection).
+func TestLogoHandler_UploadLogo_SVGRejected(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ req := buildUploadRequest(t,"logo.svg", minimalSVG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// BL-04b: Spoofed Content-Type (SVG bytes with .png extension and image/png header) → HTTP 400.
+func TestLogoHandler_UploadLogo_SpoofedContentType(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ // SVG bytes but declared as image/png
+ req := buildUploadRequest(t,"totally-a-png.png", minimalSVG, "image/png")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ // Server-side detection must see SVG bytes and reject
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// BL-04c: No Content-Type header in multipart part, valid PNG bytes → HTTP 200.
+func TestLogoHandler_UploadLogo_NoContentTypePNGBytes(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ // Use CreateFormFile (no explicit Content-Type on the part)
+ req := buildUploadRequest(t,"logo.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+// BL-05: DELETE logo clears setting and removes file → HTTP 200.
+func TestLogoHandler_DeleteLogo(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ // First upload
+ req := buildUploadRequest(t,"logo.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ require.Equal(t, http.StatusOK, w.Code)
+
+ logoPath := filepath.Join(dataDir, "uploads", "logo.png")
+ require.FileExists(t, logoPath)
+
+ // Now delete
+ delReq, err := http.NewRequest(http.MethodDelete, "/settings/logo", http.NoBody)
+ require.NoError(t, err)
+ delW := httptest.NewRecorder()
+ r.ServeHTTP(delW, delReq)
+
+ assert.Equal(t, http.StatusOK, delW.Code)
+
+ // File must be gone
+ _, statErr := os.Stat(logoPath)
+ assert.True(t, os.IsNotExist(statErr), "logo file should be removed after delete")
+
+ // Settings must be gone
+ var s models.Setting
+ assert.Error(t, db.Where("key = ?", "ui.logo_url").First(&s).Error)
+}
+
+// BL-06: Unauthenticated upload → HTTP 401.
+func TestLogoHandler_UploadLogo_Unauthenticated(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ // No role set (empty string = no context values)
+ r := buildLogoRouter(db, dataDir, "")
+
+ req := buildUploadRequest(t,"logo.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
+
+// BL-07: Non-admin upload → HTTP 403.
+func TestLogoHandler_UploadLogo_NonAdmin(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "user")
+
+ req := buildUploadRequest(t,"logo.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+}
+
+// TestLogoHandler_UploadLogo_EmptyFile uploads a 0-byte file and expects HTTP 400 "failed to read file".
+// An empty multipart file causes io.ReadFull to return io.EOF (not io.ErrUnexpectedEOF),
+// which the handler treats as a read error.
+func TestLogoHandler_UploadLogo_EmptyFile(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ req := buildUploadRequest(t, "empty.png", []byte{}, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "failed to read file")
+}
+
+// TestLogoHandler_UploadLogo_MkdirAllFailure places a regular file where the uploads directory
+// should be so that os.MkdirAll returns "not a directory", exercising the 500 error path.
+func TestLogoHandler_UploadLogo_MkdirAllFailure(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+
+ // Block directory creation: write a regular file at the "uploads" path
+ uploadsPath := filepath.Join(dataDir, "uploads")
+ require.NoError(t, os.WriteFile(uploadsPath, []byte("block"), 0o644))
+
+ r := buildLogoRouter(db, dataDir, "admin")
+ req := buildUploadRequest(t, "logo.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "failed to create uploads directory")
+}
+
+// TestLogoHandler_UploadLogo_WriteFileFailure creates a read-only uploads directory so that
+// os.WriteFile returns a permission error, exercising the 500 "failed to write" path.
+func TestLogoHandler_UploadLogo_WriteFileFailure(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+
+ // Create uploads dir but make it non-writable
+ uploadsDir := filepath.Join(dataDir, "uploads")
+ require.NoError(t, os.MkdirAll(uploadsDir, 0o555))
+ t.Cleanup(func() { _ = os.Chmod(uploadsDir, 0o755) })
+
+ r := buildLogoRouter(db, dataDir, "admin")
+ req := buildUploadRequest(t, "logo.png", minimalPNG, "")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+ assert.Contains(t, w.Body.String(), "failed to write")
+}
+
+// TestLogoHandler_UploadLogo_MissingField ensures missing "logo" field returns 400.
+func TestLogoHandler_UploadLogo_MissingField(t *testing.T) {
+ db := setupLogoTestDB(t)
+ dataDir := t.TempDir()
+ r := buildLogoRouter(db, dataDir, "admin")
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ // Write a different field name
+ w2, _ := writer.CreateFormFile("file", "logo.png")
+ _, _ = w2.Write(minimalPNG)
+ _ = writer.Close()
+
+ req, _ := http.NewRequest(http.MethodPost, "/settings/logo", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// TestAcceptedMIME tests the MIME acceptlist.
+func TestAcceptedMIME(t *testing.T) {
+ tests := []struct {
+ mime string
+ wantExt string
+ wantOK bool
+ }{
+ {"image/png", ".png", true},
+ {"image/jpeg", ".jpg", true},
+ {"image/webp", ".webp", true},
+ {"image/svg+xml", "", false},
+ {"text/html; charset=utf-8", "", false},
+ {"application/octet-stream", "", false},
+ {"text/xml; charset=utf-8", "", false},
+ }
+ for _, tt := range tests {
+ ext, ok := acceptedMIME(tt.mime)
+ assert.Equal(t, tt.wantOK, ok, "mime=%s", tt.mime)
+ assert.Equal(t, tt.wantExt, ext, "mime=%s", tt.mime)
+ }
+}
diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go
index 93115c84e..44fdaf132 100644
--- a/backend/internal/api/routes/routes.go
+++ b/backend/internal/api/routes/routes.go
@@ -133,6 +133,7 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
&models.TunnelConfig{}, // Issue #368: Hecate tunnel provider configs
&models.OrthrusAgent{}, // Issue #369: Orthrus reverse-proxy agent registry
&models.RequestLog{}, // Issue #25: Enhanced dashboard statistics
+ &models.CustomTheme{}, // User-created named color-scheme themes
); err != nil {
return fmt.Errorf("auto migrate: %w", err)
}
@@ -343,6 +344,23 @@ func RegisterWithDeps(ctx context.Context, router *gin.Engine, db *gorm.DB, cfg
management.PATCH("/settings", settingsHandler.UpdateSetting) // E2E tests use PATCH
management.PATCH("/config", settingsHandler.PatchConfig) // Bulk configuration update
+ // Logo upload/delete — admin only
+ logoHandler := handlers.NewLogoHandler(db, dataRoot)
+ management.POST("/settings/logo", logoHandler.UploadLogo)
+ management.DELETE("/settings/logo", logoHandler.DeleteLogo)
+
+ // Banner upload/delete — admin only (enforced inside ImageUploadHandler)
+ bannerHandler := handlers.NewBannerHandler(db, dataRoot)
+ management.POST("/settings/banner", bannerHandler.UploadBanner)
+ management.DELETE("/settings/banner", bannerHandler.DeleteBanner)
+
+ // User-created named themes — available to all management users (not admin-only)
+ themeHandler := handlers.NewCustomThemeHandler(db)
+ management.GET("/themes", themeHandler.ListThemes)
+ management.POST("/themes", themeHandler.CreateTheme)
+ management.PUT("/themes/:id", themeHandler.UpdateTheme)
+ management.DELETE("/themes/:id", themeHandler.DeleteTheme)
+
// SMTP Configuration
management.GET("/settings/smtp", middleware.RequireRole(models.RoleAdmin), settingsHandler.GetSMTPConfig)
management.POST("/settings/smtp", settingsHandler.UpdateSMTPConfig)
diff --git a/backend/internal/models/custom_theme.go b/backend/internal/models/custom_theme.go
new file mode 100644
index 000000000..5d3721092
--- /dev/null
+++ b/backend/internal/models/custom_theme.go
@@ -0,0 +1,26 @@
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// CustomTheme stores a user-created named color-scheme theme.
+// Colors is stored as a JSON text blob matching the frontend CustomThemeColors type.
+type CustomTheme struct {
+ ID string `json:"id" gorm:"primaryKey;type:text"`
+ Name string `json:"name" gorm:"type:text;not null;uniqueIndex"`
+ Colors string `json:"colors" gorm:"type:text;not null"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// BeforeCreate generates a UUID if ID is empty.
+func (ct *CustomTheme) BeforeCreate(tx *gorm.DB) error {
+ if ct.ID == "" {
+ ct.ID = uuid.New().String()
+ }
+ return nil
+}
diff --git a/backend/internal/models/custom_theme_test.go b/backend/internal/models/custom_theme_test.go
new file mode 100644
index 000000000..a8cac08ff
--- /dev/null
+++ b/backend/internal/models/custom_theme_test.go
@@ -0,0 +1,62 @@
+package models
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+ "gorm.io/gorm/logger"
+)
+
+func setupCustomThemeTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&CustomTheme{}))
+ return db
+}
+
+// TestCustomTheme_BeforeCreate_GeneratesUUID verifies UUID is set when ID is empty.
+func TestCustomTheme_BeforeCreate_GeneratesUUID(t *testing.T) {
+ db := setupCustomThemeTestDB(t)
+
+ ct := &CustomTheme{
+ Name: "Test Theme",
+ Colors: `{"bgBase":"15 23 42"}`,
+ }
+ require.NoError(t, db.Create(ct).Error)
+
+ assert.NotEmpty(t, ct.ID, "ID should be set after create")
+ assert.Len(t, ct.ID, 36, "UUID should be 36 characters")
+}
+
+// TestCustomTheme_BeforeCreate_PreservesExistingID verifies existing ID is not overwritten.
+func TestCustomTheme_BeforeCreate_PreservesExistingID(t *testing.T) {
+ db := setupCustomThemeTestDB(t)
+
+ existingID := "550e8400-e29b-41d4-a716-446655440000"
+ ct := &CustomTheme{
+ ID: existingID,
+ Name: "Pre-set Theme",
+ Colors: `{"bgBase":"30 41 59"}`,
+ }
+ require.NoError(t, db.Create(ct).Error)
+
+ assert.Equal(t, existingID, ct.ID, "pre-set ID should not be overwritten")
+}
+
+// TestCustomTheme_UniqueNameConstraint verifies duplicate names are rejected.
+func TestCustomTheme_UniqueNameConstraint(t *testing.T) {
+ db := setupCustomThemeTestDB(t)
+
+ ct1 := &CustomTheme{Name: "Duplicate", Colors: `{"bgBase":"0 0 0"}`}
+ require.NoError(t, db.Create(ct1).Error)
+
+ ct2 := &CustomTheme{Name: "Duplicate", Colors: `{"bgBase":"255 255 255"}`}
+ err := db.Create(ct2).Error
+ assert.Error(t, err, "duplicate name should fail")
+}
diff --git a/backend/internal/models/request_log.go b/backend/internal/models/request_log.go
index c87850f47..d042980a5 100644
--- a/backend/internal/models/request_log.go
+++ b/backend/internal/models/request_log.go
@@ -11,13 +11,13 @@ import (
// ClientIPHash stores the first 16 bytes of the SHA-256 hash of the client IP
// as a hex string (GDPR-safe pseudonymisation).
type RequestLog struct {
- ID string `json:"id" gorm:"primaryKey;type:text"`
- HostID string `json:"host_id" gorm:"type:text;not null;index"`
- Timestamp time.Time `json:"timestamp" gorm:"not null;index"`
- Method string `json:"method" gorm:"type:text;not null"`
- StatusCode int `json:"status_code" gorm:"not null;index"`
- BytesSent int64 `json:"bytes_sent" gorm:"not null"`
- DurationMs int64 `json:"duration_ms" gorm:"not null"`
+ ID string `json:"id" gorm:"primaryKey;type:text"`
+ HostID string `json:"host_id" gorm:"type:text;not null;index"`
+ Timestamp time.Time `json:"timestamp" gorm:"not null;index"`
+ Method string `json:"method" gorm:"type:text;not null"`
+ StatusCode int `json:"status_code" gorm:"not null;index"`
+ BytesSent int64 `json:"bytes_sent" gorm:"not null"`
+ DurationMs int64 `json:"duration_ms" gorm:"not null"`
// gorm-scanner:ignore — ClientIPHash is a GDPR-safe SHA-256 pseudonymisation (first 16 bytes, hex-encoded); not raw PII.
ClientIPHash string `json:"client_ip_hash" gorm:"type:text"`
}
diff --git a/backend/internal/orthrus/server_coverage_test.go b/backend/internal/orthrus/server_coverage_test.go
index f940981e0..89f4697f2 100644
--- a/backend/internal/orthrus/server_coverage_test.go
+++ b/backend/internal/orthrus/server_coverage_test.go
@@ -368,6 +368,7 @@ func TestOrthrusServer_HandleWebSocket_ExternalProxyFails(t *testing.T) {
assert.False(t, status.Active)
assert.NotEmpty(t, status.Error)
}
+
// TestHandleWebSocket_DisplacesExistingSession covers server.go:98-100 —
// the displacement block that closes the old session when a new connection
// arrives for an agent UUID that already has an active session in the map.
diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go
index 45cd2c7fc..9a5a098af 100644
--- a/backend/internal/server/server.go
+++ b/backend/internal/server/server.go
@@ -9,13 +9,20 @@ import (
)
// NewRouter creates a new Gin router with frontend static file serving.
-func NewRouter(frontendDir string) *gin.Engine {
+// dataDir is the application data directory (e.g. filepath.Dir(cfg.DatabasePath)).
+// When non-empty, /uploads is served from dataDir/uploads for custom logo files.
+func NewRouter(frontendDir string, dataDir string) *gin.Engine {
router := gin.Default()
// Gin trusts all proxies by default. In v1.11.x, SetTrustedProxies(nil) disables
// trusting forwarded headers entirely, making Context.ClientIP() use the remote
// socket address. Only enable trusted proxies via an explicit allow-list.
_ = router.SetTrustedProxies(nil)
+ // Serve uploaded logo files from data directory
+ if dataDir != "" {
+ router.Static("/uploads", dataDir+"/uploads")
+ }
+
// Serve frontend static files
if frontendDir != "" {
router.Static("/assets", frontendDir+"/assets")
diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go
index de08110bb..1451d75fd 100644
--- a/backend/internal/server/server_test.go
+++ b/backend/internal/server/server_test.go
@@ -20,9 +20,10 @@ func TestNewRouter(t *testing.T) {
err := os.WriteFile(filepath.Join(tempDir, "index.html"), []byte(""), 0o644)
assert.NoError(t, err)
- router := NewRouter(tempDir)
+ router := NewRouter(tempDir, "")
assert.NotNil(t, router)
+
// Test static file serving
req, _ := http.NewRequest("GET", "/", http.NoBody)
w := httptest.NewRecorder()
@@ -38,6 +39,13 @@ func TestNewRouter(t *testing.T) {
assert.NotContains(t, apiW.Body.String(), "")
assert.Contains(t, apiW.Body.String(), "not found")
+ // Test /unknown-path SPA fallback: return HTML for non-API paths
+ spaReq, _ := http.NewRequest("GET", "/some/deep/route", http.NoBody)
+ spaW := httptest.NewRecorder()
+ router.ServeHTTP(spaW, spaReq)
+ assert.Equal(t, http.StatusOK, spaW.Code)
+ assert.Contains(t, spaW.Body.String(), "")
+
// Test WebP/SVG static routes return 200 when the file exists
for _, asset := range []struct{ route, file string }{
{"/banner.webp", "banner.webp"},
@@ -53,3 +61,22 @@ func TestNewRouter(t *testing.T) {
assert.Equal(t, http.StatusOK, rw.Code, "route %s should return 200", asset.route)
}
}
+
+// TestNewRouter_WithDataDir verifies that /uploads is served from dataDir/uploads when dataDir is set.
+func TestNewRouter_WithDataDir(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ dataDir := t.TempDir()
+ uploadsDir := filepath.Join(dataDir, "uploads")
+ assert.NoError(t, os.MkdirAll(uploadsDir, 0o755))
+ // #nosec G306 -- Test fixture needs to be world-readable for HTTP serving test
+ assert.NoError(t, os.WriteFile(filepath.Join(uploadsDir, "logo.png"), []byte("fake-png"), 0o644))
+
+ router := NewRouter("", dataDir)
+ assert.NotNil(t, router)
+
+ req, _ := http.NewRequest("GET", "/uploads/logo.png", http.NoBody)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+}
diff --git a/docs/features.md b/docs/features.md
index 83b2836d7..61094c160 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -337,9 +337,19 @@ Charon speaks your language. The interface is available in English, Spanish, Fre
---
-### 🎨 Dark Mode & Modern UI
+### 🎨 Themes & Personalization
-Easy on the eyes, day or night. Toggle between light and dark themes to match your preference. The clean, modern interface makes managing complex setups feel simple.
+Make Charon look exactly the way you want. Choose from five built-in themes — Dark, Light, High Contrast Dark, High Contrast Light, and Solarized — or design your own with a full color picker. Your theme is remembered and applied the instant the page loads, with no flicker or flash.
+
+**What you can do:**
+
+- **Theme Gallery** — Open Settings > Appearance to browse all themes as visual preview cards. Hover over any card to see it applied live before you commit to it.
+- **Follow System** — Turn this on and Charon automatically switches between light and dark to match your operating system's setting.
+- **Custom Colors** — Not happy with the built-in options? Pick any color you like for every part of the interface.
+- **Import & Export** — Save your custom theme as a file and share it with others, or load a theme someone else created. Great for backups too.
+- **Logo Customization** — Upload your own image or paste a URL to replace the Charon logo in the sidebar with something that feels like home.
+- **Banner Image** — Upload a custom banner image (PNG, JPG, GIF, or WebP, up to 5 MB) to display across the top of the app. Preview it before saving, and remove it any time — all from Settings > Appearance.
+- **Named Custom Themes** — Build your own color theme from scratch, give it a name, and save it. You can create as many as you like, switch between them, edit them later, or delete ones you no longer need. Find it under Settings > Appearance.
→ [Learn More](features/ui-themes.md)
diff --git a/docs/features/ui-themes.md b/docs/features/ui-themes.md
index 4c765c9b8..fa71eacd4 100644
--- a/docs/features/ui-themes.md
+++ b/docs/features/ui-themes.md
@@ -1,117 +1,84 @@
---
-title: Dark Mode & Modern UI
-description: Toggle between light and dark themes with a clean, modern interface
+title: Themes & Personalization
+description: Choose from built-in themes, build your own with a color picker, and upload a custom logo
---
-# Dark Mode & Modern UI
+# Themes & Personalization
-Easy on the eyes, day or night. Toggle between light and dark themes to match your preference. The clean, modern interface makes managing complex setups feel simple.
+Make Charon look exactly the way you want. Pick from five ready-made themes, build a completely custom one with a color picker, or upload your own logo. Your preferences are saved instantly and applied the moment the page loads — no flicker, no flash.
-## Overview
+## Choosing a Theme
-Charon's interface is built with **Tailwind CSS v4** and a modern React component library. Dark mode is the default, with automatic system preference detection and manual override support.
+Open **Settings → Appearance** to see the theme gallery. Each theme is shown as a visual preview card so you know exactly what you're getting before you pick it.
-### Design Philosophy
+### Built-In Themes
-- **Dark-first**: Optimized for low-light environments and reduced eye strain
-- **Semantic colors**: Consistent meaning across light and dark modes
-- **Accessibility-first**: WCAG 2.1 AA compliant with focus management
-- **Responsive**: Works seamlessly on desktop, tablet, and mobile
+| Theme | Best For |
+|-------|---------|
+| **Dark** (default) | Low-light environments, long sessions |
+| **Light** | Bright rooms, printing |
+| **High Contrast Dark** | Maximum readability on dark backgrounds |
+| **High Contrast Light** | Maximum readability on light backgrounds |
+| **Solarized** | A popular low-eyestrain palette |
-## Why a Modern UI Matters
+Hover over any card to see a live preview applied to the whole interface before you commit to it.
-| Feature | Benefit |
-|---------|---------|
-| **Dark Mode** | Reduced eye strain during long sessions |
-| **Semantic Tokens** | Consistent, predictable color behavior |
-| **Component Library** | Professional, polished interactions |
-| **Keyboard Navigation** | Full functionality without a mouse |
-| **Screen Reader Support** | Accessible to all users |
+### Follow System
-## Theme System
+Turn on **Follow System** and Charon will automatically switch between Light and Dark to match your operating system's setting. When you change your OS theme, Charon changes with it — no manual toggle needed.
-### Color Tokens
+## Custom Colors
-Charon uses semantic color tokens that automatically adapt:
+Not happy with the built-in options? Click **Custom** in the gallery to open the color picker. You can set any color you like for every part of the interface:
-| Token | Light Mode | Dark Mode | Usage |
-|-------|------------|-----------|-------|
-| `--background` | White | Slate 950 | Page backgrounds |
-| `--foreground` | Slate 900 | Slate 50 | Primary text |
-| `--primary` | Blue 600 | Blue 500 | Actions, links |
-| `--destructive` | Red 600 | Red 500 | Delete, errors |
-| `--muted` | Slate 100 | Slate 800 | Secondary surfaces |
-| `--border` | Slate 200 | Slate 700 | Dividers, outlines |
+- Background and surface colors
+- Text and heading colors
+- Accent and action colors
+- Border and divider colors
+- Status indicator colors (success, warning, error)
-### Switching Themes
+Changes apply instantly as you pick colors, so you can see exactly what everything will look like.
-1. Click the **theme toggle** in the top navigation
-2. Choose: **Light**, **Dark**, or **System**
-3. Preference is saved to local storage
+## Saving & Sharing Themes
-## Component Library
+### Export
-### Core Components
+Built a custom theme you love? Click **Export Theme** to download it as a small `.json` file. Keep it as a backup or share it with other Charon users.
-| Component | Purpose | Accessibility |
-|-----------|---------|---------------|
-| **Badge** | Status indicators, tags | Color + icon redundancy |
-| **Alert** | Notifications, warnings | ARIA live regions |
-| **Dialog** | Modal interactions | Focus trap, ESC to close |
-| **DataTable** | Sortable data display | Keyboard navigation |
-| **Tooltip** | Contextual help | Delay for screen readers |
-| **DropdownMenu** | Action menus | Arrow key navigation |
+### Import
-### Status Indicators
+Got a theme file from someone else? Click **Import Theme** and select the file. Charon validates it before applying — no broken themes, no security risks.
-Visual status uses color AND icons for accessibility:
+## Logo Customization
-- ✅ **Online** - Green badge with check icon
-- ⚠️ **Warning** - Yellow badge with alert icon
-- ❌ **Offline** - Red badge with X icon
-- ⏳ **Pending** - Gray badge with clock icon
+Replace the Charon logo in the sidebar with your own image. Go to **Settings → Appearance → Logo** and either:
-## Accessibility Features
+- **Upload a file** — PNG, JPG, or WebP, up to 2 MB
+- **Paste a URL** — Point to any image hosted online
-### WCAG 2.1 Compliance
+Click **Reset** at any time to go back to the default Charon logo.
-- **Color contrast**: Minimum 4.5:1 for text, 3:1 for UI elements
-- **Focus indicators**: Visible focus rings on all interactive elements
-- **Text scaling**: UI adapts to browser zoom up to 200%
-- **Motion**: Respects `prefers-reduced-motion`
+> **Note:** Logo changes require admin access. Non-admin users will see the logo but cannot change it.
-### Keyboard Navigation
+## How It Works (No Flicker)
-| Key | Action |
-|-----|--------|
-| `Tab` | Move between interactive elements |
-| `Enter` / `Space` | Activate buttons, links |
-| `Escape` | Close dialogs, dropdowns |
-| `Arrow keys` | Navigate within menus, tables |
-
-### Screen Reader Support
+Charon applies your saved theme before the page finishes loading. This means there is no flash of the wrong colors when the page first appears — the right theme is there from the very first pixel.
-- Semantic HTML structure with landmarks
-- ARIA labels on icon-only buttons
-- Live regions for dynamic content updates
-- Skip links for main content access
+## Accessibility
-## Customization
+All built-in themes meet **WCAG 2.1 AA** contrast requirements. The High Contrast themes exceed AA and approach AAA for users who need extra readability. Charon also respects `prefers-reduced-motion` — animations are minimized if your system has that setting enabled.
-### CSS Variables Override
+## Keyboard Navigation
-Advanced users can customize the theme via CSS:
-
-```css
-/* Custom brand colors */
-:root {
- --primary: 210 100% 50%; /* Custom blue */
- --radius: 0.75rem; /* Rounder corners */
-}
-```
+| Key | Action |
+|-----|--------|
+| `Tab` | Move between theme cards |
+| `Enter` / `Space` | Select a theme |
+| `Escape` | Close color picker / preview overlay |
+| `Arrow keys` | Navigate within the gallery |
## Related
-- [Notifications](notifications.md) - Visual notification system
-- [REST API](api.md) - Programmatic access
+- [Notifications](notifications.md) — Visual notification system
+- [REST API](api.md) — Programmatic access
- [Back to Features](../features.md)
diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md
index f52bdbcb5..edc317d33 100644
--- a/docs/plans/current_spec.md
+++ b/docs/plans/current_spec.md
@@ -1,9 +1,9 @@
-# Technical Specification: TrafficVolumeChart Invisible Line Bug Fix
+# Theme System Extensions — Banner Upload + Named User Themes
-**Version:** 1.0
-**Date:** 2026-06-17
-**Branch:** `feature/stats`
-**Status:** Draft
+**Author:** Planning Agent
+**Date:** 2026-06-21
+**Branch:** feature/theme
+**Scope:** Backend + Frontend additions extending the existing theme system
---
@@ -11,7 +11,7 @@
1. [Introduction](#1-introduction)
2. [Research Findings](#2-research-findings)
-3. [Technical Specification](#3-technical-specification)
+3. [Technical Specifications](#3-technical-specifications)
4. [Implementation Plan](#4-implementation-plan)
5. [Acceptance Criteria](#5-acceptance-criteria)
6. [Commit Slicing Strategy](#6-commit-slicing-strategy)
@@ -20,291 +20,1249 @@
## 1. Introduction
-### Overview
+### 1.1 Overview
-The Traffic Volume widget on the Dashboard page renders a chart container with correct axes, grid lines, and a functional hover tooltip, but the line itself is completely invisible. This means users see a blank white rectangle where the traffic trend line should appear, even when data is present and the tooltip confirms values on hover.
+This plan extends the existing Charon theme system (fully implemented on `feature/theme`) with two new capabilities:
-### Objectives
+1. **Banner Image Upload** — a dedicated upload endpoint and UI for the expanded-sidebar banner image, separate from the logo upload already implemented.
+2. **Custom User-Created Named Themes** — multi-slot, DB-persisted color-scheme themes that users can create, name, edit, switch between, and delete. This replaces the single `localStorage`-only "Custom" theme slot.
-- Identify and fix the root cause of the invisible line in `TrafficVolumeChart`.
-- Add a unit test assertion that guards against regression of this specific failure mode.
-- Confirm tooltip behavior is unaffected by the fix.
-- Add a Playwright E2E assertion that the chart SVG contains a rendered `` element when data is present.
+### 1.2 Objectives
-### Scope
+- Provide a clean separation between the small collapsed-state logo and the wide expanded-state banner, both customizable independently.
+- Persist user-created themes in the database so they survive browser cache clears and are shared across devices logged into the same Charon instance.
+- Maintain backward compatibility with existing single-slot custom theme data stored in `localStorage`.
+- Follow all existing patterns: DRY, GORM auto-migrate, UUID server-side, `filepath.Clean`, 85%+ coverage, no new npm packages.
-- **In scope**: `frontend/src/components/stats/TrafficVolumeChart.tsx`, `frontend/src/components/stats/__tests__/TrafficVolumeChart.test.tsx`, `tests/stats.spec.ts`.
-- **Out of scope**: Backend, other chart components, CSS design token definitions.
+### 1.3 Non-Goals
+
+- Do NOT redesign the FOUC fix or the existing built-in theme gallery — those are complete and working.
+- Do NOT support SVG uploads (same restriction as logo handler).
+- Do NOT break the existing `custom` theme localStorage behavior for users who have not migrated.
---
## 2. Research Findings
-### 2.1 Component Architecture
+### 2.1 Existing Architecture Summary
-`TrafficVolumeChart` is a pure presentational component located at `frontend/src/components/stats/TrafficVolumeChart.tsx`. It accepts `data: TrafficBucket[] | undefined`, `isLoading: boolean`, and `bucket: StatsBucket` props. It renders via Recharts `LineChart` + `Line`. The component is consumed in `frontend/src/pages/Dashboard.tsx` at line 325.
+**Backend:**
-### 2.2 Chart Library
+- `backend/internal/api/handlers/logo_handler.go` — `LogoHandler` with `UploadLogo` / `DeleteLogo`. Uses `requireAuthenticatedAdmin`, `http.MaxBytesReader`, byte-sniff MIME detection, fixed filename `logo.`, upserts `ui.logo_url` and `ui.logo_type` into the `Setting` model.
+- `backend/internal/models/setting.go` — `Setting` struct with `Key`, `Value`, `Type`, `Category`, `UpdatedAt`. All persistence uses `db.Where(Setting{Key:key}).Assign(s).FirstOrCreate(&s)`.
+- `backend/internal/api/routes/routes.go` — `RegisterWithDeps` runs `AutoMigrate` for all models, wires `logoHandler` under `management` group (requires `RequireManagementAccess`), registers `POST /settings/logo` and `DELETE /settings/logo`.
+- `backend/internal/server/server.go` — `NewRouter` serves `router.Static("/uploads", dataDir+"/uploads")` for uploaded files.
-The project uses **Recharts v3.8.1** (`"recharts": "^3.8.1"` in `frontend/package.json`). Recharts renders charts as inline SVG. Visual properties such as `stroke` and `fill` on components like `` and `` are passed directly as SVG presentation attributes — they are **not** resolved through the browser's CSS engine.
+**Frontend:**
-### 2.3 Root Cause: Invalid SVG Color Value via CSS Variable
+- `frontend/src/context/ThemeContextValue.ts` — `ThemeId = BuiltInTheme | MetaTheme`, `MetaTheme = 'system' | 'custom'`. `CustomTheme = { name, colors }`. Single `CUSTOM_THEME_STORAGE_KEY = 'charon-custom-theme'`.
+- `frontend/src/context/ThemeContext.tsx` — `ThemeProvider` with single-slot `customTheme` state. `setCustomTheme(colors, name)` writes to `localStorage` and sets `theme` to `'custom'`.
+- `frontend/src/components/Layout.tsx` — reads `settings['ui.logo_url']` as `customLogoUrl`. Sidebar header renders:
+ - Collapsed: `` (logoSrc = customLogoUrl or `/logo.png`)
+ - Expanded with customLogoUrl: `` where `bannerSrc = customLogoUrl` (currently the SAME setting used for both)
+ - Expanded without customLogoUrl: ``
+- `frontend/src/pages/AppearanceSettings.tsx` — uses `getSettings` query to read `ui.logo_url`, mounts `LogoCustomizer`, `ThemeGallery`, `CustomColorPicker`, `ThemeImportExport`.
+- `frontend/src/api/settings.ts` — `uploadLogo(file)` posts multipart to `/settings/logo`, `deleteLogo()` sends DELETE.
+- `frontend/index.html` — inline script reads only `localStorage`. Cannot fetch from backend. For `user:*` theme IDs it cannot resolve colors at paint time.
-The `` component in `TrafficVolumeChart.tsx` (line 135) specifies:
+### 2.2 Key Observations
-```tsx
-stroke="var(--color-brand-500, #6366f1)"
+1. **Logo / Banner conflation**: `Layout.tsx` line 74 sets `bannerSrc = customLogoUrl || undefined`. This means uploading a logo currently replaces the banner too. The new banner upload must introduce `ui.banner_url` as a separate setting key and update Layout to read it independently.
+
+2. **Single-slot custom theme**: `ThemeContext.tsx` has one `customTheme` state slot and one `CUSTOM_THEME_STORAGE_KEY`. Named user themes require a collection stored in the DB, accessed via React Query, without removing the existing single-slot code path.
+
+3. **`data-theme` constraint**: The CSS theme system only has `data-theme` values for the static built-ins and `custom`. User-created named themes must reuse `data-theme="custom"` and inject their colors via `style.setProperty` on ``, exactly like the existing custom theme does.
+
+4. **FOUC constraint**: The inline script in `index.html` can only access `localStorage`, not the network. If `localStorage['charon-theme']` is `user:`, the script cannot fetch that theme's colors at paint time. It must fall back to `data-theme="dark"` for any `user:*` value. React's hydration will then apply the correct colors after the network fetch completes.
+
+5. **Shared upsert logic**: `LogoHandler.upsertSetting` is private. The new `BannerHandler` will need the same helper. The architectural choice of whether to share this via a dedicated `ImageUploadHandler` or duplicate is addressed in Section 3.1.
+
+### 2.3 Architectural Decision: Shared Image Upload Handler
+
+**Decision: Create `image_upload_handler.go` as a shared handler with a configuration struct.**
+
+**Rationale:**
+
+The `upsertSetting`, `acceptedMIME`, and the full upload pipeline in `logo_handler.go` are identical for any image asset upload. Duplicating them into `banner_handler.go` violates the DRY principle (CLAUDE.md: "Consolidate duplicate patterns into reusable functions after the second occurrence"). A generalized `ImageUploadHandler` parameterized by asset type (`logo`, `banner`) is cleaner and testable once.
+
+The implementation will:
+- Define an `AssetConfig` struct carrying `FormField`, `URLSettingKey`, `TypeSettingKey`, `FileBaseName`.
+- `ImageUploadHandler` holds `db`, `dataDir`, and `cfg AssetConfig`.
+- `UploadAsset(c *gin.Context)` and `DeleteAsset(c *gin.Context)` are the generic methods.
+- `LogoHandler` and `BannerHandler` become thin wrappers that instantiate `ImageUploadHandler` with the appropriate config, keeping the existing public constructor signatures and route method names for backward compatibility.
+
+This means:
+- `logo_handler.go` is refactored to use `ImageUploadHandler` internally.
+- `banner_handler.go` is a new file instantiating `ImageUploadHandler` for the banner asset.
+- Tests for both share helper setup functions.
+
+### 2.4 Architectural Decision: Named Themes Storage (Option B — New GORM Model)
+
+**Decision: Option B — New `CustomTheme` GORM model.**
+
+**Rationale:**
+
+Option A (JSON array blob in Setting) has significant drawbacks: no efficient ID-based lookup for `PUT /themes/:id` and `DELETE /themes/:id`, no indexing, size limits for large theme libraries, and GORM's `FirstOrCreate` pattern is awkward for array mutation. Option B provides:
+- First-class row identity (`id` UUID) for direct API addressing.
+- GORM auto-migrate handles schema creation.
+- Efficient per-row updates and deletes.
+- Consistent with how all other Charon entities are modeled (users, notifications, uptime monitors).
+- No scaling concerns: themes are a handful of rows.
+
+---
+
+## 3. Technical Specifications
+
+### 3.1 Feature 1: Banner Image Upload
+
+#### 3.1.1 Backend — `image_upload_handler.go` (Refactor + Extend)
+
+**File:** `backend/internal/api/handlers/image_upload_handler.go`
+
+This file absorbs the shared upload logic. The existing constants `maxLogoSize`, `mimeSniffBytes`, `logoFilePerm`, `uploadsDirPerm` and function `acceptedMIME` move here. The constant `maxLogoSize` is renamed `maxImageSize` for generality.
+
+**Admin enforcement:** Since both logo and banner uploads are admin-only operations, `requireAuthenticatedAdmin(c)` MUST be called inside `ImageUploadHandler.UploadAsset` and `ImageUploadHandler.DeleteAsset` directly — NOT in the thin `LogoHandler`/`BannerHandler` wrappers. This avoids duplicating the admin check in every thin wrapper and ensures any future image asset type is automatically admin-only. The thin wrappers (`LogoHandler.UploadLogo`, `BannerHandler.UploadBanner`, etc.) do not need to call `requireAuthenticatedAdmin` themselves.
+
+Signatures:
+
+```
+// AssetConfig parameterizes a specific image asset type.
+type AssetConfig struct {
+ FormField string // multipart field name, e.g. "logo" or "banner"
+ URLSettingKey string // e.g. "ui.logo_url" or "ui.banner_url"
+ TypeSettingKey string // e.g. "ui.logo_type" or "ui.banner_type"
+ FileBaseName string // e.g. "logo" or "banner" (extension appended at runtime)
+}
+
+// ImageUploadHandler handles generic image asset upload and deletion.
+// Both UploadAsset and DeleteAsset enforce requireAuthenticatedAdmin internally.
+type ImageUploadHandler struct {
+ db *gorm.DB
+ dataDir string
+ cfg AssetConfig
+}
+
+func NewImageUploadHandler(db *gorm.DB, dataDir string, cfg AssetConfig) *ImageUploadHandler
+
+// UploadAsset handles POST for any image asset type.
+// Calls requireAuthenticatedAdmin(c) at the top — returns 401/403 and aborts if not satisfied.
+func (h *ImageUploadHandler) UploadAsset(c *gin.Context)
+
+// DeleteAsset handles DELETE for any image asset type.
+// Calls requireAuthenticatedAdmin(c) at the top — returns 401/403 and aborts if not satisfied.
+func (h *ImageUploadHandler) DeleteAsset(c *gin.Context)
+
+// upsertSetting creates or updates a Setting row (moved from logo_handler.go).
+func (h *ImageUploadHandler) upsertSetting(key, value, category, settingType string) error
+
+// acceptedMIME maps a detected MIME type to file extension (moved from logo_handler.go).
+// Returns ("", false) for disallowed types including SVG.
+func acceptedMIME(mime string) (string, bool)
```
-The CSS custom property `--color-brand-500` is defined in `frontend/src/index.css` (line 88) as:
+**File:** `backend/internal/api/handlers/logo_handler.go` (Refactored)
+
+Becomes a thin wrapper. `LogoHandler` delegates to `ImageUploadHandler`:
-```css
---color-brand-500: 59 130 246; /* #3b82f6 - Primary */
```
+// LogoHandler wraps ImageUploadHandler for the logo asset.
+type LogoHandler struct {
+ inner *ImageUploadHandler
+}
-This is a **space-separated raw RGB triplet** — the design token format used by Tailwind v4's `rgb(var(--color-brand-500) / )` utility syntax. It is NOT a valid standalone CSS `` value.
+func NewLogoHandler(db *gorm.DB, dataDir string) *LogoHandler
-When Recharts passes `stroke="var(--color-brand-500, #6366f1)"` to the SVG DOM as a presentation attribute (not a CSS `style` property), the browser resolves `--color-brand-500` to `"59 130 246"` (the defined value), discards the `#6366f1` fallback (because the variable IS defined), and produces an invalid SVG color string `"59 130 246"`. The SVG renderer ignores invalid `stroke` values and falls back to `"none"`, making the line invisible.
+func (h *LogoHandler) UploadLogo(c *gin.Context) // delegates to h.inner.UploadAsset
+func (h *LogoHandler) DeleteLogo(c *gin.Context) // delegates to h.inner.DeleteAsset
+```
-The tooltip still works because it is rendered as an HTML `
` element outside the SVG, driven by Recharts event handlers that receive the raw data regardless of visual rendering.
+Public API and method signatures are unchanged. All existing tests in `logo_handler_test.go` remain valid with zero changes to the test file.
-### 2.4 Confirmation via Comparison
+**File:** `backend/internal/api/handlers/banner_handler.go` (New)
-All other working charts in the codebase use **literal hex color values** for SVG attributes:
+```
+// BannerHandler wraps ImageUploadHandler for the banner asset.
+type BannerHandler struct {
+ inner *ImageUploadHandler
+}
-| Component | Color Approach | Works |
-|---|---|---|
-| `TopHostsChart` | `HOST_COLORS = ['#6366f1', ...]` literal hex in `` | Yes |
-| `TopAttackingIPsChart` | `const BAR_COLOR = '#6366f1'` literal hex in `` | Yes |
-| `BanTimelineChart` | `const BAN_COLOR = '#3b82f6'` literal hex in `` | Yes |
-| `StatusDistributionChart` | `STATUS_COLORS` map with literal hex in `` | Yes |
-| `TrafficVolumeChart` | `var(--color-brand-500, #6366f1)` CSS variable in `` | **Broken** |
+func NewBannerHandler(db *gorm.DB, dataDir string) *BannerHandler
-Note: `BanTimelineChart` uses `'#3b82f6'` which is exactly the hex equivalent of `--color-brand-500: 59 130 246`.
+func (h *BannerHandler) UploadBanner(c *gin.Context) // delegates to h.inner.UploadAsset
+func (h *BannerHandler) DeleteBanner(c *gin.Context) // delegates to h.inner.DeleteAsset
+```
-### 2.5 Secondary Finding: activeDot Inherits Broken Stroke
+The `AssetConfig` for `BannerHandler`:
+- `FormField: "banner"`
+- `URLSettingKey: "ui.banner_url"`
+- `TypeSettingKey: "ui.banner_type"`
+- `FileBaseName: "banner"`
-The `` also defines `activeDot={{ r: 4 }}` without an explicit `fill`. Recharts derives the active dot fill from the Line's `stroke`. Because the stroke is invalid, the active dot's fill is also unset, making the hover indicator invisible as well (though the tooltip div itself still appears).
+#### 3.1.2 Backend — Route Registration
-### 2.6 Existing Test Coverage Gap
+**File:** `backend/internal/api/routes/routes.go`
-The existing test file at `frontend/src/components/stats/__tests__/TrafficVolumeChart.test.tsx` mocks `ResponsiveContainer`, `YAxis`, and `Tooltip` but does **not** mock the `Line` component. This means no test currently asserts the `stroke` prop value on ``, and the bug could not be caught by the test suite.
+Add after the existing logo route wiring (after line 349):
-### 2.7 Existing E2E Test Coverage Gap
+```go
+// Banner upload/delete — admin only (enforced inside ImageUploadHandler.UploadAsset / DeleteAsset)
+bannerHandler := handlers.NewBannerHandler(db, dataRoot)
+management.POST("/settings/banner", bannerHandler.UploadBanner)
+management.DELETE("/settings/banner", bannerHandler.DeleteBanner)
+```
-`tests/stats.spec.ts` includes a "Traffic Volume chart container" test (line 194) that only checks for the card heading and presence of `