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 @@ [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) [![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/) [![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/) + [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](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. + +

+ + Buy Me A Coffee + +

+ +Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR. + ## 🙏 Acknowledgments PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible. diff --git a/backend/migrations/core/080_add_customer_phone.js b/backend/migrations/core/080_add_customer_phone.js new file mode 100644 index 00000000..db2c765e --- /dev/null +++ b/backend/migrations/core/080_add_customer_phone.js @@ -0,0 +1,33 @@ +const { addColumnIfNotExists } = require('../helpers'); + +/** + * #322 — optional phone-number field on events. Off by default; surfaced + * only when the global `event_phone_field_enabled` app setting is true, + * so existing deployments see no UI change unless the admin opts in. + */ +exports.up = async function up(knex) { + await addColumnIfNotExists(knex, 'events', 'customer_phone', (table) => { + table.string('customer_phone', 32).nullable(); + }); + + // Seed the global enable flag (default false). + const exists = await knex('app_settings') + .where('setting_key', 'event_phone_field_enabled') + .first(); + if (!exists) { + await knex('app_settings').insert({ + setting_key: 'event_phone_field_enabled', + setting_value: JSON.stringify(false), + setting_type: 'boolean' + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasColumn('events', 'customer_phone')) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('customer_phone'); + }); + } + await knex('app_settings').where('setting_key', 'event_phone_field_enabled').delete(); +}; diff --git a/backend/migrations/core/081_add_api_tokens.js b/backend/migrations/core/081_add_api_tokens.js new file mode 100644 index 00000000..000dbfb9 --- /dev/null +++ b/backend/migrations/core/081_add_api_tokens.js @@ -0,0 +1,41 @@ +/** + * #322 — long-lived API tokens for programmatic access (n8n, custom + * integrations, external apps). Each token belongs to an admin user; the + * token's effective permissions are the *intersection* of the user's + * role permissions and the token's own scope flags. That way revoking + * the user revokes the token, and scope flags let an admin issue a + * read-only token even if their account is super_admin. + */ + +exports.up = async function up(knex) { + if (!(await knex.schema.hasTable('api_tokens'))) { + await knex.schema.createTable('api_tokens', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + // SHA-256 of the full token string (`pp_live_`). Lookup + // hashes the incoming Authorization header and queries by this. + table.string('hashed_token', 64).notNullable().unique(); + // Scope flags — comma-separated subset of: read, write, admin. + // 'read' allows GETs; 'write' adds POST/PATCH/DELETE on + // event/photo data; 'admin' allows creating/deleting events and + // anything else gated by admin.* permissions. + table.string('scopes', 64).notNullable().defaultTo('read'); + table.integer('created_by').notNullable() + .references('id').inTable('admin_users').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('expires_at').nullable(); + table.timestamp('last_used_at').nullable(); + table.timestamp('revoked_at').nullable(); + // Cosmetic for the admin UI: first 8 chars of the plaintext + // token (after the prefix) so admins can identify which token is + // which without seeing the secret half. + table.string('preview', 16).nullable(); + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasTable('api_tokens')) { + await knex.schema.dropTable('api_tokens'); + } +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 4eead2c4..aa148a96 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-backend", - "version": "3.24.1-beta.0", + "version": "3.28.3-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.24.1-beta.0", + "version": "3.28.3-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", @@ -45,6 +45,8 @@ "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", "winston": "^3.8.2", "zxcvbn": "^4.4.2" @@ -57,6 +59,50 @@ "supertest": "^6.3.3" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -2656,6 +2702,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2769,6 +2821,13 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -3697,6 +3756,12 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", @@ -4515,6 +4580,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5143,7 +5214,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -5570,7 +5640,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -5671,6 +5740,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8023,6 +8093,13 @@ "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8035,6 +8112,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -8066,6 +8150,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -9029,6 +9119,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10794,6 +10891,71 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.5.tgz", + "integrity": "sha512-7/FQfWe9A4qoyYFdAwy0chD0uDYidDp/ZT9VQ9LZlgD4AnnHJk8/+ytAA1HkJYOPySmK6helPDdJQMlcumt7HA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -11507,6 +11669,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -11571,6 +11742,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 6080ebef..5baaad29 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,8 @@ "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", "winston": "^3.8.2", "zxcvbn": "^4.4.2" diff --git a/backend/scripts/generate-openapi.js b/backend/scripts/generate-openapi.js new file mode 100644 index 00000000..a4a775b9 --- /dev/null +++ b/backend/scripts/generate-openapi.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Generate the OpenAPI spec from JSDoc annotations in src/routes/v1/* and + * write it as YAML + JSON to ../docs/. Used by scripts/sync-api-docs.sh + * to keep the picpeak-docs site in lockstep with the running API. + */ + +const fs = require('fs'); +const path = require('path'); + +// Need yaml — runtime require so the script fails clearly with an +// install hint instead of an opaque MODULE_NOT_FOUND. +let yaml; +try { + yaml = require('js-yaml'); +} catch { + console.error('generate-openapi: missing dependency `js-yaml`. Run `npm install --save-dev js-yaml` in /backend.'); + process.exit(2); +} + +const { getOpenApiSpec } = require('../src/openapi/spec'); + +const outDir = path.resolve(__dirname, '../../docs'); +fs.mkdirSync(outDir, { recursive: true }); + +const spec = getOpenApiSpec(); +fs.writeFileSync(path.join(outDir, 'openapi.json'), JSON.stringify(spec, null, 2)); +fs.writeFileSync(path.join(outDir, 'openapi.yaml'), yaml.dump(spec, { lineWidth: 100 })); + +console.log(`Wrote openapi.json + openapi.yaml to ${outDir}`); diff --git a/backend/server.js b/backend/server.js index 5bd6f1b4..2ee73c79 100644 --- a/backend/server.js +++ b/backend/server.js @@ -486,21 +486,24 @@ app.get('/robots.txt', async (req, res) => { } }); -// Health check endpoint +// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E +// watchdog) detect a silent process restart between two checks. app.get('/health', async (req, res) => { try { - // Check database connectivity await db.raw('SELECT 1'); - res.json({ status: 'ok', - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + pid: process.pid, + uptime: process.uptime() }); } catch (error) { logger.error('Health check failed:', error); res.status(503).json({ status: 'error', - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + pid: process.pid, + uptime: process.uptime() }); } }); @@ -529,6 +532,26 @@ app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/admin/users', require('./src/routes/adminUsers')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); +app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); +// Public v1 API for n8n / external integrations (#322). Mounted under +// /api/v1; auth handled per-route via apiTokenAuth (Bearer tokens). +app.use('/api/v1', require('./src/routes/v1/events')); + +// Swagger UI for the v1 API. Admin-gated since it lists endpoint shapes +// that should not be enumerable to anonymous users (a common reduce-info-leak hardening). +{ + const swaggerUi = require('swagger-ui-express'); + const { adminAuth } = require('./src/middleware/auth'); + const { getOpenApiSpec } = require('./src/openapi/spec'); + app.get('/api/openapi.json', adminAuth, (_req, res) => res.json(getOpenApiSpec())); + app.use( + '/api/docs', + adminAuth, + swaggerUi.serve, + swaggerUi.setup(getOpenApiSpec(), { customSiteTitle: 'PicPeak API · v1' }) + ); +} + app.use('/api/invite', require('./src/routes/acceptInvite')); app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public', require('./src/routes/publicCMS')); diff --git a/backend/src/database/db.js b/backend/src/database/db.js index f338798c..7b0ab1ef 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -463,8 +463,15 @@ async function ensureGlobalCategories() { table.text('title_de'); table.text('content_en'); table.text('content_de'); + table.string('logo_url').nullable(); table.timestamp('updated_at').defaultTo(db.fn.now()); }); + } else if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) { + // Online migration for existing deployments — see issue #324, per-page + // logo override for admin-customisable error pages. + await db.schema.alterTable('cms_pages', (table) => { + table.string('logo_url').nullable(); + }); } const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first(); @@ -501,6 +508,24 @@ async function ensureGlobalCategories() { content_de: '

Datenschutzerklärung

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: '

Page Not Found

The page you are looking for does not exist or has been moved.

', + content_de: '

Seite nicht gefunden

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: '

Gallery Not Found

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: '

Galerie nicht gefunden

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_`. Scopes: `read`, `write`, `admin`.' + } + }, + schemas: { + EventSummary: { + type: 'object', + properties: { + id: { type: 'integer' }, + slug: { type: 'string' }, + event_name: { type: 'string' }, + event_type: { type: 'string' }, + event_date: { type: 'string', format: 'date', nullable: true }, + expires_at: { type: 'string', format: 'date-time', nullable: true }, + is_active: { type: 'boolean' }, + is_archived: { type: 'boolean' }, + is_draft: { type: 'boolean' }, + created_at: { type: 'string', format: 'date-time' } + } + } + } + }, + security: [{ bearerAuth: [] }] +}; + +const options = { + definition: baseDoc, + // Pull @openapi blocks from every v1 route file. + apis: [path.join(__dirname, '../routes/v1/**/*.js')] +}; + +let cached = null; + +function getOpenApiSpec() { + if (!cached) { + cached = swaggerJSDoc(options); + } + return cached; +} + +module.exports = { getOpenApiSpec }; diff --git a/backend/src/routes/adminApiTokens.js b/backend/src/routes/adminApiTokens.js new file mode 100644 index 00000000..b2a74d2a --- /dev/null +++ b/backend/src/routes/adminApiTokens.js @@ -0,0 +1,117 @@ +/** + * Admin endpoints for managing API tokens (#322). Tokens are issued to + * an admin user; subsequent /api/v1/* calls authenticate via the token + * and act as the user that minted it (intersected with the token's + * scope set). Plaintext tokens are returned ONCE on creation. + */ + +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('./../middleware/auth'); +const { requirePermission } = require('./../middleware/permissions'); +const { generateApiToken, VALID_SCOPES } = require('./../middleware/apiTokenAuth'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +// List tokens for the current admin (or all, if super_admin) — without +// the plaintext, never recoverable after creation. +router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const tokens = await db('api_tokens') + .leftJoin('admin_users', 'admin_users.id', 'api_tokens.created_by') + .select( + 'api_tokens.id', + 'api_tokens.name', + 'api_tokens.scopes', + 'api_tokens.preview', + 'api_tokens.created_at', + 'api_tokens.expires_at', + 'api_tokens.last_used_at', + 'api_tokens.revoked_at', + 'admin_users.username as owner_username' + ) + .orderBy('api_tokens.created_at', 'desc'); + res.json(tokens); + } catch (error) { + logger.error('Failed to list API tokens', { error: error.message }); + res.status(500).json({ error: 'Failed to list tokens' }); + } +}); + +// Create a token. Returns plaintext exactly once. +router.post( + '/', + adminAuth, + requirePermission('settings.edit'), + [ + body('name').isString().trim().isLength({ min: 1, max: 100 }), + body('scopes').isArray({ min: 1 }).custom((arr) => { + const ok = arr.every((s) => VALID_SCOPES.includes(s)); + if (!ok) throw new Error(`Scopes must be a subset of: ${VALID_SCOPES.join(', ')}`); + return true; + }), + body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const { name, scopes, expires_at } = req.body; + const { plaintext, hashed, preview } = generateApiToken(); + + const insertResult = await db('api_tokens').insert({ + name, + hashed_token: hashed, + scopes: scopes.join(','), + preview, + created_by: req.admin.id, + expires_at: expires_at || null + }).returning('id'); + const id = insertResult[0]?.id || insertResult[0]; + + await logActivity('api_token_created', { name, scopes }, null, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + + // Return the plaintext exactly once. + res.status(201).json({ + id, + name, + scopes, + token: plaintext, + preview, + expires_at: expires_at || null, + created_at: new Date().toISOString(), + notice: 'Save this token now — it will not be shown again.' + }); + } catch (error) { + logger.error('Failed to create API token', { error: error.message }); + res.status(500).json({ error: 'Failed to create token' }); + } + } +); + +// Revoke a token (soft-delete; lookups still find it but reject). +router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const { id } = req.params; + const row = await db('api_tokens').where({ id }).first(); + if (!row) return res.status(404).json({ error: 'Token not found' }); + if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' }); + + await db('api_tokens').where({ id }).update({ revoked_at: new Date() }); + await logActivity('api_token_revoked', { name: row.name }, null, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + res.json({ id: Number(id), revoked: true }); + } catch (error) { + logger.error('Failed to revoke API token', { error: error.message }); + res.status(500).json({ error: 'Failed to revoke token' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js index 0fc64085..647037b9 100644 --- a/backend/src/routes/adminCMS.js +++ b/backend/src/routes/adminCMS.js @@ -1,10 +1,42 @@ const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const multer = require('multer'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { validateFileType } = require('../utils/fileSecurityUtils'); const router = express.Router(); +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Multer config for per-page logo uploads. Stores into the same +// /uploads/logos directory the global branding logo uses, with a +// per-slug filename so a page swap doesn't fight an unrelated upload. +const pageLogoStorage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + const dir = path.join(getStoragePath(), 'uploads/logos'); + await fs.mkdir(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + const safeSlug = (req.params.slug || 'page').replace(/[^a-z0-9-]/gi, ''); + cb(null, `cms-${safeSlug}-${Date.now()}${ext}`); + } +}); + +const pageLogoUpload = multer({ + storage: pageLogoStorage, + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']; + if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true); + else cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed')); + } +}); + // Get all CMS pages router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => { try { @@ -21,11 +53,11 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, try { const { slug } = req.params; const page = await db('cms_pages').where('slug', slug).first(); - + if (!page) { return res.status(404).json({ error: 'Page not found' }); } - + res.json(page); } catch (error) { console.error('Error fetching CMS page:', error); @@ -38,42 +70,46 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ body('title_en').optional().isString(), body('title_de').optional().isString(), body('content_en').optional().isString(), - body('content_de').optional().isString() + body('content_de').optional().isString(), + body('logo_url').optional({ nullable: true }).isString() ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } - + const { slug } = req.params; - const { title_en, title_de, content_en, content_de } = req.body; - + const { title_en, title_de, content_en, content_de, logo_url } = req.body; + const page = await db('cms_pages').where('slug', slug).first(); if (!page) { return res.status(404).json({ error: 'Page not found' }); } - - // Update the page - await db('cms_pages') - .where('slug', slug) - .update({ - title_en, - title_de, - content_en, - content_de, - updated_at: new Date() - }); - + + const updateFields = { + title_en, + title_de, + content_en, + content_de, + updated_at: new Date() + }; + // Only touch logo_url when explicitly present so partial updates + // (e.g. text-only edits) don't accidentally clear the upload. + if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) { + updateFields.logo_url = logo_url || null; + } + + await db('cms_pages').where('slug', slug).update(updateFields); + const updated = await db('cms_pages').where('slug', slug).first(); - - // Log activity + await logActivity('cms_page_updated', { page: slug }, null, { type: 'admin', id: req.admin.id, name: req.admin.username } ); - + res.json(updated); } catch (error) { console.error('Error updating CMS page:', error); @@ -81,4 +117,69 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ } }); -module.exports = router; \ No newline at end of file +// Upload a per-page logo (#324). Persists the URL to cms_pages.logo_url +// and returns it so the client can re-render without a refetch. +router.post( + '/pages/:slug/logo', + adminAuth, + requirePermission('cms.edit'), + pageLogoUpload.single('logo'), + async (req, res) => { + try { + const { slug } = req.params; + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + const page = await db('cms_pages').where('slug', slug).first(); + if (!page) { + // Best-effort cleanup of the orphaned upload before erroring. + await fs.unlink(req.file.path).catch(() => {}); + return res.status(404).json({ error: 'Page not found' }); + } + + const logoUrl = `/uploads/logos/${path.basename(req.file.path)}`; + await db('cms_pages').where('slug', slug).update({ + logo_url: logoUrl, + updated_at: new Date() + }); + + await logActivity('cms_page_logo_uploaded', + { page: slug }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ logo_url: logoUrl }); + } catch (error) { + console.error('Error uploading CMS page logo:', error); + res.status(500).json({ error: 'Failed to upload logo' }); + } + } +); + +// Clear a per-page logo override (revert to global branding logo). +router.delete( + '/pages/:slug/logo', + adminAuth, + requirePermission('cms.edit'), + async (req, res) => { + try { + const { slug } = req.params; + const page = await db('cms_pages').where('slug', slug).first(); + if (!page) return res.status(404).json({ error: 'Page not found' }); + + await db('cms_pages').where('slug', slug).update({ + logo_url: null, + updated_at: new Date() + }); + + res.json({ logo_url: null }); + } catch (error) { + console.error('Error clearing CMS page logo:', error); + res.status(500).json({ error: 'Failed to clear logo' }); + } + } +); + +module.exports = router; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index c12dadd4..a729aacb 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -185,6 +185,25 @@ const getBrandingDefaults = async () => { // Use parseStringInput from shared parsers for customer data extraction const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); +const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); + +// Whether the global "phone field" toggle (#322) is enabled. Cached for +// the request via a module-level read; drift is acceptable since this +// only governs whether to persist the field, not security boundaries. +const isPhoneFieldEnabled = async () => { + try { + const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + if (!row) return false; + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return value === true; + } catch (error) { + logger.debug('Failed to read event_phone_field_enabled', { error: error.message }); + return false; + } +}; const mapEventForApi = (event) => { if (!event || typeof event !== 'object') { @@ -196,6 +215,7 @@ const mapEventForApi = (event) => { host_email, customer_name, customer_email, + customer_phone, password_hash: _ph, client_password_hash: _cph, ...rest @@ -204,7 +224,8 @@ const mapEventForApi = (event) => { return { ...rest, customer_name: customer_name ?? host_name ?? null, - customer_email: customer_email ?? host_email ?? null + customer_email: customer_email ?? host_email ?? null, + customer_phone: customer_phone ?? null }; }; @@ -239,6 +260,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('event_date').optional({ values: 'falsy' }).isDate(), body('customer_name').optional().trim(), body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), body('require_password').optional().isBoolean(), body('password').optional().isString().custom((value, { req }) => { @@ -354,6 +378,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [ const customerName = getCustomerNameFromPayload(req.body); const customerEmail = getCustomerEmailFromPayload(req.body); + // Phone field is opt-in via the global setting (#322). If disabled, + // ignore whatever the client posted — defence in depth against form + // bypass. + const phoneEnabled = await isPhoneFieldEnabled(); + const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null; const customerColumnsAvailable = await hasCustomerContactColumns(); @@ -509,6 +538,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ event_name, event_date: event_date || null, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + ...(customerPhone ? { customer_phone: customerPhone } : {}), host_name: customerName || null, host_email: customerEmail || null, admin_email: admin_email || null, @@ -863,6 +893,9 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne body('allow_user_uploads').optional().isBoolean(), body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(), body('customer_email').optional().isEmail().normalizeEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }) + .isString().trim() + .isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'), body('upload_category_id').optional().custom((value) => { // Accept null, undefined, or integer values if (value === null || value === undefined) return true; @@ -961,6 +994,19 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne } } + // Phone is gated on the global toggle (#322). Strip from the update + // unconditionally if disabled — even null/clear is rejected so an + // admin can't accidentally write to a field they've turned off. + if (Object.prototype.hasOwnProperty.call(updates, 'customer_phone')) { + const phoneEnabled = await isPhoneFieldEnabled(); + if (!phoneEnabled) { + delete updates.customer_phone; + } else { + const nextPhone = getCustomerPhoneFromPayload(updates); + updates.customer_phone = nextPhone || null; + } + } + const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password'); let requirePasswordUpdate; if (hasRequirePasswordUpdate) { diff --git a/backend/src/routes/publicCMS.js b/backend/src/routes/publicCMS.js index 8a69f27a..b999ed0f 100644 --- a/backend/src/routes/publicCMS.js +++ b/backend/src/routes/publicCMS.js @@ -22,6 +22,9 @@ router.get('/pages/:slug', async (req, res) => { title, content, slug: page.slug, + // Per-page logo override (#324). Null means "fall back to global + // branding logo" — the consumer decides. + logo_url: page.logo_url || null, updated_at: page.updated_at }); } catch (error) { diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index ff7441e1..1d689b3d 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -16,7 +16,8 @@ router.get('/', async (req, res) => { .orWhereIn('setting_key', [ 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'event_default_require_password', - 'gallery_show_filter_bar' + 'gallery_show_filter_bar', + 'event_phone_field_enabled' ]); }) .select('setting_key', 'setting_value'); @@ -84,6 +85,8 @@ router.get('/', async (req, res) => { event_require_expiration: settingsObject.event_require_expiration !== false, // Default value for "Require password" toggle in event creation form event_default_require_password: settingsObject.event_default_require_password !== false, + // Phone-number field on events is opt-in (#322). + event_phone_field_enabled: settingsObject.event_phone_field_enabled === true, // Whether to show the search/sort filter bar in public galleries (default: true) gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false, // Upload settings (safe to expose - needed for client-side validation) diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js new file mode 100644 index 00000000..2c214f64 --- /dev/null +++ b/backend/src/routes/v1/events.js @@ -0,0 +1,447 @@ +/** + * Public v1 API — events + photo upload + share link. + * + * Surface chosen for the n8n / automation use case (#322): create gallery, + * upload photos, get a share URL. Intentionally narrow — update/delete + * are admin-only via the UI for v1. Mounts under /api/v1 with apiTokenAuth. + * + * Each route is annotated with @openapi JSDoc that swagger-jsdoc picks + * up to generate docs/openapi.yaml — the source of truth for picpeak-docs. + */ + +const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const fsSync = require('fs'); +const crypto = require('crypto'); +const multer = require('multer'); +const sharp = require('sharp'); +const { body, query, validationResult } = require('express-validator'); +const { db, logActivity } = require('../../database/db'); +const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth'); +const { buildShareLinkVariants } = require('../../services/shareLinkService'); +const { generateThumbnail } = require('../../services/imageProcessor'); +const logger = require('../../utils/logger'); + +const router = express.Router(); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); + +// ────────────────────────────────────────────────────────────────────────── +// Multer for single-photo upload. Lean — no replace-by-name, no batching. +// ────────────────────────────────────────────────────────────────────────── +const photoStorage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + const tempDir = path.join(getStoragePath(), 'temp'); + await fs.mkdir(tempDir, { recursive: true }); + cb(null, tempDir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `v1_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`); + } +}); +const photoUpload = multer({ + storage: photoStorage, + limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file for v1 + fileFilter: (_req, file, cb) => { + if (/^image\//.test(file.mimetype)) cb(null, true); + else cb(new Error('Only image uploads are accepted on this endpoint')); + } +}); + +const slugify = (s) => + String(s).toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + +// ────────────────────────────────────────────────────────────────────────── +// POST /events — create event +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events: + * post: + * tags: [Events] + * summary: Create a gallery event + * description: Returns the new event's id, slug, and absolute share URL. + * security: [{ bearerAuth: [] }] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [event_name, event_type] + * properties: + * event_name: { type: string } + * event_type: + * type: string + * enum: [wedding, birthday, corporate, other, family] + * event_date: { type: string, format: date, nullable: true } + * customer_name: { type: string, nullable: true } + * customer_email: { type: string, format: email, nullable: true } + * customer_phone: { type: string, nullable: true, description: "Only persisted when the global phone-field setting is enabled." } + * admin_email: { type: string, format: email, nullable: true } + * require_password: { type: boolean, default: true } + * password: { type: string, nullable: true, description: "Required when require_password is true." } + * expires_at: { type: string, format: date-time, nullable: true } + * responses: + * 201: + * description: Event created + * content: + * application/json: + * schema: + * type: object + * properties: + * id: { type: integer } + * slug: { type: string } + * share_url: { type: string, format: uri } + * share_token: { type: string } + * 400: { description: Validation error } + * 401: { description: Missing/invalid token } + * 403: { description: Token lacks admin scope } + */ +router.post( + '/events', + apiTokenAuth, + requireApiScope('admin'), + [ + body('event_name').isString().trim().notEmpty(), + body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']), + body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(), + body('customer_name').optional({ nullable: true }).isString(), + body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }).isString().isLength({ max: 32 }), + body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(), + body('require_password').optional().isBoolean(), + body('password').optional({ nullable: true }).isString().isLength({ min: 6 }), + body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { + event_name, event_type, event_date, + customer_name = null, customer_email = null, customer_phone = null, + admin_email = null, require_password = true, password, + expires_at = null + } = req.body; + + if (require_password && (!password || password.length < 6)) { + return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' }); + } + + // Honour global phone-field toggle (#322). + let persistPhone = null; + if (customer_phone) { + const setting = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + const enabled = setting ? JSON.parse(setting.setting_value) === true : false; + persistPhone = enabled ? customer_phone : null; + } + + // Generate unique slug. + const baseSlug = `${event_type}-${slugify(event_name)}-${event_date || crypto.randomBytes(3).toString('hex')}`; + let slug = baseSlug; + let counter = 1; + while (await db('events').where({ slug }).first()) slug = `${baseSlug}-${counter++}`; + + const shareToken = crypto.randomBytes(16).toString('hex'); + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + + // password_hash is NOT NULL; use a random placeholder when no + // password is required so the column constraint is satisfied. + const bcrypt = require('bcrypt'); + const passwordHash = require_password + ? await bcrypt.hash(password, 10) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10); + + const insertResult = await db('events').insert({ + slug, + event_type, + event_name, + event_date: event_date || null, + host_name: customer_name, + host_email: customer_email, + admin_email, + password_hash: passwordHash, + require_password, + share_link: shareLinkToStore, + share_token: shareToken, + expires_at: expires_at || null, + created_at: new Date().toISOString(), + created_by: req.admin.id, + is_draft: false, + ...(customer_name ? { customer_name } : {}), + ...(customer_email ? { customer_email } : {}), + ...(persistPhone ? { customer_phone: persistPhone } : {}) + }).returning('id'); + const id = insertResult[0]?.id || insertResult[0]; + + await logActivity('event_created', { via: 'api_v1', event_type }, id, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + + res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken }); + } catch (error) { + logger.error('v1 POST /events failed', { error: error.message, stack: error.stack }); + res.status(500).json({ error: 'Failed to create event', detail: error.message }); + } + } +); + +// ────────────────────────────────────────────────────────────────────────── +// GET /events — list +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events: + * get: + * tags: [Events] + * summary: List gallery events (paginated) + * security: [{ bearerAuth: [] }] + * parameters: + * - in: query + * name: page + * schema: { type: integer, minimum: 1, default: 1 } + * - in: query + * name: limit + * schema: { type: integer, minimum: 1, maximum: 100, default: 25 } + * responses: + * 200: + * description: Paginated list + * content: + * application/json: + * schema: + * type: object + * properties: + * events: + * type: array + * items: { $ref: '#/components/schemas/EventSummary' } + * pagination: + * type: object + * properties: + * page: { type: integer } + * limit: { type: integer } + * total: { type: integer } + */ +router.get( + '/events', + apiTokenAuth, + requireApiScope('read'), + [ + query('page').optional().isInt({ min: 1 }).toInt(), + query('limit').optional().isInt({ min: 1, max: 100 }).toInt() + ], + async (req, res) => { + try { + const page = req.query.page || 1; + const limit = req.query.limit || 25; + const offset = (page - 1) * limit; + + const [events, totalRow] = await Promise.all([ + db('events') + .select('id', 'slug', 'event_name', 'event_type', 'event_date', 'expires_at', + 'is_active', 'is_archived', 'is_draft', 'created_at') + .orderBy('created_at', 'desc') + .limit(limit) + .offset(offset), + db('events').count('id as count').first() + ]); + const total = parseInt(totalRow?.count || 0, 10); + res.json({ events, pagination: { page, limit, total } }); + } catch (error) { + logger.error('v1 GET /events failed', { error: error.message }); + res.status(500).json({ error: 'Failed to list events' }); + } + } +); + +// ────────────────────────────────────────────────────────────────────────── +// GET /events/:id — read +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events/{id}: + * get: + * tags: [Events] + * summary: Get a single event + * security: [{ bearerAuth: [] }] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: { description: Event details } + * 404: { description: Not found } + */ +router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res) => { + try { + const event = await db('events').where({ id: req.params.id }).first(); + if (!event) return res.status(404).json({ error: 'Event not found' }); + delete event.password_hash; + delete event.client_password_hash; + res.json(event); + } catch (error) { + logger.error('v1 GET /events/:id failed', { error: error.message }); + res.status(500).json({ error: 'Failed to fetch event' }); + } +}); + +// ────────────────────────────────────────────────────────────────────────── +// POST /events/:id/photos — upload one photo +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events/{id}/photos: + * post: + * tags: [Photos] + * summary: Upload a single photo to an event + * security: [{ bearerAuth: [] }] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: [photo] + * properties: + * photo: { type: string, format: binary } + * responses: + * 201: + * description: Photo uploaded + * content: + * application/json: + * schema: + * type: object + * properties: + * id: { type: integer } + * filename: { type: string } + * path: { type: string } + * thumbnail_path: { type: string, nullable: true } + * size_bytes: { type: integer } + * 400: { description: No file or invalid type } + * 404: { description: Event not found } + */ +router.post( + '/events/:id/photos', + apiTokenAuth, + requireApiScope('write'), + photoUpload.single('photo'), + async (req, res) => { + let tempPath = null; + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded under field "photo"' }); + tempPath = req.file.path; + + const event = await db('events').where({ id: req.params.id }).first(); + if (!event) return res.status(404).json({ error: 'Event not found' }); + + const finalDir = path.join(getStoragePath(), 'events/active', event.slug); + await fs.mkdir(finalDir, { recursive: true }); + const ext = path.extname(req.file.originalname); + const finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`; + const finalPath = path.join(finalDir, finalName); + await fs.rename(tempPath, finalPath); + tempPath = null; + + const stat = fsSync.statSync(finalPath); + const relPath = path.relative(path.join(getStoragePath(), 'events/active'), finalPath); + + let thumbRel = null; + try { + const thumbPath = await generateThumbnail(finalPath); + thumbRel = path.relative(getStoragePath(), thumbPath); + } catch (err) { + logger.warn('v1 thumbnail generation failed', { err: err.message }); + } + + // Detect image dimensions for masonry layouts. + let width = null; + let height = null; + try { + const meta = await sharp(finalPath).metadata(); + width = meta.width || null; + height = meta.height || null; + } catch { /* non-fatal */ } + + const insertResult = await db('photos').insert({ + event_id: event.id, + filename: finalName, + original_filename: req.file.originalname, + path: relPath, + thumbnail_path: thumbRel, + type: 'individual', + size_bytes: stat.size, + width, + height, + media_type: 'image', + mime_type: req.file.mimetype, + uploaded_at: new Date().toISOString() + }).returning('id'); + const id = insertResult[0]?.id || insertResult[0]; + + await logActivity('photo_uploaded', { via: 'api_v1', filename: finalName }, event.id, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + + res.status(201).json({ id, filename: finalName, path: relPath, thumbnail_path: thumbRel, size_bytes: stat.size }); + } catch (error) { + logger.error('v1 POST /events/:id/photos failed', { error: error.message }); + if (tempPath) await fs.unlink(tempPath).catch(() => {}); + res.status(500).json({ error: 'Failed to upload photo' }); + } + } +); + +// ────────────────────────────────────────────────────────────────────────── +// GET /events/:id/share-link — full URL for sending to guests +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events/{id}/share-link: + * get: + * tags: [Events] + * summary: Get the absolute share URL for an event + * security: [{ bearerAuth: [] }] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: Share URL + * content: + * application/json: + * schema: + * type: object + * properties: + * slug: { type: string } + * share_token: { type: string } + * share_url: { type: string, format: uri } + * 404: { description: Not found } + */ +router.get('/events/:id/share-link', apiTokenAuth, requireApiScope('read'), async (req, res) => { + try { + const event = await db('events').where({ id: req.params.id }).first(); + if (!event) return res.status(404).json({ error: 'Event not found' }); + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + res.json({ slug: event.slug, share_token: event.share_token, share_url: shareUrl }); + } catch (error) { + logger.error('v1 GET /events/:id/share-link failed', { error: error.message }); + res.status(500).json({ error: 'Failed to build share link' }); + } +}); + +module.exports = router; diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 00000000..f340e68e --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,416 @@ +{ + "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_`. Scopes: `read`, `write`, `admin`." + } + }, + "schemas": { + "EventSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "event_name": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "event_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "is_active": { + "type": "boolean" + }, + "is_archived": { + "type": "boolean" + }, + "is_draft": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/events": { + "post": { + "tags": [ + "Events" + ], + "summary": "Create a gallery event", + "description": "Returns the new event's id, slug, and absolute share URL.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "event_name", + "event_type" + ], + "properties": { + "event_name": { + "type": "string" + }, + "event_type": { + "type": "string", + "enum": [ + "wedding", + "birthday", + "corporate", + "other", + "family" + ] + }, + "event_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "customer_name": { + "type": "string", + "nullable": true + }, + "customer_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "customer_phone": { + "type": "string", + "nullable": true, + "description": "Only persisted when the global phone-field setting is enabled." + }, + "admin_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "require_password": { + "type": "boolean", + "default": true + }, + "password": { + "type": "string", + "nullable": true, + "description": "Required when require_password is true." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Event created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "share_url": { + "type": "string", + "format": "uri" + }, + "share_token": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Validation error" + }, + "401": { + "description": "Missing/invalid token" + }, + "403": { + "description": "Token lacks admin scope" + } + } + }, + "get": { + "tags": [ + "Events" + ], + "summary": "List gallery events (paginated)", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "Paginated list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventSummary" + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + }, + "/events/{id}": { + "get": { + "tags": [ + "Events" + ], + "summary": "Get a single event", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Event details" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/events/{id}/photos": { + "post": { + "tags": [ + "Photos" + ], + "summary": "Upload a single photo to an event", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "photo" + ], + "properties": { + "photo": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Photo uploaded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "path": { + "type": "string" + }, + "thumbnail_path": { + "type": "string", + "nullable": true + }, + "size_bytes": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "No file or invalid type" + }, + "404": { + "description": "Event not found" + } + } + } + }, + "/events/{id}/share-link": { + "get": { + "tags": [ + "Events" + ], + "summary": "Get the absolute share URL for an event", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Share URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "share_token": { + "type": "string" + }, + "share_url": { + "type": "string", + "format": "uri" + } + } + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + } + }, + "tags": [] +} \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 00000000..ddf8a8ea --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,270 @@ +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_`. + Scopes: `read`, `write`, `admin`. + schemas: + EventSummary: + type: object + properties: + id: + type: integer + slug: + type: string + event_name: + type: string + event_type: + type: string + event_date: + type: string + format: date + nullable: true + expires_at: + type: string + format: date-time + nullable: true + is_active: + type: boolean + is_archived: + type: boolean + is_draft: + type: boolean + created_at: + type: string + format: date-time +security: + - bearerAuth: [] +paths: + /events: + post: + tags: + - Events + summary: Create a gallery event + description: Returns the new event's id, slug, and absolute share URL. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - event_name + - event_type + properties: + event_name: + type: string + event_type: + type: string + enum: + - wedding + - birthday + - corporate + - other + - family + event_date: + type: string + format: date + nullable: true + customer_name: + type: string + nullable: true + customer_email: + type: string + format: email + nullable: true + customer_phone: + type: string + nullable: true + description: Only persisted when the global phone-field setting is enabled. + admin_email: + type: string + format: email + nullable: true + require_password: + type: boolean + default: true + password: + type: string + nullable: true + description: Required when require_password is true. + expires_at: + type: string + format: date-time + nullable: true + responses: + '201': + description: Event created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + slug: + type: string + share_url: + type: string + format: uri + share_token: + type: string + '400': + description: Validation error + '401': + description: Missing/invalid token + '403': + description: Token lacks admin scope + get: + tags: + - Events + summary: List gallery events (paginated) + security: + - bearerAuth: [] + parameters: + - in: query + name: page + schema: + type: integer + minimum: 1 + default: 1 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + responses: + '200': + description: Paginated list + content: + application/json: + schema: + type: object + properties: + events: + type: array + items: + $ref: '#/components/schemas/EventSummary' + pagination: + type: object + properties: + page: + type: integer + limit: + type: integer + total: + type: integer + /events/{id}: + get: + tags: + - Events + summary: Get a single event + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Event details + '404': + description: Not found + /events/{id}/photos: + post: + tags: + - Photos + summary: Upload a single photo to an event + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - photo + properties: + photo: + type: string + format: binary + responses: + '201': + description: Photo uploaded + content: + application/json: + schema: + type: object + properties: + id: + type: integer + filename: + type: string + path: + type: string + thumbnail_path: + type: string + nullable: true + size_bytes: + type: integer + '400': + description: No file or invalid type + '404': + description: Event not found + /events/{id}/share-link: + get: + tags: + - Events + summary: Get the absolute share URL for an event + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Share URL + content: + application/json: + schema: + type: object + properties: + slug: + type: string + share_token: + type: string + share_url: + type: string + format: uri + '404': + description: Not found +tags: [] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 688150bb..ed0f113f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,7 +30,7 @@ import { } from './pages/admin'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; -import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags } from './components/common'; +import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { getApiBaseUrl } from './utils/url'; @@ -165,6 +165,11 @@ function App() { {/* Default redirect */} } /> + + {/* Customisable 404 (#324) — caught here for any path that + didn't match. Top-level `/:slug` is consumed above by + LegalPage; this picks up deeper unknown paths. */} + } /> diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index 3ad8dfbd..274337f8 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -12,7 +12,6 @@ interface ThemeCustomizerEnhancedProps { onChange: (theme: ThemeConfig) => void; presetName?: string; onPresetChange?: (presetName: string) => void; - isPreviewMode?: boolean; showGalleryLayouts?: boolean; hideActions?: boolean; onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise | void; @@ -76,7 +75,6 @@ export const ThemeCustomizerEnhanced: React.FC = ( onChange, presetName = 'default', onPresetChange, - isPreviewMode = false, showGalleryLayouts = true, hideActions = false, onApply, @@ -125,10 +123,11 @@ export const ThemeCustomizerEnhanced: React.FC = ( onPresetChange('custom'); } - if (isPreviewMode) { - // Include customCss in the propagated theme - onChange({ ...updated, customCss }); - } + // Always propagate to parent so Save sees the latest values (#323). + // The "Apply changes immediately (Live Preview)" toggle controls whether + // the parent applies the theme globally — that gating belongs in the + // parent, not here. + onChange({ ...updated, customCss }); }; const handlePresetSelect = (presetKey: string) => { @@ -140,9 +139,8 @@ export const ThemeCustomizerEnhanced: React.FC = ( if (onPresetChange) { onPresetChange(presetKey); } - if (isPreviewMode) { - onChange(preset.config); - } + // Always propagate; live-apply gating is the parent's concern (#323). + onChange(preset.config); } }; @@ -201,6 +199,7 @@ export const ThemeCustomizerEnhanced: React.FC = (
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => ( + +
+ + + + )} + +
+
+ + setName(e.target.value)} + placeholder={t('settings.apiTokens.namePlaceholder', 'e.g. n8n production')} + /> +
+
+ +
+ {ALL_SCOPES.map((s) => ( + + ))} +
+
+
+ +
+
+

+ {t('settings.apiTokens.scopeHint', 'admin > write > read. A read-only token cannot mutate, even if its owner is super_admin.')} +

+ + + +

+ {t('settings.apiTokens.existing', 'Existing tokens')} +

+ {tokens && tokens.length > 0 ? ( +
+ + + + + + + + + + + + + + {tokens.map((token) => { + const revoked = !!token.revoked_at; + const expired = token.expires_at && new Date(token.expires_at) <= new Date(); + const status = revoked + ? t('settings.apiTokens.statusRevoked', 'Revoked') + : expired + ? t('settings.apiTokens.statusExpired', 'Expired') + : t('settings.apiTokens.statusActive', 'Active'); + return ( + + + + + + + + + + ); + })} + +
{t('settings.apiTokens.name', 'Name')}{t('settings.apiTokens.scopes', 'Scopes')}Preview{t('settings.apiTokens.lastUsed', 'Last used')}{t('settings.apiTokens.created', 'Created')}{t('settings.apiTokens.status', 'Status')}
{token.name}{token.scopes} + pp_live_{token.preview || '••••'}… + + {token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '—'} + + {new Date(token.created_at).toLocaleDateString()} + + + {status} + + + {!revoked && ( + + )} +
+
+ ) : ( +

+ {t('settings.apiTokens.empty', 'No tokens yet. Generate one above to get started.')} +

+ )} +
+ + ); +}; diff --git a/frontend/src/features/settings/tabs/EventsTab.tsx b/frontend/src/features/settings/tabs/EventsTab.tsx index c89c3202..1365d3c7 100644 --- a/frontend/src/features/settings/tabs/EventsTab.tsx +++ b/frontend/src/features/settings/tabs/EventsTab.tsx @@ -187,6 +187,25 @@ export const EventsTab: React.FC = ({ + +
+ +
diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 35f75578..f440bbc7 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../hooks/useLocalizedDate'; import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common'; +import { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock } from '../components/common'; import { useGalleryAuth, useTheme } from '../contexts'; import { useGalleryInfo } from '../hooks/useGallery'; import { GalleryView } from '../components/gallery'; +import { GallerySkeleton } from '../components/gallery/GallerySkeleton'; import { analyticsService } from '../services/analytics.service'; import { galleryService } from '../services'; import { api } from '../config/api'; @@ -258,128 +259,23 @@ export const GalleryPage: React.FC = () => { } }; - // Show loading state + // Show the same skeleton GalleryView uses while photos load, so the + // visitor sees one continuous loading state from URL open to real photos + // instead of three different full-page interstitials (#321). if (isLoadingInfo) { - return ( -
-
- -
-
- ); + return ; } - if (identifierError && !resolvedSlug && !isResolvingIdentifier) { - return ( -
-
- {settingsData?.branding_logo_url && ( -
- {settingsData.branding_company_name -
- )} - -
- - - -

- {t('errors.galleryNotFound')} -

-

- {identifierError} -

-
-
-
- -
-
- - {t('legal.impressum')} - - | - - {t('legal.datenschutz')} - -
-

- Powered by PicPeak -

-
-
-
- ); - } - - // Show error state - if (infoError) { - // Check if it's an archived gallery error - const errorMessage = (infoError as any)?.response?.data?.error; - const isArchived = errorMessage?.includes('archived'); - - return ( -
-
- {/* Logo at top */} - {settingsData?.branding_logo_url && ( -
- {settingsData.branding_company_name -
- )} - -
- - - -

- {t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')} -

-

- {t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')} -

-
-
-
- - {/* Legal Links */} -
-
- - {t('legal.impressum')} - - | - - {t('legal.datenschutz')} - -
-

- Powered by PicPeak -

-
-
-
- ); + // Gallery missing / archived / expired-link / unresolvable identifier all + // collapse into the customisable "gallery-not-found" CMS page (#324). + // Admins can edit the title, body, and logo from the CMS Pages tab; the + // seeded default copy is intentionally generic so any of those reasons + // reads correctly. + if ( + (identifierError && !resolvedSlug && !isResolvingIdentifier) || + infoError + ) { + return ; } // Show expired state @@ -448,6 +344,13 @@ export const GalleryPage: React.FC = () => { return ; } + // Public gallery: auto-login is in flight (or about to fire). Show the + // skeleton instead of the "publicly accessible — loading photos" card so + // visitors see one continuous skeleton until real photos appear (#321). + if (!requiresPassword) { + return ; + } + // Show login form return (
@@ -487,67 +390,40 @@ export const GalleryPage: React.FC = () => { - {requiresPassword ? ( - <> -

{t('auth.enterPassword')}

- -
- setPassword(e.target.value)} - error={loginError || undefined} - autoFocus - className="text-sm sm:text-base" - /> - - setRecaptchaToken(null)} - /> - - - +

{t('auth.enterPassword')}

-

- {t('auth.passwordHint')} -

- - ) : ( -
- {isLoadingSettings ? ( -
- -
- ) : ( - <> -

- {t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')} -

-

- {t('gallery.publicGallerySubtitle', 'Loading the photos now...')} -

-
- -
- - )} - {loginError && ( -

{loginError}

- )} -
- )} +
+ setPassword(e.target.value)} + error={loginError || undefined} + autoFocus + className="text-sm sm:text-base" + /> + + setRecaptchaToken(null)} + /> + + + + +

+ {t('auth.passwordHint')} +

diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 1fb2e86a..2e6c84b9 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -106,9 +106,16 @@ export const BrandingPage: React.FC = () => { setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl })); } - // Try to identify which preset this matches + // Try to identify which preset this matches. Compare only on the + // fields the preset itself defines so saved themes carrying extras + // like a `logoUrl` (preserved through preset changes — see + // handlePresetChange) still match the original preset shape. for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) { - if (JSON.stringify(preset.config) === JSON.stringify(formatted)) { + const keys = Object.keys(preset.config); + const matches = keys.every((k) => + JSON.stringify((preset.config as any)[k]) === JSON.stringify((formatted as any)[k]) + ); + if (matches) { setCurrentThemeName(key); break; } @@ -738,7 +745,6 @@ export const BrandingPage: React.FC = () => { onChange={handleThemeChange} presetName={currentThemeName} onPresetChange={handlePresetChange} - isPreviewMode={isPreviewMode} showGalleryLayouts={true} hideActions={true} /> diff --git a/frontend/src/pages/admin/CMSPage.tsx b/frontend/src/pages/admin/CMSPage.tsx index 804071a1..40c75fbe 100644 --- a/frontend/src/pages/admin/CMSPage.tsx +++ b/frontend/src/pages/admin/CMSPage.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; -import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react'; +import { FileText, Globe, Clock, Sparkles, ShieldCheck, Image as ImageIcon, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { debounce } from 'lodash'; import DOMPurify from 'dompurify'; @@ -11,6 +11,7 @@ import { CMSEditor } from '../../components/admin/CMSEditor'; import { cmsService } from '../../services/cms.service'; import type { CMSPage as CMSPageType } from '../../services/cms.service'; import { settingsService, PublicSiteBranding } from '../../services/settings.service'; +import { buildResourceUrl } from '../../utils/url'; export const CMSPage: React.FC = () => { const { t } = useTranslation(); @@ -181,6 +182,28 @@ export const CMSPage: React.FC = () => { setHasUnsavedChanges(true); }; + // Per-page logo upload (#324). Only meaningful for the customisable + // error pages right now, but harmless if exposed for any slug. + const logoInputRef = useRef(null); + const uploadLogoMutation = useMutation({ + mutationFn: async (file: File) => cmsService.uploadPageLogo(selectedPage, file), + onSuccess: ({ logo_url }) => { + setEditForm(prev => ({ ...prev, logo_url })); + queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); + toast.success(t('cms.logoUploaded', 'Logo uploaded')); + }, + onError: () => toast.error(t('toast.uploadError')), + }); + const clearLogoMutation = useMutation({ + mutationFn: async () => cmsService.clearPageLogo(selectedPage), + onSuccess: () => { + setEditForm(prev => ({ ...prev, logo_url: null })); + queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); + toast.success(t('cms.logoCleared', 'Logo cleared')); + }, + onError: () => toast.error(t('toast.saveError')), + }); + // Warn before leaving with unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -483,7 +506,12 @@ export const CMSPage: React.FC = () => { >
-

