Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0e9145bba | |||
| 39696d42fe | |||
| 50f5ca1d5b | |||
| 11b6490e4c | |||
| 1cff576439 | |||
| 8978acdb49 | |||
| 0d8123ed4a | |||
| db1d28a75b | |||
| 64bcd0ab9f | |||
| 1d48f59fe1 | |||
| e37d1fac58 | |||
| 9ee3ff45d0 | |||
| 5453152f1c | |||
| b416baec5c | |||
| 38ddd70c12 | |||
| b00a16159e | |||
| dcfcb67f9b | |||
| cde0b465a9 | |||
| 28f69e4bf3 | |||
| 1cf82d81a7 | |||
| ae98e7ad74 | |||
| caa9fe5d56 | |||
| c6e61f64ba | |||
| 3ec0451cbb | |||
| edac463ec3 | |||
| 2d3537f61c | |||
| 6025b3194d | |||
| 8713ab7f60 | |||
| 8994901e4a | |||
| c29ad747f2 | |||
| a71b9b5ed7 | |||
| 1e08a4fb15 | |||
| bb235e72e5 | |||
| 99d5996561 | |||
| c8cb4c88ca | |||
| 2c5c1d561b | |||
| 0dbf863f60 | |||
| 72cbb95b44 | |||
| c622a35033 | |||
| 88fe9f9844 | |||
| 9596342d6a | |||
| 7cc1c59661 | |||
| f9c2b4ed75 | |||
| 976d52280e | |||
| 0ed3bbefa1 | |||
| b96ad36f5d | |||
| d0bdcb1a6a | |||
| 768e84711f | |||
| da3a77dac4 | |||
| ee46cf2125 | |||
| c0ac5c36a4 | |||
| 760a201b60 | |||
| 945026d446 | |||
| 2522d7e1ce | |||
| 0c2d319fc1 | |||
| 26eeb76197 | |||
| c0008be39b | |||
| 2c7b351458 | |||
| ea86871b81 | |||
| fcc3e9195d | |||
| a52317d8a5 | |||
| a88da99c8d | |||
| 60b03b1728 | |||
| 96fe478bf8 | |||
| 43213b50ce | |||
| a87ad77d8d | |||
| a0a28a4777 | |||
| 0dffe0ce92 | |||
| f15e104702 | |||
| 9a763337b6 | |||
| 3682de195b | |||
| c3ed7f1693 | |||
| a5f49e3235 | |||
| dadaaeea77 | |||
| c76877c0c4 | |||
| e985c6bacd | |||
| f564b38c5a | |||
| cf073615ef | |||
| b732974779 | |||
| b187f588b4 | |||
| 96e3c68b9d | |||
| cdbfb514bd | |||
| 72e2ef6721 | |||
| 081f3edcdf | |||
| 5b26dbd935 | |||
| e513e8345b | |||
| 766351b588 | |||
| 760c3d7b67 | |||
| 8c86518aad | |||
| 2ea26a4962 | |||
| b5eafc52bd | |||
| fdf62dafef | |||
| 51f827774a | |||
| 0f230f53fb | |||
| 6eb46d8c31 | |||
| d44ead41c5 | |||
| 9d7b6f0e11 | |||
| eb71fcf209 | |||
| fa7665c5b1 | |||
| 07b450a954 | |||
| a95ee473ae | |||
| 86324e7da7 | |||
| d4b143f313 | |||
| b04ef216f5 | |||
| 4aa6583bae | |||
| c8610276c2 | |||
| cc79b3d9ec | |||
| f57462f798 | |||
| 66d61c87ca | |||
| 2b66f6d889 | |||
| 2920d82186 | |||
| 422dfe1cc8 | |||
| 38b3aef63d | |||
| 5b535f8658 | |||
| 64e4925fbb | |||
| 12a9d963f5 | |||
| 24c287d051 | |||
| 681619f0a1 | |||
| d35c413651 | |||
| 6850bd3bef | |||
| 97b9853709 | |||
| bafc96f468 | |||
| e08a33d9ea | |||
| 2b2dc6e35c | |||
| d00d52a221 | |||
| e54100ac46 | |||
| 0cab43ed89 | |||
| b0912c7427 | |||
| 95dac43fdf | |||
| fb64ec0910 | |||
| 3e69c5df3f | |||
| a3e7232b8e | |||
| d9b0eb7232 | |||
| 934cc92bb8 | |||
| 286975dc52 | |||
| 6f5a02b817 | |||
| f5b4aa7a5b | |||
| 8ca74776f4 | |||
| 415bffa04c | |||
| b8b33ae6d6 | |||
| e1dae31e16 | |||
| e873f7c98c | |||
| 6f95796b7c | |||
| 4881d2040a | |||
| b8665e1d86 | |||
| f4a1db8b6a | |||
| 1be871cb9a | |||
| 2a81d992f1 | |||
| cc26296c58 | |||
| 1b0100cfad | |||
| 46ce59d82e | |||
| b86669f1e1 | |||
| 80296282e8 | |||
| 5551c89bda | |||
| dbde67c0fa | |||
| 067e460a4d | |||
| 3678193ae2 | |||
| 74eacbc78f | |||
| 37bf894412 | |||
| 506b5c3dc4 | |||
| ab6db37326 | |||
| eb2ce290a7 | |||
| 8a4c1a7c0a | |||
| 4d3836fb2e | |||
| 75499992eb | |||
| 62643f241b | |||
| 64f606152f | |||
| e2a698e892 | |||
| d1d71dba25 | |||
| bb81fa5f4b | |||
| 03e19893b3 | |||
| 279314e4b7 | |||
| 730912a3f4 | |||
| ff9fb64e75 | |||
| 9cbbe74051 | |||
| 2e1c71c1ab | |||
| f6ca713a6e | |||
| 197cd8e1e0 | |||
| 681b440381 | |||
| 3daeac9e53 | |||
| 7febba2d9c | |||
| 0a3a53763c | |||
| 85a60a2dc7 | |||
| e74e73a3a0 |
+20
-8
@@ -4,8 +4,11 @@
|
||||
# Environment
|
||||
NODE_ENV=production
|
||||
|
||||
# JWT Secret (generate with: openssl rand -base64 64)
|
||||
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker: the secrets-init service writes it to a private volume and reuses it
|
||||
# across restarts). Set it explicitly only to pin your own value.
|
||||
# Generate one with: openssl rand -base64 64
|
||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||
@@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||
# Database Configuration (PostgreSQL)
|
||||
DATABASE_CLIENT=pg
|
||||
DB_USER=picpeak
|
||||
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
|
||||
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
|
||||
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||
DB_PASSWORD=your_secure_postgres_password_here
|
||||
#DB_PASSWORD=your_secure_postgres_password_here
|
||||
DB_NAME=picpeak_prod
|
||||
|
||||
# Redis Configuration
|
||||
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
|
||||
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||
REDIS_PASSWORD=your_secure_redis_password_here
|
||||
#REDIS_PASSWORD=your_secure_redis_password_here
|
||||
|
||||
# Admin Account (initial setup)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
# Admin Account (initial setup) — OPTIONAL
|
||||
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
|
||||
# open /admin and PicPeak shows a setup screen. The one-time setup token is
|
||||
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
|
||||
# and saved to data/SETUP_TOKEN.
|
||||
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
|
||||
# credentials written to data/ADMIN_CREDENTIALS.txt).
|
||||
#ADMIN_USERNAME=admin
|
||||
#ADMIN_EMAIL=admin@yourdomain.com
|
||||
#ADMIN_PASSWORD=your_secure_admin_password_here
|
||||
|
||||
# Email Configuration
|
||||
# For Gmail: use app-specific password
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -38,6 +38,16 @@ on:
|
||||
- 'true'
|
||||
- 'false'
|
||||
|
||||
# Once release-please authors releases with a PAT (#719), a new version fires
|
||||
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
|
||||
# suppress them). They build the same immutable version, so collapse them into a
|
||||
# single run by grouping on the ref. Branch and PR builds use different refs and
|
||||
# still run independently; a superseding push cancels an in-flight run for the
|
||||
# same ref (only the newest build per ref is kept).
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: PR Title Lint
|
||||
|
||||
# Release Please derives version bumps and the changelog from Conventional
|
||||
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
|
||||
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
|
||||
# without a version bump or a changelog entry. This check fails a PR whose
|
||||
# title is not a valid Conventional Commit so the release stays automated.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate PR title is a Conventional Commit
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
perf
|
||||
revert
|
||||
docs
|
||||
style
|
||||
chore
|
||||
refactor
|
||||
test
|
||||
build
|
||||
ci
|
||||
@@ -20,11 +20,49 @@ jobs:
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A dedicated token (fine-grained PAT) makes the release PR run CI
|
||||
# automatically (no "workflows awaiting approval") and lets it be
|
||||
# merged without a manual review. Falls back to GITHUB_TOKEN so the
|
||||
# workflow still works before the secret is added (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config-beta.json
|
||||
manifest-file: .release-please-manifest-beta.json
|
||||
target-branch: main
|
||||
|
||||
# Auto-approve + enable auto-merge on the open release PR so betas publish
|
||||
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
|
||||
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
|
||||
# a valid review (requires the org's "Allow GitHub Actions to approve pull
|
||||
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
|
||||
# set: without it the PR is bot-authored and can't be self-approved, so we
|
||||
# skip and leave today's manual flow. Best-effort — never blocks the run.
|
||||
- name: Auto-approve and enable auto-merge on the release PR
|
||||
if: ${{ steps.release.outputs.release_created != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# This job has no checkout, so gh can't infer the repo from a git
|
||||
# remote — set it explicitly (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh pr list --head release-please--branches--main --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
|
||||
# than the PR author (the PAT) — so it counts as a valid review.
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
# Enable auto-merge as the PAT so the eventual merge commit is
|
||||
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
|
||||
# push is suppressed by recursion prevention and the follow-up run that
|
||||
# cuts the tag/release never fires (#719).
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
|
||||
else
|
||||
echo "No open release PR to auto-merge."
|
||||
fi
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
|
||||
@@ -20,9 +20,39 @@ jobs:
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Dedicated token so the release PR runs CI + can auto-merge without a
|
||||
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# Auto-approve + auto-merge the open stable release PR. See the beta
|
||||
# workflow for the full rationale. Skipped on the release-cutting run and
|
||||
# whenever no PAT is configured.
|
||||
- name: Auto-approve and enable auto-merge on the release PR
|
||||
if: ${{ steps.release.outputs.release_created != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
|
||||
# No checkout in this job — set the repo explicitly so gh works
|
||||
# without a git remote (same pattern as whatsnew, 2a5f0a8).
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
if [ -z "$RELEASE_PAT" ]; then
|
||||
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
|
||||
exit 0
|
||||
fi
|
||||
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
|
||||
# is a valid review; enable auto-merge as the PAT so the merge commit is
|
||||
# attributed to a real identity and triggers the tag-cutting run (#719).
|
||||
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
|
||||
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
|
||||
else
|
||||
echo "No open release PR to auto-merge."
|
||||
fi
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
|
||||
@@ -17,9 +17,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta]
|
||||
branches: [main, beta, stable]
|
||||
pull_request:
|
||||
branches: [main, beta]
|
||||
branches: [main, beta, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.76.2-beta.0"
|
||||
".": "3.83.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
{".":"3.45.5"}
|
||||
|
||||
+843
-875
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -50,7 +50,10 @@ Unsure where to begin? You can start by looking through these issues:
|
||||
- Linting passes: `npm run lint`
|
||||
4. **Write tests** if you've added code
|
||||
5. **Update documentation** if needed
|
||||
6. **Create a Pull Request**
|
||||
6. **Attach a screenshot for any UI change** (see below)
|
||||
7. **Create a Pull Request**
|
||||
|
||||
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
|
||||
|
||||
## 💻 Development Setup
|
||||
|
||||
|
||||
@@ -94,18 +94,31 @@ Get PicPeak running in under 5 minutes:
|
||||
git clone https://github.com/PicPeak/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
# Copy the environment template — the defaults work out of the box.
|
||||
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
|
||||
# admin account is created in the browser (see below). Edit .env only to
|
||||
# customise (domain, SMTP, storage paths, …) — nothing is required.
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
### First run — create your admin account
|
||||
|
||||
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
|
||||
|
||||
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
|
||||
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
|
||||
|
||||
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
|
||||
|
||||
Note on Docker file permissions
|
||||
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
|
||||
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
|
||||
|
||||
@@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \
|
||||
- `picpeak-workers` - Background workers
|
||||
- `caddy` - Web server (optional)
|
||||
|
||||
## 🔑 First Login — Create Your Admin
|
||||
|
||||
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
|
||||
|
||||
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
|
||||
|
||||
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
|
||||
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
### Direct Access (Simplest)
|
||||
|
||||
@@ -9,6 +9,16 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
|
||||
+17
-5
@@ -27,12 +27,24 @@ FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
|
||||
# stage's declaration never reached this stage. Consuming it in the RUN below
|
||||
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
|
||||
# image always picks up current Alpine security updates instead of reusing a
|
||||
# stale cached upgrade layer.
|
||||
ARG CACHEBUST=1
|
||||
|
||||
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
|
||||
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
|
||||
RUN npm install -g npm@10
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
|
||||
|
||||
# Upgrade the npm CLI in the final image so its bundled deps are patched
|
||||
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
|
||||
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
|
||||
# dependencies come from the builder stage (COPY --from=builder node_modules
|
||||
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
|
||||
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
|
||||
# Node >=22.9, satisfied by node:22-alpine.
|
||||
RUN npm install -g npm@11
|
||||
|
||||
# Install dumb-init for proper signal handling, postgresql-client for database
|
||||
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Smoke tests for backupService's config resolution + file-collection
|
||||
* and manifest validation paths — safety net ahead of the god-file
|
||||
* decomposition.
|
||||
*
|
||||
* Uses the same real-SQLite harness as
|
||||
* backupService.configurableWalker.test.js (bootCrmDb + a temp
|
||||
* STORAGE_PATH) rather than the broken deep-mock approach in
|
||||
* backupService.enhanced.test.js.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupService — config + file collection + manifest (smoke)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupService;
|
||||
let backupManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupService = require('../../src/services/backupService');
|
||||
backupManifest = require('../../src/services/backupManifest');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
// Reset the storage tree so each test starts from a pristine walk.
|
||||
await fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||
});
|
||||
|
||||
function seedFile(relPath, content = 'dummy bytes') {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return abs;
|
||||
}
|
||||
|
||||
async function insertBackupSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: value,
|
||||
setting_type: 'backup',
|
||||
});
|
||||
}
|
||||
|
||||
describe('getBackupConfig', () => {
|
||||
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
|
||||
await insertBackupSetting('backup_enabled', 'true');
|
||||
await insertBackupSetting('backup_include_archived', 'false');
|
||||
await insertBackupSetting('backup_retention_days', '30');
|
||||
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
|
||||
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
|
||||
// Non-backup settings must not leak into the backup config.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'general_site_name',
|
||||
setting_value: 'PicPeak',
|
||||
setting_type: 'general',
|
||||
});
|
||||
|
||||
const config = await backupService.getBackupConfig();
|
||||
|
||||
expect(config.backup_enabled).toBe(true);
|
||||
expect(config.backup_include_archived).toBe(false);
|
||||
expect(config.backup_retention_days).toBe(30);
|
||||
expect(config.backup_destination_path).toBe('/backups/picpeak');
|
||||
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
|
||||
expect(config).not.toHaveProperty('general_site_name');
|
||||
// Raw (unparsed) values are preserved on the non-enumerable __raw.
|
||||
expect(String(config.__raw.backup_retention_days)).toBe('30');
|
||||
});
|
||||
|
||||
it('returns an empty config object (not null) when nothing is configured', async () => {
|
||||
const config = await backupService.getBackupConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(Object.keys(config)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilesToBackup', () => {
|
||||
it('returns an empty list on a pristine storage tree', async () => {
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
|
||||
const content = 'not really a jpeg';
|
||||
const abs = seedFile('events/active/E9/pic.jpg', content);
|
||||
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.path).toBe(abs);
|
||||
expect(entry.size).toBe(Buffer.byteLength(content));
|
||||
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
|
||||
// realm under Jest and fails the cross-realm instanceof check.
|
||||
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackupManifest', () => {
|
||||
it('round-trips a generated manifest as valid', async () => {
|
||||
seedFile('events/active/E1/a.jpg', 'aaa');
|
||||
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
||||
|
||||
const manifest = await backupManifest.generateManifest({
|
||||
backupType: 'full',
|
||||
backupPath: '/backup/run-1',
|
||||
files,
|
||||
});
|
||||
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
|
||||
await backupManifest.saveManifest(manifest, manifestPath, 'json');
|
||||
|
||||
const result = await backupService.validateBackupManifest(manifestPath);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.manifest.backup.type).toBe('full');
|
||||
expect(result.manifest.files.count).toBe(files.length);
|
||||
expect(result.manifest.verification.total_checksum).toBeTruthy();
|
||||
});
|
||||
|
||||
it('flags a manifest missing required sections as invalid', async () => {
|
||||
const badPath = path.join(storagePath, 'manifest-broken.json');
|
||||
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
|
||||
|
||||
const result = await backupService.validateBackupManifest(badPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toMatch(/Missing required section/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
// Validates the engine-neutral .picpeak export: it must produce a real zip with
|
||||
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
|
||||
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
|
||||
// bootCrmDb MUST run before requiring the service (which transitively requires
|
||||
// db.js) so the export reads this test's DB, not the default path.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
async function readZip(filePath) {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const entries = Object.keys(await zip.entries());
|
||||
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
await zip.close();
|
||||
return { entries, manifest };
|
||||
}
|
||||
|
||||
describe('picpeak export (.picpeak logical export)', () => {
|
||||
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
expect(filePath.endsWith('.picpeak')).toBe(true);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
|
||||
expect(manifest.format).toBe(1);
|
||||
expect(manifest.kind).toBe('picpeak-backup');
|
||||
expect(manifest.database.engine).toBe('sqlite');
|
||||
expect(manifest.options.includePhotos).toBe(false);
|
||||
expect(manifest.contains_secrets).toBe(true);
|
||||
// Migrations seed real tables (e.g. app_settings) — expect several.
|
||||
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(manifest.tables)).toContain('app_settings');
|
||||
|
||||
const { entries, manifest: zipped } = await readZip(filePath);
|
||||
expect(entries).toContain('manifest.json');
|
||||
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
|
||||
expect(entries).toContain('data/app_settings.ndjson');
|
||||
// Manifest inside the zip matches the returned one.
|
||||
expect(zipped.tables).toEqual(manifest.tables);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never exports knex bookkeeping tables', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const names = Object.keys(manifest.tables);
|
||||
expect(names).not.toContain('knex_migrations');
|
||||
expect(names).not.toContain('knex_migrations_lock');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('row counts in the manifest match the NDJSON line counts', async () => {
|
||||
// Insert a couple of settings so at least one table is non-empty.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const buf = await zip.entryData('data/app_settings.ndjson');
|
||||
await zip.close();
|
||||
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
|
||||
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
|
||||
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
// Full .picpeak roundtrip on a temp SQLite DB:
|
||||
// 1. seed a "backup" instance (admin A + a marker setting)
|
||||
// 2. export → .picpeak
|
||||
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
|
||||
// 4. import the backup with currentAdminId = B
|
||||
// 5. assert the backup data is restored AND the current account (B) survives,
|
||||
// while the backup's admin (A) is also present (different email → added).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
let importFromPicpeak;
|
||||
let validateManifest;
|
||||
let superAdminRoleId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
const adminRow = (email, hash) => ({
|
||||
username: email,
|
||||
email,
|
||||
password_hash: hash,
|
||||
role_id: superAdminRoleId,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
async function setMarker(value) {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
}
|
||||
async function getMarker() {
|
||||
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
|
||||
return row ? JSON.parse(row.setting_value) : null;
|
||||
}
|
||||
|
||||
describe('.picpeak roundtrip (export → import)', () => {
|
||||
it('restores backup data and preserves the current account', async () => {
|
||||
// 1. Seed the "source" instance.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
|
||||
await setMarker('from_backup');
|
||||
|
||||
// 2. Export.
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// 3. Simulate a reinstall: fresh current admin B, mutated data.
|
||||
await db('admin_users').del();
|
||||
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
|
||||
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
|
||||
await setMarker('mutated_after_backup');
|
||||
|
||||
// 4. Import, preserving the current admin.
|
||||
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
|
||||
expect(result.restored).toBe(true);
|
||||
expect(result.tables).toBeGreaterThan(0);
|
||||
|
||||
// 5a. Backup data restored (marker reverted to the backup value).
|
||||
expect(await getMarker()).toBe('from_backup');
|
||||
|
||||
// 5b. The backup's admin is present (different email → added).
|
||||
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
|
||||
expect(a).toBeTruthy();
|
||||
expect(a.password_hash).toBe('HASH_A');
|
||||
|
||||
// 5c. The current account SURVIVES the override, with its own credentials.
|
||||
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.password_hash).toBe('HASH_B');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites a backup admin that collides with the current account email', async () => {
|
||||
// Source has an admin at the SAME email the current operator will use.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
|
||||
await setMarker('collision_case');
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// Reinstall: current admin uses the same email but a NEW password.
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
|
||||
// Exactly one admin at that email, and it keeps the CURRENT password.
|
||||
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].password_hash).toBe('NEW_HASH');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores files/ and reports filesRestored', async () => {
|
||||
// A business-doc that lives in storage → travels in the backup.
|
||||
const docDir = path.join(tmpDir, 'business-docs');
|
||||
const marker = path.join(docDir, 'roundtrip-doc.txt');
|
||||
fs.mkdirSync(docDir, { recursive: true });
|
||||
fs.writeFileSync(marker, 'hello');
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
fs.rmSync(marker); // delete on disk so the restore must bring it back
|
||||
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
fs.rmSync(docDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('.picpeak manifest validation', () => {
|
||||
it('rejects a database-engine mismatch', async () => {
|
||||
// Harness runs on SQLite, so a pg manifest must be refused.
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a backup from a newer schema (forward-only)', async () => {
|
||||
// validateManifest reads knex_migrations for the target's latest migration;
|
||||
// the harness has none, so create it with an older migration than the backup.
|
||||
await db.schema.createTable('knex_migrations', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
t.integer('batch');
|
||||
t.timestamp('migration_time');
|
||||
});
|
||||
try {
|
||||
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1,
|
||||
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
|
||||
tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
|
||||
} finally {
|
||||
await db.schema.dropTableIfExists('knex_migrations');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a file that is not a PicPeak backup', async () => {
|
||||
const blockers = await validateManifest({ some: 'random-json' });
|
||||
expect(blockers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
|
||||
*
|
||||
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
|
||||
* the script in a child process (--email <addr> --yes) pointed at the same
|
||||
* DB file, and asserts the four MFA columns are zeroed. The script runs in
|
||||
* its own process with its own knex connection; the parent connection is
|
||||
* idle during the spawn so the SQLite write lock isn't contended.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
|
||||
|
||||
async function seedEnrolledAdmin(email) {
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
password_hash: 'x',
|
||||
is_active: true,
|
||||
two_factor_enabled: true,
|
||||
two_factor_secret: 'iv.tag.ct',
|
||||
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
|
||||
two_factor_enrolled_at: new Date(),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('zeroes the four MFA columns for the targeted admin', async () => {
|
||||
const email = 'reset-me@example.com';
|
||||
const id = await seedEnrolledAdmin(email);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const row = await db('admin_users').where({ id }).first();
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
expect(row.two_factor_enrolled_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a different admin untouched', async () => {
|
||||
const targetEmail = 'target@example.com';
|
||||
const bystanderEmail = 'bystander@example.com';
|
||||
const targetId = await seedEnrolledAdmin(targetEmail);
|
||||
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
|
||||
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const target = await db('admin_users').where({ id: targetId }).first();
|
||||
const bystander = await db('admin_users').where({ id: bystanderId }).first();
|
||||
expect(Number(target.two_factor_enabled)).toBe(0);
|
||||
expect(Number(bystander.two_factor_enabled)).toBe(1);
|
||||
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
'use strict';
|
||||
|
||||
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
|
||||
// so setupService shares this test's db instance (see crmDb.js note).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let setupService;
|
||||
let getAppSetting;
|
||||
let upsertAppSetting;
|
||||
let app;
|
||||
|
||||
const VALID_PW = 'Str0ng-Passw0rd!';
|
||||
|
||||
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
|
||||
// service/util), or db.js binds to the default path instead of the temp one.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
||||
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('admin_users').del();
|
||||
await db('app_settings').where({ setting_key: 'setup_token' }).del();
|
||||
});
|
||||
|
||||
describe('setupService (first-run bootstrap)', () => {
|
||||
it('reports needsAdmin while no admin exists', async () => {
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('generates and persists a one-time token while no admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(token).toEqual(expect.any(String));
|
||||
expect(token.length).toBeGreaterThan(20);
|
||||
expect(await getAppSetting('setup_token')).toBe(token);
|
||||
// Idempotent — a second call returns the same token, not a fresh one.
|
||||
expect(await setupService.ensureSetupToken()).toBe(token);
|
||||
});
|
||||
|
||||
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
|
||||
// Regression guard for the SQLite-only miss: a bare token string is rejected
|
||||
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
|
||||
// value must be JSON-parseable and round-trip back to the token.
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
|
||||
expect(() => JSON.parse(row.setting_value)).not.toThrow();
|
||||
expect(JSON.parse(row.setting_value)).toBe(token);
|
||||
});
|
||||
|
||||
it('rejects a wrong token', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('rejects a weak password', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const result = await setupService.createInitialAdmin({
|
||||
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
|
||||
});
|
||||
|
||||
expect(result.user.email).toBe('owner@example.com'); // normalised
|
||||
expect(result.user.role.name).toBe('super_admin');
|
||||
expect(result.token).toEqual(expect.any(String));
|
||||
|
||||
const row = await db('admin_users').first();
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
expect(row.role_id).toBe(role.id);
|
||||
expect(row.password_hash).not.toBe(VALID_PW); // hashed
|
||||
|
||||
// One-time: token burned, status now complete.
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
|
||||
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
|
||||
const token = await setupService.ensureSetupToken();
|
||||
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
|
||||
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
|
||||
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
|
||||
});
|
||||
|
||||
it('refuses to create a second admin (setup already complete)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
await expect(
|
||||
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
|
||||
).rejects.toMatchObject({ statusCode: 409 });
|
||||
});
|
||||
|
||||
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const results = await Promise.allSettled([
|
||||
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
|
||||
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
|
||||
]);
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
|
||||
const count = await db('admin_users').count({ c: '*' }).first();
|
||||
expect(Number(count.c)).toBe(1);
|
||||
});
|
||||
|
||||
it('ensureSetupToken clears any stale token once an admin exists', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
// Simulate a stale token left in settings, then re-run the boot hook.
|
||||
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
|
||||
expect(await setupService.ensureSetupToken()).toBeNull();
|
||||
expect(await getAppSetting('setup_token')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup routes', () => {
|
||||
it('GET /api/setup/status reports needsAdmin', async () => {
|
||||
const res = await request(app).get('/api/setup/status');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ needsAdmin: true, complete: false });
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ valid: true });
|
||||
// Token is NOT consumed — it still works for the actual create.
|
||||
expect(await getAppSetting('setup_token')).toBe(token);
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.field).toBe('token');
|
||||
});
|
||||
|
||||
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
|
||||
await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
|
||||
expect(res.status).toBe(400);
|
||||
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token, email: 'owner@example.com', password: VALID_PW });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.user.role.name).toBe('super_admin');
|
||||
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
|
||||
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
||||
});
|
||||
|
||||
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
|
||||
const token = await setupService.ensureSetupToken();
|
||||
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
||||
const res = await request(app)
|
||||
.post('/api/setup/admin')
|
||||
.send({ token, email: 'second@example.com', password: VALID_PW });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
|
||||
expect(again.already).toBe(true);
|
||||
});
|
||||
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
|
||||
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
|
||||
const noopLogger = { info() {}, warn() {} };
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
|
||||
expect(wf).toBeTruthy();
|
||||
expect(!!wf.is_builtin).toBe(true);
|
||||
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
|
||||
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
|
||||
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(7);
|
||||
|
||||
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
|
||||
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
|
||||
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
|
||||
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
|
||||
const reseeded = await db('workflows').where({ id: wf.id }).first();
|
||||
expect(reseeded.version).toBe(wf.version + 1); // bumped
|
||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
|
||||
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(7);
|
||||
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
|
||||
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
|
||||
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Regression test for the bulk archive/delete ownership bypass.
|
||||
*
|
||||
* bulk-archive and bulk-delete acted on body-supplied event ids with no
|
||||
* ownership filter, so an admin/editor scoped to their own events (the
|
||||
* single-event routes enforce requireEventOwnership) could archive or
|
||||
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
|
||||
* routes now use to drop foreign/non-existent ids.
|
||||
*/
|
||||
|
||||
// events owned by admin 7; event 3 owned by someone else; event 4 is
|
||||
// ownerless (legacy). The mock models:
|
||||
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
|
||||
const EVENTS = [
|
||||
{ id: 1, created_by: 7 },
|
||||
{ id: 2, created_by: 7 },
|
||||
{ id: 3, created_by: 99 }, // foreign
|
||||
{ id: 4, created_by: null }, // ownerless/legacy
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
const q = {
|
||||
_ids: null,
|
||||
_adminId: null,
|
||||
whereIn(_col, ids) { this._ids = ids; return this; },
|
||||
andWhere(cb) {
|
||||
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
|
||||
// by capturing the admin id the callback closes over via a probe.
|
||||
const probe = {
|
||||
_adminId: null,
|
||||
whereNull() { return this; },
|
||||
orWhere(_col, id) { this._adminId = id; return this; },
|
||||
};
|
||||
cb(probe);
|
||||
this._adminId = probe._adminId;
|
||||
return this;
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve(
|
||||
EVENTS
|
||||
.filter((e) => this._ids.includes(e.id))
|
||||
.filter((e) => e.created_by === null || e.created_by === this._adminId)
|
||||
.map((e) => ({ id: e.id }))
|
||||
);
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
}));
|
||||
|
||||
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
|
||||
|
||||
describe('filterOwnedEventIds', () => {
|
||||
it('super_admin gets every id, nothing denied', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
|
||||
);
|
||||
expect(allowed).toEqual([1, 3, 4, 999]);
|
||||
expect(denied).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
|
||||
);
|
||||
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
|
||||
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
|
||||
});
|
||||
|
||||
it('foreign-only request yields empty allowed', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'editor' }, [3]
|
||||
);
|
||||
expect(allowed).toEqual([]);
|
||||
expect(denied).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Regression test for the cross-event thumbnail enumeration leak.
|
||||
*
|
||||
* Thumbnails are served flat from /thumbnails/thumb_<name> with
|
||||
* deterministic, enumerable filenames. photoAuth previously granted any
|
||||
* holder of a gallery token for ANY active event access to ANY thumbnail
|
||||
* (it set eventSlug=null and returned next() as long as the token's event
|
||||
* existed), so a visitor to one gallery could pull another (password-
|
||||
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
|
||||
* access to the token's event by matching the requested file against
|
||||
* photos.thumbnail_path for that event_id.
|
||||
*/
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Two events, each owning one thumbnail. The photos mock resolves a row
|
||||
// only when BOTH event_id and thumbnail_path match — i.e. it models the
|
||||
// real ownership query.
|
||||
const EVENTS = [
|
||||
{ id: 10, slug: 'event-a', is_active: 1 },
|
||||
{ id: 20, slug: 'event-b', is_active: 1 },
|
||||
];
|
||||
const PHOTOS = [
|
||||
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
|
||||
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: (table) => ({
|
||||
_cond: null,
|
||||
where(cond) { this._cond = cond; return this; },
|
||||
first() {
|
||||
if (table === 'events') {
|
||||
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return Promise.resolve(
|
||||
PHOTOS.find((p) => p.event_id === this._cond.event_id
|
||||
&& p.thumbnail_path === this._cond.thumbnail_path) || null
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const photoAuth = require('../../src/middleware/photoAuth');
|
||||
|
||||
function galleryToken(eventId) {
|
||||
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
}
|
||||
|
||||
function makeReqRes(token, thumbPath) {
|
||||
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('photoAuth — thumbnail ownership scoping', () => {
|
||||
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
// Access denied: middleware must not pass the request through.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.event).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* HTTP smoke tests for the core admin event CRUD endpoints:
|
||||
* POST /api/admin/events (create)
|
||||
* GET /api/admin/events (list + pagination)
|
||||
* GET /api/admin/events/:id (detail + stats)
|
||||
* PUT /api/admin/events/:id (update)
|
||||
* DELETE /api/admin/events/:id (cascade delete)
|
||||
*
|
||||
* Safety net ahead of the adminEvents.js god-file decomposition —
|
||||
* pins the request/response contracts of the main CRUD paths using
|
||||
* the same real-SQLite harness as slideshowAdmin.test.js.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-smoke-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, adminId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Test Wedding',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: adminId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('admin events CRUD endpoints (smoke)', () => {
|
||||
let db; let cleanup; let app; let adminId; let token;
|
||||
|
||||
// bootCrmDb's full migration run intermittently exceeds Jest's default
|
||||
// 5s beforeAll timeout on slower CI runners; raise it.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('email_queue').del();
|
||||
await db('events').del();
|
||||
});
|
||||
|
||||
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
it('401s without an admin token', async () => {
|
||||
const res = await request(app).get('/api/admin/events');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('creates an event, mints slug + share link and persists the row', async () => {
|
||||
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'wedding',
|
||||
event_name: 'Smoke Wedding',
|
||||
event_date: '2026-09-01',
|
||||
// Field requirements default to ON (getEventFieldRequirements)
|
||||
// so customer + admin contact data must be supplied.
|
||||
customer_name: 'Client Person',
|
||||
customer_email: 'client@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
require_password: false,
|
||||
is_draft: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBeDefined();
|
||||
expect(res.body.slug).toContain('wedding-smoke-wedding');
|
||||
expect(typeof res.body.share_link).toBe('string');
|
||||
expect(res.body.is_draft).toBe(true);
|
||||
|
||||
const row = await db('events').where({ id: res.body.id }).first();
|
||||
expect(row).toBeDefined();
|
||||
expect(row.event_name).toBe('Smoke Wedding');
|
||||
expect(row.created_by).toBe(adminId);
|
||||
|
||||
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
|
||||
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
|
||||
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
|
||||
|
||||
// Draft creates must NOT queue the gallery_created email.
|
||||
const queued = await db('email_queue').where({ event_id: res.body.id });
|
||||
expect(queued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('400s on an invalid event type', async () => {
|
||||
const res = await auth(request(app).post('/api/admin/events')).send({
|
||||
event_type: 'not-a-real-type',
|
||||
event_name: 'Broken',
|
||||
require_password: false,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(Array.isArray(res.body.errors)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /', () => {
|
||||
it('lists events with pagination metadata and photo counts', async () => {
|
||||
await insertEvent(db, adminId, { event_name: 'Alpha' });
|
||||
await insertEvent(db, adminId, { event_name: 'Beta' });
|
||||
|
||||
const res = await auth(request(app).get('/api/admin/events'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.events).toHaveLength(2);
|
||||
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
|
||||
for (const ev of res.body.events) {
|
||||
expect(ev.photo_count).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id', () => {
|
||||
it('returns the event with photo/view stats', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
|
||||
const res = await auth(request(app).get(`/api/admin/events/${id}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.event_name).toBe('Detail Event');
|
||||
expect(res.body.photo_count).toBe(0);
|
||||
expect(res.body.total_views).toBe(0);
|
||||
expect(res.body.total_downloads).toBe(0);
|
||||
expect(Array.isArray(res.body.recent_photos)).toBe(true);
|
||||
});
|
||||
|
||||
it('404s for an unknown event id', async () => {
|
||||
const res = await auth(request(app).get('/api/admin/events/999999'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('updates mutable fields and persists them', async () => {
|
||||
const id = await insertEvent(db, adminId, { event_name: 'Before' });
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
event_name: 'After',
|
||||
welcome_message: 'Hello guests',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.event_name).toBe('After');
|
||||
expect(row.welcome_message).toBe('Hello guests');
|
||||
});
|
||||
|
||||
it('404s when updating a missing event', async () => {
|
||||
const res = await auth(request(app).put('/api/admin/events/999999')).send({
|
||||
event_name: 'Ghost',
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
|
||||
// branding toggle"), but the validator used .optional() without
|
||||
// { nullable: true }, so an explicit null was rejected with 400.
|
||||
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
|
||||
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
hero_logo_visible: null,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row.hero_logo_visible).toBeNull();
|
||||
});
|
||||
|
||||
it('still rejects a non-boolean hero_logo_visible', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
|
||||
hero_logo_visible: 'maybe',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('cascade-deletes the event row', async () => {
|
||||
const id = await insertEvent(db, adminId);
|
||||
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.message).toMatch(/deleted/i);
|
||||
const row = await db('events').where({ id }).first();
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s when deleting a missing event', async () => {
|
||||
const res = await auth(request(app).delete('/api/admin/events/999999'));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* HTTP-level tests for the admin TOTP MFA feature (#738).
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
|
||||
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
|
||||
* /api/admin/auth (src/routes/adminAuth.js).
|
||||
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
|
||||
* (src/routes/auth.js, mounted /api/auth).
|
||||
*
|
||||
* Uses the same real-SQLite harness as the CRM route tests
|
||||
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
|
||||
* generated in-test via otplib's authenticator against the secret the
|
||||
* /setup endpoint returns in plaintext.
|
||||
*
|
||||
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
|
||||
* first require of db.js — mirror adminCrmAuth.test.js exactly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
|
||||
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
|
||||
// tests don't need a token. Be explicit so a leaked env can't flip it on.
|
||||
delete process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminApp; // /api/admin/auth (enrollment)
|
||||
let authApp; // /api/auth (login challenge)
|
||||
|
||||
/**
|
||||
* Seed a bare admin (password known) and return its id + login creds.
|
||||
* seedMinimal always creates username 'tester'; we need distinct rows per
|
||||
* scenario, so insert directly with a unique username/email.
|
||||
*/
|
||||
async function seedAdmin({ username, superAdmin = false } = {}) {
|
||||
const password = 'correct-horse';
|
||||
const passwordHash = await bcrypt.hash(password, 4);
|
||||
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = {
|
||||
username: uname,
|
||||
email: `${uname}@example.com`,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (superAdmin) {
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
if (!role) throw new Error('super_admin role not seeded');
|
||||
row.role_id = role.id;
|
||||
}
|
||||
const inserted = await db('admin_users').insert(row).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
return { id, username: uname, password };
|
||||
}
|
||||
|
||||
/** Run the full setup→enable enrollment against the live app. Returns
|
||||
* the plaintext TOTP secret (for later login codes) and recovery codes. */
|
||||
async function enroll(adminId) {
|
||||
const token = mintAdminToken(adminId);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(setup.status).toBe(200);
|
||||
const secret = setup.body.secret;
|
||||
|
||||
const enable = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
|
||||
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toEqual(expect.any(String));
|
||||
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
// Not yet enabled: status must still report disabled.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
|
||||
// And the row stores an encrypted secret (not the plaintext one).
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeTruthy();
|
||||
expect(row.two_factor_secret).not.toBe(res.body.secret);
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
});
|
||||
|
||||
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
|
||||
expect(Array.isArray(recoveryCodes)).toBe(true);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(10);
|
||||
expect(status.body.enrolledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const valid = authenticator.generate(setup.body.secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enable before setup is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '123456' });
|
||||
// No provisional secret → ValidationError (400).
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
|
||||
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
|
||||
expect(noToken.status).toBe(401);
|
||||
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
|
||||
expect(setup.status).toBe(401);
|
||||
});
|
||||
|
||||
// Regression guard for #735: super_admin used to be blocked from enrolling.
|
||||
// Enrollment operates on req.admin.id and is role-agnostic — assert a
|
||||
// super_admin can complete the full setup→enable flow.
|
||||
it('#735 regression — a super_admin can enroll in MFA', async () => {
|
||||
const admin = await seedAdmin({ superAdmin: true });
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
it('requires a valid code; a wrong code is rejected and state persists', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { token } = await enroll(admin.id);
|
||||
|
||||
const bad = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const stillOn = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(stillOn.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a valid TOTP disables MFA and clears the stored secret', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(0);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
expect(res.body.mfaToken).toEqual(expect.any(String));
|
||||
expect(res.body.user).toBeUndefined(); // no completed session
|
||||
// No admin auth cookie should have been set on the challenge response.
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.join(';')).not.toMatch(/adminToken/i);
|
||||
});
|
||||
|
||||
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.username).toBe(admin.username);
|
||||
});
|
||||
|
||||
it('login/mfa with a valid TOTP completes the session', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const { mfaToken } = challenge.body;
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken, code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('MFA_INVALID');
|
||||
expect(res.body.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a recovery code logs in and is then single-use (second use fails)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes } = await enroll(admin.id);
|
||||
const recovery = recoveryCodes[0];
|
||||
|
||||
// First challenge + recovery-code exchange succeeds.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code: recovery });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// recoveryCodesRemaining dropped by one.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(9);
|
||||
|
||||
// Second use of the SAME recovery code must fail.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const second = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code: recovery });
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Regression test for GHSA-9hmx-68vc-qpqw — share-link login must not bypass
|
||||
* the gallery password.
|
||||
*
|
||||
* POST /auth/gallery/share-login validates only the share token. For a
|
||||
* password-protected gallery it previously minted a full `type:'gallery'`
|
||||
* access token on the share token alone, letting anyone holding the share URL
|
||||
* read the gallery without the password. The fix: when the gallery requires a
|
||||
* password, return `{ requires_password: true }` with NO token and NO cookie.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
process.env.JWT_SECRET = 'share-login-test-secret';
|
||||
|
||||
const events = [];
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
function dbFn(table) {
|
||||
if (table === 'events') {
|
||||
let filter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
filter = (row) => Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() { return events.find(filter); },
|
||||
};
|
||||
}
|
||||
return { where() { return this; }, async first() { return undefined; } };
|
||||
}
|
||||
dbFn.raw = async () => {};
|
||||
return { db: dbFn, logActivity: async () => {} };
|
||||
});
|
||||
|
||||
// Share token is stored plainly on the fake event row.
|
||||
jest.mock('../../src/services/shareLinkService', () => ({
|
||||
getEventShareToken: (event) => event.share_token,
|
||||
resolveShareIdentifier: async () => ({ event: null }),
|
||||
}));
|
||||
|
||||
const mockSetGalleryAuthCookies = jest.fn();
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
setGalleryAuthCookies: (...args) => mockSetGalleryAuthCookies(...args),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
getGalleryTokenFromRequest: jest.fn(),
|
||||
setAdminAuthCookies: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/authSecurity', () => ({
|
||||
trackFailedAttempt: jest.fn(async () => {}),
|
||||
trackSuccessfulLogin: jest.fn(async () => {}),
|
||||
checkAccountLockout: jest.fn(async () => ({ isLocked: false })),
|
||||
resetLockout: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Collaborators the router imports at load but the share-login path doesn't hit.
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: async () => true }));
|
||||
jest.mock('../../src/services/mfaService', () => ({}));
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn(), sessionTimeoutMiddleware: (req, res, next) => next() }));
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({ revokeToken: jest.fn(async () => {}), isTokenRevoked: async () => false }));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
const SHARE_TOKEN = 'a'.repeat(64);
|
||||
|
||||
beforeEach(() => {
|
||||
events.length = 0;
|
||||
mockSetGalleryAuthCookies.mockClear();
|
||||
});
|
||||
|
||||
describe('POST /auth/gallery/share-login password enforcement', () => {
|
||||
it('does NOT mint a token for a password-protected gallery', async () => {
|
||||
events.push({
|
||||
id: 1, slug: 'private-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: 1, share_token: SHARE_TOKEN, event_name: 'Private',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'private-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.requires_password).toBe(true);
|
||||
expect(res.body.token).toBeUndefined();
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints a token for a public (no-password) gallery', async () => {
|
||||
events.push({
|
||||
id: 2, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: SHARE_TOKEN });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
expect(res.body.event).toBeDefined();
|
||||
expect(mockSetGalleryAuthCookies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a wrong share token regardless of password setting', async () => {
|
||||
events.push({
|
||||
id: 3, slug: 'public-gallery', is_active: 1, is_archived: 0,
|
||||
require_password: false, share_token: SHARE_TOKEN, event_name: 'Public',
|
||||
});
|
||||
const res = await request(makeApp())
|
||||
.post('/auth/gallery/share-login')
|
||||
.send({ slug: 'public-gallery', token: 'b'.repeat(64) });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockSetGalleryAuthCookies).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Regression test for GHSA-4j34-x562-5vfq — broken access control in the legacy
|
||||
* /api/events router.
|
||||
*
|
||||
* The legacy router exposed create/list/update/delete/extend guarded by
|
||||
* adminAuth ALONE (no requirePermission, no requireEventOwnership), so any
|
||||
* back-office account — down to a read-only viewer — could read every gallery's
|
||||
* password_hash/share_token and take over any gallery. The fix removes that
|
||||
* router entirely and migrates its one UI-used route (POST /:id/extend) to the
|
||||
* canonical /api/admin/events mount, where it inherits the permission +
|
||||
* ownership guards.
|
||||
*
|
||||
* This test pins two invariants:
|
||||
* 1. The legacy source file is gone (nothing can re-mount it).
|
||||
* 2. The migrated extend route enforces ownership — a non-owning editor gets
|
||||
* 403, the owner succeeds.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-legacy-acl-')), 'db.sqlite'
|
||||
);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'legacy-acl-test-secret';
|
||||
|
||||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
|
||||
|
||||
async function insertEvent(db, ownerId, over = {}) {
|
||||
const base = {
|
||||
slug: `ev-${Math.random().toString(16).slice(2)}`,
|
||||
event_type: 'wedding',
|
||||
event_name: 'Owner Gallery',
|
||||
event_date: '2026-05-29',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
|
||||
share_token: `st-${Math.random().toString(16).slice(2)}`,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||
is_active: 1, is_archived: 0, is_draft: 0,
|
||||
created_by: ownerId,
|
||||
created_at: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
const r = await db('events').insert(base).returning('id');
|
||||
return r[0]?.id ?? r[0];
|
||||
}
|
||||
|
||||
describe('GHSA-4j34: legacy /api/events router removed + extend guarded', () => {
|
||||
it('the legacy events router source file no longer exists', () => {
|
||||
expect(fs.existsSync(path.join(__dirname, '../../src/routes/events.js'))).toBe(false);
|
||||
});
|
||||
|
||||
describe('POST /api/admin/events/:id/extend ownership enforcement', () => {
|
||||
let db; let cleanup; let app;
|
||||
let ownerId; let ownerToken;
|
||||
let editorId; let editorToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ adminId: ownerId } = await seedMinimal(db));
|
||||
await assignAdminRole(db, ownerId, 'super_admin');
|
||||
ownerToken = mintAdminToken(ownerId);
|
||||
|
||||
// A second, non-owning account with the low-trust editor role.
|
||||
[editorId] = await db('admin_users').insert({
|
||||
username: 'editor1', email: 'editor1@example.com',
|
||||
password_hash: 'x', is_active: 1,
|
||||
}).returning('id');
|
||||
editorId = editorId?.id ?? editorId;
|
||||
await assignAdminRole(db, editorId, 'editor');
|
||||
editorToken = mintAdminToken(editorId);
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { await cleanup(); });
|
||||
|
||||
it('lets the owner extend their own gallery', async () => {
|
||||
const id = await insertEvent(db, ownerId, { expires_at: '2026-06-01T00:00:00.000Z' });
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({ days: 10 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(new Date(res.body.expires_at).toISOString()).toBe('2026-06-11T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('403s a non-owning editor trying to extend someone else\'s gallery', async () => {
|
||||
const id = await insertEvent(db, ownerId); // owned by the super_admin
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${editorToken}`)
|
||||
.send({ days: 30 });
|
||||
expect(res.status).toBe(403); // requireEventOwnership blocks it
|
||||
});
|
||||
|
||||
it('validates the days field', async () => {
|
||||
const id = await insertEvent(db, ownerId);
|
||||
const res = await request(app)
|
||||
.post(`/api/admin/events/${id}/extend`)
|
||||
.set('Authorization', `Bearer ${ownerToken}`)
|
||||
.send({ days: 9999 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Point storage at a throwaway temp dir before requiring the service so the
|
||||
// module-level getStoragePath() picks it up if evaluated.
|
||||
process.env.STORAGE_PATH = path.join(os.tmpdir(), `picpeak-chunk-test-${process.pid}`);
|
||||
|
||||
const chunkedUpload = require('../../src/services/chunkedUploadService');
|
||||
|
||||
describe('chunkedUploadService.initializeUpload filename sanitisation (GHSA-pc72-jf53-w28j)', () => {
|
||||
afterAll(async () => {
|
||||
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
it('strips directory-traversal components from the stored filename', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: '../../uploads/logos/evil.svg',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
const meta = chunkedUpload.getUploadStatus(uploadId);
|
||||
// basename('../../uploads/logos/evil.svg') === 'evil.svg' — the traversal
|
||||
// is gone, so path.join(tempDir, filename) can no longer escape tempDir.
|
||||
expect(meta.filename).toBe('evil.svg');
|
||||
});
|
||||
|
||||
it('keeps a normal filename intact', async () => {
|
||||
const { uploadId } = await chunkedUpload.initializeUpload({
|
||||
filename: 'clip.mp4',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
});
|
||||
expect(uploadId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a filename that collapses to nothing', async () => {
|
||||
await expect(
|
||||
chunkedUpload.initializeUpload({
|
||||
filename: '../',
|
||||
fileSize: 10,
|
||||
mimeType: 'video/mp4',
|
||||
eventId: 1,
|
||||
totalChunks: 1,
|
||||
})
|
||||
).rejects.toThrow(/Invalid filename/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Regression tests for the Docker update instructions (environmentService).
|
||||
*
|
||||
* A production install (docker-compose.production.yml) must get `-f
|
||||
* docker-compose.production.yml` in every update command — bare `docker compose`
|
||||
* targets docker-compose.yml, a different build-based stack that also starts the
|
||||
* dev-only mailhog, which left production users stranded on the old version
|
||||
* (reported against 3.44.0 → 3.45.2).
|
||||
*/
|
||||
const { detectEnvironment, generateUpdateInstructions } = require('../../src/services/environmentService');
|
||||
|
||||
describe('detectEnvironment — production compose detection', () => {
|
||||
const orig = process.env.PICPEAK_RELEASE_CHANNEL;
|
||||
afterEach(() => {
|
||||
if (orig === undefined) delete process.env.PICPEAK_RELEASE_CHANNEL;
|
||||
else process.env.PICPEAK_RELEASE_CHANNEL = orig;
|
||||
});
|
||||
|
||||
it('flags isProductionCompose when PICPEAK_RELEASE_CHANNEL is set', async () => {
|
||||
process.env.PICPEAK_RELEASE_CHANNEL = 'stable';
|
||||
const env = await detectEnvironment();
|
||||
expect(env.isProductionCompose).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag it when the var is absent (default docker-compose.yml)', async () => {
|
||||
delete process.env.PICPEAK_RELEASE_CHANNEL;
|
||||
const env = await detectEnvironment();
|
||||
expect(env.isProductionCompose).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateUpdateInstructions — Docker commands', () => {
|
||||
const cmds = (env) => generateUpdateInstructions(env, '3.45.2').steps.map((s) => s.command);
|
||||
|
||||
it('targets docker-compose.production.yml for a production install', () => {
|
||||
const commands = cmds({ isDocker: true, isProductionCompose: true });
|
||||
expect(commands).toEqual([
|
||||
'docker compose -f docker-compose.production.yml pull',
|
||||
'docker compose -f docker-compose.production.yml up -d',
|
||||
'docker compose -f docker-compose.production.yml logs -f backend',
|
||||
]);
|
||||
// And the warning tells them where to run it.
|
||||
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: true }, '3.45.2');
|
||||
expect(warnings.join(' ')).toMatch(/docker-compose\.production\.yml/);
|
||||
});
|
||||
|
||||
it('uses bare commands + a hint when not a production compose', () => {
|
||||
const commands = cmds({ isDocker: true, isProductionCompose: false });
|
||||
expect(commands).toEqual([
|
||||
'docker compose pull',
|
||||
'docker compose up -d',
|
||||
'docker compose logs -f backend',
|
||||
]);
|
||||
const { warnings } = generateUpdateInstructions({ isDocker: true, isProductionCompose: false }, '3.45.2');
|
||||
// Still nudges production users to add -f in case detection missed.
|
||||
expect(warnings.join(' ')).toMatch(/-f docker-compose\.production\.yml/);
|
||||
});
|
||||
});
|
||||
@@ -72,9 +72,16 @@ jest.mock('../../src/services/businessProfileService', () => ({
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => ({
|
||||
claimNextSequence: jest.fn(async () => 42),
|
||||
}));
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Smoke tests for invoiceService's primary flows ahead of the god-file
|
||||
* decomposition — createInvoice happy path (incl. the line-item
|
||||
* totals/VAT math), list/get reads, and the status-transition guards
|
||||
* on cancelInvoice / releaseForDelivery.
|
||||
*
|
||||
* Uses the same deep-mocked db pattern as
|
||||
* invoiceService.installmentPlan.test.js — chains are queued per table
|
||||
* and assertions probe insert/update call shapes rather than SQL.
|
||||
*/
|
||||
|
||||
const chains = [];
|
||||
function makeChain() {
|
||||
const c = {
|
||||
_firstValue: undefined,
|
||||
_updateResult: 1,
|
||||
_insertResult: [{ id: 999 }],
|
||||
_selectResult: [],
|
||||
then: function (onResolve, onReject) {
|
||||
return Promise.resolve(this._selectResult).then(onResolve, onReject);
|
||||
},
|
||||
where: jest.fn(function () { return this; }),
|
||||
whereNot: jest.fn(function () { return this; }),
|
||||
whereIn: jest.fn(function () { return this; }),
|
||||
whereNull: jest.fn(function () { return this; }),
|
||||
whereNotNull: jest.fn(function () { return this; }),
|
||||
andWhere: jest.fn(function () { return this; }),
|
||||
orderBy: jest.fn(function () { return this; }),
|
||||
limit: jest.fn(function () { return this; }),
|
||||
select: jest.fn(function () { return this; }),
|
||||
sum: jest.fn(function () { return this; }),
|
||||
count: jest.fn(function () { return this; }),
|
||||
clone: jest.fn(function () { return this; }),
|
||||
clearSelect: jest.fn(function () { return this; }),
|
||||
clearOrder: jest.fn(function () { return this; }),
|
||||
offset: jest.fn(function () { return this; }),
|
||||
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
|
||||
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
|
||||
insert: jest.fn(function () { return this; }),
|
||||
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
|
||||
del: jest.fn(function () { return Promise.resolve(1); }),
|
||||
onConflict: jest.fn(function () { return this; }),
|
||||
ignore: jest.fn(function () { return Promise.resolve(1); }),
|
||||
merge: jest.fn(function () { return Promise.resolve(1); }),
|
||||
increment: jest.fn(function () { return this; }),
|
||||
forUpdate: jest.fn(function () { return this; }),
|
||||
leftJoin: jest.fn(function () { return this; }),
|
||||
};
|
||||
chains.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
const tableChains = {};
|
||||
function pickChainFor(name) {
|
||||
if (!tableChains[name]) tableChains[name] = makeChain();
|
||||
return tableChains[name];
|
||||
}
|
||||
|
||||
const mockDbFn = jest.fn((name) => pickChainFor(name));
|
||||
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
|
||||
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: mockDbFn,
|
||||
withRetry: jest.fn(async (fn) => fn()),
|
||||
logActivity: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/appSettings', () => ({
|
||||
getAppSetting: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/businessProfileService', () => ({
|
||||
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
|
||||
resolveBankAccountForCurrency: jest.fn(async () => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/documentSequences', () => {
|
||||
const claimNextSequence = jest.fn(async () => 42);
|
||||
// Delegates to the claimNextSequence mock so call-count assertions
|
||||
// below keep observing sequence claims.
|
||||
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
|
||||
const seq = await claimNextSequence(kind, 2026, trx);
|
||||
return `R-2026-${String(seq).padStart(4, '0')}`;
|
||||
});
|
||||
return { claimNextSequence, nextDocumentNumber };
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/pdfService', () => ({
|
||||
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
|
||||
}));
|
||||
|
||||
const invoiceService = require('../../src/services/invoiceService');
|
||||
|
||||
function resetChains() {
|
||||
for (const k of Object.keys(tableChains)) delete tableChains[k];
|
||||
jest.clearAllMocks();
|
||||
}
|
||||
|
||||
const activeCustomer = {
|
||||
id: 5, is_active: 1, feature_bills: 1,
|
||||
billing_cadence: 'per_event', preferred_language: 'de',
|
||||
};
|
||||
|
||||
describe('createInvoice — happy path + totals', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
pickChainFor('invoices')._insertResult = [{ id: 777 }];
|
||||
|
||||
const result = await invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 8.1,
|
||||
lineItems: [
|
||||
// 2 × 100.00 = 200.00
|
||||
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
|
||||
// 50.00 with 10% discount = 45.00
|
||||
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
|
||||
// Parent header — total auto-resolves from priced sub-items (350.00)
|
||||
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
|
||||
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
|
||||
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
|
||||
],
|
||||
}, 1);
|
||||
|
||||
expect(result.invoiceIds).toEqual([777]);
|
||||
|
||||
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
|
||||
// double-count. VAT = round(59500 × 8.1%) = 4820.
|
||||
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
|
||||
invoice_number: 'R-2026-0042',
|
||||
customer_account_id: 5,
|
||||
currency: 'CHF',
|
||||
status: 'scheduled',
|
||||
net_amount_minor: 59500,
|
||||
vat_rate: 8.1,
|
||||
vat_amount_minor: 4820,
|
||||
shipping_amount_minor: 0,
|
||||
total_amount_minor: 64320,
|
||||
installment_total: 1,
|
||||
}));
|
||||
// Exactly one sequence number claimed for a single-row create.
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).toHaveBeenCalledTimes(1);
|
||||
// Line items landed in invoice_line_items.
|
||||
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('409s on a deactivated customer before touching the sequence', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5, vatRate: 0, lineItems: [],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 409 });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
|
||||
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
|
||||
await expect(invoiceService.createInvoice({
|
||||
customerAccountId: 5,
|
||||
vatRate: 7.7,
|
||||
lineItems: [
|
||||
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
|
||||
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
|
||||
],
|
||||
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
|
||||
const { claimNextSequence } = require('../../src/utils/documentSequences');
|
||||
expect(claimNextSequence).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('lists invoices with total + pagination echo', async () => {
|
||||
pickChainFor('invoices')._selectResult = [
|
||||
{ id: 1, invoice_number: 'R-2026-0001' },
|
||||
{ id: 2, invoice_number: 'R-2026-0002' },
|
||||
];
|
||||
pickChainFor('invoices')._firstValue = { total: 7 };
|
||||
|
||||
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
|
||||
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.total).toBe(7);
|
||||
expect(result.page).toBe(2);
|
||||
expect(result.pageSize).toBe(10);
|
||||
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
|
||||
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
|
||||
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
|
||||
pickChainFor('invoice_line_items as li')._selectResult = [
|
||||
{ id: 30, position: 1, description: 'Shoot' },
|
||||
];
|
||||
pickChainFor('invoice_payment_log')._selectResult = [];
|
||||
|
||||
const result = await invoiceService.getInvoiceById(3);
|
||||
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.payments).toEqual([]);
|
||||
});
|
||||
|
||||
it('getInvoiceById returns null for an unknown id', async () => {
|
||||
pickChainFor('invoices')._firstValue = undefined;
|
||||
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
|
||||
beforeEach(() => resetChains());
|
||||
|
||||
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
|
||||
};
|
||||
const result = await invoiceService.cancelInvoice(9, 1);
|
||||
expect(result).toEqual({ cancelled: true, stornoId: null });
|
||||
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'cancelled' })
|
||||
);
|
||||
});
|
||||
|
||||
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 9, status: 'cancelled', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(9, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
|
||||
});
|
||||
|
||||
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 10, status: 'sent', kind: 'storno',
|
||||
};
|
||||
await expect(invoiceService.cancelInvoice(10, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
|
||||
});
|
||||
|
||||
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
|
||||
pickChainFor('invoices')._firstValue = {
|
||||
id: 11, status: 'sent', kind: 'invoice',
|
||||
};
|
||||
await expect(invoiceService.releaseForDelivery(11, 1))
|
||||
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Unit tests for mfaService — admin TOTP MFA (#738).
|
||||
*
|
||||
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
||||
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
||||
* derivation has key material (the service derives the AES key from
|
||||
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
||||
*/
|
||||
|
||||
// Must be set BEFORE the service is required — the key is derived lazily per
|
||||
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
||||
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
||||
it('round-trips encrypt → decrypt to the original secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const blob = mfaService.encryptSecret(secret);
|
||||
expect(blob).toEqual(expect.any(String));
|
||||
expect(blob).not.toContain(secret); // stored form is not plaintext
|
||||
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
||||
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const a = mfaService.encryptSecret(secret);
|
||||
const b = mfaService.encryptSecret(secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(mfaService.decryptSecret(a)).toBe(secret);
|
||||
expect(mfaService.decryptSecret(b)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
||||
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
||||
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the auth tag / ciphertext is tampered with', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
||||
// Flip a character in the ciphertext → GCM auth check must fail.
|
||||
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
||||
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — TOTP verification', () => {
|
||||
it('accepts a freshly generated code for the plaintext secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates whitespace in the submitted code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty inputs rather than throwing', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
||||
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
||||
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
||||
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
||||
expect(uri).toContain(`secret=${secret}`);
|
||||
});
|
||||
|
||||
it('builds a PNG data-URL QR for the URI', async () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
const qr = await mfaService.buildQrDataUrl(uri);
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — recovery codes', () => {
|
||||
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(new Set(plain).size).toBe(10);
|
||||
expect(new Set(hashed).size).toBe(10);
|
||||
// Hashes are bcrypt, not the plaintext.
|
||||
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
||||
plain.forEach((p) => expect(hashed).not.toContain(p));
|
||||
});
|
||||
|
||||
it('formats a raw code into 4-char groups', () => {
|
||||
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
||||
});
|
||||
|
||||
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const target = plain[3];
|
||||
|
||||
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
||||
expect(first.matched).toBe(true);
|
||||
expect(first.remainingHashes).toHaveLength(9);
|
||||
|
||||
// Reusing the same code against the reduced set must now fail.
|
||||
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
||||
expect(reuse.matched).toBe(false);
|
||||
expect(reuse.remainingHashes).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and trims whitespace', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
||||
expect(res.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('handles empty / missing input safely', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toBe(hashed);
|
||||
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
||||
expect(noHashes.matched).toBe(false);
|
||||
expect(noHashes.remainingHashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — parseRecoveryCodes', () => {
|
||||
it('parses a JSON string array', () => {
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
||||
});
|
||||
it('passes an already-array through', () => {
|
||||
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns [] for null / garbage / non-array JSON', () => {
|
||||
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — isEnrolled coercion', () => {
|
||||
it('treats true / 1 / "1" as enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
||||
});
|
||||
it('treats false / 0 / null / missing as not enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
||||
expect(mfaService.isEnrolled({})).toBe(false);
|
||||
expect(mfaService.isEnrolled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
|
||||
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
|
||||
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
|
||||
* as in production. Reconciliation is non-destructive (update-in-place / rename,
|
||||
* never delete) so restored rows referenced by FKs keep their ids.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
|
||||
let db;
|
||||
let reinjectCurrentAdmin;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
|
||||
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
await db.schema.createTable('admin_users', (t) => {
|
||||
t.increments('id');
|
||||
t.string('username').notNullable().unique();
|
||||
t.string('email').notNullable().unique();
|
||||
t.string('password_hash');
|
||||
t.boolean('is_active').defaultTo(true);
|
||||
t.boolean('must_change_password').defaultTo(false);
|
||||
t.integer('role_id');
|
||||
t.integer('created_by');
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
t.string('two_factor_secret');
|
||||
t.text('two_factor_recovery_codes');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => { await db.destroy(); });
|
||||
|
||||
const operator = {
|
||||
id: 1, username: 'admin', email: 'op@example.com',
|
||||
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
|
||||
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
|
||||
};
|
||||
|
||||
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 7, username: 'someoneelse', email: 'OP@example.com',
|
||||
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
|
||||
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users');
|
||||
expect(rows).toHaveLength(1);
|
||||
const row = rows[0];
|
||||
expect(row.id).toBe(7); // id preserved → FK refs hold
|
||||
expect(row.username).toBe('admin');
|
||||
expect(row.password_hash).toBe('OP_HASH');
|
||||
expect(Boolean(row.two_factor_enabled)).toBe(true);
|
||||
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
|
||||
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
|
||||
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
|
||||
// dangling role_id/created_by on a cross-instance restore) — the restored
|
||||
// row keeps its own already-valid values.
|
||||
expect(row.role_id).toBe(4);
|
||||
expect(row.created_by).toBe(5);
|
||||
});
|
||||
|
||||
test('renames (not deletes) a different row holding the operator username', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 3, username: 'admin', email: 'other@instance.test',
|
||||
password_hash: 'OTHER', is_active: 1, role_id: 4,
|
||||
});
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
|
||||
const other = rows.find((r) => r.id === 3);
|
||||
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
|
||||
expect(other.email).toBe('other@instance.test');
|
||||
const op = rows.find((r) => r.username === 'admin');
|
||||
expect(op.password_hash).toBe('OP_HASH');
|
||||
});
|
||||
|
||||
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
|
||||
await db('admin_users').insert([
|
||||
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
|
||||
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
|
||||
]);
|
||||
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // both rows survive
|
||||
const opRow = rows.find((r) => r.id === 4); // email match updated in place
|
||||
expect(opRow.username).toBe('admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
|
||||
expect(renamed.username).toBe('admin__restored_5');
|
||||
});
|
||||
|
||||
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
|
||||
await db('admin_users').insert({
|
||||
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
|
||||
});
|
||||
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const rows = await db('admin_users').orderBy('id');
|
||||
expect(rows).toHaveLength(2); // backup admin untouched
|
||||
const opRow = rows.find((r) => r.username === 'admin');
|
||||
expect(opRow.password_hash).toBe('OP_HASH');
|
||||
expect(opRow.id).toBe(10); // max(9)+1, no collision
|
||||
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug.
|
||||
*
|
||||
* The event-create route seeds show_interval_ms/show_transition_ms from
|
||||
* app_settings via an int-parse-and-clamp. The old inline guard
|
||||
* (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself
|
||||
* for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so
|
||||
* NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL
|
||||
* rejects NaN for integer columns ("invalid input syntax for type
|
||||
* integer: NaN") while SQLite silently stores NULL — so POST
|
||||
* /api/admin/events 500'd on PG whenever the slideshow settings rows
|
||||
* were absent (getAppSetting returns its null default).
|
||||
*/
|
||||
|
||||
const { clampIntOrUndefined } = require('../../src/utils/numericHelpers');
|
||||
|
||||
describe('clampIntOrUndefined', () => {
|
||||
it('returns undefined for null (the getAppSetting missing-row default)', () => {
|
||||
expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined, empty string, and booleans', () => {
|
||||
expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-numeric garbage', () => {
|
||||
expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never returns NaN for any of the failure-mode inputs', () => {
|
||||
for (const v of [null, undefined, '', true, false, 'x', {}, []]) {
|
||||
const out = clampIntOrUndefined(v, 100, 5000);
|
||||
expect(Number.isNaN(out)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses and clamps valid values', () => {
|
||||
expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000);
|
||||
expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000);
|
||||
expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Regression tests for the password-complexity setting read path.
|
||||
*
|
||||
* Bug 1 (key mismatch): the settings UI saves the admin's choice as
|
||||
* `security_password_complexity` (useSettingsState.ts prefixes every
|
||||
* security field with `security_`), but getPasswordComplexitySettings()
|
||||
* queried `security_password_complexity_level` — a key nothing writes —
|
||||
* so the configured level was silently ignored.
|
||||
*
|
||||
* Bug 2 (driver shape, codex review of #843): on SQLite the TEXT column
|
||||
* returns the JSON-stringified value ('"very_strong"'), but on Postgres
|
||||
* (production default) `setting_value` is a json column and comes back
|
||||
* already decoded ('very_strong'). A bare JSON.parse throws on the
|
||||
* decoded shape and the outer catch fell back to 'moderate' — the
|
||||
* setting stayed unenforced on Postgres even with the right key.
|
||||
*/
|
||||
|
||||
const mockQueriedKeys = [];
|
||||
let mockStoredValue;
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => ({
|
||||
where(_col, key) {
|
||||
mockQueriedKeys.push(key);
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
return Promise.resolve(
|
||||
mockQueriedKeys[mockQueriedKeys.length - 1] === 'security_password_complexity'
|
||||
? { setting_key: 'security_password_complexity', setting_value: mockStoredValue }
|
||||
: undefined
|
||||
);
|
||||
},
|
||||
}),
|
||||
withRetry: (fn) => fn(),
|
||||
}));
|
||||
|
||||
const { getPasswordComplexitySettings } = require('../../src/utils/passwordValidation');
|
||||
|
||||
describe('getPasswordComplexitySettings', () => {
|
||||
beforeEach(() => { mockQueriedKeys.length = 0; });
|
||||
|
||||
it('reads the key the settings UI actually writes (SQLite shape: JSON-stringified)', async () => {
|
||||
mockStoredValue = JSON.stringify('very_strong'); // '"very_strong"'
|
||||
const level = await getPasswordComplexitySettings();
|
||||
expect(mockQueriedKeys).toContain('security_password_complexity');
|
||||
expect(level).toBe('very_strong');
|
||||
});
|
||||
|
||||
it('accepts the Postgres json-column shape (already decoded, no quotes)', async () => {
|
||||
mockStoredValue = 'very_strong'; // pg driver auto-parses the json column
|
||||
const level = await getPasswordComplexitySettings();
|
||||
expect(level).toBe('very_strong');
|
||||
});
|
||||
|
||||
it('falls back to moderate on an empty value', async () => {
|
||||
mockStoredValue = '';
|
||||
const level = await getPasswordComplexitySettings();
|
||||
expect(level).toBe('moderate');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const path = require('path');
|
||||
const { assertZipEntriesWithin } = require('../../src/utils/safePath');
|
||||
|
||||
describe('assertZipEntriesWithin (ZIP-slip guard, GHSA-jfhw-fj23-fx6x)', () => {
|
||||
const root = path.join('/tmp', 'picpeak-extract-root');
|
||||
|
||||
it('accepts entries that stay within the extraction root', () => {
|
||||
const entries = [
|
||||
{ name: 'photo.jpg' },
|
||||
{ name: 'category/nested/photo.png' },
|
||||
{ name: 'photos_manifest.json' },
|
||||
{ name: 'subdir/' },
|
||||
];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a parent-traversal entry', () => {
|
||||
const entries = [{ name: '../../uploads/logos/evil.svg' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects an absolute-path entry', () => {
|
||||
const entries = [{ name: '/etc/cron.d/evil' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('rejects when a safe entry is mixed with a traversal entry', () => {
|
||||
const entries = [{ name: 'ok.jpg' }, { name: '../escape.txt' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
|
||||
it('tolerates empty / nameless entries', () => {
|
||||
expect(() => assertZipEntriesWithin([{}, { name: '' }, null], root)).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not treat a sibling prefix directory as inside the root', () => {
|
||||
// root is .../picpeak-extract-root; ../picpeak-extract-root-evil must not pass
|
||||
const entries = [{ name: '../picpeak-extract-root-evil/x' }];
|
||||
expect(() => assertZipEntriesWithin(entries, root)).toThrow(/escapes the extraction directory/);
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,16 @@ exports.up = async function(knex) {
|
||||
// Initialize tables
|
||||
await initializeDatabase();
|
||||
|
||||
// Create default admin user if none exists
|
||||
// Create default admin user if none exists.
|
||||
//
|
||||
// Legacy path — only when ADMIN_PASSWORD is explicitly provided (keeps
|
||||
// existing docker-compose installs working unchanged). When it is NOT set,
|
||||
// we deliberately leave admin_users empty so the first-run setup wizard
|
||||
// (setupService / /setup) creates the admin in-browser — no ADMIN_PASSWORD
|
||||
// in .env. Existing deployments already ran this migration, so this only
|
||||
// affects fresh installs.
|
||||
const adminExists = await knex('admin_users').first();
|
||||
if (!adminExists) {
|
||||
if (!adminExists && process.env.ADMIN_PASSWORD) {
|
||||
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
|
||||
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Migration 151: admin MFA (TOTP) enrollment support — issue #738.
|
||||
*
|
||||
* The `admin_users.two_factor_enabled` / `two_factor_secret` columns already
|
||||
* exist from the legacy migration 016 but were never wired to any code. This
|
||||
* migration adds the two columns the real TOTP flow needs on top of them:
|
||||
*
|
||||
* - two_factor_recovery_codes: JSON array of one-time backup codes, stored
|
||||
* HASHED (never plaintext), so a locked-out admin can log in without the
|
||||
* authenticator. Consumed on use.
|
||||
* - two_factor_enrolled_at: when the admin completed enrollment (audit /
|
||||
* display only).
|
||||
*
|
||||
* The TOTP secret itself continues to live in the existing `two_factor_secret`
|
||||
* column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService —
|
||||
* the column type is unchanged (the encrypted blob is short).
|
||||
*
|
||||
* Additive and idempotent: only adds columns, guarded by hasColumn, so it is
|
||||
* safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
|
||||
const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Backfill the legacy columns too, in case an install somehow lacks them
|
||||
// (016 is a legacy migration; guard defensively).
|
||||
if (!hasEnabled) {
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
}
|
||||
if (!hasSecret) {
|
||||
t.string('two_factor_secret').nullable();
|
||||
}
|
||||
if (!hasRecovery) {
|
||||
t.text('two_factor_recovery_codes').nullable();
|
||||
}
|
||||
if (!hasEnrolledAt) {
|
||||
t.timestamp('two_factor_enrolled_at').nullable();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Only drop what THIS migration added; leave the legacy 016 columns.
|
||||
if (hasRecovery) {
|
||||
t.dropColumn('two_factor_recovery_codes');
|
||||
}
|
||||
if (hasEnrolledAt) {
|
||||
t.dropColumn('two_factor_enrolled_at');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Migration 152: make events.hero_logo_visible NULL-able so NULL means
|
||||
* "inherit the global branding_logo_display_hero setting" (#756).
|
||||
*
|
||||
* Before: hero_logo_visible was `boolean NOT NULL DEFAULT true`, and every
|
||||
* event got a concrete true/false snapshotted at creation. The global
|
||||
* "Show logo in hero section" toggle (branding_logo_display_hero) was only a
|
||||
* creation-time default and never affected existing galleries — so disabling
|
||||
* it did nothing to already-published galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to the global
|
||||
* setting when the per-event value is NULL, so the global toggle controls
|
||||
* every gallery that hasn't been deliberately overridden per-event.
|
||||
*
|
||||
* Data backfill: NULL out the DEFAULTED `true` rows so they start inheriting
|
||||
* the global. A deliberate per-gallery hide (`false`) is kept — we can't tell a
|
||||
* defaulted-true from a chosen-true, but `false` is almost always a conscious
|
||||
* "hide it here", and nulling it could silently re-show a hidden logo.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP NOT NULL');
|
||||
} else {
|
||||
// SQLite (and others): knex recreates the table without the NOT NULL/default.
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
// Existing defaulted-`true` galleries now inherit the global toggle.
|
||||
await knex('events').where('hero_logo_visible', true).update({ hero_logo_visible: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
// Re-materialise NULLs as the old default (true) before restoring NOT NULL.
|
||||
await knex('events').whereNull('hero_logo_visible').update({ hero_logo_visible: true });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET DEFAULT true');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').notNullable().defaultTo(true).alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Migration 153: make events.hero_logo_size NULL-able so NULL means "inherit
|
||||
* the global branding_logo_size" (#756 follow-up — the size counterpart of 152).
|
||||
*
|
||||
* Before: hero_logo_size was `varchar NOT NULL DEFAULT 'medium'`, snapshotted
|
||||
* from the global branding_logo_size at creation. The two gallery render paths
|
||||
* then disagreed — GalleryLayout read the global size live, while the
|
||||
* hero-header path used the per-event snapshot — so a hero logo could render at
|
||||
* different sizes on different layouts, and changing the global size didn't
|
||||
* update hero-header galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to
|
||||
* branding_logo_size when the per-event value is NULL, and both render paths
|
||||
* consume that resolved size.
|
||||
*
|
||||
* Data backfill: NULL out ALL existing hero_logo_size so every gallery inherits
|
||||
* the global size going forward. Unlike a boolean we can't tell a defaulted
|
||||
* value from a chosen one — but nulling is the safe choice here: it restores the
|
||||
* live-global behaviour GalleryLayout already had, and the per-event size can be
|
||||
* re-set from the event's edit page.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
await knex('events').update({ hero_logo_size: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
await knex('events').whereNull('hero_logo_size').update({ hero_logo_size: 'medium' });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw("ALTER TABLE events ALTER COLUMN hero_logo_size SET DEFAULT 'medium'");
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).notNullable().defaultTo('medium').alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Messages Phase 2 — additional inbound mailboxes + captured message bodies.
|
||||
*
|
||||
* `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP
|
||||
* that already lives in `email_configs` (e.g. the customer `hello@` mailbox).
|
||||
* The intake poller (emailIntakeService) polls the accounting mailbox AND every
|
||||
* enabled row here; customer mail is logged with its body but not routed to the
|
||||
* accounting inbox.
|
||||
*
|
||||
* The new `received_emails` columns capture the parsed message so the Messages
|
||||
* reading pane can show it: `account_key` tags which mailbox it came from,
|
||||
* `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the
|
||||
* envelope recipient. All additive + guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const hasAccounts = await knex.schema.hasTable('mail_accounts');
|
||||
if (!hasAccounts) {
|
||||
await knex.schema.createTable('mail_accounts', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('account_key', 64).notNullable().unique(); // e.g. 'customers'
|
||||
t.string('label', 120);
|
||||
t.string('imap_host', 255);
|
||||
t.integer('imap_port').defaultTo(993);
|
||||
t.boolean('imap_secure').defaultTo(true);
|
||||
t.string('imap_user', 255);
|
||||
t.string('imap_pass', 512);
|
||||
t.string('imap_folder', 255).defaultTo('INBOX');
|
||||
t.boolean('enabled').defaultTo(false);
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const cols = [
|
||||
['account_key', (t) => t.string('account_key', 64)],
|
||||
['to_address', (t) => t.string('to_address', 512)],
|
||||
['body_html', (t) => t.text('body_html')],
|
||||
['body_text', (t) => t.text('body_text')],
|
||||
];
|
||||
for (const [name, add] of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('received_emails', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) await knex.schema.alterTable('received_emails', add);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
// Non-destructive on the audit log: leave the added columns in place (they're
|
||||
// nullable and harmless). Only drop the new table.
|
||||
await knex.schema.dropTableIfExists('mail_accounts');
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Messages Phase 3 — distinguish human-composed sends from system mail.
|
||||
*
|
||||
* `origin` is 'system' for everything the app queues automatically (invoices,
|
||||
* reminders, gallery notices — the Automated stream) and 'manual' for emails an
|
||||
* admin composed/edited in the Messages composer (replies + document messages —
|
||||
* the Customers ▸ Sent stream). Existing rows default to 'system'.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const has = await knex.schema.hasColumn('email_queue', 'origin');
|
||||
if (!has) {
|
||||
await knex.schema.alterTable('email_queue', (t) => {
|
||||
t.string('origin', 16).defaultTo('system');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const has = await knex.schema.hasColumn('email_queue', 'origin');
|
||||
if (has) {
|
||||
await knex.schema.alterTable('email_queue', (t) => {
|
||||
t.dropColumn('origin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
|
||||
*
|
||||
* The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
|
||||
* outgoing (SMTP) config, so replies to customers send from hello@ instead of
|
||||
* the global no-reply@ identity. All additive/guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const cols = [
|
||||
['smtp_host', (t) => t.string('smtp_host', 255)],
|
||||
['smtp_port', (t) => t.integer('smtp_port')],
|
||||
['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
|
||||
['smtp_user', (t) => t.string('smtp_user', 255)],
|
||||
['smtp_pass', (t) => t.string('smtp_pass', 512)],
|
||||
['from_email', (t) => t.string('from_email', 255)],
|
||||
['from_name', (t) => t.string('from_name', 120)],
|
||||
];
|
||||
for (const [name, add] of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('mail_accounts', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) await knex.schema.alterTable('mail_accounts', add);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
|
||||
for (const name of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('mail_accounts', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Messages — Archive / Delete (trash) support.
|
||||
*
|
||||
* `mailbox_state` on both mail tables: 'active' (normal folders), 'archived'
|
||||
* (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the
|
||||
* row moves to 'deleted' and is only removed for good when purged FROM the
|
||||
* Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
for (const table of ['email_queue', 'received_emails']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn(table, 'mailbox_state');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable(table, (t) => {
|
||||
t.string('mailbox_state', 16).defaultTo('active');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
for (const table of ['email_queue', 'received_emails']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn(table, 'mailbox_state');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (has) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); });
|
||||
}
|
||||
}
|
||||
};
|
||||
Generated
+430
-242
File diff suppressed because it is too large
Load Diff
+11
-7
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.76.2-beta.0",
|
||||
"version": "3.45.5",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
@@ -18,7 +21,7 @@
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "1.16.0",
|
||||
"axios": "1.18.1",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -46,22 +49,23 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"pg": "^8.16.3",
|
||||
"postcss": "8.5.10",
|
||||
"qrcode": "^1.5.4",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sharp": "0.34.3",
|
||||
"sharp": "0.35.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"swissqrbill": "^4.3.0",
|
||||
"tar": ">=7.5.16",
|
||||
"uuid": "^11.1.1",
|
||||
"winston": "^3.8.2",
|
||||
"zxcvbn": "^4.4.2",
|
||||
"postcss": "8.5.10",
|
||||
"tar": ">=7.5.16"
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
@@ -79,7 +83,7 @@
|
||||
"fast-xml-parser": ">=5.7.0",
|
||||
"qs": ">=6.15.2",
|
||||
"tar": ">=7.5.16",
|
||||
"brace-expansion": ">=5.0.6",
|
||||
"brace-expansion": ">=5.0.7",
|
||||
"minimatch": ">=9.0.7",
|
||||
"path-to-regexp": "0.1.13",
|
||||
"lodash": ">=4.18.1",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738).
|
||||
*
|
||||
* Break-glass recovery for when an admin loses their authenticator AND their
|
||||
* recovery codes. Clears the MFA state so the admin can log in with just their
|
||||
* password and re-enroll from Settings.
|
||||
*
|
||||
* Usage (inside the running backend container):
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --email admin@example.com
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
*
|
||||
* Flags:
|
||||
* --email <addr> target a single admin by email (or --username <name>)
|
||||
* --all reset MFA for EVERY admin (full lockout / break-glass)
|
||||
* --yes non-interactive (skip the confirmation prompt)
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const { db, logActivity } = require('../src/database/db');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (f) => args.includes(f);
|
||||
const getOption = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive');
|
||||
const all = hasFlag('--all');
|
||||
const email = getOption('email');
|
||||
const username = getOption('username');
|
||||
|
||||
const MFA_CLEAR = {
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
function ask(prompt) {
|
||||
if (force) return Promise.resolve('yes');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n========================================');
|
||||
console.log('PicPeak Admin MFA Reset Tool');
|
||||
console.log('========================================\n');
|
||||
|
||||
if (!all && !email && !username) {
|
||||
console.error('❌ Specify a target: --email <addr>, --username <name>, or --all');
|
||||
console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve target admins.
|
||||
let targets;
|
||||
if (all) {
|
||||
targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled');
|
||||
} else {
|
||||
const q = db('admin_users');
|
||||
if (email) q.where({ email });
|
||||
if (username) q.where({ username });
|
||||
targets = await q.select('id', 'username', 'email', 'two_factor_enabled');
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error('❌ No matching admin user found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1);
|
||||
console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`);
|
||||
for (const t of targets) {
|
||||
const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off';
|
||||
console.log(` - ${t.username} <${t.email}> [${flag}]`);
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDisable MFA for the above? (yes/no): ');
|
||||
const normalized = String(confirm).trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Cancelled. No changes made.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ids = targets.map((t) => t.id);
|
||||
const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR);
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
await logActivity('admin_mfa_reset_cli',
|
||||
{ admin_id: t.id, via: 'cli' },
|
||||
null,
|
||||
{ type: 'system', id: 0, name: 'reset-admin-mfa.js' }
|
||||
);
|
||||
} catch (_) { /* activity log is best-effort */ }
|
||||
}
|
||||
|
||||
console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Failed to reset MFA:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+22
-3
@@ -38,11 +38,11 @@ const {
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./src/routes/auth');
|
||||
const eventRoutes = require('./src/routes/events');
|
||||
const galleryRoutes = require('./src/routes/gallery');
|
||||
const adminRoutes = require('./src/routes/admin');
|
||||
const adminAuthRoutes = require('./src/routes/adminAuth');
|
||||
const secureImagesRoutes = require('./src/routes/secureImages');
|
||||
const setupRoutes = require('./src/routes/setup');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -397,6 +397,8 @@ async function initializeRateLimiters() {
|
||||
app.use('/api/auth', authRateLimiter);
|
||||
app.use('/api/gallery/:slug/verify', authRateLimiter);
|
||||
app.use('/api/admin/auth/login', authRateLimiter);
|
||||
app.use('/api/setup/admin', authRateLimiter);
|
||||
app.use('/api/setup/verify-token', authRateLimiter);
|
||||
}
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
@@ -690,9 +692,9 @@ app.get('/health', async (req, res) => {
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||
// Gallery routes - main routes first, then feedback routes
|
||||
app.use('/api/gallery', galleryRoutes);
|
||||
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||
@@ -999,6 +1001,16 @@ async function startServer() {
|
||||
logger.warn('Install-from-backup hook threw:', err.message);
|
||||
}
|
||||
|
||||
// First-run: surface a one-time setup token while no admin account exists.
|
||||
// Runs AFTER install-from-backup so a restored instance (which repopulates
|
||||
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
|
||||
let setupToken = null;
|
||||
try {
|
||||
setupToken = await require('./src/services/setupService').ensureSetupToken();
|
||||
} catch (err) {
|
||||
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
|
||||
}
|
||||
|
||||
// Start backup service
|
||||
await startBackupService();
|
||||
|
||||
@@ -1014,6 +1026,13 @@ async function startServer() {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
||||
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
|
||||
// First-run: print the one-time setup token to STDOUT (the file logger
|
||||
// doesn't reach `docker logs`), as the last + most visible thing at boot.
|
||||
if (setupToken) {
|
||||
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
|
||||
const line = '='.repeat(64);
|
||||
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
|
||||
@@ -343,12 +343,34 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => {
|
||||
// 3rd-party preview services used by business-messaging stacks
|
||||
'LinkPreview/1.0',
|
||||
'Slack-ImgProxy/1.0',
|
||||
// Viber + broader crawler set (#699 follow-up)
|
||||
'Mozilla/5.0 (compatible; Viber)',
|
||||
'Mozilla/5.0 (compatible; Bluesky Cardyb/1.1)',
|
||||
'facebookcatalog/1.0',
|
||||
'kakaotalk-scrap/1.0',
|
||||
'Mozilla/5.0 (compatible; Synapse/1.98)',
|
||||
'Rocket.Chat/6.0',
|
||||
];
|
||||
for (const ua of knownBots) {
|
||||
expect(isSocialCrawler(ua)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT match human in-app-browser UAs (our OG response is meta-only, no redirect)', () => {
|
||||
// These share a token with a preview bot but are also sent by real users
|
||||
// browsing inside the app's webview — matching them would serve a human
|
||||
// the bare OG stub. Deliberately excluded; guard against re-adding them.
|
||||
const inAppBrowsers = [
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit MicroMessenger/8.0.0', // WeChat in-app
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit Line/13.0.0', // LINE in-app
|
||||
'Mozilla/5.0 (Linux; Android) Zalo', // Zalo in-app
|
||||
'Mozilla/5.0 (Macintosh) Chrome/120.0 Safari/537.36 boxing', // "XING" substring trap
|
||||
];
|
||||
for (const ua of inAppBrowsers) {
|
||||
expect(isSocialCrawler(ua)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match a regular browser UA', () => {
|
||||
const browsers = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
|
||||
it('allows access when the event_customer_assignments row exists', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -131,6 +132,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -160,6 +162,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
// and start 403'ing per-event-password sessions.
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
customerId: 7,
|
||||
// intentionally no `via` claim
|
||||
@@ -191,6 +194,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
|
||||
it('does NOT touch event_customer_assignments and passes through', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
// No via, no customerId — this is the legacy per-event-password
|
||||
// flow where every guest mints their own JWT after entering the
|
||||
|
||||
@@ -9,6 +9,7 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Get the storage path from environment or default
|
||||
@@ -220,14 +221,14 @@ const createCustomUploader = (config) => {
|
||||
const uploadTimeoutMiddleware = (timeout = 300000) => {
|
||||
return (req, res, next) => {
|
||||
req.setTimeout(timeout, () => {
|
||||
console.error('Upload request timed out');
|
||||
logger.error('Upload request timed out');
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({ error: 'Upload request timed out' });
|
||||
}
|
||||
});
|
||||
|
||||
res.setTimeout(timeout, () => {
|
||||
console.error('Upload response timed out');
|
||||
logger.error('Upload response timed out');
|
||||
});
|
||||
|
||||
next();
|
||||
|
||||
@@ -18,6 +18,7 @@ async function adminAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
@@ -140,6 +141,7 @@ async function galleryAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
@@ -209,7 +211,7 @@ async function photoAuth(req, res, next) {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ async function customerAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
|
||||
@@ -66,18 +66,29 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (error) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
|
||||
// Only gallery-scoped tokens grant gallery access. Every legitimate
|
||||
// path (password login, share link, client access, customer-minted,
|
||||
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
|
||||
// identity token (type:'guest', for feedback attribution) that carries a
|
||||
// matching eventId — instead of relying on other token types incidentally
|
||||
// lacking an eventId to fail the id match below.
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
||||
}
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
|
||||
@@ -23,6 +23,7 @@ async function resolveGuest(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Cache maintenance mode status to avoid DB queries on every request
|
||||
let maintenanceMode = false;
|
||||
@@ -26,7 +27,7 @@ async function queryWithRetry(queryFn, retries = MAX_RETRIES) {
|
||||
error.code === 'ECONNRESET';
|
||||
|
||||
if (isConnectionError) {
|
||||
console.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
logger.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
|
||||
} else {
|
||||
throw error; // Don't retry non-connection errors
|
||||
@@ -56,7 +57,7 @@ async function checkMaintenanceMode() {
|
||||
|
||||
return maintenanceMode;
|
||||
} catch (error) {
|
||||
console.error('Error checking maintenance mode after retries:', error.message);
|
||||
logger.error('Error checking maintenance mode after retries:', error.message);
|
||||
// Return cached value or false if no cache
|
||||
return maintenanceMode;
|
||||
}
|
||||
@@ -102,7 +103,7 @@ async function maintenanceMiddleware(req, res, next) {
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't check maintenance mode, allow the request to proceed
|
||||
console.error('Failed to check maintenance mode, allowing request:', error.message);
|
||||
logger.error('Failed to check maintenance mode, allowing request:', error.message);
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -32,4 +32,36 @@ function requireEventOwnership(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership };
|
||||
/**
|
||||
* Return the subset of `eventIds` the admin may act on, mirroring
|
||||
* requireEventOwnership for bulk routes that can't use it (they take an
|
||||
* array in the body, not an :id param). super_admin gets everything;
|
||||
* other roles get events they created plus ownerless legacy/system
|
||||
* events (created_by IS NULL). Ids that are foreign OR non-existent both
|
||||
* land in `denied` — deliberately indistinguishable, so bulk routes
|
||||
* don't become an ownership/existence oracle.
|
||||
*
|
||||
* @returns {Promise<{allowed: Array, denied: Array}>}
|
||||
*/
|
||||
async function filterOwnedEventIds(admin, eventIds) {
|
||||
if (admin.roleName === 'super_admin') {
|
||||
return { allowed: [...eventIds], denied: [] };
|
||||
}
|
||||
const rows = await db('events')
|
||||
.whereIn('id', eventIds)
|
||||
.andWhere((q) => q.whereNull('created_by').orWhere('created_by', admin.id))
|
||||
.select('id');
|
||||
const allowedSet = new Set(rows.map((r) => r.id));
|
||||
const allowed = [];
|
||||
const denied = [];
|
||||
for (const id of eventIds) {
|
||||
if (allowedSet.has(id) || allowedSet.has(Number(id))) {
|
||||
allowed.push(id);
|
||||
} else {
|
||||
denied.push(id);
|
||||
}
|
||||
}
|
||||
return { allowed, denied };
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership, filterOwnedEventIds };
|
||||
|
||||
@@ -28,12 +28,13 @@ async function photoAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
@@ -43,24 +44,36 @@ async function photoAuth(req, res, next) {
|
||||
if (decoded.type === 'gallery') {
|
||||
// For thumbnails, we need to verify the token is for a valid event
|
||||
if (!eventSlug) {
|
||||
// Extract event ID from the decoded token
|
||||
// Resolve the token's event (by id, or legacy slug fallback)...
|
||||
let event = null;
|
||||
if (decoded.eventId) {
|
||||
const event = await db('events')
|
||||
event = await db('events')
|
||||
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
}
|
||||
if (!event && decoded.eventSlug) {
|
||||
event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
}
|
||||
// ...then confirm the REQUESTED thumbnail actually belongs to
|
||||
// that event. Thumbnails are stored flat (thumbnails/thumb_<name>)
|
||||
// with deterministic, enumerable filenames derived from the
|
||||
// public event name + a sequential counter. Without this
|
||||
// ownership check any holder of a gallery token for any event
|
||||
// could enumerate and fetch another (password-protected) event's
|
||||
// entire thumbnail set, defeating the gallery password. A
|
||||
// traversal or foreign filename simply fails to match → denied.
|
||||
if (event) {
|
||||
const requestedKey = `thumbnails${req.path}`;
|
||||
const ownsThumbnail = await db('photos')
|
||||
.where({ event_id: event.id, thumbnail_path: requestedKey })
|
||||
.first();
|
||||
if (ownsThumbnail) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// Fallback to slug
|
||||
const event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// For regular photos, check if token matches the event
|
||||
else if (decoded.eventSlug === eventSlug) {
|
||||
|
||||
@@ -265,7 +265,7 @@ class SecureImageMiddleware {
|
||||
'X-Frame-Options': 'DENY',
|
||||
'X-XSS-Protection': '1; mode=block',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'Content-Security-Policy': "default-src 'none'; img-src 'self'",
|
||||
'Content-Security-Policy': 'default-src \'none\'; img-src \'self\'',
|
||||
|
||||
// Custom security headers
|
||||
'X-Protected-Content': 'true',
|
||||
@@ -333,7 +333,7 @@ class SecureImageMiddleware {
|
||||
await db('security_logs').insert(logData).catch(console.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error logging security event:', error);
|
||||
logger.error('Error logging security event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ class SecureImageMiddleware {
|
||||
perHour: config.perHour || 500
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting rate limit settings:', error);
|
||||
logger.error('Error getting rate limit settings:', error);
|
||||
return { perMinute: 30, per5Minutes: 100, perHour: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { safePathJoin, isPathSafe } = require('../utils/fileSecurityUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Create a secure static file serving middleware that prevents path traversal attacks
|
||||
@@ -17,7 +18,7 @@ function secureStatic(basePath, options = {}) {
|
||||
|
||||
// Validate the path doesn't contain dangerous patterns
|
||||
if (!isPathSafe(requestedPath)) {
|
||||
console.warn(`Potential path traversal attempt blocked: ${requestedPath}`);
|
||||
logger.warn(`Potential path traversal attempt blocked: ${requestedPath}`);
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
@@ -43,7 +44,7 @@ function secureStatic(basePath, options = {}) {
|
||||
// `default-src 'none'` already implies script-src 'none';
|
||||
// style-src + img-src(data:) keep normal SVG rendering working.
|
||||
if (/\.svg$/i.test(filePath)) {
|
||||
resp.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
|
||||
resp.setHeader('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'; img-src \'self\' data:');
|
||||
resp.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
}
|
||||
@@ -52,7 +53,7 @@ function secureStatic(basePath, options = {}) {
|
||||
return staticMiddleware(req, res, next);
|
||||
} catch (error) {
|
||||
// Path traversal detected
|
||||
console.error(`Path traversal blocked: ${requestedPath}`, error.message);
|
||||
logger.error(`Path traversal blocked: ${requestedPath}`, error.message);
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// In-memory session tracking (in production, use Redis)
|
||||
const sessions = new Map();
|
||||
@@ -69,7 +70,7 @@ async function getSessionTimeout() {
|
||||
} catch (error) {
|
||||
// Only log if it's not a connection error (to avoid spam)
|
||||
if (error.code !== 'ECONNRESET' && !error.message?.includes('Connection terminated')) {
|
||||
console.error('Error getting session timeout:', error.message);
|
||||
logger.error('Error getting session timeout:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
|
||||
try {
|
||||
// Verify token is valid
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
|
||||
// Check if this is an admin token
|
||||
if (!decoded.id) {
|
||||
@@ -127,7 +128,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
for (const [oldToken, _] of sessions.entries()) {
|
||||
if (oldToken !== token) {
|
||||
try {
|
||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET);
|
||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
if (oldDecoded.id === userId) {
|
||||
sessions.delete(oldToken);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ async function validateUploadedFile(filePath) {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(filePath, {
|
||||
failOnError: false, // Don't fail on recoverable errors
|
||||
failOn: 'none', // Don't fail on recoverable errors
|
||||
limitInputPixels: 268402689 // ~16k x 16k max
|
||||
}).metadata();
|
||||
} catch (metadataError) {
|
||||
@@ -43,7 +43,7 @@ async function validateUploadedFile(filePath) {
|
||||
// Additional check: verify we can actually decode a small portion of the image
|
||||
try {
|
||||
await sharp(filePath, {
|
||||
failOnError: false,
|
||||
failOn: 'none',
|
||||
limitInputPixels: 268402689
|
||||
})
|
||||
.resize(10, 10) // Try to resize to very small size
|
||||
|
||||
@@ -9,14 +9,15 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all archived events
|
||||
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const { page, limit, offset } = getPagination(req);
|
||||
|
||||
// Get total count
|
||||
const totalCount = await db('events')
|
||||
@@ -48,7 +49,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveFileSize = stats.size;
|
||||
} catch (error) {
|
||||
console.error(`Archive file not found: ${archive.archive_path}`);
|
||||
logger.error(`Archive file not found: ${archive.archive_path}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +79,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Archives list error:', error);
|
||||
logger.error('Archives list error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch archives' });
|
||||
}
|
||||
});
|
||||
@@ -113,7 +114,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOw
|
||||
path: archive.archive_path
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', error);
|
||||
logger.error('Archive file not found:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOw
|
||||
archiveFile: archiveFileInfo
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Archive details error:', error);
|
||||
logger.error('Archive details error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch archive details' });
|
||||
}
|
||||
});
|
||||
@@ -179,9 +180,19 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
await fs.mkdir(eventDir, { recursive: true });
|
||||
|
||||
// Log ZIP contents for debugging
|
||||
console.log(`Extracting archive to: ${eventDir}`);
|
||||
logger.info(`Extracting archive to: ${eventDir}`);
|
||||
const entries = Object.values(await zip.entries());
|
||||
console.log(`Archive contains ${entries.length} entries`);
|
||||
logger.info(`Archive contains ${entries.length} entries`);
|
||||
|
||||
// Reject ZIP-slip entries before writing anything to disk — extract()
|
||||
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
|
||||
try {
|
||||
assertZipEntriesWithin(entries, eventDir);
|
||||
} catch (slipErr) {
|
||||
await zip.close();
|
||||
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
|
||||
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
|
||||
}
|
||||
|
||||
// Stream-extract everything to disk
|
||||
await zip.extract(null, eventDir);
|
||||
@@ -203,12 +214,12 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
if (m && m.filename) manifestByFilename.set(m.filename, m);
|
||||
}
|
||||
}
|
||||
console.log(`Loaded photos manifest: ${manifestByFilename.size} entries`);
|
||||
logger.info(`Loaded photos manifest: ${manifestByFilename.size} entries`);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
console.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
|
||||
logger.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
|
||||
} else {
|
||||
console.log('No photos manifest in archive (older archive); original_filename falls back to filename');
|
||||
logger.info('No photos manifest in archive (older archive); original_filename falls back to filename');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,9 +297,9 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
});
|
||||
}
|
||||
} catch (statError) {
|
||||
console.error(`Failed to stat file: ${actualFilePath}`);
|
||||
console.error(`Entry name was: ${entry.name}`);
|
||||
console.error('Error:', statError.message);
|
||||
logger.error(`Failed to stat file: ${actualFilePath}`);
|
||||
logger.error(`Entry name was: ${entry.name}`);
|
||||
logger.error('Error:', statError.message);
|
||||
// Skip this file if we can't stat it
|
||||
continue;
|
||||
}
|
||||
@@ -301,7 +312,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
}
|
||||
|
||||
} catch (extractError) {
|
||||
console.error('Archive extraction error:', extractError);
|
||||
logger.error('Archive extraction error:', extractError);
|
||||
return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message });
|
||||
}
|
||||
|
||||
@@ -331,7 +342,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
|
||||
res.json({ message: 'Archive restored successfully' });
|
||||
} catch (error) {
|
||||
console.error('Archive restore error:', error);
|
||||
logger.error('Archive restore error:', error);
|
||||
res.status(500).json({ error: 'Failed to restore archive' });
|
||||
}
|
||||
});
|
||||
@@ -380,7 +391,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), r
|
||||
metadata: JSON.stringify({ event_name: archive.event_name })
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Archive download error:', error);
|
||||
logger.error('Archive download error:', error);
|
||||
res.status(500).json({ error: 'Failed to download archive' });
|
||||
}
|
||||
});
|
||||
@@ -404,7 +415,7 @@ router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEv
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
await fs.unlink(fullArchivePath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete archive file:', error);
|
||||
logger.error('Failed to delete archive file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +451,7 @@ router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEv
|
||||
|
||||
res.json({ message: 'Archive deleted permanently' });
|
||||
} catch (error) {
|
||||
console.error('Archive delete error:', error);
|
||||
logger.error('Archive delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete archive' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
@@ -184,4 +185,175 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
successResponse(res, { message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-factor authentication (TOTP) — issue #738.
|
||||
//
|
||||
// All endpoints operate on the AUTHENTICATED admin's own account
|
||||
// (req.admin.id) — enrollment is per-user and works for every role,
|
||||
// super_admin included (closes #735). The TOTP secret is stored encrypted
|
||||
// at rest and recovery codes are hashed; see services/mfaService.js.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isMfaEnabled = mfaService.isEnrolled;
|
||||
|
||||
// Current MFA state for the logged-in admin.
|
||||
router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
const enabled = isMfaEnabled(admin);
|
||||
res.json({
|
||||
enabled,
|
||||
enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null,
|
||||
recoveryCodesRemaining: enabled
|
||||
? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length
|
||||
: 0
|
||||
});
|
||||
}));
|
||||
|
||||
// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet
|
||||
// enabled), and return the otpauth URI + QR for the authenticator app. Calling
|
||||
// this again before /enable simply regenerates the provisional secret.
|
||||
router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
|
||||
const secret = mfaService.generateSecret();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_secret: mfaService.encryptSecret(secret),
|
||||
two_factor_enabled: false,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const accountName = admin.email || admin.username;
|
||||
const otpauthUri = mfaService.buildOtpauthUri(accountName, secret);
|
||||
const qr = await mfaService.buildQrDataUrl(otpauthUri);
|
||||
|
||||
res.json({
|
||||
// `secret` is returned for manual entry when a QR can't be scanned.
|
||||
secret,
|
||||
otpauthUri,
|
||||
qr,
|
||||
issuer: mfaService.ISSUER,
|
||||
account: accountName
|
||||
});
|
||||
}));
|
||||
|
||||
// Complete enrollment: verify a code against the provisional secret, enable
|
||||
// MFA, and return one-time recovery codes (shown exactly once).
|
||||
router.post('/mfa/enable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('Verification code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
if (!admin.two_factor_secret) {
|
||||
throw new ValidationError('Start setup before enabling two-factor authentication');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: true,
|
||||
two_factor_enrolled_at: new Date(),
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_enabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Two-factor authentication enabled',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session
|
||||
// can't silently strip the second factor.
|
||||
router.post('/mfa/disable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current code is required to disable 2FA')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
|
||||
let recoveryOk = false;
|
||||
if (!totpOk) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched;
|
||||
}
|
||||
if (!totpOk && !recoveryOk) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_disabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Two-factor authentication disabled' });
|
||||
}));
|
||||
|
||||
// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP
|
||||
// code. Returns the new codes once.
|
||||
router.post('/mfa/recovery-codes', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current authenticator code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_recovery_regenerated',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Recovery codes regenerated',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+289
-215
@@ -2,8 +2,12 @@ const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
||||
const { formatBytes } = require('../utils/formatBytes');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
@@ -30,8 +34,7 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
|
||||
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup configuration:', error);
|
||||
res.status(500).json({ error: 'Failed to get backup configuration' });
|
||||
errorResponse(res, error, 500, 'Failed to get backup configuration');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,22 +46,22 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
||||
// Validate required fields based on destination type
|
||||
if (updates.backup_destination_type) {
|
||||
switch (updates.backup_destination_type) {
|
||||
case 'local':
|
||||
if (!updates.backup_destination_path) {
|
||||
return res.status(400).json({ error: 'Local backup requires destination path' });
|
||||
}
|
||||
break;
|
||||
case 'rsync':
|
||||
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
|
||||
return res.status(400).json({ error: 'Rsync backup requires host and path' });
|
||||
}
|
||||
break;
|
||||
case 's3':
|
||||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
|
||||
case 'local':
|
||||
if (!updates.backup_destination_path) {
|
||||
return res.status(400).json({ error: 'Local backup requires destination path' });
|
||||
}
|
||||
break;
|
||||
case 'rsync':
|
||||
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
|
||||
return res.status(400).json({ error: 'Rsync backup requires host and path' });
|
||||
}
|
||||
break;
|
||||
case 's3':
|
||||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
|
||||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
|
||||
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
||||
}
|
||||
break;
|
||||
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,21 +95,19 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
||||
|
||||
res.json({ success: true, message: 'Backup configuration updated' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update backup configuration:', error);
|
||||
res.status(500).json({ error: 'Failed to update backup configuration' });
|
||||
errorResponse(res, error, 500, 'Failed to update backup configuration');
|
||||
}
|
||||
});
|
||||
|
||||
// Get backup status and history
|
||||
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const { limit } = getPagination(req, { limit: 10 });
|
||||
const status = await getBackupStatus(limit);
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup status:', error);
|
||||
res.status(500).json({ error: 'Failed to get backup status' });
|
||||
errorResponse(res, error, 500, 'Failed to get backup status');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -126,8 +127,103 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
|
||||
|
||||
res.json({ success: true, message: 'Backup started' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger manual backup:', error);
|
||||
res.status(500).json({ error: 'Failed to trigger backup' });
|
||||
errorResponse(res, error, 500, 'Failed to trigger backup');
|
||||
}
|
||||
});
|
||||
|
||||
// Generate + download a portable ".picpeak" export — an engine-neutral logical
|
||||
// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to
|
||||
// another instance via the web UI. `?includePhotos=true` also bundles original
|
||||
// gallery photos (larger); otherwise the admin re-uploads them per gallery.
|
||||
//
|
||||
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
|
||||
// hashes, API keys). The download UI must warn before offering it. We surface
|
||||
// the flag as a response header too so the client can double-confirm.
|
||||
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
try {
|
||||
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
||||
const { createPicpeak } = require('../services/picpeakExportService');
|
||||
const { filePath } = await createPicpeak({ includePhotos });
|
||||
const filename = path.basename(filePath);
|
||||
res.setHeader('X-Picpeak-Contains-Secrets', 'true');
|
||||
res.download(filePath, filename, (err) => {
|
||||
// Best-effort cleanup of the temp .picpeak (and its temp dir) after send.
|
||||
fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {});
|
||||
if (err) logger.error('[picpeak-export] download failed', { error: err.message });
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[picpeak-export] failed to create export', { error: error.message });
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' });
|
||||
}
|
||||
});
|
||||
|
||||
// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER
|
||||
// auth so an unauthenticated request can't push a large file to disk.
|
||||
const os = require('os');
|
||||
const multer = require('multer');
|
||||
const picpeakUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
||||
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
|
||||
});
|
||||
|
||||
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
|
||||
// all data except the current logged-in account (the client shows an explicit
|
||||
// confirmation before calling this). Returns `usesExternalMedia` so the UI can
|
||||
// prompt the admin to reconfigure the external-media mount afterwards.
|
||||
router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
|
||||
const picpeakPath = req.file.path;
|
||||
try {
|
||||
const { importFromPicpeak } = require('../services/picpeakImportService');
|
||||
// adminAuth populates req.admin, not req.user. Passing req.user.id here
|
||||
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
|
||||
// to preserve and the admin_users table was fully replaced by the backup —
|
||||
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
|
||||
|
||||
// The restore rewrote admin_users, so ids may have shifted. The operator's
|
||||
// current JWT is bound only to the pre-restore admin id (adminAuth trusts
|
||||
// `decoded.id` — IP is logged, not enforced, and the backup controls
|
||||
// password_changed_at), which could now resolve to a DIFFERENT restored
|
||||
// account and silently grant its permissions. Force a fresh login instead
|
||||
// of trusting the old session: revoke the token and clear the cookie.
|
||||
// Clearing the cookie is the guarantee — it drops the operator's browser
|
||||
// session unconditionally. Revocation is the extra layer that also kills a
|
||||
// Bearer-header copy of the JWT; revokeToken() swallows DB errors and
|
||||
// returns false, so check the result and log loudly if the denylist write
|
||||
// didn't land (the operator should still re-login, which the cookie clear
|
||||
// forces).
|
||||
let tokenRevoked = false;
|
||||
try {
|
||||
if (req.token) {
|
||||
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
|
||||
}
|
||||
} catch (revokeErr) {
|
||||
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
|
||||
}
|
||||
if (req.token && !tokenRevoked) {
|
||||
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
|
||||
}
|
||||
clearAdminAuthCookie(res);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
sessionInvalidated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.statusCode || 500;
|
||||
logger.error('[picpeak-import] restore failed', { error: error.message });
|
||||
res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation });
|
||||
} finally {
|
||||
fsSync.unlink(picpeakPath, () => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -155,8 +251,7 @@ router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req,
|
||||
|
||||
res.json(run);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup run details:', error);
|
||||
res.status(500).json({ error: 'Failed to get backup run details' });
|
||||
errorResponse(res, error, 500, 'Failed to get backup run details');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,8 +285,7 @@ router.get('/files', adminAuth, requirePermission('backup.view'), async (req, re
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup file states:', error);
|
||||
res.status(500).json({ error: 'Failed to get file states' });
|
||||
errorResponse(res, error, 500, 'Failed to get file states');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -204,8 +298,7 @@ router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (
|
||||
|
||||
res.json({ success: true, message: `Cleaned up backup runs older than ${days} days` });
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup old backup runs:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup backup runs' });
|
||||
errorResponse(res, error, 500, 'Failed to cleanup backup runs');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -215,129 +308,128 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
|
||||
const { destination_type, ...config } = req.body;
|
||||
|
||||
switch (destination_type) {
|
||||
case 'local':
|
||||
// Test local path access
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(config.path, fs.constants.W_OK);
|
||||
res.json({ success: true, message: 'Local path is writable' });
|
||||
} catch (error) {
|
||||
logger.warn('Local backup path not writable', {
|
||||
path: config.path,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
case 'local':
|
||||
// Test local path access
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(config.path, fs.constants.W_OK);
|
||||
res.json({ success: true, message: 'Local path is writable' });
|
||||
} catch (error) {
|
||||
logger.warn('Local backup path not writable', {
|
||||
path: config.path,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rsync':
|
||||
// Test rsync connection using spawn with argument arrays to prevent command injection
|
||||
const { spawn } = require('child_process');
|
||||
case 'rsync':
|
||||
// Test rsync connection using spawn with argument arrays to prevent command injection
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Validate and sanitize inputs to prevent command injection
|
||||
const sanitizeInput = (input) => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
// Remove any shell metacharacters and limit length
|
||||
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
|
||||
};
|
||||
// Validate and sanitize inputs to prevent command injection
|
||||
const sanitizeInput = (input) => {
|
||||
if (!input || typeof input !== 'string') return null;
|
||||
// Remove any shell metacharacters and limit length
|
||||
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
|
||||
};
|
||||
|
||||
const host = sanitizeInput(config.host);
|
||||
const user = sanitizeInput(config.user);
|
||||
const sshKeyPath = sanitizeInput(config.ssh_key);
|
||||
const host = sanitizeInput(config.host);
|
||||
const user = sanitizeInput(config.user);
|
||||
const sshKeyPath = sanitizeInput(config.ssh_key);
|
||||
|
||||
if (!host) {
|
||||
res.json({ success: false, message: 'Invalid host specified' });
|
||||
if (!host) {
|
||||
res.json({ success: false, message: 'Invalid host specified' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate host format (hostname or IP only)
|
||||
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||
res.json({ success: false, message: 'Invalid host format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// SSRF protection: block connections to private/internal addresses
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(host)) {
|
||||
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate username format if provided
|
||||
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
res.json({ success: false, message: 'Invalid username format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Build SSH arguments as array (safe from injection)
|
||||
const sshArgs = [];
|
||||
if (sshKeyPath) {
|
||||
// Validate SSH key path exists and is a file
|
||||
const fsSync = require('fs');
|
||||
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
|
||||
res.json({ success: false, message: 'SSH key file not found' });
|
||||
break;
|
||||
}
|
||||
sshArgs.push('-i', sshKeyPath);
|
||||
}
|
||||
sshArgs.push('-o', 'StrictHostKeyChecking=no');
|
||||
sshArgs.push('-o', 'ConnectTimeout=10');
|
||||
sshArgs.push('-o', 'BatchMode=yes');
|
||||
|
||||
// Validate host format (hostname or IP only)
|
||||
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
if (!hostRegex.test(host) && !ipRegex.test(host)) {
|
||||
res.json({ success: false, message: 'Invalid host format' });
|
||||
break;
|
||||
}
|
||||
// Add target (user@host or just host)
|
||||
const target = user ? `${user}@${host}` : host;
|
||||
sshArgs.push(target);
|
||||
sshArgs.push('echo', 'Connection successful');
|
||||
|
||||
// SSRF protection: block connections to private/internal addresses
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (isPrivateIP(host)) {
|
||||
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate username format if provided
|
||||
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
res.json({ success: false, message: 'Invalid username format' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Build SSH arguments as array (safe from injection)
|
||||
const sshArgs = [];
|
||||
if (sshKeyPath) {
|
||||
// Validate SSH key path exists and is a file
|
||||
const fsSync = require('fs');
|
||||
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
|
||||
res.json({ success: false, message: 'SSH key file not found' });
|
||||
break;
|
||||
}
|
||||
sshArgs.push('-i', sshKeyPath);
|
||||
}
|
||||
sshArgs.push('-o', 'StrictHostKeyChecking=no');
|
||||
sshArgs.push('-o', 'ConnectTimeout=10');
|
||||
sshArgs.push('-o', 'BatchMode=yes');
|
||||
|
||||
// Add target (user@host or just host)
|
||||
const target = user ? `${user}@${host}` : host;
|
||||
sshArgs.push(target);
|
||||
sshArgs.push('echo', 'Connection successful');
|
||||
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const sshProcess = spawn('ssh', sshArgs, {
|
||||
timeout: 15000,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
sshProcess.stdout.on('data', (data) => { stdout += data; });
|
||||
sshProcess.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
sshProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true, stdout });
|
||||
} else {
|
||||
reject(new Error(stderr || `SSH exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
sshProcess.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const sshProcess = spawn('ssh', sshArgs, {
|
||||
timeout: 15000,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Rsync connection successful' });
|
||||
} catch (error) {
|
||||
logger.warn('Rsync connection test failed', {
|
||||
destination: host,
|
||||
error: error.message
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
sshProcess.stdout.on('data', (data) => { stdout += data; });
|
||||
sshProcess.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
sshProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true, stdout });
|
||||
} else {
|
||||
reject(new Error(stderr || `SSH exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
sshProcess.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Rsync connection successful' });
|
||||
} catch (error) {
|
||||
logger.warn('Rsync connection test failed', {
|
||||
destination: host,
|
||||
error: error.message
|
||||
});
|
||||
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||
}
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
// Test S3 connection (would need AWS SDK)
|
||||
res.json({ success: false, message: 'S3 testing not implemented yet' });
|
||||
break;
|
||||
case 's3':
|
||||
// Test S3 connection (would need AWS SDK)
|
||||
res.json({ success: false, message: 'S3 testing not implemented yet' });
|
||||
break;
|
||||
|
||||
default:
|
||||
res.status(400).json({ error: 'Invalid destination type' });
|
||||
default:
|
||||
res.status(400).json({ error: 'Invalid destination type' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to test backup connection:', error);
|
||||
res.status(500).json({ error: 'Failed to test connection' });
|
||||
errorResponse(res, error, 500, 'Failed to test connection');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -380,8 +472,7 @@ router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), a
|
||||
manifestPath
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate manifest:', error);
|
||||
res.status(500).json({ error: 'Failed to validate manifest' });
|
||||
errorResponse(res, error, 500, 'Failed to validate manifest');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -493,8 +584,7 @@ router.post('/manifests/validate', adminAuth, requirePermission('backup.view'),
|
||||
manifestPath
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate manifest:', error);
|
||||
res.status(500).json({ error: 'Failed to validate manifest' });
|
||||
errorResponse(res, error, 500, 'Failed to validate manifest');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -525,8 +615,7 @@ router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (re
|
||||
owner: result.Owner || null
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list S3 buckets:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 buckets' });
|
||||
errorResponse(res, error, 500, 'Failed to list S3 buckets');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -562,8 +651,7 @@ router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req,
|
||||
prefix: prefix
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to list S3 files:', error);
|
||||
res.status(500).json({ error: 'Failed to list S3 files' });
|
||||
errorResponse(res, error, 500, 'Failed to list S3 files');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -624,8 +712,7 @@ router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), asyn
|
||||
message: `Cleaned up ${deletedCount} S3 backup files older than ${retentionDays} days`
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup S3 backups:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup S3 backups' });
|
||||
errorResponse(res, error, 500, 'Failed to cleanup S3 backups');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -676,8 +763,7 @@ router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), as
|
||||
message: 'S3 upload test completed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('S3 upload test failed:', error);
|
||||
res.status(500).json({ error: 'S3 upload test failed' });
|
||||
errorResponse(res, error, 500, 'S3 upload test failed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -703,69 +789,68 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
|
||||
|
||||
// Handle different backup types
|
||||
switch (config.backup_destination_type) {
|
||||
case 'local':
|
||||
// Stream local backup as zip
|
||||
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
case 'local':
|
||||
// Stream local backup as zip
|
||||
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
|
||||
archive.pipe(res);
|
||||
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
|
||||
archive.pipe(res);
|
||||
|
||||
// Add backup directory contents
|
||||
archive.directory(backupPath, false);
|
||||
// Add backup directory contents
|
||||
archive.directory(backupPath, false);
|
||||
|
||||
// Add manifest if exists
|
||||
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
|
||||
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
|
||||
}
|
||||
// Add manifest if exists
|
||||
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
|
||||
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
break;
|
||||
await archive.finalize();
|
||||
break;
|
||||
|
||||
case 's3':
|
||||
// For S3, provide pre-signed URLs or stream files
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
endpoint: config.backup_s3_endpoint,
|
||||
bucket: config.backup_s3_bucket,
|
||||
accessKeyId: config.backup_s3_access_key,
|
||||
secretAccessKey: config.backup_s3_secret_key,
|
||||
region: config.backup_s3_region || 'us-east-1',
|
||||
forcePathStyle: config.backup_s3_force_path_style || false
|
||||
case 's3':
|
||||
// For S3, provide pre-signed URLs or stream files
|
||||
const s3Adapter = new S3StorageAdapter({
|
||||
endpoint: config.backup_s3_endpoint,
|
||||
bucket: config.backup_s3_bucket,
|
||||
accessKeyId: config.backup_s3_access_key,
|
||||
secretAccessKey: config.backup_s3_secret_key,
|
||||
region: config.backup_s3_region || 'us-east-1',
|
||||
forcePathStyle: config.backup_s3_force_path_style || false
|
||||
});
|
||||
|
||||
// List all files for this backup
|
||||
const prefix = `backups/${backupRun.id}/`;
|
||||
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
|
||||
|
||||
// Generate pre-signed URLs
|
||||
const urls = [];
|
||||
for (const file of files.objects || []) {
|
||||
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
|
||||
urls.push({
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
url: url
|
||||
});
|
||||
}
|
||||
|
||||
// List all files for this backup
|
||||
const prefix = `backups/${backupRun.id}/`;
|
||||
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
|
||||
res.json({
|
||||
backupId: backupRun.id,
|
||||
type: 's3',
|
||||
files: urls,
|
||||
expiresIn: 3600,
|
||||
message: 'Use the provided URLs to download individual files'
|
||||
});
|
||||
break;
|
||||
|
||||
// Generate pre-signed URLs
|
||||
const urls = [];
|
||||
for (const file of files.objects || []) {
|
||||
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
|
||||
urls.push({
|
||||
key: file.key,
|
||||
size: file.size,
|
||||
url: url
|
||||
});
|
||||
}
|
||||
case 'rsync':
|
||||
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
|
||||
|
||||
res.json({
|
||||
backupId: backupRun.id,
|
||||
type: 's3',
|
||||
files: urls,
|
||||
expiresIn: 3600,
|
||||
message: 'Use the provided URLs to download individual files'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'rsync':
|
||||
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
|
||||
|
||||
default:
|
||||
return res.status(400).json({ error: 'Unknown backup type' });
|
||||
default:
|
||||
return res.status(400).json({ error: 'Unknown backup type' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to download backup:', error);
|
||||
res.status(500).json({ error: 'Failed to download backup' });
|
||||
errorResponse(res, error, 500, 'Failed to download backup');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -837,8 +922,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
|
||||
path: targetPath || '/'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get file checksums:', error);
|
||||
res.status(500).json({ error: 'Failed to get file checksums' });
|
||||
errorResponse(res, error, 500, 'Failed to get file checksums');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -940,21 +1024,11 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
|
||||
warnings: totalSize > 10 * 1024 * 1024 * 1024 ? ['Backup size exceeds 10GB, may take significant time'] : []
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to estimate backup size:', error);
|
||||
res.status(500).json({ error: 'Failed to estimate backup size' });
|
||||
errorResponse(res, error, 500, 'Failed to estimate backup size');
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to format bytes
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Helper function to get backup configuration
|
||||
async function getBackupConfig() {
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -43,7 +44,7 @@ router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res)
|
||||
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
|
||||
res.json(pages);
|
||||
} catch (error) {
|
||||
console.error('Error fetching CMS pages:', error);
|
||||
logger.error('Error fetching CMS pages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch pages' });
|
||||
}
|
||||
});
|
||||
@@ -60,7 +61,7 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req,
|
||||
|
||||
res.json(page);
|
||||
} catch (error) {
|
||||
console.error('Error fetching CMS page:', error);
|
||||
logger.error('Error fetching CMS page:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch page' });
|
||||
}
|
||||
});
|
||||
@@ -144,7 +145,7 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating CMS page:', error);
|
||||
logger.error('Error updating CMS page:', error);
|
||||
res.status(500).json({ error: 'Failed to update page' });
|
||||
}
|
||||
});
|
||||
@@ -184,7 +185,7 @@ router.post(
|
||||
|
||||
res.json({ logo_url: logoUrl });
|
||||
} catch (error) {
|
||||
console.error('Error uploading CMS page logo:', error);
|
||||
logger.error('Error uploading CMS page logo:', error);
|
||||
res.status(500).json({ error: 'Failed to upload logo' });
|
||||
}
|
||||
}
|
||||
@@ -208,7 +209,7 @@ router.delete(
|
||||
|
||||
res.json({ logo_url: null });
|
||||
} catch (error) {
|
||||
console.error('Error clearing CMS page logo:', error);
|
||||
logger.error('Error clearing CMS page logo:', error);
|
||||
res.status(500).json({ error: 'Failed to clear logo' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get all global categories
|
||||
@@ -15,7 +16,7 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
logger.error('Error fetching categories:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch categories' });
|
||||
}
|
||||
});
|
||||
@@ -35,7 +36,7 @@ router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), asy
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
console.error('Error fetching event categories:', error);
|
||||
logger.error('Error fetching event categories:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch categories' });
|
||||
}
|
||||
});
|
||||
@@ -101,7 +102,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
res.json(category);
|
||||
} catch (error) {
|
||||
console.error('Error creating category:', error);
|
||||
logger.error('Error creating category:', error);
|
||||
res.status(500).json({ error: 'Failed to create category' });
|
||||
}
|
||||
});
|
||||
@@ -165,7 +166,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error);
|
||||
logger.error('Error updating category:', error);
|
||||
res.status(500).json({ error: 'Failed to update category' });
|
||||
}
|
||||
});
|
||||
@@ -214,7 +215,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating category hero:', error);
|
||||
logger.error('Error updating category hero:', error);
|
||||
res.status(500).json({ error: 'Failed to update category hero' });
|
||||
}
|
||||
});
|
||||
@@ -248,7 +249,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
|
||||
res.json({ message: 'Category deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting category:', error);
|
||||
logger.error('Error deleting category:', error);
|
||||
res.status(500).json({ error: 'Failed to delete category' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
|
||||
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* GET /admin/css-templates
|
||||
@@ -23,7 +24,7 @@ router.get('/', adminAuth, requirePermission('branding.view'), async (req, res)
|
||||
);
|
||||
res.json({ success: true, templates });
|
||||
} catch (error) {
|
||||
console.error('Get CSS templates error:', error);
|
||||
logger.error('Get CSS templates error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||
}
|
||||
});
|
||||
@@ -42,7 +43,7 @@ router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req
|
||||
);
|
||||
res.json({ success: true, templates });
|
||||
} catch (error) {
|
||||
console.error('Get enabled templates error:', error);
|
||||
logger.error('Get enabled templates error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch templates' });
|
||||
}
|
||||
});
|
||||
@@ -73,7 +74,7 @@ router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
|
||||
|
||||
res.json({ success: true, template });
|
||||
} catch (error) {
|
||||
console.error('Get template error:', error);
|
||||
logger.error('Get template error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch template' });
|
||||
}
|
||||
});
|
||||
@@ -150,7 +151,7 @@ router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
|
||||
sanitization_warnings: warnings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update template error:', error);
|
||||
logger.error('Update template error:', error);
|
||||
res.status(500).json({ error: 'Failed to update template' });
|
||||
}
|
||||
});
|
||||
@@ -187,7 +188,7 @@ router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'),
|
||||
|
||||
res.json({ success: true, template });
|
||||
} catch (error) {
|
||||
console.error('Reset template error:', error);
|
||||
logger.error('Reset template error:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { resolveAdapter } = require('../services/trackers');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
@@ -126,16 +127,15 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
totalEvents: totalEvents.count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch dashboard statistics');
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent activity
|
||||
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
|
||||
const { limit } = getPagination(req, { limit: 10 });
|
||||
|
||||
const activities = await db('activity_logs')
|
||||
.select('activity_logs.*', 'events.event_name')
|
||||
.leftJoin('events', 'activity_logs.event_id', 'events.id')
|
||||
@@ -155,7 +155,7 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
|
||||
if (typeof activity.metadata === 'object') return activity.metadata;
|
||||
return JSON.parse(activity.metadata);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse metadata for activity:', activity.id, e.message);
|
||||
logger.warn('Failed to parse metadata for activity:', activity.id, e.message);
|
||||
return {};
|
||||
}
|
||||
})(),
|
||||
@@ -164,8 +164,7 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
|
||||
|
||||
res.json(formattedActivities);
|
||||
} catch (error) {
|
||||
console.error('Activity log error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity log' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch activity log');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -233,7 +232,7 @@ router.get('/health', adminAuth, requirePermission('settings.view'), async (req,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
logger.error('Health check error:', error);
|
||||
res.status(500).json({
|
||||
overall: 'error',
|
||||
error: 'Failed to check system health'
|
||||
@@ -400,8 +399,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Analytics error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch analytics data' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch analytics data');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -586,8 +584,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
require('../utils/logger').error('CRM stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to load CRM stats' });
|
||||
errorResponse(res, error, 500, 'Failed to load CRM stats');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { databaseBackupService } = require('../services/databaseBackup');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
|
||||
// All routes require admin authentication
|
||||
router.use(adminAuth);
|
||||
@@ -154,10 +155,8 @@ router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||
*/
|
||||
router.get('/history', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const { page, limit, offset } = getPagination(req);
|
||||
|
||||
const [backups, totalCount] = await Promise.all([
|
||||
db('database_backup_runs')
|
||||
.orderBy('started_at', 'desc')
|
||||
|
||||
@@ -4,7 +4,13 @@ const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
// Gate the NEW Messages routes on the `messaging` flag (per-route, NOT the whole
|
||||
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const messagingGate = requireFeatureFlag('messaging');
|
||||
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
@@ -31,8 +37,7 @@ router.get('/config', adminAuth, requirePermission('email.view'), async (req, re
|
||||
smtp_pass: config.smtp_pass ? '********' : ''
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email config fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email configuration' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch email configuration');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,8 +118,7 @@ router.post('/config', [
|
||||
|
||||
res.json({ message: 'Email configuration updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Email config update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update email configuration' });
|
||||
errorResponse(res, error, 500, 'Failed to update email configuration');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -131,8 +135,7 @@ router.get('/incoming-config', adminAuth, requirePermission('email.view'), async
|
||||
imap_folder: c?.imap_folder || 'INBOX',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Incoming mail config fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch incoming mail configuration' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch incoming mail configuration');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,8 +171,7 @@ router.post('/incoming-config', [
|
||||
await logActivity('incoming_mail_config_updated', { imap_host }, null, { type: 'admin', id: req.admin.id, name: req.admin.username });
|
||||
res.json({ message: 'Incoming mail configuration updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Incoming mail config update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update incoming mail configuration' });
|
||||
errorResponse(res, error, 500, 'Failed to update incoming mail configuration');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -192,7 +194,7 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
|
||||
);
|
||||
res.json({ folders });
|
||||
} catch (error) {
|
||||
console.error('IMAP folder detection error:', error);
|
||||
logger.error('IMAP folder detection error:', error);
|
||||
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993) and credentials.` });
|
||||
}
|
||||
});
|
||||
@@ -217,7 +219,7 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('IMAP connection test error:', error);
|
||||
logger.error('IMAP connection test error:', error);
|
||||
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993), credentials and folder.` });
|
||||
}
|
||||
});
|
||||
@@ -239,7 +241,7 @@ router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.se
|
||||
return res.status(result.reason === 'not_received' ? 504 : 400)
|
||||
.json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient });
|
||||
} catch (error) {
|
||||
console.error('Round-trip test error:', error);
|
||||
logger.error('Round-trip test error:', error);
|
||||
res.status(422).json({ error: `Round-trip test failed (${error.message}) — check both SMTP and IMAP settings.` });
|
||||
}
|
||||
});
|
||||
@@ -253,7 +255,7 @@ router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'),
|
||||
const result = await emailIntakeService.pollOnce();
|
||||
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
|
||||
} catch (error) {
|
||||
console.error('Manual poll error:', error);
|
||||
logger.error('Manual poll error:', error);
|
||||
res.status(422).json({ error: `Mailbox poll failed (${error.message}).` });
|
||||
}
|
||||
});
|
||||
@@ -262,14 +264,193 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25));
|
||||
const base = db('received_emails');
|
||||
const countRow = await base.clone().count({ c: '*' }).first();
|
||||
const account = req.query.account ? String(req.query.account) : null;
|
||||
// mailbox_state filter: no param → active (+ legacy NULL); else exact.
|
||||
const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
|
||||
// Optional full-table search (sender / subject) so results aren't truncated
|
||||
// to the first page before matching.
|
||||
const q = req.query.q ? String(req.query.q).trim().slice(0, 255) : '';
|
||||
// 'accounting' matches legacy rows too (account_key was NULL before mig 154).
|
||||
const applyAccount = (qb) => {
|
||||
if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key'));
|
||||
else if (account) qb.where('account_key', account);
|
||||
if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state'));
|
||||
else qb.where('mailbox_state', state);
|
||||
if (q) qb.where((b) => b.where('from_address', 'like', `%${q}%`).orWhere('subject', 'like', `%${q}%`));
|
||||
return qb;
|
||||
};
|
||||
const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first();
|
||||
const total = parseInt(countRow?.c || 0, 10);
|
||||
const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
|
||||
// Bodies are excluded from the list (can be large); fetched per-message.
|
||||
const items = await applyAccount(db('received_emails'))
|
||||
.select('id', 'message_id', 'account_key', 'from_address', 'to_address', 'subject',
|
||||
'received_at', 'attachment_count', 'status', 'inbound_document_id', 'error')
|
||||
.orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
|
||||
res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } });
|
||||
} catch (error) {
|
||||
console.error('Received emails fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch received emails' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch received emails');
|
||||
}
|
||||
});
|
||||
|
||||
// Single received email WITH its captured (server-sanitized) body — Messages
|
||||
// reading pane. body_html was already sanitized on ingest; the viewer renders
|
||||
// it in a script-less sandboxed iframe as well.
|
||||
router.get('/received/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const row = await db('received_emails').where({ id }).first();
|
||||
if (!row) return res.status(404).json({ error: 'Email not found' });
|
||||
res.json(row);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch email');
|
||||
}
|
||||
});
|
||||
|
||||
// Move an email between mailbox states: Archive / Delete (soft) or Restore
|
||||
// (back to active). kind = 'queue' | 'received'. Delete is a soft move to the
|
||||
// trash; the row is only removed for good by the DELETE handler below.
|
||||
router.post('/item/:kind/:id/state', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
|
||||
if (!table) return res.status(400).json({ error: 'Invalid kind' });
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const state = String(req.body?.state || '');
|
||||
if (!['active', 'archived', 'deleted'].includes(state)) return res.status(400).json({ error: 'Invalid state' });
|
||||
const n = await db(table).where({ id }).update({ mailbox_state: state });
|
||||
if (!n) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update email');
|
||||
}
|
||||
});
|
||||
|
||||
// Permanently delete an email row — only offered from the Deleted folder.
|
||||
router.delete('/item/:kind/:id', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
|
||||
try {
|
||||
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
|
||||
if (!table) return res.status(400).json({ error: 'Invalid kind' });
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
await db(table).where({ id }).del();
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete email');
|
||||
}
|
||||
});
|
||||
|
||||
// Additional inbound mailboxes (beyond the primary accounting IMAP in
|
||||
// email_configs) — e.g. the customer hello@ box. Passwords are masked out.
|
||||
router.get('/accounts', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const rows = await db('mail_accounts').orderBy('id');
|
||||
res.json({ items: rows.map((a) => ({
|
||||
...a,
|
||||
imap_pass: a.imap_pass ? '********' : '',
|
||||
smtp_pass: a.smtp_pass ? '********' : '',
|
||||
})) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load mail accounts');
|
||||
}
|
||||
});
|
||||
|
||||
// Resolved sender/mailbox addresses for the Messages UI — so the sidebar shows
|
||||
// the REAL configured addresses instead of hardcoded placeholders. Accounting =
|
||||
// the primary IMAP login (rechnungen@); customers = the hello@ mailbox; the
|
||||
// automated stream sends from the global SMTP from-address.
|
||||
router.get('/identities', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const cfg = await db('email_configs').first();
|
||||
let customers = null;
|
||||
try {
|
||||
const cust = await db('mail_accounts').where({ account_key: 'customers' }).first();
|
||||
customers = cust?.imap_user || cust?.from_email || null;
|
||||
} catch (_) { customers = null; }
|
||||
res.json({
|
||||
automated: cfg?.from_email || null,
|
||||
accounting: cfg?.imap_user || null,
|
||||
customers,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load mail identities');
|
||||
}
|
||||
});
|
||||
|
||||
// Upsert a mailbox by account_key. A masked password ('********') keeps the
|
||||
// stored value so the admin never has to re-type it.
|
||||
router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
|
||||
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
|
||||
// SMTP host may point at a private/internal address.
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
|
||||
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||
}
|
||||
const patch = {
|
||||
label: b.label || null,
|
||||
imap_host: b.imap_host || null,
|
||||
imap_port: b.imap_port ? parseInt(b.imap_port, 10) : 993,
|
||||
imap_secure: b.imap_secure !== false,
|
||||
imap_user: b.imap_user || null,
|
||||
imap_folder: b.imap_folder || 'INBOX',
|
||||
// Outgoing (SMTP) identity — replies from this mailbox send from here.
|
||||
smtp_host: b.smtp_host || null,
|
||||
smtp_port: b.smtp_port ? parseInt(b.smtp_port, 10) : 587,
|
||||
smtp_secure: b.smtp_secure === true,
|
||||
smtp_user: b.smtp_user || null,
|
||||
from_email: b.from_email || null,
|
||||
from_name: b.from_name || null,
|
||||
enabled: !!b.enabled,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass;
|
||||
if (b.smtp_pass && b.smtp_pass !== '********') patch.smtp_pass = b.smtp_pass;
|
||||
const existing = await db('mail_accounts').where({ account_key: b.account_key }).first();
|
||||
if (existing) {
|
||||
await db('mail_accounts').where({ account_key: b.account_key }).update(patch);
|
||||
} else {
|
||||
await db('mail_accounts').insert({
|
||||
account_key: b.account_key,
|
||||
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
|
||||
smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_pass : '',
|
||||
created_at: new Date(),
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to save mail account');
|
||||
}
|
||||
});
|
||||
|
||||
// Test an inbound mailbox's IMAP connection (before or after saving). Resolves
|
||||
// a masked/blank password from the stored row for the given account_key.
|
||||
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
let pass = b.imap_pass;
|
||||
if ((!pass || pass === '********') && b.account_key) {
|
||||
const stored = await db('mail_accounts').where({ account_key: b.account_key }).first();
|
||||
pass = stored?.imap_pass || '';
|
||||
}
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.testConnection({
|
||||
host: b.imap_host, port: b.imap_port, secure: b.imap_secure,
|
||||
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -322,7 +503,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
debug: process.env.NODE_ENV === 'development'
|
||||
};
|
||||
|
||||
console.log('Creating email transporter with config:', {
|
||||
logger.info('Creating email transporter with config:', {
|
||||
host: transportConfig.host,
|
||||
port: transportConfig.port,
|
||||
secure: transportConfig.secure,
|
||||
@@ -356,8 +537,8 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
|
||||
res.json({ message: 'Test email sent successfully' });
|
||||
} catch (error) {
|
||||
console.error('Test email error:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
logger.error('Test email error:', error);
|
||||
logger.error('Error stack:', error.stack);
|
||||
|
||||
// Provide more specific error messages with translation keys
|
||||
let errorMessage = 'Error sending email';
|
||||
@@ -428,7 +609,7 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
|
||||
} catch (_) { /* activity logging is best-effort */ }
|
||||
res.json({ message: 'Email queue flushed', ...summary });
|
||||
} catch (error) {
|
||||
console.error('Flush email queue error:', error);
|
||||
logger.error('Flush email queue error:', error);
|
||||
res.status(500).json({ error: 'Failed to flush email queue', details: error.message });
|
||||
}
|
||||
});
|
||||
@@ -441,6 +622,8 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
|
||||
router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
|
||||
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||
query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']),
|
||||
query('state').optional({ values: 'falsy' }).isIn(['active', 'archived', 'deleted']),
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
query('from').optional({ values: 'falsy' }).isISO8601(),
|
||||
query('to').optional({ values: 'falsy' }).isISO8601(),
|
||||
@@ -459,6 +642,13 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
const applyFilters = (qb) => {
|
||||
if (req.query.status) qb.where('email_queue.status', req.query.status);
|
||||
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
|
||||
// 'system' includes legacy rows (origin was NULL before migration 155).
|
||||
if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual');
|
||||
else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin'));
|
||||
// mailbox_state: default active (+ legacy NULL); Archived/Deleted folders pass it explicitly.
|
||||
const st = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
|
||||
if (st === 'active') qb.where((b) => b.where('email_queue.mailbox_state', 'active').orWhereNull('email_queue.mailbox_state'));
|
||||
else qb.where('email_queue.mailbox_state', st);
|
||||
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
|
||||
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
|
||||
if (req.query.q) {
|
||||
@@ -487,6 +677,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
'email_queue.sent_at',
|
||||
'email_queue.error_message',
|
||||
'email_queue.retry_count',
|
||||
'email_queue.origin',
|
||||
'email_queue.event_id',
|
||||
'events.event_name as event_name',
|
||||
'events.slug as event_slug'
|
||||
@@ -506,6 +697,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
sentAt: r.sent_at,
|
||||
errorMessage: r.error_message,
|
||||
retryCount: r.retry_count,
|
||||
origin: r.origin || 'system',
|
||||
eventId: r.event_id,
|
||||
eventName: r.event_name || null,
|
||||
eventSlug: r.event_slug || null,
|
||||
@@ -516,11 +708,116 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List email queue error:', error);
|
||||
logger.error('List email queue error:', error);
|
||||
res.status(500).json({ error: 'Failed to load email queue', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Single queued/sent email WITH its rendered body — powers the Messages
|
||||
// reading pane. `rendered_html` is the exact HTML that was sent (migration
|
||||
// 119); rows sent before that migration have none. Attachment disk paths in
|
||||
// `email_data` are never exposed — only the filenames, so the pane can list
|
||||
// attachments without leaking storage paths (same PII posture as the list).
|
||||
router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const row = await db('email_queue')
|
||||
.leftJoin('events', 'events.id', 'email_queue.event_id')
|
||||
.select('email_queue.*', 'events.event_name as event_name', 'events.slug as event_slug')
|
||||
.where('email_queue.id', id)
|
||||
.first();
|
||||
if (!row) return res.status(404).json({ error: 'Email not found' });
|
||||
|
||||
let cc = null;
|
||||
let attachments = [];
|
||||
try {
|
||||
const data = row.email_data ? JSON.parse(row.email_data) : {};
|
||||
if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc);
|
||||
if (Array.isArray(data.attachments)) {
|
||||
attachments = data.attachments
|
||||
.filter((a) => a && a.filename)
|
||||
.map((a) => ({ filename: a.filename, contentType: a.contentType || null }));
|
||||
}
|
||||
} catch (_) { /* malformed email_data → no cc/attachments, still return the body */ }
|
||||
|
||||
res.json({
|
||||
id: row.id,
|
||||
recipientEmail: row.recipient_email,
|
||||
emailType: row.email_type,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
scheduledAt: row.scheduled_at,
|
||||
sentAt: row.sent_at,
|
||||
errorMessage: row.error_message,
|
||||
retryCount: row.retry_count,
|
||||
eventId: row.event_id,
|
||||
eventName: row.event_name || null,
|
||||
eventSlug: row.event_slug || null,
|
||||
renderedHtml: row.rendered_html || null,
|
||||
cc,
|
||||
attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get email queue item error:', error);
|
||||
res.status(500).json({ error: 'Failed to load email', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Send a human-composed email from the Messages composer. The admin already
|
||||
// edited the body (reply or document message), so it is sent as-is — no
|
||||
// template render — after a sanitize pass. Recorded in email_queue as a
|
||||
// 'manual' send so it surfaces under Customers > Sent.
|
||||
router.post('/send', adminAuth, messagingGate, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const to = String(b.to || '').trim();
|
||||
const subject = String(b.subject || '').trim();
|
||||
if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
|
||||
return res.status(400).json({ error: 'A valid recipient email is required.' });
|
||||
}
|
||||
if (!subject) return res.status(400).json({ error: 'A subject is required.' });
|
||||
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
// Match the stricter inbound sanitizeBody allowlist: no <style> tag, no
|
||||
// data: scheme — inline style/class attributes are enough for composed mail.
|
||||
const html = sanitizeHtml(String(b.html || ''), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
'*': ['style', 'class'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
|
||||
});
|
||||
const cc = b.cc ? String(b.cc).trim() : null;
|
||||
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
|
||||
|
||||
const emailProcessor = require('../services/emailProcessor');
|
||||
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
|
||||
|
||||
await db('email_queue').insert({
|
||||
recipient_email: to,
|
||||
email_type: 'manual_message',
|
||||
email_data: JSON.stringify({
|
||||
subject,
|
||||
cc: cc || undefined,
|
||||
replyToReceivedId: b.replyToReceivedId || undefined,
|
||||
messageId: result.messageId,
|
||||
}),
|
||||
status: 'sent',
|
||||
origin: 'manual',
|
||||
rendered_html: html,
|
||||
created_at: new Date(),
|
||||
sent_at: new Date(),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Manual send error:', error);
|
||||
res.status(500).json({ error: 'Failed to send message', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
@@ -528,7 +825,7 @@ function parseVariables(template) {
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
logger.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -611,8 +908,7 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
|
||||
|
||||
res.json(formattedTemplates);
|
||||
} catch (error) {
|
||||
console.error('Email templates fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email templates' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch email templates');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -641,8 +937,7 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email template fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email template' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch email template');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -730,8 +1025,7 @@ router.put('/templates/:key', [
|
||||
|
||||
res.json({ message: 'Email template updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Email template update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update email template' });
|
||||
errorResponse(res, error, 500, 'Failed to update email template');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -818,8 +1112,7 @@ router.post('/templates', [
|
||||
|
||||
return res.status(201).json({ template_key: templateKey, id: templateId });
|
||||
} catch (error) {
|
||||
console.error('Email template create error:', error);
|
||||
return res.status(500).json({ error: 'Failed to create email template' });
|
||||
return errorResponse(res, error, 500, 'Failed to create email template');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -897,8 +1190,7 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
language
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email template preview error:', error);
|
||||
res.status(500).json({ error: 'Failed to preview email template' });
|
||||
errorResponse(res, error, 500, 'Failed to preview email template');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@ const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const eventRenameService = require('../services/eventRenameService');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/admin/events/:eventId/rename
|
||||
* Rename an event
|
||||
*/
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
@@ -50,7 +52,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
data: result.data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error renaming event:', error);
|
||||
logger.error('Error renaming event:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to rename event' });
|
||||
}
|
||||
});
|
||||
@@ -59,7 +61,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
* POST /api/admin/events/:eventId/validate-rename
|
||||
* Validate a potential rename without executing it
|
||||
*/
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
@@ -81,7 +83,7 @@ router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.ed
|
||||
|
||||
res.json(validation);
|
||||
} catch (error) {
|
||||
console.error('Error validating rename:', error);
|
||||
logger.error('Error validating rename:', error);
|
||||
res.status(500).json({ valid: false, error: 'Validation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
// This is a partial file showing the enhanced event creation with password validation
|
||||
// Only the relevant parts are shown - merge with existing adminEvents.js
|
||||
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
||||
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('admin_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('password').notEmpty(), // Remove the weak isLength validation
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
body('welcome_message').optional().trim(),
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('customer_name').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.error('Validation errors:', errors.array());
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
customer_name,
|
||||
customer_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
color_theme = null,
|
||||
expiration_days = 30,
|
||||
allow_user_uploads = false,
|
||||
upload_category_id = null,
|
||||
photo_cap = null
|
||||
} = req.body;
|
||||
|
||||
// Validate password strength for gallery
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link based on configured style
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
customer_name,
|
||||
customer_email,
|
||||
host_name: customer_name,
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
upload_category_id,
|
||||
photo_cap: photo_cap || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{
|
||||
event_type,
|
||||
expires_at,
|
||||
password_strength: passwordValidation.score
|
||||
},
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Rest of the implementation remains the same...
|
||||
// Queue creation email, etc.
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const { archiveEvent } = require('../../services/archiveService');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { requireEventOwnership, filterOwnedEventIds } = require('../../middleware/ownership');
|
||||
const { deleteEventCascade } = require('./helpers');
|
||||
|
||||
|
||||
// Bulk delete — destructive, irreversible. Caps at 100 events per request
|
||||
// to keep request time bounded; the per-event cascade touches 5 DB tables
|
||||
// + 3 filesystem paths so 1000 events would risk timing out the request.
|
||||
// Loops via deleteEventCascade so the per-event delete behaviour stays in
|
||||
// lock-step with DELETE /:id.
|
||||
//
|
||||
// Confirmation is enforced client-side via the typed-DELETE pattern in
|
||||
// BulkDeleteModal (#417). The previous server-side bcrypt-password gate
|
||||
// was dropped because the destructive single-event DELETE /:id has never
|
||||
// required a password either — events.delete permission + admin session
|
||||
// is the auth boundary for both. The typed-literal client gate is the
|
||||
// "accidental click" safeguard, and unlike a password input it isn't
|
||||
// affected by passkey/Windows Hello autofill that auto-submits the form.
|
||||
const BULK_DELETE_MAX = 100;
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (event.is_archived) {
|
||||
return res.status(400).json({ error: 'Event is already archived' });
|
||||
}
|
||||
|
||||
// Use the archive service to create ZIP archive
|
||||
await archiveEvent(event);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_archived',
|
||||
{ eventName: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event archived successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to archive event');
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk archive events
|
||||
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
body('eventIds').isArray().withMessage('eventIds must be an array'),
|
||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
if (eventIds.length === 0) {
|
||||
return res.status(400).json({ error: 'No events selected for archiving' });
|
||||
}
|
||||
|
||||
// Ownership scope: a non-super_admin may only archive events they own.
|
||||
// Foreign/non-existent ids are dropped and reported as failures so this
|
||||
// route can't archive another admin's events (the single-event
|
||||
// /:id/archive route enforces the same via requireEventOwnership).
|
||||
const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds);
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' }))
|
||||
};
|
||||
|
||||
// Get all events to archive
|
||||
const events = allowedIds.length
|
||||
? await db('events')
|
||||
.whereIn('id', allowedIds)
|
||||
.where('is_archived', formatBoolean(false))
|
||||
: [];
|
||||
|
||||
if (events.length === 0) {
|
||||
if (results.failed.length > 0) {
|
||||
return res.json({
|
||||
message: `Bulk archive completed: 0 succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
}
|
||||
return res.status(400).json({ error: 'No valid events found to archive' });
|
||||
}
|
||||
|
||||
// Process each event
|
||||
for (const event of events) {
|
||||
try {
|
||||
// Use the archive service to create ZIP archive
|
||||
await archiveEvent(event);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_archived',
|
||||
{ eventName: event.event_name, bulkOperation: true },
|
||||
event.id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
results.successful.push({
|
||||
id: event.id,
|
||||
name: event.event_name
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to archive event ${event.id}:`, error);
|
||||
results.failed.push({
|
||||
id: event.id,
|
||||
name: event.event_name,
|
||||
error: 'Failed to archive event. Check server logs for details.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Log bulk archive activity
|
||||
await logActivity('bulk_archive_completed',
|
||||
{
|
||||
totalEvents: eventIds.length,
|
||||
successfulCount: results.successful.length,
|
||||
failedCount: results.failed.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to perform bulk archive');
|
||||
}
|
||||
});
|
||||
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
|
||||
body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`),
|
||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { eventIds } = req.body;
|
||||
|
||||
// Ownership scope: a non-super_admin may only delete events they own.
|
||||
// The single-event DELETE /:id route enforces this via
|
||||
// requireEventOwnership; this bulk route must match it, otherwise an
|
||||
// admin/editor scoped to their own events could cascade-delete any
|
||||
// event by id. Foreign/non-existent ids are dropped and reported as
|
||||
// failures (indistinguishable, to avoid an existence oracle).
|
||||
const { allowed: allowedIds, denied: deniedIds } = await filterOwnedEventIds(req.admin, eventIds);
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: deniedIds.map((id) => ({ id, name: null, error: 'Access denied or event not found' }))
|
||||
};
|
||||
const adminContext = { id: req.admin.id, username: req.admin.username };
|
||||
|
||||
for (const eventId of allowedIds) {
|
||||
try {
|
||||
const deleted = await deleteEventCascade(eventId, adminContext);
|
||||
results.successful.push(deleted);
|
||||
} catch (err) {
|
||||
results.failed.push({
|
||||
id: eventId,
|
||||
name: null,
|
||||
error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event'
|
||||
});
|
||||
logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await logActivity('bulk_delete_completed',
|
||||
{
|
||||
totalEvents: eventIds.length,
|
||||
successfulCount: results.successful.length,
|
||||
failedCount: results.failed.length
|
||||
},
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to perform bulk delete');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Shared helpers + module-level caches used across the adminEvents sub-routers.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../../utils/logger');
|
||||
const { parseStringInput } = require('../../utils/parsers');
|
||||
|
||||
// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point
|
||||
const validateHeroImageAnchor = (value) => {
|
||||
if (['top', 'center', 'bottom'].includes(value)) return true;
|
||||
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
|
||||
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
|
||||
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
|
||||
}
|
||||
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
|
||||
};
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
value = value === 'true';
|
||||
}
|
||||
}
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get event field requirements', { error: error.message });
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read app_settings booleans by key, used to inherit per-setting
|
||||
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
|
||||
// so callers can fall back to a legacy default.
|
||||
const readBooleanSetting = async (key) => {
|
||||
try {
|
||||
const setting = await db('app_settings').where('setting_key', key).first();
|
||||
if (!setting) return undefined;
|
||||
let value = setting.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read app setting', { key, error: error.message });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to read the global "enable_devtools_protection" admin setting so
|
||||
// new events inherit it instead of always falling back to the DB column default
|
||||
// (#317 — admin disabled it globally but new events still got it ON).
|
||||
const getDownloadProtectionDefaults = async () => {
|
||||
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
|
||||
};
|
||||
|
||||
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
|
||||
//
|
||||
// Note: `branding_logo_position` (header bar — left/center/right) is a
|
||||
// different concept from `hero_logo_position` (hero block — top/center/
|
||||
// bottom) and must NOT be mapped here. A previous version copied the
|
||||
// branding value over, which wrote 'left'/'right' into per-event
|
||||
// hero_logo_position columns and broke any subsequent PUT validation
|
||||
// (#357). Migration 084 heals existing rows.
|
||||
const getBrandingDefaults = async () => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'branding_logo_display_hero',
|
||||
'branding_logo_size'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const defaults = {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
let value = s.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_display_hero') {
|
||||
defaults.hero_logo_visible = value !== false;
|
||||
}
|
||||
if (s.setting_key === 'branding_logo_size' && value) {
|
||||
defaults.hero_logo_size = value;
|
||||
}
|
||||
});
|
||||
|
||||
return defaults;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get branding defaults', { error: error.message });
|
||||
return {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
||||
|
||||
// Whether the global "phone field" toggle (#322) is enabled. Cached for
|
||||
// the request via a module-level read; drift is acceptable since this
|
||||
// only governs whether to persist the field, not security boundaries.
|
||||
const isPhoneFieldEnabled = async () => {
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
if (!row) return false;
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return value === true;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
customer_phone,
|
||||
password_hash: _ph,
|
||||
client_password_hash: _cph,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null,
|
||||
customer_phone: customer_phone ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Cascade-delete a single event: photos, audit/access logs, queued emails,
|
||||
// the event row itself (in one transaction), then the on-disk folder /
|
||||
// archive zip / hero logo (best-effort — file failures don't unwind the DB
|
||||
// changes since the source of truth is the database). Used by both the
|
||||
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
|
||||
//
|
||||
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
|
||||
// bulk-delete loop can report it as a per-id failure without aborting the
|
||||
// whole batch. Any other error propagates and is the caller's problem.
|
||||
async function deleteEventCascade(eventId, adminContext) {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
const err = new Error('Event not found');
|
||||
err.code = 'EVENT_NOT_FOUND';
|
||||
throw err;
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', eventId).del();
|
||||
// 2. Delete access logs
|
||||
await trx('access_logs').where('event_id', eventId).del();
|
||||
// 3. Delete email queue entries
|
||||
await trx('email_queue').where('event_id', eventId).del();
|
||||
// 4. Delete photos (also handles hero_photo_id foreign key)
|
||||
await trx('photos').where('event_id', eventId).del();
|
||||
// 5. Finally delete the event row
|
||||
await trx('events').where('id', eventId).del();
|
||||
|
||||
// Best-effort filesystem cleanup. Failures are logged but don't unwind
|
||||
// the transaction — the canonical state lives in the DB; orphan files
|
||||
// are recoverable noise, a half-deleted DB row is a permanent mess.
|
||||
//
|
||||
// #608 — previous code read `event.folder_path`, but that column is
|
||||
// never written anywhere in the codebase (grep confirms: two reads in
|
||||
// this function, zero writes). It's always undefined, so the
|
||||
// `if (event.folder_path)` branch silently no-op'd and every event
|
||||
// delete since this cascade landed left its photos orphaned on disk.
|
||||
// jodrmx's Pi report (v3.44.0) was the first surfacing.
|
||||
//
|
||||
// Files actually live at:
|
||||
// {STORAGE_PATH}/events/active/{slug}/... (uploaded photos)
|
||||
// {STORAGE_PATH}/events/archived/{slug}/... (after the event
|
||||
// was archived — folder copy survives the archive flow)
|
||||
//
|
||||
// `event.slug` is NOT NULL on the events table and is slugify-sanitized
|
||||
// on every write (lower-case ASCII + dashes only via utils/slug.js),
|
||||
// so path-traversal isn't a concern.
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
for (const sub of ['active', 'archived']) {
|
||||
const eventFolderPath = path.join(storagePath, 'events', sub, event.slug);
|
||||
try {
|
||||
await fs.rm(eventFolderPath, { recursive: true, force: true });
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
|
||||
const archiveFile = path.join(storagePath, event.archive_path);
|
||||
try {
|
||||
await fs.unlink(archiveFile);
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
} catch (fsErr) {
|
||||
logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Audit trail (outside the transaction so a logging failure can't undo
|
||||
// the actual delete).
|
||||
await logActivity('event_deleted',
|
||||
{ event_name: event.event_name },
|
||||
null,
|
||||
{ type: 'admin', id: adminContext.id, name: adminContext.username }
|
||||
);
|
||||
|
||||
return { id: event.id, name: event.event_name };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
|
||||
// events that auto-picks-up new uploads (migration 138). Mirrors the
|
||||
// client-access second-token pattern: the link is minted on demand, rotatable
|
||||
// and disable-able, independent of the gallery password / share link.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Allowed slide transition styles (kept in sync with the SlideshowPage).
|
||||
// dipwhite/dipblack = fade through highlights / lowlights between images.
|
||||
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
// Allowed per-slide color filters.
|
||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
module.exports = {
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
getEventFieldRequirements,
|
||||
readBooleanSetting,
|
||||
getDownloadProtectionDefaults,
|
||||
getBrandingDefaults,
|
||||
getCustomerNameFromPayload,
|
||||
getCustomerEmailFromPayload,
|
||||
getCustomerPhoneFromPayload,
|
||||
isPhoneFieldEnabled,
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
deleteEventCascade,
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// adminEvents router — decomposed move-code refactor of the original
|
||||
// routes/adminEvents.js god file. Each sub-module attaches its routes onto the
|
||||
// shared router below. CRITICAL: the require(...)(router) calls preserve the
|
||||
// original registration order — Express matches in registration order, so
|
||||
// literal segments and '/:id' patterns must keep their relative positions.
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
require('./crud')(router);
|
||||
require('./slideshow')(router);
|
||||
require('./resets')(router);
|
||||
require('./archiveBulk')(router);
|
||||
require('./logo')(router);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,145 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { validateFileType } = require('../../utils/fileSecurityUtils');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { getStoragePath } = require('./helpers');
|
||||
|
||||
|
||||
// Configure multer for event logo uploads
|
||||
const eventLogoStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const eventLogoUpload = multer({
|
||||
storage: eventLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Upload event custom logo
|
||||
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if event exists
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No logo file provided' });
|
||||
}
|
||||
|
||||
// Delete old logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
|
||||
const logoPath = req.file.path;
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: logoUrl,
|
||||
hero_logo_path: logoPath
|
||||
});
|
||||
|
||||
await logActivity('event_logo_uploaded',
|
||||
{ eventName: event.event_name, filename: req.file.filename },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Event logo uploaded successfully',
|
||||
hero_logo_url: logoUrl
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to upload event logo');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event custom logo
|
||||
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: null,
|
||||
hero_logo_path: null
|
||||
});
|
||||
|
||||
await logActivity('event_logo_removed',
|
||||
{ eventName: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event logo removed successfully' });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete event logo');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { queueEmail } = require('../../services/emailProcessor');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
|
||||
const logger = require('../../utils/logger');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { buildShareLinkVariants } = require('../../services/shareLinkService');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Reset event password
|
||||
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sendEmail = true, password: clientPassword } = req.body;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (event.is_archived) {
|
||||
return res.status(400).json({ error: 'Cannot reset password for archived event' });
|
||||
}
|
||||
|
||||
// Use the admin-supplied password when provided; otherwise auto-generate
|
||||
// (preserves the previous one-click behaviour for callers/cron that don't
|
||||
// pass a body). Validation matches the create-event flow so the same
|
||||
// strength rules apply both ways.
|
||||
let newPassword;
|
||||
if (typeof clientPassword === 'string' && clientPassword.length > 0) {
|
||||
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
|
||||
eventName: event.event_name
|
||||
});
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
newPassword = clientPassword;
|
||||
} else {
|
||||
const { generateReadablePassword } = require('../../utils/passwordGenerator');
|
||||
newPassword = generateReadablePassword();
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
// Update event with new password
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
password_hash: passwordHash
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('password_reset',
|
||||
{ eventName: event.event_name, emailSent: sendEmail },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form (`/gallery/<slug>/<token>`).
|
||||
// Use the full URL so customers can click straight from the email.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: newPassword,
|
||||
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Password reset successfully',
|
||||
newPassword: newPassword,
|
||||
emailSent: sendEmail
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to reset password');
|
||||
}
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get event details
|
||||
let eventQuery = db('events').where('id', id);
|
||||
// Editor role can only edit their own events
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// The email processor will determine the language based on:
|
||||
// 1. Event language setting
|
||||
// 2. App settings general_default_language
|
||||
// 3. Email config default language
|
||||
// 4. Domain-based detection
|
||||
// So we don't need to determine it here
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
// Use optional chaining to handle cases where req.body might be undefined
|
||||
let galleryPassword = req.body?.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
if (!galleryPassword) {
|
||||
// We'll let the email processor determine the language for the security message
|
||||
galleryPassword = '{{password_security_message}}';
|
||||
}
|
||||
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
|
||||
// Queue the email
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
// event.share_link is the path-only form; use the full URL so the
|
||||
// customer's mail client renders a clickable absolute link.
|
||||
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: galleryPassword,
|
||||
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
|
||||
welcome_message: event.welcome_message || '',
|
||||
eventId: id,
|
||||
isResend: true // Flag to indicate this is a resend
|
||||
});
|
||||
|
||||
// Log the activity using the proper schema
|
||||
try {
|
||||
await logActivity('email_resent', {
|
||||
email_type: 'gallery_created',
|
||||
recipient: recipientEmail,
|
||||
ip_address: req.ip || '0.0.0.0',
|
||||
user_agent: req.get('user-agent') || 'Unknown'
|
||||
}, id, {
|
||||
type: 'admin',
|
||||
id: req.admin.id,
|
||||
name: req.admin.username
|
||||
});
|
||||
} catch (logError) {
|
||||
logger.error('Warning: Failed to log activity:', logError);
|
||||
// Don't fail the request if activity logging fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Creation email has been queued for sending'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error resending creation email:', error);
|
||||
errorResponse(res, error, 500, 'Failed to resend creation email');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
|
||||
// Exports a register function; ./index.js calls the sub-routers in the original
|
||||
// registration order so Express route matching is unchanged.
|
||||
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../../database/db');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { adminAuth } = require('../../middleware/auth');
|
||||
const { requirePermission } = require('../../middleware/permissions');
|
||||
const crypto = require('crypto');
|
||||
const { errorResponse } = require('../../utils/routeHelpers');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
|
||||
// The watermark LOOK (source/position/opacity/style/size) is global-only
|
||||
// (app_settings, Settings → Slideshow); events only carry the show_watermark
|
||||
// mode (NULL=inherit / true / false), so no per-event look enums live here.
|
||||
|
||||
// Build the public slideshow URL for a freshly-minted/existing token.
|
||||
async function buildSlideshowUrl(slug, token) {
|
||||
if (!token) return null;
|
||||
const base = await getFrontendBaseUrl();
|
||||
return `${base.replace(/\/$/, '')}/gallery/${slug}/show/${token}`;
|
||||
}
|
||||
|
||||
// Fetch the event respecting the editor-role ownership scope (requireEventOwnership
|
||||
// already gates the route; this re-applies the created_by filter for editors so the
|
||||
// 404 is identical to the rest of this file).
|
||||
async function loadOwnedEvent(req) {
|
||||
let q = db('events').where('id', req.params.id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
q = q.where('created_by', req.admin.id);
|
||||
}
|
||||
return q.first();
|
||||
}
|
||||
|
||||
module.exports = (router) => {
|
||||
|
||||
|
||||
// Generate (or rotate) the slideshow share token. Idempotent in intent: each
|
||||
// call mints a fresh token, which both "Generate" (first time) and "Regenerate"
|
||||
// (rotate, kills the old link) use.
|
||||
router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const event = await loadOwnedEvent(req);
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
// NB: the events table has no updated_at column (only created_at), so we
|
||||
// must not set it here or the UPDATE throws.
|
||||
await db('events').where('id', req.params.id).update({
|
||||
show_share_token: token
|
||||
});
|
||||
|
||||
await logActivity('slideshow_link_generated',
|
||||
{ eventName: event.event_name, rotated: Boolean(event.show_share_token) },
|
||||
req.params.id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
show_share_token: token,
|
||||
slideshow_url: await buildSlideshowUrl(event.slug, token)
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to generate slideshow link');
|
||||
}
|
||||
});
|
||||
|
||||
// Disable the slideshow link (null the token). The public /show/ route dies on
|
||||
// its next poll, killing any projector currently pointed at the old link.
|
||||
router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const event = await loadOwnedEvent(req);
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
await db('events').where('id', req.params.id).update({
|
||||
show_share_token: null
|
||||
});
|
||||
|
||||
await logActivity('slideshow_link_disabled',
|
||||
{ eventName: event.event_name },
|
||||
req.params.id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ show_share_token: null });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to disable slideshow link');
|
||||
}
|
||||
});
|
||||
|
||||
// Update the LIVE slideshow settings (display time / transition style / speed).
|
||||
// A running projector picks these up via the show-page settings poll within a
|
||||
// few seconds — no need to regenerate the link.
|
||||
router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, [
|
||||
body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }),
|
||||
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
|
||||
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
|
||||
body('show_watermark').optional({ nullable: true }),
|
||||
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() });
|
||||
}
|
||||
|
||||
const event = await loadOwnedEvent(req);
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// events has no updated_at column — don't set it.
|
||||
const updates = {};
|
||||
if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10);
|
||||
if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition;
|
||||
if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10);
|
||||
// Tri-state: explicit null = inherit the global default.
|
||||
if (req.body.show_watermark !== undefined) {
|
||||
updates.show_watermark = req.body.show_watermark === null
|
||||
? null
|
||||
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
|
||||
}
|
||||
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
|
||||
|
||||
// Knex throws on an empty update; only write if something changed.
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db('events').where('id', req.params.id).update(updates);
|
||||
}
|
||||
|
||||
res.json({
|
||||
show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000,
|
||||
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
|
||||
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
|
||||
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
|
||||
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update slideshow settings');
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const sharp = require('sharp');
|
||||
@@ -48,7 +49,7 @@ async function walkDir(dir, baseDir) {
|
||||
|
||||
// POST /api/admin/events/:id/import-external
|
||||
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
|
||||
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.id);
|
||||
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
|
||||
|
||||
@@ -8,6 +8,7 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || '';
|
||||
|
||||
@@ -73,10 +74,10 @@ router.get(
|
||||
'gallery_guests.created_at',
|
||||
'gallery_guests.last_seen_at',
|
||||
'gallery_guests.email_verified_at',
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
|
||||
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'like\' THEN 1 END) AS likes'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'favorite\' THEN 1 END) AS favorites'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'),
|
||||
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'),
|
||||
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
|
||||
)
|
||||
.orderBy('gallery_guests.created_at', 'desc');
|
||||
@@ -94,8 +95,7 @@ router.get(
|
||||
|
||||
res.json({ guests });
|
||||
} catch (error) {
|
||||
logger.error('Error listing guests:', error);
|
||||
res.status(500).json({ error: 'Failed to list guests' });
|
||||
errorResponse(res, error, 500, 'Failed to list guests');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -117,7 +117,7 @@ router.get(
|
||||
const photos = await db('photos')
|
||||
.leftJoin('photo_feedback', function () {
|
||||
this.on('photo_feedback.photo_id', '=', 'photos.id')
|
||||
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
|
||||
.andOn(db.raw('photo_feedback.feedback_type IN (\'like\',\'favorite\')'))
|
||||
.andOnNotNull('photo_feedback.guest_id');
|
||||
})
|
||||
.where('photos.event_id', eventId)
|
||||
@@ -144,8 +144,7 @@ router.get(
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching aggregate view:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch aggregate view' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch aggregate view');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -196,8 +195,7 @@ router.get(
|
||||
|
||||
res.json({ invites });
|
||||
} catch (error) {
|
||||
logger.error('Error listing invites:', error);
|
||||
res.status(500).json({ error: 'Failed to list invites' });
|
||||
errorResponse(res, error, 500, 'Failed to list invites');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -266,8 +264,7 @@ router.post(
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating invite:', error);
|
||||
res.status(500).json({ error: 'Failed to create invite' });
|
||||
errorResponse(res, error, 500, 'Failed to create invite');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -302,8 +299,7 @@ router.delete(
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error revoking invite:', error);
|
||||
res.status(500).json({ error: 'Failed to revoke invite' });
|
||||
errorResponse(res, error, 500, 'Failed to revoke invite');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -457,8 +453,7 @@ router.get(
|
||||
selections,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching guest detail:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch guest detail' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch guest detail');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -509,8 +504,7 @@ router.get(
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
|
||||
} catch (error) {
|
||||
logger.error('Error exporting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to export guest' });
|
||||
errorResponse(res, error, 500, 'Failed to export guest');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -548,8 +542,7 @@ router.delete(
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting guest:', error);
|
||||
res.status(500).json({ error: 'Failed to delete guest' });
|
||||
errorResponse(res, error, 500, 'Failed to delete guest');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -600,8 +593,7 @@ router.post(
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error('Error merging guests:', error);
|
||||
res.status(500).json({ error: 'Failed to merge guests' });
|
||||
errorResponse(res, error, 500, 'Failed to merge guests');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get notifications (unread activity logs)
|
||||
@@ -39,7 +40,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
if (typeof notification.metadata === 'object') return notification.metadata;
|
||||
return JSON.parse(notification.metadata);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse metadata for notification:', notification.id, e.message);
|
||||
logger.warn('Failed to parse metadata for notification:', notification.id, e.message);
|
||||
return {};
|
||||
}
|
||||
})(),
|
||||
@@ -59,7 +60,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
unreadCount: unreadCount.count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Notifications fetch error:', error);
|
||||
logger.error('Notifications fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||
}
|
||||
});
|
||||
@@ -77,7 +78,7 @@ router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
res.json({ message: 'Notification marked as read' });
|
||||
} catch (error) {
|
||||
console.error('Mark notification read error:', error);
|
||||
logger.error('Mark notification read error:', error);
|
||||
res.status(500).json({ error: 'Failed to mark notification as read' });
|
||||
}
|
||||
});
|
||||
@@ -93,7 +94,7 @@ router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
res.json({ message: 'All notifications marked as read' });
|
||||
} catch (error) {
|
||||
console.error('Mark all notifications read error:', error);
|
||||
logger.error('Mark all notifications read error:', error);
|
||||
res.status(500).json({ error: 'Failed to mark all notifications as read' });
|
||||
}
|
||||
});
|
||||
@@ -112,7 +113,7 @@ router.delete('/clear-all', adminAuth, requirePermission('settings.edit'), async
|
||||
const deletedCount = await db('activity_logs').delete();
|
||||
res.json({ message: 'All notifications cleared', deletedCount });
|
||||
} catch (error) {
|
||||
console.error('Clear notifications error:', error);
|
||||
logger.error('Clear notifications error:', error);
|
||||
res.status(500).json({ error: 'Failed to clear notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,9 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const { PhotoExportService } = require('../services/photoExportService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const exportService = new PhotoExportService();
|
||||
|
||||
@@ -66,8 +68,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
|
||||
|
||||
const sort = req.query.sort || 'date';
|
||||
const order = req.query.order || 'desc';
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const { page, limit } = getPagination(req, { limit: 50 });
|
||||
|
||||
// Build filtered query
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
@@ -124,7 +125,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Filter photos error:', error);
|
||||
logger.error('Filter photos error:', error);
|
||||
res.status(500).json({ error: 'Failed to filter photos' });
|
||||
}
|
||||
});
|
||||
@@ -146,7 +147,7 @@ router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view
|
||||
data: summary
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Filter summary error:', error);
|
||||
logger.error('Filter summary error:', error);
|
||||
res.status(500).json({ error: 'Failed to get filter summary' });
|
||||
}
|
||||
});
|
||||
@@ -206,7 +207,7 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
|
||||
res.send(result.content);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export photos error:', error);
|
||||
logger.error('Export photos error:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to export photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ const downloadZipService = require('../services/downloadZipService');
|
||||
const { findReplacementCandidate, replacePhoto } = require('../services/photoReplacementService');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { getStorage } = require('../services/storage');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -31,7 +33,7 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
console.log('Multer destination called for file:', file.originalname);
|
||||
logger.info('Multer destination called for file:', file.originalname);
|
||||
const { eventId } = req.params;
|
||||
|
||||
// We'll validate the event exists in the route handler
|
||||
@@ -40,7 +42,7 @@ const storage = multer.diskStorage({
|
||||
|
||||
// Create directory synchronously
|
||||
require('fs').mkdirSync(tempPath, { recursive: true });
|
||||
console.log('Temp destination path:', tempPath);
|
||||
logger.info('Temp destination path:', tempPath);
|
||||
|
||||
// Store temp path for cleanup
|
||||
req.tempUploadPath = tempPath;
|
||||
@@ -48,10 +50,10 @@ const storage = multer.diskStorage({
|
||||
cb(null, tempPath);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
console.log('Multer filename called for file:', file.originalname);
|
||||
logger.info('Multer filename called for file:', file.originalname);
|
||||
// Use a simple temporary filename
|
||||
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
|
||||
console.log('Temp filename:', tempName);
|
||||
logger.info('Temp filename:', tempName);
|
||||
cb(null, tempName);
|
||||
}
|
||||
});
|
||||
@@ -89,7 +91,7 @@ const resolveAllowedTypes = async (req, res, next) => {
|
||||
try {
|
||||
req.allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve allowed MIME types:', error);
|
||||
logger.error('Failed to resolve allowed MIME types:', error);
|
||||
req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
next();
|
||||
@@ -111,7 +113,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
return (req, res, next) => {
|
||||
// Set timeout for the request
|
||||
req.setTimeout(timeout, () => {
|
||||
console.error('Upload request timed out');
|
||||
logger.error('Upload request timed out');
|
||||
if (!res.headersSent) {
|
||||
res.status(408).json({ error: 'Upload request timed out' });
|
||||
}
|
||||
@@ -119,7 +121,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
|
||||
// Set response timeout as well
|
||||
res.setTimeout(timeout, () => {
|
||||
console.error('Upload response timed out');
|
||||
logger.error('Upload response timed out');
|
||||
});
|
||||
|
||||
next();
|
||||
@@ -133,13 +135,12 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve max files per upload:', error);
|
||||
return res.status(500).json({ error: 'Unable to determine upload limits' });
|
||||
return errorResponse(res, error, 500, 'Unable to determine upload limits');
|
||||
}
|
||||
|
||||
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
logger.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' });
|
||||
@@ -166,7 +167,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
logger.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
};
|
||||
res.on('finish', cleanupTempDir);
|
||||
@@ -177,16 +178,16 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
const { category_id, replace_by_name } = req.body;
|
||||
const replaceByName = replace_by_name === 'true' || replace_by_name === true;
|
||||
|
||||
console.log('Upload request received for event:', eventId);
|
||||
console.log('Body:', req.body);
|
||||
console.log('Files:', req.files ? req.files.length : 'none');
|
||||
console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
|
||||
console.log('Category ID received:', category_id);
|
||||
logger.info('Upload request received for event:', eventId);
|
||||
logger.info('Body:', req.body);
|
||||
logger.info('Files:', req.files ? req.files.length : 'none');
|
||||
logger.info('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
|
||||
logger.info('Category ID received:', category_id);
|
||||
|
||||
// Verify event exists and admin has access
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found:', eventId);
|
||||
logger.error('Event not found:', eventId);
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
@@ -213,8 +214,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
logger.error('No files in request. req.files:', req.files);
|
||||
logger.error('Request body keys:', Object.keys(req.body));
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
@@ -389,7 +390,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
category_id: parsedCategoryId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error queuing file ${file.originalname}:`, err);
|
||||
logger.error(`Error queuing file ${file.originalname}:`, err);
|
||||
errors.push({ filename: file.originalname, error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -451,10 +452,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
// 202 Accepted — files stored, processing happens in background.
|
||||
res.status(202).json(response);
|
||||
} catch (error) {
|
||||
console.error('Error uploading photos:', error);
|
||||
// Temp directory cleanup is handled by the response finish/close
|
||||
// listeners above, regardless of which exit path fires.
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
errorResponse(res, error, 500, 'Failed to upload photos');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -523,8 +523,7 @@ router.get(
|
||||
...summariseUpload(group.photos),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading upload status:', error);
|
||||
res.status(500).json({ error: 'Failed to read upload status' });
|
||||
errorResponse(res, error, 500, 'Failed to read upload status');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -575,7 +574,7 @@ router.get(
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload stream poll error:', e);
|
||||
logger.error('Upload stream poll error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -601,12 +600,18 @@ router.post(
|
||||
const photo = await db('photos').where({ id: req.params.photoId }).first();
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
|
||||
// Editor role: only allow retry on photos in events they own.
|
||||
if (req.admin.roleName === 'editor') {
|
||||
// Ownership scope: any non-super_admin may only retry photos in events
|
||||
// they own — matching requireEventOwnership (which scopes both the
|
||||
// admin and editor roles; only super_admin bypasses). Previously this
|
||||
// checked the editor role alone, leaving admin-role users able to
|
||||
// reprocess another admin's photos.
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
const event = await db('events')
|
||||
.where({ id: photo.event_id, created_by: req.admin.id })
|
||||
.where({ id: photo.event_id })
|
||||
.first();
|
||||
if (!event) return res.status(404).json({ error: 'Photo not found' });
|
||||
if (event && event.created_by && event.created_by !== req.admin.id) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
}
|
||||
|
||||
if (photo.processing_status !== 'failed') {
|
||||
@@ -622,8 +627,7 @@ router.post(
|
||||
});
|
||||
res.json({ id: photo.id, status: 'pending' });
|
||||
} catch (error) {
|
||||
console.error('Error retrying photo processing:', error);
|
||||
res.status(500).json({ error: 'Failed to retry photo processing' });
|
||||
errorResponse(res, error, 500, 'Failed to retry photo processing');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -651,7 +655,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
const originalKey = resolvePhotoStorageKey(event, photo);
|
||||
if (originalKey) await storage.delete(originalKey);
|
||||
} catch (error) {
|
||||
console.error('Error deleting photo file:', error);
|
||||
logger.error('Error deleting photo file:', error);
|
||||
}
|
||||
|
||||
// photo.thumbnail_path is stored as the canonical storage key
|
||||
@@ -660,7 +664,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
try {
|
||||
await storage.delete(photo.thumbnail_path);
|
||||
} catch (error) {
|
||||
console.error('Error deleting thumbnail:', error);
|
||||
logger.error('Error deleting thumbnail:', error);
|
||||
}
|
||||
}
|
||||
if (photo.hero_path) {
|
||||
@@ -700,8 +704,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
downloadZipService.invalidate(parseInt(eventId));
|
||||
res.json({ message: 'Photo deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting photo:', error);
|
||||
res.status(500).json({ error: 'Failed to delete photo' });
|
||||
errorResponse(res, error, 500, 'Failed to delete photo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -763,8 +766,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
|
||||
photo: updatedPhoto
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo' });
|
||||
errorResponse(res, error, 500, 'Failed to update photo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -797,7 +799,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
const originalKey = resolvePhotoStorageKey(event, photo);
|
||||
if (originalKey) await storage.delete(originalKey);
|
||||
} catch (error) {
|
||||
console.error('Error deleting photo file:', error);
|
||||
logger.error('Error deleting photo file:', error);
|
||||
}
|
||||
|
||||
if (photo.thumbnail_path) {
|
||||
@@ -842,8 +844,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
downloadZipService.invalidate(parseInt(eventId));
|
||||
res.json({ message: `${photos.length} photos deleted successfully` });
|
||||
} catch (error) {
|
||||
console.error('Error bulk deleting photos:', error);
|
||||
res.status(500).json({ error: 'Failed to delete photos' });
|
||||
errorResponse(res, error, 500, 'Failed to delete photos');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -905,8 +906,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
|
||||
res.json({ message: `${photoIds.length} photos updated successfully` });
|
||||
} catch (error) {
|
||||
console.error('Error bulk updating photos:', error);
|
||||
res.status(500).json({ error: 'Failed to update photos' });
|
||||
errorResponse(res, error, 500, 'Failed to update photos');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -961,8 +961,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
||||
});
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
console.error('Error downloading photo:', error);
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
errorResponse(res, error, 500, 'Failed to download photo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1096,8 +1095,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photos' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch photos');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1153,8 +1151,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
|
||||
}
|
||||
res.sendFile(path.resolve(filePath));
|
||||
} catch (error) {
|
||||
console.error('Error serving photo:', error);
|
||||
res.status(500).json({ error: 'Failed to serve photo' });
|
||||
errorResponse(res, error, 500, 'Failed to serve photo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1168,7 +1165,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||
logger.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
@@ -1192,7 +1189,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||
}
|
||||
|
||||
@@ -1209,10 +1206,9 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
const stream = await storage.get(thumbnailPath);
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error serving thumbnail:', error);
|
||||
console.error('Photo ID:', req.params.photoId);
|
||||
console.error('Event ID:', req.params.eventId);
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
logger.error('Error serving thumbnail:', error);
|
||||
logger.error('Photo ID:', req.params.photoId);
|
||||
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1232,8 +1228,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requi
|
||||
storagePath: getStoragePath()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin photo debug data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photo debug data' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch photo debug data');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1262,7 +1257,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
// Validate file size (max 10GB)
|
||||
const maxSize = 10 * 1024 * 1024 * 1024;
|
||||
if (fileSize > maxSize) {
|
||||
return res.status(400).json({ error: `File too large. Maximum size is 10GB.` });
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 10GB.' });
|
||||
}
|
||||
|
||||
const result = await chunkedUpload.initializeUpload({
|
||||
@@ -1275,8 +1270,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error initializing chunked upload:', error);
|
||||
res.status(500).json({ error: 'Failed to initialize upload' });
|
||||
errorResponse(res, error, 500, 'Failed to initialize upload');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1296,7 +1290,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error uploading chunk:', error);
|
||||
logger.error('Error uploading chunk:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
|
||||
}
|
||||
});
|
||||
@@ -1329,7 +1323,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
try {
|
||||
await fs.rm(mergedFile.tempDir, { recursive: true, force: true });
|
||||
} catch (cleanupErr) {
|
||||
console.warn('Failed to clean up temp directory:', cleanupErr.message);
|
||||
logger.warn('Failed to clean up temp directory:', cleanupErr.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -1338,7 +1332,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
||||
photos: uploadedPhotos
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error completing chunked upload:', error);
|
||||
logger.error('Error completing chunked upload:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to complete upload' });
|
||||
}
|
||||
});
|
||||
@@ -1356,8 +1350,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermis
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting upload status:', error);
|
||||
res.status(500).json({ error: 'Failed to get upload status' });
|
||||
errorResponse(res, error, 500, 'Failed to get upload status');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1370,8 +1363,7 @@ router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission
|
||||
|
||||
res.json({ success: true, message: 'Upload aborted' });
|
||||
} catch (error) {
|
||||
console.error('Error aborting upload:', error);
|
||||
res.status(500).json({ error: 'Failed to abort upload' });
|
||||
errorResponse(res, error, 500, 'Failed to abort upload');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const { db } = require('../database/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
@@ -48,7 +49,7 @@ function transformS3Config(body) {
|
||||
*/
|
||||
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
const { limit } = getPagination(req, { limit: 10 });
|
||||
const history = await restoreService.getRestoreHistory(limit);
|
||||
|
||||
const status = {
|
||||
|
||||
@@ -22,6 +22,8 @@ const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { upsertAppSetting } = require('../utils/appSettings');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
@@ -158,8 +160,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -200,8 +201,7 @@ router.get('/customer-surface', adminAuth, requirePermission('settings.view'), a
|
||||
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Customer surface settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch customer surface settings' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch customer surface settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -231,8 +231,7 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
|
||||
|
||||
res.json({ message: 'Customer surface settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
console.error('Customer surface settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save customer surface settings' });
|
||||
errorResponse(res, error, 500, 'Failed to save customer surface settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -295,8 +294,7 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
|
||||
}
|
||||
res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
console.error('Accounting settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save accounting settings' });
|
||||
errorResponse(res, error, 500, 'Failed to save accounting settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -360,8 +358,7 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
|
||||
require('../utils/slideshowGlobals').invalidateSlideshowGlobals();
|
||||
res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) });
|
||||
} catch (error) {
|
||||
console.error('Slideshow settings save error:', error);
|
||||
res.status(500).json({ error: 'Failed to save slideshow settings' });
|
||||
errorResponse(res, error, 500, 'Failed to save slideshow settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -413,8 +410,7 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
|
||||
res.json(settingsObject);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -434,8 +430,7 @@ router.get('/password/complexity', adminAuth, requirePermission('settings.view')
|
||||
config
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Password complexity settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch password complexity settings' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch password complexity settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -577,9 +572,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(faviconPath);
|
||||
console.log('Deleted favicon file:', faviconPath);
|
||||
logger.info('Deleted favicon file:', faviconPath);
|
||||
} catch (err) {
|
||||
console.error('Error deleting favicon file:', err);
|
||||
logger.error('Error deleting favicon file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -608,9 +603,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
const logoPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(logoPath);
|
||||
console.log('Deleted logo file:', logoPath);
|
||||
logger.info('Deleted logo file:', logoPath);
|
||||
} catch (err) {
|
||||
console.error('Error deleting logo file:', err);
|
||||
logger.error('Error deleting logo file:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -656,20 +651,20 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
// Start background regeneration of all watermarks
|
||||
console.log('Watermark settings changed, starting background regeneration');
|
||||
logger.info('Watermark settings changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
logger.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
} else {
|
||||
// Watermarking was disabled, clear all pre-generated watermarks
|
||||
console.log('Watermarking disabled, clearing pre-generated watermarks');
|
||||
logger.info('Watermarking disabled, clearing pre-generated watermarks');
|
||||
watermarkGeneratorService.clearAllWatermarks()
|
||||
.catch(err => console.error('Failed to clear watermarks:', err));
|
||||
.catch(err => logger.error('Failed to clear watermarks:', err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,8 +673,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update branding settings' });
|
||||
errorResponse(res, error, 500, 'Failed to update branding settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -711,7 +705,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
}
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old logo:', error);
|
||||
logger.error('Failed to delete old logo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,8 +745,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
|
||||
logoUrl: publicPath
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logo upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload logo' });
|
||||
errorResponse(res, error, 500, 'Failed to upload logo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -772,7 +765,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
|
||||
if (p.startsWith('"')) p = JSON.parse(p);
|
||||
await fs.unlink(p);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete logo file:', error);
|
||||
logger.error('Failed to delete logo file:', error);
|
||||
}
|
||||
}
|
||||
await db('app_settings')
|
||||
@@ -781,8 +774,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
|
||||
|
||||
res.json({ message: 'Logo removed' });
|
||||
} catch (error) {
|
||||
console.error('Logo delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to remove logo' });
|
||||
errorResponse(res, error, 500, 'Failed to remove logo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -812,7 +804,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old watermark logo:', error);
|
||||
logger.error('Failed to delete old watermark logo:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -854,13 +846,13 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
console.log('Watermark logo changed, starting background regeneration');
|
||||
logger.info('Watermark logo changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
logger.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
}
|
||||
@@ -871,8 +863,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Watermark logo upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload watermark logo' });
|
||||
errorResponse(res, error, 500, 'Failed to upload watermark logo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -908,8 +899,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
|
||||
res.json({ message: 'Theme settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Theme update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update theme settings' });
|
||||
errorResponse(res, error, 500, 'Failed to update theme settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1005,7 +995,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
require('../services/downloadFilenameService').clearCache();
|
||||
require('../services/downloadZipService').invalidateAll();
|
||||
} catch (e) {
|
||||
console.warn('Failed to invalidate download caches after filename setting change:', e.message);
|
||||
logger.warn('Failed to invalidate download caches after filename setting change:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,8 +1010,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
|
||||
res.json({ message: 'General settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('General settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update general settings' });
|
||||
errorResponse(res, error, 500, 'Failed to update general settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1059,8 +1048,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
res.json({ message: 'Security settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Security settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update security settings' });
|
||||
errorResponse(res, error, 500, 'Failed to update security settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1115,8 +1103,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
|
||||
res.json({ message: 'Analytics settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Analytics settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update analytics settings' });
|
||||
errorResponse(res, error, 500, 'Failed to update analytics settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1179,8 +1166,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
|
||||
|
||||
res.json({ message: 'SEO settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('SEO settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update SEO settings' });
|
||||
errorResponse(res, error, 500, 'Failed to update SEO settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1216,7 +1202,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path, error.message);
|
||||
logger.error('Archive file not found:', archive.archive_path, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1234,7 +1220,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
|
||||
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
|
||||
} catch (diskError) {
|
||||
console.error('Disk stats error:', diskError.message);
|
||||
logger.error('Disk stats error:', diskError.message);
|
||||
}
|
||||
|
||||
const clampDiskValue = (value) => {
|
||||
@@ -1313,27 +1299,27 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
}
|
||||
|
||||
switch (setting.setting_key) {
|
||||
case 'general_storage_soft_limit_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
configuredSoftLimit = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_capacity_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
capacityOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_available_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
availableOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
case 'general_storage_soft_limit_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
configuredSoftLimit = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_capacity_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
capacityOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
case 'general_storage_available_override_bytes':
|
||||
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||
availableOverrideDb = parsedValue;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage settings read error:', error.message);
|
||||
logger.error('Storage settings read error:', error.message);
|
||||
}
|
||||
|
||||
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
|
||||
@@ -1407,8 +1393,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
|
||||
disk_override_source: overrideSource
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Storage info error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch storage information' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch storage information');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1445,8 +1430,7 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
|
||||
|
||||
res.json({ faviconUrl });
|
||||
} catch (error) {
|
||||
console.error('Error uploading favicon:', error);
|
||||
res.status(500).json({ error: 'Failed to upload favicon' });
|
||||
errorResponse(res, error, 500, 'Failed to upload favicon');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1509,8 +1493,7 @@ router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit')
|
||||
|
||||
res.json({ message: 'Rate limit settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Rate limit settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update rate limit settings' });
|
||||
errorResponse(res, error, 500, 'Failed to update rate limit settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1530,8 +1513,7 @@ router.get('/public-site/default', adminAuth, requirePermission('settings.view')
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load public site defaults:', error);
|
||||
res.status(500).json({ error: 'Failed to load defaults' });
|
||||
errorResponse(res, error, 500, 'Failed to load defaults');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1584,8 +1566,7 @@ router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'),
|
||||
branding: defaults.branding
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to reset public site template:', error);
|
||||
res.status(500).json({ error: 'Failed to reset template' });
|
||||
errorResponse(res, error, 500, 'Failed to reset template');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
|
||||
const packageJson = JSON.parse(packageContent);
|
||||
backendVersion = packageJson.version || '1.0.0';
|
||||
} catch (err) {
|
||||
console.error('Could not read package.json:', err);
|
||||
logger.error('Could not read package.json:', err);
|
||||
}
|
||||
|
||||
const channel = getCurrentChannel(backendVersion);
|
||||
@@ -42,7 +42,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
|
||||
channel: channel
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching version:', error);
|
||||
logger.error('Error fetching version:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch version information' });
|
||||
}
|
||||
});
|
||||
@@ -231,7 +231,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
`, [dbName]);
|
||||
dbSize = result.rows[0]?.size || 0;
|
||||
} catch (error) {
|
||||
console.error('Error getting PostgreSQL database size:', error);
|
||||
logger.error('Error getting PostgreSQL database size:', error);
|
||||
}
|
||||
} else {
|
||||
// SQLite - check file size
|
||||
@@ -240,7 +240,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
const stats = await fs.stat(dbPath);
|
||||
dbSize = stats.size;
|
||||
} catch (error) {
|
||||
console.error('Error getting SQLite database size:', error);
|
||||
logger.error('Error getting SQLite database size:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
logger.error('Archive file not found:', archive.archive_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,7 +345,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error fetching system status:', error);
|
||||
logger.error('Error fetching system status:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch system status' });
|
||||
}
|
||||
});
|
||||
@@ -407,7 +407,7 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
|
||||
timestamp: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching database info:', error);
|
||||
logger.error('Error fetching database info:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch database information' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -254,6 +254,19 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
|
||||
// enabled state on the next SEED_VERSION bump (review nit #1).
|
||||
if (await hasColumnCached('workflows', 'admin_toggled_at')) patch.admin_toggled_at = db.fn.now();
|
||||
await db('workflows').where({ id }).update(patch);
|
||||
// Turning dunning ON enrolls existing open/unpaid invoices (anchored to
|
||||
// their due date) so it starts chasing current debtors, not only invoices
|
||||
// sent after enabling (#750). Scoped to this flow's id so the backfill only
|
||||
// enrolls dunning, not any custom invoice.sent flow. Best-effort — never
|
||||
// fail the toggle over it.
|
||||
if (enabled && wf.builtin_key === 'invoice_dunning') {
|
||||
try {
|
||||
const n = await require('../services/workflows').backfillDunningRuns(id);
|
||||
require('../utils/logger').info('[workflow] dunning enabled — enrolled existing invoices', { enrolled: n });
|
||||
} catch (e) {
|
||||
require('../utils/logger').warn('[workflow] dunning backfill failed', { error: e.message });
|
||||
}
|
||||
}
|
||||
res.json({ id, enabled });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
+185
-53
@@ -2,9 +2,10 @@ const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { db } = require('../database/db');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const {
|
||||
trackFailedAttempt,
|
||||
trackSuccessfulLogin,
|
||||
@@ -14,7 +15,9 @@ const {
|
||||
} = require('../utils/authSecurity');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { timingSafeEqualStr } = require('../utils/timingSafe');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const {
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
@@ -32,6 +35,49 @@ const {
|
||||
} = require('../utils/passwordValidation');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Finish a successful admin login: reset the lockout counter, stamp
|
||||
* last_login, mint the 24h admin JWT, set the HttpOnly cookie, and return the
|
||||
* user payload. Shared by the direct (no-MFA) path and the MFA-verify path so
|
||||
* both produce an identical session. `lockoutKey` is the identifier the user
|
||||
* typed (username or email) so success/failure tracking stays in one bucket.
|
||||
*/
|
||||
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
|
||||
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name,
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Admin login with enhanced security
|
||||
router.post('/admin/login', [
|
||||
body('username').notEmpty().trim(),
|
||||
@@ -94,46 +140,128 @@ router.post('/admin/login', [
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// Successful login
|
||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||
|
||||
// Update last login and login metadata
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
});
|
||||
|
||||
// Generate token with additional claims including role
|
||||
const token = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
type: 'admin',
|
||||
role: admin.role_name, // Add role to JWT
|
||||
ip: ipAddress,
|
||||
loginTime: Date.now()
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
// Token is delivered via HttpOnly cookie only (not in response body)
|
||||
res.json({
|
||||
user: {
|
||||
// Second factor: if this admin has TOTP enabled, do NOT complete the login
|
||||
// yet. Issue a short-lived, single-purpose mfa_pending token and require the
|
||||
// code via /admin/login/mfa. We deliberately don't reset the lockout counter
|
||||
// (trackSuccessfulLogin) or stamp last_login until the second factor passes,
|
||||
// so MFA brute-force is still gated by the account lockout. `loginId` carries
|
||||
// the typed identifier so the verify step tracks the same lockout bucket.
|
||||
if (mfaService.isEnrolled(admin)) {
|
||||
const mfaToken = jwt.sign({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
type: 'mfa_pending',
|
||||
loginId: username
|
||||
}, process.env.JWT_SECRET, {
|
||||
expiresIn: '5m',
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
return res.json({ mfaRequired: true, mfaToken });
|
||||
}
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, username);
|
||||
} catch (error) {
|
||||
logger.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
errorResponse(res, error, 500, 'Login failed');
|
||||
}
|
||||
});
|
||||
|
||||
// Second-factor verification. Exchanges the short-lived mfa_pending token
|
||||
// (from /admin/login) plus a TOTP or recovery code for a full admin session.
|
||||
router.post('/admin/login/mfa', [
|
||||
body('mfaToken').notEmpty(),
|
||||
body('code').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { mfaToken, code } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(401).json({
|
||||
error: 'Your verification session expired. Please sign in again.',
|
||||
code: 'MFA_SESSION_EXPIRED'
|
||||
});
|
||||
}
|
||||
|
||||
if (decoded.type !== 'mfa_pending') {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
const lockoutKey = decoded.loginId || decoded.username;
|
||||
const lockoutStatus = await checkAccountLockout(lockoutKey);
|
||||
if (lockoutStatus.isLocked) {
|
||||
return res.status(423).json({
|
||||
error: 'Account temporarily locked due to too many failed attempts',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const admin = await db('admin_users')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where('admin_users.id', decoded.id)
|
||||
.select(
|
||||
'admin_users.*',
|
||||
'roles.name as role_name',
|
||||
'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!admin || !admin.is_active || !mfaService.isEnrolled(admin)) {
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
|
||||
// TOTP first, then a one-time recovery code.
|
||||
let ok = mfaService.verifyTotpEncrypted(code, admin.two_factor_secret);
|
||||
let usedRecovery = false;
|
||||
let remainingHashes = null;
|
||||
if (!ok) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
const result = await mfaService.consumeRecoveryCode(code, stored);
|
||||
if (result.matched) {
|
||||
ok = true;
|
||||
usedRecovery = true;
|
||||
remainingHashes = result.remainingHashes;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid verification code', code: 'MFA_INVALID' });
|
||||
}
|
||||
|
||||
if (usedRecovery) {
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(remainingHashes),
|
||||
updated_at: new Date()
|
||||
});
|
||||
await logActivity('admin_mfa_recovery_used',
|
||||
{ admin_id: admin.id, remaining: remainingHashes.length },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
}
|
||||
|
||||
await logActivity('admin_mfa_login',
|
||||
{ admin_id: admin.id, method: usedRecovery ? 'recovery_code' : 'totp' },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
return await completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey);
|
||||
} catch (error) {
|
||||
logger.error('MFA verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,8 +303,7 @@ router.post('/logout', async (req, res) => {
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
errorResponse(res, error, 500, 'Logout failed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -288,8 +415,7 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Gallery verification error:', error);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
errorResponse(res, error, 500, 'Verification failed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -364,8 +490,7 @@ router.post('/gallery/:slug/client-login', [
|
||||
accessLevel: 'client'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Client login error:', error);
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
errorResponse(res, error, 500, 'Authentication failed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -413,11 +538,23 @@ router.post('/gallery/share-login', [
|
||||
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
if (!expectedToken || !timingSafeEqualStr(token, expectedToken)) {
|
||||
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
// The share link only proves the holder was given the link — it is NOT the
|
||||
// gallery password. For a password-protected gallery, minting a full
|
||||
// `type:'gallery'` token here would let anyone with the share URL bypass
|
||||
// the password entirely (GHSA-9hmx-68vc-qpqw). Signal that a password is
|
||||
// still required and return WITHOUT a token/cookie; the client then goes
|
||||
// through POST /gallery/verify, which does check the password.
|
||||
if (requiresPassword) {
|
||||
return res.json({ requires_password: true });
|
||||
}
|
||||
|
||||
const jwtToken = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
@@ -432,8 +569,6 @@ router.post('/gallery/share-login', [
|
||||
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
@@ -451,8 +586,7 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Share link authentication error:', error);
|
||||
res.status(500).json({ error: 'Share link login failed' });
|
||||
errorResponse(res, error, 500, 'Share link login failed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -467,8 +601,7 @@ router.post('/gallery/logout', async (req, res) => {
|
||||
clearGalleryAuthCookies(res, slug);
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Gallery logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
errorResponse(res, error, 500, 'Logout failed');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -682,8 +815,7 @@ router.post('/admin/change-password', [
|
||||
score: passwordValidation.score
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Password change error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
errorResponse(res, error, 500, 'Failed to change password');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { customerAuth } = require('../middleware/customerAuth');
|
||||
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
|
||||
@@ -117,8 +118,7 @@ router.get('/events', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Customer event list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load events' });
|
||||
errorResponse(res, error, 500, 'Failed to load events');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -225,8 +225,7 @@ router.get('/events/:slug/access-token', [
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Customer access-token exchange error:', error);
|
||||
res.status(500).json({ error: 'Failed to issue access token' });
|
||||
errorResponse(res, error, 500, 'Failed to issue access token');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -248,8 +247,7 @@ router.get('/profile', customerAuth, async (req, res) => {
|
||||
}
|
||||
res.json({ profile: shapeProfile(row) });
|
||||
} catch (error) {
|
||||
logger.error('Customer profile read error:', error);
|
||||
res.status(500).json({ error: 'Failed to load profile' });
|
||||
errorResponse(res, error, 500, 'Failed to load profile');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -316,8 +314,7 @@ router.put('/profile', [
|
||||
|
||||
res.json({ profile: shapeProfile(row) });
|
||||
} catch (error) {
|
||||
logger.error('Customer profile update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
errorResponse(res, error, 500, 'Failed to update profile');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -376,8 +373,7 @@ router.post('/profile/password', [
|
||||
|
||||
res.json({ message: 'Password updated' });
|
||||
} catch (error) {
|
||||
logger.error('Customer password change error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
errorResponse(res, error, 500, 'Failed to change password');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -457,8 +453,7 @@ router.get('/quotes', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Customer quotes list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load quotes' });
|
||||
errorResponse(res, error, 500, 'Failed to load quotes');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -545,8 +540,7 @@ router.get('/invoices', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Customer invoice list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load invoices' });
|
||||
errorResponse(res, error, 500, 'Failed to load invoices');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -582,8 +576,7 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.send(buf);
|
||||
} catch (error) {
|
||||
logger.error('Customer quote PDF error:', error);
|
||||
res.status(500).json({ error: 'Failed to render quote PDF' });
|
||||
errorResponse(res, error, 500, 'Failed to render quote PDF');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -614,8 +607,7 @@ router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
|
||||
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.send(buf);
|
||||
} catch (error) {
|
||||
logger.error('Customer invoice PDF error:', error);
|
||||
res.status(500).json({ error: 'Failed to render invoice PDF' });
|
||||
errorResponse(res, error, 500, 'Failed to render invoice PDF');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -679,8 +671,7 @@ router.get('/contracts', customerAuth, async (req, res) => {
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Customer contracts list error:', error);
|
||||
res.status(500).json({ error: 'Failed to load contracts' });
|
||||
errorResponse(res, error, 500, 'Failed to load contracts');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -717,8 +708,7 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
|
||||
res.set('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
} catch (error) {
|
||||
logger.error('Customer contract PDF error:', error);
|
||||
res.status(500).json({ error: 'Failed to render contract PDF' });
|
||||
errorResponse(res, error, 500, 'Failed to render contract PDF');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { slugify } = require('../utils/slug');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
|
||||
|
||||
// Whether the global "phone field" toggle (#322) is enabled. Same shape as
|
||||
// the helper in adminEvents.js — kept local so this route doesn't import
|
||||
// from a sibling route file.
|
||||
const isPhoneFieldEnabled = async () => {
|
||||
try {
|
||||
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
|
||||
if (!row) return false;
|
||||
let value = row.setting_value;
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch { /* keep raw */ }
|
||||
}
|
||||
return value === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('customer_phone').optional({ nullable: true, checkFalsy: true })
|
||||
.isString().trim()
|
||||
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
|
||||
body('admin_email').isEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const requirePassword = parseBooleanInput(req.body.require_password, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
const phoneEnabled = await isPhoneFieldEnabled();
|
||||
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
if (requirePassword) {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug — slugify() handles accents (see #525).
|
||||
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link variants (auto-detects short URL preference)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventPath = path.join(storagePath, 'events/active', slug);
|
||||
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
|
||||
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
|
||||
|
||||
// Insert into database
|
||||
const insertResult = await db('events').insert({
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
...(customerPhone ? { customer_phone: customerPhone } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
// Queue creation email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName,
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
// WhatsApp gallery_ready notification (#647 follow-up). Mirrors the
|
||||
// adminEvents.js path: fires when the customer supplied a phone, the
|
||||
// feature is enabled, and a config exists. Non-fatal — a queue failure
|
||||
// must never block gallery creation.
|
||||
if (customerPhone) {
|
||||
try {
|
||||
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
|
||||
const waConfig = await getWhatsAppConfig();
|
||||
if (waConfig && waConfig.enabled) {
|
||||
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
|
||||
customer_name: customerName || '',
|
||||
event_name,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : '',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null,
|
||||
language: null,
|
||||
});
|
||||
}
|
||||
} catch (waError) {
|
||||
console.warn('Failed to queue WhatsApp notification on create', waError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Webhook lifecycle (#327). Legacy public endpoint — events go live
|
||||
// immediately so created + published fire together. Payload uses the
|
||||
// canonical event subject (#341) — every event.* webhook now includes
|
||||
// customer contact + share_token.
|
||||
try {
|
||||
const webhookService = require('../services/webhookService');
|
||||
const eventSubject = webhookService.buildEventSubject({
|
||||
id: eventId,
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
event_date,
|
||||
share_url: shareUrl,
|
||||
share_token: shareToken,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customerPhone,
|
||||
});
|
||||
await webhookService.fire('event.created', { event: eventSubject });
|
||||
await webhookService.fire('event.published', { event: eventSubject });
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: shareUrl,
|
||||
expires_at,
|
||||
require_password: requirePassword,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all events (admin)
|
||||
router.get('/', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { status = 'all' } = req.query;
|
||||
|
||||
let query = db('events').select('*');
|
||||
|
||||
if (status === 'active') {
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
} else if (status === 'archived') {
|
||||
query = query.where('is_archived', formatBoolean(true));
|
||||
}
|
||||
|
||||
const events = await query.orderBy('created_at', 'desc');
|
||||
|
||||
// Add photo counts
|
||||
for (const event of events) {
|
||||
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
|
||||
event.photo_count = photoCount.count;
|
||||
}
|
||||
|
||||
res.json(events.map(mapEventForApi));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch events' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, [
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
|
||||
body('require_password').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
delete updates.slug;
|
||||
delete updates.created_at;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
await db('events').where('id', id).update(updates);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event (mark as inactive)
|
||||
router.delete('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
}
|
||||
});
|
||||
|
||||
// Extend expiration
|
||||
router.post('/:id/extend', adminAuth, [
|
||||
body('days').isInt({ min: 1, max: 365 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { days } = req.body;
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const newExpiration = new Date(event.expires_at);
|
||||
newExpiration.setDate(newExpiration.getDate() + days);
|
||||
|
||||
await db('events').where('id', id).update({
|
||||
expires_at: newExpiration,
|
||||
is_active: formatBoolean(true) // Reactivate if expired
|
||||
});
|
||||
|
||||
res.json({ expires_at: newExpiration });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to extend expiration' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,9 +2,21 @@ const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
|
||||
// #756: a NULL per-event hero_logo_visible means "inherit the global
|
||||
// branding_logo_display_hero toggle". Only an explicit true/false is a
|
||||
// per-gallery override. `globalDefault` is branding_logo_display_hero
|
||||
// (defaults true when unset).
|
||||
function resolveHeroLogoVisible(perEvent, globalDefault) {
|
||||
if (perEvent === null || perEvent === undefined) {
|
||||
return globalDefault !== false;
|
||||
}
|
||||
return perEvent !== false && perEvent !== 0 && perEvent !== '0';
|
||||
}
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
|
||||
@@ -14,7 +26,7 @@ const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
|
||||
const downloadZipService = require('../services/downloadZipService');
|
||||
@@ -182,6 +194,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
res.json({
|
||||
event_name: event.event_name,
|
||||
@@ -199,8 +213,9 @@ router.get('/:slug/info', async (req, res) => {
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||
// #756: NULL per-event size inherits the global branding_logo_size.
|
||||
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
@@ -213,8 +228,7 @@ router.get('/:slug/info', async (req, res) => {
|
||||
promo_markdown: event.promo_markdown || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch gallery info' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch gallery info');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -636,7 +650,8 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// selection back to source files. Tied to the same toggle as downloads —
|
||||
// one switch controls both surfaces.
|
||||
const useOriginalFilenames = await getUseOriginalFilenames();
|
||||
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
res.json({
|
||||
event: {
|
||||
@@ -655,8 +670,8 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
watermark_text: req.event.watermark_text,
|
||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
||||
hero_logo_size: req.event.hero_logo_size || 'medium',
|
||||
hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible),
|
||||
hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||
hero_logo_url: req.event.hero_logo_url || null,
|
||||
header_style: req.event.header_style || 'standard',
|
||||
@@ -739,8 +754,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch photos' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch photos');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -772,8 +786,7 @@ router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (re
|
||||
|
||||
res.json({ message: 'Photo visibility updated', visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -801,8 +814,7 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
|
||||
|
||||
res.json({ message: `${count} photos updated`, visibility });
|
||||
} catch (error) {
|
||||
logger.error('Error bulk updating photo visibility:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo visibility' });
|
||||
errorResponse(res, error, 500, 'Failed to update photo visibility');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -916,13 +928,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error processing gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
errorResponse(res, error, 500, 'Failed to download photo');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1093,12 +1099,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
|
||||
action: 'download_all'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating bulk gallery download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to create download archive' });
|
||||
errorResponse(res, error, 500, 'Failed to create download archive');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1215,12 +1216,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
|
||||
action: 'download_selected'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in download-selected:', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download selected photos' });
|
||||
errorResponse(res, error, 500, 'Failed to download selected photos');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1452,13 +1448,7 @@ router.get('/:slug/photo/:photoId',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving photo:', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve photo' });
|
||||
errorResponse(res, error, 500, 'Failed to serve photo');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1549,12 +1539,7 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
stream.pipe(res);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving thumbnail:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
errorResponse(res, error, 500, 'Failed to serve thumbnail');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1763,8 +1748,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
|
||||
identity_mode: settings.identity_mode || 'simple'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching feedback settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch feedback settings' });
|
||||
errorResponse(res, error, 500, 'Failed to fetch feedback settings');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1826,8 +1810,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
|
||||
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
|
||||
logger.info('Created temp upload directory:', tempUploadDir);
|
||||
} catch (mkdirErr) {
|
||||
logger.error('Failed to create temp upload directory:', mkdirErr);
|
||||
return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' });
|
||||
return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1877,7 +1860,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
|
||||
// Handle upload
|
||||
upload(req, res, async (err) => {
|
||||
if (err) {
|
||||
console.error('Upload error:', err);
|
||||
logger.error('Upload error:', err);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
@@ -1911,13 +1894,11 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
});
|
||||
} catch (processError) {
|
||||
console.error('Photo processing error:', processError);
|
||||
res.status(500).json({ error: 'Failed to process photos' });
|
||||
errorResponse(res, processError, 500, 'Failed to process photos');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload route error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
errorResponse(res, error, 500, 'Failed to upload photos');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1955,7 +1936,7 @@ router.get('/:slug/css-template', async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
|
||||
res.send(template.css_content);
|
||||
} catch (error) {
|
||||
console.error('Get CSS template error:', error);
|
||||
logger.error('Get CSS template error:', error);
|
||||
res.status(500).send('/* Error loading template */');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { photoAuth } = require('../middleware/photoAuth');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const { resolveGuest } = require('../middleware/guestAuth');
|
||||
|
||||
@@ -8,6 +8,8 @@ const { getStorage } = require('../services/storage');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { withLocalCopy } = require('../services/imageProcessor');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
const { timingSafeEqualStr } = require('../utils/timingSafe');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -32,9 +34,9 @@ function verifyImageToken(token) {
|
||||
const decoded = Buffer.from(data, 'base64').toString();
|
||||
const [photoId, expires] = decoded.split(':');
|
||||
|
||||
// Verify signature
|
||||
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
|
||||
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
|
||||
if (signature !== expectedSignature) {
|
||||
if (!timingSafeEqualStr(signature, expectedSignature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -164,7 +166,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
|
||||
res.send(finalImage);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error serving protected image:', error);
|
||||
logger.error('Error serving protected image:', error);
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
});
|
||||
@@ -208,7 +210,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating secure token:', error);
|
||||
logger.error('Error generating secure token:', error);
|
||||
res.status(500).json({ error: 'Failed to generate token' });
|
||||
}
|
||||
});
|
||||
@@ -242,7 +244,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating signed URL:', error);
|
||||
logger.error('Error generating signed URL:', error);
|
||||
res.status(500).json({ error: 'Failed to generate URL' });
|
||||
}
|
||||
});
|
||||
@@ -304,7 +306,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
|
||||
res.send(imageBuffer);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error serving signed image:', error);
|
||||
logger.error('Error serving signed image:', error);
|
||||
res.status(500).json({ error: 'Failed to serve image' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get public CMS page
|
||||
@@ -41,7 +42,7 @@ router.get('/pages/:slug', async (req, res) => {
|
||||
updated_at: page.updated_at
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching public CMS page:', error);
|
||||
logger.error('Error fetching public CMS page:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch page' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
// Get public settings (branding and theme)
|
||||
@@ -205,7 +206,7 @@ router.get('/', async (req, res) => {
|
||||
|
||||
res.json(publicSettings);
|
||||
} catch (error) {
|
||||
console.error('Public settings fetch error:', error);
|
||||
logger.error('Public settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -23,7 +23,7 @@ router.post('/:slug/generate-token', async (req, res, next) => {
|
||||
// Add slug to request for verifyGalleryAccess
|
||||
req.requestedSlug = req.params.slug;
|
||||
next();
|
||||
}, verifyGalleryAccess, async (req, res) => {
|
||||
}, verifyGalleryAccess, denySlideshowToken, async (req, res) => {
|
||||
try {
|
||||
const { photoId, accessType = 'view' } = req.body;
|
||||
|
||||
@@ -273,6 +273,7 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
next();
|
||||
},
|
||||
verifyGalleryAccess,
|
||||
denySlideshowToken,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId, token } = req.params;
|
||||
@@ -412,7 +413,7 @@ async function getRecentAccessStats() {
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
console.error('Error getting recent access stats:', error);
|
||||
logger.error('Error getting recent access stats:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -440,7 +441,7 @@ async function getSuspiciousActivityStats() {
|
||||
uniqueIPs: parseInt(uniqueIPs.count)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting suspicious activity stats:', error);
|
||||
logger.error('Error getting suspicious activity stats:', error);
|
||||
return { suspiciousEvents: 0, uniqueIPs: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
// Public first-run setup endpoints. UNAUTHENTICATED by design — they exist so a
|
||||
// fresh instance can create its first admin from the browser (no ADMIN_PASSWORD
|
||||
// in .env). Both are hard-gated on "no admin exists yet", and POST /admin also
|
||||
// requires the one-time setup token, so they self-close after setup. The POST is
|
||||
// rate-limited at the mount point in server.js (authRateLimiter).
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const setupService = require('../services/setupService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
res.json(await setupService.getSetupStatus());
|
||||
} catch (err) {
|
||||
logger.error('[setup] status failed', { error: err.message });
|
||||
res.status(500).json({ error: 'Failed to read setup status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Step-1 pre-flight: validate the setup token without consuming it, so the
|
||||
// two-step wizard can block "Continue" on a wrong token. Rate-limited at the
|
||||
// mount point in server.js (authRateLimiter), same as POST /admin.
|
||||
router.post('/verify-token', [
|
||||
body('token').notEmpty().withMessage('Setup token is required'),
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const valid = await setupService.verifySetupToken(req.body.token);
|
||||
if (!valid) {
|
||||
return res.status(400).json({ error: 'Invalid setup token', field: 'token' });
|
||||
}
|
||||
return res.json({ valid: true });
|
||||
} catch (err) {
|
||||
if (err.statusCode) {
|
||||
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
|
||||
}
|
||||
logger.error('[setup] verifyToken failed', { error: err.message });
|
||||
return res.status(500).json({ error: 'Setup failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/admin', [
|
||||
body('token').notEmpty().withMessage('Setup token is required'),
|
||||
body('email').isEmail().withMessage('A valid email is required'),
|
||||
body('password').notEmpty().withMessage('Password is required'),
|
||||
], async (req, res) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
try {
|
||||
const { token, email, password } = req.body;
|
||||
const result = await setupService.createInitialAdmin({
|
||||
token,
|
||||
email,
|
||||
password,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
setAdminAuthCookie(res, result.token);
|
||||
// Token delivered via HttpOnly cookie only (mirrors admin login).
|
||||
res.status(201).json({ user: result.user });
|
||||
} catch (err) {
|
||||
if (err.statusCode) {
|
||||
// `field` (token/email/password) lets the client show a translated
|
||||
// message instead of rendering the raw English error verbatim.
|
||||
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
|
||||
}
|
||||
logger.error('[setup] createInitialAdmin failed', { error: err.message });
|
||||
return res.status(500).json({ error: 'Setup failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user