diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1194a929..15bc747ba 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,8 @@ "features": { "ghcr.io/devcontainers/features/sshd:1": { "version": "latest" - } + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, "forwardPorts": [8890, 3449, 4334], "portsAttributes": { @@ -17,7 +18,8 @@ }, "3449": { "label": "Figwheel", - "onAutoForward": "silent" + "onAutoForward": "silent", + "visibility": "public" }, "4334": { "label": "Datomic", diff --git a/.env.example b/.env.example index 8a95039de..9516f341e 100644 --- a/.env.example +++ b/.env.example @@ -5,23 +5,30 @@ # cp .env.example .env # # Or run the setup script to generate .env with secure random values: -# ./docker-setup.sh +# ./run # ============================================================================ # --- Application --- PORT=8890 +TZ=America/Chicago -# Image tag for docker-compose.yaml (pre-built images only, ignored by build compose) -# ORCPUB_TAG=release-v2.5.0.27 +# Docker image names. Set these if you tag your own builds +# (e.g., docker build -t dmv:2.6.0.0 .) so compose/swarm finds them. +# Defaults: orcpub-app, orcpub-datomic +# ORCPUB_IMAGE=dmv:2.6.0.0 +# DATOMIC_IMAGE=orcpub-datomic # --- Datomic Database --- # Datomic Pro with dev storage protocol (required for Java 21 support) # ADMIN_PASSWORD secures the Datomic admin interface -# DATOMIC_PASSWORD is used by the application to connect to Datomic -# The password in DATOMIC_URL must match DATOMIC_PASSWORD +# DATOMIC_PASSWORD is used by both the transactor and the app to authenticate. +# The app reads it separately and appends ?password= to DATOMIC_URL at startup, +# so you don't need to embed the password in the URL. +# DATOMIC_URL should NOT contain ?password= (the app adds it from DATOMIC_PASSWORD). +# Old URLs with ?password= still work — the embedded password takes priority. ADMIN_PASSWORD=change-me-admin DATOMIC_PASSWORD=change-me-datomic -DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=change-me-datomic +DATOMIC_URL=datomic:dev://datomic:4334/orcpub # --- Transactor Tuning --- # These rarely need changing. See docker/transactor.properties.template. @@ -43,6 +50,7 @@ CSP_POLICY=strict # Dev mode: CSP violations are logged (Report-Only) instead of blocked, # allowing Figwheel hot-reload scripts to execute. +# Must be the string "true" (case-insensitive). Any other value is treated as false. DEV_MODE=true # --- Plugins --- @@ -54,6 +62,15 @@ DEV_MODE=true # Defaults to project logs/ if unset LOG_DIR= +# --- Remote Dev (optional) --- +# Figwheel WebSocket port for hot-reload (default: 3449) +# FIGWHEEL_PORT=3449 + +# Figwheel WebSocket URL override for remote environments (Gitpod, tunnels, etc.) +# Auto-detected for GitHub Codespaces — only set this for other remote setups. +# Example: wss://my-remote-host:3449/figwheel-connect +# FIGWHEEL_CONNECT_URL= + # --- Email (SMTP) --- # Leave EMAIL_SERVER_URL empty to disable email functionality EMAIL_SERVER_URL= @@ -65,6 +82,38 @@ EMAIL_ERRORS_TO= EMAIL_SSL=FALSE EMAIL_TLS=FALSE +# --- Branding (optional) --- +# Override app identity for forks. All have sensible defaults in fork/branding.clj. +# APP_NAME=Dungeon Master's Vault +# APP_URL=https://www.dungeonmastersvault.com +# APP_LOGO_PATH=/image/dmv-logo.svg +# APP_OG_IMAGE=/image/dmv-box-logo.png +# APP_COPYRIGHT_HOLDER=Dungeon Master's Vault +# APP_COPYRIGHT_YEAR=2026 +# APP_EMAIL_SENDER_NAME=Dungeon Master's Vault Team +# APP_PAGE_TITLE=Dungeon Master's Vault +# APP_TAGLINE=A D&D 5e character builder and resource compendium +# APP_SUPPORT_EMAIL=thDM@dungeonmastersvault.com +# APP_HELP_URL=https://www.dungeonmastersvault.com/help/ + +# --- Social Links (optional) --- +# Shown in app header. Leave empty to hide a link. +# APP_SOCIAL_PATREON=https://www.patreon.com/DungeonMastersVault +# APP_SOCIAL_FACEBOOK=https://www.facebook.com/groups/252484128656613/ +# APP_SOCIAL_BLUESKY= +# APP_SOCIAL_TWITTER=https://twitter.com/thdmv +# APP_SOCIAL_REDDIT= +# APP_SOCIAL_DISCORD= + +# --- Analytics & Ads (optional) --- +# Third-party integrations. Leave empty to disable. +# Server-side (fork/integrations.clj) loads SDK scripts; client-side (fork/integrations.cljs) +# provides in-app hooks. CSP domains are auto-derived from these values. +# MATOMO_URL=https://analytics.dungeonmastersvault.com +# MATOMO_SITE_ID=1 +# ADSENSE_CLIENT=ca-pub-3202063096003962 +# ADSENSE_SLOT=4970831358 + # --- Initial Admin User (optional) --- # Set these then run: ./docker-user.sh init # Safe to run multiple times — duplicates are skipped. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..bc326d5ab --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Fork override files — keep each branch's version during merges. +# DMV has real implementations, public/breaking has stubs. +src/clj/orcpub/fork/** merge=ours +src/cljc/orcpub/fork/** merge=ours +src/cljs/orcpub/fork/** merge=ours + +# DMV branding assets — keep each branch's version during merges. +# These only exist on dmv/ and should never appear on public branches. +resources/public/image/*DM* merge=ours +resources/public/image/patron_button* merge=ours +resources/public/js/dungeonmastersvault* merge=ours +resources/public/ads.txt merge=ours +deploy/error/** merge=ours +deploy/maintenance/** merge=ours diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 698c1f670..45352ad36 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -13,7 +13,6 @@ on: permissions: contents: read - pull-requests: write checks: write jobs: @@ -162,78 +161,6 @@ jobs: exit 1 fi - - name: Post PR comment with results - if: github.event_name == 'pull_request' && always() - continue-on-error: true # Fork PRs get read-only GITHUB_TOKEN - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - - const lintOutput = fs.existsSync('lint-output.txt') ? fs.readFileSync('lint-output.txt', 'utf8') : 'No output'; - const testOutput = fs.existsSync('test-output.txt') ? fs.readFileSync('test-output.txt', 'utf8') : 'No output'; - const cljsOutput = fs.existsSync('cljs-output.txt') ? fs.readFileSync('cljs-output.txt', 'utf8') : 'No output'; - - const lintOk = '${{ steps.lint.outcome }}' === 'success'; - const testOk = '${{ steps.test.outcome }}' === 'success'; - const cljsOk = '${{ steps.cljs.outcome }}' === 'success'; - const allOk = lintOk && testOk && cljsOk; - const stackLabel = '${{ needs.detect-stack.outputs.stack-label }}'; - - const testMatch = testOutput.match(/Ran (\d+) tests containing (\d+) assertions/); - const testSummary = testMatch ? `${testMatch[1]} tests, ${testMatch[2]} assertions` : 'See logs'; - - const status = allOk ? 'All checks passed' : 'Some checks failed'; - const body = [ - `## ${status}`, - '', - '| Check | Status | Details |', - '|-------|--------|---------|', - `| Lint | ${lintOk ? 'Pass' : 'Fail'} | ${lintOk ? 'No errors' : 'See workflow logs'} |`, - `| Tests | ${testOk ? 'Pass' : 'Fail'} | ${testOk ? testSummary : 'See workflow logs'} |`, - `| CLJS Build | ${cljsOk ? 'Pass' : 'Fail'} | ${cljsOk ? 'Compiled' : 'See workflow logs'} |`, - '', - `**Stack**: ${stackLabel}`, - '', - '
', - 'Full test output', - '', - '```', - testOutput.slice(-2000), - '```', - '', - '
', - '', - '---', - `*[Workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*` - ].join('\n'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number - }); - - const botComment = comments.find(c => - c.user.type === 'Bot' && (c.body.includes('All checks passed') || c.body.includes('Some checks failed')) - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: body - }); - } - - name: Upload artifacts on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index d16f0b058..5f3d91390 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -3,7 +3,7 @@ # # Java 8 (legacy): Pulls pre-built images from Docker Hub — skipped gracefully # when images are unavailable. -# Java 21 (modern): Builds from source using docker-compose-build.yaml with +# Java 21 (modern): Builds from source using docker-compose.yaml (--build) with # Datomic Pro and eclipse-temurin:21. name: Docker Integration Test @@ -14,7 +14,7 @@ on: paths: - 'docker/**' - 'docker-compose*.yaml' - - 'docker-setup.sh' + - 'run' - 'docker-user.sh' - 'deploy/**' - '.github/workflows/docker-integration.yml' @@ -38,7 +38,7 @@ jobs: if [ -f "dev.cljs.edn" ]; then echo "Detected: Java 21 / Datomic Pro / figwheel-main → build from source" echo "build-mode=build" >> $GITHUB_OUTPUT - echo "compose-file=docker-compose-build.yaml" >> $GITHUB_OUTPUT + echo "compose-file=docker-compose.yaml" >> $GITHUB_OUTPUT echo "stack-label=Java 21 / Datomic Pro (build)" >> $GITHUB_OUTPUT else echo "Detected: Java 8 / Datomic Free / cljsbuild → pull pre-built" @@ -65,23 +65,38 @@ jobs: - name: Lint shell scripts run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck docker-setup.sh docker-user.sh + shellcheck -x --severity=warning run docker-user.sh scripts/swarm.sh - - name: Run docker-setup.sh --auto + - name: Run run --auto run: | - ./docker-setup.sh --auto + ./run --auto + # Naked ./run runs full pipeline (setup→build→up). Tear down containers + # and wipe H2 data so CI's own build/start steps get a clean slate. + docker compose down -v 2>/dev/null || true + sudo rm -rf data/ echo "--- Generated .env (secrets redacted) ---" sed 's/=.*/=***/' .env - name: Validate .env password consistency run: | PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) - URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') - if [ "$PW" != "$URL_PW" ]; then - echo "FAIL: DATOMIC_PASSWORD and DATOMIC_URL password mismatch" - exit 1 + URL=$(grep '^DATOMIC_URL=' .env | cut -d= -f2-) + if echo "$URL" | grep -q 'password='; then + # Legacy format: password embedded in URL — must match DATOMIC_PASSWORD + URL_PW=$(echo "$URL" | sed 's/.*password=//') + if [ "$PW" != "$URL_PW" ]; then + echo "FAIL: DATOMIC_PASSWORD and DATOMIC_URL password mismatch" + exit 1 + fi + echo "OK: Passwords match (embedded in URL)" + else + # New format: password separate — just verify both exist + if [ -z "$PW" ]; then + echo "FAIL: DATOMIC_PASSWORD not set" + exit 1 + fi + echo "OK: DATOMIC_PASSWORD set, URL clean (password appended at runtime by config.clj)" fi - echo "OK: Passwords match" - name: Test — setup --force preserves existing values run: | @@ -91,7 +106,10 @@ jobs: ORIG_SIG=$(grep '^SIGNATURE=' .env | cut -d= -f2) # Re-run with --force --auto (should regenerate) - ./docker-setup.sh --auto --force + # Tear down first — naked ./run creates containers + H2 data with old passwords + docker compose down -v 2>/dev/null || true + sudo rm -rf data/ + ./run --auto --force # Verify .env was regenerated (new passwords, since --auto generates fresh ones) NEW_ADMIN=$(grep '^ADMIN_PASSWORD=' .env | cut -d= -f2) @@ -102,14 +120,17 @@ jobs: grep -q '^SIGNATURE=' .env || { echo "FAIL: SIGNATURE missing"; exit 1; } grep -q '^PORT=' .env || { echo "FAIL: PORT missing"; exit 1; } - # Re-check password consistency after --force + # Re-check password exists after --force PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) - URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') - if [ "$PW" != "$URL_PW" ]; then - echo "FAIL: Password mismatch after --force re-run" + if [ -z "$PW" ]; then + echo "FAIL: DATOMIC_PASSWORD missing after --force re-run" exit 1 fi - echo "OK: --force regenerated .env with consistent passwords" + echo "OK: --force regenerated .env with DATOMIC_PASSWORD set" + + # Clean up containers/data from --force pipeline run + docker compose down -v 2>/dev/null || true + sudo rm -rf data/ # ── Container image acquisition ───────────────────────────── # Java 21: build from source (no pre-built images for new stack) @@ -130,6 +151,10 @@ jobs: echo "=== Building orcpub (app) ===" docker build --target app -t orcpub-app -f docker/Dockerfile . echo "=== Build complete ===" + # Set image env vars so compose uses locally-built images + # instead of pulling old Datomic Free images from Docker Hub + echo "ORCPUB_IMAGE=orcpub-app" >> "$GITHUB_ENV" + echo "DATOMIC_IMAGE=orcpub-datomic" >> "$GITHUB_ENV" - name: Pull container images (Java 8) id: pull diff --git a/.gitignore b/.gitignore index 7375787d0..8e7616ba8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,14 @@ -# Ignore environment file (secrets/config) +# Ignore environment file and Docker secret files (passwords) .env +.env.secrets.backup +/secrets/ +docker-compose.secrets.yaml +docker-compose.swarm.yaml +docker-compose.swarm.yaml.backup.* +.env.portainer +.env.backup.* +transactor.properties.reference .editorconfig -.gitattributes /resources/public/css/compiled /resources/public/js/compiled /resources/*_backup.pdf @@ -68,6 +75,15 @@ cljs-test-runner-out # Claude Code local data (conversation history, credentials) .claude-data/ +# Agentic/AI tool files — belong in dotfiles or agents/ branch, not code branches +.claude/ + +# NewRelic (DMV-specific, contains license key) +newrelic* + +# Transactor properties (may contain hardcoded passwords) +deploy/transactor.properties + # Ignore all log files in logs/ logs/ diff --git a/README.md b/README.md index 77b360d03..04d216652 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,7 @@ For running your own production instance: ```bash git clone https://github.com/orcpub/orcpub.git && cd orcpub -./docker-setup.sh # generates .env, SSL certs, directories -docker compose up -d # pull images and start +./run # setup, build, and start (interactive) ./docker-user.sh init # create admin from .env settings ``` @@ -221,7 +220,7 @@ For the full list, see [docs/migration/dev-tooling.md](docs/migration/dev-toolin Run these before committing: ```bash -# Server-side tests (74 tests, 237 assertions) +# Server-side tests (210 tests, 963 assertions) lein test # Linter (0 errors expected; warnings are from third-party libs) @@ -305,8 +304,8 @@ For self-hosting a production instance. ```bash git clone https://github.com/orcpub/orcpub.git && cd orcpub -# Interactive setup — generates .env, SSL certs, and directories -./docker-setup.sh +# Full pipeline — setup, build, and start (interactive) +./run # Pull pre-built images and start docker compose up -d @@ -321,8 +320,7 @@ Visit `https://localhost` when running. To build from source instead of pulling images: ```bash -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d ``` For environment variable details, see [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md). @@ -349,8 +347,7 @@ The storage protocols (`datomic:free://` vs `datomic:dev://`) use different form ```bash ./docker-migrate.sh backup # With old stack running docker compose down -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d ./docker-migrate.sh restore # After new stack is healthy ./docker-migrate.sh verify ``` @@ -388,7 +385,7 @@ Place your `.orcbrew` file at `./deploy/homebrew/homebrew.orcbrew` — it loads |--------|---------| | `scripts/migrate-db.sh` | Migrate data from Datomic Free to Pro (bare metal) | | `docker-migrate.sh` | Migrate data from Datomic Free to Pro (Docker) | -| `docker-setup.sh` | Generate `.env`, SSL certs, and directories | +| `run` | Setup, build, and deploy — full pipeline or individual steps | | `docker-user.sh` | Create, verify, and list users in the database | --- diff --git a/build.bat b/build.bat new file mode 100644 index 000000000..52b9e103b --- /dev/null +++ b/build.bat @@ -0,0 +1,9 @@ +rem Windows build script + +CALL lein clean +rem # Step 1: Compile CLJS +CALL lein fig:prod +rem # Step 2: AOT compile (with uberjar-package to skip clean) +CALL lein with-profile uberjar,uberjar-package compile +rem # Step 3: Package jar (uberjar-package prevents re-cleaning) +CALL lein with-profile uberjar,uberjar-package uberjar \ No newline at end of file diff --git a/deploy/nginx-dev.conf b/deploy/nginx-dev.conf new file mode 100644 index 000000000..67440fe76 --- /dev/null +++ b/deploy/nginx-dev.conf @@ -0,0 +1,65 @@ +server { + listen 80; + listen 443 ssl; + server_name localhost; + + ssl_certificate /etc/nginx/snakeoil.crt; + ssl_certificate_key /etc/nginx/snakeoil.key; + #charset koi8-r; + #access_log /var/log/nginx/host.access.log main; + + location / { + client_max_body_size 10m; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://orcpub-dev:8890; + } + + location /generator/ { + client_max_body_size 10m; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://dndgenerator-dev:80; + } + + + #error_page 404 /404.html; + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + location = /bg.jpg { + root /usr/share/nginx/html; + } + location /homebrew.orcbrew { + root /usr/share/nginx/html/homebrew; + } + + # proxy the PHP scripts to Apache listening on 127.0.0.1:80 + # + #location ~ \.php$ { + # proxy_pass http://127.0.0.1; + #} + + # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 + # + #location ~ \.php$ { + # root html; + # fastcgi_pass 127.0.0.1:9000; + # fastcgi_index index.php; + # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; + # include fastcgi_params; + #} + + # deny access to .htaccess files, if Apache's document root + # concurs with nginx's one + # + #location ~ /\.ht { + # deny all; + #} +} \ No newline at end of file diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 7d887474e..6a9532dc6 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -13,11 +13,17 @@ server { #charset koi8-r; #access_log /var/log/nginx/host.access.log main; + # Resolve upstream at request time (not startup) so nginx survives + # Swarm's lack of depends_on — the app may not be in DNS yet. + # Docker's embedded DNS is at 127.0.0.11. + resolver 127.0.0.11 valid=30s; + set $upstream_app http://orcpub:${ORCPUB_PORT}; + location / { proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; - proxy_pass http://orcpub:${ORCPUB_PORT}; + proxy_pass $upstream_app; } #error_page 404 /404.html; diff --git a/deploy/start.sh b/deploy/start.sh index 997810e9f..805bc9762 100644 --- a/deploy/start.sh +++ b/deploy/start.sh @@ -5,9 +5,14 @@ # Substitutes secrets into transactor.properties.template and launches the # transactor. Uses pure bash sed (no envsubst/gettext — Alpine doesn't have it). # -# Required env vars: ADMIN_PASSWORD, DATOMIC_PASSWORD -# Optional env vars: ALT_HOST, ENCRYPT_CHANNEL, -# ADMIN_PASSWORD_OLD, DATOMIC_PASSWORD_OLD +# Secret resolution order (per variable): +# 1. Docker secret file at /run/secrets/ (Swarm / compose secrets) +# 2. Environment variable (compose environment / .env) +# 3. (none — required vars exit 1 if missing) +# +# Required: ADMIN_PASSWORD, DATOMIC_PASSWORD +# Optional: ALT_HOST, ENCRYPT_CHANNEL, +# ADMIN_PASSWORD_OLD, DATOMIC_PASSWORD_OLD # =========================================================================== set -euo pipefail @@ -15,17 +20,39 @@ set -euo pipefail TEMPLATE="/datomic/transactor.properties.template" OUTPUT="/datomic/transactor.properties" +# --- Read Docker secrets (if mounted) ---------------------------------------- +# Docker secrets are files at /run/secrets/. When present, the secret +# file OVERRIDES any env var — this matches the resolution order documented +# above and in config.clj. If no secret file exists, the env var is used as-is. + +read_secret() { + local var_name="$1" + local secret_file="/run/secrets/${2:-$(echo "$var_name" | tr '[:upper:]' '[:lower:]')}" + if [ -f "$secret_file" ]; then + local val + val=$(<"$secret_file") + # Strip trailing whitespace: \r (Windows), \n (common in secret files) + val="${val%$'\r\n'}" + val="${val%$'\n'}" + val="${val%$'\r'}" + export "$var_name=$val" + fi +} + +read_secret ADMIN_PASSWORD +read_secret DATOMIC_PASSWORD +read_secret ADMIN_PASSWORD_OLD +read_secret DATOMIC_PASSWORD_OLD + # --- Validate required secrets ------------------------------------------------ if [ -z "${ADMIN_PASSWORD:-}" ]; then - echo "ERROR: ADMIN_PASSWORD not set." - echo "See https://docs.datomic.com/on-prem/configuring-embedded.html#sec-2-1" + echo "ERROR: ADMIN_PASSWORD not set (checked env var and /run/secrets/admin_password)." exit 1 fi if [ -z "${DATOMIC_PASSWORD:-}" ]; then - echo "ERROR: DATOMIC_PASSWORD not set." - echo "See https://docs.datomic.com/on-prem/configuring-embedded.html#sec-2-1" + echo "ERROR: DATOMIC_PASSWORD not set (checked env var and /run/secrets/datomic_password)." exit 1 fi diff --git a/dev/user.clj b/dev/user.clj index 82ba7926e..2fba3ceb2 100644 --- a/dev/user.clj +++ b/dev/user.clj @@ -295,4 +295,4 @@ (System/exit 1))) ;; Datomic peer metrics thread is non-daemon and prevents clean JVM exit. ;; Force exit after successful command completion. - (System/exit 0)) + (System/exit 0)) \ No newline at end of file diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml deleted file mode 100644 index 186b85c87..000000000 --- a/docker-compose-build.yaml +++ /dev/null @@ -1,77 +0,0 @@ ---- -services: - orcpub: - image: orcpub-app - build: - context: . - dockerfile: docker/Dockerfile - target: app - environment: - PORT: ${PORT:-8890} - EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} - EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} - EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} - EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} - EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} - EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} - EMAIL_SSL: ${EMAIL_SSL:-FALSE} - EMAIL_TLS: ${EMAIL_TLS:-FALSE} - # Datomic Pro with dev storage protocol (required for Java 21 support) - DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub?password=change-me} - SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} - CSP_POLICY: ${CSP_POLICY:-strict} - DEV_MODE: ${DEV_MODE:-} - LOAD_HOMEBREW_URL: ${LOAD_HOMEBREW_URL:-} - depends_on: - datomic: - condition: service_healthy - healthcheck: - # BusyBox wget (Alpine): only -q and --spider are supported. - # Use 127.0.0.1 (not localhost) to avoid IPv4/IPv6 ambiguity. - # /health returns 200 OK — lighter than / which renders the full SPA page. - test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:${PORT:-8890}/health"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 60s - restart: always - datomic: - image: orcpub-datomic - build: - context: . - dockerfile: docker/Dockerfile - target: transactor - environment: - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} - DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} - ALT_HOST: ${ALT_HOST:-127.0.0.1} - ENCRYPT_CHANNEL: ${ENCRYPT_CHANNEL:-true} - volumes: - - ./data:/data - - ./logs:/log - - ./backups:/backups - healthcheck: - test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] - interval: 5s - timeout: 3s - retries: 30 - start_period: 40s - restart: always - web: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - environment: - # nginx:alpine runs envsubst on /etc/nginx/templates/*.template at startup. - # Only defined env vars are substituted — nginx's own $host, $scheme, etc. are safe. - ORCPUB_PORT: ${PORT:-8890} - volumes: - - ./deploy/nginx.conf.template:/etc/nginx/templates/default.conf.template - - ./deploy/snakeoil.crt:/etc/nginx/snakeoil.crt - - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key - - ./deploy/homebrew/:/usr/share/nginx/html/homebrew/ - depends_on: - orcpub: - condition: service_healthy - restart: always diff --git a/docker-compose.yaml b/docker-compose.yaml index 94fdc3cea..be568a6d9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,40 @@ --- +# ============================================================================ +# How variables work in this file +# ============================================================================ +# +# Each ${VAR:-default} reads from TWO places, in this order: +# 1. Shell environment variables (export VAR=value) +# 2. The .env file in this directory +# +# Shell env vars ALWAYS win over .env values. This means: +# - You can override any setting with: VAR=value docker compose up -d +# - If your shell already has a variable set (e.g. from a Codespace or +# .bashrc), it will override what's in .env — even if you didn't intend it. +# +# To check what compose actually resolves: +# docker compose config | grep DATOMIC_URL +# +# Recommended setup: +# 1. Run ./run to generate .env with secure passwords +# 2. Run: docker compose up --build -d +# 3. If a variable isn't being picked up from .env, check for a conflicting +# shell variable with: echo $DATOMIC_URL +# Clear it with: unset DATOMIC_URL +# +# See docs/DOCKER.md for the full quick-start guide. +# See docs/ENVIRONMENT.md for all available variables. +# ============================================================================ + services: orcpub: - # No :latest tag on Docker Hub — pin to newest published version. - # Override with ORCPUB_TAG env var for custom builds. - image: orcpub/orcpub:${ORCPUB_TAG:-release-v2.5.0.27} + # Build: docker compose up --build + # Override image: ORCPUB_IMAGE=registry/name:tag docker compose up + image: ${ORCPUB_IMAGE:-orcpub-app} + build: + context: . + dockerfile: docker/Dockerfile + target: app environment: PORT: ${PORT:-8890} EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} @@ -14,8 +45,12 @@ services: EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} EMAIL_SSL: ${EMAIL_SSL:-FALSE} EMAIL_TLS: ${EMAIL_TLS:-FALSE} - # Datomic Pro with dev storage protocol (required for Java 21 support) - DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub?password=change-me} + # Datomic Pro with dev storage protocol (required for Java 21 support). + # The hostname MUST be "datomic" (the compose service name), NOT "localhost". + # Password is NOT in the URL — the app reads DATOMIC_PASSWORD separately + # and appends it at startup. Old URLs with ?password= still work. + DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} CSP_POLICY: ${CSP_POLICY:-strict} DEV_MODE: ${DEV_MODE:-} @@ -34,10 +69,16 @@ services: start_period: 60s restart: always datomic: - image: orcpub/datomic:latest + image: ${DATOMIC_IMAGE:-orcpub-datomic} + build: + context: . + dockerfile: docker/Dockerfile + target: transactor environment: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} + # ALT_HOST: what the transactor advertises to peers for fallback connections. + # Default 127.0.0.1 works for single-host. Set to "datomic" for Swarm. ALT_HOST: ${ALT_HOST:-127.0.0.1} ENCRYPT_CHANNEL: ${ENCRYPT_CHANNEL:-true} volumes: @@ -69,3 +110,57 @@ services: orcpub: condition: service_healthy restart: always + +# --- Docker Secrets --- +# Uncomment to use Docker secrets instead of .env for passwords. +# Secrets are mounted as files at /run/secrets/ inside the container. +# Both deploy/start.sh (transactor) and the app (config.clj) check +# /run/secrets/ first, then fall back to environment variables. +# +# Option A: File-based secrets (works with plain docker compose, no Swarm) +# Each password goes in its own file instead of .env. Docker mounts +# them inside the container. Still plain files on your hard drive — +# but isolated with strict permissions instead of all in one .env. +# Create a secrets/ directory with one file per secret: +# mkdir -p secrets +# printf 'mypassword' > secrets/datomic_password +# printf 'mypassword' > secrets/admin_password +# printf 'mysecret' > secrets/signature +# chmod 600 secrets/* +# +# secrets: +# datomic_password: +# file: ./secrets/datomic_password +# admin_password: +# file: ./secrets/admin_password +# signature: +# file: ./secrets/signature +# +# Option B: External secrets (Swarm only — created via docker secret create) +# Swarm stores passwords encrypted inside the cluster. When a container +# needs one, Swarm delivers it into memory — the password is never +# saved to the server's hard drive. Use this for multi-server clusters. +# printf 'mypassword' | docker secret create datomic_password - +# printf 'mypassword' | docker secret create admin_password - +# printf 'mysecret' | docker secret create signature - +# +# secrets: +# datomic_password: +# external: true +# admin_password: +# external: true +# signature: +# external: true +# +# Then add to each service that needs secrets: +# orcpub: +# secrets: +# - datomic_password +# - signature +# datomic: +# secrets: +# - datomic_password +# - admin_password +# +# You can remove the corresponding env vars from .env, or leave them — +# secret files take priority over env vars. diff --git a/docker-migrate.sh b/docker-migrate.sh index c3a49d35e..f6a9b3d69 100755 --- a/docker-migrate.sh +++ b/docker-migrate.sh @@ -23,7 +23,7 @@ # - Docker Compose v2 (docker compose plugin) # - For backup: OLD datomic container must be running # - For restore: NEW datomic container must be running -# - .env file must exist (run docker-setup.sh first) +# - .env file must exist (run run first) # # See docs/migration/datomic-data-migration.md for the full guide. # ============================================================================= @@ -72,7 +72,7 @@ Usage: ./docker-migrate.sh full Guided full migration (backup → swap → restore) Options (must come BEFORE the command): - --compose-yaml Override compose file for rebuild (default: docker-compose-build.yaml) + --compose-yaml Override compose file for rebuild (default: docker-compose.yaml) --old-uri Override source database URI detection --new-uri Override target database URI detection --help Show this help @@ -81,7 +81,7 @@ Examples: # Step-by-step (recommended for large databases) ./docker-migrate.sh backup # With old stack running docker compose down - docker compose -f docker-compose-build.yaml up -d + docker compose -f docker-compose.yaml up -d ./docker-migrate.sh restore # After new stack is healthy ./docker-migrate.sh verify # Verify backup integrity @@ -97,8 +97,9 @@ load_env() { local env_file="${SCRIPT_DIR}/.env" if [[ -f "$env_file" ]]; then # Read specific variables we need rather than exporting everything + # tr -d '\r' handles Windows line endings in .env # shellcheck disable=SC1090 - DATOMIC_PASSWORD="${DATOMIC_PASSWORD:-$(. "$env_file" && echo "${DATOMIC_PASSWORD:-}")}" + DATOMIC_PASSWORD="${DATOMIC_PASSWORD:-$(. <(tr -d '\r' < "$env_file") && echo "${DATOMIC_PASSWORD:-}")}" fi } @@ -403,7 +404,7 @@ do_full() { # Use a distinct variable name to avoid colliding with Docker Compose's # COMPOSE_FILE env var (which changes docker compose's behavior globally). - local compose_yaml="${COMPOSE_YAML:-docker-compose-build.yaml}" + local compose_yaml="${COMPOSE_YAML:-docker-compose.yaml}" info "New compose file: $compose_yaml" echo "" diff --git a/docker-setup.sh b/docker-setup.sh deleted file mode 100755 index 2e89594fa..000000000 --- a/docker-setup.sh +++ /dev/null @@ -1,373 +0,0 @@ -#!/usr/bin/env bash -# -# OrcPub / Dungeon Master's Vault — Docker Setup Script -# -# Prepares everything needed to run the application via Docker Compose: -# 1. Generates secure random passwords and a signing secret -# 2. Creates a .env file (or uses an existing one) -# 3. Generates self-signed SSL certificates (if missing) -# 4. Creates required directories (data, logs, deploy/homebrew) -# -# Usage: -# ./docker-setup.sh # Interactive mode — prompts for optional values -# ./docker-setup.sh --auto # Non-interactive — accepts all defaults -# ./docker-setup.sh --help # Show usage -# - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${SCRIPT_DIR}/.env" - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -color_green='\033[0;32m' -color_yellow='\033[1;33m' -color_red='\033[0;31m' -color_cyan='\033[0;36m' -color_reset='\033[0m' - -info() { printf '%s[INFO]%s %s\n' "$color_green" "$color_reset" "$*"; } -warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } -error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } -header() { printf '\n%s=== %s ===%s\n\n' "$color_cyan" "$*" "$color_reset"; } - -generate_password() { - # Generate a URL-safe random password (no special chars that break URLs/YAML) - local length="${1:-24}" - if command -v openssl &>/dev/null; then - openssl rand -base64 "$((length * 2))" | tr -d '/+=' | head -c "$length" - elif [ -r /dev/urandom ]; then - tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$length" - else - error "Cannot generate random password: no openssl or /dev/urandom available" - exit 1 - fi -} - -prompt_value() { - local prompt_text="$1" - local default_value="$2" - local result - - if [ "${AUTO_MODE:-false}" = "true" ]; then - echo "$default_value" - return - fi - - if [ -n "$default_value" ]; then - read -rp "${prompt_text} [${default_value}]: " result - echo "${result:-$default_value}" - else - read -rp "${prompt_text}: " result - echo "$result" - fi -} - -usage() { - cat <<'USAGE' -Usage: ./docker-setup.sh [OPTIONS] - -Options: - --auto Non-interactive mode; accept all defaults - --force Overwrite existing .env file - --help Show this help message - -Examples: - ./docker-setup.sh # Interactive setup - ./docker-setup.sh --auto # Quick setup with generated defaults - ./docker-setup.sh --auto --force # Regenerate everything from scratch -USAGE -} - -# --------------------------------------------------------------------------- -# Parse arguments -# --------------------------------------------------------------------------- - -AUTO_MODE=false -FORCE_MODE=false - -for arg in "$@"; do - case "$arg" in - --auto) AUTO_MODE=true ;; - --force) FORCE_MODE=true ;; - --help) usage; exit 0 ;; - *) - error "Unknown option: $arg" - usage - exit 1 - ;; - esac -done - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -header "Dungeon Master's Vault — Docker Setup" - -# ---- Step 1: .env file --------------------------------------------------- - -if [ -f "$ENV_FILE" ] && [ "$FORCE_MODE" = "false" ]; then - info "Existing .env file found. Skipping generation (use --force to overwrite)." -else - # Source existing .env (if any) so current values become defaults for prompts - if [ -f "$ENV_FILE" ]; then - # shellcheck disable=SC1090 - . "$ENV_FILE" - fi - - header "Database Passwords" - - # Generate defaults but let user override - DEFAULT_ADMIN_PW="$(generate_password 24)" - DEFAULT_DATOMIC_PW="$(generate_password 24)" - DEFAULT_SIGNATURE="$(generate_password 32)" - - ADMIN_PASSWORD=$(prompt_value "Datomic admin password" "$DEFAULT_ADMIN_PW") - DATOMIC_PASSWORD=$(prompt_value "Datomic application password" "$DEFAULT_DATOMIC_PW") - SIGNATURE=$(prompt_value "JWT signing secret (20+ chars)" "$DEFAULT_SIGNATURE") - - header "Application" - - PORT=$(prompt_value "Application port" "8890") - EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") - EMAIL_ACCESS_KEY="" - EMAIL_SECRET_KEY="" - EMAIL_SERVER_PORT="587" - EMAIL_FROM_ADDRESS="" - EMAIL_ERRORS_TO="" - EMAIL_SSL="FALSE" - EMAIL_TLS="FALSE" - - if [ -n "$EMAIL_SERVER_URL" ]; then - EMAIL_ACCESS_KEY=$(prompt_value "SMTP username" "") - EMAIL_SECRET_KEY=$(prompt_value "SMTP password" "") - EMAIL_SERVER_PORT=$(prompt_value "SMTP port" "587") - EMAIL_FROM_ADDRESS=$(prompt_value "From email address" "no-reply@orcpub.com") - EMAIL_ERRORS_TO=$(prompt_value "Error notification email" "") - EMAIL_SSL=$(prompt_value "Use SSL? (TRUE/FALSE)" "FALSE") - EMAIL_TLS=$(prompt_value "Use TLS? (TRUE/FALSE)" "FALSE") - fi - - header "Initial Admin User" - - # Check environment / existing .env for pre-set values - INIT_ADMIN_USER="${INIT_ADMIN_USER:-}" - INIT_ADMIN_EMAIL="${INIT_ADMIN_EMAIL:-}" - INIT_ADMIN_PASSWORD="${INIT_ADMIN_PASSWORD:-}" - - if [ -n "$INIT_ADMIN_USER" ] && [ -n "$INIT_ADMIN_EMAIL" ] && [ -n "$INIT_ADMIN_PASSWORD" ]; then - info "Using admin user from environment: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" - elif [ "${AUTO_MODE}" = "true" ]; then - info "No INIT_ADMIN_* variables set. Skipping admin user setup." - info "Create users later with: ./docker-user.sh create ..." - else - info "Optionally create an initial admin account." - info "You can skip this and create users later with ./docker-user.sh" - echo "" - INIT_ADMIN_USER=$(prompt_value "Admin username (leave empty to skip)" "") - if [ -n "$INIT_ADMIN_USER" ]; then - INIT_ADMIN_EMAIL=$(prompt_value "Admin email" "") - INIT_ADMIN_PASSWORD=$(prompt_value "Admin password" "") - if [ -z "$INIT_ADMIN_EMAIL" ] || [ -z "$INIT_ADMIN_PASSWORD" ]; then - warn "Email and password are required. Skipping admin user setup." - INIT_ADMIN_USER="" - INIT_ADMIN_EMAIL="" - INIT_ADMIN_PASSWORD="" - fi - fi - fi - - info "Writing .env file..." - - cat > "$ENV_FILE" </dev/null; then - info "Generating self-signed SSL certificate..." - openssl req \ - -subj "/C=US/ST=State/L=City/O=OrcPub/OU=Dev/CN=localhost" \ - -x509 \ - -nodes \ - -days 365 \ - -newkey rsa:2048 \ - -keyout "$KEY_FILE" \ - -out "$CERT_FILE" \ - 2>/dev/null - info "SSL certificate generated (valid for 365 days)." - else - warn "openssl not found — cannot generate SSL certificates." - warn "Install openssl and run: ./deploy/snakeoil.sh" - fi -fi - -# ---- Step 4: Validation -------------------------------------------------- - -header "Validation" - -ERRORS=0 - -check_file() { - local label="$1" path="$2" - if [ -f "$path" ]; then - info " ${label}: OK" - else - warn " ${label}: MISSING (${path})" - ERRORS=$((ERRORS + 1)) - fi -} - -check_dir() { - local label="$1" path="$2" - if [ -d "$path" ]; then - info " ${label}: OK" - else - warn " ${label}: MISSING (${path})" - ERRORS=$((ERRORS + 1)) - fi -} - -# Validate DATOMIC_PASSWORD matches the password in DATOMIC_URL -if [ -f "$ENV_FILE" ]; then - # Read specific values without polluting current shell namespace - _env_datomic_pw=$(grep -m1 '^DATOMIC_PASSWORD=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-) - _env_datomic_url=$(grep -m1 '^DATOMIC_URL=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-) - if [ -n "$_env_datomic_pw" ] && [ -n "$_env_datomic_url" ]; then - if [[ "$_env_datomic_url" != *"password=${_env_datomic_pw}"* ]]; then - warn " DATOMIC_PASSWORD does not match the password in DATOMIC_URL" - ERRORS=$((ERRORS + 1)) - else - info " DATOMIC_URL password: OK" - fi - fi - unset _env_datomic_pw _env_datomic_url -fi - -check_file ".env" "$ENV_FILE" -check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" -check_file "nginx.conf" "${SCRIPT_DIR}/deploy/nginx.conf" -check_file "SSL certificate" "$CERT_FILE" -check_file "SSL key" "$KEY_FILE" -check_dir "data/" "${SCRIPT_DIR}/data" -check_dir "logs/" "${SCRIPT_DIR}/logs" -check_dir "backups/" "${SCRIPT_DIR}/backups" -check_dir "deploy/homebrew/" "${SCRIPT_DIR}/deploy/homebrew" - -echo "" - -if [ "$ERRORS" -gt 0 ]; then - warn "Setup completed with ${ERRORS} warning(s). Review the items above." -else - info "All checks passed!" -fi - -# ---- Step 5: Next steps --------------------------------------------------- - -header "Next Steps" - -cat <<'NEXT' -1. Review your .env file and adjust values if needed. - -2. Launch the application: - docker compose up -d - -3. Create your first user (once containers are running): - ./docker-user.sh init # uses admin from .env - ./docker-user.sh create # or specify directly - -4. Access the site at: - https://localhost - -5. Manage users later with: - ./docker-user.sh list # List all users - ./docker-user.sh check # Check a user's status - ./docker-user.sh verify # Verify an unverified user - -6. To import homebrew content, place your .orcbrew file at: - deploy/homebrew/homebrew.orcbrew - -7. To build from source instead of pulling images: - docker compose -f docker-compose-build.yaml build - docker compose -f docker-compose-build.yaml up -d - -For more details, see README.md. -NEXT diff --git a/docker-user.sh b/docker-user.sh index 914ed1bc4..d514966d4 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -24,10 +24,10 @@ MANAGE_SCRIPT="${SCRIPT_DIR}/docker/scripts/manage-user.clj" # Helpers # --------------------------------------------------------------------------- -color_green='\033[0;32m' -color_red='\033[0;31m' -color_yellow='\033[1;33m' -color_reset='\033[0m' +color_green=$'\033[0;32m' +color_red=$'\033[0;31m' +color_yellow=$'\033[1;33m' +color_reset=$'\033[0m' info() { printf '%s[OK]%s %s\n' "$color_green" "$color_reset" "$*"; } error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } @@ -80,7 +80,7 @@ USAGE find_container() { local container="" - # Try docker-compose/docker compose service name first + # Try docker compose service name first (works for compose, not Swarm) if command -v docker-compose &>/dev/null; then container=$(docker-compose ps -q orcpub 2>/dev/null || true) fi @@ -88,14 +88,28 @@ find_container() { container=$(docker compose ps -q orcpub 2>/dev/null || true) fi - # Fallback: search by image name + # Try Swarm: look for running task by service label + # Stack name varies (orcpub, dev_dmv, etc.) — match any service containing "orcpub" if [ -z "$container" ]; then - container=$(docker ps -q --filter "ancestor=orcpub/orcpub:latest" 2>/dev/null | head -1 || true) + container=$(docker ps -q --filter "label=com.docker.swarm.service.name" 2>/dev/null | while read -r cid; do + local svc + svc=$(docker inspect --format '{{index .Config.Labels "com.docker.swarm.service.name"}}' "$cid" 2>/dev/null || true) + if [[ "$svc" == *orcpub* ]] && [[ "$svc" != *datomic* ]]; then + echo "$cid" + break + fi + done || true) + fi + + # Fallback: search by image name pattern + if [ -z "$container" ]; then + container=$(docker ps -q --filter "ancestor=orcpub-app" 2>/dev/null | head -1 || true) + [ -z "$container" ] && container=$(docker ps -q --filter "ancestor=dmv" 2>/dev/null | head -1 || true) fi # Fallback: search by container name pattern if [ -z "$container" ]; then - container=$(docker ps -q --filter "name=orcpub" 2>/dev/null | head -1 || true) + container=$(docker ps --format '{{.ID}} {{.Names}}' 2>/dev/null | grep -i 'orcpub' | grep -iv 'datomic' | head -1 | awk '{print $1}' || true) fi echo "$container" @@ -150,9 +164,10 @@ wait_for_ready() { warn "No Docker healthcheck found; polling HTTP on container port..." printf "Waiting for app" while [ $waited -lt $max_wait ]; do - # BusyBox wget (Alpine): only -q and --spider are supported + # BusyBox wget (Alpine): only -q and --spider are supported. + # Use /health endpoint (lightweight, returns 200 when DB is connected). if docker exec "$container" wget -q --spider \ - "http://localhost:${PORT:-8890}/" 2>/dev/null; then + "http://127.0.0.1:${PORT:-8890}/health" 2>/dev/null; then echo "" return 0 fi @@ -229,7 +244,7 @@ fi if [ -z "$CONTAINER" ]; then error "Cannot find the orcpub container." - error "Make sure the containers are running: docker-compose up -d" + error "Make sure the services are running (Compose: docker compose up -d, Swarm: docker stack ps )" exit 1 fi @@ -240,17 +255,17 @@ wait_for_ready "$CONTAINER" if [ "${1:-}" = "init" ]; then ENV_FILE="${SCRIPT_DIR}/.env" if [ ! -f "$ENV_FILE" ]; then - error "No .env file found. Run ./docker-setup.sh first." + error "No .env file found. Run ./run first." exit 1 fi - # Source .env to get INIT_ADMIN_* variables + # Source .env to get INIT_ADMIN_* variables (tr -d '\r' for Windows line endings) # shellcheck disable=SC1090 - . "$ENV_FILE" + . <(tr -d '\r' < "$ENV_FILE") if [ -z "${INIT_ADMIN_USER:-}" ]; then error "INIT_ADMIN_USER is not set in .env" - error "Run ./docker-setup.sh to configure, or set it manually in .env" + error "Run ./run to configure, or set it manually in .env" exit 1 fi if [ -z "${INIT_ADMIN_EMAIL:-}" ] || [ -z "${INIT_ADMIN_PASSWORD:-}" ]; then diff --git a/docker/Dockerfile b/docker/Dockerfile index d6d114c97..149930a38 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ # transactor — datomic transactor (dev storage protocol) # app — orcpub uberjar runner # -# Usage via docker-compose-build.yaml (each service sets its target). +# Usage: docker compose up --build (each service sets its target). # ── Shared: download Datomic Pro distribution ───────────────── FROM alpine:3.22 AS datomic-dist @@ -71,6 +71,11 @@ RUN lein deps ADD ./ /orcpub +# Timezone for build-date macro and runtime logging. +# Override via docker build --build-arg TZ=... or .env +ARG TZ=America/Chicago +ENV TZ=${TZ} + # Three-step build: CLJS, AOT compile, uberjar packaging. # # lein's compile task spawns a subprocess that hangs in no-TTY (Docker/CI) @@ -99,8 +104,11 @@ RUN timeout 600 lein with-profile uberjar,uberjar-package uberjar; \ # Alpine runner — BusyBox wget handles healthchecks (use -q --spider, not GNU flags) FROM eclipse-temurin:21-jre-alpine-3.22 AS app +ARG TZ=America/Chicago +ENV TZ=${TZ} + # PDFBox requires fontconfig, fonts, and lcms2 for PDF character sheet generation -RUN apk add --no-cache fontconfig ttf-dejavu freetype lcms2 +RUN apk add --no-cache fontconfig ttf-dejavu freetype lcms2 tzdata COPY --from=app-builder /orcpub/target/orcpub.jar /orcpub.jar diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj index 19cd1dde5..319f6a86b 100644 --- a/docker/scripts/manage-user.clj +++ b/docker/scripts/manage-user.clj @@ -16,8 +16,14 @@ [clojure.string :as s])) (def datomic-url - (or (System/getenv "DATOMIC_URL") - "datomic:dev://datomic:4334/orcpub?password=datomic")) + (let [base-url (or (System/getenv "DATOMIC_URL") + "datomic:dev://datomic:4334/orcpub") + password (System/getenv "DATOMIC_PASSWORD")] + (if (and password (not (.contains base-url "password="))) + (str base-url "?password=" password) + (if (.contains base-url "password=") + base-url + (str base-url "?password=datomic"))))) (defn get-conn [] (try diff --git a/docker/transactor.properties.template b/docker/transactor.properties.template index 41349f9d5..d753fd534 100644 --- a/docker/transactor.properties.template +++ b/docker/transactor.properties.template @@ -17,28 +17,25 @@ protocol=dev # --- Host & Networking -------------------------------------------------------- -# host= is what the transactor ADVERTISES to peers, not what it binds to. -# Peers connect via the URI hostname, receive this advertised host, then use -# it for subsequent connections. +# host= controls BOTH what the transactor binds to AND what it advertises +# to peers via heartbeats. Datomic has no separate bind/advertise setting. # -# Why "datomic" (the Docker Compose service name) and not "0.0.0.0": +# COMPOSE (default): host=datomic +# DNS resolves to the container's own IP → bind works, advertise gives +# peers the correct service name for reconnection. # -# host=0.0.0.0 works on single-host Docker Compose by accident — peers -# never actually use the advertised address (they reuse the URI hostname). -# In Swarm with multi-node overlay, a peer on node A would try to connect -# to 0.0.0.0:4334 locally, hitting itself instead of the transactor on -# node B. host= works in both modes because Docker DNS -# resolves it correctly across the overlay network. +# SWARM: host=0.0.0.0, alt-host=datomic +# "datomic" resolves to a VIP in overlay networks — can't bind to that. +# 0.0.0.0 binds to all interfaces; alt-host gives peers the service name +# for reconnection. run --swarm switches this automatically. # -# If host=datomic fails: your containers aren't on a shared Docker network. -# docker compose creates one by default (projectname_default). Standalone -# docker run needs --network. Host networking mode has no DNS at all. +# To switch manually: comment one host= line and uncomment the other. host=datomic +#host=0.0.0.0 port=4334 # Alternative hostname for peer fallback connections. -# Default 127.0.0.1 (container loopback). For Swarm with overlay networks, -# set ALT_HOST to the service name or external hostname. +# Compose: 127.0.0.1 (loopback). Swarm: datomic (Docker DNS). alt-host=${ALT_HOST:-127.0.0.1} # --- Storage Access ----------------------------------------------------------- @@ -59,8 +56,12 @@ encrypt-channel=${ENCRYPT_CHANNEL:-true} # deploy/start.sh refuses to start if ADMIN_PASSWORD or DATOMIC_PASSWORD # are not set. # -# Peers must connect with the same DATOMIC_PASSWORD in their URI: -# datomic:dev://datomic:4334/orcpub?password= +# storage-admin-password — locked into the H2 database on first startup. +# Changing it later crashes the transactor ("Unable to connect to embedded +# storage"). Data is NOT lost — set it back or use ADMIN_PASSWORD_OLD to rotate. +# +# storage-datomic-password — peer connection password. Shared by transactor and +# app. Changing it disconnects the app. Use DATOMIC_PASSWORD_OLD to rotate. storage-admin-password=${ADMIN_PASSWORD} storage-datomic-password=${DATOMIC_PASSWORD} diff --git a/docs/BRANDING-AND-INTEGRATIONS.md b/docs/BRANDING-AND-INTEGRATIONS.md new file mode 100644 index 000000000..5bb32a4fa --- /dev/null +++ b/docs/BRANDING-AND-INTEGRATIONS.md @@ -0,0 +1,218 @@ +# Branding, Integrations & Configuration + +How the app handles fork-specific customization (logos, names, analytics, ads, tier gating) without touching shared code. + +## What Changed + +The app used to have DMV-specific values hardcoded throughout shared files — views, events, email templates, privacy pages. Every merge between public and production required manually resolving dozens of conflicts in the same large files. + +Now all fork-specific behavior lives in **6 small override files**. Shared files call the same functions on both branches — they just get different results. + +### Before + +``` +views.cljs:438 → hardcoded Patreon URL +views.cljs:1522 → hardcoded ad reload script +views.cljs:1576 → hardcoded donation banner HTML +views.cljs:3769 → hardcoded PDF upsell content +email.clj:93 → hardcoded "no-reply@dungeonmastersvault.com" +privacy.clj:127 → hardcoded ad-network + ↓ + fork/branding.cljs (reads window.__BRANDING__ at load time) + ↓ + Any CLJS file can require [orcpub.fork.branding :as branding] +``` + +A parallel bridge exists for integrations: `fork/integrations.clj` provides `client-config` which index.clj injects as `window.__INTEGRATIONS__`, read by `fork/integrations.cljs`. + +Why not just read env vars in ClojureScript? `environ.core/env` is JVM-only. CLJS runs in the browser — it needs the values injected. + +--- + +## Configuration Reference + +All values have defaults in `fork/branding.clj`. Set env vars in `.env` to override. + +### App Identity + +| Env Var | Default (public) | Default (production) | Where it shows up | +|---------|-----------------|---------------------|-------------------| +| `APP_NAME` | OrcPub | Dungeon Master's Vault | Page titles, emails, privacy policy, OG tags | +| `APP_URL` | *(empty)* | https://www.dungeonmastersvault.com | Privacy policy domain references | +| `APP_LOGO_PATH` | /image/orcpub-logo.svg | /image/dmv-logo.svg | Header, splash page, privacy page | +| `APP_OG_IMAGE` | /image/orcpub-logo.png | /image/dmv-box-logo.png | Social sharing preview | +| `APP_TAGLINE` | Generic D&D 5e description | DMV-specific description | OG meta tags | +| `APP_PAGE_TITLE` | OrcPub: D&D 5e... | Dungeon Master's Vault: D&D 5e... | Browser tab title | + +### Copyright & Contact + +| Env Var | Default (public) | Default (production) | Where it shows up | +|---------|-----------------|---------------------|-------------------| +| `APP_COPYRIGHT_HOLDER` | OrcPub | Dungeon Master's Vault | Footer | +| `APP_COPYRIGHT_YEAR` | *(current year)* | *(current year)* | Footer | +| `APP_SUPPORT_EMAIL` | *(empty = hidden)* | thDM@dungeonmastersvault.com | Privacy page, error messages, events.cljs mailto | +| `APP_HELP_URL` | *(empty = hidden)* | https://www.dungeonmastersvault.com/help/ | Footer help link | + +### Email + +| Env Var | Default (public) | Default (production) | Where it shows up | +|---------|-----------------|---------------------|-------------------| +| `APP_EMAIL_SENDER_NAME` | OrcPub Team | Dungeon Master's Vault Team | "From" display name | +| `EMAIL_FROM_ADDRESS` | no-reply@orcpub.com | no-reply@dungeonmastersvault.com | "From" address | + +### Social Links + +Shown in the app header/footer when non-empty. Leave empty to hide. + +| Env Var | Default (public) | Default (production) | +|---------|-----------------|---------------------| +| `APP_SOCIAL_PATREON` | *(empty = hidden)* | Patreon URL | +| `APP_SOCIAL_FACEBOOK` | *(empty = hidden)* | Facebook group URL | +| `APP_SOCIAL_BLUESKY` | *(empty = hidden)* | *(empty)* | +| `APP_SOCIAL_TWITTER` | *(empty = hidden)* | Twitter URL | +| `APP_SOCIAL_REDDIT` | *(empty = hidden)* | *(empty)* | +| `APP_SOCIAL_DISCORD` | *(empty = hidden)* | *(empty)* | + +When `APP_SOCIAL_PATREON` is set, the supporter button appears in the header. When empty, nothing renders. Same code on both branches. + +### Field Limits + +Input validation constraints, configurable via env vars. + +| Env Var | Default | Used for | +|---------|---------|----------| +| `APP_FIELD_LIMIT_NOTES` | 50000 | Character notes, backstory textareas | +| `APP_FIELD_LIMIT_TEXT` | 255 | Name fields, short text inputs | +| `APP_FIELD_LIMIT_NUMBER` | 7 | Numeric input fields | + +### Analytics & Ads + +Server-side (`fork/integrations.clj`) injects SDK scripts in `` and exports CSP domain allowlists for `pedestal.clj`. Client-side (`fork/integrations.cljs`) handles in-app behavior, reading ad client/slot IDs from the `window.__INTEGRATIONS__` config bridge. + +| Env Var | Default (public) | Default (production) | +|---------|-----------------|---------------------| +| `MATOMO_URL` | *(empty = disabled)* | Analytics server URL | +| `MATOMO_SITE_ID` | *(empty = disabled)* | Matomo site ID | +| `ADSENSE_CLIENT` | *(empty = disabled)* | AdSense publisher ID | +| `ADSENSE_SLOT` | *(empty = disabled)* | AdSense ad slot ID | + +--- + +## Integration Hooks (fork/integrations.cljs) + +These are the functions that shared files call. Public repo returns stubs/nil, production returns real UI. + +### Lifecycle (called from events/views, no return value) + +| Function | Called from | What it does (production) | +|----------|-----------|--------------------------| +| `track-page-view!` | events.cljs `:route` handler | Matomo page view tracking | +| `on-app-mount!` | views.cljs `content-page` mount | Matomo user identification + ad slot reload | +| `track-character-list!` | views.cljs character list render | Matomo custom variable for character count | + +### UI Hooks (return hiccup or nil) + +| Function | Called from | What it renders (production) | +|----------|-----------|------------------------------| +| `content-slot` | views.cljs content page (2 slots) | AdSense banner (default-tier only) | +| `supporter-link` | views.cljs app header | Tier badge (patrons) or Patreon button (default) | +| `support-banner` | views.cljs content page | Dismissable donation CTA (default-tier only) | +| `pdf-options-slot` | views.cljs PDF options panel | Sheet upsell (default-tier only) | +| `share-links` | views.cljs + character_builder.cljs | Email + www share links | +| `share-link-www` | views.cljs character list | Single www link | + +**Public repo returns:** `nil` for content-slot, support-banner, pdf-options-slot. Basic Patreon button for supporter-link (when URL configured). Single email link for share-links. + +--- + +## User Tier System (fork/user_tier.cljs) + +| Branch | `:user-tier` subscription returns | +|--------|----------------------------------| +| Public | Always `:free` | +| Production | Derived from `:patron` + `:patron-tier` → `:free`, `:patron`, `:gold`, etc. | + +All tier gating in shared code uses `@(subscribe [:user-tier])`. The integration hooks also self-gate — `content-slot` checks tier internally, so callers don't need to. + +--- + +## Merging + +When merging public → production: + +| File type | What happens | Action | +|-----------|-------------|--------| +| Override files (6 listed above) | Conflict | **Keep ours (production)** | +| Everything else | No conflict | Auto-merge | + +When adding a new integration hook: +1. Add stub on public first (empty body or `nil` return) +2. Add real implementation on production +3. Wire the call site in shared code (same on both branches) + +--- + +## Files That Read Branding/Integration Config + +| File | What it reads | +|------|--------------| +| `fork/branding.clj` | All `APP_*` env vars, `EMAIL_FROM_ADDRESS` | +| `fork/integrations.clj` | `MATOMO_URL`, `MATOMO_SITE_ID`, `ADSENSE_CLIENT`, `ADSENSE_SLOT` | +| `index.clj` | Calls `fork/branding/client-config` + `fork/integrations/head-tags` + `fork/integrations/client-config` | +| `privacy.clj` | Calls `fork/branding/*` for names + `fork/integrations/head-tags` for scripts | +| `email.clj` | `fork/branding/email-from-address`, `fork/branding/email-sender-name` | +| `routes.clj` | `fork/branding/*` (app-name), `fork/user-data/*` (response enrichment) | +| `pedestal.clj` | `fork/integrations/csp-domains` (CSP allowlists) | +| `fork/branding.cljs` | Reads `window.__BRANDING__` (injected by index.clj) | +| `fork/integrations.cljs` | Reads `window.__INTEGRATIONS__` + `fork/branding/*` via branding.cljs | +| `views.cljs` | Reads `fork/branding/*` + calls `fork/integrations/*` hooks | +| `events.cljs` | Reads `fork/branding/support-email`, calls `fork/integrations/track-page-view!` | +| `character_builder.cljs` | Calls `fork/integrations/share-links` | diff --git a/docs/DOCKER-SECURITY.md b/docs/DOCKER-SECURITY.md index 1af0efd15..23a0a4e6b 100644 --- a/docs/DOCKER-SECURITY.md +++ b/docs/DOCKER-SECURITY.md @@ -86,7 +86,7 @@ escape_sed_replacement() { **Order matters:** backslash must be escaped first. If we escaped `&` first (producing `\&`), then the backslash pass would double it to `\\&`. -`docker-setup.sh` generates alphanumeric-only passwords (no special chars), but +`run` generates alphanumeric-only passwords (no special chars), but users who set passwords manually via `.env` can use any characters. The escaping makes this safe. @@ -100,7 +100,7 @@ sed ... "$TEMPLATE" > "$OUTPUT" chmod 600 "$OUTPUT" ``` -Similarly, `docker-setup.sh` sets `chmod 600` on the generated `.env` file, +Similarly, `run` sets `chmod 600` on the generated `.env` file, which contains `ADMIN_PASSWORD`, `DATOMIC_PASSWORD`, `SIGNATURE` (JWT secret), and SMTP credentials. @@ -173,7 +173,7 @@ password in `DATOMIC_URL`, the app connects with the wrong credential. The error is a cryptic Datomic authentication failure with no mention of password mismatch. -`docker-setup.sh` validates this in its verification section: +`run` validates this in its verification section: ```bash _env_datomic_pw=$(grep -m1 '^DATOMIC_PASSWORD=' "$ENV_FILE" | cut -d= -f2-) @@ -183,7 +183,7 @@ if [[ "$_env_datomic_url" != *"password=${_env_datomic_pw}"* ]]; then fi ``` -When `docker-setup.sh` generates the file, it constructs `DATOMIC_URL` using +When `run` generates the file, it constructs `DATOMIC_URL` using `${DATOMIC_PASSWORD}` so they always match at creation time. ## Environment Variable Passthrough diff --git a/docs/DOCKER.md b/docs/DOCKER.md index a3dca17f8..ca988d12a 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -1,7 +1,221 @@ # Docker Reference Consolidated reference for OrcPub's Docker infrastructure: three services, -two compose files, and the configuration patterns that connect them. +one compose file, and the configuration patterns that connect them. + +**Contents** + +- [Platform Notes](#platform-notes) — Linux, macOS, Windows +- [Quick Start](#quick-start) — setup, build, launch, first user + - [Upgrading an Existing Installation](#upgrading-an-existing-installation) + - [Key Environment Variables](#key-environment-variables) +- [Architecture](#architecture) — services, networking, boot order +- [Compose File](#compose-file) — image naming, registry overrides +- [Transactor Configuration](#transactor-configuration-option-c-hybrid-template) +- [host=datomic Rationale](#hostdatomic-rationale) +- [Jetty Binding](#jetty-binding) +- [Healthcheck Strategy](#healthcheck-strategy) — app and transactor probes +- [Production Memory Tuning](#production-memory-tuning) +- [Volumes](#volumes) +- [Docker Swarm Deployment](#docker-swarm-deployment) — ALT_HOST, scaling, secrets +- [File Inventory](#file-inventory) +- [Security](#security) +- [Troubleshooting](#troubleshooting) — env var conflicts, protocol errors, restarts + +--- + +### Platform Notes + +The setup and management scripts (`run`, `docker-user.sh`, +`docker-migrate.sh`) are bash scripts. They work on: + +- **Linux** — natively +- **macOS** — natively (Terminal.app, iTerm, etc.) +- **Windows** — run from **Git Bash** (ships with Git for Windows) or + **WSL** (Windows Subsystem for Linux, required by Docker Desktop anyway) + +All scripts handle Windows line endings (`\r\n`) in `.env` and config files +defensively. If you edit `.env` in Notepad or another Windows editor, it +will still work. + +`docker compose` itself works on all three platforms without special steps. + +--- + +## Quick Start — New Install + +You need to run **3 commands**. You don't need to edit any files. + +```sh +# 1. Setup — generates passwords, creates directories, makes SSL certs +./run --auto + +# 2. Build and launch (first build takes ~10 minutes, then seconds) +docker compose up --build -d + +# 3. Create a user (once all 3 services show "healthy" in docker compose ps) +./docker-user.sh create myuser me@example.com MyPassword1 +``` + +Open **https://localhost** (self-signed cert — your browser will warn, that's +normal). + +That's it. Everything else is optional. + +
+Want to customize? (email, admin user, ports) + +Run the interactive version instead: + +```sh +./run # prompts for each setting +``` + +Or edit `.env` after setup — it's a plain text file with comments explaining +every setting. See `.env.example` for the full list. + +
+ +### What the setup script creates + +| What | Where | Purpose | +|------|-------|---------| +| `.env` | project root | All your passwords and settings | +| `data/` | project root | Database files (persists between restarts) | +| `logs/` | project root | Transactor log files | +| `backups/` | project root | Database backup destination | +| `deploy/snakeoil.*` | deploy/ | Self-signed SSL certificate + key | + +### What you actually run day-to-day + +| Task | Command | +|------|---------| +| Start everything | `docker compose up -d` | +| Stop everything | `docker compose down` | +| Rebuild after code changes | `docker compose up --build -d` | +| Check status | `docker compose ps` | +| View logs | `docker compose logs orcpub --tail 50` | +| Create a user | `./docker-user.sh create ` | +| List users | `./docker-user.sh list` | + +--- + +## Upgrading an Existing Install + +**You edit: nothing.** The upgrade script checks your `.env` and fixes +anything that's out of date. + +```sh +# 1. Pull latest code +git pull + +# 2. Let the upgrade script check and fix your .env +./run --upgrade + +# 3. Rebuild and restart +docker compose up --build -d +``` + +The upgrade script: +- **Backs up** your `.env` before changing anything +- **Detects** old patterns (password in URL, missing variables) +- **Fixes** them automatically +- **Warns** about things that need your attention (like Free→Pro migration) +- **Does nothing** if your `.env` is already fine + +Your `data/`, `logs/`, and SSL certs are never touched. + +
+What does --upgrade actually check? + +| Issue | What it does | +|-------|--------------| +| `?password=` in DATOMIC_URL | Extracts password to DATOMIC_PASSWORD, cleans URL | +| Missing DATOMIC_PASSWORD | Adds it (generates random if `--auto` also passed) | +| Missing SIGNATURE | Adds it (warns that sessions will be invalidated) | +| Missing ADMIN_PASSWORD | Adds it | +| `datomic:free://` in URL | Changes to `datomic:dev://` (Datomic Pro). Warns about data migration if needed | +| `localhost` in URL | Changes to `datomic` (Docker service name). Warns if you run outside Docker | + +If you prefer to edit files by hand, just read the output — it tells you +exactly what to change and why. + +
+ +### Don't use `.env`? (Export vars directly) + +If you set passwords as shell environment variables instead of using `.env`, +the upgrade script can't check your setup. But nothing breaks — the app +reads env vars the same way it always did. + +If you want to start using `.env`: + +```sh +./run --auto # creates .env with generated passwords +``` + +Then edit the generated `.env` to use your existing passwords instead of +the random ones. + +### Optional: Docker secrets + +Move passwords out of `.env` so they aren't all sitting in one file: + +```sh +# Single server (creates secret files on disk) +./run --secrets + +# Swarm cluster (stores secrets encrypted in the cluster) +./run --swarm +``` + +Both read your existing passwords from `.env` or shell env vars — you +don't re-enter anything. If you're not sure which to pick, run `--secrets` +and it will ask if you're using Swarm. + +The script creates `docker-compose.secrets.yaml` and adds `COMPOSE_FILE` +to your `.env` so compose merges both files automatically. No manual +edits needed. Secret files take priority over `.env` — you can leave +`.env` passwords in place or remove them. + +### Password rotation + +```sh +# 1. In .env, add the OLD vars with your current password: +# ADMIN_PASSWORD_OLD=current-password +# DATOMIC_PASSWORD_OLD=current-password + +# 2. Change the main vars to the new password: +# ADMIN_PASSWORD=new-password +# DATOMIC_PASSWORD=new-password + +# 3. Restart +docker compose down && docker compose up -d + +# 4. After everything is working, remove the _OLD vars from .env +``` + +### Key Environment Variables + +These are the variables you'll actually touch. Full reference in +[ENVIRONMENT.md](ENVIRONMENT.md). + +| Variable | Required | Default | What it does | +|----------|----------|---------|--------------| +| `DATOMIC_PASSWORD` | Yes | — | App-to-transactor auth. The app appends `?password=` to `DATOMIC_URL` at startup. | +| `ADMIN_PASSWORD` | Yes | — | Transactor admin/monitoring auth. | +| `SIGNATURE` | Yes | — | JWT signing secret. All login and API calls fail without it. | +| `DATOMIC_URL` | Yes | `datomic:dev://datomic:4334/orcpub` | Database connection URI. No `?password=` — the app adds it from `DATOMIC_PASSWORD`. | +| `PORT` | No | `8890` | App server port. Nginx and healthcheck adapt automatically. | +| `ALT_HOST` | No | `127.0.0.1` | Transactor peer fallback host. Change to `datomic` for Swarm. | +| `EMAIL_SERVER_URL` | No | *(empty)* | SMTP server. Leave empty to disable email (registration still works, just no verification emails). | +| `CSP_POLICY` | No | `strict` | Content Security Policy: `strict`, `permissive`, or `none`. | +| `DEV_MODE` | No | *(empty)* | Set to `true` for CSP Report-Only mode (allows Figwheel hot-reload). | +| `LOAD_HOMEBREW_URL` | No | *(empty)* | URL to fetch `.orcbrew` plugins on first page load. | + +`run` generates `DATOMIC_PASSWORD`, `ADMIN_PASSWORD`, and +`SIGNATURE` automatically. You only need to edit `.env` if you want email or +custom branding. ## Architecture @@ -21,27 +235,17 @@ Boot order is enforced by healthcheck dependencies: datomic --> orcpub (waits for datomic healthy) --> web (waits for orcpub healthy) -## Compose Files +## Compose File -### `docker-compose-build.yaml` — Build from Source - -Builds images locally using the multi-target `docker/Dockerfile`. Use for CI -pipelines and local development. - -```sh -docker compose -f docker-compose-build.yaml up --build -``` - -### `docker-compose.yaml` — Pre-built Images - -Pulls pre-built images from Docker Hub. Use for production and release -deployments. +A single `docker-compose.yaml`: ```sh -docker compose up -d +docker compose up --build -d # Build from source using docker/Dockerfile ``` -Both files are kept in sync: same env vars, healthchecks, and volume mounts. +Image names default to local build tags (`orcpub-app`, `orcpub-datomic`). +Override with `ORCPUB_IMAGE` and `DATOMIC_IMAGE` env vars to point at a +registry (e.g., `ORCPUB_IMAGE=registry/orcpub:2.6.0.0 docker compose up -d`). ## Transactor Configuration (Option C Hybrid Template) @@ -170,18 +374,146 @@ Rules of thumb (from Datomic capacity planning docs): | `./deploy/nginx.conf.template` | web | Nginx config template (`envsubst` at startup) | | `./deploy/snakeoil.*` | web | Self-signed SSL certificates | -## Swarm Migration Notes +## Docker Swarm Deployment + +`./run --swarm` generates a Swarm-compatible compose file and a Portainer-ready +env file. It produces **text files only** — no Docker daemon required. + +### What changes from Compose + +| Concern | Compose (`docker-compose.yaml`) | Swarm (`docker-compose.swarm.yaml`) | +|---------|---------------------------------|-------------------------------------| +| Volumes | Bind mounts (`./data:/data`) | Named volumes (`orcpub_data`) with `external: true` | +| Dependencies | `depends_on` + healthchecks | Removed (Swarm ignores `depends_on`) | +| Restart | `restart: always` | `deploy.restart_policy` | +| Build | `build:` supported | Removed (Swarm needs pre-built images) | +| Networks | Default bridge | Explicit overlay (`backend`) | +| `.env` file | Auto-read by `docker compose` | **Not read** by `docker stack deploy` | + +### Quick start -The current configuration is Swarm-ready with minimal changes: +```sh +# 1. Generate .env (if you haven't already) +./run + +# 2. Generate Swarm compose + Portainer env file +./run --swarm + +# 3. Pre-create named volumes (Swarm won't auto-create external volumes) +docker volume create orcpub_data +docker volume create orcpub_logs +docker volume create orcpub_backups + +# 4. Deploy via CLI +set -a; source .env; set +a +docker stack deploy -c docker-compose.swarm.yaml orcpub + +# 5. Check status +docker stack services orcpub +docker service logs orcpub_orcpub --follow +``` + +### Generated files + +| File | Purpose | +|------|---------| +| `docker-compose.swarm.yaml` | Swarm-ready compose — named volumes, deploy sections, overlay networks | +| `.env.portainer` | Flat `KEY=VALUE` file — no comments, no blank lines, no quotes. Paste into Portainer's "Advanced mode" env editor | +| `transactor.properties.reference` | Generated when you bind-mount a custom `transactor.properties`. Shows current template values so you can diff against your file | + +### Portainer import + +Portainer has no `.env` file upload. Use its "Advanced mode" for bulk env vars: + +1. **Stacks → Add stack** (or update existing) +2. Paste `docker-compose.swarm.yaml` into the compose editor +3. Click **Advanced mode** in the Environment variables section +4. Paste the contents of `.env.portainer` (one `KEY=VALUE` per line) +5. Deploy + +### Upgrading an existing Swarm deployment + +Running `./run --swarm` when `docker-compose.swarm.yaml` already exists: + +1. Backs up the existing file (timestamped `.bak`) +2. Extracts your customizations (Traefik labels, resource limits, network names, JVM settings, env var values, stack name) +3. Regenerates the compose with the latest template, preserving your customizations +4. Shows a colorized diff: + - **White** — unchanged lines + - **Cyan** — new upstream lines (using defaults) + - **Green** — new upstream lines where your `.env` value was applied + - **Yellow** — lines where the value changed from the old file + +New canonical env vars added upstream are appended to each service's +environment block — existing vars are never reordered or removed. + +### Scaling notes + +- **datomic**: Must be exactly 1 replica (Datomic transactor is a singleton). +- **orcpub**: Can scale to multiple replicas if they share the same transactor. + Each replica connects to `datomic:4334`. +- **web**: Can scale freely. Each replica proxies to any `orcpub` replica via + Swarm's built-in load balancing. + +### JVM memory guidance -- `host=datomic` already works with overlay network DNS -- Set `ALT_HOST=datomic` in `.env` (change from default `127.0.0.1`) -- Add a `deploy:` section to each service for replica count and placement - constraints (~5-10 lines per service) -- Consider using Docker secrets instead of environment variables for - `DATOMIC_PASSWORD` and `ADMIN_PASSWORD` -- No separate compose file needed — the same file works with added deploy - config +Do **not** set heap equal to the container memory limit — the JVM needs +headroom for off-heap memory (metaspace, thread stacks, NIO buffers). + +| Approach | Example | When to use | +|----------|---------|-------------| +| Auto-percentage (recommended) | `JAVA_OPTS=-XX:MaxRAMPercentage=75.0` | JDK 11+, lets JVM scale with container limit | +| Explicit heap | `XMS=-Xms1g` / `XMX=-Xmx1g` | When you need predictable fixed sizing | +| Default (no setting) | Leave `JAVA_OPTS`, `XMS`, `XMX` empty | Small deployments, JVM picks conservative defaults | + +The Swarm compose sets `deploy.resources.limits.memory` (hard ceiling) and +`deploy.resources.reservations.memory` (scheduling minimum). Configure these +in `.env` via `APP_MEMORY_LIMIT` and `APP_MEMORY_RESERVATION`. + +### Docker Secrets + +Docker secrets mount passwords as files at `/run/secrets/` inside +the container instead of passing them as environment variables. Both the +transactor (`deploy/start.sh`) and the app (`config.clj`) already check +`/run/secrets/` first, then fall back to environment variables — no code +changes needed. + +**Secret files always win over env vars.** If both exist, the file is used. + +#### File-based secrets (single server, no Swarm) + +```sh +./run --secrets +``` + +Creates a `secrets/` directory with one file per password (`chmod 600`), +generates `docker-compose.secrets.yaml`, and adds `COMPOSE_FILE` to `.env` +so compose merges both files automatically. + +#### Swarm Raft secrets (cluster) + +Passwords are stored encrypted in the Swarm Raft log. Containers receive +them via an in-memory tmpfs mount — never written to disk on worker nodes. + +```sh +./run --swarm --secrets +``` + +This generates the Swarm compose (if not already present), then creates +Docker secrets via `docker secret create` and uncomments the `secrets:` +blocks in the generated compose file. Requires a running Swarm manager. + +#### What changes when using secrets + +| Without secrets | With secrets | +|----------------|--------------| +| Passwords in `.env` (plaintext on disk) | Passwords in `/run/secrets/` (tmpfs in Swarm, file in compose) | +| `DATOMIC_PASSWORD=xxx` in env | `DATOMIC_PASSWORD` env var optional (ignored when secret exists) | +| All config in one `.env` file | Secrets separated from non-sensitive config | + +**Use `printf`, not `echo`** when creating secrets — `echo` appends a newline +that becomes part of the password. Both `start.sh` and `config.clj` strip +trailing newlines defensively, but `printf` avoids the issue entirely. ## File Inventory @@ -189,22 +521,128 @@ The current configuration is Swarm-ready with minimal changes: |------|---------| | `docker/Dockerfile` | Multi-target: `datomic-dist` (downloader), `transactor`, `app-builder`, `app` | | `docker/transactor.properties.template` | Complete transactor config (Option C hybrid template) | -| `deploy/start.sh` | Transactor startup: secret substitution + exec | +| `deploy/start.sh` | Transactor startup: Docker secrets → env var fallback → template substitution → exec | | `deploy/nginx.conf.template` | Nginx reverse proxy template (`envsubst` resolves `${ORCPUB_PORT}`) | | `deploy/snakeoil.sh` | Self-signed SSL certificate generator | -| `docker-compose-build.yaml` | Build-from-source compose | -| `docker-compose.yaml` | Pre-built images compose | -| `docker-setup.sh` | Interactive setup: generates `.env`, dirs, SSL certs | +| `docker-compose.yaml` | Compose file (pull or build-from-source) | +| `docker-compose.secrets.yaml` | Generated by `--secrets` — merges file-based secrets into compose | +| `docker-compose.swarm.yaml` | Generated by `--swarm` — Swarm-ready compose (named volumes, deploy sections) | +| `.env.portainer` | Generated by `--swarm` — flat KEY=VALUE for Portainer's Advanced mode env editor | +| `run` | Interactive setup: generates `.env`, dirs, SSL certs, secrets, Swarm compose | +| `scripts/swarm.sh` | Swarm compose generation functions (sourced by `run`) | | `.env.example` | Environment variable reference with defaults | ## Security -Both containers run as non-root users (`datomic` and `app`). Secrets are -handled with `chmod 600` file permissions, sed escaping for special characters -in passwords, and `.dockerignore` exclusion of `.env` from the build context. +Both containers run as non-root users (`datomic` and `app`). Passwords support +Docker secrets (`/run/secrets/` files) as an alternative to environment variables — +secret files take priority over env vars when both exist. Additional hardening +includes `chmod 600` file permissions, sed escaping for special characters in +passwords, and `.dockerignore` exclusion of `.env` from the build context. For full reasoning behind each security decision, see `DOCKER-SECURITY.md`. +## Troubleshooting + +### "Connection refused: localhost:4335" + +The app is trying to connect to Datomic at `localhost` instead of the +`datomic` service. This happens when a shell environment variable +overrides the `.env` value. + +**Check what compose sees:** + +```sh +docker compose config | grep DATOMIC_URL +``` + +If it shows `localhost` instead of `datomic`, something in your shell is +setting `DATOMIC_URL`. Common sources: + +- **Codespaces**: `containerEnv` in `devcontainer.json` sets it for local dev +- **`.bashrc` / `.profile`**: Previous `export DATOMIC_URL=...` +- **Other dotfiles**: Any shell init script that exports the variable + +**Fix:** + +```sh +# Check if it's set in your shell +echo $DATOMIC_URL + +# Clear it for this session +unset DATOMIC_URL + +# Or override inline (one-shot, doesn't persist): +source .env && docker compose up -d +``` + +**Why this happens:** Docker Compose resolves `${VAR:-default}` from +your shell environment first, then falls back to the `.env` file. +If your shell already has `DATOMIC_URL` set, compose uses that value +and ignores `.env` entirely. See the comment block at the top of +`docker-compose.yaml` for details. + +### "Unsupported protocol :dev" + +The app jar was built with Datomic Free (which only supports `datomic:free://`), +but the connection URL uses `datomic:dev://` (Datomic Pro). This means the +Docker image wasn't built from source — it was pulled from Docker Hub, where +only old Datomic Free images exist. + +**Fix:** Rebuild from source: + +```sh +docker compose up --build -d +``` + +The `--build` flag is important. Without it, compose reuses existing images +or pulls from a registry. Building from source installs the Datomic Pro peer +jar, which supports the `dev://` protocol. + +### App container keeps restarting + +The compose file has `restart: always`, so a crashed app retries indefinitely. +Check why it's failing: + +```sh +docker compose logs orcpub --tail 50 +``` + +Common causes: +- Wrong `DATOMIC_URL` (see above) +- Transactor not ready yet (wait for `datomic` to show "healthy") +- Missing `SIGNATURE` (set it in `.env` or `run` generates one) + +### Build takes too long / hangs + +First build downloads Datomic Pro (~400MB) and compiles a Clojure uberjar. +This normally takes ~10 minutes. + +If it hangs during `lein uberjar`, the JVM may be running out of memory. +Check your Docker memory limit: + +```sh +docker info --format '{{.MemTotal}}' +``` + +The build needs at least 4GB. Increase Docker's memory allocation in +Docker Desktop settings, or set `MAVEN_OPTS=-Xmx2g` in the Dockerfile's +build args. + +### Healthcheck failing + +```sh +docker compose ps # shows health status +docker inspect --format='{{json .State.Health}}' orcpub-orcpub-1 +``` + +- **datomic**: Checks if port 4334 is listening. If unhealthy, check + `docker compose logs datomic` for password or storage errors. +- **orcpub**: Hits `http://127.0.0.1:8890/health`. Takes ~2 minutes after + container start. If it never becomes healthy, check the app logs. +- **web**: Depends on orcpub being healthy first. Won't start until orcpub + passes its healthcheck. + ## See Also - `DOCKER-SECURITY.md` — Security hardening decisions with reasoning diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 07cd4f247..99ebff0eb 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -47,7 +47,7 @@ All configuration is managed via a `.env` file at the repository root. Copy `.en | Variable | Default | Description | |----------|---------|-------------| | `CSP_POLICY` | `strict` | Content Security Policy mode: `strict`, `permissive`, or `none` | -| `DEV_MODE` | `true` (in :dev profile) | Enables dev-mode CSP (Report-Only instead of enforcing) | +| `DEV_MODE` | `"true"` (in :dev profile) | Enables dev-mode CSP (Report-Only instead of enforcing). Must be the string `"true"` (case-insensitive) -- any other value (including `"1"`, `"yes"`, or empty) is treated as false. | CSP modes: - **strict** — nonce-based CSP with `strict-dynamic`. Dev mode uses `Report-Only` header (logs violations but doesn't block). Prod uses enforcing header. @@ -90,6 +90,12 @@ See `docker/transactor.properties.template` for the full transactor configuratio | Variable | Default | Description | |----------|---------|-------------| | `ORCPUB_ENV` | — | Set to `dev` to enable `add-test-user` in user.clj | +| `FIGWHEEL_PORT` | `3449` | Figwheel WebSocket port for frontend hot-reload. Read by `scripts/common.sh`. | +| `FIGWHEEL_CONNECT_URL` | *(auto-detected)* | Figwheel WebSocket URL override for remote environments (Gitpod, tunnels). Auto-detected for GitHub Codespaces. Example: `wss://my-remote-host:3449/figwheel-connect` | + +### Branding, Social Links & Integrations + +See [BRANDING-AND-INTEGRATIONS.md](BRANDING-AND-INTEGRATIONS.md) for the full reference covering `APP_*`, `APP_SOCIAL_*`, `MATOMO_*`, and `ADSENSE_*` variables. ## Files That Read Environment @@ -98,8 +104,12 @@ See `docker/transactor.properties.template` for the full transactor configuratio | `src/clj/orcpub/config.clj` | `DATOMIC_URL`, `CSP_POLICY`, `DEV_MODE` | | `src/clj/orcpub/system.clj` | `PORT` (via `System/getenv`) | | `src/clj/orcpub/routes.clj` | `SIGNATURE`, `EMAIL_*`, `ADMIN_PASSWORD` | -| `src/clj/orcpub/index.clj` | `DEV_MODE`, `LOAD_HOMEBREW_URL` | +| `src/clj/orcpub/index.clj` | `LOAD_HOMEBREW_URL` (calls `fork/branding` + `fork/integrations`) | +| `src/clj/orcpub/fork/branding.clj` | `APP_*`, `APP_SOCIAL_*`, `APP_FIELD_LIMIT_*`, `EMAIL_FROM_ADDRESS` | +| `src/clj/orcpub/fork/integrations.clj` | `MATOMO_URL`, `MATOMO_SITE_ID`, `ADSENSE_CLIENT`, `ADSENSE_SLOT` | +| `src/clj/orcpub/pedestal.clj` | (reads `fork/integrations` for CSP domain allowlists) | | `.devcontainer/post-create.sh` | `DATOMIC_VERSION`, `DATOMIC_TYPE` | -| `scripts/start.sh` | `DATOMIC_URL`, `LOG_DIR` | +| `scripts/start.sh` | `DATOMIC_URL`, `LOG_DIR`, `FIGWHEEL_PORT`, `FIGWHEEL_CONNECT_URL` | +| `scripts/common.sh` | `FIGWHEEL_PORT` (default: 3449) | | `deploy/start.sh` | `ADMIN_PASSWORD`, `DATOMIC_PASSWORD`, `ALT_HOST`, `ENCRYPT_CHANNEL`, `*_OLD` rotation vars | | `dev/user.clj` | `ORCPUB_ENV` (for add-test-user guard) | diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index afed80ba4..9e6b13dd8 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -216,6 +216,55 @@ All API-calling re-frame subscriptions use the `handle-api-response` HOF from `e This prevents the class of bug where a bare `case` with no default clause crashes on unexpected HTTP statuses. +## Error Notification Emails + +Unhandled exceptions in the Pedestal interceptor chain trigger error notification emails +to the address configured in `EMAIL_ERRORS_TO`. The `send-error-email` function in +`email.clj` produces actionable, security-conscious notifications. + +### Email Format + +**Subject:** `[AppName] ExceptionClass: message @ METHOD /uri` + +**Body sections:** +1. **Request** — scrubbed request map (no credentials, cookies, body params, or Datomic objects) +2. **Exception** — full cause chain with `orcpub.*` stack frames (infrastructure frames suppressed with count) +3. **Exception Data** — `ex-data` map for `ExceptionInfo` exceptions +4. **Interceptor Context** — Pedestal metadata when the exception is a wrapped interceptor error + +### Security: Request Scrubbing + +The following are stripped from the request before emailing: + +- `:json-params`, `:transit-params`, `:form-params` (may contain passwords) +- `:body` (raw input stream) +- `:db`, `:conn` (live Datomic objects) +- `:servlet-request`, `:servlet-response`, `:servlet`, `:url-for` +- `:identity`, `:async-supported?`, `:character-encoding`, `:protocol`, `:path-params`, `:content-length` +- Headers filtered to safe set: `user-agent`, `referer`, `content-type`, `accept-language`, + `cf-ipcountry`, `x-forwarded-for`, `x-real-ip`, `cf-ray`, `sec-fetch-site`, `sec-fetch-mode`, + `x-forwarded-host`, `x-forwarded-proto` + +### Flood Throttling + +One email per unique error fingerprint per 5 minutes. Fingerprint = root cause class + +deepest `orcpub.*` stack frame (or first 60 chars of root message if no app frames). +Duplicate emails log `INFO: Suppressed duplicate error email` to stdout. + +### Stack Trace Filtering + +- Filters to `orcpub.*` frames only +- Falls back to deepest non-infrastructure frame when no app frames exist +- Infrastructure prefixes suppressed: `org.eclipse.jetty.`, `io.pedestal.`, `clojure.lang.`, + `java.lang.Thread`, `sun.reflect.`, `java.util.concurrent.`, `clojure.core$` +- Walks the full `.getCause()` chain + +### Pedestal Wrapper Detection + +When `ex-data` contains both `:exception` and `:interceptor` keys, the real exception +is extracted from `:exception` and the remaining metadata is shown in a separate +"Interceptor Context" section. + ## Future Improvements Potential enhancements to consider: diff --git a/docs/README.md b/docs/README.md index ba1159f08..8c9941500 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,10 @@ Guides for developers and power users working with OrcPub's homebrew content sys - [🔍 Missing Content Detection](CONTENT_RECONCILIATION.md) - Find/fix missing content references - [📋 Required Fields Guide](HOMEBREW_REQUIRED_FIELDS.md) - Required fields per content type +**Agent Knowledge Base:** +- [📚 KB Index](kb/README.md) - Verified findings from deep investigations +- [💥 Datomic Crash Analysis](kb/datomic-crash-analysis.md) - Root cause, frequency, fix options + **For Developers:** - [🚨 Error Handling](ERROR_HANDLING.md) - Error handling utilities - [🗡️ Language Selection Fix](LANGUAGE_SELECTION_FIX.md) - Ranger favored enemy language corruption (#296) diff --git a/docs/TODO.md b/docs/TODO.md index 79134dada..b24f5f5b8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,45 @@ # TODO — Tracked Issues +## Datomic transactor crashes — investigate Postgres migration + +**Status:** Open +**Severity:** Critical — transactor crashing 3–5× per day, 2–3 min downtime each +**Reported:** 2026-02-26 +**KB doc:** [docs/kb/datomic-crash-analysis.md](kb/datomic-crash-analysis.md) + +### Summary + +The Datomic transactor is self-terminating multiple times daily with +`"Critical failure, cannot continue: Heartbeat failed"`. Root cause is H2 +write-lock contention during memoryIndex flushes starving the heartbeat thread. +`writeConcurrency=4` amplifies the problem — H2 cannot parallelize writes. + +### Immediate mitigation (low risk, config only) + +Set `datomic.writeConcurrency=1` in the transactor properties file. See KB doc +for caveats. + +### Permanent fix + +Migrate from Datomic Free + H2 to Datomic Pro + PostgreSQL. Datomic Pro is +free under Apache 2.0 (see `docs/migration/datomic-pro.md` — peer migration +already done). What remains is the **storage backend migration**: + +1. Provision PostgreSQL (Docker service or managed) +2. Run Datomic's SQL init scripts (`bin/sql/postgres-*.sql`) +3. Export data from H2 transactor with `bin/datomic backup-db` +4. Restore into Postgres transactor with `bin/datomic restore-db` +5. Update transactor properties: `storage-class=sql`, JDBC params +6. Update Docker Compose to add Postgres service and remove H2 volume + +### Related + +- `docker/datomic/` — transactor container and config templates +- `docs/migration/datomic-pro.md` — peer library already migrated to Pro +- `docs/kb/datomic-crash-analysis.md` — full root cause analysis with log evidence + +--- + ## localStorage corrupt data persistence **Status:** Open diff --git a/docs/docker-user-management.md b/docs/docker-user-management.md index eb2a1908e..de4c33512 100644 --- a/docs/docker-user-management.md +++ b/docs/docker-user-management.md @@ -59,9 +59,9 @@ If you forget the commands: `./docker-user.sh --help` If you've cloned the repo, the setup script generates secure passwords, SSL certs, and required directories in one step: ```bash -./docker-setup.sh # Interactive — prompts for optional values -./docker-setup.sh --auto # Non-interactive — accepts all defaults -./docker-setup.sh --auto --force # Regenerate everything from scratch +./run # Interactive — prompts for optional values +./run --auto # Non-interactive — accepts all defaults +./run --auto --force # Regenerate everything from scratch ``` In interactive mode, the setup script will prompt for an initial admin account. Then start the stack and initialize: @@ -78,7 +78,7 @@ docker compose up -d ./docker-user.sh create admin admin@example.com MySecurePass123 ``` -The setup script creates a `.env` file used by both `docker-compose.yaml` and `docker-compose-build.yaml`. You can also copy and edit `.env.example` manually if you prefer. +The setup script creates a `.env` file used by `docker-compose.yaml`. You can also copy and edit `.env.example` manually if you prefer. ### Environment Variables @@ -108,7 +108,7 @@ The setup script creates a `.env` file used by both `docker-compose.yaml` and `d Reads `INIT_ADMIN_USER`, `INIT_ADMIN_EMAIL`, and `INIT_ADMIN_PASSWORD` from `.env` and creates the account. Safe to run multiple times — if the user already exists, it's skipped. -This is the easiest path after running `docker-setup.sh` in interactive mode, which prompts for these values. +This is the easiest path after running `run` in interactive mode, which prompts for these values. ### Create a Single User @@ -170,9 +170,9 @@ Prints a table of all users in the database with their verification status. ## Docker Compose Changes -Both `docker-compose.yaml` and `docker-compose-build.yaml` have been updated: +`docker-compose.yaml` has been updated: -- **Environment variables use `.env` interpolation** — no more editing passwords directly in the YAML. All config flows from the `.env` file generated by `docker-setup.sh`. +- **Environment variables use `.env` interpolation** — no more editing passwords directly in the YAML. All config flows from the `.env` file generated by `run`. - **Native healthchecks** — Datomic and the application containers declare healthchecks so that dependent services wait for readiness automatically. This replaces fragile startup-order workarounds. - **Service dependencies use `condition: service_healthy`** — nginx won't start until the app is actually serving, and the app won't start until Datomic is accepting connections. diff --git a/docs/email-system.md b/docs/email-system.md index 579dc331f..a6b39384d 100644 --- a/docs/email-system.md +++ b/docs/email-system.md @@ -98,13 +98,27 @@ User attributes related to email and verification (`src/clj/orcpub/db/schema.clj ### 4. Error Notification -**Trigger:** Called from exception handlers (e.g., Pedestal error interceptor) +**Trigger:** Called from `service-error-handler` interceptor in `routes.clj` on unhandled exceptions -- Sends a plaintext email with the request context and exception data -- Only sends if `EMAIL_ERRORS_TO` is set -- Uses `email/send-error-email` +- Only sends if `EMAIL_ERRORS_TO` env var is set +- Sends plaintext email with scrubbed request, filtered stack trace, and cause chain +- Throttled: one email per unique error fingerprint per 5 minutes +- Detects Pedestal-wrapped exceptions (extracts real exception from `ex-data :exception`) -**Files:** `email.clj:send-error-email` +**Subject format:** `[AppName] ExceptionClass: message @ METHOD /uri` + +**Request scrubbing:** Strips credentials (`:json-params`, `:transit-params`, `:form-params`), session +data (`:identity`, `:body`), Datomic objects (`:db`, `:conn`), servlet internals, and cookie headers. +Only safe headers are included (user-agent, referer, content-type, CF headers, etc.). + +**Stack trace filtering:** Shows only `orcpub.*` frames. Falls back to deepest non-infrastructure +frame when no app frames exist. Infrastructure prefixes (Jetty, Pedestal, clojure.lang, Thread) +are suppressed with a count. + +**Throttle fingerprint:** Root cause class + deepest `orcpub.*` frame method (or first 60 chars +of root message). Suppressed duplicates are logged to stdout. + +**Files:** `email.clj:send-error-email`, `routes.clj:service-error-handler` ## Rate Limiting (Email Change) diff --git a/docs/error-email-improvements.md b/docs/error-email-improvements.md new file mode 100644 index 000000000..b3ba78ebe --- /dev/null +++ b/docs/error-email-improvements.md @@ -0,0 +1,432 @@ +# Error Email Improvements — Analysis & Handoff + +Live document. Updated as each email example is analyzed. When all examples are covered, convert the plan section into implementation tasks and commit. + +**Branch:** `dmv/hotfix-integrations` (keep in sync with `breaking/`) +**Files in scope:** `src/clj/orcpub/email.clj`, `src/clj/orcpub/routes.clj`, `docs/ERROR_HANDLING.md`, `docs/email-system.md` + +--- + +## How error emails work today + +1. Any unhandled exception in a Pedestal interceptor chain hits `service-error-handler` in `routes.clj:1378`. +2. That calls `email/send-error-email ctx ex` (`email.clj:238`). +3. `send-error-email` fires only when `EMAIL_ERRORS_TO` env var is set. +4. Subject is always the hard-coded string `"Exception"`. +5. Body is `pprint` of `(:request context)` + `pprint` of `(or (ex-data exception) exception)`. + +--- + +## Known problems (from codebase review, before example analysis) + +| # | Problem | Impact | +|---|---------|--------| +| P1 | Subject is always `"Exception"` | Inbox is untriadgeable; every email looks the same | +| P2 | Body uses `(or (ex-data exception) exception)` — for `ExceptionInfo` this prints the data map only, no stack trace | Root cause is invisible | +| P3 | For plain Java exceptions, pprinting the Java object is not a stack trace | Same — no frames | +| P4 | Full `(:request context)` is dumped — includes `Authorization` headers, session cookies, POST bodies | Security exposure + very noisy | +| P5 | No flood throttle — a bad code path firing in a loop sends unlimited emails | Admin inbox spam, alert fatigue | +| P6 | No cause-chain traversal — only the outermost exception is shown | Wrapped exceptions hide the real error | +| P7 | `:json-params` (parsed POST body) is included in request dump — login requests contain `:password` | **Critical: plaintext credentials in admin email** | +| P8 | `:db` and `:conn` fields (live Datomic objects) are in request context and get dumped — they pprint to internal DB identifiers, t-values, index-rev | Noise + exposes internal DB metadata | +| P9 | Pedestal wraps the real exception under an `:exception` key inside `ex-data` — current format buries the actual stack trace one level deep | Hard to read; actionable frames require digging into nested map | +| P10 | `:cookie` header is fully included — exposes CloudFlare clearance tokens, analytics IDs, consent UUIDs | User session data leaked to admin email | +| P11 | Java object refs (`:servlet-request`, `:servlet-response`, `:servlet`, `:url-for`, `:body` stream) are dumped as `#object[...]` strings | Pure noise, adds length with zero signal | + +--- + +## Example emails + +### Example 1 — Raw Jetty infrastructure stack + +**Full body received:** + +``` +{"ServletHolder.java" 845] + [org.eclipse.jetty.servlet.ServletHandler doHandle "ServletHandler.java" 583] + [org.eclipse.jetty.server.handler.ScopedHandler handle "ScopedHandler.java" 143] + [org.eclipse.jetty.server.handler.gzip.GzipHandler handle "GzipHandler.java" 399] + [org.eclipse.jetty.server.handler.ContextHandler doHandle "ContextHandler.java" 1162] + [org.eclipse.jetty.servlet.ServletHandler doScope "ServletHandler.java" 511] + [org.eclipse.jetty.server.handler.ContextHandler doScope "ContextHandler.java" 1092] + [org.eclipse.jetty.server.handler.ScopedHandler handle "ScopedHandler.java" 141] + [org.eclipse.jetty.server.handler.HandlerWrapper handle "HandlerWrapper.java" 134] + [org.eclipse.jetty.server.Server handle "Server.java" 518] + [org.eclipse.jetty.server.HttpChannel handle "HttpChannel.java" 308] + [org.eclipse.jetty.server.HttpConnection onFillable "HttpConnection.java" 244] + [org.eclipse.jetty.io.AbstractConnection$ReadCallback succeeded "AbstractConnection.java" 273] + [org.eclipse.jetty.io.FillInterest fillable "FillInterest.java" 95] + [org.eclipse.jetty.io.SelectChannelEndPoint$2 run "SelectChannelEndPoint.java" 93] + [org.eclipse.jetty.util.thread.strategy.ExecuteProduceConsume produceAndRun "ExecuteProduceConsume.java" 246] + [org.eclipse.jetty.util.thread.strategy.ExecuteProduceConsume run "ExecuteProduceConsume.java" 156] + [org.eclipse.jetty.util.thread.QueuedThreadPool runJob "QueuedThreadPool.java" 654] + [org.eclipse.jetty.util.thread.QueuedThreadPool$3 run "QueuedThreadPool.java" 572] + [java.lang.Thread run "Thread.java" 750]]}} +``` + +**What this tells us:** + +- Every single frame is `org.eclipse.jetty.*` or `java.lang.Thread`. Zero `orcpub.*` frames are visible. +- The body snippet begins mid-structure (the `{` at the start is a truncated pprint of the exception map). The actual exception type and message are not shown — they were in the part of the pprint that was cut off or came before. +- This is `(ex-data exception)` being pprinted for an `ExceptionInfo` whose `:cause` is a Jetty-wrapped exception. The entire call graph is infrastructure scaffolding — completely unactionable. +- The email format (P3, P2) is the direct cause: because `(or (ex-data exception) exception)` selects the data map over the Java exception, the `.getStackTrace()` frames are never rendered at all. What appears here is a Clojure/EDN representation of the stack frames stored inside the exception data — not a filtered Java stack trace. + +**What's needed to make this actionable:** + +- Walk `(.getCause exception)` chain to find the deepest cause. +- Render `.getStackTrace()` as text, filtered to keep only `orcpub.*` frames (with infrastructure count appended). +- Label the exception type and message clearly at the top. + +--- + +## Plan (current state) + +### Send-error-email rewrite (`email.clj`) + +1. **Subject:** `[AppName] ExceptionClassName: message-preview @ METHOD /path` + Example: `[DMV] NullPointerException: Cannot read field on nil @ GET /character/12345` + +2. **Stack trace rendering:** + - Walk `.getCause` chain (for Java exceptions) and `:via` chain (for `ExceptionInfo`); render each level. + - For each, print: `ExceptionClass: message` then indented frames. + - Filter frames: keep `orcpub\.` frames; suppress `org.eclipse.jetty`, `io.pedestal`, `clojure.lang`, `java.lang.Thread`, `sun.reflect`, `java.util.concurrent`. Append `... N frames suppressed`. + - **Fallback (P15):** if zero `orcpub.*` frames exist after filtering, include the deepest non-suppressed frame from the innermost cause (e.g., `datomic.sql/connect`) rather than rendering nothing. Label it `← deepest non-infrastructure frame`. + +3. **Request scrub:** extract only safe fields from `(:request context)`: + - Keep: `:request-method`, `:uri`, `:query-string`, `:remote-addr`, `:route-name`, `:username` (if present — it's useful context and not a secret) + - Drop from headers: `authorization`, `cookie`, `x-auth-token`, `x-session` + - Drop entirely: `:json-params`, `:transit-params`, `:form-params`, `:body`, `:db`, `:conn`, `:servlet-request`, `:servlet-response`, `:servlet`, `:url-for`, `:async-supported?`, `:identity` + +4. **`ex-data` block:** keep but exclude from Pedestal wrapper — extract `(-> ex-data :exception ex-data)` (the inner exception's data), not the outer Pedestal context map. The Pedestal context fields (`:execution-id`, `:stage`, `:interceptor`) go in a separate "Interceptor context" section. + +5. **Flood throttle (P5, P16):** + - Primary fingerprint: `exception-class + first-orcpub-frame` + - Fallback fingerprint (no orcpub frames): `root-cause-class + first 60 chars of root-cause-message` + - Skip send (log instead) if same fingerprint seen within 5 minutes. + - Log: `"Suppressed duplicate error email (fingerprint: %s, last sent: %s ago)"`. + +### Docs updates + +- `docs/ERROR_HANDLING.md` — update "Error Notification" section with new email format. +- `docs/email-system.md` — update § 4 with throttle behaviour and scrubbed fields list. + +--- + +### Example 2 — Transactor unavailable during login (full email) + +**What arrived:** Two pprinted blocks — the full Pedestal request map, then the Pedestal interceptor error context map. + +**What the exception is:** + +``` +clojure.lang.ExceptionInfo +:db.error/transactor-unavailable Transactor not available + at datomic.peer$transactor_unavailable (peer.clj:185) + datomic.peer.Connection transact (peer.clj:331) + datomic.api$transact (api.clj:94) + → orcpub.routes$create_login_response (routes.clj:205) + → orcpub.routes$login_response (routes.clj:233) + → orcpub.routes$login (routes.clj:237) + io.pedestal.interceptor.chain ... [infrastructure] + org.eclipse.jetty ... [infrastructure] +``` + +A user hit `POST /login` while the Datomic transactor was down. The three `orcpub.routes` frames tell us exactly what code path was live. This is completely actionable — with the current email format you have to hunt for it inside a deeply nested pprint. + +**New problems surfaced by this example:** + +- **P7 (critical):** The request dump includes `:json-params {:username "...", :password ""}`. The `` was done by whoever forwarded us the email — **the app itself sent the plaintext password**. Login, registration, and password-reset routes all POST credentials that would appear here. +- **P8:** `:db datomic.db.Db@82bd5625` and `:conn #object[datomic.peer.Connection ... {:db-id "orcpub-e1a68122-...", :next-t 181480137, ...}]` — both are live Java objects that Pedestal injects into the request map. They pprint to internal connection metadata including the full DB UUID and transaction counters. +- **P9:** The exception is nested as `(-> (ex-data interceptor-error) :exception)`. The current `pprint` of `ex-data` does output the full #error map (so the trace IS technically present), but it's buried under `:execution-id`, `:stage`, `:interceptor`, `:exception-type`, then `:exception`, then `:trace`. In Example 1 the email was likely truncated before those frames appeared. +- **P10:** The full `:cookie` string is in the headers dump — CloudFlare clearance token, Matomo `_pk_id`/`_pk_ses`, Google Analytics `_ga`, IAB consent UUIDs. These belong to individual users. +- **P11:** `:servlet-request`, `:servlet-response`, `:servlet`, `:url-for`, `:body`, `:async-supported?` are all Java/fn object refs — `#object[...]` strings with zero diagnostic value. + +**Flood throttle is important for this class of error:** Datomic going down means every authenticated request will throw the same exception. Without throttling, a 60-second Datomic blip at peak traffic could send hundreds of identical emails before anyone can react. + +**What a good email for this error would look like:** + +``` +Subject: [DMV] ExceptionInfo: :db.error/transactor-unavailable @ POST /login + +Request: POST /login (10.0.38.3 via 2605:a601:..., Firefox/147.0 Windows) + +Exception chain: + clojure.lang.ExceptionInfo: :db.error/transactor-unavailable Transactor not available + data: {:db/error :db.error/transactor-unavailable} + at orcpub.routes/create-login-response (routes.clj:205) + orcpub.routes/login-response (routes.clj:233) + orcpub.routes/login (routes.clj:237) + ... 40 infrastructure frames suppressed (datomic.*, io.pedestal.*, org.eclipse.jetty.*) + +Interceptor context: + {:execution-id 1329, :stage :enter, :interceptor :orcpub.routes/login} +``` + +--- + +### Example 3 — H2 storage backend refused connection on `GET /dnd/5e/items` + +**What the exception is — 3-level cause chain:** + +``` +java.util.concurrent.ExecutionException + wraps → org.h2.jdbc.JdbcSQLException + "Connection is broken: java.net.ConnectException: Connection refused: datomic:4335" + wraps → java.net.ConnectException + "Connection refused (Connection refused)" + at java.net.PlainSocketImpl.socketConnect (native) + org.h2.engine.SessionRemote.connectServer (SessionRemote.java:395) + datomic.sql/connect (sql.clj:16) + datomic.kv_sql.KVSql.get (kv_sql.clj:60) + datomic.kv_cluster ... [retry logic] + java.util.concurrent.FutureTask ... [thread pool] +``` + +**What this tells us:** + +- Port 4335 is the H2 SQL storage backend that Datomic Free uses underneath the transactor. This is a different failure layer from Example 2 (port 4334, transactor unreachable). Both happened within ~1 minute of each other (04:50:57 and 04:51:48 on the same day) with identical DB identifiers (`orcpub-e1a68122-...`, `next-t 181480137`). **This was a single Datomic outage event that generated at least two emails in under a minute** — possibly many more across all concurrent requests. +- Zero `orcpub.*` frames anywhere in the trace. The failure is entirely inside Datomic's storage layer. With the current frame-filter plan (keep only `orcpub.*`), this email would render an empty stack trace. The plan needs a fallback. +- The request is authenticated — `:identity {:user "millennialdoomer", :exp 1772560158}` is the decoded JWT payload injected by the auth interceptor, and `:username "millennialdoomer"` is an additional field. Both are in the request map. + +**New problems surfaced:** + +- **P12 (critical):** `authorization: Token eyJhbG...` — a live signed JWT is in the headers dump. It's still valid until `:exp 1772560158`. Anyone with this email can impersonate that user until expiry. +- **P13:** `:identity` (decoded JWT) is in the request map — exposes username and token expiry. +- **P14:** `:username` field (added by auth interceptor) echoes the username again — minor, but confirms auth-enriched fields are present on all authenticated routes. +- **P15 (plan gap):** When zero `orcpub.*` frames exist, the filtered stack trace should fall back to showing the deepest non-boilerplate frame — e.g., `datomic.sql/connect (sql.clj:16)` — rather than rendering nothing. The cause message alone (`Connection refused: datomic:4335`) is enough to diagnose layer but a single anchor frame is more useful than silence. +- **P16 (throttle fingerprint design):** Fingerprinting by `exception-class + first-orcpub-frame` won't work when there are no orcpub frames. Fingerprint should fall back to `root-cause-class + root-cause-message-prefix (first 60 chars)`. For this incident both Example 2 and Example 3 would get separate fingerprints (different root cause classes/messages) — which is correct, they're different failure modes. But many copies of Example 3 across concurrent `/items` requests would correctly collapse to one. + +**What a good email for this error would look like:** + +``` +Subject: [DMV] ExecutionException: Connection is broken "Connection refused: datomic:4335" @ GET /dnd/5e/items + +Request: GET /dnd/5e/items (10.0.38.3 via 2600:4040:..., Chrome/143 Opera GX Windows) +User: millennialdoomer + +Exception chain: + java.util.concurrent.ExecutionException + wraps → org.h2.jdbc.JdbcSQLException: Connection is broken: "java.net.ConnectException: Connection refused: datomic:4335" + wraps → java.net.ConnectException: Connection refused (Connection refused) + at datomic.sql/connect (sql.clj:16) ← deepest non-infra frame + ... 38 infrastructure frames suppressed (java.net.*, org.h2.*, org.apache.tomcat.*, datomic.kv_*) + +Interceptor context: + {:execution-id 1286, :stage :enter, :interceptor :orcpub.routes/item-list} +``` + +--- + +### Example 4 — `IllegalArgumentException: No matching clause` during character save + +**Timestamp:** 04:50:25 — 32 seconds *before* Example 3, same DB connection (`next-t 181480137`, same `db-id`). Same Datomic outage window. + +**What the exception is:** + +``` +java.lang.IllegalArgumentException: No matching clause: + at orcpub.routes/do-save-character (routes.clj:755) + orcpub.routes/save-character (routes.clj:761) + io.pedestal ... [infrastructure] + org.eclipse.jetty ... [infrastructure] +``` + +**What actually caused it — a secondary bug:** + +`do-save-character` (currently at `routes.clj:932`) has this pattern: + +```clojure +(catch clojure.lang.ExceptionInfo e + (let [data (ex-data e)] + (case (:error data) + :character-problems {:status 400 :body (:problems data)} + :not-user-character {:status 401 ...}))) + ; ← NO DEFAULT CLAUSE +``` + +The Datomic transactor-unavailable exception is an `ExceptionInfo` with `{:db/error :db.error/transactor-unavailable}` — note `:db/error`, not `:error`. So `(:error data)` returns `nil`. Clojure's `case` with no default clause throws `IllegalArgumentException: No matching clause: ` (empty, because `(str nil)` is `""`). + +**The Datomic outage caused a secondary application bug to fire.** The actual Datomic error was swallowed by the wrong catch block and transformed into a misleading `IllegalArgumentException`. Without this email (and the effort to trace it), the admin would have seen an obscure character-save crash with no obvious connection to the infrastructure failure already reported by Examples 2 and 3. + +**This is a separate fixable bug independent of the email improvements:** add a default `case` clause that re-throws: + +```clojure +(case (:error data) + :character-problems {:status 400 :body (:problems data)} + :not-user-character {:status 401 :body "You do not own this character"} + (throw e)) ; ← re-throw unrecognized ExceptionInfo +``` + +**New problems surfaced:** + +- **P17:** `:transit-params` contains the full parsed request body. For character saves, that's the entire character entity — deeply nested, including DB entity IDs, all equipment, stats, ability scores, class/race selections. ~5KB of user character data per email. Not credentials, but user data that has no business in admin emails. +- **P18:** `:content-length 5388` and `:content-type "application/transit+json"` confirm the email includes the full parsed transit body even for large POST requests. No size cap. + +**On "we still don't know WHY Datomic dropped":** + +Correct, and the error emails can never tell you. `send-error-email` fires at the HTTP request layer — it knows a request failed because Datomic was unreachable, but the transactor itself logs its own shutdown reason separately in `logs/datomic.log`. + +**However:** when the Datomic container drops, Docker restarts it and clears the container — `logs/datomic.log` is gone. **The error emails are the only post-mortem signal available.** This makes fixing them higher priority than previously assessed, and also means any Datomic-level diagnostics (restart count, OOM, OOD, etc.) must come from Docker/orchestration tooling, not the app. Out of scope for these improvements but worth tracking separately. + +--- + +## Pending examples + +*Paste additional error emails below as they come in. Each gets its own subsection with the same structure: raw body → what it tells us → any new problems identified → plan additions.* + +--- + +## Datomic crash root cause (from log analysis) + +### Logs examined +- `logs/datomic.1.log` — Feb 24 (64,695 lines) +- `logs/datomic.2.log` — Feb 25 +- `logs/datomic.3.log` — Feb 26 from 00:00 (the crash at 04:50 that generated the emails is past the visible excerpt) + +### Pattern — confirmed across all crashes + +Every crash follows the same sequence immediately before `"Critical failure, cannot continue: Heartbeat failed"`: + +``` +08:35:24 kv-cluster/create-val bufsize=74,458 msec=5,300 +08:35:24 kv-cluster/create-val bufsize=74,923 msec=5,440 +08:35:24 kv-cluster/create-val bufsize=1,394,358 msec=19,500 ← large write +08:35:39 transactor/heartbeat-failed cause=:timeout +08:35:39 ERROR Critical failure, cannot continue: Heartbeat failed +08:35:44 ActiveMQ Artemis stopped (uptime 7 days 19 hours) +08:37:23 Starting datomic:free://... ← Docker restart +08:38:01 System started ← recovery ~2.5 min after crash +``` + +**Root cause:** H2 write latency under concurrent load spikes (5–27 seconds per `kv-cluster/create-val`). Datomic's heartbeat must write to storage every 5 seconds. When a storage write saturates H2's I/O, the heartbeat misses its deadline and the transactor self-terminates with `cause: :timeout`. + +This is a known limitation of **Datomic Free + H2**: H2 is a single-file embedded database that cannot handle concurrent write contention. Under heavy character save traffic (large transit payloads — the 5 KB character from Example 4 becomes 400KB–5MB of kv-cluster segments), H2 latency spikes and the heartbeat dies. + +### Frequency + +| Log file | Date | Crashes observed | +|----------|------|-----------------| +| datomic.1.log | Feb 24 | 3 (08:35, 09:41, 21:21) | +| datomic.2.log | Feb 25 | 4+ (05:37, 06:56, 08:08, 09:19, plus more) | +| datomic.3.log | Feb 26 | ongoing (04:50 crash generated the example emails) | + +**This is not an occasional blip — Datomic is crashing multiple times per day.** + +### Why the container-restart wipes the log + +Docker restarts the Datomic container on crash. The container's log volume is written to rotated files (`datomic.N.log`) but the *current* container's live log is lost on kill. The rotated files are what we have. The restart cycle (crash → down ~1 min → restart → ready ~2 min) means 2–3 minutes of unavailability per crash. + +### Fix options (out of scope for this PR, but should be tracked) + +| Option | What it does | Complexity | +|--------|-------------|------------| +| Migrate to Datomic Pro + PostgreSQL | Replaces H2 with a proper concurrent database; eliminates the write-contention crash mode entirely | High — requires Datomic Pro license and storage migration | +| Tune H2 write-ahead log / connection pool | May reduce contention but doesn't eliminate it | Medium — requires Datomic Free config experimentation | +| Add Docker restart delay + health check | Avoids thundering-herd of app requests hitting a half-started transactor | Low — Docker Compose `healthcheck` config | +| Add `DATOMIC_TRANSACTOR_OPTS` memory tuning | More JVM heap → larger write buffers → fewer contention spikes | Low | + +The most important near-term mitigation is the **flood throttle** in the email fix: a Datomic outage currently generates one error email per in-flight request (potentially hundreds). With throttling, each distinct failure mode sends one email per 5 minutes. + +- [x] Fix missing `case` default in `do-save-character` catch block (`routes.clj:~947`) — **done** +- [ ] Fix bracket error in `email.clj:~390` (one extra `)`) — **in progress by another agent** +- [ ] Verify `email.clj` compiles clean (`lein check` or start REPL) +- [ ] Update `docs/ERROR_HANDLING.md` — see spec below +- [ ] Update `docs/email-system.md` — see spec below +- [ ] Smoke-test — see spec below +- [ ] Commit both files + docs to `dmv/hotfix-integrations` +- [ ] Sync commit to `breaking/` + +--- + +## Implementation guide (for the agent doing the work) + +### Current state of `email.clj` + +The rewrite of `send-error-email` has been applied but has a bracket imbalance at the closing of the function (~line 390). The intended final structure of the closing is: + +```clojure + nil)))))) ; catch / try / do / if / let / when +``` + +That is 6 closing parens after `nil`: +1. `)` closes `catch Exception` +2. `)` closes `try` +3. `)` closes `do` +4. `)` closes `if (throttled?)` +5. `)` closes `let [data-map ...]` +6. `)` closes `when (not-empty ...)` + +Do **not** add a 7th. Run `lein check` after fixing to confirm zero errors before proceeding. + +### `routes.clj` — already done + +The `case` default clause (`(throw e)`) is in place at `routes.clj:~947`. No further changes needed there. + +### `docs/ERROR_HANDLING.md` — what to change + +Find the "Error Notification" subsection under "Email Operations" and replace the description with: + +- Function: `email/send-error-email ctx ex` +- Triggered by: `service-error-handler` in `routes.clj` on any unhandled interceptor exception +- Subject format: `[AppName] ExceptionClassName: message-preview @ METHOD /path` +- Body sections: `=== Request ===` (scrubbed), `=== Exception ===` (cause chain + filtered frames), `=== Exception Data ===` (ex-data if present), `=== Interceptor Context ===` (Pedestal metadata) +- Scrubbed from request: all body params (`:json-params`, `:transit-params`, `:form-params`), credentials headers (`authorization`, `cookie`), Datomic objects (`:db`, `:conn`), Java object refs, `:identity` +- Throttle: one email per fingerprint per 5 minutes; duplicates logged as `INFO: Suppressed duplicate error email` + +### `docs/email-system.md` — what to change + +Update § 4 "Error Notification" (currently just "Called from exception handlers..."): + +- Add the scrubbed fields list (same as above) +- Add the throttle window (5 min) and log message +- Add a note that `EMAIL_ERRORS_TO` must be set; if unset, function is a no-op +- Add a note that `logs/datomic.log` is cleared on container restart — error emails are the only post-mortem signal for Datomic outages + +### Smoke test + +Since `EMAIL_ERRORS_TO` won't be set in dev, test the helper functions directly in a REPL: + +```clojure +(require '[orcpub.email :as email]) + +;; 1. Scrubbing — confirm no creds leak +(email/scrub-request {:uri "/login" + :request-method :post + :json-params {:username "foo" :password "secret"} + :headers {"authorization" "Token xyz" + "cookie" "cf_clearance=abc" + "user-agent" "Mozilla/5.0"} + :db (Object.) + :conn (Object.)}) +;; Expected: {:uri "/login", :request-method :post, :headers {"user-agent" "Mozilla/5.0"}} +;; Must NOT contain :json-params, :db, :conn, authorization, cookie + +;; 2. Subject line +(email/email-subject (Exception. "boom") {:request-method :get :uri "/test"}) +;; Expected: "[DMV] Exception: boom @ GET /test" + +;; 3. Throttle — second call suppressed +(let [ex (Exception. "test")] + (email/record-sent! (email/throttle-fingerprint ex)) + (email/throttled? (email/throttle-fingerprint ex))) +;; Expected: true (a Long timestamp, truthy) +``` + +Note: `scrub-request`, `email-subject`, `throttle-fingerprint`, `throttled?`, and `record-sent!` are `defn-` (private). Either make them `defn` temporarily for testing, or test via `#'orcpub.email/scrub-request`. + +### Commit message + +``` +fix: improve error notification emails and fix save-character exception masking + +- send-error-email: scrub credentials/cookies/body params from request dump +- send-error-email: render filtered stack trace (orcpub.* frames only, + fallback to deepest non-infra frame) +- send-error-email: walk full cause chain +- send-error-email: readable subject line with exception type + route +- send-error-email: 5-minute flood throttle per error fingerprint +- do-save-character: add case default clause to re-throw unrecognised + ExceptionInfo (previously masked Datomic errors as IllegalArgumentException) + +Fixes P1-P18 documented in docs/error-email-improvements.md +``` diff --git a/docs/kb/README.md b/docs/kb/README.md new file mode 100644 index 000000000..dad7d896f --- /dev/null +++ b/docs/kb/README.md @@ -0,0 +1,18 @@ +# OrcPub Agent Knowledge Base + +Verified, research-backed findings from in-depth investigations. Each document is sourced from +direct inspection of code, logs, or authoritative references. Speculation is marked +**⚠️ UNVALIDATED SPECULATION** and must not be treated as fact without further verification. + +## Index + +| Document | Topic | Source quality | +|----------|-------|---------------| +| [datomic-crash-analysis.md](datomic-crash-analysis.md) | Datomic transactor crashes — root cause, frequency, fix options | High — direct log analysis from `logs/datomic.{1,2,3}.log` | + +## Contribution rules + +- Only add findings you can cite directly (log lines, code lines, benchmark results, official docs). +- If you are reasoning from circumstantial evidence, mark the entire paragraph with **⚠️ UNVALIDATED SPECULATION — [brief rationale]**. +- Include the date the analysis was done and the artifact(s) it was based on. +- Do not remove speculation flags — if something is later verified, replace the flag with a **✅ VERIFIED — [how]** marker and update the text. diff --git a/docs/kb/datomic-crash-analysis.md b/docs/kb/datomic-crash-analysis.md new file mode 100644 index 000000000..7b21c5cea --- /dev/null +++ b/docs/kb/datomic-crash-analysis.md @@ -0,0 +1,204 @@ +# Datomic Transactor Crash Analysis + +**Analyzed:** 2026-02-26 +**Artifacts:** `logs/datomic.1.log` (64,695 lines, Feb 24), `logs/datomic.2.log` (Feb 25), `logs/datomic.3.log` (Feb 26 from 00:00) +**Branch at time of analysis:** `dmv/hotfix-integrations` + +--- + +## Active transactor configuration (verified from log startup lines) + +``` +heartbeatIntervalMsec=5000 +writeConcurrency=4 +memoryIndexMax=256m +memoryIndexThreshold=32m +txTimeoutMsec=10000 +``` + +Source: the transactor logs its own config on startup. These values were read directly +from the log startup block in `logs/datomic.1.log`. + +--- + +## Crash mechanism — verified + +Every crash in all three log files follows an identical sequence. Example from +`datomic.1.log` 2026-02-24 08:35: + +``` +08:35:19 kv-cluster/create-val bufsize=74,458 msec=5,300 (tid 1212) +08:35:19 kv-cluster/create-val bufsize=74,923 msec=5,440 (tid 1213) +... + ← 14-second gap; no log output of any kind → + +08:35:38 kv-cluster/create-val bufsize=1,394,358 msec=19,500 (tid 982) +08:35:39 transactor/heartbeat-failed cause=:timeout +08:35:39 ERROR Critical failure, cannot continue: Heartbeat failed +08:35:44 ActiveMQ Artemis stopped (uptime 7 days 19 hours) +08:35:46 kv-cluster/create-val bufsize=5,795,494 msec=27,100 (tid 1195) ← still draining +08:37:23 Starting datomic:free://... ← Docker restart +08:38:01 System started ← recovery +``` + +**What is happening:** + +1. The memoryIndex threshold triggers a segment flush. Multiple write threads + (up to `writeConcurrency=4`) begin writing `kv-cluster` segments to H2. +2. H2 is a single-writer embedded database. Concurrent writes serialize on an + exclusive file lock. When one large write is in progress, all other writes + — including the heartbeat's own timestamp write — queue behind it. +3. The heartbeat thread (tid 21) fires every `heartbeatIntervalMsec=5000` ms. + Its write is blocked by the H2 lock. After approximately 3× the interval + (~15 seconds, confirmed: heartbeat fired at 08:35:24.377, failed at + 08:35:39.350 = exactly 15 seconds), the transactor declares itself dead + and self-terminates. +4. Docker restarts the container. Recovery takes ~2.5 minutes. + +**The direct killer is H2 write serialization, not the writes themselves.** +A single 19.5-second write blocked the heartbeat from acquiring the H2 lock +for longer than the 15-second failure threshold. + +--- + +## GC role — verified not sufficient alone + +Datomic logs every JVM GC event via `datomic.log-gc`. All observed GC events are: + +``` +G1 Young Generation / end of minor GC / G1 Evacuation Pause +``` + +GC pause durations near the 08:35 crash: +- 08:30:58 — **1380 ms** (largest observed in entire log) +- 08:31:18 — 331 ms +- 08:31:31 — 364 ms +- 08:31:42 — 330 ms +- ...continuing through 08:33:47 at intervals of 5–15 seconds +- **Last GC before crash: 08:33:47 (295 ms) — 1 minute 52 seconds before crash** +- **No GC events between 08:33:47 and crash at 08:35:39** + +The largest GC pause observed (1380 ms) is well below the 15-second heartbeat +failure threshold. GC alone cannot kill the transactor. + +**⚠️ UNVALIDATED SPECULATION — [plausible mechanism, not directly observable in logs]:** +The GC storm between 08:30 and 08:33 (minor GC every ~10 seconds, up to 1.4s pauses) +likely reflects the memoryIndex flush churning through large object graphs. Each GC +pause interrupts the H2 write threads mid-operation. Because H2 holds file locks across +the full write duration (not just during active I/O), a write that would take 2–3s under +no-GC conditions may stretch to 19–27s when repeatedly interrupted by 300–1400ms STW +pauses. This is the probable mechanism connecting the GC activity to the anomalous write +latency, but it cannot be confirmed from logs alone — it would require JVM flight +recorder data or an H2 lock trace. + +--- + +## Crash frequency — verified + +| Log file | Date | Crash times (UTC) | Crashes | +|----------|------|-------------------|---------| +| datomic.1.log | Feb 24 | 08:35, 09:41, 21:21 | 3 | +| datomic.2.log | Feb 25 | 05:37, 06:56, 08:08, 09:19 + more | 4+ | +| datomic.3.log | Feb 26 | 04:50 (generated the email examples) | 1+ (log starts at 00:00) | + +This is not an occasional blip. The transactor is crashing multiple times daily with +roughly 60–90 minute intervals between crashes during high-activity windows. + +--- + +## Schema noHistory status — verified, no action needed + +`src/clj/orcpub/db/schema.clj` already applies `:db/noHistory true` to all high-churn +gameplay attributes: + +- `::char5e/current-hit-points` +- `::char5e/notes` +- `::char5e/prepared-spells` / `::char5e/prepared-spells-by-class` +- `::char5e/worn-armor`, `::char5e/wielded-shield`, `::char5e/main-hand-weapon`, `::char5e/off-hand-weapon` +- All spell slot usage (`::char5e/features-used`, `::spells5e/slots-used`, all slot-level keys) +- All time-unit usage trackers (`::units5e/minute`, `::units5e/round`, etc.) +- All of `magic-item-schema` and `weapon-schema` + +Removing history from additional attributes would not affect the crash. The crash +is caused by segment *flush* volume (memoryIndex → H2 kv-cluster writes), not by +the presence of historical datoms in those writes. + +--- + +## `writeConcurrency=4` is actively harmful with H2 + +H2 cannot parallelize writes — it serializes them internally on a file lock. +`writeConcurrency=4` causes 4 threads to contend simultaneously for that lock, +meaning all 4 wait while whichever one holds the lock makes slow progress. This +amplifies total write latency without increasing throughput. + +**⚠️ UNVALIDATED SPECULATION — [well-reasoned but untested in this codebase]:** +Reducing `writeConcurrency` to `1` should eliminate the multi-thread H2 contention +and reduce the probability of a single write holding the lock long enough to starve +the heartbeat. However, this has not been tested. It may reduce throughput under +bursty write loads if the bottleneck shifts from contention to raw H2 sequential I/O. +If the total volume of writes during a flush exceeds what a single thread can process +within the heartbeat window, crashes could still occur — just less frequently. + +Config to try: +``` +datomic.writeConcurrency=1 +``` + +This is a transactor properties file change — no code change required. + +--- + +## Increasing heartbeat interval — not recommended + +Setting `heartbeatIntervalMsec` to e.g. 60000 (1 minute) would raise the failure +threshold to ~3 minutes, which is longer than the observed 19.5-second worst-case +write. This would stop the self-termination. + +**This is not a fix.** During the same write-backpressure window, user transactions +queue behind the H2 lock with a `txTimeoutMsec=10000` (10s) timeout. Users would see +transaction timeout errors regardless. Raising the heartbeat masks the infrastructure +signal (crash + admin email) while leaving the user-visible failure intact — and makes +the system harder to monitor. + +--- + +## Recovery time + +Each crash results in approximately **2–3 minutes of complete unavailability**: +- Transactor self-terminates (~1s) +- Docker detects exit and restarts container (~10–30s depending on health check config) +- Datomic transactor starts, initializes storage, begins accepting peer connections (~90–120s) +- Peer reconnects + +During this window, all requests that require Datomic (all authenticated write routes, +all read routes that aren't cached in the peer) will fail with +`transactor-unavailable` or `Connection refused: datomic:4335`. + +--- + +## Fix options + +| Option | Severs which link in the failure chain | Verified effectiveness | Complexity | +|--------|----------------------------------------|----------------------|------------| +| `writeConcurrency=1` | Eliminates concurrent H2 lock contention | ⚠️ UNVALIDATED SPECULATION — should help, see above | Low — config only | +| `memoryIndexMax=512m` or higher | Fewer flushes → fewer contention windows | ⚠️ UNVALIDATED SPECULATION — trades memory for frequency | Low — config only | +| Migrate to Datomic Pro + PostgreSQL | Replaces H2 entirely; Postgres handles concurrent writers and is not subject to single-file lock contention | Established — Datomic Pro + Postgres is the documented production path | High — storage migration required | +| Application-layer circuit breaker | Detects slow `d/transact` calls (>2s) and returns 503 on write endpoints; reads remain up | ⚠️ UNVALIDATED SPECULATION — requires careful implementation; does not prevent crashes | Medium — application code | +| `heartbeatIntervalMsec=15000–60000` | Prevents self-termination | Verified would stop crashes | Low — config only; **NOT RECOMMENDED** — masks signal, see above | +| Docker health check + restart delay | Avoids thundering-herd of app reconnects against a half-started transactor | Verified useful as secondary measure | Low — Docker Compose config | + +**Recommended path:** `writeConcurrency=1` as immediate mitigation, Postgres migration +as the permanent fix. See TODO entry: [Investigate Datomic + Postgres migration path](../TODO.md). + +--- + +## What the error emails reveal about this (relation to P1–P18 analysis) + +Each Datomic crash generates one error email **per in-flight request at the time of +crash**. At peak traffic this could be dozens to hundreds of emails per minute. +The flood throttle in the `send-error-email` rewrite (P5) is therefore especially +important for this failure class — without it, a single crash event could saturate +the admin inbox and trigger alert fatigue that causes real bugs to be missed. + +See [error-email-improvements.md](../error-email-improvements.md) for full analysis. diff --git a/docs/migration/datomic-data-migration.md b/docs/migration/datomic-data-migration.md index 9c7fb757d..32807dc3c 100644 --- a/docs/migration/datomic-data-migration.md +++ b/docs/migration/datomic-data-migration.md @@ -81,8 +81,7 @@ bin/datomic verify-backup true # verify (Pro only) # 2. Stop old stack, build and start new: docker compose down -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d # 3. After services are healthy, restore: ./docker-migrate.sh restore @@ -158,7 +157,7 @@ accessible, user accounts work. - Old Docker stack running (`docker compose ps` shows healthy datomic + orcpub) - `.env` file with correct `DATOMIC_PASSWORD` - Enough disk space (see [Performance](#performance)) -- New source code checked out (has `docker-compose-build.yaml` and migration scripts) +- New source code checked out (has `docker-compose.yaml` and migration scripts) Each phase launches a **temporary container** (`docker run --rm`) on the Compose network, bind-mounting `./backup/` for I/O. No running containers are modified. @@ -191,8 +190,7 @@ network, bind-mounting `./backup/` for I/O. No running containers are modified. docker compose down mv ./data ./data.free-backup mkdir -p ./data -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d ``` Wait for healthy: `docker compose ps` @@ -239,7 +237,7 @@ mv ./data.free-backup ./data docker compose down rm -rf ./data mv ./data.free-backup ./data -docker compose up -d # OLD compose file, not docker-compose-build.yaml +docker compose up -d # pulls pre-built images (old stack) ``` The backup directory is never modified — you can re-attempt the restore as many @@ -365,5 +363,5 @@ continue to work for regular backups going forward. - [datomic-pro.md](datomic-pro.md) — Code-level changes (dependency, URI, API) - [../ENVIRONMENT.md](../ENVIRONMENT.md) — Environment variable reference -- [../../docker-setup.sh](../../docker-setup.sh) — Initial Docker setup +- [../../run](../../run) — Initial Docker setup - [../../docker-user.sh](../../docker-user.sh) — User management after migration diff --git a/docs/migration/dev-tooling.md b/docs/migration/dev-tooling.md index 73a46190c..21ac66cb7 100644 --- a/docs/migration/dev-tooling.md +++ b/docs/migration/dev-tooling.md @@ -161,7 +161,7 @@ Single source of truth for all runtime configuration. Reads environment variable | `(config/get-datomic-uri)` | `DATOMIC_URL` env or `"datomic:dev://localhost:4334/orcpub"` | | `(config/get-csp-policy)` | `CSP_POLICY` env or `"strict"` | | `(config/strict-csp?)` | `true` when CSP policy is strict | -| `(config/dev-mode?)` | `true` when `DEV_MODE` env is truthy | +| `(config/dev-mode?)` | `true` when `DEV_MODE` env is the string `"true"` (case-insensitive) | | `(config/get-secure-headers-config)` | Pedestal secure-headers map based on CSP policy | Used by: `system.clj`, `pedestal.clj`, `user.clj` diff --git a/env/dev/env/index.cljs b/env/dev/env/index.cljs index 885bd1089..1fe47d535 100644 --- a/env/dev/env/index.cljs +++ b/env/dev/env/index.cljs @@ -5,6 +5,6 @@ (set! js/window.goog js/undefined) (-> (js/require "figwheel-bridge") - (.withModules #js {"react" (js/require "react"), "react-native" (js/require "react-native"), "expo" (js/require "expo"), "./assets/icons/app.png" (js/require "../../assets/icons/app.png"), "./assets/icons/loading.png" (js/require "../../assets/icons/loading.png"), "./assets/images/cljs.png" (js/require "../../assets/images/cljs.png"), "./assets/images/dmv-logo.svg" (js/require "../../assets/images/dmv-logo.svg"), "./assets/images/dmv-logo.png" (js/require "../../assets/images/dmv-logo.png")} + (.withModules #js {"react" (js/require "react"), "react-native" (js/require "react-native"), "expo" (js/require "expo"), "./assets/icons/app.png" (js/require "../../assets/icons/app.png"), "./assets/icons/loading.png" (js/require "../../assets/icons/loading.png"), "./assets/images/cljs.png" (js/require "../../assets/images/cljs.png"), "./assets/images/dmv-logo.svg" (js/require "../../assets/images/dmv-logo.svg"), "./assets/images/orcpub-logo.png" (js/require "../../assets/images/orcpub-logo.png")} ) (.start "main")) diff --git a/menu b/menu index 897a07697..f480391e6 100755 --- a/menu +++ b/menu @@ -64,6 +64,11 @@ Stop options (passed through to stop.sh): --yes, -y Skip confirmation --force, -f Use SIGKILL if needed +Remote Dev (Figwheel): + Figwheel hot-reload auto-detects GitHub Codespaces and configures + the WebSocket URL. For other remote setups (Gitpod, tunnels), set + FIGWHEEL_CONNECT_URL in .env. Check status: ./menu start figwheel -c + Tmux tips: Attach: tmux attach -t orcpub Detach: Ctrl+B, then D diff --git a/project.clj b/project.clj index 3f28a4d4b..f60454c33 100644 --- a/project.clj +++ b/project.clj @@ -23,7 +23,8 @@ :dependencies [[org.clojure/clojure "1.12.4"] [org.clojure/test.check "1.1.1"] [org.clojure/clojurescript "1.12.134"] - [org.clojure/core.async "1.8.741"] + [org.clojure/core.async "1.8.741"] + [org.postgresql/postgresql "42.7.3"] ;; React 18 + Reagent 2.0 (Concurrent Mode) [cljsjs/react "18.3.1-1"] [cljsjs/react-dom "18.3.1-1"] @@ -236,6 +237,7 @@ :pretty-print false}}}}} ;; Dev-only deps, source paths, and compiler overlays (devtools, re-frame-10x). :dev-config {:dependencies [[binaryage/devtools "1.0.7"] + [nrepl "1.3.0"] [cider/piggieback "0.5.3"] [day8.re-frame/re-frame-10x "1.11.0" :exclusions [zprint rewrite-clj]] ] diff --git a/resources/public/dnld/5eActionsReferencePage.pdf b/resources/public/dnld/5eActionsReferencePage.pdf new file mode 100644 index 000000000..33e9b3d4a Binary files /dev/null and b/resources/public/dnld/5eActionsReferencePage.pdf differ diff --git a/resources/public/SRD-OGL_V5.1.pdf b/resources/public/dnld/SRD-OGL_V5.1.pdf similarity index 100% rename from resources/public/SRD-OGL_V5.1.pdf rename to resources/public/dnld/SRD-OGL_V5.1.pdf diff --git a/resources/public/favicon.ico b/resources/public/favicon.ico index c3dc72ea8..6b71e6b34 100644 Binary files a/resources/public/favicon.ico and b/resources/public/favicon.ico differ diff --git a/resources/public/image/discussion.svg b/resources/public/image/discussion.svg new file mode 100644 index 000000000..871964b77 --- /dev/null +++ b/resources/public/image/discussion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/elven-castle.svg b/resources/public/image/elven-castle.svg new file mode 100644 index 000000000..418496d8e --- /dev/null +++ b/resources/public/image/elven-castle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/giant-squid.svg b/resources/public/image/giant-squid.svg new file mode 100644 index 000000000..c231b46d3 --- /dev/null +++ b/resources/public/image/giant-squid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/login-side.jpg b/resources/public/image/login-side.jpg index 3ea03361d..b37c6202b 100644 Binary files a/resources/public/image/login-side.jpg and b/resources/public/image/login-side.jpg differ diff --git a/resources/public/image/monk-face.svg b/resources/public/image/monk-face.svg new file mode 100644 index 000000000..f261f682d --- /dev/null +++ b/resources/public/image/monk-face.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/orcpub-box-logo.png b/resources/public/image/orcpub-box-logo.png deleted file mode 100644 index b7233cc85..000000000 Binary files a/resources/public/image/orcpub-box-logo.png and /dev/null differ diff --git a/resources/public/image/orcpub-card-logo - original.png b/resources/public/image/orcpub-card-logo - original.png deleted file mode 100644 index 3aa38cd55..000000000 Binary files a/resources/public/image/orcpub-card-logo - original.png and /dev/null differ diff --git a/resources/public/image/stone-tablet.svg b/resources/public/image/stone-tablet.svg new file mode 100644 index 000000000..92d86ff3f --- /dev/null +++ b/resources/public/image/stone-tablet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/image/wanted-reward.svg b/resources/public/image/wanted-reward.svg new file mode 100644 index 000000000..e0bf3dcb4 --- /dev/null +++ b/resources/public/image/wanted-reward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/public/js/cookies.js b/resources/public/js/cookies.js index c0210a203..47902b11d 100644 --- a/resources/public/js/cookies.js +++ b/resources/public/js/cookies.js @@ -9,8 +9,8 @@ function Pop() { var conDivObj; var fadeInTime = 10; var fadeOutTime = 10; - let cookie = { name: "cookieconsent_status", path: "/", expiryDays: 365 * 24 * 60 * 60 * 5000 }; - let content = { message: "This website uses cookies to ensure you get the best experience on our website.", btnText: "Got it!", mode: " banner bottom", theme: " theme-classic", palette: " palette1", link: "Learn more", href: "https://www.cookiesandyou.com", target: "_blank" }; + let cookie = { name: "flatsome_cookie_notice", path: "/", expiryDays: 365 * 24 * 60 * 60 * 5000 }; + let content = { message: "This website uses cookies to ensure you get the best experience on our website.", btnText: "Got it!", mode: " banner bottom", theme: " theme-classic", palette: " palette1", link: "Learn more", href: "/cookies-policy", target: "_blank" }; let createPopUp = function() { if (typeof conDivObj === "undefined") { conDivObj = document.createElement("DIV"); @@ -67,7 +67,7 @@ function Pop() { expires.setTime(expires.getTime() + cookie.expiryDays); document.cookie = cookie.name + "=" + - "ok" + + "1" + ";expires=" + expires.toUTCString() + "path=" + diff --git a/run b/run new file mode 100755 index 000000000..fe74f797c --- /dev/null +++ b/run @@ -0,0 +1,1651 @@ +#!/usr/bin/env bash +# +# OrcPub / Dungeon Master's Vault — Docker Setup Script +# +# Prepares everything needed to run the application via Docker Compose: +# 1. Generates secure random passwords and a signing secret +# 2. Creates a .env file (or uses an existing one) +# 3. Generates self-signed SSL certificates (if missing) +# 4. Creates required directories (data, logs, deploy/homebrew) +# +# Usage: +# ./${SCRIPT_NAME} # Interactive mode — prompts for optional values +# ./${SCRIPT_NAME} --auto # Non-interactive — accepts all defaults +# ./${SCRIPT_NAME} --help # Show usage +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" +ENV_FILE="${SCRIPT_DIR}/.env" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +color_green=$'\033[0;32m' +color_yellow=$'\033[1;33m' +color_red=$'\033[0;31m' +color_cyan=$'\033[0;36m' +color_magenta=$'\033[0;35m' +color_reset=$'\033[0m' + +info() { printf '%s[INFO]%s %s\n' "$color_green" "$color_reset" "$*"; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } +change() { printf '%s[FIXD]%s %s\n' "$color_magenta" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +success() { printf '\n%s=== %s ===%s\n' "$color_green" "$*" "$color_reset"; } +header() { printf '\n%s=== %s ===%s\n\n' "$color_cyan" "$*" "$color_reset"; } +next() { printf '%s ▸%s %s\n' "$color_cyan" "$color_reset" "$*"; } + +# Read a variable value from a .env file, stripping Windows \r line endings. +# Usage: val=$(read_env_val "VAR_NAME" "/path/to/.env") +read_env_val() { + grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true +} + +# Source a .env file safely (strips Windows \r line endings). +# Usage: source_env "/path/to/.env" +source_env() { + # shellcheck disable=SC1090 + . <(tr -d '\r' < "$1") +} + +# Source optional modules +# shellcheck source=scripts/swarm.sh +[ -f "${SCRIPT_DIR}/scripts/swarm.sh" ] && . "${SCRIPT_DIR}/scripts/swarm.sh" + +# Set a variable in a .env file. Uses awk to avoid sed delimiter issues with URLs. +# Usage: set_env_val "VAR_NAME" "value" "/path/to/.env" +set_env_val() { + local var="$1" val="$2" file="$3" + if grep -q "^${var}=" "$file" 2>/dev/null; then + awk -v var="$var" -v val="$val" '{ + if (index($0, var"=") == 1) print var"="val; else print + }' "$file" > "${file}.tmp" + mv "${file}.tmp" "$file" + else + echo "${var}=${val}" >> "$file" + fi +} + +generate_password() { + # Generate a URL-safe random password (no special chars that break URLs/YAML) + local length="${1:-24}" + if command -v openssl &>/dev/null; then + openssl rand -base64 "$((length * 2))" | tr -d '/+=' | head -c "$length" + elif [ -r /dev/urandom ]; then + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$length" + else + error "Cannot generate random password: no openssl or /dev/urandom available" + exit 1 + fi +} + +# Generate docker-compose.secrets.yaml and wire COMPOSE_FILE into .env. +# Usage: write_compose_secrets "file" → file-based secrets +# write_compose_secrets "external" → Swarm external secrets +write_compose_secrets() { + local mode="$1" # "file" or "external" + local secrets_compose="${SCRIPT_DIR}/docker-compose.secrets.yaml" + local compose_file_var="docker-compose.yaml:docker-compose.secrets.yaml" + + if [ -f "$secrets_compose" ] && [ "$FORCE_MODE" = "false" ]; then + warn "docker-compose.secrets.yaml already exists. Use --force to overwrite." + return 0 + fi + + if [ "$mode" = "file" ]; then + cat > "$secrets_compose" < "$secrets_compose" <> "$ENV_FILE" + change "Added COMPOSE_FILE to .env (compose will merge both files)" + elif [ "$current_cf" != "$compose_file_var" ]; then + warn "COMPOSE_FILE already set in .env: ${current_cf}" + warn "Make sure it includes docker-compose.secrets.yaml" + fi + else + warn "No .env file — add to your environment:" + warn " export COMPOSE_FILE=${compose_file_var}" + fi +} + +# Switch transactor host binding between Compose and Swarm modes. +# Usage: switch_transactor_host "compose" → host=datomic (Compose DNS bind) +# switch_transactor_host "swarm" → host=0.0.0.0 (Swarm all-interfaces) +switch_transactor_host() { + local mode="$1" + local template="${SCRIPT_DIR}/docker/transactor.properties.template" + [ -f "$template" ] || return 0 + + if [ "$mode" = "compose" ]; then + if grep -q '^host=0\.0\.0\.0' "$template"; then + sed -i 's/^host=0\.0\.0\.0$/#host=0.0.0.0/' "$template" + sed -i 's/^#host=datomic$/host=datomic/' "$template" + change "Transactor host: 0.0.0.0 → datomic (Compose bind)" + fi + elif [ "$mode" = "swarm" ]; then + if grep -q '^host=datomic' "$template"; then + sed -i 's/^host=datomic$/#host=datomic/' "$template" + sed -i 's/^#host=0\.0\.0\.0$/host=0.0.0.0/' "$template" + change "Transactor host: datomic → 0.0.0.0 (Swarm bind)" + fi + fi +} + +# Read DATOMIC_PASSWORD, ADMIN_PASSWORD, SIGNATURE from .env + shell env. +# Sets _pw_datomic, _pw_admin, _pw_signature. Exits on missing values. +# Usage: read_passwords "--secrets" (label for error message) +read_passwords() { + local mode_label="$1" + _pw_datomic="" + _pw_admin="" + _pw_signature="" + + if [ -f "$ENV_FILE" ]; then + info "Reading passwords from .env" + _pw_datomic=$(read_env_val DATOMIC_PASSWORD "$ENV_FILE") + _pw_admin=$(read_env_val ADMIN_PASSWORD "$ENV_FILE") + _pw_signature=$(read_env_val SIGNATURE "$ENV_FILE") + fi + + # Fill gaps from shell env vars (for admins who export directly) + _pw_datomic="${_pw_datomic:-${DATOMIC_PASSWORD:-}}" + _pw_admin="${_pw_admin:-${ADMIN_PASSWORD:-}}" + _pw_signature="${_pw_signature:-${SIGNATURE:-}}" + + if [ -z "$_pw_datomic" ] || [ -z "$_pw_admin" ] || [ -z "$_pw_signature" ]; then + error "Could not find all required passwords." + error "Checked .env file and shell environment variables." + [ -z "$_pw_datomic" ] && error " DATOMIC_PASSWORD — not found" + [ -z "$_pw_admin" ] && error " ADMIN_PASSWORD — not found" + [ -z "$_pw_signature" ] && error " SIGNATURE — not found" + echo "" + error "Either create a .env file (./${SCRIPT_NAME}) or export the" + error "variables in your shell before running ${mode_label}." + exit 1 + fi +} + +# Check that a file exists, incrementing ERRORS if not. +# Usage: check_file "label" "/path/to/file" +check_file() { + local label="$1" path="$2" + if [ -f "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING" + WARNING_MSGS+=("${label} is missing") + ERRORS=$((ERRORS + 1)) + fi +} + +# Check that a directory exists, incrementing ERRORS if not. +# Usage: check_dir "label" "/path/to/dir" +check_dir() { + local label="$1" path="$2" + if [ -d "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING" + WARNING_MSGS+=("${label} is missing") + ERRORS=$((ERRORS + 1)) + fi +} + +# Check if a shell env var conflicts with .env value, incrementing ERRORS if so. +# Usage: check_env_conflict "VAR_NAME" +check_env_conflict() { + local var_name="$1" + local env_val="${!var_name:-}" + local file_val + file_val=$(read_env_val "$var_name" "$ENV_FILE") + + if [ -n "$env_val" ] && [ -n "$file_val" ] && [ "$env_val" != "$file_val" ]; then + warn " Shell \$${var_name} differs from .env value" + warn " Shell: ${env_val}" + warn " .env: ${file_val}" + ENV_CONFLICTS+=("$var_name") + WARNING_MSGS+=("\$${var_name}: shell value overrides .env") + ERRORS=$((ERRORS + 1)) + fi +} + +# Build a docker compose command, prefixing with env -u for each shell/env conflict. +# Usage: build_compose_cmd "docker compose up --build -d" +build_compose_cmd() { + local base_cmd="$1" + if [ "${#ENV_CONFLICTS[@]}" -gt 0 ]; then + local prefix="" + for var in "${ENV_CONFLICTS[@]}"; do + prefix="${prefix}env -u ${var} " + done + echo "${prefix}${base_cmd}" + else + echo "$base_cmd" + fi +} + +prompt_value() { + local prompt_text="$1" + local default_value="$2" + local result + + if [ "${AUTO_MODE:-false}" = "true" ]; then + echo "$default_value" + return + fi + + if [ -n "$default_value" ]; then + read -rp "${prompt_text} [${default_value}]: " result || true + echo "${result:-$default_value}" + else + read -rp "${prompt_text}: " result || true + echo "$result" + fi +} + +# Confirm an action, auto-accepting in auto mode. +# Returns 0 (yes) or 1 (no). Usage: +# if confirm_action "Stop Compose services now?"; then ... +confirm_action() { + local prompt_text="$1" + if [ "${AUTO_MODE:-false}" = "true" ]; then + return 0 + fi + local _answer + read -rp "${prompt_text} [Y/n]: " _answer || true + [[ "${_answer,,}" =~ ^(y|)$ ]] +} + +# Write .env file using current variable values. +# Expects PORT, ADMIN_PASSWORD, DATOMIC_PASSWORD, SIGNATURE, EMAIL_*, ORCPUB_IMAGE, +# DATOMIC_IMAGE, INIT_ADMIN_* to be set in scope before calling. +write_env_file() { + cat > "$ENV_FILE" </dev/null; then + info "Generating self-signed SSL certificate..." + openssl req \ + -subj "/C=US/ST=State/L=City/O=OrcPub/OU=Dev/CN=localhost" \ + -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$key" -out "$cert" 2>/dev/null + change "SSL certificate generated (valid for 365 days)." + else + warn "openssl not found — cannot generate SSL certificates." + warn "Install openssl and run: ./deploy/snakeoil.sh" + fi +} + +# Generate a fresh .env through prompts (interactive) or defaults (--auto). +# Also creates directories and SSL certs. Works in both modes because +# prompt_value() returns the default silently when AUTO_MODE=true. +generate_env() { + header "Database Passwords" + + DEFAULT_ADMIN_PW="$(generate_password 24)" + DEFAULT_DATOMIC_PW="$(generate_password 24)" + DEFAULT_SIGNATURE="$(generate_password 32)" + + echo "" + echo " Datomic uses two passwords (both locked into the DB on first startup):" + echo " Admin password — internal, controls database create/delete" + echo " Peer password — shared between transactor and app for connections" + + if [ -f "${SCRIPT_DIR}/data/db/datomic.mv.db" ]; then + echo "" + warn "Existing database found in data/db/." + warn "The admin password is locked into this database. If you set a new" + warn "password, the transactor will fail to start. Either:" + warn " 1. Keep the same admin password as before" + warn " 2. Wipe the database first: rm -rf data/db/*" + fi + + echo "" + ADMIN_PASSWORD=$(prompt_value "Admin password" "$DEFAULT_ADMIN_PW") + DATOMIC_PASSWORD=$(prompt_value "Peer password" "$DEFAULT_DATOMIC_PW") + SIGNATURE=$(prompt_value "JWT signing secret (20+ chars)" "$DEFAULT_SIGNATURE") + + header "Application" + + PORT=$(prompt_value "Application port" "8890") + + _image_tag=$(prompt_value "Image tag (leave empty for default)" "") + if [ -n "$_image_tag" ]; then + ORCPUB_IMAGE="orcpub-app:${_image_tag}" + DATOMIC_IMAGE="orcpub-datomic:${_image_tag}" + else + ORCPUB_IMAGE="" + DATOMIC_IMAGE="" + fi + EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") + EMAIL_ACCESS_KEY="" + EMAIL_SECRET_KEY="" + EMAIL_SERVER_PORT="587" + EMAIL_FROM_ADDRESS="" + EMAIL_ERRORS_TO="" + EMAIL_SSL="FALSE" + EMAIL_TLS="FALSE" + + if [ -n "$EMAIL_SERVER_URL" ]; then + EMAIL_ACCESS_KEY=$(prompt_value "SMTP username" "") + EMAIL_SECRET_KEY=$(prompt_value "SMTP password" "") + EMAIL_SERVER_PORT=$(prompt_value "SMTP port" "587") + EMAIL_FROM_ADDRESS=$(prompt_value "From email address" "no-reply@orcpub.com") + EMAIL_ERRORS_TO=$(prompt_value "Error notification email" "") + EMAIL_SSL=$(prompt_value "Use SSL? (TRUE/FALSE)" "FALSE") + EMAIL_TLS=$(prompt_value "Use TLS? (TRUE/FALSE)" "FALSE") + fi + + header "Initial Admin User" + + INIT_ADMIN_USER="${INIT_ADMIN_USER:-}" + INIT_ADMIN_EMAIL="${INIT_ADMIN_EMAIL:-}" + INIT_ADMIN_PASSWORD="${INIT_ADMIN_PASSWORD:-}" + + if [ -n "$INIT_ADMIN_USER" ] && [ -n "$INIT_ADMIN_EMAIL" ] && [ -n "$INIT_ADMIN_PASSWORD" ]; then + info "Using admin user from environment: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" + elif [ "${AUTO_MODE}" = "true" ]; then + INIT_ADMIN_USER="admin" + INIT_ADMIN_EMAIL="admin@localhost" + INIT_ADMIN_PASSWORD=$(generate_password 16) + change "Generated admin user: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" + change "Generated admin password: ${INIT_ADMIN_PASSWORD}" + info "Change these in .env before going to production." + else + info "Optionally create an initial admin account." + info "You can skip this and create users later with ./docker-user.sh" + echo "" + INIT_ADMIN_USER=$(prompt_value "Admin username (leave empty to skip)" "") + if [ -n "$INIT_ADMIN_USER" ]; then + _default_email="${INIT_ADMIN_USER}@example.com" + _default_pw="$(generate_password 16)" + INIT_ADMIN_EMAIL=$(prompt_value "Admin email" "$_default_email") + INIT_ADMIN_PASSWORD=$(prompt_value "Admin password" "$_default_pw") + if [ -z "$INIT_ADMIN_EMAIL" ] || [ -z "$INIT_ADMIN_PASSWORD" ]; then + warn "Email and password are required. Skipping admin user setup." + INIT_ADMIN_USER="" + INIT_ADMIN_EMAIL="" + INIT_ADMIN_PASSWORD="" + fi + fi + fi + + info "Writing .env file..." + write_env_file + setup_directories + setup_ssl_certs + echo "" +} + +usage() { + cat < build -> up (interactive) + ./${SCRIPT_NAME} --auto Same, non-interactive (CI/scripting) + +Individual steps: + --check Validate .env and environment (read-only) + --build Build Docker images only + --up Deploy as a Docker Swarm stack + --down Teardown (auto-detects Swarm vs Compose) + +Setup & secrets: + --secrets Convert .env passwords to Docker secret files + --swarm Convert .env passwords to Docker Swarm secrets + --upgrade Update an existing .env to the latest format + --upgrade-secrets Upgrade .env + create Docker secret files (one step) + --upgrade-swarm Upgrade .env + create Swarm secrets (one step) + +Modifiers: + --auto Non-interactive mode; accept all defaults + --force Overwrite existing .env file + --help Show this help message + +Swarm (flags compose together): + ./${SCRIPT_NAME} --swarm --auto --build --up # Zero to running stack + +Examples: + ./${SCRIPT_NAME} # Full pipeline — interactive + ./${SCRIPT_NAME} --auto # Full pipeline — accept defaults (CI) + ./${SCRIPT_NAME} --down # Stop everything + ./${SCRIPT_NAME} --check # Validate without changes + ./${SCRIPT_NAME} --build # Build images only + ./${SCRIPT_NAME} --upgrade # Fix old .env format + ./${SCRIPT_NAME} --swarm --auto # Generate .env + init Swarm + create secrets + ./${SCRIPT_NAME} --build --up # Build + deploy (Swarm only) +USAGE +} + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- + +AUTO_MODE=false +BUILD_MODE=false +CHECK_MODE=false +DOWN_MODE=false +UP_MODE=false +FORCE_MODE=false +SECRETS_MODE=false +SWARM_MODE=false +UPGRADE_MODE=false + +for arg in "$@"; do + case "$arg" in + --auto) AUTO_MODE=true ;; + --check) CHECK_MODE=true ;; + --build) BUILD_MODE=true ;; + --down) DOWN_MODE=true ;; + --up) UP_MODE=true ;; + --force) FORCE_MODE=true ;; + --secrets) SECRETS_MODE=true ;; + --swarm) SWARM_MODE=true ;; + --upgrade) UPGRADE_MODE=true ;; + --upgrade-swarm) UPGRADE_MODE=true; SWARM_MODE=true ;; + --upgrade-secrets) UPGRADE_MODE=true; SECRETS_MODE=true ;; + --help) usage; exit 0 ;; + *) + error "Unknown option: $arg" + usage + exit 1 + ;; + esac +done + +# --secrets alone = Compose file-based secrets +# --swarm --secrets = Swarm Raft-based secrets (after compose generation) + +# Conflict: --down is standalone +if [ "$DOWN_MODE" = "true" ]; then + for _m in "$UP_MODE" "$BUILD_MODE" "$SECRETS_MODE" "$SWARM_MODE" "$UPGRADE_MODE"; do + if [ "$_m" = "true" ]; then + error "--down cannot be combined with other mode flags." + exit 1 + fi + done +fi + +# Detect naked mode (no mode flags set) +_any_mode=false +for _m in "$CHECK_MODE" "$SECRETS_MODE" "$SWARM_MODE" "$BUILD_MODE" "$UP_MODE" "$UPGRADE_MODE" "$DOWN_MODE"; do + [ "$_m" = "true" ] && _any_mode=true +done + +# --------------------------------------------------------------------------- +# Check mode (--check) — read-only validation +# --------------------------------------------------------------------------- +# Validates .env has all required variables, checks for common issues, +# and reports shell env conflicts. Makes no changes. + +if [ "$CHECK_MODE" = "true" ]; then + header "Dungeon Master's Vault — Environment Check" + + if [ ! -f "$ENV_FILE" ]; then + warn "No .env file found." + echo " 1) Interactive setup (prompts for each value)" + echo " 2) Auto setup (generates random passwords, safe defaults)" + echo " 3) Skip" + read -rp "Choice [1]: " _create || true + case "${_create:-1}" in + 1) exec "$0" ;; + 2) exec "$0" --auto ;; + *) exit 0 ;; + esac + fi + + ERRORS=0 + WARNING_MSGS=() + ENV_CONFLICTS=() + + # Required variables — check .env first, then secrets/ files + _has_secrets_dir=false + [ -d "${SCRIPT_DIR}/secrets" ] && _has_secrets_dir=true + + for _var in DATOMIC_URL DATOMIC_PASSWORD ADMIN_PASSWORD SIGNATURE; do + _val=$(read_env_val "$_var" "$ENV_FILE") + if [ -n "$_val" ]; then + info " ${_var}: OK" + elif [ "$_has_secrets_dir" = "true" ]; then + # Map var names to secret file names + case "$_var" in + DATOMIC_PASSWORD) _secret_file="datomic_password" ;; + ADMIN_PASSWORD) _secret_file="admin_password" ;; + SIGNATURE) _secret_file="signature" ;; + *) _secret_file="" ;; + esac + if [ -n "$_secret_file" ] && [ -f "${SCRIPT_DIR}/secrets/${_secret_file}" ]; then + info " ${_var}: OK (via secrets/${_secret_file})" + elif [ -n "$_secret_file" ] && docker secret inspect "$_secret_file" &>/dev/null; then + info " ${_var}: OK (via Swarm secret ${_secret_file})" + else + warn " ${_var}: MISSING" + WARNING_MSGS+=("${_var} is missing from .env") + ERRORS=$((ERRORS + 1)) + fi + else + warn " ${_var}: MISSING" + WARNING_MSGS+=("${_var} is missing from .env") + ERRORS=$((ERRORS + 1)) + fi + done + + echo "" + + # URL health checks + _url=$(read_env_val DATOMIC_URL "$ENV_FILE") + if [[ "$_url" == *"password="* ]]; then + warn " DATOMIC_URL has embedded password (legacy format)" + warn " Run ./${SCRIPT_NAME} --upgrade to clean it" + WARNING_MSGS+=("DATOMIC_URL has embedded password") + ERRORS=$((ERRORS + 1)) + fi + if [[ "$_url" == *"datomic:free://"* ]]; then + warn " DATOMIC_URL uses old Free protocol" + warn " Run ./${SCRIPT_NAME} --upgrade to convert to datomic:dev://" + WARNING_MSGS+=("DATOMIC_URL uses Free protocol") + ERRORS=$((ERRORS + 1)) + fi + if [[ "$_url" == *"localhost"* ]]; then + warn " DATOMIC_URL contains 'localhost' (should be 'datomic' for Docker)" + warn " Run ./${SCRIPT_NAME} --upgrade to fix" + WARNING_MSGS+=("DATOMIC_URL contains localhost") + ERRORS=$((ERRORS + 1)) + fi + + echo "" + + # Shell env conflicts + for _var in DATOMIC_URL DATOMIC_PASSWORD SIGNATURE ADMIN_PASSWORD; do + check_env_conflict "$_var" + done + + # Required files and directories + echo "" + check_file ".env" "$ENV_FILE" + check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" + check_file "nginx.conf.template" "${SCRIPT_DIR}/deploy/nginx.conf.template" + check_dir "data/" "${SCRIPT_DIR}/data" + check_dir "logs/" "${SCRIPT_DIR}/logs" + + echo "" + if [ "$ERRORS" -eq 0 ]; then + success "All checks passed" + else + warn "${ERRORS} issue(s) found." + read -rp "Run upgrade to fix? [Y/n]: " _fix || true + if [[ "${_fix,,}" =~ ^(y|)$ ]]; then + exec "$0" --upgrade + fi + fi + exit 0 +fi + +# --------------------------------------------------------------------------- +# Down mode (--down) — teardown running services +# --------------------------------------------------------------------------- + +if [ "$DOWN_MODE" = "true" ]; then + header "Dungeon Master's Vault — Teardown" + + _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) + + if [ "$_swarm_state" = "active" ] && docker stack ls 2>/dev/null | grep -q orcpub; then + info "Swarm stack detected." + if confirm_action "Remove Swarm stack 'orcpub'?"; then + docker stack rm orcpub 2>&1 + change "Swarm stack removed." + fi + elif docker compose ps -q 2>/dev/null | head -1 | grep -q .; then + info "Compose services detected." + if confirm_action "Tear down Compose services?"; then + docker compose down 2>&1 + change "Compose services stopped." + fi + else + info "No running services found." + fi + + exit 0 +fi + +# --------------------------------------------------------------------------- +# Secrets migration (--secrets mode) +# --------------------------------------------------------------------------- +# Reads passwords from .env or shell environment and writes them as +# individual secret files. Works whether you use .env or export vars directly. +# Auto-generates docker-compose.secrets.yaml and wires it via COMPOSE_FILE. + +if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then + header "Dungeon Master's Vault — Secrets Migration" + + # Ask if they're running Swarm — different flow entirely + if [ "${AUTO_MODE}" != "true" ]; then + echo "This will create secret files on your machine (works with docker compose)." + echo "If you're running Docker Swarm, secrets are stored in the cluster instead." + echo "" + read -rp "Are you using Docker Swarm? [y/N]: " _is_swarm || true + if [[ "${_is_swarm,,}" == "y" ]]; then + exec "$0" --swarm + fi + fi + + SECRETS_DIR="${SCRIPT_DIR}/secrets" + + # If no .env exists, generate one (enables one-step secrets setup) + if [ ! -f "$ENV_FILE" ]; then + generate_env + fi + + read_passwords "--secrets" + + # Check for existing secrets (--auto implies --force here) + if [ -d "$SECRETS_DIR" ] && [ "$FORCE_MODE" = "false" ] && [ "$AUTO_MODE" != "true" ]; then + warn "secrets/ directory already exists. Use --force to overwrite." + exit 1 + fi + + mkdir -p "$SECRETS_DIR" + + # Write each password to its own file (printf, not echo, to avoid trailing newline) + printf '%s' "$_pw_datomic" > "${SECRETS_DIR}/datomic_password" || { error "Failed to write ${SECRETS_DIR}/datomic_password"; exit 1; } + printf '%s' "$_pw_admin" > "${SECRETS_DIR}/admin_password" || { error "Failed to write ${SECRETS_DIR}/admin_password"; exit 1; } + printf '%s' "$_pw_signature" > "${SECRETS_DIR}/signature" || { error "Failed to write ${SECRETS_DIR}/signature"; exit 1; } + chmod 644 "${SECRETS_DIR}"/* + + change "Created secrets/datomic_password" + change "Created secrets/admin_password" + change "Created secrets/signature" + change "File permissions set to 644" + + unset _pw_datomic _pw_admin _pw_signature + + # Generate compose override so secrets are wired in automatically + write_compose_secrets "file" + + # Revert transactor host to compose-compatible binding if previously + # switched to Swarm mode (host=0.0.0.0). + switch_transactor_host "compose" + + # Move secret vars from .env to a backup file so they aren't duplicated + BACKUP_FILE="${ENV_FILE}.secrets.backup" + SECRET_VARS="DATOMIC_PASSWORD|ADMIN_PASSWORD|SIGNATURE" + _moved=0 + if grep -qE "^(${SECRET_VARS})=" "$ENV_FILE" 2>/dev/null; then + grep -E "^(${SECRET_VARS})=" "$ENV_FILE" >> "$BACKUP_FILE" + sed -i -E "/^(${SECRET_VARS})=/d" "$ENV_FILE" + _moved=1 + fi + + echo "" + success "Done! Passwords are in secrets/, compose is configured." + if [ "$_moved" -eq 1 ]; then + change "Moved DATOMIC_PASSWORD, ADMIN_PASSWORD, SIGNATURE from .env to .env.secrets.backup" + warn "Delete .env.secrets.backup after verifying secrets work." + fi + echo "" + next "docker compose down && docker compose up -d" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Swarm compose generation (--swarm) — standalone Swarm-ready YAML +# --------------------------------------------------------------------------- +# Pure text file generation — no Docker daemon required. +# Produces docker-compose.swarm.yaml + .env.portainer for Portainer import. + +if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then + # Ensure .env exists — generate if needed + if [ ! -f "$ENV_FILE" ]; then + generate_env + fi + source_env "$ENV_FILE" + + _backup_file="" + _is_upgrade=false + + if [ -f "$SWARM_COMPOSE" ]; then + _is_upgrade=true + _backup_file="${SWARM_COMPOSE}.backup.$(date +%s)" + cp "$SWARM_COMPOSE" "$_backup_file" + info "Existing swarm compose backed up: $_backup_file" + + # Extract customizations from existing file + if ! extract_swarm_config "$SWARM_COMPOSE"; then + warn "Could not parse existing compose — generating fresh." + _is_upgrade=false + fi + fi + + # Generate the swarm compose file (uses _swarm_* vars if upgrade) + generate_swarm_compose "$SWARM_COMPOSE" "$_is_upgrade" + change "Generated: $SWARM_COMPOSE" + + # Generate stripped .env for Portainer advanced mode + generate_env_portainer "$ENV_FILE" "$SWARM_PORTAINER_ENV" + change "Generated: $SWARM_PORTAINER_ENV" + + # Generate transactor.properties reference if bind-mount detected + if [ "${_swarm_has_transactor_props:-false}" = "true" ]; then + if generate_transactor_reference; then + change "Generated: ${SCRIPT_DIR}/transactor.properties.reference" + fi + fi + + # Show colorized summary + print_swarm_summary "$SWARM_COMPOSE" "$_is_upgrade" "$_backup_file" + + # If --build, --up, or --secrets also specified, fall through; otherwise exit + if [ "$BUILD_MODE" != "true" ] && [ "$UP_MODE" != "true" ] && [ "$SECRETS_MODE" != "true" ]; then + exit 0 + fi +fi +# --------------------------------------------------------------------------- +# Swarm secrets (--swarm --secrets) +# --------------------------------------------------------------------------- +# Creates Docker secrets via `docker secret create` for use in Swarm clusters. +# Secrets are stored encrypted in the Swarm Raft log and delivered to +# containers in memory — never written to disk on any node. +# Requires a running Docker daemon in Swarm mode. + +if [ "$SWARM_MODE" = "true" ] && [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then + header "Dungeon Master's Vault — Swarm Secrets" + + # Check for running Compose containers — they'll conflict with Swarm networking + _running=$(docker compose ps -q 2>/dev/null | head -1 || true) + if [ -n "$_running" ]; then + warn "Compose containers are running. These must be stopped before Swarm deploy." + if confirm_action "Stop Compose services now?"; then + docker compose down 2>&1 || true + change "Compose services stopped." + else + warn "Swarm deploy will fail if Compose networks overlap. Continuing anyway..." + fi + fi + + # Verify this node is part of a Swarm — offer to initialize if not + _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) + if [ "$_swarm_state" != "active" ]; then + warn "This Docker node is not in Swarm mode." + echo "" + if confirm_action "Initialize a single-node Swarm now?"; then + docker swarm init 2>&1 || { error "Failed to initialize Swarm."; exit 1; } + change "Docker Swarm initialized." + else + info "Run 'docker swarm init' manually, then re-run: ./${SCRIPT_NAME} --swarm --secrets" + exit 0 + fi + fi + + # If no .env exists, generate one (enables one-step swarm setup) + if [ ! -f "$ENV_FILE" ]; then + generate_env + fi + + read_passwords "--swarm" + + # Check for existing secrets and handle accordingly + _existing=0 + _created=0 + + for _name in datomic_password admin_password signature; do + if docker secret inspect "$_name" &>/dev/null; then + if [ "$FORCE_MODE" = "true" ]; then + # Swarm doesn't support updating secrets — must remove and recreate. + # Services using the secret must be stopped first. + docker secret rm "$_name" &>/dev/null || true + warn "Removed existing secret: $_name (--force)" + else + warn "Secret already exists: $_name (use --force to replace)" + _existing=$((_existing + 1)) + continue + fi + fi + + # Get the right password value for this secret name + case "$_name" in + datomic_password) _val="$_pw_datomic" ;; + admin_password) _val="$_pw_admin" ;; + signature) _val="$_pw_signature" ;; + esac + + if printf '%s' "$_val" | docker secret create "$_name" - 2>/dev/null; then + change "Created Swarm secret: $_name" + _created=$((_created + 1)) + else + error "Failed to create Swarm secret: $_name" + exit 1 + fi + done + + unset _pw_datomic _pw_admin _pw_signature _val + + echo "" + if [ "$_existing" -gt 0 ] && [ "$FORCE_MODE" = "false" ]; then + warn "${_existing} secret(s) already existed (skipped)." + fi + if [ "$_created" -gt 0 ]; then + change "${_created} Swarm secret(s) created." + fi + + # Generate compose override so secrets are wired in automatically + write_compose_secrets "external" + + # Uncomment secrets blocks in swarm compose if it exists + if [ -f "$SWARM_COMPOSE" ]; then + activate_swarm_secrets "$SWARM_COMPOSE" + change "Activated secrets in $(basename "$SWARM_COMPOSE")" + fi + + # Switch transactor to Swarm-compatible host binding. + switch_transactor_host "swarm" + set_env_val ALT_HOST datomic "$ENV_FILE" + change "ALT_HOST=datomic (peer fallback via Docker DNS)" + + echo "" + success "Done! Swarm secrets created, compose is configured." + + # If --build or --up also specified, fall through to those blocks. + if [ "$BUILD_MODE" != "true" ] && [ "$UP_MODE" != "true" ]; then + echo "" + echo "Next steps:" + echo "" + printf ' %sBUILD%s images:\n' "$color_cyan" "$color_reset" + next " ./${SCRIPT_NAME} --build" + echo "" + printf ' %sDEPLOY%s as a Swarm stack:\n' "$color_cyan" "$color_reset" + next " ./${SCRIPT_NAME} --up" + echo "" + info "Or both in one step: ./${SCRIPT_NAME} --build --up" + echo "" + echo "--- Tip: Managing secrets later ---" + echo " docker secret ls # List all secrets" + echo " docker secret inspect # Show metadata (not the value)" + echo " docker secret rm # Remove (stop services first)" + exit 0 + fi +fi +# --------------------------------------------------------------------------- +# Build mode (--build) — build Docker images +# --------------------------------------------------------------------------- + +if [ "$BUILD_MODE" = "true" ] && [ "$UP_MODE" != "true" ]; then + header "Dungeon Master's Vault — Build" + + if ! docker compose version &>/dev/null; then + error "docker compose is not available." + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + error "No .env file found." + echo " Run setup first: ./${SCRIPT_NAME} [--auto]" + if confirm_action "Run setup now?"; then + generate_env + else + exit 1 + fi + fi + + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } + + echo "" + success "Images built!" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Up mode (--up) — deploy as a Docker Swarm stack +# --------------------------------------------------------------------------- + +if [ "$UP_MODE" = "true" ]; then + header "Dungeon Master's Vault — Swarm Deploy" + + if ! docker compose version &>/dev/null; then + error "docker compose is not available." + exit 1 + fi + + # Verify Swarm is active + _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) + if [ "$_swarm_state" != "active" ]; then + error "Docker Swarm is not active." + echo "" + echo "Options:" + echo " 1) Initialize Swarm: ./${SCRIPT_NAME} --swarm [--auto]" + echo " 2) Use Compose instead: docker compose up --build -d" + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + error "No .env file found." + echo " Run setup first: ./${SCRIPT_NAME} [--auto]" + exit 1 + fi + + if ! command -v jq &>/dev/null; then + error "jq is required for --up. Install it with: apt-get install jq" + exit 1 + fi + + if [ "$BUILD_MODE" = "true" ]; then + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } + echo "" + fi + + info "Deploying stack..." + # docker compose config resolves ${VAR:-default} from shell env first, then + # .env. Shell vars (e.g. Codespace DATOMIC_URL=localhost) override .env + # values meant for Docker. Unset known conflicts so .env wins. + _deploy_env=(DATOMIC_URL DATOMIC_PASSWORD SIGNATURE ADMIN_PASSWORD) + for _v in "${_deploy_env[@]}"; do unset "$_v" 2>/dev/null || true; done + + # docker compose config outputs Compose Specification format; + # docker stack deploy expects legacy v3 schema. JSON + jq bridges the gap. + # See docs/kb/docker-swarm-compat.md for the full incompatibility list. + docker compose config --format json | jq ' + del(.name) | + .services |= with_entries( + .value.depends_on |= (if type == "object" then keys else . end) + ) | + .services |= with_entries( + .value.ports |= (if . then [.[] | .published |= tonumber] else . end) + ) | + # Strip explicit nulls last — Swarm rejects null entrypoint/command/ports + # and |= on missing keys re-creates them as null + .services |= with_entries( + .value |= with_entries(select(.value != null)) + ) + ' | docker stack deploy -c - orcpub || { error "Deploy failed."; exit 1; } + + echo "" + success "Stack deployed!" + echo "" + echo "Useful commands:" + echo " docker stack services orcpub # List services" + echo " docker stack ps orcpub # List tasks" + echo " docker service logs # View logs" + exit 0 +fi + + +# --------------------------------------------------------------------------- +# Upgrade existing .env (--upgrade mode) +# --------------------------------------------------------------------------- +# Reads the current .env, detects old patterns, fixes them, adds missing +# variables. Backs up the original first. No data loss. + +if [ "$UPGRADE_MODE" = "true" ]; then + header "Dungeon Master's Vault — Upgrade .env" + + if [ ! -f "$ENV_FILE" ]; then + warn "No .env file found." + info "Scanning config files for hardcoded values..." + + _compose="${SCRIPT_DIR}/docker-compose.yaml" + _transactor="${SCRIPT_DIR}/deploy/transactor.properties" + _found=0 + + # Declare associative arrays for values from each source + declare -A _compose_vals _transactor_vals + + # Scan docker-compose.yaml for hardcoded values (not ${...} templated) + if [ -f "$_compose" ]; then + for _var in DATOMIC_URL DATOMIC_PASSWORD ADMIN_PASSWORD SIGNATURE; do + _val=$(grep -E "^\s+${_var}:" "$_compose" | head -1 | sed "s/^[[:space:]]*${_var}: *//" | tr -d '\r') + # shellcheck disable=SC2016 # Intentional: checking for literal '${' prefix + if [ -n "$_val" ] && [ "${_val#'${'}" = "$_val" ]; then + _compose_vals[$_var]="$_val" + fi + done + fi + + # Scan deploy/transactor.properties for hardcoded passwords (not ${...} templated) + if [ -f "$_transactor" ]; then + for _prop in "storage-admin-password:ADMIN_PASSWORD" "storage-datomic-password:DATOMIC_PASSWORD"; do + _key="${_prop%%:*}" + _var="${_prop##*:}" + _val=$(grep -E "^${_key}=" "$_transactor" | head -1 | sed 's/.*=//' | tr -d '\r') + # shellcheck disable=SC2016 # Intentional: checking for literal '${' prefix + if [ -n "$_val" ] && [ "${_val#'${'}" = "$_val" ]; then + _transactor_vals[$_var]="$_val" + fi + done + fi + + # Build .env, checking for conflicts between sources + for _var in DATOMIC_URL DATOMIC_PASSWORD ADMIN_PASSWORD SIGNATURE; do + _cv="${_compose_vals[$_var]:-}" + _tv="${_transactor_vals[$_var]:-}" + + if [ -n "$_cv" ] && [ -n "$_tv" ] && [ "$_cv" != "$_tv" ]; then + echo "" + warn " ${_var}: CONFLICT between docker-compose.yaml and transactor.properties" + warn " 1) compose: ${_cv}" + warn " 2) transactor: ${_tv}" + if [ "${AUTO_MODE}" = "true" ]; then + warn " Using transactor value (that's what the database is actually using)" + echo "${_var}=${_tv}" >> "$ENV_FILE" + else + read -rp " Use which? [1] compose [2] transactor (recommended): " _choice || true + if [ "$_choice" = "1" ]; then + echo "${_var}=${_cv}" >> "$ENV_FILE" + change " Using compose value for ${_var}" + else + echo "${_var}=${_tv}" >> "$ENV_FILE" + change " Using transactor value for ${_var}" + fi + fi + _found=$((_found + 1)) + elif [ -n "$_tv" ]; then + echo "${_var}=${_tv}" >> "$ENV_FILE" + change " Found ${_var} in transactor.properties" + _found=$((_found + 1)) + elif [ -n "$_cv" ]; then + echo "${_var}=${_cv}" >> "$ENV_FILE" + change " Found ${_var} in docker-compose.yaml" + _found=$((_found + 1)) + fi + done + + if [ "$_found" -eq 0 ]; then + error "No hardcoded values found in docker-compose.yaml or transactor.properties." + error "For a new install, run: ./${SCRIPT_NAME} --auto" + exit 1 + fi + + chmod 600 "$ENV_FILE" + change "Created .env with ${_found} value(s) extracted from config files" + info "Continuing with upgrade to fill any gaps..." + echo "" + fi + + CHANGES=0 + BACKUP="${ENV_FILE}.backup.$(date +%s)" + cp "$ENV_FILE" "$BACKUP" + info "Backed up .env to $(basename "$BACKUP")" + + # Load current values + source_env "$ENV_FILE" + + _url=$(read_env_val DATOMIC_URL "$ENV_FILE") + _pw=$(read_env_val DATOMIC_PASSWORD "$ENV_FILE") + _sig=$(read_env_val SIGNATURE "$ENV_FILE") + _admin=$(read_env_val ADMIN_PASSWORD "$ENV_FILE") + + echo "" + + # --- Check 1: Password embedded in DATOMIC_URL --- + if [[ "$_url" == *"password="* ]]; then + # Extract password from URL + _url_pw=$(echo "$_url" | sed -n 's/.*[?&]password=\([^&]*\).*/\1/p') + # shellcheck disable=SC2001 # Regex too complex for parameter expansion + _clean_url=$(echo "$_url" | sed 's/[?&]password=[^&]*//') + + if [ -n "$_url_pw" ]; then + change "Found password embedded in DATOMIC_URL — extracting" + + # If DATOMIC_PASSWORD exists and differs, warn + if [ -n "$_pw" ] && [ "$_pw" != "$_url_pw" ]; then + warn " DATOMIC_PASSWORD in .env differs from URL password!" + warn " Using the URL password (that's what the transactor is using)" + fi + + # Update the file + set_env_val DATOMIC_URL "$_clean_url" "$ENV_FILE" + set_env_val DATOMIC_PASSWORD "$_url_pw" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " DATOMIC_URL: password removed" + change " DATOMIC_PASSWORD: set to extracted password" + fi + else + info "DATOMIC_URL: OK (no embedded password)" + fi + + # --- Check 2: Missing DATOMIC_PASSWORD --- + if [ -z "$_pw" ] && ! grep -q '^DATOMIC_PASSWORD=' "$ENV_FILE"; then + if [ "${AUTO_MODE}" = "true" ]; then + _new_pw="$(generate_password 24)" + set_env_val DATOMIC_PASSWORD "$_new_pw" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new DATOMIC_PASSWORD (random)" + warn " IMPORTANT: Update your transactor to use this password too!" + else + warn " DATOMIC_PASSWORD is missing." + warn " This should match what your transactor uses." + read -rp " Generate a random one? [Y/n]: " _gen || true + if [[ "${_gen,,}" =~ ^(y|)$ ]]; then + _new_pw="$(generate_password 24)" + set_env_val DATOMIC_PASSWORD "$_new_pw" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new DATOMIC_PASSWORD (random)" + warn " IMPORTANT: Update your transactor to use this password too!" + fi + fi + else + info "DATOMIC_PASSWORD: OK" + fi + + # --- Check 3: Missing SIGNATURE --- + if [ -z "$_sig" ] && ! grep -q '^SIGNATURE=' "$ENV_FILE"; then + if [ "${AUTO_MODE}" = "true" ]; then + _new_sig="$(generate_password 32)" + set_env_val SIGNATURE "$_new_sig" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new SIGNATURE (random)" + warn " Note: Changing this later will log out all active users." + else + warn " SIGNATURE is missing (needed for login/API)." + read -rp " Generate a random one? [Y/n]: " _gen || true + if [[ "${_gen,,}" =~ ^(y|)$ ]]; then + _new_sig="$(generate_password 32)" + set_env_val SIGNATURE "$_new_sig" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new SIGNATURE (random)" + warn " Note: Changing this later will log out all active users." + fi + fi + else + info "SIGNATURE: OK" + fi + + # --- Check 4: Missing ADMIN_PASSWORD --- + if [ -z "$_admin" ] && ! grep -q '^ADMIN_PASSWORD=' "$ENV_FILE"; then + if [ "${AUTO_MODE}" = "true" ]; then + _new_admin="$(generate_password 24)" + set_env_val ADMIN_PASSWORD "$_new_admin" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new ADMIN_PASSWORD (random)" + else + warn " ADMIN_PASSWORD is missing." + read -rp " Generate a random one? [Y/n]: " _gen || true + if [[ "${_gen,,}" =~ ^(y|)$ ]]; then + _new_admin="$(generate_password 24)" + set_env_val ADMIN_PASSWORD "$_new_admin" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new ADMIN_PASSWORD (random)" + fi + fi + else + info "ADMIN_PASSWORD: OK" + fi + + # --- Check 5: Old Free protocol → upgrade to dev --- + # Re-read URL after password extraction may have changed it + _url=$(read_env_val DATOMIC_URL "$ENV_FILE") + + if [[ "$_url" == *"datomic:free://"* ]]; then + _new_url="${_url/datomic:free:\/\//datomic:dev:\/\/}" + set_env_val DATOMIC_URL "$_new_url" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Changed datomic:free:// to datomic:dev:// (Datomic Pro)" + warn " If you have existing Free data, migrate it:" + warn " 1. Back up first: ./docker-migrate.sh backup" + warn " 2. Rebuild: docker compose up --build -d" + warn " 3. Restore: ./docker-migrate.sh restore" + warn " See docs/migration/datomic-data-migration.md for the full guide." + # Update for Check 6 + _url="$_new_url" + fi + + # --- Check 6: localhost → datomic (Docker service name) --- + if [[ "$_url" == *"localhost"* ]]; then + _new_url="${_url//localhost/datomic}" + set_env_val DATOMIC_URL "$_new_url" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Changed 'localhost' to 'datomic' (Docker service name)" + fi + + # --- Check 7: Image tags (ORCPUB_IMAGE / DATOMIC_IMAGE) --- + _orcpub_img=$(read_env_val ORCPUB_IMAGE "$ENV_FILE") + _datomic_img=$(read_env_val DATOMIC_IMAGE "$ENV_FILE") + if [ -z "$_orcpub_img" ] && [ -z "$_datomic_img" ]; then + if [ "${AUTO_MODE}" != "true" ]; then + echo "" + info "No image tags set (builds use default names: orcpub-app, orcpub-datomic)" + _tag=$(prompt_value "Image tag to version your builds (leave empty to skip)" "") + if [ -n "$_tag" ]; then + set_env_val ORCPUB_IMAGE "orcpub-app:${_tag}" "$ENV_FILE" + set_env_val DATOMIC_IMAGE "orcpub-datomic:${_tag}" "$ENV_FILE" + CHANGES=$((CHANGES + 2)) + change " Set ORCPUB_IMAGE=orcpub-app:${_tag}" + change " Set DATOMIC_IMAGE=orcpub-datomic:${_tag}" + fi + fi + else + if [ "${AUTO_MODE}" != "true" ]; then + # Extract current tag from image name (e.g. "orcpub-app:v2.6" → "v2.6") + _current_tag="${_orcpub_img##*:}" + [ "$_current_tag" = "$_orcpub_img" ] && _current_tag="" + info "ORCPUB_IMAGE: ${_orcpub_img}" + info "DATOMIC_IMAGE: ${_datomic_img}" + _tag=$(prompt_value "Image tag" "${_current_tag}") + if [ -n "$_tag" ] && [ "$_tag" != "$_current_tag" ]; then + # Preserve base name, update tag + _orcpub_base="${_orcpub_img%%:*}" + _datomic_base="${_datomic_img%%:*}" + set_env_val ORCPUB_IMAGE "${_orcpub_base}:${_tag}" "$ENV_FILE" + set_env_val DATOMIC_IMAGE "${_datomic_base}:${_tag}" "$ENV_FILE" + CHANGES=$((CHANGES + 2)) + change " Set ORCPUB_IMAGE=${_orcpub_base}:${_tag}" + change " Set DATOMIC_IMAGE=${_datomic_base}:${_tag}" + else + info " Image tags unchanged." + fi + else + info "ORCPUB_IMAGE: ${_orcpub_img}" + info "DATOMIC_IMAGE: ${_datomic_img}" + fi + fi + + echo "" + if [ "$CHANGES" -gt 0 ]; then + change "${CHANGES} change(s) applied to .env" + else + info "No changes needed — .env is already up to date." + fi + info "Backup saved: $(basename "$BACKUP")" + + # --- Chain into secrets if combined (--upgrade-swarm / --upgrade-secrets) --- + if [ "$SWARM_MODE" = "true" ]; then + echo "" + info "Continuing to Swarm secrets setup..." + if [ "${AUTO_MODE}" = "true" ]; then + exec "$0" --swarm --auto + else + exec "$0" --swarm + fi + elif [ "$SECRETS_MODE" = "true" ]; then + echo "" + info "Continuing to secrets setup..." + if [ "${AUTO_MODE}" = "true" ]; then + exec "$0" --secrets --auto + else + exec "$0" --secrets + fi + fi + + # Standalone --upgrade: offer secrets interactively + if [ ! -d "${SCRIPT_DIR}/secrets" ]; then + echo "" + if [ "${AUTO_MODE}" = "true" ]; then + info "Tip: Run ./${SCRIPT_NAME} --upgrade-secrets or --upgrade-swarm to also" + info " move passwords out of .env in one step." + else + echo "" + read -rp "Move passwords to Docker secret files? (more secure) [y/N]: " _do_secrets || true + if [[ "${_do_secrets,,}" == "y" ]]; then + exec "$0" --secrets + fi + fi + else + info "Docker secrets: already configured (secrets/ exists)" + fi + + # Detect shell/env conflicts for the launch command + ENV_CONFLICTS=() + WARNING_MSGS=() + ERRORS=0 + for _var in DATOMIC_URL DATOMIC_PASSWORD SIGNATURE ADMIN_PASSWORD; do + check_env_conflict "$_var" + done + + echo "" + next "$(build_compose_cmd "docker compose up -d")" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Main — setup, validate, and (in naked mode) build + start +# --------------------------------------------------------------------------- + +if [ "$_any_mode" = "false" ]; then + header "Dungeon Master's Vault — Full Setup & Deploy" +else + header "Dungeon Master's Vault — Docker Setup" +fi + +# ---- Step 1: .env file --------------------------------------------------- + +if [ -f "$ENV_FILE" ] && [ "$FORCE_MODE" = "false" ]; then + info "Existing .env file found. Skipping generation (use --force to overwrite)." +else + # Source existing .env (if any) so current values become defaults for prompts + if [ -f "$ENV_FILE" ]; then + source_env "$ENV_FILE" + fi + + generate_env +fi + +# ---- Step 2: Validation -------------------------------------------------- + +header "Validation" + +ERRORS=0 +WARNING_MSGS=() +ENV_CONFLICTS=() + +# Validate DATOMIC_PASSWORD vs DATOMIC_URL +if [ -f "$ENV_FILE" ]; then + _env_datomic_pw=$(read_env_val DATOMIC_PASSWORD "$ENV_FILE") + _env_datomic_url=$(read_env_val DATOMIC_URL "$ENV_FILE") + if [ -n "$_env_datomic_pw" ] && [ -n "$_env_datomic_url" ]; then + if [[ "$_env_datomic_url" == *"password="* ]]; then + # Legacy: password embedded in URL — check it matches DATOMIC_PASSWORD + if [[ "$_env_datomic_url" != *"password=${_env_datomic_pw}"* ]]; then + warn " DATOMIC_URL has an embedded password that doesn't match DATOMIC_PASSWORD" + warn " Remove ?password= from DATOMIC_URL — the app adds it from DATOMIC_PASSWORD automatically" + WARNING_MSGS+=("DATOMIC_URL embedded password doesn't match DATOMIC_PASSWORD") + ERRORS=$((ERRORS + 1)) + else + info " DATOMIC_URL password: OK (embedded — consider removing ?password= from URL)" + fi + else + # Modern: password separate — the app appends it at startup + info " DATOMIC_PASSWORD: OK (app will append to URL at startup)" + fi + fi + unset _env_datomic_pw _env_datomic_url +fi + +if [ -f "$ENV_FILE" ]; then + check_env_conflict DATOMIC_URL + check_env_conflict SIGNATURE + check_env_conflict ADMIN_PASSWORD + check_env_conflict DATOMIC_PASSWORD +fi + +check_file ".env" "$ENV_FILE" +check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" +check_file "nginx.conf.template" "${SCRIPT_DIR}/deploy/nginx.conf.template" +check_file "SSL certificate" "${SCRIPT_DIR}/deploy/snakeoil.crt" +check_file "SSL key" "${SCRIPT_DIR}/deploy/snakeoil.key" +check_dir "data/" "${SCRIPT_DIR}/data" +check_dir "logs/" "${SCRIPT_DIR}/logs" +check_dir "backups/" "${SCRIPT_DIR}/backups" +check_dir "deploy/homebrew/" "${SCRIPT_DIR}/deploy/homebrew" + +echo "" + +if [ "$ERRORS" -gt 0 ]; then + warn "Setup completed with ${ERRORS} warning(s). Review the items above." +else + success "All checks passed!" +fi + +# ---- Step 3: Build + Start (naked mode only) -------------------------------- + +if [ "$_any_mode" = "false" ]; then + + # Build + if confirm_action "Build Docker images?"; then + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } + echo "" + success "Images built successfully." + else + info "Skipping build." + fi + + # Start + if confirm_action "Start services?"; then + COMPOSE_CMD=$(build_compose_cmd "docker compose up -d") + info "Starting services..." + eval "$COMPOSE_CMD" || { error "Failed to start services."; exit 1; } + echo "" + success "Services started." + echo "" + echo "Wait for healthy (app takes ~2 minutes to boot):" + echo " docker compose ps" + echo "" + echo "Create your first user:" + echo " ./docker-user.sh init # uses INIT_ADMIN_* from .env" + echo " ./docker-user.sh create # or specify directly" + echo "" + echo "Access the site at:" + echo " https://localhost" + else + info "Skipping start." + COMPOSE_CMD=$(build_compose_cmd "docker compose up --build -d") + echo "" + echo "To start manually:" + printf ' %s\n' "$COMPOSE_CMD" + fi + +else + + # ---- Upgrade fall-through: show next steps only ---------------------------- + + header "Next Steps" + + COMPOSE_CMD=$(build_compose_cmd "docker compose up --build -d") + + cat < # or specify directly + +5. Access the site at: + https://localhost + +6. Manage users later with: + ./docker-user.sh list # List all users + ./docker-user.sh check # Check a user's status + ./docker-user.sh verify # Verify an unverified user + +7. To import homebrew content, place your .orcbrew file at: + deploy/homebrew/homebrew.orcbrew + +For more details, see docs/DOCKER.md +NEXT + +fi + +# ---- Final status banner ---------------------------------------------------- + +echo "" +if [ "$ERRORS" -gt 0 ]; then + printf '%s!!! %d WARNING(S) — review before starting !!!%s\n' "$color_yellow" "$ERRORS" "$color_reset" + for msg in "${WARNING_MSGS[@]}"; do + printf ' %s• %s%s\n' "$color_yellow" "$msg" "$color_reset" + done + if [ "${#ENV_CONFLICTS[@]}" -gt 0 ]; then + echo "" + printf ' %sTo launch with .env values:%s\n' "$color_cyan" "$color_reset" + printf ' %s\n' "$COMPOSE_CMD" + fi + echo "" +else + success "SUCCESS — ready to launch" + if [ "$_any_mode" = "true" ]; then + printf ' %s\n' "$COMPOSE_CMD" + fi + echo "" +fi diff --git a/scripts/common.sh b/scripts/common.sh index d96f3f8b6..c59da0336 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -23,10 +23,11 @@ REPO_ROOT="${REPO_ROOT:-$(cd "$COMMON_DIR/.." && pwd)}" # ----------------------------------------------------------------------------- # Source .env if present (authoritative config) +# tr -d '\r' strips Windows line endings so values don't silently include \r if [[ -f "$REPO_ROOT/.env" ]]; then set -a # shellcheck disable=SC1091 - . "$REPO_ROOT/.env" + . <(tr -d '\r' < "$REPO_ROOT/.env") set +a fi @@ -43,6 +44,11 @@ NREPL_PORT="${NREPL_PORT:-7888}" FIGWHEEL_PORT="${FIGWHEEL_PORT:-3449}" GARDEN_PORT="${GARDEN_PORT:-3000}" +# Figwheel WebSocket connect URL override (for remote dev environments). +# Auto-detected for GitHub Codespaces; set explicitly for other remote setups. +# Leave empty for local development (uses Figwheel default: ws://localhost:PORT). +FIGWHEEL_CONNECT_URL="${FIGWHEEL_CONNECT_URL:-}" + # Derived paths DATOMIC_DIR="$REPO_ROOT/lib/com/datomic/datomic-${DATOMIC_TYPE}/${DATOMIC_VERSION}" DATOMIC_CONFIG="$DATOMIC_DIR/config/working-transactor.properties" @@ -433,7 +439,7 @@ show_startup_failure() { get_datomic_port_from_config() { local config="$1" if [[ -f "$config" ]]; then - grep -E '^port=' "$config" 2>/dev/null | cut -d= -f2 || echo "$DATOMIC_PORT" + grep -E '^port=' "$config" 2>/dev/null | cut -d= -f2 | tr -d '\r' || echo "$DATOMIC_PORT" else echo "$DATOMIC_PORT" fi diff --git a/scripts/start.sh b/scripts/start.sh index 4287afa0e..8ddb5f912 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -242,13 +242,26 @@ run_checks() { esac case "$target" in - figwheel) + all|figwheel) echo -n "Figwheel port ($FIGWHEEL_PORT): " if port_in_use "$FIGWHEEL_PORT"; then echo -e "${YELLOW}IN USE${NC}" else echo -e "${GREEN}AVAILABLE${NC}" fi + + # Report remote dev detection status + echo -n "Remote dev: " + if [[ -n "${FIGWHEEL_CONNECT_URL:-}" ]]; then + echo -e "${GREEN}CONFIGURED${NC} (FIGWHEEL_CONNECT_URL)" + echo " URL: $FIGWHEEL_CONNECT_URL" + elif [[ "${CODESPACES:-}" == "true" ]]; then + local cs_domain="${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}" + echo -e "${GREEN}AUTO-DETECT${NC} (Codespaces)" + echo " URL: wss://${CODESPACE_NAME:-???}-${FIGWHEEL_PORT}.${cs_domain}/figwheel-connect" + else + echo -e "${CYAN}LOCAL${NC} (ws://localhost:$FIGWHEEL_PORT)" + fi ;; esac @@ -405,6 +418,7 @@ start_server() { start_figwheel() { local idempotent="${1:-false}" local port_result=0 + local build_name="dev" # Check port (0=available, 1=abort, 2=skip) check_port_available "$FIGWHEEL_PORT" "figwheel" "$idempotent" || port_result=$? @@ -418,11 +432,39 @@ start_figwheel() { # Clean up stale PID file cleanup_stale_pid "figwheel" + # ── Remote dev environment detection ────────────────────────────── + # Figwheel's default connect URL (ws://localhost:PORT) only works when + # the browser is on the same machine. In remote environments (Codespaces, + # Gitpod, etc.) the browser connects through a forwarded hostname. + # We auto-detect Codespaces and pass --fw-opts to override the connect URL. + # Set FIGWHEEL_CONNECT_URL in .env to override for other remote setups. + local connect_url="${FIGWHEEL_CONNECT_URL:-}" + local fw_opts="" + + if [[ -z "$connect_url" && "${CODESPACES:-}" == "true" ]]; then + local cs_domain="${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}" + local cs_name="${CODESPACE_NAME:?CODESPACE_NAME required in Codespaces}" + connect_url="wss://${cs_name}-${FIGWHEEL_PORT}.${cs_domain}/figwheel-connect" + log_info "Codespaces detected" + fi + + if [[ -n "$connect_url" ]]; then + # Build EDN override for figwheel's --fw-opts flag. + # Merges with dev.cljs.edn metadata — no generated file needed. + printf -v fw_opts '{:connect-url "%s" :ring-server-options {:port %s :host "0.0.0.0"} :open-url false}' \ + "$connect_url" "$FIGWHEEL_PORT" + log_info "Remote dev connect URL: $connect_url" + fi + log_info "Starting Figwheel (ClojureScript hot-reload)..." cd "$REPO_ROOT" - # Use fig:watch alias (headless build + watch, no REPL — works with nohup) - # For interactive REPL use: lein fig:dev (needs a terminal) - nohup lein fig:watch > "$LOG_DIR/figwheel.log" 2>&1 & + # Use fig:watch alias for local dev, or pass --fw-opts for remote environments. + # --fw-opts merges EDN overrides with the build config in dev.cljs.edn. + if [[ -n "$fw_opts" ]]; then + nohup lein run -m figwheel.main -- --fw-opts "$fw_opts" --build dev > "$LOG_DIR/figwheel.log" 2>&1 & + else + nohup lein fig:watch > "$LOG_DIR/figwheel.log" 2>&1 & + fi local figwheel_pid=$! echo "$figwheel_pid" > "$LOG_DIR/figwheel.pid" log_info "Figwheel started (PID $figwheel_pid)" @@ -440,6 +482,9 @@ start_figwheel() { log_info "Waiting for Figwheel to be ready (port $FIGWHEEL_PORT)..." if wait_for_port_or_die "$FIGWHEEL_PORT" "$figwheel_pid" "$PORT_WAIT"; then log_info "Figwheel is ready" + if [[ -n "$connect_url" ]]; then + log_info "Hot-reload WebSocket: $connect_url" + fi elif kill -0 "$figwheel_pid" 2>/dev/null; then # Process alive but port not ready — likely first-run compile/dep download log_warn "Figwheel still starting (PID $figwheel_pid alive, port $FIGWHEEL_PORT not yet open)" @@ -622,14 +667,16 @@ Exit Codes: 3 Runtime failure (port conflict, startup timeout, process crash) Environment Variables (via .env or shell): - DATOMIC_VERSION Datomic version (default: 1.0.7482) - DATOMIC_TYPE Datomic type: pro or dev (default: pro) - JAVA_MIN_VERSION Minimum Java version required (default: 11) - LOG_DIR Directory for log files (default: ./logs) - DATOMIC_PORT Datomic port (default: 4334) - SERVER_PORT Server port (default: 8890) - PORT_WAIT Timeout for port readiness (default: 30) - KILL_WAIT Timeout for graceful shutdown (default: 5) + DATOMIC_VERSION Datomic version (default: 1.0.7482) + DATOMIC_TYPE Datomic type: pro or dev (default: pro) + JAVA_MIN_VERSION Minimum Java version required (default: 11) + LOG_DIR Directory for log files (default: ./logs) + DATOMIC_PORT Datomic port (default: 4334) + SERVER_PORT Server port (default: 8890) + FIGWHEEL_PORT Figwheel port (default: 3449) + FIGWHEEL_CONNECT_URL Override Figwheel WebSocket URL for remote dev + PORT_WAIT Timeout for port readiness (default: 30) + KILL_WAIT Timeout for graceful shutdown (default: 5) Configuration: Config is loaded from: \$REPO_ROOT/.env @@ -660,6 +707,14 @@ Notes: - Or use ./start.sh alone for Datomic + server in one terminal - Or use ./start.sh --tmux to run all in a tmux session - In non-interactive mode (CI/cron), port conflicts fail immediately + +Remote Dev (Codespaces / Gitpod / tunnels): + Figwheel's default ws://localhost:3449 doesn't work when the browser + connects through a remote hostname. start.sh handles this automatically: + - GitHub Codespaces: auto-detected (wss:// URL built from env vars) + - Other remote setups: set FIGWHEEL_CONNECT_URL in .env + - Local dev: no action needed (Figwheel default just works) + Port 3449 must be public in Codespaces for the WebSocket to connect. EOF } diff --git a/scripts/swarm.sh b/scripts/swarm.sh new file mode 100644 index 000000000..f216f9f40 --- /dev/null +++ b/scripts/swarm.sh @@ -0,0 +1,681 @@ +#!/usr/bin/env bash +# +# Swarm compose generator — produces a standalone Swarm-ready compose file +# for import into Portainer or use with `docker stack deploy`. +# +# Sourced by the `run` script. Depends on: +# - SCRIPT_DIR, SCRIPT_NAME, ENV_FILE (set by run) +# - color_* variables (set by run) +# - info(), warn(), change(), error() helpers (set by run) +# - read_env_val() (set by run) + +# shellcheck disable=SC2154 # color_*, SCRIPT_DIR etc. defined by sourcing script (run) + +SWARM_COMPOSE="${SCRIPT_DIR}/docker-compose.swarm.yaml" +SWARM_PORTAINER_ENV="${SCRIPT_DIR}/.env.portainer" + +# Additional color codes (extends run's palette) +color_bold=$'\033[1m' +color_dim=$'\033[2m' + +# --------------------------------------------------------------------------- +# extract_swarm_config — parse existing Swarm compose into shell variables +# --------------------------------------------------------------------------- +# Sets _swarm_* variables for use by generate_swarm_compose and +# print_swarm_summary. Returns 1 if no existing file found. + +extract_swarm_config() { + local existing="$1" + [ -f "$existing" ] || return 1 + + # Stack name + _swarm_stack_name=$(grep -E '^name:' "$existing" 2>/dev/null | head -1 | sed 's/^name:[[:space:]]*//' | tr -d '\r' || true) + + # Service names — top-level keys under services: + _swarm_datomic_svc=$(sed -n '/^services:/,/^[a-z]/{ /^ [a-z].*:$/p }' "$existing" | grep -i 'datomic' | head -1 | sed 's/://;s/^[[:space:]]*//' || true) + _swarm_app_svc=$(sed -n '/^services:/,/^[a-z]/{ /^ [a-z].*:$/p }' "$existing" | grep -iv 'datomic' | head -1 | sed 's/://;s/^[[:space:]]*//' || true) + + local d_svc="${_swarm_datomic_svc:-datomic}" + local a_svc="${_swarm_app_svc:-orcpub}" + + # Images + _swarm_datomic_image=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{ /image:/p }" "$existing" | head -1 | sed 's/.*image:[[:space:]]*//' | tr -d '\r' || true) + _swarm_app_image=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{ /image:/p }" "$existing" | head -1 | sed 's/.*image:[[:space:]]*//' | tr -d '\r' || true) + + # Traefik labels + _swarm_traefik_labels=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_traefik_labels+=("$line") + done < <(grep -E '^\s*-?\s*traefik\.' "$existing" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + + # Networks — top-level block + _swarm_networks=() + _swarm_networks_external=() + while IFS= read -r line; do + local net_name + net_name=$(echo "$line" | sed 's/://;s/^[[:space:]]*//') + [ -z "$net_name" ] && continue + _swarm_networks+=("$net_name") + done < <(sed -n '/^networks:/,/^[a-z]/{/^ [a-z]/p}' "$existing" 2>/dev/null || true) + + for net in "${_swarm_networks[@]}"; do + if sed -n "/^ ${net}:/,/^ [a-z]/p" "$existing" 2>/dev/null | grep -q 'external.*true'; then + _swarm_networks_external+=("$net") + fi + done + + # Volumes — top-level block + _swarm_volumes=() + _swarm_volumes_external=() + while IFS= read -r line; do + local vol_name + vol_name=$(echo "$line" | sed 's/://;s/^[[:space:]]*//') + [ -z "$vol_name" ] && continue + _swarm_volumes+=("$vol_name") + done < <(sed -n '/^volumes:/,/^[a-z]/{/^ [a-z]/p}' "$existing" 2>/dev/null || true) + + for vol in "${_swarm_volumes[@]}"; do + if sed -n "/^ ${vol}:/,/^ [a-z]/p" "$existing" 2>/dev/null | grep -q 'external.*true'; then + _swarm_volumes_external+=("$vol") + fi + done + + # Bind mounts (host path lines starting with /) + _swarm_bind_mounts_datomic=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_bind_mounts_datomic+=("$line") + done < <(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/volumes:/,/^[[:space:]]*[a-z]/{/^\s*-\s*\//p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + + _swarm_bind_mounts_app=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_bind_mounts_app+=("$line") + done < <(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/volumes:/,/^[[:space:]]*[a-z]/{/^\s*-\s*\//p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + + # Resource limits + _swarm_datomic_mem_limit=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/limits:/,/reservations:/{/memory:/p}}" "$existing" | head -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + _swarm_datomic_mem_reservation=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/reservations:/,/[a-z].*:/{/memory:/p}}" "$existing" | tail -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + _swarm_app_mem_limit=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/limits:/,/reservations:/{/memory:/p}}" "$existing" | head -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + _swarm_app_mem_reservation=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/reservations:/,/[a-z].*:/{/memory:/p}}" "$existing" | tail -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + + # JVM settings + _swarm_xms=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/XMS:/p}" "$existing" | head -1 | sed 's/.*XMS:[[:space:]]*//' | tr -d '\r' || true) + _swarm_xmx=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/XMX:/p}" "$existing" | head -1 | sed 's/.*XMX:[[:space:]]*//' | tr -d '\r' || true) + _swarm_java_opts=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/JAVA_OPTS:/p}" "$existing" | head -1 | sed 's/.*JAVA_OPTS:[[:space:]]*//' | tr -d '\r' || true) + + # Per-service env vars + _swarm_datomic_env=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_datomic_env+=("$line") + done < <(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/environment:/,/^[[:space:]]*[a-z]/{/^[[:space:]]*[A-Z]/p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*//' || true) + + _swarm_app_env=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_app_env+=("$line") + done < <(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/environment:/,/^[[:space:]]*[a-z]/{/^[[:space:]]*[A-Z]/p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*//' || true) + + # Detect transactor.properties bind mount + _swarm_has_transactor_props=false + for m in "${_swarm_bind_mounts_datomic[@]}"; do + [[ "$m" == *transactor.properties* ]] && _swarm_has_transactor_props=true && break + done + + # Healthcheck timeout (app — for change detection) + _swarm_app_hc_timeout=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/timeout:/p}" "$existing" | head -1 | sed 's/.*timeout:[[:space:]]*//' | tr -d '\r' || true) + + return 0 +} + +# --------------------------------------------------------------------------- +# generate_env_portainer — stripped .env for Portainer advanced mode paste +# --------------------------------------------------------------------------- + +generate_env_portainer() { + local src="$1" dst="$2" + local _seen_keys="" + : > "$dst" + while IFS= read -r line; do + # Strip comments, blanks, export prefix, quotes + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// /}" ]] && continue + line="${line#export }" + # Skip Compose-only vars (irrelevant for Swarm stack deploy) + local _key="${line%%=*}" + [[ "$_key" == "COMPOSE_FILE" ]] && continue + [[ "$_key" == "COMPOSE_PROJECT_NAME" ]] && continue + # Deduplicate (keep first occurrence) + if [[ "$_seen_keys" == *"|${_key}|"* ]]; then continue; fi + _seen_keys="${_seen_keys}|${_key}|" + # Strip surrounding quotes from value + local _val="${line#*=}" + _val="${_val#\'}" ; _val="${_val%\'}" + _val="${_val#\"}" ; _val="${_val%\"}" + printf '%s=%s\n' "$_key" "$_val" >> "$dst" + done < "$src" + chmod 600 "$dst" +} + +# --------------------------------------------------------------------------- +# generate_transactor_reference — resolve template with .env values +# --------------------------------------------------------------------------- +# When the admin bind-mounts their own transactor.properties, template changes +# are invisible. This generates a .reference file showing what the current +# template would produce, so they can diff against their custom file. + +generate_transactor_reference() { + local template="${SCRIPT_DIR}/docker/transactor.properties.template" + local output="${SCRIPT_DIR}/transactor.properties.reference" + [ -f "$template" ] || return 1 + + # Resolve ${VAR} and ${VAR:-default} from environment (sourced from .env) + local line + while IFS= read -r line; do + # Skip comments and empty lines — pass through + if [[ "$line" == \#* ]] || [ -z "$line" ]; then + printf '%s\n' "$line" + continue + fi + # Resolve ${VAR:-default} patterns + while [[ "$line" =~ \$\{([A-Za-z_][A-Za-z0-9_]*)(:-)([^}]*)\} ]]; do + local var="${BASH_REMATCH[1]}" def="${BASH_REMATCH[3]}" + local val="${!var:-$def}" + line="${line/\$\{${var}:-${def}\}/$val}" + done + # Resolve ${VAR} patterns (no default) + while [[ "$line" =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\} ]]; do + local var="${BASH_REMATCH[1]}" + local val="${!var:-}" + line="${line/\$\{${var}\}/$val}" + done + printf '%s\n' "$line" + done < "$template" > "$output" + + chmod 644 "$output" +} + +# --------------------------------------------------------------------------- +# activate_swarm_secrets — uncomment secrets blocks in generated compose +# --------------------------------------------------------------------------- +# Called after `docker secret create` succeeds. Uncomments: +# - Per-service secrets: references under each service +# - Top-level secrets: declaration block + +activate_swarm_secrets() { + local compose_file="$1" + [ -f "$compose_file" ] || return 1 + + # Uncomment service-level secrets (indented "# secrets:" and "# - name") + sed -i \ + -e 's/^ # secrets:$/ secrets:/' \ + -e 's/^ # - \(.*_password\)$/ - \1/' \ + -e 's/^ # - \(signature\)$/ - \1/' \ + "$compose_file" + + # Uncomment top-level secrets block + sed -i \ + -e 's/^# secrets:$/secrets:/' \ + -e 's/^# \(.*_password:\)$/ \1/' \ + -e 's/^# \(signature:\)$/ \1/' \ + -e 's/^# external: true$/ external: true/' \ + "$compose_file" +} + +# --------------------------------------------------------------------------- +# generate_swarm_compose — write the Swarm-ready YAML file +# --------------------------------------------------------------------------- +# Call after extract_swarm_config() (upgrade) or with defaults (fresh). + +generate_swarm_compose() { + local output="$1" + local is_upgrade="${2:-false}" + + # Initialize arrays if not set (fresh generation path) + _swarm_networks=("${_swarm_networks[@]+"${_swarm_networks[@]}"}") + _swarm_networks_external=("${_swarm_networks_external[@]+"${_swarm_networks_external[@]}"}") + _swarm_volumes=("${_swarm_volumes[@]+"${_swarm_volumes[@]}"}") + _swarm_volumes_external=("${_swarm_volumes_external[@]+"${_swarm_volumes_external[@]}"}") + _swarm_bind_mounts_datomic=("${_swarm_bind_mounts_datomic[@]+"${_swarm_bind_mounts_datomic[@]}"}") + _swarm_bind_mounts_app=("${_swarm_bind_mounts_app[@]+"${_swarm_bind_mounts_app[@]}"}") + _swarm_traefik_labels=("${_swarm_traefik_labels[@]+"${_swarm_traefik_labels[@]}"}") + _swarm_datomic_env=("${_swarm_datomic_env[@]+"${_swarm_datomic_env[@]}"}") + _swarm_app_env=("${_swarm_app_env[@]+"${_swarm_app_env[@]}"}") + + # Stack name — use if/else to avoid nested ${} expansion bugs + local stack_name datomic_svc app_svc datomic_image app_image + [ -n "${_swarm_stack_name:-}" ] && stack_name="$_swarm_stack_name" || stack_name='${COMPOSE_PROJECT_NAME:-orcpub}' + [ -n "${_swarm_datomic_svc:-}" ] && datomic_svc="$_swarm_datomic_svc" || datomic_svc="datomic" + [ -n "${_swarm_app_svc:-}" ] && app_svc="$_swarm_app_svc" || app_svc="orcpub" + [ -n "${_swarm_datomic_image:-}" ] && datomic_image="$_swarm_datomic_image" || datomic_image='${DATOMIC_IMAGE:-orcpub-datomic}' + [ -n "${_swarm_app_image:-}" ] && app_image="$_swarm_app_image" || app_image='${ORCPUB_IMAGE:-orcpub-app}' + + # Resource limits + local d_mem_limit d_mem_res a_mem_limit a_mem_res + [ -n "${_swarm_datomic_mem_limit:-}" ] && d_mem_limit="$_swarm_datomic_mem_limit" || d_mem_limit='${DATOMIC_MEMORY_LIMIT:-2G}' + [ -n "${_swarm_datomic_mem_reservation:-}" ] && d_mem_res="$_swarm_datomic_mem_reservation" || d_mem_res='${DATOMIC_MEMORY_RESERVATION:-1G}' + [ -n "${_swarm_app_mem_limit:-}" ] && a_mem_limit="$_swarm_app_mem_limit" || a_mem_limit='${APP_MEMORY_LIMIT:-2G}' + [ -n "${_swarm_app_mem_reservation:-}" ] && a_mem_res="$_swarm_app_mem_reservation" || a_mem_res='${APP_MEMORY_RESERVATION:-1G}' + + # JVM + local xms xmx java_opts + [ -n "${_swarm_xms:-}" ] && xms="$_swarm_xms" || xms='${XMS:--Xms1g}' + [ -n "${_swarm_xmx:-}" ] && xmx="$_swarm_xmx" || xmx='${XMX:--Xmx1g}' + [ -n "${_swarm_java_opts:-}" ] && java_opts="$_swarm_java_opts" || java_opts='${JAVA_OPTS:-}' + + # --- Build network blocks --- + local app_networks="" datomic_networks="" net_block="" + if [ "${#_swarm_networks[@]}" -gt 0 ] 2>/dev/null; then + for net in "${_swarm_networks[@]}"; do + app_networks="${app_networks} - ${net}\n" + # Datomic skips proxy/mail networks + case "$net" in + traefik*|postfix*|mail*) ;; + *) datomic_networks="${datomic_networks} - ${net}\n" ;; + esac + done + net_block="networks:" + for net in "${_swarm_networks[@]}"; do + local is_ext=false + for ext in "${_swarm_networks_external[@]}"; do + [ "$ext" = "$net" ] && is_ext=true && break + done + if [ "$is_ext" = "true" ]; then + net_block="${net_block}\n ${net}:\n external: true" + else + net_block="${net_block}\n ${net}:\n driver: overlay" + fi + done + else + app_networks=" - backend\n # - traefik-public # Uncomment if using Traefik" + datomic_networks=" - backend" + net_block="networks:\n backend:\n driver: overlay\n # traefik-public:\n # external: true" + fi + + # --- Build volume blocks --- + local d_vol_mounts="" vol_block="" + if [ "${#_swarm_volumes[@]}" -gt 0 ] 2>/dev/null; then + # Re-read actual volume mount lines from existing file for datomic + while IFS= read -r line; do + [ -n "$line" ] && d_vol_mounts="${d_vol_mounts} - ${line}\n" + done < <(sed -n "/^ ${datomic_svc}:/,/^ [a-z]/{/volumes:/,/^[[:space:]]*[a-z]/{/^\s*-\s*[a-z]/p}}" "$SWARM_COMPOSE" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + # Fallback if nothing found + [ -z "$d_vol_mounts" ] && d_vol_mounts=" - ${_swarm_volumes[0]}:/data\n" + + vol_block="volumes:" + for v in "${_swarm_volumes[@]}"; do + local is_ext=false + for ext in "${_swarm_volumes_external[@]}"; do + [ "$ext" = "$v" ] && is_ext=true && break + done + if [ "$is_ext" = "true" ]; then + vol_block="${vol_block}\n ${v}:\n external: true" + else + vol_block="${vol_block}\n ${v}:" + fi + done + else + d_vol_mounts=" - orcpub_data:/data\n - orcpub_logs:/log\n - orcpub_backups:/backups" + vol_block="volumes:\n orcpub_data:\n external: true\n orcpub_logs:\n external: true\n orcpub_backups:\n external: true" + fi + + # Bind mounts + local d_bind_mounts="" a_bind_mounts="" + for m in "${_swarm_bind_mounts_datomic[@]+"${_swarm_bind_mounts_datomic[@]}"}"; do + [ -n "$m" ] && d_bind_mounts="${d_bind_mounts} - ${m}\n" + done + for m in "${_swarm_bind_mounts_app[@]+"${_swarm_bind_mounts_app[@]}"}"; do + [ -n "$m" ] && a_bind_mounts="${a_bind_mounts} - ${m}\n" + done + + # --- Build env blocks --- + # Define canonical env vars for each service (name:default pairs) + local -a _d_env_defaults=( + "ADMIN_PASSWORD:\${ADMIN_PASSWORD}" + "DATOMIC_PASSWORD:\${DATOMIC_PASSWORD}" + "ALT_HOST:\${ALT_HOST:-datomic}" + "ENCRYPT_CHANNEL:\${ENCRYPT_CHANNEL:-true}" + "XMS:${xms}" + "XMX:${xmx}" + "TZ:\${TZ:-America/Chicago}" + ) + local -a _a_env_defaults=( + "PORT:\${PORT:-8890}" + "EMAIL_SERVER_URL:\${EMAIL_SERVER_URL:-}" + "EMAIL_ACCESS_KEY:\${EMAIL_ACCESS_KEY:-}" + "EMAIL_SECRET_KEY:\${EMAIL_SECRET_KEY:-}" + "EMAIL_SERVER_PORT:\${EMAIL_SERVER_PORT:-587}" + "EMAIL_FROM_ADDRESS:\${EMAIL_FROM_ADDRESS:-}" + "EMAIL_ERRORS_TO:\${EMAIL_ERRORS_TO:-}" + "EMAIL_SSL:\${EMAIL_SSL:-FALSE}" + "EMAIL_TLS:\${EMAIL_TLS:-FALSE}" + "SIGNATURE:\${SIGNATURE}" + "TZ:\${TZ:-America/Chicago}" + "DATOMIC_URL:\${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub}" + "DATOMIC_PASSWORD:\${DATOMIC_PASSWORD}" + "JAVA_OPTS:${java_opts}" + "CSP_POLICY:\${CSP_POLICY:-strict}" + ) + + # Build env blocks: carry forward old vars, append any new canonical vars missing from old + local d_env_block="" a_env_block="" + + _build_env_block() { + local block="" var_name default_val + local -n _old_env=$1; shift + local -n _defaults=$1; shift + + # Start with old vars if upgrading + if [ "$is_upgrade" = "true" ] && [ "${#_old_env[@]}" -gt 0 ] 2>/dev/null; then + for e in "${_old_env[@]}"; do + block="${block} ${e}\n" + done + # Append new vars not in old + for pair in "${_defaults[@]}"; do + var_name="${pair%%:*}" + default_val="${pair#*:}" + local found=false + for e in "${_old_env[@]}"; do + [[ "$e" == "${var_name}:"* ]] && found=true && break + done + [ "$found" = "false" ] && block="${block} ${var_name}: ${default_val}\n" + done + else + for pair in "${_defaults[@]}"; do + var_name="${pair%%:*}" + default_val="${pair#*:}" + block="${block} ${var_name}: ${default_val}\n" + done + fi + printf '%s' "$block" + } + + d_env_block=$(_build_env_block _swarm_datomic_env _d_env_defaults) + a_env_block=$(_build_env_block _swarm_app_env _a_env_defaults) + + # Traefik labels + local traefik_block="" + if [ "${#_swarm_traefik_labels[@]}" -gt 0 ] 2>/dev/null; then + traefik_block=" labels:" + for label in "${_swarm_traefik_labels[@]}"; do + traefik_block="${traefik_block}\n - ${label}" + done + else + traefik_block=' # --- Traefik labels (uncomment and customize) --- + # labels: + # - traefik.enable=true + # - traefik.docker.network=traefik-public + # - traefik.http.routers.orcpub.rule=Host(`your.domain.com`) + # - traefik.http.routers.orcpub.entrypoints=websecure + # - traefik.http.routers.orcpub.tls=true + # - traefik.http.routers.orcpub.tls.certresolver=letsencrypt + # - traefik.http.services.orcpub.loadbalancer.server.port=${PORT:-8890}' + fi + + # --- Write the file --- + cat > "$output" < +name: ${stack_name} + +services: + ${datomic_svc}: + image: ${datomic_image} + environment: +$(printf '%b' "$d_env_block") + volumes: +$(printf '%b' "${d_bind_mounts}${d_vol_mounts}") + healthcheck: + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 40s + networks: +$(printf '%b' "$datomic_networks") + deploy: + resources: + limits: + memory: ${d_mem_limit} + reservations: + memory: ${d_mem_res} + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 5 + window: 120s + update_config: + parallelism: 1 + delay: 10s + order: stop-first + failure_action: rollback + rollback_config: + parallelism: 1 + order: stop-first + + ${app_svc}: + image: ${app_image} + environment: +$(printf '%b' "$a_env_block") + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:\${PORT:-8890}/health"] + interval: 30s + timeout: 5s + retries: 30 + start_period: 60s$( + # Only emit volumes: block if there are bind mounts + if [ -n "$a_bind_mounts" ]; then + printf '\n volumes:\n%b' "$a_bind_mounts" + fi +) + networks: +$(printf '%b' "$app_networks") + deploy: + resources: + limits: + memory: ${a_mem_limit} + reservations: + memory: ${a_mem_res} + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 5 + window: 120s + update_config: + parallelism: 1 + delay: 10s + order: start-first + failure_action: rollback + rollback_config: + parallelism: 1 + order: stop-first +${traefik_block} + # secrets: + # - datomic_password + # - signature + +# --- Secrets (uncomment to use Swarm encrypted secrets) --- +# secrets: +# datomic_password: +# external: true +# admin_password: +# external: true +# signature: +# external: true + +$(printf '%b' "$vol_block") + +$(printf '%b' "$net_block") +SWARM_YAML + + chmod 644 "$output" +} + +# --------------------------------------------------------------------------- +# print_swarm_summary — colorized full-file CLI output +# --------------------------------------------------------------------------- + +print_swarm_summary() { + local compose_file="$1" + local is_upgrade="${2:-false}" + local backup_file="${3:-}" + + # Safe defaults for unset variables + : "${_swarm_has_transactor_props:=false}" + _swarm_volumes=("${_swarm_volumes[@]+"${_swarm_volumes[@]}"}") + + printf '\n%s ╔══════════════════════════════════════════════════════════╗%s\n' "$color_bold" "$color_reset" + printf '%s ║ Dungeon Master'\''s Vault — Swarm Compose Generator ║%s\n' "$color_bold" "$color_reset" + printf '%s ╚══════════════════════════════════════════════════════════╝%s\n\n' "$color_bold" "$color_reset" + + printf ' %s── Environment ──────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s✓%s .env loaded\n' "$color_green" "$color_reset" + if [ -n "$backup_file" ]; then + printf ' %s✓%s .env backed up\n' "$color_green" "$color_reset" + fi + echo + + if [ "$is_upgrade" = "true" ] && [ -n "$backup_file" ]; then + printf ' %s── Existing Swarm Compose Detected ──────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s✓%s Backed up: %s%s%s\n' "$color_green" "$color_reset" "$color_dim" "$backup_file" "$color_reset" + echo + + printf ' %s── Legend ───────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s│%s white %s│%s unchanged — your config, carried forward\n' "$color_dim" "$color_reset" "$color_dim" "$color_reset" + printf ' %s│%s %scyan%s %s│%s new line — upstream addition, default value\n' "$color_dim" "$color_reset" "$color_cyan" "$color_reset" "$color_dim" "$color_reset" + printf ' %s│%s %sgreen%s %s│%s new line — upstream addition, %syour%s value from .env\n' "$color_dim" "$color_reset" "$color_green" "$color_reset" "$color_dim" "$color_reset" "$color_bold" "$color_reset" + printf ' %s│%s %syellow%s %s│%s changed — value differs from your previous config\n' "$color_dim" "$color_reset" "$color_yellow" "$color_reset" "$color_dim" "$color_reset" + echo + fi + + printf ' %s── %s ────────────────────────────────────────%s\n' "$color_dim" "$(basename "$compose_file")" "$color_reset" + + # Print file with color coding + if [ "$is_upgrade" = "true" ] && [ -n "$backup_file" ] && [ -f "$backup_file" ]; then + # Line-by-line color comparison against backup (old) file + while IFS= read -r line; do + local trimmed + trimmed=$(echo "$line" | sed 's/^[[:space:]]*//') + + # Skip empty lines and comments — print dim + if [ -z "$trimmed" ] || [[ "$trimmed" == \#* ]]; then + printf ' %s%s%s\n' "$color_dim" "$line" "$color_reset" + continue + fi + + # Structural YAML keywords — print dim + if [[ "$trimmed" =~ ^(name:|services:|volumes:|networks:|secrets:) ]] || \ + [[ "$trimmed" =~ ^[a-z_]+:$ ]]; then + printf ' %s%s%s\n' "$color_dim" "$line" "$color_reset" + continue + fi + + # Check if line exists verbatim in old file → white (unchanged) + if grep -qFx "$line" "$backup_file" 2>/dev/null; then + printf ' %s\n' "$line" + continue + fi + + # Extract key and indentation for context-aware matching + local key="" indent="" + indent=$(echo "$line" | sed 's/[^ ].*//') + if [[ "$trimmed" =~ ^([A-Za-z_][A-Za-z0-9_-]*): ]]; then + key="${BASH_REMATCH[1]}" + fi + + # If we have a key, check if same key at same indent existed with different value → yellow + if [ -n "$key" ]; then + local old_line + old_line=$(grep -E "^${indent}${key}:" "$backup_file" 2>/dev/null | head -1 || true) + if [ -n "$old_line" ]; then + local old_val new_val + old_val=$(echo "$old_line" | sed "s/^[[:space:]]*${key}:[[:space:]]*//" | tr -d '\r') + new_val=$(echo "$trimmed" | sed "s/^${key}:[[:space:]]*//" | tr -d '\r') + if [ "$old_val" != "$new_val" ]; then + printf ' %s%s%s %s# CHANGED (was: %s)%s\n' "$color_yellow" "$line" "$color_reset" "$color_yellow" "$old_val" "$color_reset" + continue + fi + fi + fi + + # New line — check if it references an env var the admin has set → green, else → cyan + if [[ "$trimmed" =~ \$\{([A-Z_][A-Z0-9_]*)(:-)([^}]*)\} ]]; then + local var_name="${BASH_REMATCH[1]}" + local default_val="${BASH_REMATCH[3]}" + local env_val + env_val=$(read_env_val "$var_name" "$ENV_FILE" 2>/dev/null || true) + if [ -n "$env_val" ] && [ "$env_val" != "$default_val" ]; then + printf ' %s%s%s %s# NEW — set to %s in .env%s\n' "$color_green" "$line" "$color_reset" "$color_green" "$env_val" "$color_reset" + else + local resolved="${default_val}" + [ -n "$env_val" ] && resolved="$env_val" + printf ' %s%s%s %s# NEW — using default: %s%s\n' "$color_cyan" "$line" "$color_reset" "$color_cyan" "$resolved" "$color_reset" + fi + continue + fi + + # New line without env var reference → cyan + printf ' %s%s%s %s# NEW%s\n' "$color_cyan" "$line" "$color_reset" "$color_cyan" "$color_reset" + done < "$compose_file" + else + # Fresh generation — all lines are new (cyan) + while IFS= read -r line; do + printf ' %s%s%s\n' "$color_cyan" "$line" "$color_reset" + done < "$compose_file" + fi + echo + + # Warnings + if [ "$is_upgrade" = "true" ]; then + local has_warnings=false + + [ "$_swarm_has_transactor_props" = "true" ] 2>/dev/null && has_warnings=true + + if [ "$has_warnings" = "true" ]; then + printf ' %s── Warnings ────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + echo + + if [ "$_swarm_has_transactor_props" = "true" ] 2>/dev/null; then + printf ' %s⚠%s %stransactor.properties%s is bind-mounted — template changes won'\''t apply.\n' "$color_yellow" "$color_reset" "$color_bold" "$color_reset" + printf ' See: %stransactor.properties.reference%s\n\n' "$color_dim" "$color_reset" + fi + fi + fi + + # Files + printf ' %s── Files ───────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s%s%s %s← ready to deploy%s\n' "$color_bold" "$compose_file" "$color_reset" "$color_green" "$color_reset" + if [ -n "$backup_file" ]; then + printf ' %s%s%s\n' "$color_dim" "$backup_file" "$color_reset" + fi + if [ "$_swarm_has_transactor_props" = "true" ] 2>/dev/null; then + printf ' %s%s/transactor.properties.reference%s\n' "$color_dim" "$SCRIPT_DIR" "$color_reset" + fi + if [ -f "$SWARM_PORTAINER_ENV" ]; then + printf ' %s%s%s %s← paste into Portainer%s\n' "$color_bold" "$SWARM_PORTAINER_ENV" "$color_reset" "$color_green" "$color_reset" + fi + printf ' %s%s%s %s← full .env%s\n' "$color_bold" "$ENV_FILE" "$color_reset" "$color_green" "$color_reset" + echo + + # Deploy instructions (fresh only) + if [ "$is_upgrade" != "true" ]; then + printf ' %s── Deploy ───────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + echo + printf ' %sPortainer:%s\n' "$color_bold" "$color_reset" + printf ' 1. Stacks → Add stack → Web editor\n' + printf ' 2. Paste contents of %s\n' "$(basename "$compose_file")" + printf ' 3. Environment variables → Advanced mode\n' + printf ' 4. Paste contents of %s\n' ".env.portainer" + echo + printf ' %sCLI:%s\n' "$color_bold" "$color_reset" + printf ' set -a; source .env; set +a\n' + printf ' docker stack deploy -c %s %s\n' "$(basename "$compose_file")" "${_swarm_stack_name:-orcpub}" + echo + + # Volume pre-creation reminder + if [ "${#_swarm_volumes[@]}" -eq 0 ] 2>/dev/null; then + printf ' %sPre-create volumes:%s\n' "$color_bold" "$color_reset" + printf ' docker volume create orcpub_data\n' + printf ' docker volume create orcpub_logs\n' + printf ' docker volume create orcpub_backups\n' + echo + fi + fi +} diff --git a/src/clj/orcpub/config.clj b/src/clj/orcpub/config.clj index 498f418b4..f7cbc1064 100644 --- a/src/clj/orcpub/config.clj +++ b/src/clj/orcpub/config.clj @@ -1,22 +1,53 @@ (ns orcpub.config (:require [environ.core :refer [env]] - [clojure.string :as str])) + [clojure.string :as str] + [clojure.java.io :as io])) (def default-datomic-uri "datomic:dev://localhost:4334/orcpub") +(defn read-secret + "Read a Docker secret from /run/secrets/, or nil if not mounted. + Trims trailing whitespace (secret files often end with a newline)." + [name] + (let [f (io/file "/run/secrets" name)] + (when (.exists f) + (not-empty (str/trim (slurp f)))))) + (defn datomic-env "Return the raw DATOMIC_URL environment value or nil if unset." [] (or (env :datomic-url) (some-> (System/getenv "DATOMIC_URL") not-empty))) +(defn datomic-password + "Return DATOMIC_PASSWORD from Docker secret, env var, or nil. + Resolution order: /run/secrets/datomic_password > DATOMIC_PASSWORD env var." [] + (or (read-secret "datomic_password") + (env :datomic-password) + (some-> (System/getenv "DATOMIC_PASSWORD") not-empty))) + +(defn signature + "Return SIGNATURE from Docker secret, env var, or nil. + Resolution order: /run/secrets/signature > SIGNATURE env var." [] + (or (read-secret "signature") + (env :signature) + (some-> (System/getenv "SIGNATURE") not-empty))) + (defn get-datomic-uri "Return the Datomic URI from the environment or the default. Prefers the raw env value (from `datomic-env`), otherwise returns a safe - local development default (datomic:dev://localhost:4334/orcpub)." + local development default (datomic:dev://localhost:4334/orcpub). + + If the URL does not contain a ?password= parameter and DATOMIC_PASSWORD + is set, appends it automatically. This allows admins to keep the password + out of DATOMIC_URL (e.g. for Docker secrets) while remaining backward + compatible with URLs that embed the password." [] - (or (datomic-env) - default-datomic-uri)) + (let [url (or (datomic-env) default-datomic-uri) + pw (datomic-password)] + (if (and pw (not (str/includes? url "password="))) + (str url "?password=" pw) + url))) ;; Content Security Policy configuration ;; CSP_POLICY environment variable options: @@ -43,11 +74,11 @@ (str/lower-case policy))) (defn dev-mode? - "Returns true when running in dev mode (DEV_MODE env var is truthy). - Used by CSP to determine if Figwheel/CLJS dev builds are in use. - See also: index.clj which uses the same env var pattern." + "Returns true when running in dev mode (DEV_MODE env var is 'true'). + Env vars are strings — (boolean \"false\") is true in Clojure, so we + must compare against the string \"true\" explicitly." [] - (boolean (env :dev-mode))) + (= "true" (str/lower-case (or (env :dev-mode) "")))) (defn strict-csp? "Returns true when CSP_POLICY=strict (regardless of dev mode). diff --git a/src/clj/orcpub/csp.clj b/src/clj/orcpub/csp.clj index 5ed53758a..7e07f8370 100644 --- a/src/clj/orcpub/csp.clj +++ b/src/clj/orcpub/csp.clj @@ -3,6 +3,7 @@ Generates per-request cryptographic nonces and builds CSP headers with 'strict-dynamic' for XSS protection." + (:require [clojure.string :as str]) (:import [java.security SecureRandom] [java.util Base64])) @@ -24,21 +25,26 @@ "Build a Content-Security-Policy header string with strict-dynamic and nonce. Options: - :dev-mode? - When true, adds ws://localhost:3449 to connect-src for - Figwheel hot-reload WebSocket support. + :dev-mode? - When true, adds ws://localhost:3449 to connect-src + :extra-connect-src - Seq of additional connect-src origins (from integrations) + :extra-frame-src - Seq of additional frame-src origins (from integrations) The resulting CSP: - Uses 'strict-dynamic' for script-src (only nonced scripts execute) - Allows Google Fonts for styles and fonts - Restricts all other sources to 'self' - Blocks object embeds, restricts base-uri, frame-ancestors, and form-action" - [nonce & {:keys [dev-mode?]}] + [nonce & {:keys [dev-mode? extra-connect-src extra-frame-src]}] (str "default-src 'self'; " "script-src 'strict-dynamic' 'nonce-" nonce "'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self' https://fonts.gstatic.com; " "img-src 'self' data: https:; " - "connect-src 'self'" (when dev-mode? " ws://localhost:3449") "; " + "connect-src 'self'" + (when (seq extra-connect-src) (str " " (str/join " " extra-connect-src))) + (when dev-mode? " ws://localhost:3449") "; " + (when (seq extra-frame-src) + (str "frame-src 'self' " (str/join " " extra-frame-src) "; ")) "object-src 'none'; " "base-uri 'self'; " "frame-ancestors 'self'; " diff --git a/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index 06ed3c833..a7061c1bf 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -150,7 +150,16 @@ :db/cardinality :db.cardinality/one} {:db/ident :orcpub.user/following :db/valueType :db.type/ref - :db/cardinality :db.cardinality/many}]) + :db/cardinality :db.cardinality/many} + {:db/ident :orcpub.user/patron + :db/valueType :db.type/boolean + :db/cardinality :db.cardinality/one} + {:db/ident :orcpub.user/patron-tier + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :orcpub.user/last-login + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one}]) (def entity-schema [{:db/ident ::se/key diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 263ad2d5d..c6e542e62 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -10,23 +10,40 @@ [clojure.pprint :as pprint] [clojure.string :as s] [orcpub.route-map :as routes] + [orcpub.fork.branding :as branding] [cuerdas.core :as str])) +(defn- social-links-footer + "Render email footer social links from branding config. Empty links hidden." + [] + (let [{:keys [patreon twitter facebook]} branding/social-links] + (remove nil? + [(when (seq patreon) [:br]) + (when (seq patreon) (str patreon " <-- Like what we are doing? Support us here.")) + (when (seq twitter) [:br]) + (when (seq twitter) twitter) + (when (seq facebook) [:br]) + (when (seq facebook) facebook) + [:br]]))) + (defn verification-email-html [first-and-last-name username verification-url] - [:div - "Dear Dungeon Master's Vault Patron," - [:br] - [:br] - "Your Dungeon Master's Vault account is almost ready, we just need you to verify your email address going the following URL to confirm that you are authorized to use this email address:" - [:br] - [:br] - [:a {:href verification-url} verification-url] - [:br] - [:br] - "Sincerely," - [:br] - [:br] - "The Dungeon Master's Vault Team"]) + (into + [:div + (str "Welcome to " branding/app-name + (when (seq first-and-last-name) (str ", " first-and-last-name)) "!") + [:br] + [:br] + (str "Your " branding/app-name " account is almost ready, we just need you to verify your email address by visiting the following URL to confirm that you are authorized to use this email address:") + [:br] + [:br] + [:a {:href verification-url} verification-url] + [:br] + [:br] + "Sincerely," + [:br] + [:br] + (str "The " branding/email-sender-name)] + (social-links-footer))) (defn verification-email [first-and-last-name username verification-url] [{:type "text/html" @@ -36,7 +53,7 @@ "Email body for existing users changing their email (distinct from registration)." [username verification-url] [:div - "Dear Dungeon Master's Vault Patron," + (str "Dear " branding/app-name " User,") [:br] [:br] "You requested to change the email address on your account (" username "). " @@ -52,7 +69,7 @@ "Sincerely," [:br] [:br] - "The Dungeon Master's Vault Team"]) + (str "The " branding/email-sender-name)]) (defn email-change-verification-email [username verification-url] [{:type "text/html" @@ -72,8 +89,11 @@ :port (environ/env :email-server-port)} e))))) -(defn emailfrom [] - (if (not (s/blank? (environ/env :email-from-address))) (environ/env :email-from-address) "no-reply@dungeonmastersvault.com")) +(defn emailfrom + "Returns the configured from-address. Delegates to branding/email-from-address + which already reads EMAIL_FROM_ADDRESS env var with a fallback default." + [] + branding/email-from-address) (defn send-verification-email "Sends account verification email to a new user. @@ -91,9 +111,9 @@ [base-url {:keys [email username first-and-last-name]} verification-key] (try (let [result (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") + {:from (str branding/email-sender-name " <" (emailfrom) ">") :to email - :subject "Dungeon Master's Vault Email Verification" + :subject (str branding/app-name " Email Verification") :body (verification-email first-and-last-name username @@ -113,34 +133,61 @@ e))))) (defn send-email-change-verification - "Send a verification email for an email-change request (not registration)." + "Send a verification email for an email-change request (not registration). + + Args: + base-url - Base URL for the application (for verification link) + user-map - Map containing :email and :username + verification-key - Unique key for email change verification + + Returns: + Postal send-message result + + Throws: + ExceptionInfo with :email-change-verification-failed error code if email cannot be sent" [base-url {:keys [email username]} verification-key] - (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") - :to email - :subject "Dungeon Master's Vault Email Change Verification" - :body (email-change-verification-email - username - (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})) + (try + (let [result (postal/send-message (email-cfg) + {:from (str branding/email-sender-name " <" (emailfrom) ">") + :to email + :subject (str branding/app-name " Email Change Verification") + :body (email-change-verification-email + username + (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})] + (when (not= :SUCCESS (:error result)) + (throw (ex-info "Failed to send email change verification" + {:error :email-send-failed + :email email + :postal-response result}))) + result) + (catch Exception e + (println "ERROR: Failed to send email change verification to" email ":" (.getMessage e)) + (throw (ex-info "Unable to send email change verification. Please check your email configuration or try again later." + {:error :email-change-verification-failed + :email email + :username username} + e))))) (defn reset-password-email-html [first-and-last-name reset-url] - [:div - "Dear Dungeon Master's Vault Patron" - [:br] - [:br] - "We received a request to reset your password, to do so please go to the following URL to complete the reset." - [:br] - [:br] - [:a {:href reset-url} reset-url] - [:br] - [:br] - "If you did NOT request a reset, please do no click on the link." - [:br] - [:br] - "Sincerely," - [:br] - [:br] - "The Dungeon Master's Vault Team"]) + (into + [:div + (str "Dear " (if (seq first-and-last-name) first-and-last-name (str branding/app-name " User")) ",") + [:br] + [:br] + "We received a request to reset your password, to do so please go to the following URL to complete the reset." + [:br] + [:br] + [:a {:href reset-url} reset-url] + [:br] + [:br] + "If you did NOT request a reset, please do NOT click on the link." + [:br] + [:br] + "Sincerely," + [:br] + [:br] + (str "The " branding/email-sender-name)] + (social-links-footer))) (defn reset-password-email [first-and-last-name reset-url] [{:type "text/html" @@ -162,9 +209,9 @@ [base-url {:keys [email username first-and-last-name]} reset-key] (try (let [result (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") + {:from (str branding/email-sender-name " <" (emailfrom) ">") :to email - :subject "Dungeon Master's Vault Password Reset" + :subject (str branding/app-name " Password Reset") :body (reset-password-email first-and-last-name (str base-url (routes/path-for routes/reset-password-page-route) "?key=" reset-key))})] @@ -182,34 +229,162 @@ :username username} e))))) -(defn send-error-email - "Sends error notification email to configured admin email. +(defn unsubscribe-url + "Build a full unsubscribe URL from a base-url and a pre-signed JWT token. + Token is generated by routes/unsubscribe-token to avoid circular deps." + [base-url token] + (str base-url (routes/path-for routes/unsubscribe-route) "?token=" token)) - This function is called when unhandled exceptions occur in the application. - It includes request context and exception details for debugging. +;; --------------------------------------------------------------------------- +;; Error email — helpers +;; --------------------------------------------------------------------------- - Args: - context - Request context map - exception - The exception that occurred +(def ^:private error-throttle + "Maps fingerprint string → last-sent-ms. Suppresses duplicate error emails." + (atom {})) - Returns: - Postal send-message result, or nil if no error email is configured - or if sending fails (failures are logged but not thrown)" +(def ^:private throttle-window-ms (* 5 60 1000)) + +(defn- root-cause [^Throwable ex] + (loop [e ex] + (if-let [c (.getCause e)] (recur c) e))) + +(defn- orcpub-frame? [^StackTraceElement f] + (s/starts-with? (.getClassName f) "orcpub.")) + +(def ^:private infra-prefixes + ["org.eclipse.jetty." "io.pedestal." "clojure.lang." + "java.lang.Thread" "sun.reflect." "java.util.concurrent." + "clojure.core$"]) + +(defn- infra-frame? [^StackTraceElement f] + (let [cls (.getClassName f)] + (some #(s/starts-with? cls %) infra-prefixes))) + +(defn- fmt-frame [^StackTraceElement f] + (str " " (.getClassName f) "." (.getMethodName f) + " (" (.getFileName f) ":" (.getLineNumber f) ")")) + +(defn- render-stack [^Throwable ex] + (let [frames (seq (.getStackTrace ex)) + app-frames (filter orcpub-frame? frames) + suppressed (count (filter infra-frame? frames))] + (str + (if (seq app-frames) + (s/join "\n" (map fmt-frame app-frames)) + (let [fallback (->> frames (remove infra-frame?) last)] + (if fallback + (str (fmt-frame fallback) " <- deepest non-infrastructure frame") + " (no frames available)"))) + (when (pos? suppressed) + (str "\n ... " suppressed " infrastructure frames suppressed"))))) + +(defn- render-cause-chain [^Throwable ex] + (let [sb (java.lang.StringBuilder.)] + (loop [e ex, depth 0] + (when e + (.append sb (str (when (pos? depth) "\nCaused by: ") + (.getName (.getClass e)) + ": " (or (.getMessage e) "(no message)") "\n")) + (.append sb (render-stack e)) + (.append sb "\n") + (recur (.getCause e) (inc depth)))) + (str sb))) + +(defn- throttle-fingerprint [^Throwable ex] + (let [root (root-cause ex) + root-class (.getName (.getClass root)) + frames (seq (.getStackTrace ex)) + app-frame (first (filter orcpub-frame? frames))] + (if app-frame + (str root-class "+" (.getClassName app-frame) "." (.getMethodName app-frame)) + (let [msg (or (.getMessage root) "")] + (str root-class "+" (subs msg 0 (min 60 (count msg)))))))) + +(defn- throttled? [fp] + (when-let [t (get @error-throttle fp)] + (< (- (System/currentTimeMillis) t) throttle-window-ms))) + +(defn- record-sent! [fp] + (swap! error-throttle assoc fp (System/currentTimeMillis))) + +(def ^:private safe-headers + #{"user-agent" "referer" "content-type" "accept-language" "cf-ipcountry" + "x-forwarded-for" "x-real-ip" "cf-ray" "sec-fetch-site" "sec-fetch-mode" + "x-forwarded-host" "x-forwarded-proto"}) + +(def ^:private drop-req-keys + #{:json-params :transit-params :form-params :body :db :conn + :servlet-request :servlet-response :servlet :url-for + :async-supported? :identity :character-encoding :protocol + :path-params :content-length}) + +(defn- scrub-request [req] + (-> (apply dissoc req drop-req-keys) + (update :headers #(select-keys (or % {}) safe-headers)))) + +(defn- pedestal-wrapper? + "True when ex-data looks like a Pedestal interceptor error map." + [data] + (and (map? data) (contains? data :exception) (contains? data :interceptor))) + +(defn- email-subject [^Throwable real-ex request] + (let [cls (.getSimpleName (.getClass real-ex)) + msg (let [m (or (.getMessage real-ex) "(no message)")] + (subs m 0 (min 80 (count m)))) + method (some-> (:request-method request) name s/upper-case) + uri (or (:uri request) "?")] + (str "[" branding/app-name "] " cls ": " msg " @ " method " " uri))) + +(defn- build-body [request real-ex pedestal-meta] + (str + "=== Request ===\n" + (with-out-str (pprint/pprint (scrub-request request))) + (when-let [u (:username request)] (str "User: " u "\n")) + "\n=== Exception ===\n" + (render-cause-chain real-ex) + (when (instance? clojure.lang.ExceptionInfo real-ex) + (when-let [d (ex-data real-ex)] + (str "\n=== Exception Data ===\n" + (with-out-str (pprint/pprint d))))) + (when pedestal-meta + (str "\n=== Interceptor Context ===\n" + (with-out-str (pprint/pprint pedestal-meta)))))) + +;; --------------------------------------------------------------------------- + +(defn send-error-email + "Sends a scrubbed, readable error notification email to the configured admin + address (EMAIL_ERRORS_TO env var). + + - Strips credentials, cookies, body params, and Datomic objects from request + - Filters stack trace to orcpub.* frames; falls back to deepest non-infra frame + - Walks the full cause chain + - Throttles: one email per unique error fingerprint per 5 minutes + - Extracts Pedestal interceptor metadata as a separate section" [context exception] (when (not-empty (environ/env :email-errors-to)) - (try - (let [result (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Errors <" (emailfrom) ">") - :to (str (environ/env :email-errors-to)) - :subject "Exception" - :body [{:type "text/plain" - :content (let [writer (java.io.StringWriter.)] - (clojure.pprint/pprint (:request context) writer) - (clojure.pprint/pprint (or (ex-data exception) exception) writer) - (str writer))}]})] - (when (not= :SUCCESS (:error result)) - (println "WARNING: Failed to send error notification email:" (:error result))) - result) - (catch Exception e - (println "ERROR: Failed to send error notification email:" (.getMessage e)) - nil)))) \ No newline at end of file + (let [data-map (ex-data exception) + pedestal? (pedestal-wrapper? data-map) + real-ex (if pedestal? (:exception data-map) exception) + pedestal-meta (when pedestal? (dissoc data-map :exception :exception-type)) + request (or (:request context) {}) + fp (throttle-fingerprint real-ex)] + (if (throttled? fp) + (println "INFO: Suppressed duplicate error email (fingerprint:" fp ")") + (do + (record-sent! fp) + (try + (let [result (postal/send-message + (email-cfg) + {:from (str branding/app-name " Errors <" (emailfrom) ">") + :to (str (environ/env :email-errors-to)) + :subject (email-subject real-ex request) + :body [{:type "text/plain" + :content (build-body request real-ex pedestal-meta)}]})] + (when (not= :SUCCESS (:error result)) + (println "WARNING: Failed to send error notification email:" (:error result))) + result) + (catch Exception e + (println "ERROR: Failed to send error notification email:" (.getMessage e)) + nil))))))) \ No newline at end of file diff --git a/src/clj/orcpub/fork/auth.clj b/src/clj/orcpub/fork/auth.clj new file mode 100644 index 000000000..b4ff4edb4 --- /dev/null +++ b/src/clj/orcpub/fork/auth.clj @@ -0,0 +1,25 @@ +(ns orcpub.fork.auth + "Fork-specific auth and session configuration. + Public/community edition: short sessions, no login tracking." + (:require [clojure.string :as s] + [orcpub.fork.branding :as branding])) + +;; ─── Session ──────────────────────────────────────────────────────── + +(def token-lifetime-hours + "JWT token lifetime in hours." + 24) + +(def track-last-login? + "Whether to record last-login timestamp on each login." + false) + +(def record-last-login-at-registration? + "Whether to set initial last-login when a user registers." + false) + +;; ─── Display ──────────────────────────────────────────────────────── + +(def verification-display-name + "Name shown in verification and password-reset emails." + "User") diff --git a/src/clj/orcpub/fork/branding.clj b/src/clj/orcpub/fork/branding.clj new file mode 100644 index 000000000..18e6a4fb2 --- /dev/null +++ b/src/clj/orcpub/fork/branding.clj @@ -0,0 +1,129 @@ +(ns orcpub.fork.branding + "Centralized branding configuration for fork-neutral deployment. + All values have sensible defaults; forks override via env vars. + + Server-side (.clj) is the source of truth. Client-side branding + is delivered via the config bridge: index.clj injects client-config + as window.__BRANDING__ JSON in , and branding.cljs reads it." + (:require [environ.core :refer [env]]) + (:import [java.time Year])) + +;; ─── App Identity ────────────────────────────────────────────────── + +(def app-name + "Full display name. Used in emails, OG tags, page titles." + (or (env :app-name) "OrcPub")) + +(def app-tagline + "One-line description for OG/meta tags." + (or (env :app-tagline) + "D&D 5e character builder/generator and digital character sheet far beyond any other in the multiverse.")) + +(def app-url + "Primary application URL for legal pages and external references. Empty = hidden." + (or (env :app-url) "")) + +(def default-page-title + "Default and og:title when no page-specific title is set." + (or (env :app-page-title) + (str app-name ": D&D 5e Character Builder/Generator"))) + +;; ─── Logos & Images ──────────────────────────────────────────────── + +(def logo-path + "Path to the main SVG logo (splash page, header, privacy page)." + (or (env :app-logo-path) "/image/orcpub-logo.svg")) + +(def og-image-filename + "Filename for the OG meta image (social sharing preview). + Combined with the request host to form the full URL." + (or (env :app-og-image) "/image/orcpub-logo.png")) + +;; ─── Copyright ───────────────────────────────────────────────────── + +(def copyright-holder + "Entity name shown in legal footer." + (or (env :app-copyright-holder) "OrcPub")) + +(def copyright-year + "Copyright year string. Defaults to the current year." + (or (env :app-copyright-year) (str (.getValue (Year/now))))) + +;; ─── Email ───────────────────────────────────────────────────────── + +(def email-sender-name + "Display name for outbound emails (verification, password reset)." + (or (env :app-email-sender-name) (str app-name " Team"))) + +(def email-from-address + "From address for outbound emails. Falls back to env EMAIL_FROM_ADDRESS." + (or (env :email-from-address) "no-reply@orcpub.com")) + +;; ─── Support & Help ────────────────────────────────────────────── + +(def support-email + "Contact email shown on privacy page, error messages, etc. Empty = hidden." + (or (env :app-support-email) "")) + +(def help-url + "URL for the help/FAQ page. Empty string = hidden." + (or (env :app-help-url) "")) + +;; ─── Social Links ────────────────────────────────────────────────── +;; Each link appears in the header/footer when non-empty. +;; Set the corresponding env var to a URL to enable, or leave unset to hide. +;; e.g. in .env: APP_SOCIAL_PATREON=https://www.patreon.com/YourProject +;; APP_SOCIAL_DISCORD=https://discord.gg/your-invite + +(def social-links + "Map of social platform links. Empty string = hidden." + {:patreon (or (env :app-social-patreon) "") + :facebook (or (env :app-social-facebook) "") + :bluesky (or (env :app-social-bluesky) "") + :twitter (or (env :app-social-twitter) "") + :reddit (or (env :app-social-reddit) "") + :discord (or (env :app-social-discord) "")}) + +;; ─── Footer ───────────────────────────────────────────────────── + +(def copyright-url + "URL for copyright holder name in footer. Empty string = plain text." + (or (env :app-copyright-url) "")) + +;; ─── UI Behavior ──────────────────────────────────────────────── + +(def registration-logo-class + "CSS class for logo on registration/login page." + "h-55") + +(def restrict-print-to-owner? + "Whether the print button on character list is restricted to the character owner." + false) + +;; ─── Field Limits ──────────────────────────────────────────────── +;; Input field max-length constraints for form validation. + +(def field-limits + "Max-length constraints for form input fields." + {:notes (or (some-> (env :app-field-limit-notes) Integer/parseInt) 50000) + :text (or (some-> (env :app-field-limit-text) Integer/parseInt) 255) + :number (or (some-> (env :app-field-limit-number) Integer/parseInt) 7)}) + +;; ─── Client-Side Config Bridge ─────────────────────────────────── +;; index.clj injects this as window.__BRANDING__ JSON in <head>. +;; branding.cljs reads it at runtime for CLJS components. + +(defn client-config + "Map of branding values for CLJS injection. Serialized to JSON by index.clj." + [] + {:app-name app-name + :logo-path logo-path + :copyright-holder copyright-holder + :copyright-year copyright-year + :copyright-url copyright-url + :support-email support-email + :help-url help-url + :social-links social-links + :field-limits field-limits + :registration-logo-class registration-logo-class + :restrict-print-to-owner? restrict-print-to-owner?}) diff --git a/src/clj/orcpub/fork/integrations.clj b/src/clj/orcpub/fork/integrations.clj new file mode 100644 index 000000000..2ac4269b0 --- /dev/null +++ b/src/clj/orcpub/fork/integrations.clj @@ -0,0 +1,43 @@ +(ns orcpub.fork.integrations + "Optional third-party <head> integrations. + Configure via environment variables; disabled when unset. + Fork overrides: uncomment examples and add real service config." + (:require [environ.core :refer [env]])) + +;; ─── How to add an integration ─────────────────────────────────────── +;; +;; 1. Define env-var-gated config: +;; (def my-service-id (env :my-service-id)) +;; +;; 2. Write a tag function that returns hiccup (or nil when disabled): +;; (defn- my-service-tag [nonce] +;; (when (seq my-service-id) +;; [:script {:nonce nonce :async "" +;; :src (str "https://example.com/sdk.js?id=" my-service-id)}])) +;; +;; 3. Add it to head-tags's concat list below. + +;; ─── Client-Side Config Bridge ────────────────────────────────── +;; Passes server-side integration config to CLJS components. +;; index.clj injects this as window.__INTEGRATIONS__ JSON in <head>. +;; integrations.cljs reads it at namespace load time. +;; See branding.clj/client-config for a working example. + +(defn client-config + "Map of integration config for CLJS injection. Empty by default. + Fork overrides: return env-var-gated config values for CLJS components." + [] + {}) + +(def csp-domains + "Extra CSP domains required by enabled integrations. + Returns {:connect-src [\"https://...\"] :frame-src [\"https://...\"]}. + csp.clj merges these into the Content-Security-Policy header." + {}) + +(defn head-tags + "All third-party integration tags for <head>. + Returns a flat seq of hiccup elements, empty when nothing is configured. + Fork overrides: add integration tag calls here." + [_nonce] + ()) diff --git a/src/clj/orcpub/fork/privacy_content.clj b/src/clj/orcpub/fork/privacy_content.clj new file mode 100644 index 000000000..f49e08ea7 --- /dev/null +++ b/src/clj/orcpub/fork/privacy_content.clj @@ -0,0 +1,78 @@ +(ns orcpub.fork.privacy-content + "Fork-specific privacy policy content. + Public/community edition: standard privacy policy." + (:require [clojure.string :as s] + [environ.core :as environ] + [orcpub.fork.branding :as branding])) + +(def privacy-policy-section + {:title "Privacy Policy" + :font-size 48 + :subsections + [{:title (str "Thank you for using " branding/app-name "!") + :font-size 32 + :paragraphs + ["We wrote this policy to help you understand what information we collect, how we use it, and what choices you have. Because we're an internet company, some of the concepts below are a little technical, but we've tried our best to explain things in a simple and clear way. We welcome your questions and comments on this policy."]} + {:title "How We Collect Your Information" + :font-size 32 + :subsections + [{:title "When you give it to us or give us permission to obtain it" + :font-size 28 + :paragraphs + [(str "When you sign up for or use our products, you voluntarily give us certain information. This can include your name, profile photo, role-playing game characters, comments, likes, the email address or phone number you used to sign up, and any other information you provide us. If you're using " branding/app-name " on your mobile device, you can also choose to provide us with location data. And if you choose to buy something on " branding/app-name ", you provide us with payment information, contact information (ex., address and phone number), and what you purchased. If you buy something for someone else on " branding/app-name ", you'd also provide us with their shipping details and contact information.") + (str "You also may give us permission to access your information in other services. For example, you may link your Facebook or Twitter account to " branding/app-name ", which allows us to obtain information from those accounts (like your friends or contacts). The information we get from those services often depends on your settings or their privacy policies, so be sure to check what those are.")]} + {:title "We also get technical information when you use our products" + :font-size 28 + :paragraphs + ["These days, whenever you use a website, mobile application, or other internet service, there's certain information that almost always gets created and recorded automatically. The same is true when you use our products. Here are some of the types of information we collect:" + (str "Log data. When you use " branding/app-name ", our servers may automatically record information (\"log data\"), including information that your browser sends whenever you visit a website or your mobile app sends when you're using it. This log data may include your Internet Protocol address, the address of the web pages you visited that had " branding/app-name " features, browser type and settings, the date and time of your request, how you used " branding/app-name ", and cookie data.") + (str "Cookie data. Depending on how you're accessing our products, we may use \"cookies\" (small text files sent by your computer each time you visit our website, unique to your " branding/app-name " account or your browser) or similar technologies to record log data. When we use cookies, we may use \"session\" cookies (that last until you close your browser) or \"persistent\" cookies (that last until you or your browser delete them). For example, we may use cookies to store your language preferences or other " branding/app-name " settings so you don't have to set them up every time you visit " branding/app-name ". Some of the cookies we use are associated with your " branding/app-name " account (including personal information about you, such as the email address you gave us), and other cookies are not.") + (str "Device information. In addition to log data, we may also collect information about the device you're using " branding/app-name " on, including what type of device it is, what operating system you're using, device settings, unique device identifiers, and crash data. Whether we collect some or all of this information often depends on what type of device you're using and its settings. For example, different types of information are available depending on whether you're using a Mac or a PC, or an iPhone or an Android phone. To learn more about what information your device makes available to us, please also check the policies of your device manufacturer or software provider.")]} + {:title "Our partners and advertisers may share information with us" + :font-size 28 + :paragraphs + [(str "We may get information about you and your activity off " branding/app-name " from our affiliates, advertisers, partners and other third parties we work with. For example:") + "Online advertisers typically share information with the websites or apps where they run ads to measure and/or improve those ads. We also receive this information, which may include information like whether clicks on ads led to purchases or a list of criteria to use in targeting ads."]}]} + {:title "How do we use the information we collect?" + :font-size 32 + :paragraphs + [(str "We use the information we collect to provide our products to you and make them better, develop new products, and protect " branding/app-name " and our users. For example, we may log how often people use two different versions of a product, which can help us understand which version is better. If you make a purchase on " branding/app-name ", we'll save your payment information and contact information so that you can use them the next time you want to buy something on " branding/app-name ".") + "We also use the information we collect to offer you customized content, including:" + "Showing you ads you might be interested in." + "We also use the information we collect to:" + (str "Send you updates (such as when certain activity, like shares or comments, happens on " branding/app-name "), newsletters, marketing materials and other information that may be of interest to you. For example, depending on your email notification settings, we may send you weekly updates that include content you may like. You can decide to stop getting these updates by updating your account settings (or through other settings we may provide).") + (str "Help your friends and contacts find you on " branding/app-name ". For example, if you sign up using a Facebook account, we may help your Facebook friends find your account on " branding/app-name " when they first sign up for " branding/app-name ". Or, we may allow people to search for your account on " branding/app-name " using your email address.") + "Respond to your questions or comments."]} + {:title "Transferring your Information" + :font-size 32 + :paragraphs + [(str branding/app-name " is headquartered in the United States. By using our products or services, you authorize us to transfer and store your information inside the United States, for the purposes described in this policy. The privacy protections and the rights of authorities to access your personal information in such countries may not be equivalent to those in your home country.")]} + {:title "How and when do we share information" + :font-size 32 + :paragraphs + [(str "Anyone can see the public role-playing game characters and other content you create, and the profile information you give us. We may also make this public information available through what are called \"APIs\" (basically a technical way to share information quickly). For example, a partner might use a " branding/app-name " API to integrate with other applications our users may be interested in. The other limited instances where we may share your personal information include:") + (str "When we have your consent. This includes sharing information with other services (like Facebook or Twitter) when you've chosen to link to your " branding/app-name " account to those services or publish your activity on " branding/app-name " to them. For example, you can choose to share your characters on Facebook or Twitter.") + (str "When you buy something on " branding/app-name " using your credit card, we may share your credit card information, contact information, and other information about the transaction with the merchant you're buying from. The merchants treat this information just as if you had made a purchase from their website directly, which means their privacy policies and marketing policies apply to the information we share with them.") + (str "Online advertisers typically use third party companies to audit the delivery and performance of their ads on websites and apps. We also allow these companies to collect this information on " branding/app-name ". To learn more, please see our Help Center.") + "We may employ third party companies or individuals to process personal information on our behalf based on our instructions and in compliance with this Privacy Policy. For example, we share credit card information with the payment companies we use to store your payment information. Or, we may share data with a security consultant to help us get better at identifying spam. In addition, some of the information we request may be collected by third party providers on our behalf." + (str "If we believe that disclosure is reasonably necessary to comply with a law, regulation or legal request; to protect the safety, rights, or property of the public, any person, or " branding/app-name "; or to detect, prevent, or otherwise address fraud, security or technical issues. We may share the information described in this Policy with our wholly-owned subsidiaries and affiliates. We may engage in a merger, acquisition, bankruptcy, dissolution, reorganization, or similar transaction or proceeding that involves the transfer of the information described in this Policy. We may also share aggregated or non-personally identifiable information with our partners, advertisers or others.")]} + {:title "What choices do you have about your information?" + :font-size 32 + :paragraphs + (if (not (s/blank? (environ/env :email-access-key))) + ["You may close your account at any time by emailing " (environ/env :email-access-key) (str "We will then inactivate your account and remove your content from " branding/app-name ". We may retain archived copies of you information as required by law or for legitimate business purposes (including to help address fraud and spam). ")] + [(str "You may remove any content you create from " branding/app-name " at any time, although we may retain archived copies of the information. You may also disable sharing of content you create at any time, whether publicly shared or privately shared with specific users.") + "Also, we support the Do Not Track browser setting."])} + {:title "Our policy on children's information" + :font-size 32 + :paragraphs + [(str branding/app-name " is not directed to children under 13. If you learn that your minor child has provided us with personal information without your consent, please contact us.")]} + {:title "How do we make changes to this policy?" + :font-size 32 + :paragraphs + [(str "We may change this policy from time to time, and if we do we'll post any changes on this page. If you continue to use " branding/app-name " after those changes are in effect, you agree to the revised policy. If the changes are significant, we may provide more prominent notice or get your consent as required by law.")]} + (when (not (s/blank? (environ/env :email-access-key))) + {:title "How can you contact us?" + :font-size 32 + :paragraphs + ["You can contact us by emailing " (environ/env :email-access-key) ]})]}) diff --git a/src/clj/orcpub/fork/user_data.clj b/src/clj/orcpub/fork/user_data.clj new file mode 100644 index 000000000..542660725 --- /dev/null +++ b/src/clj/orcpub/fork/user_data.clj @@ -0,0 +1,30 @@ +(ns orcpub.fork.user-data + "User data enrichment hooks. Pass-through by default. + Fork overrides: replace this file to add custom fields to the + user API response and registration defaults. + + Routes.clj calls these hooks at two points: + 1. user-body — enrich-response can add fields to the API response + 2. registration — registration-defaults can set initial values + + Datomic queries pull [*], so all attributes are available on the + user entity passed to enrich-response.") + +;; ─── Response Enrichment ─────────────────────────────────────── +;; No-op by default. Forks override to map additional Datomic +;; attributes into the API response sent to the client. + +(defn enrich-response + "Add fork-specific fields to the user API response map. + `data` is the base response, `user` is the full Datomic entity." + [data _user] + data) + +;; ─── Registration Defaults ───────────────────────────────────── +;; No-op by default. Forks override to set initial attribute values +;; for newly registered users. + +(defn registration-defaults + "Default Datomic attributes for newly registered users." + [] + {}) diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index 9df5fb5e2..be80b49ba 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -1,12 +1,13 @@ (ns orcpub.index (:require [hiccup.page :refer [html5 include-css]] + [cheshire.core :as cheshire] [orcpub.oauth :as oauth] + [orcpub.fork.branding :as branding] [orcpub.dnd.e5.views-2 :as views-2] [orcpub.favicon :as fi] + [orcpub.fork.integrations :as integrations] [environ.core :refer [env]])) -(def devmode? (env :dev-mode)) - (def homebrew-url "URL to fetch server-hosted .orcbrew plugins from on first load. Set LOAD_HOMEBREW_URL to enable (e.g. \"/homebrew.orcbrew\" or a full URL). @@ -21,10 +22,10 @@ (defn script-tag "Generate a script tag with optional nonce for CSP strict mode. - For external scripts, pass :src. For inline scripts, pass content as body." - [{:keys [src nonce]} & body] - (let [attrs (cond-> {} - src (assoc :src src) + For external scripts, pass :src. For inline scripts, pass content as body. + Extra attributes (e.g. :async, :crossorigin) are passed through to the tag." + [{:keys [nonce] :as opts} & body] + (let [attrs (cond-> (dissoc opts :nonce) nonce (assoc :nonce nonce))] (if (seq body) (into [:script attrs] body) @@ -45,6 +46,13 @@ (meta-tag "og:title" title) (meta-tag "og:description" description) (meta-tag "og:image" image) + (meta-tag "og:site_name" branding/app-name) + (meta-tag "og:type" "website") + (meta-tag "twitter:card" "summary_large_image") + (meta-tag "twitter:site" branding/app-name) + (meta-tag "twitter:title" title) + (meta-tag "twitter:description" description) + (meta-tag "twitter:image" image) [:meta {:charset "UTF-8"}] [:meta {:name "viewport" :content "width=device-width, initial-scale=1.0, minimum-scale=1.0"}] @@ -62,7 +70,7 @@ .splash-button .splash-button-content {height: 120px; width: 120px} .splash-button .svg-icon {height: 64px; width: 64px} -@media (max-width: 767px) +@media (max-width: 767px) {.splash-button .svg-icon {height: 32px; width: 32px} .splash-button-title-prefix {display: none} .splash-button .splash-button-content {height: 60px; width: 60px; font-size: 10px} @@ -87,7 +95,7 @@ b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, -article, aside, canvas, details, figcaption, figure, +article, aside, canvas, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary, time, mark, audio, video { margin: 0; @@ -99,7 +107,7 @@ time, mark, audio, video { vertical-align: baseline; } /* HTML5 display-role reset for older browsers */ -article, aside, details, figcaption, figure, +article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } @@ -132,7 +140,11 @@ table { html { min-height: 100%; }"] - [:title title]] + [:title title] + (integrations/head-tags nonce) + (script-tag {:nonce nonce} + (str "window.__BRANDING__=" (cheshire/generate-string (branding/client-config)) ";" + "window.__INTEGRATIONS__=" (cheshire/generate-string (integrations/client-config)) ";"))] [:body {:style "margin:0;line-height:1"} [:div#app (if splash? diff --git a/src/clj/orcpub/pdf.clj b/src/clj/orcpub/pdf.clj index 0f413856e..eab84b466 100644 --- a/src/clj/orcpub/pdf.clj +++ b/src/clj/orcpub/pdf.clj @@ -25,7 +25,8 @@ [orcpub.common :as common] [orcpub.dnd.e5.display :as dis5e] [orcpub.dnd.e5.monsters :as monsters] - [orcpub.dnd.e5.options :as options]) + [orcpub.dnd.e5.options :as options] + [clj-http.client :as client]) (:import (org.apache.pdfbox.pdmodel.interactive.form PDCheckBox PDTextField) (org.apache.pdfbox.pdmodel PDPage PDDocument PDPageContentStream PDResources) ;; PDFBox 3.x: AppendMode enum replaces boolean flags in PDPageContentStream constructor @@ -262,11 +263,11 @@ fitting-lines (vec (take max-lines lines))] (.beginText cs) (.setFont cs font font-size) - (.moveTextPositionByAmount cs units-x units-y) + (.newLineAtOffset cs units-x units-y) (doseq [i (range (count fitting-lines))] (let [line (get fitting-lines i)] - (.moveTextPositionByAmount cs 0 (- leading)) - (.drawString cs line))) + (.newLineAtOffset cs 0 (- leading)) + (.showText cs line))) (.endText cs) (vec (drop max-lines lines)))) @@ -274,8 +275,10 @@ (let [lines (split-lines text font font-size width)] (draw-lines-to-box cs lines font font-size x y height))) -(defn set-text-color [cs r g b] - (.setNonStrokingColor cs r g b)) +(defn set-text-color + "Set text (non-stroking) color. Values must be 0.0-1.0 floats (PDFBox 3.x)." + [cs r g b] + (.setNonStrokingColor cs (float r) (float g) (float b))) (defn draw-text [cs text font font-size x y & [color]] (when text @@ -285,8 +288,8 @@ (.setFont cs font font-size) (when color (apply set-text-color cs color)) - (.moveTextPositionByAmount cs units-x units-y) - (.drawString cs (if (keyword? text) (common/safe-name text) text)) + (.newLineAtOffset cs units-x units-y) + (.showText cs (if (keyword? text) (common/safe-name text) text)) (when color (set-text-color cs 0 0 0)) (.endText cs)))) @@ -294,8 +297,12 @@ (defn draw-text-from-top [cs text font font-size x y & [color]] (draw-text cs text font font-size x (- 11.0 y) color)) -(defn draw-line [cs start-x start-y end-x end-y] - (.drawLine cs start-x start-y end-x end-y)) +(defn draw-line + "Draw a line. PDFBox 3.x removed drawLine — use moveTo/lineTo/stroke." + [cs start-x start-y end-x end-y] + (.moveTo cs (float start-x) (float start-y)) + (.lineTo cs (float end-x) (float end-y)) + (.stroke cs)) (defn inches-to-units [inches] (float (* inches 72))) @@ -303,7 +310,9 @@ (defn draw-line-in [cs & coords] (apply draw-line cs (map inches-to-units coords))) -(defn draw-grid [cs box-width box-height] +(defn draw-grid + "Draw the spell card grid. Light gray lines for card boundaries." + [cs box-width box-height] (let [num-boxes-x (int (/ 8.5 box-width)) num-boxes-y (int (/ 11.0 box-height)) total-width (* num-boxes-x box-width) @@ -311,12 +320,15 @@ remaining-width (- 8.5 total-width) margin-x (/ remaining-width 2) remaining-height (- 11.0 total-height) - margin-y (/ remaining-height 2)] - (.setStrokingColor cs 225 225 225) + margin-y (/ remaining-height 2) + ;; PDFBox 3.x: setStrokingColor(float,float,float) requires 0.0-1.0 range + ;; (PDFBox 2.x accepted 0-255 integers via a separate overload) + light-gray (float (/ 225.0 255.0))] + (.setStrokingColor cs light-gray light-gray light-gray) (doseq [i (range (inc num-boxes-x))] (let [x (+ margin-x (* box-width i))] (draw-line-in cs - x + x margin-y x (+ margin-y total-height)))) @@ -327,7 +339,7 @@ y (+ margin-x total-width) y))) - (.setStrokingColor cs 0 0 0))) + (.setStrokingColor cs (float 0) (float 0) (float 0)))) (defn spell-school-level [{:keys [level school]} class-nm] (let [school-str (if school (s/capitalize school) "Unknown")] @@ -343,14 +355,14 @@ (- 11 y 0.12) 0.25 0.25)) - (.setNonStrokingColor cs 0 0 0) + (.setNonStrokingColor cs (float 0) (float 0) (float 0)) (draw-text cs value HELVETICA_BOLD_OBLIQUE 8 x (- y 0.07)) - (.setNonStrokingColor cs 0 0 0)) + (.setNonStrokingColor cs (float 0) (float 0) (float 0))) (defn abbreviate-times [time] (-> time diff --git a/src/clj/orcpub/pedestal.clj b/src/clj/orcpub/pedestal.clj index 3ca9276c3..ad0104f37 100644 --- a/src/clj/orcpub/pedestal.clj +++ b/src/clj/orcpub/pedestal.clj @@ -2,12 +2,14 @@ (:require [com.stuartsierra.component :as component] [io.pedestal.http :as http] [io.pedestal.interceptor :as interceptor] + [io.pedestal.log :as log] [pandect.algo.sha1 :refer [sha1]] [datomic.api :as d] [clojure.string :as s] [java-time.api :as t] [orcpub.csp :as csp] - [orcpub.config :as config]) + [orcpub.config :as config] + [orcpub.fork.integrations :as integrations]) (:import [java.io File] [java.time.format DateTimeFormatter])) @@ -66,7 +68,10 @@ :leave (fn [ctx] (if-let [nonce (get-in ctx [:request :csp-nonce])] (assoc-in ctx [:response :headers "Content-Security-Policy"] - (csp/build-csp-header nonce :dev-mode? false)) + (csp/build-csp-header nonce + :dev-mode? false + :extra-connect-src (:connect-src integrations/csp-domains) + :extra-frame-src (:frame-src integrations/csp-domains))) ctx))})) ;; Create the nonce interceptor with current dev-mode? setting @@ -95,8 +100,7 @@ (if new-etag (assoc-in context [:response :headers "etag"] new-etag) context))) - (catch Throwable t (prn "T" t ))))})) - + (catch Throwable t (log/error :msg "ETag interceptor error" :exception t))))})) (defrecord Pedestal [service-map conn service] component/Lifecycle diff --git a/src/clj/orcpub/privacy.clj b/src/clj/orcpub/privacy.clj index d4170e75d..c8560df2e 100644 --- a/src/clj/orcpub/privacy.clj +++ b/src/clj/orcpub/privacy.clj @@ -1,6 +1,9 @@ (ns orcpub.privacy (:require [hiccup.page :as page] [clojure.string :as s] + [orcpub.fork.branding :as branding] + [orcpub.fork.integrations :as integrations] + [orcpub.fork.privacy-content :as content] [environ.core :as environ])) (defn section [{:keys [title font-size paragraphs subsections]}] @@ -16,89 +19,25 @@ subsections)]) (def privacy-policy-section - {:title "Privacy Policy" - :font-size 48 - :subsections - [{:title "Thank you for using OrcPub!" - :font-size 32 - :paragraphs - ["We wrote this policy to help you understand what information we collect, how we use it, and what choices you have. Because we're an internet company, some of the concepts below are a little technical, but we've tried our best to explain things in a simple and clear way. We welcome your questions and comments on this policy."]} - {:title "How We Collect Your Information" - :font-size 32 - :subsections - [{:title "When you give it to us or give us permission to obtain it" - :font-size 28 - :paragraphs - ["When you sign up for or use our products, you voluntarily give us certain information. This can include your name, profile photo, role-playing game characters, comments, likes, the email address or phone number you used to sign up, and any other information you provide us. If you're using OrcPub on your mobile device, you can also choose to provide us with location data. And if you choose to buy something on OrcPub, you provide us with payment information, contact information (ex., address and phone number), and what you purchased. If you buy something for someone else on OrcPub, you'd also provide us with their shipping details and contact information." - "You also may give us permission to access your information in other services. For example, you may link your Facebook or Twitter account to OrcPub, which allows us to obtain information from those accounts (like your friends or contacts). The information we get from those services often depends on your settings or their privacy policies, so be sure to check what those are."]} - {:title "We also get technical information when you use our products" - :font-size 28 - :paragraphs - ["These days, whenever you use a website, mobile application, or other internet service, there's certain information that almost always gets created and recorded automatically. The same is true when you use our products. Here are some of the types of information we collect:" - "Log data. When you use OrcPub, our servers may automatically record information (\"log data\"), including information that your browser sends whenever you visit a website or your mobile app sends when you're using it. This log data may include your Internet Protocol address, the address of the web pages you visited that had OrcPub features, browser type and settings, the date and time of your request, how you used OrcPub, and cookie data." - "Cookie data. Depending on how you're accessing our products, we may use \"cookies\" (small text files sent by your computer each time you visit our website, unique to your OrcPub account or your browser) or similar technologies to record log data. When we use cookies, we may use \"session\" cookies (that last until you close your browser) or \"persistent\" cookies (that last until you or your browser delete them). For example, we may use cookies to store your language preferences or other OrcPub settings so you don‘t have to set them up every time you visit OrcPub. Some of the cookies we use are associated with your OrcPub account (including personal information about you, such as the email address you gave us), and other cookies are not." - "Device information. In addition to log data, we may also collect information about the device you're using OrcPub on, including what type of device it is, what operating system you're using, device settings, unique device identifiers, and crash data. Whether we collect some or all of this information often depends on what type of device you're using and its settings. For example, different types of information are available depending on whether you're using a Mac or a PC, or an iPhone or an Android phone. To learn more about what information your device makes available to us, please also check the policies of your device manufacturer or software provider."]} - {:title "Our partners and advertisers may share information with us" - :font-size 28 - :paragraphs - ["We may get information about you and your activity off OrcPub from our affiliates, advertisers, partners and other third parties we work with. For example:" - "Online advertisers typically share information with the websites or apps where they run ads to measure and/or improve those ads. We also receive this information, which may include information like whether clicks on ads led to purchases or a list of criteria to use in targeting ads."]}]} - {:title "How do we use the information we collect?" - :font-size 32 - :paragraphs - ["We use the information we collect to provide our products to you and make them better, develop new products, and protect OrcPub and our users. For example, we may log how often people use two different versions of a product, which can help us understand which version is better. If you make a purchase on OrcPub, we'll save your payment information and contact information so that you can use them the next time you want to buy something on OrcPub." - "We also use the information we collect to offer you customized content, including:" - "Showing you ads you might be interested in." - "We also use the information we collect to:" - "Send you updates (such as when certain activity, like shares or comments, happens on OrcPub), newsletters, marketing materials and other information that may be of interest to you. For example, depending on your email notification settings, we may send you weekly updates that include content you may like. You can decide to stop getting these updates by updating your account settings (or through other settings we may provide)." - "Help your friends and contacts find you on OrcPub. For example, if you sign up using a Facebook account, we may help your Facebook friends find your account on OrcPub when they first sign up for OrcPub. Or, we may allow people to search for your account on OrcPub using your email address." - "Respond to your questions or comments."]} - {:title "Transferring your Information" - :font-size 32 - :paragraphs - ["OrcPub is headquartered in the United States. By using our products or services, you authorize us to transfer and store your information inside the United States, for the purposes described in this policy. The privacy protections and the rights of authorities to access your personal information in such countries may not be equivalent to those in your home country."]} - {:title "How and when do we share information" - :font-size 32 - :paragraphs - ["Anyone can see the public role-playing game characters and other content you create, and the profile information you give us. We may also make this public information available through what are called \"APIs\" (basically a technical way to share information quickly). For example, a partner might use a OrcPub API to integrate with other applications our users may be interested in. The other limited instances where we may share your personal information include:" - "When we have your consent. This includes sharing information with other services (like Facebook or Twitter) when you've chosen to link to your OrcPub account to those services or publish your activity on OrcPub to them. For example, you can choose to share your characters on Facebook or Twitter." - "When you buy something on OrcPub using your credit card, we may share your credit card information, contact information, and other information about the transaction with the merchant you're buying from. The merchants treat this information just as if you had made a purchase from their website directly, which means their privacy policies and marketing policies apply to the information we share with them." - "Online advertisers typically use third party companies to audit the delivery and performance of their ads on websites and apps. We also allow these companies to collect this information on OrcPub. To learn more, please see our Help Center." - "We may employ third party companies or individuals to process personal information on our behalf based on our instructions and in compliance with this Privacy Policy. For example, we share credit card information with the payment companies we use to store your payment information. Or, we may share data with a security consultant to help us get better at identifying spam. In addition, some of the information we request may be collected by third party providers on our behalf." - "If we believe that disclosure is reasonably necessary to comply with a law, regulation or legal request; to protect the safety, rights, or property of the public, any person, or OrcPub; or to detect, prevent, or otherwise address fraud, security or technical issues. We may share the information described in this Policy with our wholly-owned subsidiaries and affiliates. We may engage in a merger, acquisition, bankruptcy, dissolution, reorganization, or similar transaction or proceeding that involves the transfer of the information described in this Policy. We may also share aggregated or non-personally identifiable information with our partners, advertisers or others."]} - {:title "What choices do you have about your information?" - :font-size 32 - :paragraphs - (if (not (s/blank? (environ/env :email-access-key))) - ["You may close your account at any time by emailing " (environ/env :email-access-key) "We will then inactivate your account and remove your content from OrcPub. We may retain archived copies of you information as required by law or for legitimate business purposes (including to help address fraud and spam). "] - ["You may remove any content you create from OrcPub at any time, although we may retain archived copies of the information. You may also disable sharing of content you create at any time, whether publicly shared or privately shared with specific users." - "Also, we support the Do Not Track browser setting."])} - {:title "Our policy on children's information" - :font-size 32 - :paragraphs - ["OrcPub is not directed to children under 13. If you learn that your minor child has provided us with personal information without your consent, please contact us."]} - {:title "How do we make changes to this policy?" - :font-size 32 - :paragraphs - ["We may change this policy from time to time, and if we do we'll post any changes on this page. If you continue to use OrcPub after those changes are in effect, you agree to the revised policy. If the changes are significant, we may provide more prominent notice or get your consent as required by law."]} - (when (not (s/blank? (environ/env :email-access-key))) - {:title "How can you contact us?" - :font-size 32 - :paragraphs - ["You can contact us by emailing " (environ/env :email-access-key) ]})]}) + "Privacy policy content — fork-specific. See fork/privacy_content.clj." + content/privacy-policy-section) (defn terms-page [sections] (page/html5 [:head [:link {:rel :stylesheet :href "/css/style.css" :type "text/css"}] - [:link {:rel :stylesheet :href "/css/compiled/styles.css" :type "text/css"}]] + [:link {:rel :stylesheet :href "/css/compiled/styles.css" :type "text/css"}] + + ;; Third-party integration tags (analytics, ads) — same as index.clj. + ;; Empty on public repo, populated on DMV via integrations.clj. + (integrations/head-tags nil)] [:body.sans [:div [:div.app-header-bar.container {:style "background-color:#2c3445"} [:div.content [:div.flex.justify-cont-s-b.align-items-c.w-100-p.p-l-20.p-r-20 - [:img.h-60 {:src "/image/dmv-logo.svg"}]]]] + [:a {:href "/" } [:img.h-72.pointer {:src branding/logo-path}]]]]] [:div.container [:div.content [:div.f-s-24 @@ -111,17 +50,17 @@ {:title "Terms of Service" :font-size 48 :subsections - [{:title "Thank you for using OrcPub!" + [{:title (str "Thank you for using " branding/app-name "!") :font-size 32 :paragraphs - [[:div "These Terms of Service (\"Terms\") govern your access to and use of OrcPub's website, products, and services (\"Products\"). Please read these Terms carefully, and contact us if you have any questions. By accessing or using our Products, you agree to be bound by these Terms and by our " [:a {:href "/privacy-policy" :target :_blank} "Privacy Policy"] ". You also confirm you have read and agreed to our " [:a {:href "/community-guidelines" :target :_blank} "Community guidelines"] " and our " [:a {:href "/cookies-policy"} "Cookies policy"] "."]]} - {:title "1. Using OrcPub" + [[:div (str "These Terms of Service (\"Terms\") govern your access to and use of " branding/app-name "'s website, products, and services (\"Products\"). Please read these Terms carefully, and contact us if you have any questions. By accessing or using our Products, you agree to be bound by these Terms and by our ") [:a {:href "/privacy-policy" :target :_blank} "Privacy Policy"] ". You also confirm you have read and agreed to our " [:a {:href "/community-guidelines" :target :_blank} "Community guidelines"] " and our " [:a {:href "/cookies-policy"} "Cookies policy"] "."]]} + {:title (str "1. Using " branding/app-name) :font-size 32 :subsections - [{:title "a. Who can use OrcPub" + [{:title (str "a. Who can use " branding/app-name) :font-size 28 :paragraphs - ["You may use our Products only if you can form a binding contract with OrcPub, and only in compliance with these Terms and all applicable laws. When you create your OrcPub account, you must provide us with accurate and complete information. Any use or access by anyone under the age of 13 is prohibited. If you open an account on behalf of a company, organization, or other entity, then (a) \"you\" includes you and that entity, and (b) you represent and warrant that you are authorized to grant all permissions and licenses provided in these Terms and bind the entity to these Terms, and that you agree to these Terms on the entity's behalf. Some of our Products may be software that is downloaded to your computer, phone, tablet, or other device. You agree that we may automatically upgrade those Products, and these Terms will apply to such upgrades."]} + [(str "You may use our Products only if you can form a binding contract with " branding/app-name ", and only in compliance with these Terms and all applicable laws. When you create your " branding/app-name " account, you must provide us with accurate and complete information. Any use or access by anyone under the age of 13 is prohibited. If you open an account on behalf of a company, organization, or other entity, then (a) \"you\" includes you and that entity, and (b) you represent and warrant that you are authorized to grant all permissions and licenses provided in these Terms and bind the entity to these Terms, and that you agree to these Terms on the entity's behalf. Some of our Products may be software that is downloaded to your computer, phone, tablet, or other device. You agree that we may automatically upgrade those Products, and these Terms will apply to such upgrades.")]} {:title "b. Our license to you" :font-size 28 :paragraphs @@ -132,83 +71,82 @@ [{:title "a. Posting Content" :font-size 28 :paragraphs - ["OrcPub allows you to post content, including photos, comments, links, and other materials. Anything that you post or otherwise make available on our Products is referred to as \"User Content.\" You retain all rights in, and are solely responsible for, the User Content you post to OrcPub."]} - {:title "b. How OrcPub and other users can use your content" + [(str branding/app-name " allows you to post content, including photos, comments, links, and other materials. Anything that you post or otherwise make available on our Products is referred to as \"User Content.\" You retain all rights in, and are solely responsible for, the User Content you post to " branding/app-name ".")]} + {:title (str "b. How " branding/app-name " and other users can use your content") :font-size 28 :paragraphs - ["You grant OrcPub and our users a non-exclusive, royalty-free, transferable, sublicensable, worldwide license to use, store, display, reproduce, save, modify, create derivative works, perform, and distribute your User Content on OrcPub solely for the purposes of operating, developing, providing, and using the OrcPub Products. Nothing in these Terms shall restrict other legal rights OrcPub may have to User Content, for example under other licenses. We reserve the right to remove or modify User Content for any reason, including User Content that we believe violates these Terms or our policies."]} + [(str "You grant " branding/app-name " and our users a non-exclusive, royalty-free, transferable, sublicensable, worldwide license to use, store, display, reproduce, save, modify, create derivative works, perform, and distribute your User Content on " branding/app-name " solely for the purposes of operating, developing, providing, and using the " branding/app-name " Products. Nothing in these Terms shall restrict other legal rights " branding/app-name " may have to User Content, for example under other licenses. We reserve the right to remove or modify User Content for any reason, including User Content that we believe violates these Terms or our policies.")]} {:title "c. How long we keep your content" :font-size 28 :paragraphs - ["Following termination or deactivation of your account, or if you remove any User Content from OrcPub, we may retain your User Content for a commercially reasonable period of time for backup, archival, or audit purposes. Furthermore, OrcPub and its users may retain and continue to use, store, display, reproduce, modify, create derivative works, perform, and distribute any of your User Content that other users have stored or shared through OrcPub."]} + [(str "Following termination or deactivation of your account, or if you remove any User Content from " branding/app-name ", we may retain your User Content for a commercially reasonable period of time for backup, archival, or audit purposes. Furthermore, " branding/app-name " and its users may retain and continue to use, store, display, reproduce, modify, create derivative works, perform, and distribute any of your User Content that other users have stored or shared through " branding/app-name ".")]} {:title "d. Feedback you provide" :font-size 28 :paragraphs - ["We value hearing from our users, and are always interested in learning about ways we can make OrcPub more awesome. If you choose to submit comments, ideas or feedback, you agree that we are free to use them without any restriction or compensation to you. By accepting your submission, OrcPub does not waive any rights to use similar or related Feedback previously known to OrcPub, or developed by its employees, or obtained from sources other than you"]}]} - {:title "3. Copyright policy" + [(str "We value hearing from our users, and are always interested in learning about ways we can make " branding/app-name " more awesome. If you choose to submit comments, ideas or feedback, you agree that we are free to use them without any restriction or compensation to you. By accepting your submission, " branding/app-name " does not waive any rights to use similar or related Feedback previously known to " branding/app-name ", or developed by its employees, or obtained from sources other than you")]}]} + {:title "3. Security" :font-size 32 :paragraphs - ["OrcPub has adopted and implemented the OrcPub Copyright policy in accordance with the Digital Millennium Copyright Act and other applicable copyright laws. For more information, please read our Copyright policy."]} - {:title "4. Security" + [(str "We care about the security of our users. While we work to protect the security of your content and account, " branding/app-name " cannot guarantee that unauthorized third parties will not be able to defeat our security measures. We ask that you keep your password secure. Please notify us immediately of any compromise or unauthorized use of your account.")]} + {:title "4. Third-party links, sites, and services" :font-size 32 :paragraphs - ["We care about the security of our users. While we work to protect the security of your content and account, OrcPub cannot guarantee that unauthorized third parties will not be able to defeat our security measures. We ask that you keep your password secure. Please notify us immediately of any compromise or unauthorized use of your account."]} - {:title "5. Third-party links, sites, and services" + [(str "Our Products may contain links to third-party websites, advertisers, services, special offers, or other events or activities that are not owned or controlled by " branding/app-name ". We do not endorse or assume any responsibility for any such third-party sites, information, materials, products, or services. If you access any third party website, service, or content from " branding/app-name ", you do so at your own risk and you agree that " branding/app-name " will have no liability arising from your use of or access to any third-party website, service, or content.")]} + {:title "5. Termination" :font-size 32 :paragraphs - ["Our Products may contain links to third-party websites, advertisers, services, special offers, or other events or activities that are not owned or controlled by OrcPub. We do not endorse or assume any responsibility for any such third-party sites, information, materials, products, or services. If you access any third party website, service, or content from OrcPub, you do so at your own risk and you agree that OrcPub will have no liability arising from your use of or access to any third-party website, service, or content."]} - {:title "6. Termination" + [(str branding/app-name " may terminate or suspend this license at any time, with or without cause or notice to you. Upon termination, you continue to be bound by Sections 2 and 6-11 of these Terms.")]} + {:title "6. Indemnity" :font-size 32 :paragraphs - ["OrcPub may terminate or suspend this license at any time, with or without cause or notice to you. Upon termination, you continue to be bound by Sections 2 and 6-12 of these Terms."]} - {:title "7. Indemnity" + [(str "If you use our Products for commercial purposes without agreeing to our Business Terms as required by Section 1, as determined in our sole and absolute discretion, you agree to indemnify and hold harmless " branding/app-name " and its respective officers, directors, employees and agents, from and against any claims, suits, proceedings, disputes, demands, liabilities, damages, losses, costs and expenses, including, without limitation, reasonable legal and accounting fees (including costs of defense of claims, suits or proceedings brought by third parties), in any way related to (a) your access to or use of our Products, (b) your User Content, or (c) your breach of any of these Terms.")]} + {:title "7. Disclaimers" :font-size 32 :paragraphs - ["If you use our Products for commercial purposes without agreeing to our Business Terms as required by Section 1(c), as determined in our sole and absolute discretion, you agree to indemnify and hold harmless OrcPub and its respective officers, directors, employees and agents, from and against any claims, suits, proceedings, disputes, demands, liabilities, damages, losses, costs and expenses, including, without limitation, reasonable legal and accounting fees (including costs of defense of claims, suits or proceedings brought by third parties), in any way related to (a) your access to or use of our Products, (b) your User Content, or (c) your breach of any of these Terms."]} - {:title "8. Disclaimers" + ["The Products and all included content are provided on an \"as is\" basis without warranty of any kind, whether express or implied." + (str branding/app-name " SPECIFICALLY DISCLAIMS ANY AND ALL WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR USAGE OF TRADE.") + (str branding/app-name " takes no responsibility and assumes no liability for any User Content that you or any other user or third party posts or transmits using our Products. You understand and agree that you may be exposed to User Content that is inaccurate, objectionable, inappropriate for children, or otherwise unsuited to your purpose.")]} + {:title "8. Limitation of liability" :font-size 32 :paragraphs - ["The Products and all included content are provided on an \"as is\" basis without warranty of any kind, whether express or implied." - "ORCPUB SPECIFICALLY DISCLAIMS ANY AND ALL WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR USAGE OF TRADE." - "OrcPub takes no responsibility and assumes no liability for any User Content that you or any other user or third party posts or transmits using our Products. You understand and agree that you may be exposed to User Content that is inaccurate, objectionable, inappropriate for children, or otherwise unsuited to your purpose."]} - {:title "9. Limitation of liability" + [(str "TO THE MAXIMUM EXTENT PERMITTED BY LAW, " branding/app-name " SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOOD-WILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM (A) YOUR ACCESS TO OR USE OF OR INABILITY TO ACCESS OR USE THE PRODUCTS; (B) ANY CONDUCT OR CONTENT OF ANY THIRD PARTY ON THE PRODUCTS, INCLUDING WITHOUT LIMITATION, ANY DEFAMATORY, OFFENSIVE OR ILLEGAL CONDUCT OF OTHER USERS OR THIRD PARTIES; OR (C) UNAUTHORIZED ACCESS, USE OR ALTERATION OF YOUR TRANSMISSIONS OR CONTENT. IN NO EVENT SHALL " branding/app-name "'s AGGREGATE LIABILITY FOR ALL CLAIMS RELATING TO THE PRODUCTS EXCEED ONE HUNDRED U.S. DOLLARS (U.S. $100.00).")]} + {:title "9. Arbitration" :font-size 32 :paragraphs - ["TO THE MAXIMUM EXTENT PERMITTED BY LAW, ORCPUB SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOOD-WILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM (A) YOUR ACCESS TO OR USE OF OR INABILITY TO ACCESS OR USE THE PRODUCTS; (B) ANY CONDUCT OR CONTENT OF ANY THIRD PARTY ON THE PRODUCTS, INCLUDING WITHOUT LIMITATION, ANY DEFAMATORY, OFFENSIVE OR ILLEGAL CONDUCT OF OTHER USERS OR THIRD PARTIES; OR (C) UNAUTHORIZED ACCESS, USE OR ALTERATION OF YOUR TRANSMISSIONS OR CONTENT. IN NO EVENT SHALL ORCPUB'S AGGREGATE LIABILITY FOR ALL CLAIMS RELATING TO THE PRODUCTS EXCEED ONE HUNDRED U.S. DOLLARS (U.S. $100.00)."]} - :title "10. Arbitration" - :font-size 32 - :paragraphs - ["For any dispute you have with OrcPub, you agree to first contact us and attempt to resolve the dispute with us informally. If OrcPub has not been able to resolve the dispute with you informally, we each agree to resolve any claim, dispute, or controversy (excluding claims for injunctive or other equitable relief) arising out of or in connection with or relating to these Terms by binding arbitration by the American Arbitration Association (\"AAA\") under the Commercial Arbitration Rules and Supplementary Procedures for Consumer Related Disputes then in effect for the AAA, except as provided herein. Unless you and OrcPub agree otherwise, the arbitration will be conducted in the county where you reside. Each party will be responsible for paying any AAA filing, administrative and arbitrator fees in accordance with AAA rules, except that OrcPub will pay for your reasonable filing, administrative, and arbitrator fees if your claim for damages does not exceed $75,000 and is non-frivolous (as measured by the standards set forth in Federal Rule of Civil Procedure 11(b)). The award rendered by the arbitrator shall include costs of arbitration, reasonable attorneys' fees and reasonable costs for expert and other witnesses, and any judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction. Nothing in this Section shall prevent either party from seeking injunctive or other equitable relief from the courts for matters related to data security, intellectual property or unauthorized access to the Service. ALL CLAIMS MUST BE BROUGHT IN THE PARTIES' INDIVIDUAL CAPACITY, AND NOT AS A PLAINTIFF OR CLASS MEMBER IN ANY PURPORTED CLASS OR REPRESENTATIVE PROCEEDING, AND, UNLESS WE AGREE OTHERWISE, THE ARBITRATOR MAY NOT CONSOLIDATE MORE THAN ONE PERSON'S CLAIMS. YOU AGREE THAT, BY ENTERING INTO THESE TERMS, YOU AND ORCPUB ARE EACH WAIVING THE RIGHT TO A TRIAL BY JURY OR TO PARTICIPATE IN A CLASS ACTION." - "To the extent any claim, dispute or controversy regarding OrcPub or our Products isn't arbitrable under applicable laws or otherwise: you and OrcPub both agree that any claim or dispute regarding OrcPub will be resolved exclusively in accordance with Clause 11 of these Terms."] - {:title "11. Governing law and jurisdiction" + [(str "For any dispute you have with " branding/app-name ", you agree to first contact us and attempt to resolve the dispute with us informally. If " branding/app-name " has not been able to resolve the dispute with you informally, we each agree to resolve any claim, dispute, or controversy (excluding claims for injunctive or other equitable relief) arising out of or in connection with or relating to these Terms by binding arbitration by the American Arbitration Association (\"AAA\") under the Commercial Arbitration Rules and Supplementary Procedures for Consumer Related Disputes then in effect for the AAA, except as provided herein. Unless you and " branding/app-name " agree otherwise, the arbitration will be conducted in Tulsa County, Oklahoma or the United States District Court for the District of Oklahoma with in the United states. Each party will be responsible for paying any AAA filing, administrative and arbitrator fees in accordance with AAA rules, except that " branding/app-name " will pay for your reasonable filing, administrative, and arbitrator fees if your claim for damages does not exceed $75,000 and is non-frivolous (as measured by the standards set forth in Federal Rule of Civil Procedure 11(b)). The award rendered by the arbitrator shall include costs of arbitration, reasonable attorneys' fees and reasonable costs for expert and other witnesses, and any judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction. Nothing in this Section shall prevent either party from seeking injunctive or other equitable relief from the courts for matters related to data security, intellectual property or unauthorized access to the Service. ALL CLAIMS MUST BE BROUGHT IN THE PARTIES' INDIVIDUAL CAPACITY, AND NOT AS A PLAINTIFF OR CLASS MEMBER IN ANY PURPORTED CLASS OR REPRESENTATIVE PROCEEDING, AND, UNLESS WE AGREE OTHERWISE, THE ARBITRATOR MAY NOT CONSOLIDATE MORE THAN ONE PERSON'S CLAIMS. YOU AGREE THAT, BY ENTERING INTO THESE TERMS, YOU AND " branding/app-name " ARE EACH WAIVING THE RIGHT TO A TRIAL BY JURY OR TO PARTICIPATE IN A CLASS ACTION.") + (str "To the extent any claim, dispute or controversy regarding " branding/app-name " or our Products isn't arbitrable under applicable laws or otherwise: you and " branding/app-name " both agree that any claim or dispute regarding " branding/app-name " will be resolved exclusively in accordance with Clause 10 of these Terms.")]} + {:title "10. Governing law and jurisdiction" :font-size 32 :paragraphs - ["These Terms shall be governed by the laws of the State of Utah, without respect to its conflict of laws principles. We each agree to submit to the personal jurisdiction of a state court located in Salt Lake County, Utah or the United States District Court for the District of Utah, for any actions not subject to Section 10 (Arbitration)."]} - {:title "12. General terms" + ["These Terms shall be governed by the laws of the State of Oklahoma, without respect to its conflict of laws principles. We each agree to submit to the personal jurisdiction of a state court located in Tulsa County, Oklahoma or the United States District Court for the District of Oklahoma, for any actions not subject to Section 9 (Arbitration)."]} + {:title "11. General terms" :font-size 32 :subsections [{:title "Notification procedures and changes to these Terms" :font-size 28 :paragraphs - ["OrcPub reserves the right to determine the form and means of providing notifications to you, and you agree to receive legal notices electronically if we so choose. We may revise these Terms from time to time and the most current version will always be posted on our website. If a revision, in our sole discretion, is material we will notify you. By continuing to access or use the Products after revisions become effective, you agree to be bound by the revised Terms. If you do not agree to the new terms, please stop using the Products."]} + [(str branding/app-name " reserves the right to determine the form and means of providing notifications to you, and you agree to receive legal notices electronically if we so choose. We may revise these Terms from time to time and the most current version will always be posted on our website. If a revision, in our sole discretion, is material we will notify you. By continuing to access or use the Products after revisions become effective, you agree to be bound by the revised Terms. If you do not agree to the new terms, please stop using the Products.")]} {:title "Assignment" :font-size 28 :paragraphs - ["These Terms, and any rights and licenses granted hereunder, may not be transferred or assigned by you, but may be assigned by OrcPub without restriction. Any attempted transfer or assignment in violation hereof shall be null and void. -"]} + [(str "These Terms, and any rights and licenses granted hereunder, may not be transferred or assigned by you, but may be assigned by " branding/app-name " without restriction. Any attempted transfer or assignment in violation hereof shall be null and void.")]} {:title "Entire agreement/severability" :font-size 28 :paragraphs - ["These Terms, together with the Privacy policy and any amendments and any additional agreements you may enter into with OrcPub in connection with the Products, shall constitute the entire agreement between you and OrcPub concerning the Products. If any provision of these Terms is deemed invalid, then that provision will be limited or eliminated to the minimum extent necessary, and the remaining provisions of these Terms will remain in full force and effect."]} + [(str "These Terms, together with the Privacy policy and any amendments and any additional agreements you may enter into with " branding/app-name " in connection with the Products, shall constitute the entire agreement between you and " branding/app-name " concerning the Products. If any provision of these Terms is deemed invalid, then that provision will be limited or eliminated to the minimum extent necessary, and the remaining provisions of these Terms will remain in full force and effect.")]} {:title "No waiver" :font-size 28 :paragraphs - ["No waiver of any term of these Terms shall be deemed a further or continuing waiver of such term or any other term, and OrcPub's failure to assert any right or provision under these Terms shall not constitute a waiver of such right or provision."]} + [(str "No waiver of any term of these Terms shall be deemed a further or continuing waiver of such term or any other term, and " branding/app-name "'s failure to assert any right or provision under these Terms shall not constitute a waiver of such right or provision.")]} + {:title "Terms of reuse" + :font-size 28 + :paragraphs + ["Code and portions of this Derivative Works are used under Eclipse Public License 2.0 https://github.com/Orcpub/orcpub/blob/develop/LICENSE"]} {:title "Parties" :font-size 28 :paragraphs - ["These Terms are a contract between you and OrcPub" - "Effective May 1, 2017"]}]}]}) + [(str "These Terms are a contract between you and " branding/app-name) + "Effective Dec 28th, 2021"]}]}]}) (defn terms-of-use [] (terms-page terms-section)) @@ -220,8 +158,8 @@ [{:title "Our Mission" :font-size 32 :paragraphs - ["At OrcPub, our mission is to help you discover and do what you love. That means showing you ideas that are relevant, interesting and personal to you, and making sure you don't see anything that's inappropriate or spammy." - "These are guidelines for what we do and don't allow on OrcPub. If you come across content that seems to break these rules, you can report it to us."]} + [(str "At " branding/app-name ", our mission is to help you discover and do what you love. That means showing you ideas that are relevant, interesting and personal to you, and making sure you don't see anything that's inappropriate or spammy.") + (str "These are guidelines for what we do and don't allow on " branding/app-name ". If you come across content that seems to break these rules, you can report it to us.")]} {:title "Safety" :font-size 32 :paragraphs @@ -237,20 +175,20 @@ {:title "Intellectual property and other rights" :font-size 32 :paragraphs - ["To respect the rights of people on and off OrcPub, please:" + [(str "To respect the rights of people on and off " branding/app-name ", please:") "Don't infringe anyone's intellectual property, privacy or other rights." "Don't do anything or post any content that violates laws or regulations." - "Don't use OrcPub's name, logo or trademark in a way that confuses people (check out our brand guidelines for more details)."]} + (str "Don't use " branding/app-name "'s name, logo or trademark in a way that confuses people.")]} {:title "Site security and access" :font-size 32 :paragraphs - ["To keep OrcPub secure, we ask that you please:" + [(str "To keep " branding/app-name " secure, we ask that you please:") "Don't access, use or tamper with our systems or our technical providers' systems." "Don't break or circumvent our security measures or test the vulnerability of our systems or networks." - "Don't use any undocumented or unsupported method to access, search, scrape, download or change any part of OrcPub." + (str "Don't use any undocumented or unsupported method to access, search, scrape, download or change any part of " branding/app-name ".") "Don't try to reverse engineer our software." - "Don't try to interfere with people on OrcPub or our hosts or networks, like sending a virus, overloading, spamming or mail-bombing." - "Don't collect or store personally identifiable information from OrcPub or people on OrcPub without permission." + (str "Don't try to interfere with people on " branding/app-name " or our hosts or networks, like sending a virus, overloading, spamming or mail-bombing.") + (str "Don't collect or store personally identifiable information from " branding/app-name " or people on " branding/app-name " without permission.") "Don't share your password, let anyone access your account or do anything that might put your account at risk." "Don't sell access to your account, boards, or username, or otherwise transfer account features for compensation."]} {:title "Spam" @@ -261,7 +199,8 @@ "Attempts to artificially boost views and other metrics." "Repetitive or unwanted posts." "Off-domain redirects, cloaking or other ways of obscuring where content leads." - "Misleading content."]}]}) + "Misleading content." + "Effective Nov 4th, 2020"]}]}) (defn community-guidelines [] (terms-page community-guidelines-section)) @@ -270,18 +209,18 @@ {:title "Cookies" :font-size 48 :subsections - [{:title "Cookies on OrcPub" + [{:title (str "Cookies on " branding/app-name) :font-size 32 :paragraphs - ["Our privacy policy describes how we collect and use information, and what choices you have. One way we collect information is through the use of a technology called \"cookies.\" We use cookies for all kinds of things on OrcPub."]} + [(str "Our privacy policy describes how we collect and use information, and what choices you have. One way we collect information is through the use of a technology called \"cookies.\" We use cookies for all kinds of things on " branding/app-name ".")]} {:title "What's a cookie?" :font-size 32 :paragraphs - ["When you go online, you use a program called a \"browser\" (like Apple's Safari or Google's Chrome). Most websites store a small amount of text in the browser—and that text is called a \"cookie.\""]} + ["When you go online, you use a program called a \"browser\" (like Apple's Safari or Google's Chrome). Most websites store a small amount of text in the browser and that text is called a \"cookie.\""]} {:title "How we use cookies" :font-size 32 :paragraphs - ["We use cookies for lots of essential things on OrcPub—like helping you log in and tailoring your OrcPub experience. Here are some specifics on how we use cookies."]} + [(str "We use cookies for lots of essential things on " branding/app-name " like helping you log in and tailoring your " branding/app-name " experience. Here are some specifics on how we use cookies.")]} {:title "What we use cookies for" :font-size 32 :subsections @@ -297,30 +236,30 @@ {:title "Login" :font-size 32 :paragraphs - ["Cookies let you log in and out of OrcPub."]} + [(str "Cookies let you log in and out of " branding/app-name ".")]} {:title "Security" :font-size 32 :paragraphs - ["Cookies are just one way we protect you from security risks. For example, we use them to detect when someone might be trying to hack your OrcPub account or spam the OrcPub community."]} + [(str "Cookies are just one way we protect you from security risks. For example, we use them to detect when someone might be trying to hack your " branding/app-name " account or spam the " branding/app-name " community.")]} {:title "Analytics" :font-size 32 :paragraphs - ["We use cookies to make OrcPub better. For example, these cookies tell us how many people use a certain feature and how popular it is, or whether people open an email we send." + [(str "We use cookies to make " branding/app-name " better. For example, these cookies tell us how many people use a certain feature and how popular it is, or whether people open an email we send.") "We also use cookies to help advertisers understand who sees and interacts with their ads, and who visits their website or purchases their products."]} {:title "Service providers" :font-size 32 :paragraphs - ["Sometimes we hire security vendors or use third-party analytics providers to help us understand how people are using OrcPub. Just like we do, these providers may use cookies. Learn more about the third party providers we use."]}]} + [(str "Sometimes we hire security vendors or use third-party analytics providers to help us understand how people are using " branding/app-name ". Just like we do, these providers may use cookies. Learn more about the third party providers we use.")]}]} {:title "Where we use cookies" :font-size 32 :paragraphs - ["We use cookies on orcpub.com, in our mobile applications, and in our products and services (like ads, emails and applications). We also use them on the websites of partners who use OrcPub's Save button, OrcPub widgets, or ad tools like conversion tracking."]} + [(str "We use cookies on " branding/app-name ", in our mobile applications, and in our products and services (like ads, emails and applications). We also use them on the websites of partners who use " branding/app-name "'s Save button, " branding/app-name " widgets, or ad tools like conversion tracking.")]} {:title "Your options" :font-size 32 :paragraphs ["Your browser probably gives you cookie choices. For example, most browsers let you block \"third party cookies,\" which are cookies from sites other than the one you're visiting. Those options vary from browser to browser, so check your browser settings for more info." - "Some browsers also have a privacy setting called \"Do Not Track,\" which we support. This setting is another way for you to decide whether we use info from our partners and other services to customize OrcPub for you." - "Effective November 1, 2016"]}]}) + (str "Some browsers also have a privacy setting called \"Do Not Track,\" which we support. This setting is another way for you to decide whether we use info from our partners and other services to customize " branding/app-name " for you.") + "Effective Nov 4th, 2020"]}]}) (defn cookie-policy [] (terms-page cookie-policy-section)) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index c93671c24..3e104a68c 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -39,9 +39,11 @@ [orcpub.entity.strict :as se] [orcpub.entity :as entity] [orcpub.security :as security] + [orcpub.fork.branding :as branding] + [orcpub.fork.auth :as auth] + [orcpub.fork.user-data :as user-data] [orcpub.routes.party :as party] [orcpub.routes.folder :as folder] - [orcpub.oauth :as oauth] [hiccup.page :as page] [environ.core :as environ] [clojure.set :as sets] @@ -50,7 +52,7 @@ [ring.util.request :as req]) ;; PDFBox 3.x: Use Loader class instead of PDDocument.load() static method ;; OLD (2.x): (PDDocument/load input-stream) - ;; NEW (3.x): (Loader/loadPDF input-stream) + ;; NEW (3.x): (Loader/loadPDF byte-array) — does NOT accept InputStream ;; ;; Import syntax notes for Clojure newcomers: ;; - (org.apache.pdfbox.pdmodel PDDocument PDPage) imports multiple classes from one package @@ -234,10 +236,16 @@ (map :orcpub.user/username (d/pull-many db '[:orcpub.user/username] ids))) -(defn user-body [db user] - (cond-> {:username (:orcpub.user/username user) - :email (:orcpub.user/email user) - :following (following-usernames db (map :db/id (:orcpub.user/following user)))} +(defn user-body + "Build the user API response. Core fields are inline; fork-specific + fields (e.g. tier data) are added by user-data/enrich-response." + [db user] + (cond-> (user-data/enrich-response + {:username (:orcpub.user/username user) + :email (:orcpub.user/email user) + :send-updates? (boolean (:orcpub.user/send-updates? user)) + :following (following-usernames db (map :db/id (:orcpub.user/following user)))} + user) (:orcpub.user/pending-email user) (assoc :pending-email (:orcpub.user/pending-email user)))) @@ -250,16 +258,20 @@ errors/bad-credentials errors/no-account))))) -(defn create-login-response [db user & [headers]] +(defn create-login-response [db conn user id & [headers]] (let [token (create-token (:orcpub.user/username user) - (-> 24 hours from-now))] + (-> auth/token-lifetime-hours hours from-now)) + now (java.util.Date.)] + (when auth/track-last-login? + (d/transact conn [{:db/id id + :orcpub.user/last-login now}])) {:status 200 :headers headers :body {:user-data (user-body db user) :token token}})) (defn login-response - [{:keys [json-params db remote-addr] :as request}] + [{:keys [json-params db conn remote-addr] :as request}] (let [{raw-username :username raw-password :password} json-params] (cond (s/blank? raw-username) (login-error errors/username-required) @@ -276,7 +288,8 @@ (nil? id) (bad-credentials-response db username remote-addr) (and unverified? expired?) (login-error errors/unverified-expired) unverified? (login-error errors/unverified {:email email}) - :else (create-login-response db user)))))) + :else + (create-login-response db conn user id)))))) (defn login [{:keys [json-params db] :as request}] (try @@ -337,7 +350,8 @@ validation (registration/validate-registration json-params (seq (d/q email-query db email)) - (seq (d/q username-query db username)))] + (seq (d/q username-query db username))) + now (java.util.Date.)] (try (if (seq validation) {:status 400 @@ -346,11 +360,15 @@ request json-params conn - {:orcpub.user/email email - :orcpub.user/username username - :orcpub.user/password (hashers/encrypt password) - :orcpub.user/send-updates? send-updates? - :orcpub.user/created (java.util.Date.)})) + (merge + {:orcpub.user/email email + :orcpub.user/username username + :orcpub.user/password (hashers/encrypt password) + :orcpub.user/send-updates? send-updates? + :orcpub.user/created now} + (when auth/record-last-login-at-registration? + {:orcpub.user/last-login now}) + (user-data/registration-defaults)))) (catch Throwable e (prn e) (throw e))))) (def user-for-verification-key-query @@ -428,10 +446,56 @@ (redirect route-map/verify-success-route) (do-verification request (merge query-params - {:first-and-last-name "DMV Patron"}) + {:first-and-last-name auth/verification-display-name}) conn {:db/id id})))) +;; ─── Email Preferences ───────────────────────────────────────────── + +(defn unsubscribe-token + "Create a JWT-signed unsubscribe token for embedding in email links. + Stateless — no DB storage needed. Verified by checking JWT signature." + [email] + (jwt/sign {:email (s/lower-case email) :action "unsubscribe"} + (environ/env :signature))) + +(defn unsubscribe + "GET handler for /unsubscribe?token=<jwt>. + Verifies JWT signature, sets send-updates? to false, redirects to success page. + Idempotent — unsubscribing twice is harmless." + [{:keys [query-params db conn]}] + (let [token (:token query-params)] + (if (s/blank? token) + {:status 400 :body "Missing token"} + (try + (let [{:keys [email action]} (jwt/unsign token (environ/env :signature))] + (if (not= "unsubscribe" action) + {:status 400 :body "Invalid token"} + (let [{:keys [:db/id]} (user-for-email (d/db conn) email)] + (if id + (do @(d/transact conn [{:db/id id :orcpub.user/send-updates? false}]) + (redirect route-map/unsubscribe-success-route)) + {:status 400 :body "Unknown email"})))) + (catch Exception _ + {:status 400 :body "Invalid or tampered token"}))))) + +(defn update-user-preferences + "PUT handler for /user — update user preferences (currently send-updates?). + Requires authentication. Only updates fields present in transit-params. + Re-reads from DB after transact to return authoritative state." + [{:keys [transit-params db conn identity]}] + (let [username (:user identity) + {:keys [:db/id]} (find-user-by-username db username)] + (if id + (do (when (contains? transit-params :send-updates?) + @(d/transact conn [{:db/id id + :orcpub.user/send-updates? (boolean (:send-updates? transit-params))}])) + ;; Re-read from DB after transact for authoritative response + (let [updated-user (d/entity (d/db conn) id)] + {:status 200 + :body {:send-updates? (boolean (:orcpub.user/send-updates? updated-user))}})) + {:status 400 :body {:error "User not found"}}))) + (defn do-send-password-reset [user-id email conn request] (let [key (str (java.util.UUID/randomUUID))] (try @@ -442,7 +506,7 @@ :orcpub.user/password-reset-sent (java.util.Date.)}]) (email/send-reset-email (base-url request) - {:first-and-last-name "DMV Patron" + {:first-and-last-name auth/verification-display-name :email email} key) {:status 200} @@ -587,10 +651,14 @@ output (ByteArrayOutputStream.) user-agent (get-in req [:headers "user-agent"]) chrome? (re-matches #".*Chrome.*" user-agent) - filename (str player-name " - " character-name " - " class-level ".pdf")] + filename (cond + (and (s/blank? player-name) (s/blank? character-name)) "character.pdf" + (s/blank? player-name) (str character-name " - " class-level ".pdf") + :else (str player-name " - " character-name " - " class-level ".pdf"))] - ;; PDFBox 3.x: Loader/loadPDF replaces the deprecated PDDocument/load - (with-open [doc (Loader/loadPDF input)] + ;; PDFBox 3.x: Loader/loadPDF accepts byte[], File, or RandomAccessRead — + ;; NOT InputStream. Read the resource stream into a byte array first. + (with-open [doc (Loader/loadPDF (.readAllBytes input))] (pdf/write-fields! doc fields (not chrome?) font-sizes) (when (and print-spell-cards? (seq spells-known)) (add-spell-cards! doc spells-known spell-save-dcs spell-attack-mods custom-spells print-spell-card-dc-mod?)) @@ -631,14 +699,14 @@ :in $ ?key :where [?e :orcpub.user/password-reset-key ?key]]) -(def default-title - "Dungeon Master's Vault: D&D 5e Character Builder/Generator") +(def default-title branding/default-page-title) -(def default-description - "Dungeons & Dragons 5th Edition (D&D 5e) character builder/generator and digital character sheet far beyond any other in the multiverse.") +(def default-description branding/app-tagline) -(defn default-image-url [host] - (str "http://" host "/image/dmv-box-logo.png")) +(defn default-image-url + "OG meta image URL. Uses https:// for social sharing compatibility." + [host] + (str "https://" host branding/og-image-filename)) (defn index-page-response [{:keys [headers uri csp-nonce] :as request} {:keys [title description image-url]} @@ -886,7 +954,8 @@ (let [data (ex-data e)] (case (:error data) :character-problems {:status 400 :body (:problems data)} - :not-user-character {:status 401 :body "You do not own this character"}))) + :not-user-character {:status 401 :body "You do not own this character"} + (throw e)))) ; re-throw unrecognised ExceptionInfo (e.g. :db/error from Datomic) (catch Exception e (prn "ERROR" e) (throw e))))) (defn save-character [{:keys [db transit-params body conn identity] :as request}] @@ -1265,6 +1334,7 @@ [route-map/password-reset-used-route] [route-map/verify-failed-route] [route-map/verify-success-route] + [route-map/unsubscribe-success-route] [route-map/dnd-e5-orcacle-page-route]]) (defn character-page [{:keys [db conn identity headers scheme uri] {:keys [id]} :path-params :as request}] @@ -1359,6 +1429,7 @@ {:post `register}] [(route-map/path-for route-map/user-route) ^:interceptors [check-auth] {:get `get-user + :put `update-user-preferences :delete `delete-user}] [(route-map/path-for route-map/user-email-route) ^:interceptors [check-auth] {:put `request-email-change}] @@ -1418,6 +1489,8 @@ {:get `verify}] [(route-map/path-for route-map/re-verify-route) {:get `re-verify}] + [(route-map/path-for route-map/unsubscribe-route) + {:get `unsubscribe}] [(route-map/path-for route-map/reset-password-route) ^:interceptors [ring/cookies check-auth] {:post `reset-password}] [(route-map/path-for route-map/reset-password-page-route) ^:interceptors [ring/cookies] diff --git a/src/clj/orcpub/security.clj b/src/clj/orcpub/security.clj index 35b8966cc..7d79bf692 100644 --- a/src/clj/orcpub/security.clj +++ b/src/clj/orcpub/security.clj @@ -87,6 +87,6 @@ (>= 3))) (defn multiple-ip-attempts-to-same-account? [username] - (multiple-ip-attempts-to-same-account-aux + multiple-ip-attempts-to-same-account-aux username - @failed-login-attempts-by-username)) + @failed-login-attempts-by-username) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 5ffbf7cbc..d868a8068 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -302,13 +302,19 @@ {:height "72px"}] [:.h-120 {:height "120px"}] + [:.h-170 + {:height "170px"}] [:.h-200 {:height "200px"}] [:.h-800 {:height "800px"}] + [:.h-10-p + {:height "10%"}] [:.h-100-p {:height "100%"}] + [:.h-auto + {:height "auto"}] [:.overflow-auto {:overflow :auto}] @@ -330,9 +336,11 @@ {:color "#191919"}] [:.orange {:color button-color} - [:a :a:visited {:color button-color}]] + [:.a-white + [:a :a:visited + {:color "white !important"}]] [:.green {:color green} @@ -423,6 +431,8 @@ {:border-radius "50%"}] [:.b-rad-5 {:border-radius "5px"}] + [:.b-rad-10 + {:border-radius "10px"}] [:.b-1 {:border "1px solid"}] @@ -470,6 +480,11 @@ :position "absolute" :z-index "1"}]] + [:.image-thumbnail + {:max-height "100px" + :max-width "200px" + :border-radius "5px"}] + [:.tooltip:hover [:.tooltiptext {:visibility "visible"}]] @@ -477,7 +492,7 @@ {:max-height "100px" :max-width "200px" :border-radius "5px"}] - + [:.image-faction-thumbnail {:max-height "100px" :max-width "200px" @@ -950,14 +965,14 @@ [:.app-header {:background-color :black :background-image "url(/../../image/header-background.jpg)" - :background-position "right center" + :background-position "center" :background-size "cover" :height (px const/header-height)}] [:.header-tab {:background-color "rgba(0, 0, 0, 0.5)" - :-webkit-backdrop-filter "blur(3px)" - :backdrop-filter "blur(3px)" + :-webkit-backdrop-filter "blur(5px)" + :backdrop-filter "blur(5px)" :border-radius "5px"}] [:.header-tab.mobile @@ -1326,7 +1341,7 @@ [:.text-shadow {:text-shadow :none}] - + [:.bg-light {:background-color "rgba(0,0,0,0.4)"}] [:.bg-lighter diff --git a/src/clj/orcpub/system.clj b/src/clj/orcpub/system.clj index 5f6591a45..c221c047f 100644 --- a/src/clj/orcpub/system.clj +++ b/src/clj/orcpub/system.clj @@ -34,15 +34,14 @@ ::http/host "0.0.0.0" ;; Pedestal 0.7+ requires explicit interceptor coercion for maps/functions ::http/enable-session false ; Disable default session handling if not needed - ::http/port (let [port-str (System/getenv "PORT")] - (when port-str - (try - (Integer/parseInt port-str) - (catch NumberFormatException e - (throw (ex-info "Invalid PORT environment variable. Expected a number." - {:error :invalid-port - :port port-str} - e)))))) + ::http/port (let [port-str (or (System/getenv "PORT") "8890")] + (try + (Integer/parseInt port-str) + (catch NumberFormatException e + (throw (ex-info "Invalid PORT environment variable. Expected a number." + {:error :invalid-port + :port port-str} + e))))) ::http/join false ::http/resource-path "/public" ;; CSP configured via CSP_POLICY env var (strict|permissive|none) diff --git a/src/cljc/orcpub/constants.cljc b/src/cljc/orcpub/constants.cljc index 32ac59919..951602341 100644 --- a/src/cljc/orcpub/constants.cljc +++ b/src/cljc/orcpub/constants.cljc @@ -1,3 +1,3 @@ (ns orcpub.constants) -(def header-height 227) +(def header-height 320) diff --git a/src/cljc/orcpub/dnd/e5/classes.cljc b/src/cljc/orcpub/dnd/e5/classes.cljc index 814712152..8e4656529 100644 --- a/src/cljc/orcpub/dnd/e5/classes.cljc +++ b/src/cljc/orcpub/dnd/e5/classes.cljc @@ -152,7 +152,7 @@ :traits [{:name "Frenzy" :level 3 :page 49 - :summary "You can frenzy when you rage, affording you a single melee weapon attack as a bonus action on each turn until the rage ends. When the rage ends, you suffer 1 level of exhaustrion"} + :summary "You can frenzy when you rage, affording you a single melee weapon attack as a bonus action on each turn until the rage ends. When the rage ends, you suffer 1 level of exhaustion"} {:name "Mindless Rage" :level 6 :page 49 diff --git a/src/cljc/orcpub/dnd/e5/template.cljc b/src/cljc/orcpub/dnd/e5/template.cljc index d5f9cc617..550d1085f 100644 --- a/src/cljc/orcpub/dnd/e5/template.cljc +++ b/src/cljc/orcpub/dnd/e5/template.cljc @@ -1325,7 +1325,7 @@ content] frame]) -(def srd-url "/SRD-OGL_V5.1.pdf") +(def srd-url "/dnld/SRD-OGL_V5.1.pdf") (def srd-link [:a.orange {:href srd-url :target "_blank"} "the 5e SRD"]) diff --git a/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc b/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc index 43a97d93d..8f41e36ed 100644 --- a/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc +++ b/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc @@ -1,5 +1,5 @@ (ns orcpub.dnd.e5.templates.ua-base - #_(:require [orcpub.template :as t] + (:require [orcpub.template :as t] [orcpub.common :as common] [orcpub.modifiers :as mods] [orcpub.entity-spec :as es] @@ -23,7 +23,7 @@ [orcpub.dnd.e5.templates.ua-skill-feats :as ua-skill-feats] [orcpub.dnd.e5.templates.ua-revised-class-options :as ua-revised-class-options] [orcpub.dnd.e5.templates.ua-warlock-and-wizard :as ua-warlock-and-wizard] - [re-frame.core :refer [subscribe]])) + #_[re-frame.core :refer [subscribe]])) #_(defn ua-help [name url] [:a {:href url :target :_blank} name]) diff --git a/src/cljc/orcpub/dnd/e5/views_2.cljc b/src/cljc/orcpub/dnd/e5/views_2.cljc index a59e6fdb3..5243f717a 100644 --- a/src/cljc/orcpub/dnd/e5/views_2.cljc +++ b/src/cljc/orcpub/dnd/e5/views_2.cljc @@ -1,6 +1,8 @@ (ns orcpub.dnd.e5.views-2 (:require [orcpub.route-map :as routes] - [clojure.string :as s])) + [clojure.string :as s] + [orcpub.fork.branding :as branding] + [orcpub.fork.splash :as splash])) (defn style [style] #?(:cljs style) @@ -15,13 +17,16 @@ [:img.svg-icon {:src (str "/image/" icon-name ".svg")}]) -(defn splash-page-button [title icon route & [handler]] +(defn splash-page-button + "Render a splash page button. If handler is provided, uses on-click; + otherwise resolves route (keyword = path-for, string = raw href)." + [title icon route & [handler]] [:a.splash-button (let [cfg {:style (style {:text-decoration :none :color "#f0a100"})}] (if handler (assoc cfg :on-click handler) - (assoc cfg :href (routes/path-for route)))) + (assoc cfg :href (if (string? route) route (routes/path-for route))))) [:div.splash-button-content {:style (style {:box-shadow "0 2px 6px 0 rgba(0, 0, 0, 0.5)" :margin "5px" @@ -37,15 +42,16 @@ [:div [:span.splash-button-title-prefix "D&D 5e "] [:span title]]]]]) -(defn legal-footer [] - [:div.m-l-15.m-b-10.m-t-10.t-a-l - [:span "© 2020 OrcPub"] - [:a.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"] - [:a.m-l-5 {:href "/privacy-policy" :target :_blank} "Privacy Policy"]]) - (def orange-style {:color :orange}) +(defn legal-footer [] + [:div.m-l-15.m-b-10.m-t-10.t-a-l + [:span (str "\u00a9 " branding/copyright-year " " branding/copyright-holder)] + (for [{:keys [label href]} splash/legal-footer-links] + ^{:key href} + [:a.m-l-5 {:href href :target :_blank} label])]) + (defn splash-page [] [:div.app.h-full {:style (style {:display :flex @@ -62,15 +68,10 @@ [:div {:style (style {:display :flex :justify-content :space-around})} - [:img.w-30-p - {:src "/image/dmv-logo.svg" }]] - [:div - {:style (style {:text-align :center - :text-shadow "1px 2px 1px black" - :font-weight :bold - :font-size "14px" - :height "48px"})} - "Community edition"] + [:img {:class splash/logo-width-class + :src branding/logo-path}] + (when splash/edition-label + [:div.f-s-18.opacity-5.m-t-10 splash/edition-label])] [:div {:style (style {:display :flex @@ -85,6 +86,16 @@ "Character Builder for Newbs" "baby-face" routes/dnd-e5-newb-char-builder-route) + (splash-page-button + "Homebrew Content" + "beer-stein" + routes/dnd-e5-my-content-route)] + [:div + {:style (style + {:display :flex + :flex-wrap :wrap + :justify-content :center + :margin-top "10px"})} (splash-page-button "Spells" "spell-book" @@ -100,11 +111,13 @@ (splash-page-button "Combat Tracker" "sword-clash" - routes/dnd-e5-combat-tracker-page-route) - (splash-page-button - "Homebrew Content" - "beer-stein" - routes/dnd-e5-my-content-route) + routes/dnd-e5-combat-tracker-page-route)] + [:div + {:style (style + {:display :flex + :flex-wrap :wrap + :justify-content :center + :margin-top "10px"})} (splash-page-button "Encounter Builder" "minions" @@ -132,9 +145,14 @@ (splash-page-button "Background Builder" "ages" - routes/dnd-e5-background-builder-page-route)]]] - [:div.legal-footer-parent - {:style (style {:font-size "12px" - :color :white - :padding "10px"})} - ]]) + routes/dnd-e5-background-builder-page-route)] + (when (seq splash/generator-buttons) + [:div + {:style (style + {:display :flex + :flex-wrap :wrap + :justify-content :center + :margin-top "10px"})} + (for [{:keys [title icon route]} splash/generator-buttons] + ^{:key route} + (splash-page-button title icon route))])]]]) diff --git a/src/cljc/orcpub/fork/splash.cljc b/src/cljc/orcpub/fork/splash.cljc new file mode 100644 index 000000000..c4caca412 --- /dev/null +++ b/src/cljc/orcpub/fork/splash.cljc @@ -0,0 +1,30 @@ +(ns orcpub.fork.splash + "Fork-specific splash page and navigation configuration. + Public/community edition: minimal splash, no generators.") + +;; ─── Splash Page ──────────────────────────────────────────────────── + +(def logo-width-class + "CSS class controlling splash page logo width." + "w-30-p") + +(def edition-label + "Text shown below logo on splash page. nil = hidden." + "Community edition") + +;; ─── Legal Footer ─────────────────────────────────────────────────── + +(def legal-footer-links + "Links rendered in the legal footer (splash + content pages)." + [{:label "Terms of Use" :href "/terms-of-use"} + {:label "Privacy Policy" :href "/privacy-policy"}]) + +;; ─── Generators ───────────────────────────────────────────────────── + +(def generator-buttons + "Splash page generator buttons. Empty = section hidden." + []) + +(def header-generator-entries + "Header nav generator dropdown entries. Empty = tab hidden." + []) diff --git a/src/cljc/orcpub/pdf_spec.cljc b/src/cljc/orcpub/pdf_spec.cljc index 61cf71186..0e27daed1 100644 --- a/src/cljc/orcpub/pdf_spec.cljc +++ b/src/cljc/orcpub/pdf_spec.cljc @@ -213,6 +213,42 @@ (apply dissoc treasure coin-keys) unequipped-items)))}))) +(defn treasure-fields [built-char] + (let [equipment (es/entity-val built-char :equipment) + armor (es/entity-val built-char :armor) + magic-armor (es/entity-val built-char :magic-armor) + magic-items (es/entity-val built-char :magic-items) + weapons (sort (es/entity-val built-char :weapons)) + magic-weapons (sort (es/entity-val built-char :magic-weapons)) + custom-equipment (into {} + (map + (juxt ::char-equip5e/name identity) + (char5e/custom-equipment built-char))) + custom-treasure (into {} + (map + (juxt ::char-equip5e/name identity) + (char5e/custom-treasure built-char))) + all-equipment (merge equipment custom-equipment custom-treasure magic-items armor magic-armor) + treasure (es/entity-val built-char :treasure) + treasure-map (into {} (map (fn [[kw {qty ::char-equip5e/quantity}]] [kw qty]) treasure)) + unequipped-items (filter + (fn [[kw {:keys [::char-equip5e/equipped? ::char-equip5e/quantity]}]] + (and (not equipped?) + (pos? quantity))) + (merge all-equipment weapons magic-weapons magic-items))] + (merge + (select-keys treasure-map coin-keys) + {:treasure (s/join + "\n" + (map + (fn [[kw {count ::char-equip5e/quantity}]] + (str (disp5e/equipment-name mi5e/all-equipment-map kw) " (" count ")")) + (merge + (apply dissoc treasure coin-keys) + unequipped-items)))}))) + + + (def level-max-spells {0 8 1 12 diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 4e7eade74..3d581752d 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -112,6 +112,8 @@ (def password-reset-success-route :password-reset-success) (def password-reset-expired-route :password-reset-expired) (def password-reset-used-route :password-reset-used) +(def unsubscribe-route :unsubscribe) +(def unsubscribe-success-route :unsubscribe-success) (def terms-of-use-route :terms-of-use) (def privacy-policy-route :privacy-policy) (def community-guidelines-route :community-guidelines) @@ -138,13 +140,15 @@ "password-reset-success" password-reset-success-route "password-reset-expired" password-reset-expired-route "password-reset-used" password-reset-used-route + "unsubscribe" unsubscribe-route + "unsubscribe-success" unsubscribe-success-route "terms-of-use" terms-of-use-route "privacy-policy" privacy-policy-route "community-guidelines" community-guidelines-route "cookies-policy" cookies-policy-route "following/users" {["/" :user] follow-user-route} - + "dnd/" {"5e/" {"characters" {"" dnd-e5-char-list-route ["/" :id] dnd-e5-char-route} diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 8a6144183..26db04d31 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -45,6 +45,8 @@ [clojure.core.match :refer [match]] [reagent.core :as r] + [orcpub.fork.branding :as branding] + [orcpub.fork.integrations :as integrations] [re-frame.core :refer [subscribe dispatch dispatch-sync]])) ;console-print (def print-disabled? true) @@ -123,20 +125,30 @@ (def update-value-field (memoize update-value-field-fn)) -(defn character-field [entity-values prop-name type & [cls-str handler input-type]] +(defn character-field-255 [entity-values prop-name type & [cls-str handler input-type]] [comps/input-field type (get entity-values prop-name) (update-value-field prop-name) {:type input-type - :class (str "input w-100-p " cls-str)}]) + :maxLength "255" + :class-name (str "input w-100-p " cls-str)}]) + +(defn character-field-50000 [entity-values prop-name type & [cls-str handler input-type]] + [comps/input-field + type + (get entity-values prop-name) + (update-value-field prop-name) + {:type input-type + :maxLength "50000" + :class-name (str "input w-100-p " cls-str)}]) (defn character-input [entity-values prop-name & [cls-str handler type]] - [character-field entity-values prop-name :input cls-str handler type]) + [character-field-255 entity-values prop-name :input cls-str handler type]) (defn character-textarea [entity-values prop-name & [cls-str]] - [character-field entity-values prop-name :textarea cls-str]) + [character-field-50000 entity-values prop-name :textarea cls-str]) (defn prereq-failures [option] (remove @@ -497,7 +509,7 @@ (when (and content selected?) content) (when explanation-text - [:div.i.f-s-12.f-w-n + [:div.i.f-s-12.f-w-n explanation-text])]]]))) (defn skill-help [name key ability icon description] @@ -858,7 +870,7 @@ {:class (when (and (not ability-disabled?) (zero? (ability-increases k 0))) "opacity-5")} - (ability-value (ability-increases k 0))] + (ability-value (ability-increases k 0))] [:div.f-s-16 [:i.fa.fa-minus-circle.orange {:class (when decrease-disabled? "opacity-5 cursor-disabled") @@ -1081,7 +1093,7 @@ {:value (when (abilities k) (total-abilities k)) :type :number - :on-change (fn [e] (let [total (total-abilities k) + :on-change (fn [e] (let [total (total-abilities k) value (.-value (.-target e)) diff (- total (abilities k)) @@ -1742,9 +1754,6 @@ remaining)]))) sorted-selections)))])])]])) -(def image-style - {:max-height "100px" - :max-width "200px"}) (defn set-random-name "Dispatch random name generation. Passes built-char so the handler can @@ -1835,7 +1844,7 @@ :on-error (image-error :failed-loading-image image-url) :on-load (when image-url-failed image-loaded)}]) [:div.flex-grow-1 - [:span.personality-label.f-s-18 "Image URL"] + [:span.personality-label.f-s-18 "Image URL (128k max image size for PDF)"] [character-input entity-values ::char5e/image-url nil set-image-url] (when image-url-failed [:div.red.m-t-5 "Image failed to load, please check the URL"])]] @@ -1849,7 +1858,7 @@ :on-load (when faction-image-url-failed faction-image-loaded)}]) [:div.flex-grow-1 - [:span.personality-label.f-s-18 "Faction Image URL"] + [:span.personality-label.f-s-18 "Faction Image URL (128k max image size for PDF)"] [character-input entity-values ::char5e/faction-image-url nil set-faction-image-url] (when faction-image-url-failed [:div.red.m-t-5 "Image failed to load, please check the URL"])]] @@ -1903,8 +1912,11 @@ :mobile [mobile-columns] [desktop-or-tablet-columns device-type]))) -(def patreon-link-props - {:href "https://www.patreon.com/user?u=5892323" :target "_blank"}) +(defn supporter-link-props + "Link props for the supporter/funding page. Returns nil when no URL configured." + [] + (when-let [url (not-empty (:patreon branding/social-links))] + {:href url :target "_blank"})) ;; ============================================================================ ;; Missing Content Warning @@ -2104,44 +2116,46 @@ (when (not character-changed?) (js/window.scrollTo 0,0)) ;//Force a scroll to top of page only if we are not editing. [views5e/content-page "Character Builder" - (remove - nil? - [(when character-id [views5e/share-link character-id]) - {:title "Random" - :icon "random" - :on-click (confirm-handler - character-changed? - {:confirm-button-text "GENERATE RANDOM CHARACTER" - :question "You have unsaved changes, are you sure you want to discard them and generate a random character?" - :pre set-loading - :event [:random-character character built-template locked-components]})} - {:title "New" - :icon "plus" - :on-click (confirm-handler - character-changed? - {:confirm-button-text "CREATE NEW CHARACTER" - :question "You have unsaved changes, are you sure you want to discard them and create a new character?" - :event [:reset-character]})} - {:title "Clone" - :icon "clone" - :on-click (confirm-handler - character-changed? - {:confirm-button-text "CREATE CLONE" - :question "You have unsaved changes, are you sure you want to discard them and clone this character? The new character will have the unsaved changes, the original will not." - :event [::char5e/clone-character]})} - {:title "Print" - :icon "print" - :on-click (views5e/make-print-handler (:db/id character) built-char)} - {:title (if (:db/id character) - "Update Existing Character" - "Save New Character") - :icon "save" - :style (when character-changed? unsaved-button-style) - :on-click #(save-character built-char)} - (when (:db/id character) - {:title "View" - :icon "eye" - :on-click (load-character-page (:db/id character))})]) + (into + (if character-id + (vec (integrations/share-links character-id @(subscribe [::char5e/character-name character-id]))) + []) + (remove nil? + [{:title "Random" + :icon "random" + :on-click (confirm-handler + character-changed? + {:confirm-button-text "GENERATE RANDOM CHARACTER" + :question "You have unsaved changes, are you sure you want to discard them and generate a random character?" + :pre set-loading + :event [:random-character character built-template locked-components]})} + {:title "New" + :icon "plus" + :on-click (confirm-handler + character-changed? + {:confirm-button-text "CREATE NEW CHARACTER" + :question "You have unsaved changes, are you sure you want to discard them and create a new character?" + :event [:reset-character]})} + {:title "Clone" + :icon "clone" + :on-click (confirm-handler + character-changed? + {:confirm-button-text "CREATE CLONE" + :question "You have unsaved changes, are you sure you want to discard them and clone this character? The new character will have the unsaved changes, the original will not." + :event [::char5e/clone-character]})} + {:title (if (:db/id character) + "Save" + "Save New Character") + :icon "save" + :style (when character-changed? unsaved-button-style) + :on-click #(save-character built-char)} + (when (:db/id character) + {:title "View" + :icon "eye" + :on-click (load-character-page (:db/id character))}) + {:title "Export" + :icon "download" + :on-click (views5e/make-print-handler (:db/id character) built-char)}])) [:div [:div.container [:div.content diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index 2d1ad8d4b..51f8d50ee 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -278,8 +278,10 @@ (spec/def ::email string?) (spec/def ::token string?) (spec/def ::theme string?) +(spec/def ::patron string?) ; patron +(spec/def ::patron-tier string?) ; patron-tier (spec/def ::user-data (spec/keys :req-un [::username ::email])) -(spec/def ::user (spec/keys :opt-un [::user-data ::token ::theme])) +(spec/def ::user (spec/keys :opt-un [::user-data ::token ::theme ::patron ::patron-tier])) (reg-local-store-cofx :local-store-user diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index a26bfab43..98bea8c97 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -77,6 +77,8 @@ [bidi.bidi :as bidi] [orcpub.route-map :as routes] [orcpub.errors :as errors] + [orcpub.fork.integrations :as integrations] + [orcpub.fork.branding :as branding] [clojure.set :as sets] [cljsjs.filesaverjs] [clojure.pprint :as pprint]) @@ -160,7 +162,7 @@ encounter->local-store-interceptor]) (def combat-interceptors [(path ::combat/tracker-item) - combat->local-store-interceptor]) + combat->local-store-interceptor]) (def background-interceptors [(path ::bg5e/builder-item) background->local-store-interceptor]) @@ -172,13 +174,13 @@ invocation->local-store-interceptor]) (def boon-interceptors [(path ::class5e/boon-builder-item) - boon->local-store-interceptor]) + boon->local-store-interceptor]) (def selection-interceptors [(path ::selections5e/builder-item) - selection->local-store-interceptor]) + selection->local-store-interceptor]) (def feat-interceptors [(path ::feats5e/builder-item) - feat->local-store-interceptor]) + feat->local-store-interceptor]) (def race-interceptors [(path ::race5e/builder-item) race->local-store-interceptor]) @@ -190,10 +192,10 @@ class->local-store-interceptor]) (def subclass-interceptors [(path ::class5e/subclass-builder-item) - subclass->local-store-interceptor]) + subclass->local-store-interceptor]) (def plugins-interceptors [(path :plugins) - plugins->local-store-interceptor]) + plugins->local-store-interceptor]) ;; -- Event Handlers -------------------------------------------------- @@ -286,13 +288,13 @@ (def selection-randomizers {:ability-scores (fn [s _] (fn [_] {::entity/key :standard-roll - ::entity/value (char5e/standard-ability-rolls)})) + ::entity/value (char5e/standard-ability-rolls)})) :hit-points (fn [{[_ class-kw] ::entity/path} built-char] (fn [_] (random-hit-points-option (char5e/levels built-char) class-kw)))}) #_ ;; unreferenced — random-character loop hardcodes 10 -(def max-iterations 100) + (def max-iterations 100) (defn keep-options [built-template entity option-paths] (reduce @@ -352,7 +354,7 @@ {:dispatch [:set-random-character character built-template locked-components]})) #_ ;; unreferenced — character path is constructed inline -(def dnd-5e-characters-path [:dnd :e5 :characters]) + (def dnd-5e-characters-path [:dnd :e5 :characters]) (reg-event-fx :character-save-success @@ -802,14 +804,14 @@ ::char5e/parties-map (common/map-by-id parties))}))) (reg-event-fx - ::party5e/make-empty-party - (fn [{:keys [db]} [_]] - {:dispatch [:set-loading true] - :http {:method :post - :headers (authorization-headers db) - :url (url-for-route routes/dnd-e5-char-parties-route) - :transit-params {::party5e/name "A New Party"} - :on-success [::party5e/make-empty-party-success]}})) + ::party5e/make-empty-party + (fn [{:keys [db]} [_]] + {:dispatch [:set-loading true] + :http {:method :post + :headers (authorization-headers db) + :url (url-for-route routes/dnd-e5-char-parties-route) + :transit-params {::party5e/name "A New Party"} + :on-success [::party5e/make-empty-party-success]}})) (reg-event-fx ::party5e/rename-party @@ -1174,6 +1176,28 @@ (fn [db _] (dissoc db :email-change-sent? :email-change-error))) +;; ─── Email Preferences ───────────────────────────────────────────── + +(reg-event-fx + :toggle-send-updates + (fn [{:keys [db]} [_ new-value]] + {:http {:method :put + :headers (authorization-headers db) + :url (backend-url (routes/path-for routes/user-route)) + :transit-params {:send-updates? (boolean new-value)} + :on-success [:toggle-send-updates-success new-value] + :on-failure [:toggle-send-updates-failure]}})) + +(reg-event-db + :toggle-send-updates-success + (fn [db [_ new-value]] + (assoc-in db [:user-data :user-data :send-updates?] (boolean new-value)))) + +(reg-event-db + :toggle-send-updates-failure + (fn [db _] + db)) + (reg-event-fx :unfollow-user (fn [{:keys [db]} [_ username]] @@ -1276,15 +1300,15 @@ nil))) #_ ;; never dispatched from UI -(reg-event-db - :toggle-public - character-interceptors - (fn [character _] - (update character - ::entity/values - update - ::char5e/share? - not))) + (reg-event-db + :toggle-public + character-interceptors + (fn [character _] + (update character + ::entity/values + update + ::char5e/share? + not))) (reg-event-db :set-faction-image-url @@ -1475,7 +1499,7 @@ character path (fn [skills] - (if selected? + (if selected? (vec (remove (fn [s] (= skill-key (::entity/key s))) skills)) (vec (conj skills {::entity/key skill-key})))))) @@ -1547,13 +1571,14 @@ (reg-event-fx :route (fn [{:keys [db]} [_ {:keys [handler route-params] :as new-route} {:keys [no-return? skip-path? event secure?] :as options}]] + (integrations/track-page-view! new-route) (let [{:keys [route route-history]} db seq-params (seq route-params) flat-params (flatten seq-params) path (apply routes/path-for (or handler new-route) flat-params)] (when (and js/window.location - secure? - (not= "localhost" js/window.location.hostname)) + secure? + (not= "localhost" js/window.location.hostname)) (set! js/window.location.href (make-url "https" js/window.location.hostname path @@ -1577,18 +1602,18 @@ (fn [db [_ user-data]] (update db :user-data dissoc :user-data :token))) -;; Replaces top-level @(subscribe [:user false]) in core.cljs — that was a -;; Replaces top-level @(subscribe [:user false]) in core.cljs — that was a -;; side-effect-only subscribe used to trigger an HTTP auth check on app startup. +;; Startup auth check — validates stored token on app load (core.cljs). +;; Clears stale sessions before reg-sub-raw subs fire HTTP with expired tokens. (reg-event-fx :verify-user-session (fn [{:keys [db]} _] - (if (and (:user db) (:token (:user db))) + (if (:token (:user-data db)) (do (go (let [response (<! (http/get (url-for-route routes/user-route) - {:headers (authorization-headers db)}))] + {:headers (authorization-headers db)}))] (case (:status response) 200 nil - 401 (dispatch [:clear-login]) + 401 (do (dispatch [:clear-login]) + (dispatch [:set-loading false])) nil))) {}) {}))) @@ -1599,16 +1624,23 @@ (assoc db :user user-data))) #_ ;; never dispatched from UI -(defn set-active-tabs [db [_ active-tabs]] - (assoc-in db tab-path active-tabs)) + (defn set-active-tabs [db [_ active-tabs]] + (assoc-in db tab-path active-tabs)) #_ ;; never dispatched from UI -(reg-event-db - :set-active-tabs - set-active-tabs) + (reg-event-db + :set-active-tabs + set-active-tabs) -(defn set-loading [db [_ v]] - (assoc db :loading v)) +(defn set-loading + "Loading is a counter, not a boolean. true increments, false decrements. + Overlay shows when > 0. Multiple parallel HTTP calls no longer fight." + [db [_ v]] + (let [current (or (:loading db) 0)] + (assoc db :loading + (if v + (inc current) + (max 0 (dec current)))))) (reg-event-db :set-loading @@ -1766,10 +1798,10 @@ {:db (update db :user-data merge (-> response :body)) :dispatch [:route (or (:return-route db) - routes/dnd-e5-char-builder-route)]})) + routes/dnd-e5-char-builder-route)]})) (defn show-old-account-message [] - [:show-login-message [:div "There is no account for the email or username, please double-check it. Usernames and passwords are case sensitive, email addresses are not. You can also try to " [:a {:href (routes/path-for routes/register-page-route)} "register"] "." ]]) + [:show-login-message [:div "There is no account for the email or username, please double-check it. Usernames and passwords are case sensitive, email addresses are not. You can also try to " [:a {:href (routes/path-for routes/register-page-route)} "register"] "."]]) (defn dispatch-login-failure [message] {:dispatch-n [[:clear-login] @@ -1783,13 +1815,17 @@ (= error-code errors/username-required) (dispatch-login-failure "Username is required.") (= error-code errors/too-many-attempts) (dispatch-login-failure "You have made too many login attempts, you account is locked for 15 minutes. Please do not try to login again until 15 minutes have passed.") (= error-code errors/password-required) (dispatch-login-failure "Password is required.") - (= error-code errors/bad-credentials) (dispatch-login-failure "Password is incorrect.") + (= error-code errors/bad-credentials) (dispatch-login-failure "Password is incorrect.") (= error-code errors/no-account) {:dispatch-n [[:clear-login] (show-old-account-message)]} (= error-code errors/unverified) {:db (assoc db :temp-email (-> response :body :email)) :dispatch [:route routes/verify-sent-route]} (= error-code errors/unverified-expired) {:dispatch [:route routes/verify-failed-route]} - :else (dispatch-login-failure [:div "A login error occurred."]))))) + :else (dispatch-login-failure + (if (seq branding/support-email) + [:div "An error occurred. If the problem persists please email " + [:a {:href (str "mailto:" branding/support-email) :target :blank} branding/support-email]] + [:div "An error occurred. Please try again later."])))))) (reg-event-fx :logout @@ -1806,7 +1842,8 @@ routes/send-password-reset-page-route routes/password-reset-success-route routes/password-reset-expired-route - routes/password-reset-used-route}) + routes/password-reset-used-route + routes/unsubscribe-success-route}) (reg-event-fx :login @@ -1830,7 +1867,8 @@ {:dispatch [:clear-login]})) #_ ;; dead stub — real impl is orcpub.registration/validate-registration -(defn validate-registration []) + (defn validate-registration []) + (reg-event-db :email-taken @@ -1843,10 +1881,10 @@ (assoc db :username-taken? (-> response :body (= "true"))))) #_ ;; never dispatched — registration form uses :register-first-and-last-name -(reg-event-db - :registration-first-and-last-name - (fn [db [_ first-and-last-name]] - (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) + (reg-event-db + :registration-first-and-last-name + (fn [db [_ first-and-last-name]] + (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) (reg-event-fx :registration-email @@ -1876,10 +1914,10 @@ (assoc-in db [:registration-form :send-updates?] send-updates?))) #_ ;; never dispatched from UI -(reg-event-db - :register-first-and-last-name - (fn [db [_ first-and-last-name]] - (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) + (reg-event-db + :register-first-and-last-name + (fn [db [_ first-and-last-name]] + (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) (reg-event-fx :check-email @@ -1947,21 +1985,21 @@ ;; never dispatched — character loading uses :load-user-data flow #_(reg-event-db - :load-characters-success - (fn [db [_ response]] - (assoc-in db [:dnd :e5 :characters] (:body response)))) + :load-characters-success + (fn [db [_ response]] + (assoc-in db [:dnd :e5 :characters] (:body response)))) (defn get-auth-token [db] (-> db :user-data :token)) #_ ;; never dispatched — character loading uses :load-user-data flow -(reg-event-fx - :load-characters - (fn [{:keys [db]} [_ params]] - {:http {:method :get - :auth-token (get-auth-token db) - :url (backend-url (routes/path-for routes/dnd-e5-char-list-route)) - :on-success [:load-characters-success]}})) + (reg-event-fx + :load-characters + (fn [{:keys [db]} [_ params]] + {:http {:method :get + :auth-token (get-auth-token db) + :url (backend-url (routes/path-for routes/dnd-e5-char-list-route)) + :on-success [:load-characters-success]}})) (reg-event-db :password-reset-success @@ -2195,10 +2233,10 @@ :login-message message))) #_ ;; never dispatched from UI -(reg-event-db - :hide-warning - (fn [db _] - (assoc db :warning-hidden true))) + (reg-event-db + :hide-warning + (fn [db _] + (assoc db :warning-hidden true))) (reg-event-db :hide-confirmation @@ -2228,12 +2266,12 @@ :sex sex})}))) #_ ;; unreferenced -(defn remove-subtypes [subtypes hidden-subtypes] - (let [result (sets/difference subtypes hidden-subtypes)] - result)) + (defn remove-subtypes [subtypes hidden-subtypes] + (let [result (sets/difference subtypes hidden-subtypes)] + result)) #_ ;; orphaned re-export alias — callers use compute/compute-plugin-vals directly -(def compute-plugin-vals compute/compute-plugin-vals) + (def compute-plugin-vals compute/compute-plugin-vals) (def compute-sorted-spells compute/compute-sorted-spells) (def compute-sorted-items compute/compute-sorted-items) (def filter-by-name-xform compute/filter-by-name-xform) @@ -2291,11 +2329,11 @@ (dissoc :search-text)))) #_ ;; never dispatched from UI (note: "orcacle" typo) -(reg-event-fx - :open-orcacle-over-character-builder - (fn [] - {:dispatch-n [[:route routes/dnd-e5-char-builder-route] - [:open-orcacle]]})) + (reg-event-fx + :open-orcacle-over-character-builder + (fn [] + {:dispatch-n [[:route routes/dnd-e5-char-builder-route] + [:open-orcacle]]})) (reg-event-db :open-orcacle @@ -2315,10 +2353,10 @@ (assoc db ::char5e/builder-tab tab))) (reg-event-db - ::char5e/sort-monsters - (fn [db [_ sort-criteria sort-direction]] - (assoc db ::char5e/monster-sort-criteria sort-criteria - ::char5e/monster-sort-direction sort-direction))) + ::char5e/sort-monsters + (fn [db [_ sort-criteria sort-direction]] + (assoc db ::char5e/monster-sort-criteria sort-criteria + ::char5e/monster-sort-direction sort-direction))) (reg-event-db ::char5e/filter-monsters @@ -2345,8 +2383,8 @@ (assoc db ::char5e/item-text-filter filter-text ::char5e/filtered-items (if (>= (count filter-text) 3) - (filter-items filter-text sorted) - sorted))))) + (filter-items filter-text sorted) + sorted))))) (reg-event-db ::char5e/toggle-selected @@ -2497,15 +2535,15 @@ (defn toggle-feature-used [character units nm] (-> character - (update-in - [::entity/values - ::char5e/features-used - units] - (partial toggle-set nm)) - (dissoc - [::entity/values - ::char5e/features-used - :db/id]))) + (update-in + [::entity/values + ::char5e/features-used + units] + (partial toggle-set nm)) + (dissoc + [::entity/values + ::char5e/features-used + :db/id]))) (reg-event-fx ::char5e/toggle-feature-used @@ -2537,17 +2575,17 @@ ::units5e/rest))) (reg-event-fx - ::char5e/finish-short-rest-warlock - (fn [{:keys [db]} [_ id]] - (clear-period db - id - (fn [character] - (update - character - ::entity/values - dissoc - ::spells/slots-used)) - ::units5e/rest))) + ::char5e/finish-short-rest-warlock + (fn [{:keys [db]} [_ id]] + (clear-period db + id + (fn [character] + (update + character + ::entity/values + dissoc + ::spells/slots-used)) + ::units5e/rest))) (reg-event-fx ::char5e/finish-short-rest @@ -2603,11 +2641,11 @@ "light-theme"))))) #_ ;; never dispatched from UI -(reg-event-db - ::mi/set-builder-item - [magic-item->local-store-interceptor] - (fn [db [_ magic-item]] - (assoc db ::mi/builder-item magic-item))) + (reg-event-db + ::mi/set-builder-item + [magic-item->local-store-interceptor] + (fn [db [_ magic-item]] + (assoc db ::mi/builder-item magic-item))) (reg-event-db ::mi/toggle-attunement @@ -2833,16 +2871,16 @@ :removed-conditions (map :type removed-conditions)}) individuals)) (:monster-data updated)))))] - {:dispatch-n (cond-> [[::combat/set-combat updated]] - (seq removed-conditions) - (conj [:show-message - [:div.m-t-5.f-w-b.f-s-18 - (doall - (map-indexed - (fn [i {:keys [name index removed-conditions]}] - ^{:key i} - [:div.m-b-5 (str name " #" (inc index) " is no longer " (common/list-print (map common/kw-to-name removed-conditions) "or") ".")]) - removed-conditions))]]))}))) + {:dispatch-n (cond-> [[::combat/set-combat updated]] + (seq removed-conditions) + (conj [:show-message + [:div.m-t-5.f-w-b.f-s-18 + (doall + (map-indexed + (fn [i {:keys [name index removed-conditions]}] + ^{:key i} + [:div.m-b-5 (str name " #" (inc index) " is no longer " (common/list-print (map common/kw-to-name removed-conditions) "or") ".")]) + removed-conditions))]]))}))) (reg-event-db ::encounters/set-encounter-path-prop @@ -3048,11 +3086,11 @@ (assoc-in subclass [class-spells-key level index] spell-kw))) #_ ;; never dispatched from UI -(reg-event-db - ::class5e/set-spell-list - subclass-interceptors - (fn [subclass [_ class-kw]] - (assoc-in subclass [:spellcasting :spell-list] class-kw))) + (reg-event-db + ::class5e/set-spell-list + subclass-interceptors + (fn [subclass [_ class-kw]] + (assoc-in subclass [:spellcasting :spell-list] class-kw))) (reg-event-db ::feats5e/set-feat-prop @@ -3061,11 +3099,11 @@ (assoc feat prop-key prop-value))) #_ ;; never dispatched from UI -(reg-event-db - ::bg5e/set-feature-prop - background-interceptors - (fn [background [_ prop-key prop-value]] - (assoc-in background [:traits 0 prop-key] prop-value))) + (reg-event-db + ::bg5e/set-feature-prop + background-interceptors + (fn [background [_ prop-key prop-value]] + (assoc-in background [:traits 0 prop-key] prop-value))) (reg-event-db ::feats5e/toggle-feat-prop @@ -3074,11 +3112,11 @@ (update-in feat [:props key] not))) #_ ;; never dispatched from UI — feat builder uses toggle-feat-prop instead -(reg-event-db - ::feats5e/toggle-feat-selection - feat-interceptors - (fn [feat [_ key]] - (update-in feat [:selections key] not))) + (reg-event-db + ::feats5e/toggle-feat-selection + feat-interceptors + (fn [feat [_ key]] + (update-in feat [:selections key] not))) (reg-event-db ::feats5e/toggle-feat-value-prop @@ -3100,29 +3138,29 @@ subrace-interceptors (fn [subrace [_ key num]] (update subrace :props (fn [m] - (if (= (get m key) num) - (dissoc m key) - (assoc m key num)))))) + (if (= (get m key) num) + (dissoc m key) + (assoc m key num)))))) #_ ;; never dispatched — class/subclass builder UI not wired for value-prop toggles -(reg-event-db - ::class5e/toggle-subclass-value-prop - subclass-interceptors - (fn [subclass [_ key num]] - (update subclass :props (fn [m] - (if (= (get m key) num) - (dissoc m key) - (assoc m key num)))))) + (reg-event-db + ::class5e/toggle-subclass-value-prop + subclass-interceptors + (fn [subclass [_ key num]] + (update subclass :props (fn [m] + (if (= (get m key) num) + (dissoc m key) + (assoc m key num)))))) #_ ;; never dispatched — class builder UI not wired for value-prop toggles -(reg-event-db - ::class5e/toggle-class-value-prop - class-interceptors - (fn [class [_ key num]] - (update class :props (fn [m] - (if (= (get m key) num) - (dissoc m key) - (assoc m key num)))))) + (reg-event-db + ::class5e/toggle-class-value-prop + class-interceptors + (fn [class [_ key num]] + (update class :props (fn [m] + (if (= (get m key) num) + (dissoc m key) + (assoc m key num)))))) (reg-event-db ::feats5e/toggle-feat-map-prop @@ -3149,16 +3187,16 @@ (update-in class prop-path not))) #_ ;; never dispatched — class builder UI not wired for prof toggles -(reg-event-db - ::class5e/toggle-class-prof - class-interceptors - (fn [class [_ prop-path]] - (let [v (get-in class prop-path)] - ;; for classes, the value for a prof signals whether - ;; it only applies to the first class a character takes - (if (= v false) - (common/dissoc-in class prop-path) - (assoc-in class prop-path false))))) + (reg-event-db + ::class5e/toggle-class-prof + class-interceptors + (fn [class [_ prop-path]] + (let [v (get-in class prop-path)] + ;; for classes, the value for a prof signals whether + ;; it only applies to the first class a character takes + (if (= v false) + (common/dissoc-in class prop-path) + (assoc-in class prop-path false))))) (reg-event-db ::class5e/toggle-subclass-path-prop @@ -3185,25 +3223,27 @@ (update-in race [:props key value] not))) #_ ;; never dispatched — class builder UI not wired for subclass map-prop toggles -(reg-event-db - ::class5e/toggle-subclass-map-prop - subclass-interceptors - (fn [subclass [_ key value]] - (update-in subclass [:props key value] not))) + (reg-event-db + ::class5e/toggle-subclass-map-prop + subclass-interceptors + (fn [subclass [_ key value]] + (update-in subclass [:props key value] not))) #_ ;; never dispatched — class builder UI not wired for class map-prop toggles -(reg-event-db - ::class5e/toggle-class-map-prop - class-interceptors - (fn [class [_ key value]] - (update-in class [:props key value] not))) + (reg-event-db + ::class5e/toggle-class-map-prop + class-interceptors + (fn [class [_ key value]] + (update-in class [:props key value] not))) #_ ;; never dispatched — background builder UI not wired for map-prop toggles -(reg-event-db - ::bg5e/toggle-background-map-prop - background-interceptors - (fn [background [_ key value]] - (update-in background [:props key value] not))) + (reg-event-db + ::bg5e/toggle-background-map-prop + background-interceptors + (fn [background [_ key value]] + (update-in background [:props key value] not))) + + (reg-event-db ::feats5e/toggle-feat-ability-increase @@ -3529,7 +3569,7 @@ (set-value item ::mi/magical-ac-bonus bonus))) #_ ;; orphaned re-export aliases — all callers use event-utils/ directly now -(def mod-cfg event-utils/mod-cfg) + (def mod-cfg event-utils/mod-cfg) #_(def mod-key event-utils/mod-key) #_(def compare-mod-keys event-utils/compare-mod-keys) #_(def default-mod-set event-utils/default-mod-set) @@ -3590,7 +3630,7 @@ (js/saveAs blob (str name ".orcbrew")) (if (seq (:warnings validation)) {:dispatch [:show-warning-message - (str "Plugin '" name "' exported with warnings. Check console for details.")]} + (str "Plugin '" name "' exported with warnings. Check console for details.")]} {}))) ;; Other validation failure - don't export @@ -3599,7 +3639,7 @@ (js/console.error "Export validation failed for" name ":") (js/console.error (:errors validation)) {:dispatch [:show-error-message - (str "Cannot export '" name "' - contains invalid data. Check console for details.")]}))))) + (str "Cannot export '" name "' - contains invalid data. Check console for details.")]}))))) ;; Export warning modal events (reg-event-db @@ -3638,9 +3678,9 @@ (let [all-plugins (:plugins db) ;; Validate each plugin validations (into {} - (map (fn [[name plugin]] - [name (import-val/validate-before-export plugin)]) - all-plugins)) + (map (fn [[name plugin]] + [name (import-val/validate-before-export plugin)]) + all-plugins)) has-errors (some (fn [[_ v]] (not (:valid v))) validations) has-warnings (some (fn [[_ v]] (seq (:warnings v))) validations)] @@ -3654,7 +3694,7 @@ (if has-errors {:dispatch [:show-error-message - "Cannot export all plugins - some contain invalid data. Check console for details."]} + "Cannot export all plugins - some contain invalid data. Check console for details."]} (let [blob (js/Blob. (clj->js [(str all-plugins)]) @@ -3662,26 +3702,40 @@ (js/saveAs blob "all-content.orcbrew") (if has-warnings {:dispatch [:show-warning-message - "All plugins exported with some warnings. Check console for details."]} + "All plugins exported with some warnings. Check console for details."]} {})))))) + +(defn clj->json + [ds] + (.stringify js/JSON (clj->js ds) nil 2)) + (reg-event-fx - ::e5/export-plugin-pretty-print - (fn [_ [_ name plugin]] - (let [blob (js/Blob. - (clj->js [(with-out-str (pprint/pprint plugin))]) - (clj->js {:type "text/plain;charset=utf-8"}))] - (js/saveAs blob (str name ".orcbrew")) - {}))) + ::e5/save-to-json + (fn [_ [_ name plugin]] + (let [blob (js/Blob. + (clj->js [(clj->json plugin)]) + (clj->js {:type "application/json;charset=utf-8"}))] + (js/saveAs blob (str name ".json")) + {}))) + +(reg-event-fx + ::e5/export-plugin-pretty-print + (fn [_ [_ name plugin]] + (let [blob (js/Blob. + (clj->js [(with-out-str (pprint/pprint plugin))]) + (clj->js {:type "text/plain;charset=utf-8"}))] + (js/saveAs blob (str name ".orcbrew")) + {}))) ;; Export all homebrew plugins as pretty-printed .orcbrew file. (reg-event-fx - ::e5/export-all-plugins-pretty-print - (fn [{:keys [db]} _] - (let [blob (js/Blob. - (clj->js [(with-out-str (pprint/pprint (:plugins db)))]) - (clj->js {:type "text/plain;charset=utf-8"}))] - (js/saveAs blob "all-content.orcbrew") - {}))) + ::e5/export-all-plugins-pretty-print + (fn [{:keys [db]} _] + (let [blob (js/Blob. + (clj->js [(with-out-str (pprint/pprint (:plugins db)))]) + (clj->js {:type "text/plain;charset=utf-8"}))] + (js/saveAs blob "all-content.orcbrew") + {}))) (reg-event-fx ::e5/delete-plugin @@ -3760,7 +3814,7 @@ :import-source-name plugin-name}) user-message (import-val/format-import-result result) has-conflicts? (or (seq (get-in result [:key-conflicts :internal-conflicts])) - (seq (get-in result [:key-conflicts :external-conflicts])))] + (seq (get-in result [:key-conflicts :external-conflicts])))] ;; Log detailed results to console for debugging (js/console.log "Import validation result:" (clj->js result)) @@ -3802,7 +3856,7 @@ (:success result) (let [plugin (:data result) is-multi-plugin (and (spec/valid? ::e5/plugins plugin) - (not (spec/valid? ::e5/plugin plugin)))] + (not (spec/valid? ::e5/plugin plugin)))] ;; Log skipped items if any (when (:had-errors result) @@ -3852,7 +3906,7 @@ {:dispatch-n [[::e5/set-plugins (if (= :multi-plugin (:strategy result)) (e5/merge-all-plugins (:plugins db) plugin) (assoc (:plugins db) plugin-name plugin))] - [:show-warning-message user-message]]}) + [:show-warning-message user-message]]}) {:dispatch [:show-error-message user-message]})))) @@ -4521,8 +4575,10 @@ (reg-event-fx :route-to-login - (fn [_ _] - {:dispatch [:route routes/login-page-route {:secure? true :no-return? true}]})) + (fn [{:keys [db]} _] + ;; Reset loading counter — multiple parallel 401s can leave the overlay stuck + {:db (assoc db :loading 0) + :dispatch [:route routes/login-page-route {:secure? true :no-return? true}]})) (reg-event-db ::char5e/show-options @@ -4537,10 +4593,10 @@ (assoc db ::char5e/options-shown? false))) #_ ;; never dispatched — print UI not wired -(reg-event-db - ::char5e/toggle-character-sheet-print - (fn [db _] - (update db ::char5e/exclude-character-sheet-print? not))) + (reg-event-db + ::char5e/toggle-character-sheet-print + (fn [db _] + (update db ::char5e/exclude-character-sheet-print? not))) (reg-event-db ::char5e/toggle-spell-cards-print @@ -4548,10 +4604,10 @@ (update db ::char5e/exclude-spell-cards-print? not))) #_ ;; never dispatched — print UI not wired -(reg-event-db - ::char5e/toggle-spell-cards-by-level - (fn [db _] - (update db ::char5e/exclude-spell-cards-by-level? not))) + (reg-event-db + ::char5e/toggle-spell-cards-by-level + (fn [db _] + (update db ::char5e/exclude-spell-cards-by-level? not))) (reg-event-db ::char5e/toggle-spell-cards-by-dc-mod @@ -4676,18 +4732,18 @@ weapon-kw)))) #_ ;; never dispatched — attunement UI not wired -(reg-event-fx - ::char5e/attune-magic-item - (fn [{:keys [db]} [_ id i weapon-kw]] - (update-character-fx db id #(update-in - % - [::entity/values - ::char5e/attuned-magic-items] - (fn [items] - (assoc - (or items [:none :none :none]) - i - weapon-kw)))))) + (reg-event-fx + ::char5e/attune-magic-item + (fn [{:keys [db]} [_ id i weapon-kw]] + (update-character-fx db id #(update-in + % + [::entity/values + ::char5e/attuned-magic-items] + (fn [items] + (assoc + (or items [:none :none :none]) + i + weapon-kw)))))) (reg-event-db :close-srd-message diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index ad15636e0..ae6570f5a 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -264,6 +264,11 @@ (fn [db _] (-> db :user-data :user-data :pending-email))) +(reg-sub + :send-updates? + (fn [db _] + (boolean (-> db :user-data :user-data :send-updates?)))) + (reg-sub :email-change-sent? (fn [db _] @@ -381,35 +386,37 @@ (reg-sub-raw ::char5e/characters (fn [app-db [_ login-optional?]] - (go (dispatch [:set-loading true]) - (let [response (<! (http/get (url-for-route routes/dnd-e5-char-summary-list-route) - {:headers (auth-headers @app-db)}))] - (dispatch [:set-loading false]) - (handle-api-response response - #(dispatch [::char5e/set-characters (:body response)]) - :on-401 #(when-not login-optional? (dispatch [:route-to-login])) - :context "fetch characters"))) + (when (:token (:user-data @app-db)) + (go (dispatch [:set-loading true]) + (let [response (<! (http/get (url-for-route routes/dnd-e5-char-summary-list-route) + {:headers (auth-headers @app-db)}))] + (dispatch [:set-loading false]) + (handle-api-response response + #(dispatch [::char5e/set-characters (:body response)]) + :on-401 #(when-not login-optional? (dispatch [:route-to-login])) + :context "fetch characters")))) (ra/make-reaction (fn [] (get @app-db ::char5e/characters []))))) (reg-sub-raw ::party5e/parties (fn [app-db [_ login-optional?]] - (go (dispatch [:set-loading true]) - (let [response (<! (http/get (url-for-route routes/dnd-e5-char-parties-route) - {:headers (auth-headers @app-db)}))] - (dispatch [:set-loading false]) - (handle-api-response response - #(dispatch [::party5e/set-parties (:body response)]) - :on-401 #(when-not login-optional? (dispatch [:route-to-login])) - :context "fetch parties"))) + (when (:token (:user-data @app-db)) + (go (dispatch [:set-loading true]) + (let [response (<! (http/get (url-for-route routes/dnd-e5-char-parties-route) + {:headers (auth-headers @app-db)}))] + (dispatch [:set-loading false]) + (handle-api-response response + #(dispatch [::party5e/set-parties (:body response)]) + :on-401 #(when-not login-optional? (dispatch [:route-to-login])) + :context "fetch parties")))) (ra/make-reaction (fn [] (get @app-db ::char5e/parties []))))) (reg-sub-raw :user (fn [app-db [_ required?]] - (when (and (:user @app-db) (:token (:user @app-db))) ;;check if logged in, prevent unncessary calls + (when (:token (:user-data @app-db)) ;; guard: skip HTTP when not logged in (go (let [hdrs (auth-headers @app-db) response (<! (http/get (url-for-route routes/user-route) {:headers hdrs}))] (handle-api-response response @@ -442,13 +449,14 @@ (reg-sub-raw ::folder5e/folders (fn [app-db _] - (go (dispatch [:set-loading true]) - (let [response (<! (http/get (url-for-route routes/dnd-e5-char-folders-route) - {:headers (auth-headers @app-db)}))] - (dispatch [:set-loading false]) - (handle-api-response response - #(dispatch [::folder5e/set-folders (:body response)]) - :context "fetch folders"))) + (when (:token (:user-data @app-db)) + (go (dispatch [:set-loading true]) + (let [response (<! (http/get (url-for-route routes/dnd-e5-char-folders-route) + {:headers (auth-headers @app-db)}))] + (dispatch [:set-loading false]) + (handle-api-response response + #(dispatch [::folder5e/set-folders (:body response)]) + :context "fetch folders")))) (ra/make-reaction (fn [] (get @app-db ::folder5e/folders []))))) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 81a4dc9d0..f3773403b 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -10,6 +10,7 @@ [orcpub.dice :as dice] [orcpub.entity.strict :as se] [orcpub.dnd.e5.subs :as subs] + [re-frame.db :refer [app-db]] [orcpub.dnd.e5.equipment-subs] [orcpub.dnd.e5.character :as char] [orcpub.dnd.e5.backgrounds :as bg] @@ -41,11 +42,17 @@ [orcpub.template :as template] [orcpub.dnd.e5.options :as opt] [orcpub.dnd.e5.events :as events] + [orcpub.fork.integrations :as integrations] + [orcpub.fork.branding :as branding] + [orcpub.fork.splash :as splash] + [orcpub.fork.user-tier] [orcpub.ver :as v] [clojure.string :as s] [cljs.reader :as reader] [orcpub.user-agent :as user-agent] - [bidi.bidi :as bidi])) + [bidi.bidi :as bidi] + [camel-snake-kebab.core :as csk]) + #_(:require-macros [cljs.core.async.macros :refer [go]])) ;; the `amount` of "uses" an action may have before it warrants ;; using a dropdown instead of a list of checkboxes @@ -148,6 +155,9 @@ (def login-style {:color "#f0a100"}) +(def login-style-menu + {:background-color "rgba(0,0,0,0.4)"}) + (defn dispatch-logout [] (dispatch [:logout])) @@ -212,15 +222,14 @@ (when username {:on-mouse-over handle-user-menu :on-mouse-out hide-user-menu}) - [:div.flex.align-items-c - [:div.user-icon [svg-icon "orc-head" 40 ""]] + [:div.b-rad-5.flex.align-items-c.p-l-10.p-r-10.p-t-5.p-b-5.f-s-16 {:style login-style-menu } + [:div.user-icon [svg-icon "orc-head" 35 ""]] (if username [:span.f-w-b.t-a-r (when (not @(subscribe [:mobile?])) [:span.m-r-5 username])] [:span.pointer.flex.flex-column.align-items-end - [:span.orange.underline.f-w-b.m-l-5 - {:style login-style - :on-click dispatch-route-to-login} + [:span.white.f-w-b.m-l-5 + {:on-click dispatch-route-to-login} [:span "LOGIN"]]]) (when username [:i.fa.m-l-5.fa-caret-down])] @@ -258,11 +267,12 @@ (fn [title icon on-click disabled active device-type & buttons] (let [mobile? (= :mobile device-type)] [:div.f-w-b.f-s-14.t-a-c.header-tab.m-l-2.m-r-2.posn-rel - {:on-click (fn [e] (if (seq buttons) - #(swap! hovered? not) - (on-click e))) - :on-mouse-enter #(reset! hovered? true) - :on-mouse-leave #(reset! hovered? false) + {:on-click (fn [e] + (if (seq buttons) + (swap! hovered? not) + (on-click e))) + :on-mouse-enter #(when-not mobile? (reset! hovered? true)) + :on-mouse-leave #(when-not mobile? (reset! hovered? false)) :style (when active active-style) :class (str (if disabled "disabled" "pointer") " " @@ -284,22 +294,76 @@ (let [current-route @(subscribe [:route])] {:style (when (or (= route current-route) (= route (get current-route :handler))) active-style) - :on-click (route-handler route)}) + :on-click (fn [e] + (.stopPropagation e) + (reset! hovered? false) + ((route-handler route) e))}) name]) buttons))])])))) +(defn header-tab2 [] + (let [hovered? (r/atom false)] + (fn [title icon on-click disabled active device-type & buttons] + (let [mobile? (= :mobile device-type)] + [:div.f-w-b.f-s-14.t-a-c.header-tab.m-l-2.m-r-2.posn-rel + {:on-click (fn [e] + (if (seq buttons) + (swap! hovered? not) + (when (fn? on-click) (on-click e)))) + :on-mouse-over #(when-not mobile? (reset! hovered? true)) + :on-mouse-out #(when-not mobile? (reset! hovered? false)) + :style (if active active-style) + :class-name (str (if disabled "disabled" "pointer") + " " + (if (not mobile?) "w-110"))} + [:div.p-10 + {:class-name (if (not active) (if disabled "opacity-2" "opacity-6 hover-opacity-full"))} + (let [size (if mobile? 24 48)] (svg-icon icon size "")) + (if (not mobile?) + [:div.title.uppercase title])] + (if (and (seq buttons) + @hovered?) + [:div.uppercase.shadow + {:style (if mobile? mobile-header-menu-item-style header-menu-item-style)} + (doall + (map + (fn [{:keys [name route]}] + ^{:key name} + [:div.p-10.opacity-5.hover-opacity-full.a-white + {:on-click (fn [e] + (.stopPropagation e) + (reset! hovered? false)) + :style (let [current-route @(subscribe [:route])] + (when (or (= route current-route) + (= route (get current-route :handler))) active-style))} + [:a.no-text-decoration {:href route} name]]) + buttons))])])))) (def social-icon-style {:color :white :font-size "20px"}) -(defn social-icon [icon link] +(defn social-icon + "Render a Font Awesome brand icon as a social link." + [icon link] [:a.p-5.opacity-5.hover-opacity-full.main-text-color {:style social-icon-style :href link :target :_blank} [:i.fab {:class (str "fa-" icon)}]]) +(defn bluesky-icon + "Bluesky butterfly icon (inline SVG — FA 5.13.1 has no fa-bluesky)." + [link] + [:a.p-5.opacity-5.hover-opacity-full.main-text-color + {:style social-icon-style + :href link :target :_blank} + [:svg {:xmlns "http://www.w3.org/2000/svg" + :viewBox "0 0 568 501" + :width "20" :height "18" + :style {:vertical-align "middle" :fill "currentColor"}} + [:path {:d "M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.07-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.135-1.612 123.121 33.664Z"}]]]) + (def search-input-style {:height "60px" :margin-top "0px" @@ -314,7 +378,7 @@ :right 25}) (def search-input-parent-style - {:background-color "rgba(0,0,0,0.15)"}) + {:background-color "rgba(0,0,0,0.3)"}) ;; dead — zero callers #_(def transparent-search-input-style @@ -353,9 +417,8 @@ (defn route-to-my-encounters-page [] (dispatch [:route routes/dnd-e5-my-encounters-route])) -(def logo [:img.h-60.pointer - {:src "/image/dmv-logo.svg" - :on-click route-to-default-route}]) +(def logo [:a {:href "/" } [:img.h-60.pointer + {:src branding/logo-path}]]) (defn app-header [] (let [device-type @(subscribe [:device-type]) @@ -391,16 +454,19 @@ {:class (if mobile? "justify-cont-s-b" "justify-cont-s-b")} [:div {:style {:min-width "53px"}} - [:a {:href "https://www.patreon.com/DungeonMastersVault" :target :_blank} - [:img.h-32.m-l-10.m-b-5.pointer.opacity-7.hover-opacity-full - {:src (if mobile? - "https://c5.patreon.com/external/logo/downloads_logomark_color_on_navy.png" - "https://c5.patreon.com/external/logo/become_a_patron_button.png")}]] + [integrations/supporter-link @(subscribe [:user-tier]) mobile? svg-icon] (when (not mobile?) [:div.main-text-color.p-10 - (social-icon "facebook-f" "https://www.facebook.com/groups/252484128656613/") - (social-icon "twitter" "https://twitter.com/thDMV") - (social-icon "reddit-alien" "https://www.reddit.com/r/dungeonmastersvault/")])] + (when-let [url (not-empty (:facebook branding/social-links))] + (social-icon "facebook-f" url)) + (when-let [url (not-empty (:bluesky branding/social-links))] + (bluesky-icon url)) + (when-let [url (not-empty (:twitter branding/social-links))] + (social-icon "twitter" url)) + (when-let [url (not-empty (:reddit branding/social-links))] + (social-icon "reddit-alien" url)) + (when-let [url (not-empty (:discord branding/social-links))] + (social-icon "discord" url))])] [:div.flex.m-b-5.m-t-5.justify-cont-s-b.app-header-menu [header-tab "characters" @@ -463,6 +529,15 @@ :route routes/dnd-e5-combat-tracker-page-route} {:name "Encounter Builder" :route routes/dnd-e5-encounter-builder-page-route}] + (when (seq splash/header-generator-entries) + (into [header-tab2 + "generators" + "elven-castle" + "" + false + false + device-type] + splash/header-generator-entries)) [header-tab "My Content" "beer-stein" @@ -528,8 +603,9 @@ [:div.flex {:style registration-left-column-style} [:div.flex.justify-cont-s-a.align-items-c {:style registration-header-style} - [:img.h-55.pointer - {:src "/image/dmv-logo.svg" + [:img.pointer + {:class branding/registration-logo-class + :src branding/logo-path :on-click route-to-default-page}]] [:div.flex-grow-1 content] [views-2/legal-footer]] @@ -623,7 +699,10 @@ :font-weight "600"} :class (when bad-email? "disabled opacity-5 hover-no-shadow") :on-click (when (not bad-email?) (make-event-handler :send-password-reset @params))} - "SUBMIT"]]]))))) + "SUBMIT"] + [:div.m-t-20 + [:span "Didn't receive reset email? " [:br] [:a.orange {:href "/help/im-not-getting-my-signup-password-reset-email/" :target "_blank"} "whitelist"] " our domain then try it again."]] + ]]))))) (defn password-reset-expired-page [] [send-password-reset-page "Your reset link has expired, you must complete the reset within 24 hours. Please use the form below to send another reset email."]) @@ -709,6 +788,19 @@ [:div.m-t-20 "You can now log in"] [login-link]])) +(defn unsubscribe-success [] + (registration-page + [:div {:style {:text-align :center}} + [:div {:style {:color orange + :font-weight :bold + :font-size "36px" + :text-transform :uppercase + :text-shadow "1px 2px 1px rgba(0,0,0,0.37)" + :margin-top "100px"}} + "Unsubscribed"] + [:div.m-t-20 "You have been successfully unsubscribed from email updates."] + [:div.m-t-10 "You can re-enable updates at any time from your account settings."]])) + (defn email-sent [text] (registration-page [:div {:style {:text-align :center}} @@ -727,7 +819,9 @@ [:div [:span "We sent a verification email to "] [:span.f-w-b.red.f-s-18 @(subscribe [:temp-email])] - [:span ". You must verify to complete registration and the link we sent will only be valid for 24 hours."]])) + [:span ". You must verify to complete registration and the link we sent will only be valid for 24 hours."] + [:span " "] + [:span "Remember to check your spam folder."]])) (defn password-reset-sent [] (email-sent @@ -816,11 +910,12 @@ :border-width "1px" :border-bottom-width "3px"} :on-click #(dispatch [:registration-send-updates? (not send-updates?)])}] - [:span.m-l-5 "Yes! Send me updates about OrcPub."]] - [:div.m-t-30 + [:span.m-l-5 (str "Yes! Send me updates about " branding/app-name)]] + [:div.m-t-10 [:div.p-10 [:span "Already have an account?"] (login-link)] + [:div.m-t-10.m-b-20 [:span "After clicking JOIN A validation email will be sent to the above email address."]] [:button.form-button {:style {:height "40px" :width "174px" @@ -859,8 +954,7 @@ :text-shadow "1px 2px 1px rgba(0,0,0,0.37)" :margin-top "20px"}} "LOGIN"] - ;[:div.m-t-10 - ; [facebook-login-button]] + [:div.m-t-10] [:div.login-form-inputs [form-input {:title "Username or Email" :key :username @@ -878,6 +972,7 @@ login-message hide-login-message]]) [:div.m-t-10 + [:button.form-button {:style {:height "40px" :width "174px" @@ -886,15 +981,18 @@ :on-click #(dispatch [:login @params true])} "LOGIN"] [:div.m-t-20 - [:span "Don't have a login? "] + [:span "Don't have a login? "][:br][:br] [:span.orange.underline.pointer {:on-click route-to-register-page} "REGISTER NOW"]] [:div.m-t-20 - [:span "Forgot your password? "] + [:span "Forgot your password? "][:br][:br] [:span.orange.underline.pointer {:on-click route-to-reset-password-page} - "RESET PASSWORD"]]]]]))))) + "RESET PASSWORD"]] + + [:div.m-t-20 + [:span "Didn't receive validation the email? " [:br] [:a.orange {:href "/help/im-not-getting-my-signup-password-reset-email/" :target "_blank"} "Whitelist"] " our domain then reset your password." ]]]]]))))) (def loading-style {:position :fixed @@ -1131,7 +1229,7 @@ (defn spell-component [{:keys [name level school casting-time ritual range duration components description summary page source] :as spell} include-name? & [subheader-size]] [:div.m-l-10.l-h-19 [spell-summary name level school ritual include-name? subheader-size] - (spell-field "Casting Time" casting-time) + (spell-field "Casting Time" (str casting-time (if ritual " (ritual)" ""))) (spell-field "Range" range) (spell-field "Duration" duration) (let [{:keys [verbal somatic material material-component]} components] @@ -1214,7 +1312,7 @@ legendary-actions)] [:div.m-l-10.l-h-19 (when (not @(subscribe [:mobile?])) {:style two-columns-style}) - [:span.f-s-24.f-w-b name] + [:span.f-s-24.f-w-b.m-b-20 name] [:div.f-s-18.i.f-w-b (monsters/monster-subheader size type subtypes alignment)] (spell-field "Armor Class" (str armor-class (when armor-notes (str " (" armor-notes ")")))) (let [{:keys [mean die-count die modifier]} hit-points] @@ -1355,9 +1453,7 @@ (defn close-orcacle [] (dispatch [:close-orcacle])) -;; Used in legal footer below. template.cljc has a separate srd-link for character_builder. -(def srd-link - [:a.orange {:href "/SRD-OGL_V5.1.pdf" :target "_blank"} "the 5e SRD"]) + (defn orcacle [] (let [search-text @(subscribe [:search-text])] @@ -1387,6 +1483,22 @@ [:div.flex-grow-1 [search-results]]]])) +(def srd-link + [:a.orange {:href "/dnld/SRD-OGL_V5.1.pdf" :target "_blank"} "the 5e SRD-OGL 5.1"]) + +(defn current-year [] + (.getFullYear (js/Date.))) + +;; ------------------------------------------------------------------ +;; A helper that lets us embed a raw string of HTML inside Reagent. +;; (If you’re using reagent‑core ≥1.2, `:raw` is built‑in; otherwise +;; you can use the small shim below.) +;; ------------------------------------------------------------------ +;; Render *any* raw HTML string. +(defn raw-html [html] + ;; Note: no `:>` or `js/React.createElement`. + [:div {:dangerouslySetInnerHTML #js {:__html html}}]) + (defn content-page [title button-cfgs content & {:keys [hide-header-message? frame?]}] ;; Plain atom (not r/atom) mirrors the :orcacle-open? subscription value ;; for the scroll handler, which runs as a DOM event listener outside @@ -1401,11 +1513,22 @@ (if (>= scroll-top header-height) (set! (.-display (.-style sticky-header)) "block") (set! (.-display (.-style sticky-header)) "none")))))] + (r/create-class {:component-did-mount (fn [comp] + ;; Read directly from app-db — lifecycle methods are + ;; not reactive contexts, so subscribe warns here. + (let [user-data (-> @app-db :user-data :user-data)] + (integrations/on-app-mount! + {:user-tier (if (:patron user-data) + (or (some-> user-data :patron-tier keyword) :patron) + :free) + :username (:username user-data) + :email (:email user-data)})) (when-not frame? (js/window.addEventListener "scroll" on-scroll)) (js/window.scrollTo 0,0)) + :component-will-unmount (fn [comp] (when-not frame? (js/window.removeEventListener "scroll" on-scroll))) @@ -1414,7 +1537,8 @@ (let [srd-message-closed? @(subscribe [:srd-message-closed?]) orcacle-open? @(subscribe [:orcacle-open?]) theme @(subscribe [:theme]) - mobile? @(subscribe [:mobile?])] + mobile? @(subscribe [:mobile?]) + username? @(subscribe [:username])] (reset! orcacle-open?* orcacle-open?) [:div.app.min-h-full {:class theme @@ -1422,7 +1546,7 @@ (fn [e]))} (when-not frame? [download-form]) - (when @(subscribe [:loading]) + (when (pos? (or @(subscribe [:loading]) 0)) [:div {:style loading-style} [:div.flex.justify-cont-s-a.align-items-c.h-100-p [:img.h-200.w-200.m-t-200 {:src "/image/spiral.gif"}]]]) @@ -1438,34 +1562,50 @@ hdr]]] [:div.flex.justify-cont-c.main-text-color [:div.content hdr]] - ; Banner for announcements - #_[:div.m-l-20.m-r-20.f-w-b.f-s-18.container.m-b-10.main-text-color - (if (and (not srd-message-closed?) - (not hide-header-message?)) - [:div - (if (not frame?) - [:div.content.bg-lighter.p-10.flex - [:div.flex-grow-1 - [:div "Site is based on SRD rules. " srd-link "."]] - [:i.fa.fa-times.p-10.pointer - {:on-click #(dispatch [:close-srd-message])}]])])] + + ;; Support banner (integrations-gated) + [:div.m-l-20.m-r-20.f-w-b.f-s-18.container.m-b-10.main-text-color + [integrations/support-banner + {:srd-message-closed? srd-message-closed? + :hide-header-message? hide-header-message? + :frame? frame? + :user-tier @(subscribe [:user-tier]) + :on-dismiss #(dispatch [:close-srd-message])}]] + + ;; Content slot (integrations-gated) + [integrations/content-slot @(subscribe [:user-tier])] + [:div#app-main.container [:div.content.w-100-p content]] + [:div.main-text-color.flex.justify-cont-c [:div.content.f-w-n.f-s-12 + ;; Content slot (integrations-gated) + [integrations/content-slot @(subscribe [:user-tier])] + [:div.flex.justify-cont-s-b.align-items-c.flex-wrap.p-10 [:div [:div.m-b-5 "Icons made by Lorc, Caduceus, and Delapouite. Available on " [:a.orange {:href "http://game-icons.net"} "http://game-icons.net"]] [:div.m-b-5 "Artwork provided by the talented Sandra. Available on " [:a.orange {:href "https://www.deviantart.com/sandara" :target :_blank} "Deviantart"]]] [:div.m-l-10 - [:a.orange {:href "https://github.com/Orcpub/orcpub/issues" :target :_blank} "Feedback/Bug Reports"]] + [:div.m-b-5.justify-cont-c + (when-let [url (not-empty (:patreon branding/social-links))] + [:a.orange {:href url :target :_blank} "Support this site on Patreon"]) + (when (seq branding/help-url) + [:a.orange.m-l-5 {:href branding/help-url :target :_blank} "Help"]) + [:a.orange.m-l-5 {:href "https://github.com/Orcpub/orcpub/issues" :target :_blank} "Feedback/Bug Reports"]]] [:div.m-l-10.m-r-10.p-10 - [:a.orange {:href "/privacy-policy" :target :_blank} "Privacy Policy"] - [:a.orange.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"]] + [:div.m-b-5 + (for [{:keys [label href]} splash/legal-footer-links] + ^{:key href} + [:a.orange.m-l-5 {:href href :target :_blank} label])]] [:div.legal-footer - [:p "© " (.getFullYear (js/Date.)) " " [:a.orange {:href "https://github.com/Orcpub/orcpub/" :target :_blank} "Orcpub"]] - [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © " (.getFullYear (js/Date.)) " Wizards. All Rights Reserved."] - [:p "This site is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] + [:p "© " (current-year) " " + (if (seq branding/copyright-url) + [:a.orange {:href branding/copyright-url :target :_blank} branding/copyright-holder] + branding/copyright-holder)] + [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © 2025 Wizards. All Rights Reserved."] + [:p branding/app-name " is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] [:p "Version " (v/version) " (" (v/date) ") " (v/description) " edition"]]] [debug-data]]]])]))}))) @@ -1480,7 +1620,8 @@ {:border-top "2px solid rgba(255,255,255,0.5)"}) #_(def thumbnail-style {:height "100px" - :max-width "200px"}) + :max-width "200px" + :border-radius "5px"}) (defn other-user-component [owner & [text-classes show-follow?]] (let [following-users @(subscribe [:following-users]) @@ -1649,8 +1790,8 @@ conj [:div] buttons))] - [:div {:class (if list? "m-t-0" "m-t-4")} - [:span.f-s-24.f-w-600 + [:div {:class-name (if list? "m-t-0" "m-t-4") } + [:span.f-s-24.f-w-600 {:class (csk/->camelCase (str title))} value]]]) (defn list-display-section [title image-name values] @@ -2473,7 +2614,7 @@ (some (complement s/blank?) descriptions)) [:div.m-t-20.t-a-l [:div.f-w-b.f-s-18 title] - [:div + [:div {:class (csk/->camelCase (str title))} (doall (map-indexed (fn [i description] @@ -2644,7 +2785,8 @@ @(subscribe [::char/notes id]) (set-notes-handler id) {:style notes-style - :class "input"}]]]]]]])) + :maxLength (:notes branding/field-limits) + :class-name "input"}]]]]]]])) (defn weapon-details-field [nm value] [:div.p-2 @@ -2786,10 +2928,10 @@ ^{:key (str key (:key shield))} [:tr.item.pointer {:on-click (toggle-details-expanded-handler expanded-details k)} - [:td.p-10.f-w-b (str (or (::mi/name armor) (:name armor) "unarmored") + [:td.p-10.f-w-b.armor (str (or (::mi/name armor) (:name armor) "unarmored") (when shield (str " + " (:name shield))))] (when (not mobile?) - [:td.p-10 (boolean-icon proficient?)]) + [:td.p-10.proficient (boolean-icon proficient?)]) [:td.p-10.w-100-p [:div (armor-details-section armor shield expanded?)]] @@ -3469,16 +3611,6 @@ (when @show-selections? [character-selections id])]]])))) -(defn share-link [id] - [:a.m-r-5.f-s-14 - {:href (str "mailto:?subject=My%20OrcPub%20Character%20" - @(subscribe [::char/character-name id]) - "&body=https://" - js/window.location.hostname - (routes/path-for routes/dnd-e5-char-page-route :id id))} - [:i.fa.fa-envelope.m-r-5] - "share"]) - (def character-display-style {:padding "20px 5px" :background-color "rgba(0,0,0,0.15)"}) @@ -3574,22 +3706,21 @@ has-spells? (seq (char/spells-known built-char)) print-button-enabled (if (or (= print-character-sheet-style? nil) (= (str print-character-sheet-style?) "NaN")) - false true)] + false true) + ] [:div.flex.justify-cont-end [:div.p-20 - [:div.f-s-24.f-w-b.m-b-10 "Print Options"] + [:div.f-s-20.f-w-b.m-b-10 "PDF Options"] [:div.m-b-2 [:div.flex.m-b-10 - [:div.m-t-10 + [:div.m-t-5 [labeled-dropdown "Select Character sheet" - {:items [{:title "Select" :value " "} - {:title "Original 5e Character sheet" :value 1} - {:title "Original 5e Character sheet - optional variant" :value 2} - {:title "Icewind Dale 5e Character sheet" :value 3} - {:title "Petersen Games - Cthulhu Mythos Sagas sheet" :value 4}] + {:items (into [{:title "Select" :value " "}] + (integrations/sheet-styles @(subscribe [:user-tier]))) :value print-character-sheet-style? :on-change (make-arg-event-handler ::char/set-print-character-sheet-style? js/parseInt)}]]] + [integrations/pdf-options-slot @(subscribe [:user-tier])] [:div.flex [:div {:on-click (make-event-handler ::char/toggle-large-abilities-print)} @@ -3627,9 +3758,6 @@ [labeled-checkbox "Prepared" print-prepared-spells?]]]]) - [:span.orange.underline.pointer.uppercase.f-s-12 - {:on-click (make-event-handler ::char/hide-options)} - "Cancel"] [:button.form-button.p-10.m-l-5 {:style (print-button-style print-button-enabled) :on-click (export-pdf-handler built-char @@ -3641,19 +3769,35 @@ print-large-abilities? print-character-sheet-style? print-spell-card-dc-mod?)} - "Print"]]])) + "Create PDF"] + [:div.f-s-20.f-w-b.m-b-10.m-t-10 "Other PDFs"] + [:a.orange {:href "/dnld/5eActionsReferencePage.pdf" :target "_blank"} "5e Actions Reference"]] + [:span.orange.underline.pointer.uppercase.m-l-10.f-s-12 + {:on-click (make-event-handler ::char/hide-options)} + "Cancel"]])) (defn make-print-handler [id built-char] #(dispatch [::char/show-options [print-options id built-char]])) + +(defn abilities-spec [vals suffix bonus?] + (reduce-kv + (fn [m k v] + (let [new-k (if suffix + (keyword (str (name k) "-mod")) + k) + new-v (if bonus? (common/bonus-str v) v)] + (assoc m new-k new-v))) + {} + vals)) + (defn character-page [] (let [expanded? (r/atom false)] (fn [{:keys [id] :as arg}] (let [id (js/parseInt id) frame? (= "true" (get-in arg [:query "frame"])) - _ (prn "FRAME?" frame?) {:keys [::entity/owner] :as character} @(subscribe [::char/character id]) built-template (subs/built-template @(subscribe [::char/template]) @@ -3662,26 +3806,27 @@ built-character (subs/built-character character built-template) device-type @(subscribe [:device-type]) username @(subscribe [:username])] + (prn "FRAME?" frame?) [content-page (when (not frame?) "Character Page") - (remove - nil? - [[share-link id] - [:div.m-l-5.hover-shadow.pointer - {:on-click #(swap! expanded? not)} - [:img.h-32 {:src "/image/world-anvil.jpeg"}]] - (when (and username - owner - (= owner username)) - {:title "Edit" - :icon "pencil" - :on-click (make-event-handler :edit-character character)}) - {:title "Print" - :icon "print" - :on-click (make-print-handler id built-character)} - (when (and username owner (not= owner username)) - [add-to-party-component id])]) + (into + (vec (integrations/share-links id @(subscribe [::char/character-name id]))) + (remove nil? + [#_[:div.m-l-5.hover-shadow.pointer + {:on-click #(swap! expanded? not)} + [:img.h-32 {:src "/image/world-anvil.jpeg"}]] + (when (and username + owner + (= owner username)) + {:title "Edit" + :icon "pencil" + :on-click (make-event-handler :edit-character character)}) + {:title "Export" + :icon "download" + :on-click (make-print-handler id built-character)} + (when (and username owner (not= owner username)) + [add-to-party-component id])])) [:div.p-10.main-text-color (when @expanded? (let [url js/window.location.href] @@ -3761,20 +3906,13 @@ (defn input-builder-field [name value on-change attrs] [builder-field :input name value on-change attrs]) -;; dead — zero callers -#_(defn text-field [{:keys [value on-change]}] - [comps/input-field - :input - value - on-change - {:class "input"}]) - (defn textarea-field [{:keys [value on-change]}] [comps/input-field :textarea value on-change - {:class "input"}]) + {:class-name "input" + :maxLength (:notes branding/field-limits)}]) (defn number-field [{:keys [value on-change]}] [comps/input-field @@ -3784,7 +3922,8 @@ (on-change (when (re-matches #"\d+" v) (js/parseInt v)))) {:class "input" - :type :number}]) + :type :number + :maxLength (:number branding/field-limits)}]) (defn attunement-value [attunement key name] [:div @@ -4184,7 +4323,8 @@ (prop item) #(dispatch [prop-event prop %]) {:class "input h-40" - :type type}]]) + :type type + :maxLength (:text branding/field-limits)}]]) #_(defn item-input-field [title prop item & [class-names]] (builder-input-field title prop item ::mi/set-item-name class-names)) @@ -5165,7 +5305,7 @@ "Amount to Select" {:items (map value-to-item - (range 1 11)) + (range 1 31)) :value (get selection-cfg :choose) :on-change #(dispatch [value-change-event index (assoc selection-cfg :choose (js/parseInt %))])}]])])) @@ -7690,8 +7830,14 @@ [:div.m-t-5.red error])] :else - [:div + [:<> [:span current-email] + [:button.link-button.m-l-10 + {:on-click #(do (reset! editing? true) + (reset! new-email "") + (reset! confirm-email "") + (dispatch [:change-email-clear]))} + "Change"] (when pending-email [:div.m-t-5.f-s-14 "Pending: " pending-email " — check your email to verify the change. " @@ -7701,13 +7847,22 @@ {:on-click #(dispatch [:change-email pending-email])} "Resend"] (when error - [:span.m-l-5.red.f-s-14 error])]) - [:button.link-button.m-l-10 - {:on-click #(do (reset! editing? true) - (reset! new-email "") - (reset! confirm-email "") - (dispatch [:change-email-clear]))} - "Change"]])]]]))) + [:span.m-l-5.red.f-s-14 error])])])] + ;; ─── Email Updates Toggle ───────────────────────────────── + [:div.p-5 + [:span.f-w-b "Email Updates: "] + (let [send-updates? @(subscribe [:send-updates?])] + [:span + [:i.fa.fa-check.f-s-14.pointer.m-r-5 + {:class (if send-updates? "orange" "white") + :style {:border-color "#f0a100" + :border-style :solid + :border-width "1px" + :border-bottom-width "3px"} + :on-click #(dispatch [:toggle-send-updates (not send-updates?)])}] + (if send-updates? + (str "Receiving updates from " branding/app-name) + "Not receiving updates")])]]]))) (defn newb-character-builder-page [] @@ -7908,7 +8063,7 @@ [:div {:style character-display-style} [:div.flex.justify-cont-end.uppercase.align-items-c - [share-link id] + [integrations/share-link-www id] (when (= username owner) [:button.form-button {:on-click (make-event-handler :edit-character character)} @@ -7920,20 +8075,22 @@ [:button.form-button.m-l-5 {:on-click (make-event-handler :route char-page-route)} "view"] - [:button.form-button.m-l-5 - {:on-click (export-pdf - built-char - id - plugin-data - {:print-character-sheet? true - :print-spell-cards? true - :print-prepared-spells? false - :print-character-sheet-style? 1 - :print-spell-card-dc-mod? true})} - "print"] + (when (or (not branding/restrict-print-to-owner?) (= username owner)) + [:button.form-button.m-l-5 + {:on-click (export-pdf + built-char + id + plugin-data + {:print-character-sheet? true + :print-spell-cards? true + :print-prepared-spells? false + :print-character-sheet-style? 1 + :print-spell-card-dc-mod? true})} + "print"]) (when (and (= username owner) (seq folders)) [:select.form-button.m-l-5.builder-option-dropdown - {:value (or current-folder-id "") + {:style {:width "auto" :align-self "stretch" :box-sizing "border-box"} + :value (or current-folder-id "") :on-change (fn [e] (let [val (.-value (.-target e))] (if (= val "") @@ -8199,10 +8356,19 @@ expanded-characters @(subscribe [:expanded-characters]) username @(subscribe [:username]) selected-ids @(subscribe [::char/selected]) - has-selected? @(subscribe [::char/has-selected?])] - [content-page + has-selected? @(subscribe [::char/has-selected?]) + user-tier @(subscribe [:user-tier])] + (integrations/track-character-list! (count characters) user-tier) + #_(prn (str (count characters) " characters - " user-tier)) +[content-page "Characters" - [{:title "New" + [#_(if (>= (count characters) 5) {:title "Free accounts are limited to 5 characters" + :icon "plus" + :class-name "cursor-disabled"} + {:title "New" + :icon "plus" + :on-click #(dispatch [:new-character])}) + {:title "New" :icon "plus" :on-click #(dispatch [:new-character])} {:title "Make Party" diff --git a/src/cljs/orcpub/fork/branding.cljs b/src/cljs/orcpub/fork/branding.cljs new file mode 100644 index 000000000..3c41adc52 --- /dev/null +++ b/src/cljs/orcpub/fork/branding.cljs @@ -0,0 +1,66 @@ +(ns orcpub.fork.branding + "Client-side branding config. Reads server-injected window.__BRANDING__. + Fallback values are used in dev/REPL where no server injection exists. + + Server-side source of truth: branding.clj + Bridge: index.clj injects branding/client-config as JSON in <head>.") + +;; ─── Config Bridge ─────────────────────────────────────────────── +;; Server injects branding.clj/client-config as window.__BRANDING__ JSON. +;; We read it once at namespace load time. + +(def ^:private config + (when (exists? js/window.__BRANDING__) + (js->clj js/window.__BRANDING__ :keywordize-keys true))) + +;; ─── Branding Values ───────────────────────────────────────────── +;; Each def reads from the bridge config with a hardcoded fallback +;; for dev/REPL environments where the server isn't injecting. + +(def app-name + "Full display name." + (:app-name config "OrcPub")) + +(def logo-path + "Path to the main SVG logo." + (:logo-path config "/image/orcpub-logo.svg")) + +(def copyright-holder + "Entity name for legal footer." + (:copyright-holder config "OrcPub")) + +(def copyright-year + "Copyright year string." + (:copyright-year config "2025")) + +(def support-email + "Contact email for error messages and privacy page." + (:support-email config "")) + +(def help-url + "URL for the help/FAQ page. Empty = hidden." + (:help-url config "")) + +(def social-links + "Map of social platform links. Empty string = hidden." + (:social-links config {})) + +(def field-limits + "Max-length constraints for form input fields." + (:field-limits config {:notes 50000 :text 255 :number 7})) + +;; ─── Footer ──────────────────────────────────────────────────────── + +(def copyright-url + "URL for copyright holder name in footer. Empty = plain text." + (:copyright-url config "")) + +;; ─── UI Behavior ─────────────────────────────────────────────────── + +(def registration-logo-class + "CSS class for logo on registration/login page." + (:registration-logo-class config "h-55")) + +(def restrict-print-to-owner? + "Whether print button is restricted to character owner." + (:restrict-print-to-owner? config false)) diff --git a/src/cljs/orcpub/fork/integrations.cljs b/src/cljs/orcpub/fork/integrations.cljs new file mode 100644 index 000000000..74d784e4b --- /dev/null +++ b/src/cljs/orcpub/fork/integrations.cljs @@ -0,0 +1,127 @@ +(ns orcpub.fork.integrations + "Client-side integration hooks with minimal defaults. + Fork overrides: replace with full implementations. + + Lifecycle hooks (track-page-view!, on-app-mount!, etc.) are no-ops. + UI hooks provide basic defaults (e.g. supporter-link shows a Patreon + button when configured, share-links provides a single email link). + + Companion to integrations.clj (server-side head tags). + Server-side loads third-party scripts in <head>; + this namespace provides the in-app component hooks." + (:require [orcpub.fork.branding :as branding] + [orcpub.route-map :as routes])) + +;; ─── Page View Tracking ───────────────────────────────────────── +;; Called from the :route event handler (events.cljs), NOT from render +;; function bodies (which fire on every React re-render). + +(defn track-page-view! + "Track a page navigation. No-op by default. + Fork overrides: call your analytics provider here." + [_route]) + +;; ─── App Mount Hook ─────────────────────────────────────────────── +;; Called from the app root component-did-mount. Handles mount-time +;; integration setup (e.g. user identification, external service init). + +(defn on-app-mount! + "Mount-time integrations. Called once from app root component-did-mount. + Context map: {:user-tier :free|... :username str :email str} + Fork overrides: wire analytics user identification, etc." + [_context]) + +;; ─── Analytics Custom Variables ───────────────────────────────── +;; Called from render functions that need to tag analytics events +;; with page-specific data. + +(defn track-character-list! + "Tag the character list view with analytics data. No-op by default." + [_character-count _user-tier]) + +;; ─── Content Slot ────────────────────────────────────────────── +;; Hook for rendering supplementary content in the page body. +;; Self-gated: accepts user-tier, returns nil by default. +;; Fork overrides: return hiccup for banners, promotions, etc. + +(defn content-slot + "Supplementary content component. Returns nil (renders nothing) by default. + Fork overrides: return hiccup to render content in designated slots." + [_user-tier] + nil) + +;; ─── Supporter Link ────────────────────────────────────────── +;; Header supporter area. Shows a supporter button when a URL is configured. +;; Fork overrides: add tier badges, enhanced button styles, etc. + +(defn supporter-link + "Header supporter link. Shows Patreon/supporter button when URL is configured. + icon-fn: (fn [icon-name size css] hiccup) — render function, unused in default." + [_user-tier mobile? _icon-fn] + (when-let [url (not-empty (:patreon branding/social-links))] + [:a {:href url :target :_blank} + [:img.h-32.m-l-10.m-b-5.pointer.opacity-7.hover-opacity-full + {:src (if mobile? + "https://c5.patreon.com/external/logo/downloads_logomark_color_on_navy.png" + "https://c5.patreon.com/external/logo/become_a_patron_button.png")}]])) + +;; ─── Support Banner ────────────────────────────────────────── +;; Dismissable banner for site announcements or support messages. +;; Fork overrides: return hiccup for donation CTAs, announcements, etc. + +(defn support-banner + "Site announcement/support banner. Returns nil by default. + Opts: {:srd-message-closed? bool :hide-header-message? bool + :frame? bool :user-tier keyword :on-dismiss fn}" + [_opts] + nil) + +;; ─── PDF Sheet Styles ─────────────────────────────────────── +;; Returns the list of available character sheet styles for the dropdown. +;; Fork overrides: return tier-gated styles for premium users. + +(defn sheet-styles + "Available character sheet styles. Returns the default sheet only. + Fork overrides: return additional styles gated by user tier." + [_user-tier] + [{:title "Original 5e Character sheet" :value 1}]) + +;; ─── PDF Options Slot ──────────────────────────────────────── +;; Hook for additional content below PDF sheet options. +;; Fork overrides: return hiccup for premium feature promos, etc. + +(defn pdf-options-slot + "Additional content below PDF options. Returns nil by default." + [_user-tier] + nil) + +;; ─── Share Links ───────────────────────────────────────────── +;; Character sharing links. Default provides a single email share. +;; Fork overrides: add direct links, frame support, etc. + +(defn share-links + "Returns a seq of share-link hiccup elements for a character. + Default: single email share with dynamic protocol." + [id character-name] + [[:a.m-r-5.f-s-14 + {:href (str "mailto:?subject=My%20" (js/encodeURIComponent branding/app-name) "%20Character%20" + character-name + "&body=" js/window.location.protocol "//" + js/window.location.hostname + (when-let [p js/window.location.port] (when (seq p) (str ":" p))) + (routes/path-for routes/dnd-e5-char-page-route :id id))} + [:i.fa.fa-envelope.m-r-5] + "share"]]) + +(defn share-link-www + "Direct www share link. Default: same as email share. + Fork overrides: add frame support, direct URL, etc." + [id] + [:a.m-r-5.f-s-14 + {:href (str js/window.location.protocol "//" + js/window.location.hostname + (when-let [p js/window.location.port] (when (seq p) (str ":" p))) + (routes/path-for routes/dnd-e5-char-page-route :id id)) + :target "_blank"} + [:i.fa.fa-link.m-r-5] + "www"]) diff --git a/src/cljs/orcpub/fork/user_tier.cljs b/src/cljs/orcpub/fork/user_tier.cljs new file mode 100644 index 000000000..d54787b05 --- /dev/null +++ b/src/cljs/orcpub/fork/user_tier.cljs @@ -0,0 +1,16 @@ +(ns orcpub.fork.user-tier + "User tier abstraction for feature gating. + No tier system on the public repo — all users are :free. + Fork overrides: replace this file with real tier logic. + + UI code gates on :user-tier subscription. This keeps fork-specific + vocabulary out of shared view code." + (:require [re-frame.core :refer [reg-sub]])) + +;; ─── Tier Subscription ─────────────────────────────────────────── +;; Returns :free for all users. Forks override this file to derive +;; tier from their own user data model. + +(reg-sub + :user-tier + (fn [_ _] :free)) diff --git a/src/cljs/orcpub/ver.cljc b/src/cljs/orcpub/ver.cljc index 00edd5741..7b8ed922a 100644 --- a/src/cljs/orcpub/ver.cljc +++ b/src/cljs/orcpub/ver.cljc @@ -4,12 +4,13 @@ #?(:clj (defmacro build-date "Captures the current date at compile time (MM-dd-yyyy). - In CLJS, the macro runs on the JVM during compilation, - so the date reflects when the JS was built." + In CLJS, the macro runs on the JVM during compilation. + Uses TZ env var if set, falls back to JVM default timezone." [] - (.format (java.time.LocalDate/now) - (java.time.format.DateTimeFormatter/ofPattern "MM-dd-yyyy")))) + (let [tz (or (System/getenv "TZ") (str (java.time.ZoneId/systemDefault)))] + (.format (java.time.LocalDate/now (java.time.ZoneId/of tz)) + (java.time.format.DateTimeFormatter/ofPattern "MM-dd-yyyy"))))) -(defn version [] "2.4.0.28") +(defn version [] "2.6.0.0") (defn date [] (build-date)) -(defn description [] "Assault of the Last Stand") \ No newline at end of file +(defn description [] "Liberation of the Iron Coder - tinkan's last stand") \ No newline at end of file diff --git a/test/clj/orcpub/csp_test.clj b/test/clj/orcpub/csp_test.clj index 357ad2f17..e1cc6b21d 100644 --- a/test/clj/orcpub/csp_test.clj +++ b/test/clj/orcpub/csp_test.clj @@ -51,7 +51,27 @@ (testing "Dev mode adds Figwheel WebSocket" (let [header (csp/build-csp-header "test" :dev-mode? true)] (is (str/includes? header "ws://localhost:3449") - "Dev mode header should include Figwheel WebSocket")))) + "Dev mode header should include Figwheel WebSocket"))) + + (testing "Extra connect-src domains are merged" + (let [header (csp/build-csp-header "test" + :extra-connect-src ["https://analytics.example.com" + "https://cdn.example.com"])] + (is (str/includes? header "https://analytics.example.com") + "Should include extra connect-src domain") + (is (str/includes? header "https://cdn.example.com") + "Should include second extra connect-src domain"))) + + (testing "Extra frame-src domains add frame-src directive" + (let [header (csp/build-csp-header "test" + :extra-frame-src ["https://embed.example.com"])] + (is (str/includes? header "frame-src 'self' https://embed.example.com") + "Should add frame-src directive with extra domains"))) + + (testing "No frame-src directive when no extra frame-src provided" + (let [header (csp/build-csp-header "test")] + (is (not (str/includes? header "frame-src")) + "Should not include frame-src when no extras provided")))) (deftest csp-header-does-not-have-unsafe-inline-for-scripts (testing "Strict mode should NOT have unsafe-inline in script-src" diff --git a/test/clj/orcpub/routes_test.clj b/test/clj/orcpub/routes_test.clj index a49771782..843ae5216 100644 --- a/test/clj/orcpub/routes_test.clj +++ b/test/clj/orcpub/routes_test.clj @@ -5,6 +5,8 @@ [datomic.api :as d] [datomock.core :as dm] [io.pedestal.http :as http] + [buddy.sign.jwt :as jwt] + [environ.core :as environ] [orcpub.routes :as routes] [orcpub.dnd.e5.magic-items :as mi] [orcpub.dnd.e5.character :as char5e] @@ -249,3 +251,107 @@ :v 34 :zz {:v 78 :zzz [{:s "String"}]}}]}})))) + +;; ─── Unsubscribe Token Tests ──────────────────────────────────────── + +(deftest test-unsubscribe-token-roundtrip + (testing "Token encodes email and action, verifiable with signature" + (let [token (routes/unsubscribe-token "Test@Example.com") + claims (jwt/unsign token (environ/env :signature))] + (is (= "test@example.com" (:email claims)) + "Email should be lowercased") + (is (= "unsubscribe" (:action claims)))))) + +(deftest test-unsubscribe-handler + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + @(d/transact mocked-conn schema/all-schemas) + @(d/transact mocked-conn [{:orcpub.user/username "testy" + :orcpub.user/email "test@test.com" + :orcpub.user/send-updates? true}]) + + (testing "Valid token unsubscribes user" + (let [token (routes/unsubscribe-token "test@test.com") + resp (routes/unsubscribe {:query-params {:token token} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status resp)) + "Should redirect to success page") + (let [user (routes/user-for-email (d/db mocked-conn) "test@test.com")] + (is (false? (:orcpub.user/send-updates? user)) + "send-updates? should be false after unsubscribe")))) + + (testing "Idempotent — unsubscribing twice succeeds" + (let [token (routes/unsubscribe-token "test@test.com") + resp (routes/unsubscribe {:query-params {:token token} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status resp))))) + + (testing "Tampered token returns 400" + (let [resp (routes/unsubscribe {:query-params {:token "tampered.token.here"} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 400 (:status resp))))) + + (testing "Missing token returns 400" + (let [resp (routes/unsubscribe {:query-params {} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 400 (:status resp))))) + + (testing "Unknown email returns 400" + (let [token (routes/unsubscribe-token "nobody@test.com") + resp (routes/unsubscribe {:query-params {:token token} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 400 (:status resp)))))))) + +(deftest test-update-user-preferences + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + @(d/transact mocked-conn schema/all-schemas) + @(d/transact mocked-conn [{:orcpub.user/username "testy" + :orcpub.user/email "test@test.com" + :orcpub.user/send-updates? false}]) + + (testing "Toggle send-updates to true" + (let [resp (routes/update-user-preferences + {:transit-params {:send-updates? true} + :db (d/db mocked-conn) + :conn mocked-conn + :identity {:user "testy"}})] + (is (= 200 (:status resp))) + (let [user (routes/user-for-email (d/db mocked-conn) "test@test.com")] + (is (true? (:orcpub.user/send-updates? user)))))) + + (testing "Toggle send-updates back to false" + (let [resp (routes/update-user-preferences + {:transit-params {:send-updates? false} + :db (d/db mocked-conn) + :conn mocked-conn + :identity {:user "testy"}})] + (is (= 200 (:status resp))) + (let [user (routes/user-for-email (d/db mocked-conn) "test@test.com")] + (is (false? (:orcpub.user/send-updates? user)))))) + + (testing "Unknown user returns 400" + (let [resp (routes/update-user-preferences + {:transit-params {:send-updates? true} + :db (d/db mocked-conn) + :conn mocked-conn + :identity {:user "nonexistent"}})] + (is (= 400 (:status resp)))))))) + +(deftest test-user-body-includes-send-updates + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + @(d/transact mocked-conn schema/all-schemas) + @(d/transact mocked-conn [{:orcpub.user/username "testy" + :orcpub.user/email "test@test.com" + :orcpub.user/send-updates? true}]) + (let [db (d/db mocked-conn) + user (routes/user-for-email db "test@test.com") + body (routes/user-body db user)] + (is (true? (:send-updates? body)) + "user-body should include send-updates? field"))))) diff --git a/test/cljs/orcpub/dnd/e5/subs_test.cljs b/test/cljs/orcpub/dnd/e5/subs_test.cljs new file mode 100644 index 000000000..b850ddd60 --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/subs_test.cljs @@ -0,0 +1,132 @@ +(ns orcpub.dnd.e5.subs-test + "Tests for API-backed subscriptions (reg-sub-raw). + + These subscriptions gate HTTP calls on the presence of an auth token. + When no token exists in app-db, the subscription should return an empty + vector without making any network request (no :set-loading dispatch). + + PATTERN — testing a reg-sub-raw guard: + 1. Reset app-db to a known state (with or without token) + 2. Deref the subscription to trigger it + 3. Assert the return value and check for side effects (:loading)" + (:require [cljs.test :refer-macros [deftest testing is use-fixtures]] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.party :as party5e] + [orcpub.dnd.e5.folder :as folder5e] + ;; Side effect: registers subscriptions + [orcpub.dnd.e5.subs])) + +;; --------------------------------------------------------------------------- +;; Fixtures +;; --------------------------------------------------------------------------- + +(defn reset-db! + "Reset app-db before each test to prevent state leakage." + [] + (reset! app-db {}) + ;; Clear subscription cache so reg-sub-raw re-evaluates + (rf/clear-subscription-cache!)) + +(use-fixtures :each {:before reset-db!}) + +;; --------------------------------------------------------------------------- +;; ::char5e/characters — token guard +;; --------------------------------------------------------------------------- + +(deftest characters-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [::char5e/characters])] + ;; Should return empty vector (default) + (is (= [] result)) + ;; :set-loading should NOT have been dispatched (go block skipped) + (is (nil? (:loading @app-db)) + "No loading state should be set without token")))) + +(deftest characters-no-token-login-optional + (testing "login-optional? param doesn't bypass the token guard" + (reset! app-db {}) + (let [result @(rf/subscribe [::char5e/characters true])] + (is (= [] result)) + (is (nil? (:loading @app-db)))))) + +(deftest characters-with-token-empty-is-nil + (testing "with token but no cached data, returns []" + (reset! app-db {:user-data {:token "test-token"}}) + ;; The go block will fire and try HTTP (which will fail in test env), + ;; but the reaction should still return [] since no data is cached yet + (let [result @(rf/subscribe [::char5e/characters])] + (is (= [] result))))) + +;; --------------------------------------------------------------------------- +;; ::party5e/parties — token guard +;; --------------------------------------------------------------------------- + +(deftest parties-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [::party5e/parties])] + (is (= [] result)) + (is (nil? (:loading @app-db)) + "No loading state should be set without token")))) + +(deftest parties-no-token-login-optional + (testing "login-optional? param doesn't bypass the token guard" + (reset! app-db {}) + (let [result @(rf/subscribe [::party5e/parties true])] + (is (= [] result)) + (is (nil? (:loading @app-db)))))) + +(deftest parties-with-token-empty-is-nil + (testing "with token but no cached data, returns []" + (reset! app-db {:user-data {:token "test-token"}}) + (let [result @(rf/subscribe [::party5e/parties])] + (is (= [] result))))) + +;; --------------------------------------------------------------------------- +;; :user — token guard +;; +;; Previously checked [:user :token] which was the wrong path. +;; Fixed to check [:user-data :token] (same as auth-headers). +;; --------------------------------------------------------------------------- + +(deftest user-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [:user])] + (is (= [] result))))) + +(deftest user-stale-user-no-token-still-guarded + (testing "user key present but no token → still skips HTTP" + ;; This was the accidental-guard case: [:user] existed but [:user :token] + ;; didn't, so the old guard happened to block. The new guard checks the + ;; canonical path [:user-data :token] which is authoritative. + (reset! app-db {:user {:name "stale-user"}}) + (let [result @(rf/subscribe [:user])] + (is (= [] result))))) + +(deftest user-with-token-returns-default + (testing "with token, subscription fires (returns default until HTTP resolves)" + (reset! app-db {:user-data {:token "test-token"}}) + (let [result @(rf/subscribe [:user])] + (is (= [] result))))) + +;; --------------------------------------------------------------------------- +;; ::folder5e/folders — token guard +;; --------------------------------------------------------------------------- + +(deftest folders-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [::folder5e/folders])] + (is (= [] result)) + (is (nil? (:loading @app-db)) + "No loading state should be set without token")))) + +(deftest folders-with-token-returns-default + (testing "with token but no cached data, returns []" + (reset! app-db {:user-data {:token "test-token"}}) + (let [result @(rf/subscribe [::folder5e/folders])] + (is (= [] result))))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index e6ec3ec98..e1de6f0af 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -4,12 +4,14 @@ [orcpub.dnd.e5.event-utils-test] [orcpub.dnd.e5.compute-test] ;; CLJS-only re-frame integration tests - [orcpub.dnd.e5.events-test])) + [orcpub.dnd.e5.events-test] + [orcpub.dnd.e5.subs-test])) (defn -main [] (run-tests 'orcpub.dnd.e5.event-utils-test 'orcpub.dnd.e5.compute-test - 'orcpub.dnd.e5.events-test)) + 'orcpub.dnd.e5.events-test + 'orcpub.dnd.e5.subs-test)) ;; Auto-run when figwheel reloads (defn ^:after-load on-reload [] diff --git a/test/docker/README.md b/test/docker/README.md new file mode 100644 index 000000000..99bf665a7 --- /dev/null +++ b/test/docker/README.md @@ -0,0 +1,52 @@ +# Docker Setup Tests + +Manual and automated tests for `run` and `docker-user.sh`. + +## Scripts + +| Script | Purpose | +|---|---| +| `reset-test.sh [scenario]` | Reset environment to a clean test state | +| `test-upgrade.sh` | Automated upgrade tests (46 assertions, no Docker daemon needed) | + +## Reset Scenarios + +```bash +./test/docker/reset-test.sh fresh # No .env, templated compose (default) +./test/docker/reset-test.sh conflict # No .env, hardcoded compose vs transactor +./test/docker/reset-test.sh upgrade # Old v1 .env (free protocol, localhost) +./test/docker/reset-test.sh secrets # Modern .env with passwords, ready for --secrets +``` + +## Fixtures + +Test `.env` files representing real-world configurations: + +| Fixture | Scenario | +|---|---| +| `env-v1-free-localhost.env` | Original orcpub: Free protocol, localhost, password in URL | +| `env-v2-missing-vars.env` | Hand-edited: has some vars, missing others | +| `env-v2-password-in-url.env` | Early DMV: password still embedded in URL | +| `env-v2-password-mismatch.env` | URL password differs from DATOMIC_PASSWORD | +| `env-v2-windows-crlf.env` | Windows-edited with CRLF line endings | +| `env-v3-current.env` | Current format, already up to date | +| `env-production-like.env` | Production-like with SMTP and admin configured | +| `compose-hardcoded.yaml` | docker-compose.yaml with hardcoded values (no templating) | + +## Manual Test Flow + +```bash +# New install +./test/docker/reset-test.sh fresh +./run --auto +docker compose up --build -d +./docker-user.sh init + +# Upgrade + secrets +./test/docker/reset-test.sh upgrade +./run --upgrade-secrets --auto + +# Upgrade + swarm (conflict detection) +./test/docker/reset-test.sh conflict +./run --upgrade-swarm --auto +``` diff --git a/test/docker/fixtures/compose-hardcoded.yaml b/test/docker/fixtures/compose-hardcoded.yaml new file mode 100644 index 000000000..e93d5417e --- /dev/null +++ b/test/docker/fixtures/compose-hardcoded.yaml @@ -0,0 +1,19 @@ +# Fixture: docker-compose.yaml with hardcoded values (no .env templating) +# Simulates an admin who edited docker-compose.yaml directly instead of using .env +# Usage: cp this over docker-compose.yaml, remove .env, run --upgrade +# +# To test: make sure no .env exists, then: +# cp test/docker/fixtures/compose-hardcoded.yaml docker-compose.yaml +# ./run --upgrade --auto +# +# Expected: script extracts hardcoded values into a new .env, then upgrades it +services: + orcpub: + environment: + DATOMIC_URL: datomic:free://localhost:4334/orcpub?password=hardcoded123 + DATOMIC_PASSWORD: hardcoded123 + SIGNATURE: my-secret-signature-key + datomic: + environment: + ADMIN_PASSWORD: hardcoded-admin-pw + DATOMIC_PASSWORD: hardcoded123 diff --git a/test/docker/fixtures/env-production-like.env b/test/docker/fixtures/env-production-like.env new file mode 100644 index 000000000..b78fc9985 --- /dev/null +++ b/test/docker/fixtures/env-production-like.env @@ -0,0 +1,32 @@ +# Scenario: Production-like .env (mirrors dmv/hotfix origin) +# - Password embedded in DATOMIC_URL (the pattern live was using) +# - All vars present but using the old URL-embedded-password style +# - Has SMTP configured +# - Has admin user configured +# +# Expected --upgrade results: +# - Extract password from URL into DATOMIC_PASSWORD +# - Clean URL (remove ?password=) +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK + +PORT=8890 +ADMIN_PASSWORD=prod-admin-placeholder +DATOMIC_PASSWORD=prod-db-placeholder +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=prod-db-placeholder +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=prod-signature-placeholder-long-enough +CSP_POLICY=strict +DEV_MODE=false +EMAIL_SERVER_URL=smtp.production.example.com +EMAIL_ACCESS_KEY=smtp-user@production.example.com +EMAIL_SECRET_KEY=smtp-password-placeholder +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS=noreply@production.example.com +EMAIL_ERRORS_TO=ops@production.example.com +EMAIL_SSL=FALSE +EMAIL_TLS=TRUE +INIT_ADMIN_USER=dmadmin +INIT_ADMIN_EMAIL=admin@production.example.com +INIT_ADMIN_PASSWORD=admin-password-placeholder diff --git a/test/docker/fixtures/env-v1-free-localhost.env b/test/docker/fixtures/env-v1-free-localhost.env new file mode 100644 index 000000000..56f0b455a --- /dev/null +++ b/test/docker/fixtures/env-v1-free-localhost.env @@ -0,0 +1,21 @@ +# Scenario: Original orcpub .env (pre-DMV, pre-Docker rework) +# - Datomic Free protocol (not Pro) +# - localhost instead of Docker service name +# - Password embedded in DATOMIC_URL +# - No separate DATOMIC_PASSWORD +# - No SIGNATURE +# - No ADMIN_PASSWORD +# - Old email var names (EMAIL_HOST vs EMAIL_SERVER_URL) +# - Port 8080 (old default) +# +# Expected --upgrade results: +# - Extract password from URL into DATOMIC_PASSWORD +# - Clean URL (remove ?password=) +# - Convert datomic:free:// to datomic:dev:// (Datomic Pro) +# - Convert localhost to datomic (Docker service name) +# - Final URL: datomic:dev://datomic:4334/orcpub +# - Add SIGNATURE (auto-generate) +# - Add ADMIN_PASSWORD (auto-generate) + +DATOMIC_URL=datomic:free://localhost:4334/orcpub?password=changeme +PORT=8080 diff --git a/test/docker/fixtures/env-v2-missing-vars.env b/test/docker/fixtures/env-v2-missing-vars.env new file mode 100644 index 000000000..9f8cc7c57 --- /dev/null +++ b/test/docker/fixtures/env-v2-missing-vars.env @@ -0,0 +1,24 @@ +# Scenario: Partial .env — someone hand-edited and missed variables +# - Has DATOMIC_URL (clean, no embedded password) — good +# - Missing DATOMIC_PASSWORD entirely +# - Missing SIGNATURE entirely +# - Has ADMIN_PASSWORD +# +# Expected --upgrade results: +# - DATOMIC_URL: OK +# - Warn DATOMIC_PASSWORD not found (generate in --auto) +# - Warn SIGNATURE not found (generate in --auto) +# - ADMIN_PASSWORD: OK + +ADMIN_PASSWORD=MyAdm1nP4ss +DATOMIC_URL=datomic:dev://datomic:4334/orcpub +ALT_HOST=127.0.0.1 +PORT=8890 +EMAIL_SERVER_URL=smtp.example.com +EMAIL_ACCESS_KEY=user@example.com +EMAIL_SECRET_KEY=smtp-password-here +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS=noreply@example.com +EMAIL_ERRORS_TO=errors@example.com +EMAIL_SSL=FALSE +EMAIL_TLS=TRUE diff --git a/test/docker/fixtures/env-v2-password-in-url.env b/test/docker/fixtures/env-v2-password-in-url.env new file mode 100644 index 000000000..38a131bd3 --- /dev/null +++ b/test/docker/fixtures/env-v2-password-in-url.env @@ -0,0 +1,31 @@ +# Scenario: Early DMV .env (password still embedded in URL) +# - Datomic Pro dev protocol (correct) +# - Docker service name "datomic" (correct) +# - Password embedded in DATOMIC_URL (old pattern) +# - Has DATOMIC_PASSWORD but it matches URL password +# - Has SIGNATURE and ADMIN_PASSWORD +# +# Expected --upgrade results: +# - Extract password from URL into DATOMIC_PASSWORD +# - Clean URL (remove ?password=) +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK + +ADMIN_PASSWORD=MyAdm1nP4ss +DATOMIC_PASSWORD=MyD4tom1cP4ss +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=MyD4tom1cP4ss +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=a7b3c9d2e1f4a8b6c3d7e2f5a9b4c8d1 +PORT=8890 +EMAIL_SERVER_URL= +EMAIL_ACCESS_KEY= +EMAIL_SECRET_KEY= +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS= +EMAIL_ERRORS_TO= +EMAIL_SSL=FALSE +EMAIL_TLS=FALSE +INIT_ADMIN_USER=admin +INIT_ADMIN_EMAIL=admin@example.com +INIT_ADMIN_PASSWORD=Admin123 diff --git a/test/docker/fixtures/env-v2-password-mismatch.env b/test/docker/fixtures/env-v2-password-mismatch.env new file mode 100644 index 000000000..c45238de5 --- /dev/null +++ b/test/docker/fixtures/env-v2-password-mismatch.env @@ -0,0 +1,18 @@ +# Scenario: Early DMV .env with mismatched passwords +# - Password in URL differs from DATOMIC_PASSWORD variable +# - This happens when someone changed one but not the other +# +# Expected --upgrade results: +# - Warn about mismatch +# - Use URL password (that's what the transactor is actually using) +# - Clean URL +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK + +ADMIN_PASSWORD=MyAdm1nP4ss +DATOMIC_PASSWORD=old-password-I-forgot-to-update +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=the-REAL-password +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=a7b3c9d2e1f4a8b6c3d7e2f5a9b4c8d1 +PORT=8890 diff --git a/test/docker/fixtures/env-v2-windows-crlf.env b/test/docker/fixtures/env-v2-windows-crlf.env new file mode 100644 index 000000000..c6050a71d --- /dev/null +++ b/test/docker/fixtures/env-v2-windows-crlf.env @@ -0,0 +1,17 @@ +# Scenario: Windows-edited .env with CRLF line endings +# - Every line has \r\n instead of \n +# - Password embedded in URL (old pattern) +# - This tests that our CRLF stripping works in --upgrade +# +# Expected --upgrade results: +# - CRLF handled transparently +# - Extract password from URL (no trailing \r corruption) +# - All checks pass same as env-v2-password-in-url + +ADMIN_PASSWORD=WinAdm1nP4ss +DATOMIC_PASSWORD=WinD4tom1cP4ss +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=WinD4tom1cP4ss +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=w1nd0ws3d1t3d4s1gn4tur3k3y0000 +PORT=8890 diff --git a/test/docker/fixtures/env-v3-current.env b/test/docker/fixtures/env-v3-current.env new file mode 100644 index 000000000..73f6e6ebe --- /dev/null +++ b/test/docker/fixtures/env-v3-current.env @@ -0,0 +1,30 @@ +# Scenario: Current format — already up to date +# - Clean URL (no embedded password) +# - All required vars present +# - Should pass --upgrade with "No changes needed" +# +# Expected --upgrade results: +# - DATOMIC_URL: OK +# - DATOMIC_PASSWORD: OK +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK +# - No changes needed + +ADMIN_PASSWORD=xK7mN2pQ9rT4vW8yB3cF6hJ +DATOMIC_PASSWORD=aL5nR8sU2wY4bD7fH9jM3qT6 +DATOMIC_URL=datomic:dev://datomic:4334/orcpub +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=gZ4kP7tX2vA8dG5jN9qS3wF6mC1rY8bH +PORT=8890 +EMAIL_SERVER_URL= +EMAIL_ACCESS_KEY= +EMAIL_SECRET_KEY= +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS= +EMAIL_ERRORS_TO= +EMAIL_SSL=FALSE +EMAIL_TLS=FALSE +INIT_ADMIN_USER=admin +INIT_ADMIN_EMAIL=admin@example.com +INIT_ADMIN_PASSWORD=TestAdmin123 diff --git a/test/docker/reset-test.sh b/test/docker/reset-test.sh new file mode 100755 index 000000000..66ba01fd8 --- /dev/null +++ b/test/docker/reset-test.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Reset Docker test environment to a clean state. +# Usage: ./test/docker/reset-test.sh [scenario] +# +# Scenarios: +# fresh — no .env, templated compose (default) +# conflict — no .env, hardcoded compose values that conflict with transactor +# upgrade — old-format .env (v1-free-localhost), templated compose +# secrets — modern .env with passwords, ready for --secrets +# +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$SCRIPT_DIR" + +scenario="${1:-fresh}" + +# --- Clean everything --- +rm -f .env .env.backup.* .env.secrets.backup +rm -rf secrets/ docker-compose.secrets.yaml +docker secret rm datomic_password admin_password signature 2>/dev/null || true +git checkout docker-compose.yaml 2>/dev/null || true +git checkout docker/transactor.properties.template 2>/dev/null || true + +# H2 database has ADMIN_PASSWORD locked in at creation. A fresh .env with +# new passwords will crash the transactor unless the DB is wiped or backed up. +if [ -f data/db/datomic.mv.db ]; then + echo "" + echo "Existing H2 database found in data/db/." + echo "The admin password is locked into this database." + echo "" + echo " 1) Back up to data/db.bak/ and wipe (can restore later)" + echo " 2) Wipe data/db/ (permanent)" + echo " 3) Keep it (next test may fail if passwords don't match)" + echo "" + printf "Choice [1]: " + read -r _choice </dev/tty 2>/dev/null || _choice="1" + _choice="${_choice:-1}" + case "$_choice" in + 1) + rm -rf data/db.bak + mv data/db data/db.bak + mkdir -p data/db + echo "Moved data/db/ → data/db.bak/" + ;; + 2) + rm -rf data/db/* + echo "Wiped data/db/" + ;; + 3) + echo "Keeping existing database" + ;; + *) + echo "Invalid choice, keeping existing database" + ;; + esac +fi + +case "$scenario" in + fresh) + echo "Reset: fresh (no .env, templated compose)" + ;; + conflict) + echo "Reset: conflict (hardcoded compose vs transactor)" + sed -i 's|DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub}|DATOMIC_URL: datomic:free://localhost:4334/orcpub?password=compose-pass|' docker-compose.yaml + sed -i 's|DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me}|DATOMIC_PASSWORD: compose-pass|g' docker-compose.yaml + sed -i 's|SIGNATURE: ${SIGNATURE:-change-me-to-something-unique}|SIGNATURE: compose-signature-value|' docker-compose.yaml + sed -i 's|ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin}|ADMIN_PASSWORD: compose-admin-pw|' docker-compose.yaml + ;; + upgrade) + echo "Reset: upgrade (old v1 .env)" + cp test/docker/fixtures/env-v1-free-localhost.env .env + ;; + secrets) + echo "Reset: secrets (modern .env with passwords)" + cp test/docker/fixtures/env-v3-current.env .env + ;; + *) + echo "Unknown scenario: $scenario" + echo "Options: fresh, conflict, upgrade, secrets" + exit 1 + ;; +esac +echo "Done." diff --git a/test/docker/test-upgrade.sh b/test/docker/test-upgrade.sh new file mode 100755 index 000000000..17587ec88 --- /dev/null +++ b/test/docker/test-upgrade.sh @@ -0,0 +1,497 @@ +#!/usr/bin/env bash +# +# Test run --upgrade against historical .env formats. +# +# Copies each fixture to a temp directory as .env, runs --upgrade --auto, +# then validates the result. No Docker daemon needed — only tests the +# .env transformation logic. +# +# Usage: +# ./test/docker/test-upgrade.sh # Run all fixtures +# ./test/docker/test-upgrade.sh <name> # Run one fixture (e.g., "v1-free-localhost") + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +FIXTURE_DIR="${SCRIPT_DIR}/fixtures" +SETUP_SCRIPT="${PROJECT_ROOT}/run" + +# Colors +green='\033[0;32m' +red='\033[0;31m' +yellow='\033[1;33m' +cyan='\033[0;36m' +reset='\033[0m' + +pass() { printf '%sPASS%s %s\n' "$green" "$reset" "$*"; } +fail() { printf '%sFAIL%s %s\n' "$red" "$reset" "$*"; FAILURES=$((FAILURES + 1)); } +info() { printf '%sINFO%s %s\n' "$cyan" "$reset" "$*"; } +warn() { printf '%sWARN%s %s\n' "$yellow" "$reset" "$*"; } + +TESTS=0 +FAILURES=0 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Read a value from an .env file (same logic as run) +read_val() { + grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' +} + +# Check a value in the upgraded .env +assert_val() { + local var="$1" expected="$2" file="$3" label="${4:-}" + local actual + actual=$(read_val "$var" "$file") + + if [ "$actual" = "$expected" ]; then + pass "${label}${var} = ${expected}" + else + fail "${label}${var}: expected '${expected}', got '${actual}'" + fi + TESTS=$((TESTS + 1)) +} + +# Check that a variable exists (any value) +assert_exists() { + local var="$1" file="$2" label="${3:-}" + if grep -q "^${var}=" "$file"; then + pass "${label}${var} exists" + else + fail "${label}${var} missing" + fi + TESTS=$((TESTS + 1)) +} + +# Check that a variable does NOT exist +assert_missing() { + local var="$1" file="$2" label="${3:-}" + if grep -q "^${var}=" "$file"; then + fail "${label}${var} should not exist" + else + pass "${label}${var} absent (expected)" + fi + TESTS=$((TESTS + 1)) +} + +# Check that a value does NOT contain a substring +assert_not_contains() { + local var="$1" substring="$2" file="$3" label="${4:-}" + local actual + actual=$(read_val "$var" "$file") + if [[ "$actual" == *"$substring"* ]]; then + fail "${label}${var} should not contain '${substring}' (got '${actual}')" + else + pass "${label}${var} does not contain '${substring}'" + fi + TESTS=$((TESTS + 1)) +} + +# Check that output contains a string +assert_output_contains() { + local needle="$1" output="$2" label="${3:-}" + if echo "$output" | grep -qF "$needle"; then + pass "${label}output contains '${needle}'" + else + fail "${label}output missing '${needle}'" + fi + TESTS=$((TESTS + 1)) +} + +# --------------------------------------------------------------------------- +# Run one fixture through --upgrade --auto +# --------------------------------------------------------------------------- + +run_fixture() { + local fixture_file="$1" + local fixture_name + fixture_name=$(basename "$fixture_file" .env | sed 's/^env-//') + + local tmpdir + tmpdir=$(mktemp -d) + # Don't trap RETURN — we clean up at the end of the function + + # Copy fixture as .env and the setup script into tmpdir. + # run uses SCRIPT_DIR (dirname of the script) to find .env, + # so the script must live next to the .env for it to find the fixture. + cp "$fixture_file" "${tmpdir}/.env" + cp "$SETUP_SCRIPT" "${tmpdir}/run" + + local output + output=$(bash "${tmpdir}/run" --upgrade --auto 2>&1) || true + + local result_env="${tmpdir}/.env" + local label="[${fixture_name}] " + + printf '\n%s--- %s ---%s\n' "$cyan" "$fixture_name" "$reset" + + case "$fixture_name" in + + v1-free-localhost) + # Password extracted from URL + assert_exists DATOMIC_PASSWORD "$result_env" "$label" + assert_val DATOMIC_PASSWORD "changeme" "$result_env" "$label" + # URL cleaned: no password, free→dev, localhost→datomic + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + assert_not_contains DATOMIC_URL "datomic:free://" "$result_env" "$label" + assert_not_contains DATOMIC_URL "localhost" "$result_env" "$label" + assert_val DATOMIC_URL "datomic:dev://datomic:4334/orcpub" "$result_env" "$label" + # Output mentions what was fixed + assert_output_contains "datomic:free://" "$output" "$label" + assert_output_contains "localhost" "$output" "$label" + # SIGNATURE and ADMIN_PASSWORD auto-generated + assert_exists SIGNATURE "$result_env" "$label" + assert_exists ADMIN_PASSWORD "$result_env" "$label" + ;; + + v2-password-in-url) + # Password extracted, URL cleaned + assert_val DATOMIC_PASSWORD "MyD4tom1cP4ss" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + # Existing vars untouched + assert_val ADMIN_PASSWORD "MyAdm1nP4ss" "$result_env" "$label" + assert_val SIGNATURE "a7b3c9d2e1f4a8b6c3d7e2f5a9b4c8d1" "$result_env" "$label" + assert_val PORT "8890" "$result_env" "$label" + ;; + + v2-password-mismatch) + # URL password wins over DATOMIC_PASSWORD + assert_val DATOMIC_PASSWORD "the-REAL-password" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + # Warn about mismatch + assert_output_contains "differs" "$output" "$label" + ;; + + v2-missing-vars) + # URL was already clean + assert_val DATOMIC_URL "datomic:dev://datomic:4334/orcpub" "$result_env" "$label" + # Missing vars auto-generated + assert_exists DATOMIC_PASSWORD "$result_env" "$label" + assert_exists SIGNATURE "$result_env" "$label" + # Existing var untouched + assert_val ADMIN_PASSWORD "MyAdm1nP4ss" "$result_env" "$label" + # SMTP config preserved + assert_val EMAIL_SERVER_URL "smtp.example.com" "$result_env" "$label" + ;; + + v2-windows-crlf) + # CRLF shouldn't corrupt values + assert_val DATOMIC_PASSWORD "WinD4tom1cP4ss" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + assert_val SIGNATURE "w1nd0ws3d1t3d4s1gn4tur3k3y0000" "$result_env" "$label" + assert_val ADMIN_PASSWORD "WinAdm1nP4ss" "$result_env" "$label" + ;; + + v3-current) + # No changes needed + assert_output_contains "No changes needed" "$output" "$label" + # All values preserved exactly + assert_val DATOMIC_PASSWORD "aL5nR8sU2wY4bD7fH9jM3qT6" "$result_env" "$label" + assert_val DATOMIC_URL "datomic:dev://datomic:4334/orcpub" "$result_env" "$label" + assert_val SIGNATURE "gZ4kP7tX2vA8dG5jN9qS3wF6mC1rY8bH" "$result_env" "$label" + assert_val ADMIN_PASSWORD "xK7mN2pQ9rT4vW8yB3cF6hJ" "$result_env" "$label" + ;; + + production-like) + # Password extracted, URL cleaned + assert_val DATOMIC_PASSWORD "prod-db-placeholder" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + # Non-password config preserved + assert_val ADMIN_PASSWORD "prod-admin-placeholder" "$result_env" "$label" + assert_val SIGNATURE "prod-signature-placeholder-long-enough" "$result_env" "$label" + assert_val EMAIL_SERVER_URL "smtp.production.example.com" "$result_env" "$label" + assert_val INIT_ADMIN_USER "dmadmin" "$result_env" "$label" + assert_val DEV_MODE "false" "$result_env" "$label" + ;; + + *) + warn "No assertions defined for fixture: $fixture_name" + ;; + esac + + rm -rf "$tmpdir" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if [ ! -f "$SETUP_SCRIPT" ]; then + echo "run not found at: $SETUP_SCRIPT" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Test --secrets compose override generation +# --------------------------------------------------------------------------- + +run_secrets_test() { + printf '\n%s--- secrets (compose override) ---%s\n' "$cyan" "$reset" + + local tmpdir + tmpdir=$(mktemp -d) + + # Use v3-current as the base .env (already up to date) + cp "${FIXTURE_DIR}/env-v3-current.env" "${tmpdir}/.env" + cp "$SETUP_SCRIPT" "${tmpdir}/run" + + local output + output=$(bash "${tmpdir}/run" --secrets --auto 2>&1) || true + + local label="[secrets] " + + # Secret files created + if [ -f "${tmpdir}/secrets/datomic_password" ]; then + pass "${label}secrets/datomic_password created" + else + fail "${label}secrets/datomic_password missing" + fi + TESTS=$((TESTS + 1)) + + if [ -f "${tmpdir}/secrets/signature" ]; then + pass "${label}secrets/signature created" + else + fail "${label}secrets/signature missing" + fi + TESTS=$((TESTS + 1)) + + # Compose override created + if [ -f "${tmpdir}/docker-compose.secrets.yaml" ]; then + pass "${label}docker-compose.secrets.yaml created" + else + fail "${label}docker-compose.secrets.yaml missing" + fi + TESTS=$((TESTS + 1)) + + # Compose override has file-based secrets (not external) + if [ -f "${tmpdir}/docker-compose.secrets.yaml" ]; then + assert_output_contains "file: ./secrets/datomic_password" \ + "$(cat "${tmpdir}/docker-compose.secrets.yaml")" "$label" + assert_output_contains "file: ./secrets/signature" \ + "$(cat "${tmpdir}/docker-compose.secrets.yaml")" "$label" + fi + + # COMPOSE_FILE added to .env + assert_exists COMPOSE_FILE "${tmpdir}/.env" "$label" + assert_val COMPOSE_FILE "docker-compose.yaml:docker-compose.secrets.yaml" \ + "${tmpdir}/.env" "$label" + + rm -rf "$tmpdir" +} + +# --------------------------------------------------------------------------- +# Swarm tests — generation, extraction, upgrade, .env.portainer +# --------------------------------------------------------------------------- + +run_swarm_tests() { + local label="[swarm] " + local tmpdir + tmpdir=$(mktemp -d) + + # Create a minimal .env + cat > "${tmpdir}/.env" <<'SWARMENV' +ADMIN_PASSWORD=swarm_admin_test +DATOMIC_PASSWORD=swarm_datomic_test +SIGNATURE=swarm_sig_test +PORT=8890 +ALT_HOST=datomic +ENCRYPT_CHANNEL=true +EMAIL_SSL=TRUE +CSP_POLICY=relaxed +SWARMENV + + # Source run's helpers (swarm.sh gets sourced by run) + SCRIPT_DIR="$tmpdir" + SCRIPT_NAME="run" + ENV_FILE="${tmpdir}/.env" + # Helpers needed by swarm.sh + color_green=$'\033[0;32m' + color_yellow=$'\033[1;33m' + color_red=$'\033[0;31m' + color_cyan=$'\033[0;36m' + color_magenta=$'\033[0;35m' + color_reset=$'\033[0m' + color_bold=$'\033[1m' + color_dim=$'\033[2m' + read_env_val() { grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true; } + + # Source swarm functions + # shellcheck source=../../scripts/swarm.sh + . "${PROJECT_ROOT}/scripts/swarm.sh" + SWARM_COMPOSE="${tmpdir}/docker-compose.swarm.yaml" + SWARM_PORTAINER_ENV="${tmpdir}/.env.portainer" + + # --- Test: Fresh generation --- + printf '\n%s--- swarm (fresh) ---%s\n' "$cyan" "$reset" + + generate_swarm_compose "$SWARM_COMPOSE" "false" + if [ -f "$SWARM_COMPOSE" ]; then + pass "${label}compose generated" + else + fail "${label}compose not generated" + fi + TESTS=$((TESTS + 1)) + + # File should contain service blocks + if grep -q '^ datomic:' "$SWARM_COMPOSE"; then + pass "${label}has datomic service" + else + fail "${label}missing datomic service" + fi + TESTS=$((TESTS + 1)) + + if grep -q '^ orcpub:' "$SWARM_COMPOSE"; then + pass "${label}has orcpub service" + else + fail "${label}missing orcpub service" + fi + TESTS=$((TESTS + 1)) + + # No double braces (regression test for nested ${} expansion bug) + if grep -q '}}' "$SWARM_COMPOSE"; then + fail "${label}double braces found (expansion bug)" + else + pass "${label}no double braces" + fi + TESTS=$((TESTS + 1)) + + # Has deploy sections + if grep -q 'restart_policy:' "$SWARM_COMPOSE"; then + pass "${label}has restart_policy" + else + fail "${label}missing restart_policy" + fi + TESTS=$((TESTS + 1)) + + if grep -q 'update_config:' "$SWARM_COMPOSE"; then + pass "${label}has update_config" + else + fail "${label}missing update_config" + fi + TESTS=$((TESTS + 1)) + + # --- Test: .env.portainer --- + generate_env_portainer "$ENV_FILE" "$SWARM_PORTAINER_ENV" + if [ -f "$SWARM_PORTAINER_ENV" ]; then + pass "${label}.env.portainer generated" + else + fail "${label}.env.portainer not generated" + fi + TESTS=$((TESTS + 1)) + + # No comments, no blank lines, no quotes in portainer env + if grep -q '^\s*#' "$SWARM_PORTAINER_ENV"; then + fail "${label}.env.portainer has comments" + else + pass "${label}.env.portainer clean (no comments)" + fi + TESTS=$((TESTS + 1)) + + if grep -q '^\s*$' "$SWARM_PORTAINER_ENV"; then + fail "${label}.env.portainer has blank lines" + else + pass "${label}.env.portainer clean (no blanks)" + fi + TESTS=$((TESTS + 1)) + + # --- Test: Extract + roundtrip --- + printf '\n%s--- swarm (upgrade roundtrip) ---%s\n' "$cyan" "$reset" + + extract_swarm_config "$SWARM_COMPOSE" + if [ $? -eq 0 ]; then + pass "${label}extraction succeeded" + else + fail "${label}extraction failed" + fi + TESTS=$((TESTS + 1)) + + # Backup and regenerate + local backup="${SWARM_COMPOSE}.backup" + cp "$SWARM_COMPOSE" "$backup" + generate_swarm_compose "$SWARM_COMPOSE" "true" + + # Regenerated file should still have services + if grep -q '^ datomic:' "$SWARM_COMPOSE" && grep -q '^ orcpub:' "$SWARM_COMPOSE"; then + pass "${label}upgrade preserves services" + else + fail "${label}upgrade lost services" + fi + TESTS=$((TESTS + 1)) + + # --- Test: Upgrade adds new env vars --- + # Remove CSP_POLICY from old file, verify it appears in regenerated + sed -i '/CSP_POLICY/d' "$backup" + extract_swarm_config "$backup" + generate_swarm_compose "$SWARM_COMPOSE" "true" + if grep -q 'CSP_POLICY:' "$SWARM_COMPOSE"; then + pass "${label}upgrade adds new env var (CSP_POLICY)" + else + fail "${label}upgrade missing new env var (CSP_POLICY)" + fi + TESTS=$((TESTS + 1)) + + # --- Test: activate_swarm_secrets --- + printf '\n%s--- swarm (secrets activation) ---%s\n' "$cyan" "$reset" + + # Start with fresh compose + generate_swarm_compose "$SWARM_COMPOSE" "false" + # Secrets should be commented + if grep -q '^# secrets:' "$SWARM_COMPOSE"; then + pass "${label}secrets initially commented" + else + fail "${label}secrets not commented" + fi + TESTS=$((TESTS + 1)) + + activate_swarm_secrets "$SWARM_COMPOSE" + # Top-level secrets: should now be uncommented + if grep -q '^secrets:' "$SWARM_COMPOSE"; then + pass "${label}secrets uncommented after activation" + else + fail "${label}secrets still commented after activation" + fi + TESTS=$((TESTS + 1)) + + rm -rf "$tmpdir" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Run specific fixture or all +if [ $# -gt 0 ]; then + if [ "$1" = "secrets" ]; then + run_secrets_test + elif [ "$1" = "swarm" ]; then + run_swarm_tests + else + fixture="${FIXTURE_DIR}/env-${1}.env" + if [ ! -f "$fixture" ]; then + echo "Fixture not found: $fixture" >&2 + echo "Available:" >&2 + ls "$FIXTURE_DIR"/*.env 2>/dev/null | sed 's/.*env-/ /;s/\.env$//' >&2 + echo " secrets" >&2 + exit 1 + fi + run_fixture "$fixture" + fi +else + for f in "$FIXTURE_DIR"/env-*.env; do + run_fixture "$f" + done + run_secrets_test + run_swarm_tests +fi + +# Summary +printf '\n%s===%s Results: %d tests, ' "$cyan" "$reset" "$TESTS" +if [ "$FAILURES" -eq 0 ]; then + printf '%s0 failures%s\n' "$green" "$reset" +else + printf '%s%d failure(s)%s\n' "$red" "$FAILURES" "$reset" + exit 1 +fi diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index f50c6acaa..080fbae0c 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -72,7 +72,8 @@ routes/reset-password-page-route views/password-reset-page routes/password-reset-success-route views/password-reset-success routes/password-reset-expired-route views/password-reset-expired-page - routes/password-reset-used-route views/password-reset-used-page}) + routes/password-reset-used-route views/password-reset-used-page + routes/unsubscribe-success-route views/unsubscribe-success}) (defn handle-url-change [_] (let [route (when js/window.location