{t(`legal.${page.slug}`)}

+ {/* Fall back to the page's own English title for slugs + that don't have a fixed translation key (e.g. the new + not-found / gallery-not-found error pages). */} +

+ {t(`legal.${page.slug}`, { defaultValue: page.title_en || page.slug })} +

/{page.slug}

{selectedPage === page.slug && hasUnsavedChanges && ( @@ -550,7 +578,7 @@ export const CMSPage: React.FC = () => {

- {t('cms.editPage', { page: t(`legal.${selectedPage}`) })} + {t('cms.editPage', { page: t(`legal.${selectedPage}`, { defaultValue: currentPage?.title_en || selectedPage }) })}

{/* Language Tabs */} @@ -603,6 +631,60 @@ export const CMSPage: React.FC = () => { isSaving={updateMutation.isPending} />
+ + {/* Per-page logo override (#324) */} +
+ +

+ {t('cms.pageLogoHelp', 'Optional. If set, used in place of the global branding logo on this page.')} +

+
+ {editForm.logo_url ? ( + Page logo + ) : ( +
+ {t('cms.noLogo', 'no override')} +
+ )} + { + const file = e.target.files?.[0]; + if (file) uploadLogoMutation.mutate(file); + if (logoInputRef.current) logoInputRef.current.value = ''; + }} + /> + + {editForm.logo_url && ( + + )} +
+
{currentPage?.updated_at && ( diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index ce30d945..3aa64bbd 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -35,6 +35,7 @@ interface FormData { event_date: string; customer_name: string; customer_email: string; + customer_phone: string; admin_email: string; require_password: boolean; password: string; @@ -95,6 +96,7 @@ export const CreateEventPage: React.FC = () => { event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format customer_name: '', customer_email: '', + customer_phone: '', admin_email: '', require_password: true, password: '', @@ -177,6 +179,7 @@ export const CreateEventPage: React.FC = () => { // Get field requirements (default to true if not set) const requireCustomerName = publicSettings?.event_require_customer_name !== false; const requireCustomerEmail = publicSettings?.event_require_customer_email !== false; + const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true; const requireAdminEmail = publicSettings?.event_require_admin_email !== false; const requireEventDate = publicSettings?.event_require_event_date !== false; const requireExpiration = publicSettings?.event_require_expiration !== false; @@ -204,13 +207,46 @@ export const CreateEventPage: React.FC = () => { })); }, [publicSettings]); - // Update theme when event type changes + // Apply the global Branding default theme on first load so admins who set a + // site-wide default in Branding actually see it on new events (#323). + const brandingThemeApplied = useRef(false); useEffect(() => { - // Find the selected event type's theme preset - const selectedType = availableEventTypes.find(t => t.value === formData.event_type); - const recommendedPreset = selectedType?.theme_preset || 'default'; + if (brandingThemeApplied.current) return; + const brandingTheme = settings?.theme_config as ThemeConfig | undefined; + if (!brandingTheme || Object.keys(brandingTheme).length === 0) return; + brandingThemeApplied.current = true; - if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) { + // Identify which preset (if any) the Branding theme matches. Compare + // only on the preset's own fields so saved themes carrying extras + // (e.g. logoUrl preserved through preset changes) still match. + let matchedPreset = 'custom'; + for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) { + const keys = Object.keys(preset.config); + const matches = keys.every((k) => + JSON.stringify((preset.config as any)[k]) === JSON.stringify((brandingTheme as any)[k]) + ); + if (matches) { + matchedPreset = key; + break; + } + } + + setFormData(prev => ({ + ...prev, + theme_preset: matchedPreset, + theme_config: brandingTheme + })); + }, [settings]); + + // Update theme when event type changes — but only when the event type has + // an explicit recommended preset. Skip the generic 'default' so the global + // Branding theme isn't clobbered by Classic Grid for event types like + // "Other" (#323). + useEffect(() => { + const selectedType = availableEventTypes.find(t => t.value === formData.event_type); + const recommendedPreset = selectedType?.theme_preset; + + if (recommendedPreset && recommendedPreset !== 'default' && GALLERY_THEME_PRESETS[recommendedPreset]) { setFormData(prev => ({ ...prev, theme_preset: recommendedPreset, @@ -318,6 +354,7 @@ export const CreateEventPage: React.FC = () => { event_date: formData.event_date || undefined, customer_name: formData.customer_name, customer_email: formData.customer_email, + ...(phoneFieldEnabled && formData.customer_phone ? { customer_phone: formData.customer_phone.trim() } : {}), admin_email: formData.admin_email, require_password: formData.require_password, password: formData.require_password ? formData.password : undefined, @@ -525,7 +562,6 @@ export const CreateEventPage: React.FC = () => { onChange={handleThemeChange} presetName={formData.theme_preset} onPresetChange={handlePresetChange} - isPreviewMode={true} showGalleryLayouts={true} hideActions={true} /> @@ -622,6 +658,16 @@ export const CreateEventPage: React.FC = () => { />
+ {phoneFieldEnabled && ( + + )} + { hero_photo_id: number | null; customer_name: string; customer_email: string; + customer_phone: string; source_mode: 'managed' | 'reference'; external_path: string; require_password: boolean; @@ -184,6 +185,7 @@ export const EventDetailsPage: React.FC = () => { hero_photo_id: null, customer_name: '', customer_email: '', + customer_phone: '', source_mode: 'managed', external_path: '', require_password: true, @@ -338,6 +340,7 @@ export const EventDetailsPage: React.FC = () => { queryFn: () => publicSettingsService.getPublicSettings(), }); const requireExpiration = publicSettings?.event_require_expiration !== false; + const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true; // Fetch categories for the event const { data: categories = [] } = useQuery({ @@ -430,6 +433,7 @@ export const EventDetailsPage: React.FC = () => { hero_photo_id: event.hero_photo_id || null, customer_name: event.customer_name || '', customer_email: event.customer_email || '', + customer_phone: (event as any).customer_phone || '', source_mode: event.source_mode === 'reference' ? 'reference' : 'managed', external_path: event.external_path || '', require_password: normalizeRequirePassword(event.require_password), @@ -617,6 +621,11 @@ export const EventDetailsPage: React.FC = () => { if (editForm.customer_email !== undefined && editForm.customer_email !== null && editForm.customer_email.trim()) { updateData.customer_email = editForm.customer_email; } + if (editForm.customer_phone !== undefined) { + // Send empty string as null so an admin can clear the field. Backend + // strips this entirely if the global phone-field toggle is off. + updateData.customer_phone = editForm.customer_phone.trim() || null; + } if (editForm.new_password) { updateData.password = editForm.new_password; @@ -970,6 +979,20 @@ export const EventDetailsPage: React.FC = () => { /> + {phoneFieldEnabled && ( +
+ + setEditForm(prev => ({ ...prev, customer_phone: e.target.value }))} + placeholder={t('events.customerPhonePlaceholder', '+1 555 555 1234')} + /> +
+ )} +
); }; diff --git a/frontend/src/services/cms.service.ts b/frontend/src/services/cms.service.ts index bd209104..0feccbc9 100644 --- a/frontend/src/services/cms.service.ts +++ b/frontend/src/services/cms.service.ts @@ -7,6 +7,15 @@ export interface CMSPage { title_de: string; content_en: string; content_de: string; + logo_url: string | null; + updated_at: string; +} + +export interface PublicCMSPage { + title: string; + content: string; + slug: string; + logo_url: string | null; updated_at: string; } @@ -30,10 +39,27 @@ export const cmsService = { }, // Get public CMS page (no auth required) - async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> { - const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, { + async getPublicPage(slug: string, lang: string = 'en'): Promise { + const response = await api.get(`/public/pages/${slug}`, { params: { lang } }); return response.data; + }, + + // Upload a per-page logo (#324) + async uploadPageLogo(slug: string, file: File): Promise<{ logo_url: string }> { + const formData = new FormData(); + formData.append('logo', file); + const response = await api.post<{ logo_url: string }>( + `/admin/cms/pages/${slug}/logo`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + return response.data; + }, + + // Clear a per-page logo override (revert to global branding logo). + async clearPageLogo(slug: string): Promise { + await api.delete(`/admin/cms/pages/${slug}/logo`); } }; \ No newline at end of file diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index f1c72682..41c32464 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -33,6 +33,7 @@ export interface PublicSettings { event_require_expiration?: boolean; event_default_require_password?: boolean; gallery_show_filter_bar?: boolean; + event_phone_field_enabled?: boolean; } export const publicSettingsService = { diff --git a/scripts/sync-api-docs.sh b/scripts/sync-api-docs.sh new file mode 100755 index 00000000..44f0aedd --- /dev/null +++ b/scripts/sync-api-docs.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Local-only API docs sync. Generates docs/openapi.{json,yaml} from +# the @openapi JSDoc blocks in backend/src/routes/v1/*, then copies the +# result into the picpeak-docs Nextra site at /Users/paul/Development/picpeak-docs/app/api/. +# +# Writes only — never commits or pushes the docs repo. Review the diff +# in picpeak-docs and commit there manually when ready. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOCS_REPO="${PICPEAK_DOCS_REPO:-/Users/paul/Development/picpeak-docs}" +SRC_DIR="$REPO_ROOT/docs" +TARGET_DIR="$DOCS_REPO/app/api" + +cd "$REPO_ROOT/backend" + +# 1. Generate fresh spec from JSDoc. +echo "▶ Generating OpenAPI spec from src/routes/v1/*" +node scripts/generate-openapi.js + +# 2. Verify docs repo is reachable. Soft-fail so this doesn't block a +# push when the docs repo isn't on this machine. +if [ ! -d "$DOCS_REPO" ]; then + echo "▶ Docs repo not found at $DOCS_REPO — skipping sync." + echo " (Set PICPEAK_DOCS_REPO to override, or create the path to enable sync.)" + exit 0 +fi +if [ ! -d "$TARGET_DIR" ]; then + echo "▶ Target dir $TARGET_DIR doesn't exist — creating." + mkdir -p "$TARGET_DIR" +fi + +# 3. Copy spec files into the docs repo. We do NOT git-add or commit +# here — the user reviews and commits picpeak-docs manually. +cp "$SRC_DIR/openapi.json" "$TARGET_DIR/openapi.json" +cp "$SRC_DIR/openapi.yaml" "$TARGET_DIR/openapi.yaml" +echo "▶ Wrote openapi.{json,yaml} to $TARGET_DIR" + +# 4. Brief drop-in MDX page that references the spec, so the Nextra +# nav has a stable target. Won't overwrite a hand-edited file — +# only writes if missing. +REF_MDX="$TARGET_DIR/reference.mdx" +if [ ! -f "$REF_MDX" ]; then + cat > "$REF_MDX" <<'EOF' +--- +title: API Reference +--- + +# API Reference + +The PicPeak v1 REST API is documented as an OpenAPI 3 spec. + +- [Download `openapi.yaml`](./openapi.yaml) +- [Download `openapi.json`](./openapi.json) +- A live, browseable Swagger UI is served by every PicPeak instance at + `/api/docs` (admin login required). + +This page is auto-generated from JSDoc annotations on the v1 route files. +Do not hand-edit. The narrative pages (auth, recipes) live alongside. +EOF + echo "▶ Created $REF_MDX (placeholder — replace with your preferred renderer)" +fi + +echo "✓ API docs synced. Review changes in $DOCS_REPO before committing."