From 415bffa04cd82ea16c1803b67ee432a9ebd4165e Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:42:32 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20zero-config=20first=20run=20=E2=80=94?= =?UTF-8?q?=20in-browser=20admin=20bootstrap=20+=20auto-generated=20secret?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh installs need nothing in .env. See PR description for the full feature. --- .env.example | 28 ++- README.md | 21 +- SIMPLE_SETUP.md | 13 ++ .../integration/setupService.test.js | 161 +++++++++++++ backend/migrations/core/001_init.js | 11 +- backend/server.js | 20 ++ backend/src/routes/setup.js | 55 +++++ backend/src/services/setupService.js | 142 ++++++++++++ backend/wait-for-db.sh | 15 ++ docker-compose.production.yml | 54 ++++- docker-compose.yml | 57 ++++- frontend/src/App.tsx | 9 + frontend/src/i18n/locales/de.json | 24 ++ frontend/src/i18n/locales/en.json | 24 ++ frontend/src/pages/SetupPage.tsx | 211 ++++++++++++++++++ frontend/src/pages/admin/AdminLoginPage.tsx | 13 ++ frontend/src/services/setup.service.ts | 33 +++ 17 files changed, 869 insertions(+), 22 deletions(-) create mode 100644 backend/__tests__/integration/setupService.test.js create mode 100644 backend/src/routes/setup.js create mode 100644 backend/src/services/setupService.js create mode 100644 frontend/src/pages/SetupPage.tsx create mode 100644 frontend/src/services/setup.service.ts diff --git a/.env.example b/.env.example index 197fbc56..33e4d6f5 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,11 @@ # Environment NODE_ENV=production -# JWT Secret (generate with: openssl rand -base64 64) -JWT_SECRET=your_very_long_random_jwt_secret_here +# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run +# (Docker: the secrets-init service writes it to a private volume and reuses it +# across restarts). Set it explicitly only to pin your own value. +# Generate one with: openssl rand -base64 64 +#JWT_SECRET=your_very_long_random_jwt_secret_here # Auth cookie Secure flag # unset - default: follows NODE_ENV (production=true, dev=false) @@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here # Database Configuration (PostgreSQL) DATABASE_CLIENT=pg DB_USER=picpeak +# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run +# (Docker). Set it explicitly to pin your own, e.g. for an external database. # IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution # If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word) -DB_PASSWORD=your_secure_postgres_password_here +#DB_PASSWORD=your_secure_postgres_password_here DB_NAME=picpeak_prod # Redis Configuration +# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker). # IMPORTANT: Same warning applies - avoid $ or escape as $$ -REDIS_PASSWORD=your_secure_redis_password_here +#REDIS_PASSWORD=your_secure_redis_password_here -# Admin Account (initial setup) -ADMIN_USERNAME=admin -ADMIN_EMAIL=admin@yourdomain.com -ADMIN_PASSWORD=your_secure_admin_password_here +# Admin Account (initial setup) — OPTIONAL +# Leave these unset (default) to create your admin IN THE BROWSER on first run: +# open /admin and PicPeak shows a setup screen. The one-time setup token is +# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`) +# and saved to data/SETUP_TOKEN. +# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy; +# credentials written to data/ADMIN_CREDENTIALS.txt). +#ADMIN_USERNAME=admin +#ADMIN_EMAIL=admin@yourdomain.com +#ADMIN_PASSWORD=your_secure_admin_password_here # Email Configuration # For Gmail: use app-specific password diff --git a/README.md b/README.md index 1fef0e9c..11348bf6 100644 --- a/README.md +++ b/README.md @@ -94,18 +94,31 @@ Get PicPeak running in under 5 minutes: git clone https://github.com/PicPeak/picpeak.git cd picpeak -# Copy environment template +# Copy the environment template — the defaults work out of the box. +# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the +# admin account is created in the browser (see below). Edit .env only to +# customise (domain, SMTP, storage paths, …) — nothing is required. cp .env.example .env -# Edit configuration (required: JWT_SECRET) -nano .env - # Start with Docker Compose docker compose up -d # Access at http://localhost:3000 ``` +### First run — create your admin account + +On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`: + +1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`. +2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`): + ```bash + docker compose logs backend | grep -i "setup token" + ``` +3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists. + +> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`). + Note on Docker file permissions - The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs. - If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions). diff --git a/SIMPLE_SETUP.md b/SIMPLE_SETUP.md index c5b1c813..9b6d3da8 100644 --- a/SIMPLE_SETUP.md +++ b/SIMPLE_SETUP.md @@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \ - `picpeak-workers` - Background workers - `caddy` - Web server (optional) +## 🔑 First Login — Create Your Admin + +If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password. + +If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser: + +1. Open `http://your-server:3000/admin` — you'll land on a setup screen. +2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`): + ```bash + docker compose logs backend | grep -i "setup token" + ``` +3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists. + ## 🌐 Access Methods ### Direct Access (Simplest) diff --git a/backend/__tests__/integration/setupService.test.js b/backend/__tests__/integration/setupService.test.js new file mode 100644 index 00000000..d27a211d --- /dev/null +++ b/backend/__tests__/integration/setupService.test.js @@ -0,0 +1,161 @@ +'use strict'; + +// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service +// so setupService shares this test's db instance (see crmDb.js note). +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!'; + +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); +const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb'); + +let db; +let cleanup; +let tmpDir; +let setupService; +let getAppSetting; +let upsertAppSetting; +let app; + +const VALID_PW = 'Str0ng-Passw0rd!'; + +// bootCrmDb MUST run before any require of db.js (directly or transitively via a +// service/util), or db.js binds to the default path instead of the temp one. +beforeAll(async () => { + ({ db, cleanup, tmpDir } = await bootCrmDb()); + process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir + setupService = require('../../src/services/setupService'); + ({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings')); + app = buildRouteApp('/api/setup', require('../../src/routes/setup')); +}, 60000); + +afterAll(async () => { + await cleanup(); +}); + +beforeEach(async () => { + await db('admin_users').del(); + await db('app_settings').where({ setting_key: 'setup_token' }).del(); +}); + +describe('setupService (first-run bootstrap)', () => { + it('reports needsAdmin while no admin exists', async () => { + expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false }); + }); + + it('generates and persists a one-time token while no admin exists', async () => { + const token = await setupService.ensureSetupToken(); + expect(token).toEqual(expect.any(String)); + expect(token.length).toBeGreaterThan(20); + expect(await getAppSetting('setup_token')).toBe(token); + // Idempotent — a second call returns the same token, not a fresh one. + expect(await setupService.ensureSetupToken()).toBe(token); + }); + + it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => { + // Regression guard for the SQLite-only miss: a bare token string is rejected + // by Postgres jsonb ("invalid input syntax for type json"). The raw column + // value must be JSON-parseable and round-trip back to the token. + const token = await setupService.ensureSetupToken(); + const row = await db('app_settings').where({ setting_key: 'setup_token' }).first(); + expect(() => JSON.parse(row.setting_value)).not.toThrow(); + expect(JSON.parse(row.setting_value)).toBe(token); + }); + + it('rejects a wrong token', async () => { + await setupService.ensureSetupToken(); + await expect( + setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW }) + ).rejects.toMatchObject({ statusCode: 400 }); + expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false }); + }); + + it('rejects a weak password', async () => { + const token = await setupService.ensureSetupToken(); + await expect( + setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' }) + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => { + const token = await setupService.ensureSetupToken(); + const result = await setupService.createInitialAdmin({ + token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7', + }); + + expect(result.user.email).toBe('owner@example.com'); // normalised + expect(result.user.role.name).toBe('super_admin'); + expect(result.token).toEqual(expect.any(String)); + + const row = await db('admin_users').first(); + const role = await db('roles').where({ name: 'super_admin' }).first(); + expect(row.role_id).toBe(role.id); + expect(row.password_hash).not.toBe(VALID_PW); // hashed + + // One-time: token burned, status now complete. + expect(await getAppSetting('setup_token')).toBeFalsy(); + expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true }); + }); + + it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => { + const tokenFile = path.join(tmpDir, 'SETUP_TOKEN'); + const token = await setupService.ensureSetupToken(); + expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token); + await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW }); + expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed + }); + + it('refuses to create a second admin (setup already complete)', async () => { + const token = await setupService.ensureSetupToken(); + await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW }); + await expect( + setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW }) + ).rejects.toMatchObject({ statusCode: 409 }); + }); + + it('ensureSetupToken clears any stale token once an admin exists', async () => { + const token = await setupService.ensureSetupToken(); + await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW }); + // Simulate a stale token left in settings, then re-run the boot hook. + await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string'); + expect(await setupService.ensureSetupToken()).toBeNull(); + expect(await getAppSetting('setup_token')).toBeFalsy(); + }); +}); + +describe('setup routes', () => { + it('GET /api/setup/status reports needsAdmin', async () => { + const res = await request(app).get('/api/setup/status'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ needsAdmin: true, complete: false }); + }); + + it('POST /api/setup/admin rejects a wrong token (400)', async () => { + await setupService.ensureSetupToken(); + const res = await request(app) + .post('/api/setup/admin') + .send({ token: 'nope', email: 'a@b.co', password: VALID_PW }); + expect(res.status).toBe(400); + expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true }); + }); + + it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => { + const token = await setupService.ensureSetupToken(); + const res = await request(app) + .post('/api/setup/admin') + .send({ token, email: 'owner@example.com', password: VALID_PW }); + expect(res.status).toBe(201); + expect(res.body.user.role.name).toBe('super_admin'); + expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/); + expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true }); + }); + + it('POST /api/setup/admin is closed once an admin exists (409)', async () => { + const token = await setupService.ensureSetupToken(); + await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW }); + const res = await request(app) + .post('/api/setup/admin') + .send({ token, email: 'second@example.com', password: VALID_PW }); + expect(res.status).toBe(409); + }); +}); diff --git a/backend/migrations/core/001_init.js b/backend/migrations/core/001_init.js index 654d2e09..278e1fb9 100644 --- a/backend/migrations/core/001_init.js +++ b/backend/migrations/core/001_init.js @@ -11,9 +11,16 @@ exports.up = async function(knex) { // Initialize tables await initializeDatabase(); - // Create default admin user if none exists + // Create default admin user if none exists. + // + // Legacy path — only when ADMIN_PASSWORD is explicitly provided (keeps + // existing docker-compose installs working unchanged). When it is NOT set, + // we deliberately leave admin_users empty so the first-run setup wizard + // (setupService / /setup) creates the admin in-browser — no ADMIN_PASSWORD + // in .env. Existing deployments already ran this migration, so this only + // affects fresh installs. const adminExists = await knex('admin_users').first(); - if (!adminExists) { + if (!adminExists && process.env.ADMIN_PASSWORD) { // Use ADMIN_PASSWORD from environment if set, otherwise generate a random one const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword(); const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security diff --git a/backend/server.js b/backend/server.js index 165d4342..8c2c20ca 100644 --- a/backend/server.js +++ b/backend/server.js @@ -43,6 +43,7 @@ const galleryRoutes = require('./src/routes/gallery'); const adminRoutes = require('./src/routes/admin'); const adminAuthRoutes = require('./src/routes/adminAuth'); const secureImagesRoutes = require('./src/routes/secureImages'); +const setupRoutes = require('./src/routes/setup'); const app = express(); const PORT = process.env.PORT || 3000; @@ -397,6 +398,7 @@ async function initializeRateLimiters() { app.use('/api/auth', authRateLimiter); app.use('/api/gallery/:slug/verify', authRateLimiter); app.use('/api/admin/auth/login', authRateLimiter); + app.use('/api/setup/admin', authRateLimiter); } // Note: Rate limiters will be initialized after database connection @@ -690,6 +692,7 @@ app.get('/health', async (req, res) => { }); // Routes +app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup) app.use('/api/auth', authRoutes); app.use('/api/events', eventRoutes); app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia')); @@ -999,6 +1002,16 @@ async function startServer() { logger.warn('Install-from-backup hook threw:', err.message); } + // First-run: surface a one-time setup token while no admin account exists. + // Runs AFTER install-from-backup so a restored instance (which repopulates + // admin_users) never prints a throwaway token. Best-effort — never blocks boot. + let setupToken = null; + try { + setupToken = await require('./src/services/setupService').ensureSetupToken(); + } catch (err) { + logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`); + } + // Start backup service await startBackupService(); @@ -1014,6 +1027,13 @@ async function startServer() { logger.info(`Server running on port ${PORT}`); logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`); logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`); + // First-run: print the one-time setup token to STDOUT (the file logger + // doesn't reach `docker logs`), as the last + most visible thing at boot. + if (setupToken) { + const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`; + const line = '='.repeat(64); + console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`); + } }); } catch (error) { logger.error('Failed to start server:', error); diff --git a/backend/src/routes/setup.js b/backend/src/routes/setup.js new file mode 100644 index 00000000..537093a6 --- /dev/null +++ b/backend/src/routes/setup.js @@ -0,0 +1,55 @@ +'use strict'; + +// Public first-run setup endpoints. UNAUTHENTICATED by design — they exist so a +// fresh instance can create its first admin from the browser (no ADMIN_PASSWORD +// in .env). Both are hard-gated on "no admin exists yet", and POST /admin also +// requires the one-time setup token, so they self-close after setup. The POST is +// rate-limited at the mount point in server.js (authRateLimiter). +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const setupService = require('../services/setupService'); +const { getClientIp } = require('../utils/requestIp'); +const { setAdminAuthCookie } = require('../utils/tokenUtils'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +router.get('/status', async (req, res) => { + try { + res.json(await setupService.getSetupStatus()); + } catch (err) { + logger.error('[setup] status failed', { error: err.message }); + res.status(500).json({ error: 'Failed to read setup status' }); + } +}); + +router.post('/admin', [ + body('token').notEmpty().withMessage('Setup token is required'), + body('email').isEmail().withMessage('A valid email is required'), + body('password').notEmpty().withMessage('Password is required'), +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + try { + const { token, email, password } = req.body; + const result = await setupService.createInitialAdmin({ + token, + email, + password, + ip: getClientIp(req), + }); + setAdminAuthCookie(res, result.token); + // Token delivered via HttpOnly cookie only (mirrors admin login). + res.status(201).json({ user: result.user }); + } catch (err) { + if (err.statusCode) { + return res.status(err.statusCode).json({ error: err.message }); + } + logger.error('[setup] createInitialAdmin failed', { error: err.message }); + return res.status(500).json({ error: 'Setup failed' }); + } +}); + +module.exports = router; diff --git a/backend/src/services/setupService.js b/backend/src/services/setupService.js new file mode 100644 index 00000000..4c971a0f --- /dev/null +++ b/backend/src/services/setupService.js @@ -0,0 +1,142 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { getAppSetting, upsertAppSetting } = require('../utils/appSettings'); +const { ValidationError, ConflictError } = require('../utils/errors'); +const { validatePassword, getBcryptRounds } = require('../utils/passwordValidation'); +const { formatBoolean } = require('../utils/dbCompat'); + +// First-run bootstrap. The app boots with NO admin account and no +// ADMIN_PASSWORD in the environment; the first browser visit creates the admin. +// That create call is guarded by a one-time setup token, generated at boot +// while no admin exists and printed to the logs (+ a best-effort data/SETUP_TOKEN +// file). The token is ALWAYS required and burned on first use, so the endpoint +// is permanently closed once setup is done — safe even on a public IP. +const SETUP_TOKEN_KEY = 'setup_token'; + +async function noAdminExists() { + const row = await db('admin_users').count({ c: '*' }).first(); + return Number(row?.c || 0) === 0; +} + +// Public status the /setup gate reads. Deliberately leaks nothing beyond +// "is the instance still waiting for its first admin". +async function getSetupStatus() { + const needsAdmin = await noAdminExists(); + return { needsAdmin, complete: !needsAdmin }; +} + +// Logs are the source of truth; the file is a convenience for operators who +// reach a shell more easily than the container log view (e.g. `cat data/SETUP_TOKEN`). +function setupTokenFilePath() { + const dir = process.env.DATA_DIR || path.join(__dirname, '..', '..', 'data'); + return path.join(dir, 'SETUP_TOKEN'); +} + +// Called once at startup. Idempotent: generates + surfaces a token only while +// the instance still needs an admin, and clears any stale token afterwards. +// Clear the token everywhere — the app_settings row AND the on-disk file — so a +// completed (or restored) install leaves no stale token behind. +async function clearSetupToken() { + await upsertAppSetting(SETUP_TOKEN_KEY, null, 'string'); + try { fs.unlinkSync(setupTokenFilePath()); } catch (_) { /* file may be absent — best-effort */ } +} + +async function ensureSetupToken() { + if (!(await noAdminExists())) { + await clearSetupToken(); + return null; + } + let token = await getAppSetting(SETUP_TOKEN_KEY); + if (!token) { + token = crypto.randomBytes(24).toString('base64url'); + // app_settings.setting_value is JSON on Postgres — store JSON-stringified + // (getAppSetting JSON.parses on read). A raw string is rejected by jsonb. + await upsertAppSetting(SETUP_TOKEN_KEY, JSON.stringify(token), 'string'); + } + logger.warn(`[setup] No admin account yet — open /admin to finish setup. One-time setup token: ${token}`); + try { + const file = setupTokenFilePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${token}\n`, { mode: 0o600 }); + } catch (err) { + logger.warn(`[setup] Could not write setup token file (logs still have it): ${err.message}`); + } + return token; +} + +// Constant-time compare so the token can't be recovered by timing the response. +function tokensMatch(provided, expected) { + if (!provided || !expected) return false; + const a = Buffer.from(String(provided)); + const b = Buffer.from(String(expected)); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +// Creates the first admin as super_admin (the highest role) and returns a +// ready-to-set admin JWT so the browser flows straight into the wizard. +async function createInitialAdmin({ token, email, password, ip }) { + if (!(await noAdminExists())) { + throw new ConflictError('Setup already completed — an admin account exists'); + } + const expected = await getAppSetting(SETUP_TOKEN_KEY); + if (!tokensMatch(token, expected)) { + throw new ValidationError('Invalid setup token', 'token'); + } + + const cleanEmail = String(email || '').trim().toLowerCase(); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(cleanEmail)) { + throw new ValidationError('A valid email address is required', 'email'); + } + const strength = validatePassword(password); + if (!strength.valid) { + throw new ValidationError(strength.errors[0] || 'Password does not meet requirements', 'password'); + } + + const role = await db('roles').where('name', 'super_admin').first(); + if (!role) { + throw new ConflictError('super_admin role missing — database not initialised'); + } + + const passwordHash = await bcrypt.hash(password, getBcryptRounds()); + const inserted = await db('admin_users').insert({ + username: cleanEmail, + email: cleanEmail, + password_hash: passwordHash, + role_id: role.id, + is_active: formatBoolean(true), + must_change_password: formatBoolean(false), + created_at: new Date(), + updated_at: new Date(), + }).returning('id'); + const id = inserted[0]?.id || inserted[0]; + + // Burn the one-time token (DB + file) — the endpoint is now permanently closed. + await clearSetupToken(); + logger.info(`[setup] Initial super_admin created (id=${id}, email=${cleanEmail})`); + + const authToken = jwt.sign( + { id, username: cleanEmail, type: 'admin', role: role.name, ip: ip || null, loginTime: Date.now() }, + process.env.JWT_SECRET, + { expiresIn: '24h', issuer: 'picpeak-auth' } + ); + + return { + token: authToken, + user: { + id, + username: cleanEmail, + email: cleanEmail, + role: { name: role.name, displayName: role.display_name }, + }, + }; +} + +module.exports = { getSetupStatus, ensureSetupToken, createInitialAdmin }; diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh index 6471326f..2316520b 100755 --- a/backend/wait-for-db.sh +++ b/backend/wait-for-db.sh @@ -3,6 +3,21 @@ set -e +# Machine secrets (JWT/DB/Redis): if not supplied via the environment, read them +# from the generated secret files that the compose `secrets-init` service writes +# to /run/secrets. Explicit env ALWAYS wins, so installs that set +# JWT_SECRET/DB_PASSWORD/REDIS_PASSWORD in .env are unaffected. Runs before the +# root -> nodejs re-exec so the exported values survive su-exec. +for _pair in JWT_SECRET:jwt_secret DB_PASSWORD:db_password REDIS_PASSWORD:redis_password; do + _var="${_pair%%:*}" + _file="/run/secrets/${_pair##*:}" + eval "_cur=\${$_var:-}" + if [ -z "$_cur" ] && [ -s "$_file" ]; then + export "$_var=$(cat "$_file")" + fi +done +unset _pair _var _file _cur + # Permission handling (#484): the image starts as root so this script can # chown bind-mounted host volumes to UID 1001 (nodejs) before dropping # privileges via su-exec. This avoids the fresh-install restart loop where diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 38718c3b..9245a63f 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -1,16 +1,53 @@ version: '3.8' services: + # Generates machine secrets (JWT/DB/Redis) on first run when they aren't set + # in .env, so a fresh install needs zero secret management. Each file is seeded + # from the matching env var when provided (backward-compatible), otherwise a + # strong random value. Idempotent — never overwrites an existing file, so the + # DB password can't drift out from under an already-initialised Postgres volume. + secrets-init: + image: alpine:3.20 + container_name: picpeak-secrets-init + env_file: .env + entrypoint: + - sh + - -c + - | + set -e + mkdir -p /run/secrets + if [ ! -s /run/secrets/jwt_secret ]; then + if [ -n "$$JWT_SECRET" ]; then printf '%s' "$$JWT_SECRET" > /run/secrets/jwt_secret; + else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/jwt_secret; fi + fi + if [ ! -s /run/secrets/db_password ]; then + if [ -n "$$DB_PASSWORD" ]; then printf '%s' "$$DB_PASSWORD" > /run/secrets/db_password; + else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/db_password; fi + fi + if [ ! -s /run/secrets/redis_password ]; then + if [ -n "$$REDIS_PASSWORD" ]; then printf '%s' "$$REDIS_PASSWORD" > /run/secrets/redis_password; + else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/redis_password; fi + fi + chmod 644 /run/secrets/jwt_secret /run/secrets/db_password /run/secrets/redis_password + volumes: + - picpeak-secrets:/run/secrets + restart: "no" + postgres: image: postgres:15-alpine container_name: picpeak-postgres userns_mode: "host" environment: POSTGRES_USER: ${DB_USER:-picpeak} - POSTGRES_PASSWORD: ${DB_PASSWORD} + # Reads the generated (or .env-seeded) password from the shared secrets volume. + POSTGRES_PASSWORD_FILE: /run/secrets/db_password POSTGRES_DB: ${DB_NAME:-picpeak} volumes: - postgres-data:/var/lib/postgresql/data + - picpeak-secrets:/run/secrets:ro + depends_on: + secrets-init: + condition: service_completed_successfully networks: - picpeak-network restart: unless-stopped @@ -30,9 +67,14 @@ services: image: redis:7-alpine container_name: picpeak-redis userns_mode: "host" - command: redis-server --requirepass ${REDIS_PASSWORD} + # Reads the generated (or .env-seeded) password from the shared secrets volume. + command: sh -c 'exec redis-server --requirepass "$$(cat /run/secrets/redis_password)"' volumes: - redis-data:/data + - picpeak-secrets:/run/secrets:ro + depends_on: + secrets-init: + condition: service_completed_successfully networks: - picpeak-network restart: unless-stopped @@ -59,11 +101,14 @@ services: - ${APP_STORAGE}:/app/storage - ${LOGS}:/app/logs - ${APP_DATA}:/app/data + - picpeak-secrets:/run/secrets:ro ports: - "${BACKEND_PORT:-3001}:3000" networks: - picpeak-network depends_on: + secrets-init: + condition: service_completed_successfully postgres: condition: service_healthy redis: @@ -144,6 +189,11 @@ volumes: driver: local redis-data: driver: local + # Holds the auto-generated machine secrets (jwt_secret, db_password, + # redis_password). Keep it — deleting it orphans the DB password from the + # Postgres volume. Back it up alongside postgres-data. + picpeak-secrets: + driver: local networks: picpeak-network: diff --git a/docker-compose.yml b/docker-compose.yml index 0ced821b..8724a54f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,34 @@ services: + # Generates machine secrets (JWT/DB/Redis) on first run when they aren't set + # in .env (seeds from the env var when provided, else a random value). + # Idempotent — never overwrites an existing file. See docker-compose.production.yml. + secrets-init: + image: alpine:3.20 + container_name: picpeak-secrets-init + env_file: .env + entrypoint: + - sh + - -c + - | + set -e + mkdir -p /run/secrets + if [ ! -s /run/secrets/jwt_secret ]; then + if [ -n "$$JWT_SECRET" ]; then printf '%s' "$$JWT_SECRET" > /run/secrets/jwt_secret; + else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/jwt_secret; fi + fi + if [ ! -s /run/secrets/db_password ]; then + if [ -n "$$DB_PASSWORD" ]; then printf '%s' "$$DB_PASSWORD" > /run/secrets/db_password; + else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/db_password; fi + fi + if [ ! -s /run/secrets/redis_password ]; then + if [ -n "$$REDIS_PASSWORD" ]; then printf '%s' "$$REDIS_PASSWORD" > /run/secrets/redis_password; + else tr -dc A-Za-z0-9 < /dev/urandom | head -c 48 > /run/secrets/redis_password; fi + fi + chmod 644 /run/secrets/jwt_secret /run/secrets/db_password /run/secrets/redis_password + volumes: + - picpeak-secrets:/run/secrets + restart: "no" + backend: build: context: ./backend @@ -8,17 +38,17 @@ services: environment: - NODE_ENV=${NODE_ENV:-production} - PORT=3000 - - JWT_SECRET=${JWT_SECRET} + - JWT_SECRET=${JWT_SECRET:-} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} - - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} - DATABASE_CLIENT=pg - - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME} + - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD:-}@postgres:5432/${DB_NAME} - DB_TYPE=postgresql - DB_HOST=postgres - DB_PORT=5432 - DB_USER=${DB_USER} - - DB_PASSWORD=${DB_PASSWORD} + - DB_PASSWORD=${DB_PASSWORD:-} - DB_NAME=${DB_NAME} - EXTERNAL_MEDIA_ROOT=${EXTERNAL_MEDIA_ROOT:-/app/storage/external-media} - SMTP_HOST=${SMTP_HOST} @@ -42,9 +72,12 @@ services: - ./logs:/app/logs - ./backup:/backup - ./storage:/app/storage + - picpeak-secrets:/run/secrets:ro ports: - "${BACKEND_PORT:-3001}:3000" depends_on: + secrets-init: + condition: service_completed_successfully postgres: condition: service_healthy healthcheck: @@ -63,12 +96,16 @@ services: userns_mode: "host" environment: - POSTGRES_USER=${DB_USER} - - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_PASSWORD_FILE=/run/secrets/db_password - POSTGRES_DB=${DB_NAME} - PGDATA=/var/lib/postgresql/data/pgdata - TZ=${TZ:-UTC} volumes: - postgres-data:/var/lib/postgresql/data + - picpeak-secrets:/run/secrets:ro + depends_on: + secrets-init: + condition: service_completed_successfully ports: - "127.0.0.1:${DB_PORT:-5432}:5432" healthcheck: @@ -85,9 +122,13 @@ services: container_name: picpeak-redis restart: unless-stopped userns_mode: "host" - command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass} + command: sh -c 'exec redis-server --appendonly yes --requirepass "$$(cat /run/secrets/redis_password)"' volumes: - redis-data:/data + - picpeak-secrets:/run/secrets:ro + depends_on: + secrets-init: + condition: service_completed_successfully ports: - "127.0.0.1:${REDIS_PORT:-6379}:6379" healthcheck: @@ -142,6 +183,10 @@ volumes: driver: local redis-data: driver: local + # Auto-generated machine secrets (jwt/db/redis). Keep it — deleting it orphans + # the DB password from the Postgres volume. + picpeak-secrets: + driver: local networks: picpeak-network: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6aa17d84..27629a4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -79,6 +79,8 @@ import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { ConfirmDialogProvider } from './components/common'; import { usePublicSettings } from './hooks/usePublicSettings'; +import { SetupPage } from './pages/SetupPage'; +import { AdminAuthProvider } from './contexts'; // Create a client const queryClient = new QueryClient({ @@ -214,6 +216,13 @@ function App() { } /> + {/* First-run setup — public, self-closes once an admin exists */} + + + + } /> + {/* Admin routes - wrap with AdminAuthProvider */} }> } /> diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 6b101a5c..cc0e8277 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3462,6 +3462,30 @@ "regenerateToken": "Link neu generieren", "tokenRegenerated": "Kundenzugangs-Link neu generiert" }, + "setup": { + "title": "Willkommen bei PicPeak", + "subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen", + "tokenLabel": "Setup-Token", + "tokenPlaceholder": "Einmaligen Setup-Token einfügen", + "tokenHint": "Wird beim ersten Start in den Server-Logs ausgegeben (auch in data/SETUP_TOKEN gespeichert).", + "tokenRequired": "Der Setup-Token ist erforderlich", + "tokenLocationHint": "Nicht gefunden? Führen Sie aus: docker compose logs backend | grep -i \"setup token\"", + "emailLabel": "E-Mail-Adresse", + "emailPlaceholder": "sie@beispiel.de", + "emailRequired": "E-Mail ist erforderlich", + "invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein", + "passwordLabel": "Passwort", + "passwordPlaceholder": "Wählen Sie ein sicheres Passwort", + "passwordRequired": "Passwort ist erforderlich", + "passwordMinLength": "Das Passwort muss mindestens 8 Zeichen lang sein", + "confirmLabel": "Passwort bestätigen", + "confirmPlaceholder": "Passwort erneut eingeben", + "passwordMismatch": "Die Passwörter stimmen nicht überein", + "submit": "Admin-Konto erstellen", + "success": "Admin-Konto erstellt. Willkommen!", + "genericError": "Einrichtung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "tooManyAttempts": "Zu viele Versuche. Bitte warten Sie einen Moment und versuchen Sie es erneut." + }, "adminLogin": { "title": "Admin-Anmeldung", "subtitle": "Melden Sie sich an, um Ihre Fotogalerien zu verwalten", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index fcec9271..704b8f9c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3358,6 +3358,30 @@ "fourStarsPlus": "4+ Stars", "fiveStarsOnly": "5 Stars Only" }, + "setup": { + "title": "Welcome to PicPeak", + "subtitle": "Create your administrator account to get started", + "tokenLabel": "Setup token", + "tokenPlaceholder": "Paste the one-time setup token", + "tokenHint": "Printed to the server logs on first start (also saved to data/SETUP_TOKEN).", + "tokenRequired": "The setup token is required", + "tokenLocationHint": "Can't find it? Run: docker compose logs backend | grep -i \"setup token\"", + "emailLabel": "Email address", + "emailPlaceholder": "you@example.com", + "emailRequired": "Email is required", + "invalidEmail": "Please enter a valid email address", + "passwordLabel": "Password", + "passwordPlaceholder": "Choose a strong password", + "passwordRequired": "Password is required", + "passwordMinLength": "Password must be at least 8 characters", + "confirmLabel": "Confirm password", + "confirmPlaceholder": "Re-enter your password", + "passwordMismatch": "Passwords do not match", + "submit": "Create admin account", + "success": "Admin account created. Welcome!", + "genericError": "Setup failed. Please try again.", + "tooManyAttempts": "Too many attempts. Please wait a moment and try again." + }, "adminLogin": { "title": "Admin Login", "subtitle": "Sign in to manage your photo galleries", diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx new file mode 100644 index 00000000..896bd882 --- /dev/null +++ b/frontend/src/pages/SetupPage.tsx @@ -0,0 +1,211 @@ +import React, { useState } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, Sparkles } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; + +import { Button, Input, Card, Loading } from '../components/common'; +import { useAdminAuth } from '../contexts'; +import { setupService } from '../services/setup.service'; +import type { AdminUser } from '../types'; + +// First-run screen. Reached on a fresh instance where no admin account exists +// yet — creates the first (super_admin) account from the browser using the +// one-time setup token printed to the server logs. Once an admin exists the +// endpoints self-close and this page redirects to the login. +export const SetupPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { login } = useAdminAuth(); + + const { data: status, isLoading: statusLoading } = useQuery({ + queryKey: ['setup-status'], + queryFn: setupService.getSetupStatus, + retry: false, + staleTime: Infinity, + }); + + const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); + const [showPassword, setShowPassword] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + + if (statusLoading) { + return ; + } + // Setup already done → nothing to bootstrap here. + if (status && !status.needsAdmin) { + return ; + } + + const setField = (field: keyof typeof form) => (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [field]: e.target.value })); + if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' })); + }; + + const validate = (): boolean => { + const next: Record = {}; + if (!form.token.trim()) next.token = t('setup.tokenRequired'); + if (!form.email) next.email = t('setup.emailRequired'); + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) next.email = t('setup.invalidEmail'); + if (!form.password) next.password = t('setup.passwordRequired'); + else if (form.password.length < 8) next.password = t('setup.passwordMinLength'); + if (form.confirm !== form.password) next.confirm = t('setup.passwordMismatch'); + setErrors(next); + return Object.keys(next).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + toast.dismiss(); + if (!validate()) return; + + setIsSubmitting(true); + setErrors({}); + try { + const { user } = await setupService.createInitialAdmin({ + token: form.token.trim(), + email: form.email.trim(), + password: form.password, + }); + // Cookie is set by the backend; register the session and enter the app. + const adminUser: AdminUser = { + id: user.id, + username: user.username, + email: user.email, + mustChangePassword: false, + role: { name: user.role.name, displayName: user.role.displayName ?? user.role.name }, + }; + login('', adminUser); + toast.success(t('setup.success')); + navigate('/admin/dashboard', { replace: true }); + } catch (error: any) { + const apiErrors = error.response?.data?.errors; + if (error.response?.status === 429) { + toast.error(t('setup.tooManyAttempts')); + } else if (Array.isArray(apiErrors) && apiErrors.length) { + setErrors({ form: apiErrors[0]?.msg || t('setup.genericError') }); + } else if (error.response?.status === 409) { + // Someone else finished setup first — send to login. + navigate('/admin/login', { replace: true }); + } else if (error.response?.data?.error) { + setErrors({ form: error.response.data.error }); + } else { + toast.error(t('setup.genericError')); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+
+ +
+

