diff --git a/.gitignore b/.gitignore index 931739a7..a49a1d81 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,12 @@ backup/ # Local artifacts from browser tooling .playwright-mcp/ +# Local-only E2E suite (never pushed; runs as pre-push gate on this machine) +tests/e2e/local/ +playwright-local-results/ +e2e-test.log +scripts/e2e-local.sh + # Local SQLite files in backend backend/*.sqlite* backend/*.db diff --git a/README.md b/README.md index 0c8357f6..71f6109d 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ [](https://www.docker.com/) [](https://nodejs.org/) [](https://reactjs.org/) + [](https://buymeacoffee.com/theluap) - [Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md) + [Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md) · [Support the project ☕](https://buymeacoffee.com/theluap) **PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding. @@ -312,6 +313,18 @@ These features are currently in beta testing and may have limited functionality **Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned +## ☕ Support the Project + +PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running. + +
+
+
+
+
Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.
', updated_at: new Date(), }, + // Customisable error pages — issue #324. Generic copy by default; + // admins can edit text + logo per page in the CMS Pages tab. + { + slug: 'not-found', + title_en: 'Page Not Found', + title_de: 'Seite nicht gefunden', + content_en: 'The page you are looking for does not exist or has been moved.
', + content_de: 'Die gesuchte Seite existiert nicht oder wurde verschoben.
', + updated_at: new Date(), + }, + { + slug: 'gallery-not-found', + title_en: 'Gallery Not Found', + title_de: 'Galerie nicht gefunden', + content_en: 'This gallery could not be found. The link may be incorrect, or the gallery may have expired or been archived. Please contact the organiser if you believe this is a mistake.
', + content_de: 'Diese Galerie konnte nicht gefunden werden. Der Link ist möglicherweise nicht korrekt, oder die Galerie ist abgelaufen oder wurde archiviert. Bitte kontaktieren Sie den Veranstalter, falls Sie glauben, dass dies ein Fehler ist.
', + updated_at: new Date(), + }, ]; for (const page of defaultPages) { diff --git a/backend/src/middleware/apiTokenAuth.js b/backend/src/middleware/apiTokenAuth.js new file mode 100644 index 00000000..c3d826af --- /dev/null +++ b/backend/src/middleware/apiTokenAuth.js @@ -0,0 +1,123 @@ +const crypto = require('crypto'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +const TOKEN_PREFIX = 'pp_live_'; +const VALID_SCOPES = ['read', 'write', 'admin']; + +function hashToken(plaintext) { + return crypto.createHash('sha256').update(plaintext).digest('hex'); +} + +/** + * Generate a new API token. Returns the plaintext (return once, never + * stored) plus the row payload to insert. Caller persists. + */ +function generateApiToken() { + const random = crypto.randomBytes(24).toString('base64url'); // 32 chars + const plaintext = `${TOKEN_PREFIX}${random}`; + return { + plaintext, + hashed: hashToken(plaintext), + preview: random.slice(0, 8) + }; +} + +function parseScopes(raw) { + if (!raw) return []; + return String(raw) + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter((s) => VALID_SCOPES.includes(s)); +} + +/** + * Middleware: authenticate via API token. Maps the token to its owner + * admin user, attaches { req.admin, req.apiToken }, then defers to the + * regular permission machinery on top. + * + * Mount this *instead* of `adminAuth` on /api/v1/* routes. Existing + * permission decorators (`requirePermission('events.create')`) still + * work because they read `req.admin.id`. + */ +async function apiTokenAuth(req, res, next) { + try { + const header = req.headers?.authorization || ''; + if (!header.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing API token', code: 'NO_TOKEN' }); + } + const token = header.slice(7).trim(); + if (!token.startsWith(TOKEN_PREFIX)) { + return res.status(401).json({ error: 'Invalid token format', code: 'INVALID_TOKEN' }); + } + + const hashed = hashToken(token); + const row = await db('api_tokens').where({ hashed_token: hashed }).first(); + if (!row) { + return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' }); + } + if (row.revoked_at) { + return res.status(401).json({ error: 'Token revoked', code: 'TOKEN_REVOKED' }); + } + if (row.expires_at && new Date(row.expires_at) <= new Date()) { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); + } + + const admin = await db('admin_users') + .where({ id: row.created_by, is_active: true }) + .select('id', 'username', 'email', 'role_id') + .first(); + if (!admin) { + return res.status(401).json({ error: 'Token owner unavailable', code: 'OWNER_INACTIVE' }); + } + + // Touch last_used_at — async, don't block the request. + db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() }) + .catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message })); + + req.admin = admin; + req.apiToken = { + id: row.id, + name: row.name, + scopes: parseScopes(row.scopes) + }; + return next(); + } catch (error) { + logger.error('apiTokenAuth error', { error: error.message }); + return res.status(500).json({ error: 'Authentication error' }); + } +} + +/** + * Middleware factory: require a specific scope on the API token. Use + * after apiTokenAuth — `requireApiScope('write')` rejects read-only + * tokens trying to mutate. + */ +function requireApiScope(scope) { + return (req, res, next) => { + const have = req.apiToken?.scopes || []; + // 'admin' implies write/read; 'write' implies read. + const expanded = new Set(have); + if (have.includes('admin')) ['write', 'read'].forEach((s) => expanded.add(s)); + if (have.includes('write')) expanded.add('read'); + if (!expanded.has(scope)) { + return res.status(403).json({ + error: `Token lacks required scope: ${scope}`, + code: 'INSUFFICIENT_SCOPE', + required: scope, + granted: have + }); + } + next(); + }; +} + +module.exports = { + apiTokenAuth, + requireApiScope, + generateApiToken, + hashToken, + parseScopes, + TOKEN_PREFIX, + VALID_SCOPES +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 4522d7c4..214485f1 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -91,10 +91,16 @@ async function adminAuth(req, res, next) { return res.status(401).json({ error: 'Invalid token' }); } - // Check if password was changed after token was issued + // Check if password was changed after token was issued. JWT `iat` has + // 1-second resolution; `password_changed_at` is sub-second. Floor the + // comparison so a token issued in the *same* second as the password + // change isn't incorrectly rejected — that race used to bite anyone + // logging in immediately after a password reset/change. if (admin.password_changed_at) { - const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; - if (decoded.iat < passwordChangedTime) { + const passwordChangedSeconds = Math.floor( + new Date(admin.password_changed_at).getTime() / 1000 + ); + if (decoded.iat < passwordChangedSeconds) { logger.warn('Token used after password change', { userId: decoded.id }); return res.status(401).json({ error: 'Token invalid due to password change', diff --git a/backend/src/openapi/spec.js b/backend/src/openapi/spec.js new file mode 100644 index 00000000..62d8086d --- /dev/null +++ b/backend/src/openapi/spec.js @@ -0,0 +1,70 @@ +/** + * OpenAPI 3.1 spec for /api/v1/* (#322). Source of truth for the + * picpeak-docs reference page. Built from JSDoc `@openapi` blocks + * scattered through src/routes/v1 — those stay co-located with the + * routes they describe so the spec can't drift in isolation. + */ + +const swaggerJSDoc = require('swagger-jsdoc'); +const path = require('path'); + +const baseDoc = { + openapi: '3.0.3', + info: { + title: 'PicPeak API', + version: 'v1', + description: + 'Public REST API for PicPeak — create gallery events, upload photos, fetch share links. ' + + 'Authenticate with a Bearer token issued via the admin **Settings → API Tokens** tab.' + }, + servers: [ + { url: '/api/v1', description: 'Same-origin (production)' } + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'pp_live_*', + description: + 'Long-lived API token. Issue via Settings → API Tokens. ' + + 'Token format: `pp_live_