{t('setup.title')}

+

{t('setup.subtitle')}

+
+ + +
+ {errors.form && ( +
+ +

{errors.form}

+
+ )} + +
+ + } + autoFocus + /> +

{t('setup.tokenHint')}

+
+ +
+ + } + autoComplete="email" + /> +
+ +
+ +
+ } + autoComplete="new-password" + /> + +
+
+ +
+ + } + autoComplete="new-password" + /> +
+ + +
+
+ +

+ {t('setup.tokenLocationHint')} +

+
+
+ ); +}; + +SetupPage.displayName = 'SetupPage'; diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 7fe2bc33..5a482708 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Navigate, useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; @@ -7,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Button, Input, Card, ReCaptcha } from '../../components/common'; import { useAdminAuth } from '../../contexts'; import { authService } from '../../services/auth.service'; +import { setupService } from '../../services/setup.service'; import { usePublicSettings } from '../../hooks/usePublicSettings'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; import { resolveLoginLogoClasses } from '../../utils/loginLogoSize'; @@ -50,6 +52,17 @@ export const AdminLoginPage: React.FC = () => { } }, [searchParams, t]); + // Fresh instance with no admin yet → send to first-run setup. + const { data: setupStatus } = useQuery({ + queryKey: ['setup-status'], + queryFn: setupService.getSetupStatus, + retry: false, + staleTime: Infinity, + }); + if (setupStatus?.needsAdmin) { + return ; + } + // Redirect if already authenticated or login successful if (isAuthenticated || loginSuccess) { return ; diff --git a/frontend/src/services/setup.service.ts b/frontend/src/services/setup.service.ts new file mode 100644 index 00000000..ffe06c3e --- /dev/null +++ b/frontend/src/services/setup.service.ts @@ -0,0 +1,33 @@ +import { api } from '../config/api'; + +export interface SetupStatus { + needsAdmin: boolean; + complete: boolean; +} + +export interface SetupAdminUser { + id: number; + username: string; + email: string; + role: { name: string; displayName?: string }; +} + +export interface CreateInitialAdminInput { + token: string; + email: string; + password: string; +} + +// First-run bootstrap. Public endpoints that self-close once an admin exists. +export const setupService = { + async getSetupStatus(): Promise { + const response = await api.get('/setup/status'); + return response.data; + }, + + async createInitialAdmin(input: CreateInitialAdminInput): Promise<{ user: SetupAdminUser }> { + // Admin JWT is returned as an HttpOnly cookie (mirrors login); body carries the user. + const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input); + return response.data; + }, +};