Merge pull request #329 from the-luap/feat/post-319-fixes-and-features

Bugfixes, public API, CMS error pages, BMC sponsor link
This commit is contained in:
Paul Nothaft
2026-04-27 22:41:02 +02:00
committed by GitHub
40 changed files with 2826 additions and 280 deletions
+6
View File
@@ -100,6 +100,12 @@ backup/
# Local artifacts from browser tooling # Local artifacts from browser tooling
.playwright-mcp/ .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 # Local SQLite files in backend
backend/*.sqlite* backend/*.sqlite*
backend/*.db backend/*.db
+14 -1
View File
@@ -7,8 +7,9 @@
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) [![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/) [![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/) [![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)
</div> </div>
**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. **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 **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.
<p align="left">
<a href="https://buymeacoffee.com/theluap" target="_blank">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
## 🙏 Acknowledgments ## 🙏 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. 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.
@@ -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();
};
@@ -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_<random>`). 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');
}
};
+205 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.24.1-beta.0", "version": "3.28.3-beta.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.24.1-beta.0", "version": "3.28.3-beta.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
@@ -45,6 +45,8 @@
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"sharp": "0.34.3", "sharp": "0.34.3",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.8.2", "winston": "^3.8.2",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
@@ -57,6 +59,50 @@
"supertest": "^6.3.3" "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": { "node_modules/@aws-crypto/crc32": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -2656,6 +2702,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -2769,6 +2821,13 @@
"@noble/hashes": "^1.1.5" "@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": { "node_modules/@sideway/address": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -3697,6 +3756,12 @@
"@types/istanbul-lib-report": "*" "@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": { "node_modules/@types/node": {
"version": "25.0.3", "version": "25.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
@@ -4515,6 +4580,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -5143,7 +5214,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"esutils": "^2.0.2" "esutils": "^2.0.2"
@@ -5570,7 +5640,6 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5671,6 +5740,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@@ -8023,6 +8093,13 @@
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"license": "MIT" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -8035,6 +8112,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT" "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": { "node_modules/lodash.isinteger": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@@ -8066,6 +8150,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/lodash.once": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
@@ -9029,6 +9119,13 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -10794,6 +10891,71 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tar": {
"version": "7.5.13", "version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
@@ -11507,6 +11669,15 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -11571,6 +11742,36 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/zip-stream": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
+2
View File
@@ -50,6 +50,8 @@
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"sharp": "0.34.3", "sharp": "0.34.3",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.8.2", "winston": "^3.8.2",
"zxcvbn": "^4.4.2" "zxcvbn": "^4.4.2"
+30
View File
@@ -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}`);
+28 -5
View File
@@ -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) => { app.get('/health', async (req, res) => {
try { try {
// Check database connectivity
await db.raw('SELECT 1'); await db.raw('SELECT 1');
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
pid: process.pid,
uptime: process.uptime()
}); });
} catch (error) { } catch (error) {
logger.error('Health check failed:', error); logger.error('Health check failed:', error);
res.status(503).json({ res.status(503).json({
status: 'error', 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/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers')); app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); 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/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/public', require('./src/routes/publicCMS'));
+25
View File
@@ -463,8 +463,15 @@ async function ensureGlobalCategories() {
table.text('title_de'); table.text('title_de');
table.text('content_en'); table.text('content_en');
table.text('content_de'); table.text('content_de');
table.string('logo_url').nullable();
table.timestamp('updated_at').defaultTo(db.fn.now()); 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(); const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
@@ -501,6 +508,24 @@ async function ensureGlobalCategories() {
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>', content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(), 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: '<h2>Page Not Found</h2><p>The page you are looking for does not exist or has been moved.</p>',
content_de: '<h2>Seite nicht gefunden</h2><p>Die gesuchte Seite existiert nicht oder wurde verschoben.</p>',
updated_at: new Date(),
},
{
slug: 'gallery-not-found',
title_en: 'Gallery Not Found',
title_de: 'Galerie nicht gefunden',
content_en: '<h2>Gallery Not Found</h2><p>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.</p>',
content_de: '<h2>Galerie nicht gefunden</h2><p>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.</p>',
updated_at: new Date(),
},
]; ];
for (const page of defaultPages) { for (const page of defaultPages) {
+123
View File
@@ -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
};
+9 -3
View File
@@ -91,10 +91,16 @@ async function adminAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid token' }); 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) { if (admin.password_changed_at) {
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; const passwordChangedSeconds = Math.floor(
if (decoded.iat < passwordChangedTime) { new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
logger.warn('Token used after password change', { userId: decoded.id }); logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({ return res.status(401).json({
error: 'Token invalid due to password change', error: 'Token invalid due to password change',
+70
View File
@@ -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_<random>`. 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 };
+117
View File
@@ -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;
+123 -22
View File
@@ -1,10 +1,42 @@
const express = require('express'); const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const multer = require('multer');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { validateFileType } = require('../utils/fileSecurityUtils');
const router = express.Router(); 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 // Get all CMS pages
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => { router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
try { try {
@@ -21,11 +53,11 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req,
try { try {
const { slug } = req.params; const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first(); const page = await db('cms_pages').where('slug', slug).first();
if (!page) { if (!page) {
return res.status(404).json({ error: 'Page not found' }); return res.status(404).json({ error: 'Page not found' });
} }
res.json(page); res.json(page);
} catch (error) { } catch (error) {
console.error('Error fetching CMS page:', 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_en').optional().isString(),
body('title_de').optional().isString(), body('title_de').optional().isString(),
body('content_en').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) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() }); return res.status(400).json({ errors: errors.array() });
} }
const { slug } = req.params; 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(); const page = await db('cms_pages').where('slug', slug).first();
if (!page) { if (!page) {
return res.status(404).json({ error: 'Page not found' }); return res.status(404).json({ error: 'Page not found' });
} }
// Update the page const updateFields = {
await db('cms_pages') title_en,
.where('slug', slug) title_de,
.update({ content_en,
title_en, content_de,
title_de, updated_at: new Date()
content_en, };
content_de, // Only touch logo_url when explicitly present so partial updates
updated_at: new Date() // (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(); const updated = await db('cms_pages').where('slug', slug).first();
// Log activity
await logActivity('cms_page_updated', await logActivity('cms_page_updated',
{ page: slug }, { page: slug },
null, null,
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
res.json(updated); res.json(updated);
} catch (error) { } catch (error) {
console.error('Error updating CMS page:', error); console.error('Error updating CMS page:', error);
@@ -81,4 +117,69 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
} }
}); });
module.exports = router; // 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;
+47 -1
View File
@@ -185,6 +185,25 @@ const getBrandingDefaults = async () => {
// Use parseStringInput from shared parsers for customer data extraction // Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); 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) => { const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') { if (!event || typeof event !== 'object') {
@@ -196,6 +215,7 @@ const mapEventForApi = (event) => {
host_email, host_email,
customer_name, customer_name,
customer_email, customer_email,
customer_phone,
password_hash: _ph, password_hash: _ph,
client_password_hash: _cph, client_password_hash: _cph,
...rest ...rest
@@ -204,7 +224,8 @@ const mapEventForApi = (event) => {
return { return {
...rest, ...rest,
customer_name: customer_name ?? host_name ?? null, 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('event_date').optional({ values: 'falsy' }).isDate(),
body('customer_name').optional().trim(), body('customer_name').optional().trim(),
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(), 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('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('require_password').optional().isBoolean(), body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => { body('password').optional().isString().custom((value, { req }) => {
@@ -354,6 +378,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
const customerName = getCustomerNameFromPayload(req.body); const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(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(); const customerColumnsAvailable = await hasCustomerContactColumns();
@@ -509,6 +538,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
event_name, event_name,
event_date: event_date || null, event_date: event_date || null,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName || null, host_name: customerName || null,
host_email: customerEmail || null, host_email: customerEmail || null,
admin_email: admin_email || 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('allow_user_uploads').optional().isBoolean(),
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(), body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
body('customer_email').optional().isEmail().normalizeEmail(), 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) => { body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values // Accept null, undefined, or integer values
if (value === null || value === undefined) return true; 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'); const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate; let requirePasswordUpdate;
if (hasRequirePasswordUpdate) { if (hasRequirePasswordUpdate) {
+3
View File
@@ -22,6 +22,9 @@ router.get('/pages/:slug', async (req, res) => {
title, title,
content, content,
slug: page.slug, 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 updated_at: page.updated_at
}); });
} catch (error) { } catch (error) {
+4 -1
View File
@@ -16,7 +16,8 @@ router.get('/', async (req, res) => {
.orWhereIn('setting_key', [ .orWhereIn('setting_key', [
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
'event_default_require_password', 'event_default_require_password',
'gallery_show_filter_bar' 'gallery_show_filter_bar',
'event_phone_field_enabled'
]); ]);
}) })
.select('setting_key', 'setting_value'); .select('setting_key', 'setting_value');
@@ -84,6 +85,8 @@ router.get('/', async (req, res) => {
event_require_expiration: settingsObject.event_require_expiration !== false, event_require_expiration: settingsObject.event_require_expiration !== false,
// Default value for "Require password" toggle in event creation form // Default value for "Require password" toggle in event creation form
event_default_require_password: settingsObject.event_default_require_password !== false, 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) // Whether to show the search/sort filter bar in public galleries (default: true)
gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false, gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false,
// Upload settings (safe to expose - needed for client-side validation) // Upload settings (safe to expose - needed for client-side validation)
+447
View File
@@ -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;
+416
View File
@@ -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_<random>`. 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": []
}
+270
View File
@@ -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_<random>`.
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: []
+6 -1
View File
@@ -30,7 +30,7 @@ import {
} from './pages/admin'; } from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin'; 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 { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { getApiBaseUrl } from './utils/url'; import { getApiBaseUrl } from './utils/url';
@@ -165,6 +165,11 @@ function App() {
{/* Default redirect */} {/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} /> <Route path="/" element={<Navigate to="/admin/login" replace />} />
{/* 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. */}
<Route path="*" element={<CMSContentBlock slug="not-found" />} />
</Routes> </Routes>
</MaintenanceWrapper> </MaintenanceWrapper>
</Router> </Router>
@@ -12,7 +12,6 @@ interface ThemeCustomizerEnhancedProps {
onChange: (theme: ThemeConfig) => void; onChange: (theme: ThemeConfig) => void;
presetName?: string; presetName?: string;
onPresetChange?: (presetName: string) => void; onPresetChange?: (presetName: string) => void;
isPreviewMode?: boolean;
showGalleryLayouts?: boolean; showGalleryLayouts?: boolean;
hideActions?: boolean; hideActions?: boolean;
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void; onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
@@ -76,7 +75,6 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onChange, onChange,
presetName = 'default', presetName = 'default',
onPresetChange, onPresetChange,
isPreviewMode = false,
showGalleryLayouts = true, showGalleryLayouts = true,
hideActions = false, hideActions = false,
onApply, onApply,
@@ -125,10 +123,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onPresetChange('custom'); onPresetChange('custom');
} }
if (isPreviewMode) { // Always propagate to parent so Save sees the latest values (#323).
// Include customCss in the propagated theme // The "Apply changes immediately (Live Preview)" toggle controls whether
onChange({ ...updated, customCss }); // the parent applies the theme globally — that gating belongs in the
} // parent, not here.
onChange({ ...updated, customCss });
}; };
const handlePresetSelect = (presetKey: string) => { const handlePresetSelect = (presetKey: string) => {
@@ -140,9 +139,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
if (onPresetChange) { if (onPresetChange) {
onPresetChange(presetKey); onPresetChange(presetKey);
} }
if (isPreviewMode) { // Always propagate; live-apply gating is the parent's concern (#323).
onChange(preset.config); onChange(preset.config);
}
} }
}; };
@@ -201,6 +199,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => ( {Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
<button <button
type="button"
key={key} key={key}
onClick={() => handlePresetSelect(key)} onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${ className={`relative p-4 rounded-lg border-2 transition-all text-left ${
@@ -283,6 +282,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => ( {(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
<button <button
type="button"
key={layout} key={layout}
onClick={() => handleChange('galleryLayout', layout)} onClick={() => handleChange('galleryLayout', layout)}
className={`relative p-4 rounded-lg border-2 transition-all ${ className={`relative p-4 rounded-lg border-2 transition-all ${
@@ -629,6 +629,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => ( {(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
<button <button
type="button"
key={style} key={style}
onClick={() => handleChange('headerStyle', style)} onClick={() => handleChange('headerStyle', style)}
className={`relative p-4 rounded-lg border-2 transition-all ${ className={`relative p-4 rounded-lg border-2 transition-all ${
@@ -667,6 +668,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => ( {(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => (
<button <button
type="button"
key={divider} key={divider}
onClick={() => handleChange('heroDividerStyle', divider)} onClick={() => handleChange('heroDividerStyle', divider)}
className={`relative p-3 rounded-lg border-2 transition-all ${ className={`relative p-3 rounded-lg border-2 transition-all ${
@@ -707,6 +709,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button <button
type="button"
onClick={() => handleChange('controlsStyle', 'classic')} onClick={() => handleChange('controlsStyle', 'classic')}
className={`relative p-4 rounded-lg border-2 transition-all ${ className={`relative p-4 rounded-lg border-2 transition-all ${
(localTheme.controlsStyle || 'classic') === 'classic' (localTheme.controlsStyle || 'classic') === 'classic'
@@ -730,6 +733,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
)} )}
</button> </button>
<button <button
type="button"
onClick={() => handleChange('controlsStyle', 'sidebar')} onClick={() => handleChange('controlsStyle', 'sidebar')}
className={`relative p-4 rounded-lg border-2 transition-all ${ className={`relative p-4 rounded-lg border-2 transition-all ${
localTheme.controlsStyle === 'sidebar' localTheme.controlsStyle === 'sidebar'
@@ -780,6 +784,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="flex gap-2"> <div className="flex gap-2">
{(['light', 'dark', 'auto'] as const).map((mode) => ( {(['light', 'dark', 'auto'] as const).map((mode) => (
<button <button
type="button"
key={mode} key={mode}
onClick={() => { onClick={() => {
handleChange('colorMode', mode); handleChange('colorMode', mode);
@@ -795,7 +800,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
mutedTextColor: '#a3a3a3', mutedTextColor: '#a3a3a3',
}; };
setLocalTheme(updated); setLocalTheme(updated);
if (isPreviewMode) onChange({ ...updated, customCss }); onChange({ ...updated, customCss });
} else if (mode === 'light' && localTheme.colorMode === 'dark') { } else if (mode === 'light' && localTheme.colorMode === 'dark') {
const updated = { const updated = {
...localTheme, ...localTheme,
@@ -807,7 +812,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
mutedTextColor: '#737373', mutedTextColor: '#737373',
}; };
setLocalTheme(updated); setLocalTheme(updated);
if (isPreviewMode) onChange({ ...updated, customCss }); onChange({ ...updated, customCss });
} }
}} }}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${ className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
@@ -1039,6 +1044,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* No template option */} {/* No template option */}
<button <button
type="button"
onClick={() => onCssTemplateChange(null)} onClick={() => onCssTemplateChange(null)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${ className={`relative p-4 rounded-lg border-2 transition-all text-left ${
!cssTemplateId !cssTemplateId
@@ -1059,6 +1065,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Template options */} {/* Template options */}
{cssTemplates.map((template) => ( {cssTemplates.map((template) => (
<button <button
type="button"
key={template.id} key={template.id}
onClick={() => onCssTemplateChange(template.id)} onClick={() => onCssTemplateChange(template.id)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${ className={`relative p-4 rounded-lg border-2 transition-all text-left ${
@@ -1181,10 +1188,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
setSelectedPreset('custom'); setSelectedPreset('custom');
onPresetChange('custom'); onPresetChange('custom');
} }
// Propagate customCss changes to parent in preview mode // Propagate to parent so Save sees the latest CSS (#323).
if (isPreviewMode) { onChange({ ...localTheme, customCss: newCss });
onChange({ ...localTheme, customCss: newCss });
}
}} }}
placeholder="/* Add custom CSS here */" placeholder="/* Add custom CSS here */"
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
@@ -0,0 +1,134 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { Card } from './Card';
import { Loading } from './Loading';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
import { buildResourceUrl } from '../../utils/url';
import '../../styles/prose-overrides.css';
interface CMSContentBlockProps {
/** CMS page slug, e.g. "not-found" or "gallery-not-found". */
slug: string;
/** Rendered when the slug doesn't exist or the fetch fails so the
* caller is never left with a blank screen during cold deployments. */
fallback?: React.ReactNode;
}
const ALLOWED_TAGS = [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
'code', 'pre', 'hr', 'div', 'span', 'img',
];
const ALLOWED_ATTR = ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', 'title'];
/**
* Renders a CMS page inside the standard branded shell. Used for the
* customisable 404 and gallery-not-found pages (#324). Logo precedence:
* per-page logo → global branding logo → bundled placeholder.
*/
export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback }) => {
const { i18n } = useTranslation();
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000,
});
const lang = settings?.default_language || i18n.language || 'en';
const { data: page, isLoading, error } = useQuery({
queryKey: ['cms-public-page', slug, lang],
queryFn: () => cmsService.getPublicPage(slug, lang),
enabled: !!slug,
retry: false,
});
useEffect(() => {
if (page?.title) document.title = `${page.title} - ${settings?.branding_company_name || 'PicPeak'}`;
}, [page?.title, settings?.branding_company_name]);
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<Loading size="lg" />
</div>
);
}
if (error || !page) {
return <>{fallback ?? null}</>;
}
// Logo: per-page override beats global branding logo.
const rawLogo = page.logo_url || settings?.branding_logo_url || '/picpeak-logo-transparent.png';
const logoSrc = rawLogo.startsWith('http') || rawLogo.startsWith('/picpeak-')
? rawLogo
: buildResourceUrl(rawLogo);
const companyName = settings?.branding_company_name || 'PicPeak';
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="p-8 text-center">
<img
src={logoSrc}
alt={companyName}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
<main className="flex-1 flex items-start justify-center px-4">
<div className="max-w-2xl w-full">
<Card padding="lg">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-6">
{page.title}
</h1>
<div
className="prose prose-neutral dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.content, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
}),
}}
/>
<div className="mt-8">
<Link
to="/"
className="text-sm font-medium text-primary-600 hover:text-primary-700"
>
{lang === 'de' ? '← Zur Startseite' : '← Back to home'}
</Link>
</div>
</Card>
</div>
</main>
<footer className="py-8 text-center text-xs text-neutral-500">
<div className="flex justify-center gap-4">
<Link to="/impressum" className="hover:text-neutral-700">
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
</Link>
<span className="text-neutral-400"></span>
<Link to="/datenschutz" className="hover:text-neutral-700">
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
</Link>
</div>
{!settings?.branding_hide_powered_by && (
<p className="mt-2">
Powered by <span className="font-semibold">PicPeak</span>
</p>
)}
</footer>
</div>
);
};
+1
View File
@@ -1,4 +1,5 @@
export { Button } from './Button'; export { Button } from './Button';
export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input'; export { Input } from './Input';
export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading'; export { Loading, LoadingSkeleton } from './Loading';
@@ -0,0 +1,31 @@
import React from 'react';
import { Skeleton, SkeletonGalleryGrid } from '../common';
/**
* Loading placeholder shown while a gallery is resolving (slug → info →
* auto-login → photos). Used by GalleryPage during the pre-photos phases and
* by GalleryView while the photos query runs, so the visitor sees one
* continuous skeleton instead of multiple full-page interstitials (#321).
*/
export const GallerySkeleton: React.FC = () => (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<header className="bg-surface border-b border-surface sticky top-0 z-40">
<div className="container py-4">
<div className="flex items-center justify-between">
<div>
<Skeleton height={32} width={200} className="mb-2" />
<Skeleton height={20} width={300} />
</div>
<div className="flex items-center gap-2">
<Skeleton height={40} width={120} />
<Skeleton height={40} width={100} />
</div>
</div>
</div>
</header>
<div className="container mt-6">
<Skeleton height={80} className="mb-6" />
<SkeletonGalleryGrid count={12} />
</div>
</div>
);
@@ -3,7 +3,8 @@ import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, SkeletonGalleryGrid, Skeleton } from '../common'; import { Button } from '../common';
import { GallerySkeleton } from './GallerySkeleton';
import { useGalleryAuth, useTheme } from '../../contexts'; import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery'; import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts'; import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
@@ -587,31 +588,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}, [showUrgentWarning, daysUntilExpiration, slug]); }, [showUrgentWarning, daysUntilExpiration, slug]);
if (isLoading) { if (isLoading) {
return ( return <GallerySkeleton />;
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
{/* Header Skeleton */}
<header className="bg-surface border-b border-surface sticky top-0 z-40">
<div className="container py-4">
<div className="flex items-center justify-between">
<div>
<Skeleton height={32} width={200} className="mb-2" />
<Skeleton height={20} width={300} />
</div>
<div className="flex items-center gap-2">
<Skeleton height={40} width={120} />
<Skeleton height={40} width={100} />
</div>
</div>
</div>
</header>
{/* Content Skeleton */}
<div className="container mt-6">
<Skeleton height={80} className="mb-6" />
<SkeletonGalleryGrid count={12} />
</div>
</div>
);
} }
if (error || !data) { if (error || !data) {
+16 -3
View File
@@ -11,6 +11,12 @@ import { getApiBaseUrl } from '../utils/url';
// Maintenance mode callback // Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null; let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
// Set true the moment we kick off a hard redirect to /admin/login so
// subsequent 401s in the same tick don't queue more navigations on top
// (each `window.location.href = …` aborts the previous, producing a
// flicker storm — see the response interceptor below).
let adminLoginRedirectPending = false;
export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => { export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => {
maintenanceModeCallback = callback; maintenanceModeCallback = callback;
}; };
@@ -131,10 +137,17 @@ api.interceptors.response.use(
// Check if it's an admin route (but not public endpoints) // Check if it's an admin route (but not public endpoints)
const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/'); const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/');
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
if (isAdminRoute) { if (isAdminRoute) {
// Only redirect if we're not already on the admin login page // Only redirect if we're not already on the admin login page.
if (!currentPath.includes('/admin/login')) { // `window.location.href = …` is async — `pathname` doesn't change
// synchronously — so a fan-out of 401s (the dashboard fires 7 admin
// queries in parallel) would each see the old pathname and each
// call `location.href`, producing a navigation storm where every
// request is aborted by the next. Guard with a module-level flag
// so only the first 401 triggers the redirect.
if (!currentPath.includes('/admin/login') && !adminLoginRedirectPending) {
adminLoginRedirectPending = true;
window.location.href = '/admin/login'; window.location.href = '/admin/login';
} }
} else { } else {
@@ -52,6 +52,7 @@ export interface EventSettings {
event_require_expiration: boolean; event_require_expiration: boolean;
event_default_require_password: boolean; event_default_require_password: boolean;
gallery_show_filter_bar: boolean; gallery_show_filter_bar: boolean;
event_phone_field_enabled: boolean;
} }
export interface SeoSettings { export interface SeoSettings {
@@ -127,7 +128,8 @@ export function useSettingsState() {
event_require_event_date: true, event_require_event_date: true,
event_require_expiration: true, event_require_expiration: true,
event_default_require_password: true, event_default_require_password: true,
gallery_show_filter_bar: true gallery_show_filter_bar: true,
event_phone_field_enabled: false
}); });
// SEO settings state // SEO settings state
@@ -212,7 +214,8 @@ export function useSettingsState() {
event_require_event_date: toBoolean(settings.event_require_event_date, true), event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true), event_require_expiration: toBoolean(settings.event_require_expiration, true),
event_default_require_password: toBoolean(settings.event_default_require_password, true), event_default_require_password: toBoolean(settings.event_default_require_password, true),
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true) gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true),
event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false)
}); });
setSeoSettings({ setSeoSettings({
+1
View File
@@ -15,3 +15,4 @@ export { ModerationTab } from './tabs/ModerationTab';
export { StylingTab } from './tabs/StylingTab'; export { StylingTab } from './tabs/StylingTab';
export { SEOTab } from './tabs/SEOTab'; export { SEOTab } from './tabs/SEOTab';
export { ThumbnailsTab } from './tabs/ThumbnailsTab'; export { ThumbnailsTab } from './tabs/ThumbnailsTab';
export { ApiTokensTab } from './tabs/ApiTokensTab';
@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { KeyRound, Trash2, Copy, AlertTriangle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
interface ApiTokenRow {
id: number;
name: string;
scopes: string;
preview: string | null;
created_at: string;
expires_at: string | null;
last_used_at: string | null;
revoked_at: string | null;
owner_username: string | null;
}
const ALL_SCOPES: Array<'read' | 'write' | 'admin'> = ['read', 'write', 'admin'];
/**
* Admin tab for managing API tokens (#322). Lists active tokens, lets
* admins generate new ones (plaintext shown ONCE), and revokes them.
* The plaintext token is returned only on creation — there is no way
* to retrieve it again, by design.
*/
export const ApiTokensTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [scopes, setScopes] = useState<Array<'read' | 'write' | 'admin'>>(['read']);
const [justCreatedToken, setJustCreatedToken] = useState<string | null>(null);
const { data: tokens, isLoading } = useQuery({
queryKey: ['admin-api-tokens'],
queryFn: async () => {
const res = await api.get<ApiTokenRow[]>('/admin/api-tokens');
return res.data;
},
});
const createMutation = useMutation({
mutationFn: async () => {
const res = await api.post<{ token: string }>('/admin/api-tokens', { name, scopes });
return res.data.token;
},
onSuccess: (token) => {
setJustCreatedToken(token);
setName('');
setScopes(['read']);
queryClient.invalidateQueries({ queryKey: ['admin-api-tokens'] });
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || t('settings.apiTokens.createError', 'Failed to create token'));
},
});
const revokeMutation = useMutation({
mutationFn: async (id: number) => api.delete(`/admin/api-tokens/${id}`),
onSuccess: () => {
toast.success(t('settings.apiTokens.revoked', 'Token revoked'));
queryClient.invalidateQueries({ queryKey: ['admin-api-tokens'] });
},
onError: () => toast.error(t('toast.saveError')),
});
const toggleScope = (scope: 'read' | 'write' | 'admin') => {
setScopes((prev) => (prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope]));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loading size="lg" />
</div>
);
}
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2 flex items-center gap-2">
<KeyRound className="w-5 h-5" />
{t('settings.apiTokens.title', 'API Tokens')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.apiTokens.subtitle', 'Long-lived bearer tokens for the public /api/v1 surface — n8n integrations, custom apps, scripts. Tokens act as the admin user that minted them, intersected with the chosen scopes.')}
</p>
{justCreatedToken && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200 mb-1">
{t('settings.apiTokens.copyNow', 'Copy this token now — it will not be shown again.')}
</p>
<div className="flex items-center gap-2">
<code className="block flex-1 min-w-0 px-3 py-2 bg-white dark:bg-neutral-900 border border-amber-300 dark:border-amber-700 rounded text-xs font-mono break-all">
{justCreatedToken}
</code>
<Button
size="sm"
variant="outline"
leftIcon={<Copy className="w-4 h-4" />}
onClick={async () => {
try {
await navigator.clipboard.writeText(justCreatedToken);
toast.success(t('settings.apiTokens.copied', 'Copied'));
} catch {
toast.error(t('settings.apiTokens.copyFailed', 'Copy failed'));
}
}}
>
{t('events.copy', 'Copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setJustCreatedToken(null)}>
{t('common.dismiss', 'Dismiss')}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end mb-2">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.apiTokens.name', 'Name')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('settings.apiTokens.namePlaceholder', 'e.g. n8n production')}
/>
</div>
<div className="md:col-span-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.apiTokens.scopes', 'Scopes')}
</label>
<div className="flex gap-3 pt-2">
{ALL_SCOPES.map((s) => (
<label key={s} className="flex items-center gap-1.5 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
checked={scopes.includes(s)}
onChange={() => toggleScope(s)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
{s}
</label>
))}
</div>
</div>
<div className="md:col-span-1">
<Button
variant="primary"
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
disabled={!name.trim() || scopes.length === 0}
>
{t('settings.apiTokens.generate', 'Generate Token')}
</Button>
</div>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.apiTokens.scopeHint', 'admin > write > read. A read-only token cannot mutate, even if its owner is super_admin.')}
</p>
</Card>
<Card padding="md">
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
{t('settings.apiTokens.existing', 'Existing tokens')}
</h3>
{tokens && tokens.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<th className="py-2 pr-3">{t('settings.apiTokens.name', 'Name')}</th>
<th className="py-2 pr-3">{t('settings.apiTokens.scopes', 'Scopes')}</th>
<th className="py-2 pr-3">Preview</th>
<th className="py-2 pr-3">{t('settings.apiTokens.lastUsed', 'Last used')}</th>
<th className="py-2 pr-3">{t('settings.apiTokens.created', 'Created')}</th>
<th className="py-2 pr-3">{t('settings.apiTokens.status', 'Status')}</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{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 (
<tr key={token.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0">
<td className="py-3 pr-3 font-medium">{token.name}</td>
<td className="py-3 pr-3 text-neutral-600 dark:text-neutral-400">{token.scopes}</td>
<td className="py-3 pr-3 font-mono text-xs text-neutral-500">
pp_live_{token.preview || '••••'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '—'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{new Date(token.created_at).toLocaleDateString()}
</td>
<td className="py-3 pr-3">
<span className={`text-xs px-2 py-0.5 rounded ${
revoked || expired
? 'bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
}`}>
{status}
</span>
</td>
<td className="py-3 text-right">
{!revoked && (
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(t('settings.apiTokens.confirmRevoke', `Revoke "${token.name}"? Existing integrations using this token will start getting 401.`))) {
revokeMutation.mutate(token.id);
}
}}
>
{t('settings.apiTokens.revoke', 'Revoke')}
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.apiTokens.empty', 'No tokens yet. Generate one above to get started.')}
</p>
)}
</Card>
</div>
);
};
@@ -187,6 +187,25 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div> </div>
</label> </label>
</div> </div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_phone_field_enabled}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_phone_field_enabled: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.enablePhoneField', 'Enable phone number field')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.enablePhoneFieldHelp', 'Adds an optional phone number input to the event form. Useful for downstream automations like WhatsApp delivery via n8n. Always optional even when enabled.')}
</p>
</div>
</label>
</div>
</div> </div>
<div className="mt-6"> <div className="mt-6">
+56 -180
View File
@@ -6,10 +6,11 @@ import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate'; import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query'; 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 { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery'; import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery'; import { GalleryView } from '../components/gallery';
import { GallerySkeleton } from '../components/gallery/GallerySkeleton';
import { analyticsService } from '../services/analytics.service'; import { analyticsService } from '../services/analytics.service';
import { galleryService } from '../services'; import { galleryService } from '../services';
import { api } from '../config/api'; 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) { if (isLoadingInfo) {
return ( return <GallerySkeleton />;
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
</div>
);
} }
if (identifierError && !resolvedSlug && !isResolvingIdentifier) { // Gallery missing / archived / expired-link / unresolvable identifier all
return ( // collapse into the customisable "gallery-not-found" CMS page (#324).
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}> // Admins can edit the title, body, and logo from the CMS Pages tab; the
<div className="min-h-screen flex flex-col"> // seeded default copy is intentionally generic so any of those reasons
{settingsData?.branding_logo_url && ( // reads correctly.
<div className="p-8 text-center"> if (
<img (identifierError && !resolvedSlug && !isResolvingIdentifier) ||
src={buildResourceUrl(settingsData.branding_logo_url)} infoError
alt={settingsData.branding_company_name || 'Company Logo'} ) {
className="h-16 w-auto object-contain mx-auto" return <CMSContentBlock slug="gallery-not-found" />;
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">
{t('errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{identifierError}
</p>
</CardContent>
</Card>
</div>
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
}
// 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 (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">
{t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
} }
// Show expired state // Show expired state
@@ -448,6 +344,13 @@ export const GalleryPage: React.FC = () => {
return <GalleryView slug={gallerySlugForView} event={event} />; return <GalleryView slug={gallerySlugForView} event={event} />;
} }
// 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 <GallerySkeleton />;
}
// Show login form // Show login form
return ( return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}> <div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
@@ -487,67 +390,40 @@ export const GalleryPage: React.FC = () => {
<Card> <Card>
<CardContent className="p-4 sm:p-6"> <CardContent className="p-4 sm:p-6">
{requiresPassword ? ( <h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<>
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
className="text-sm sm:text-base"
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-sm sm:text-base"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6"> <form onSubmit={handleLogin} className="space-y-4">
{t('auth.passwordHint')} <Input
</p> type="password"
</> label={t('auth.password')}
) : ( placeholder={t('auth.passwordPlaceholder')}
<div className="text-center space-y-3"> value={password}
{isLoadingSettings ? ( onChange={(e) => setPassword(e.target.value)}
<div className="flex justify-center py-4"> error={loginError || undefined}
<Loading size="sm" /> autoFocus
</div> className="text-sm sm:text-base"
) : ( />
<>
<h2 className="text-base sm:text-lg lg:text-xl font-semibold"> <ReCaptcha
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')} onChange={setRecaptchaToken}
</h2> onExpired={() => setRecaptchaToken(null)}
<p className="text-sm text-neutral-600"> />
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
</p> <Button
<div className="flex justify-center py-4"> type="submit"
<Loading size="sm" text={t('gallery.loading')} /> variant="primary"
</div> size="lg"
</> className="w-full text-sm sm:text-base"
)} isLoading={isLoggingIn}
{loginError && ( disabled={isLoggingIn}
<p className="text-xs text-red-600">{loginError}</p> >
)} {t('gallery.viewGallery')}
</div> </Button>
)} </form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
</CardContent> </CardContent>
</Card> </Card>
+9 -3
View File
@@ -106,9 +106,16 @@ export const BrandingPage: React.FC = () => {
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl })); 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)) { 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); setCurrentThemeName(key);
break; break;
} }
@@ -738,7 +745,6 @@ export const BrandingPage: React.FC = () => {
onChange={handleThemeChange} onChange={handleThemeChange}
presetName={currentThemeName} presetName={currentThemeName}
onPresetChange={handlePresetChange} onPresetChange={handlePresetChange}
isPreviewMode={isPreviewMode}
showGalleryLayouts={true} showGalleryLayouts={true}
hideActions={true} hideActions={true}
/> />
+86 -4
View File
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify'; 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 { useTranslation } from 'react-i18next';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@@ -11,6 +11,7 @@ import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service'; import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service'; import type { CMSPage as CMSPageType } from '../../services/cms.service';
import { settingsService, PublicSiteBranding } from '../../services/settings.service'; import { settingsService, PublicSiteBranding } from '../../services/settings.service';
import { buildResourceUrl } from '../../utils/url';
export const CMSPage: React.FC = () => { export const CMSPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -181,6 +182,28 @@ export const CMSPage: React.FC = () => {
setHasUnsavedChanges(true); 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<HTMLInputElement>(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 // Warn before leaving with unsaved changes
useEffect(() => { useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => { const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -483,7 +506,12 @@ export const CMSPage: React.FC = () => {
> >
<FileText className="w-5 h-5 flex-shrink-0" /> <FileText className="w-5 h-5 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p> {/* 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). */}
<p className="font-medium truncate">
{t(`legal.${page.slug}`, { defaultValue: page.title_en || page.slug })}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{page.slug}</p> <p className="text-sm text-neutral-500 dark:text-neutral-400">/{page.slug}</p>
</div> </div>
{selectedPage === page.slug && hasUnsavedChanges && ( {selectedPage === page.slug && hasUnsavedChanges && (
@@ -550,7 +578,7 @@ export const CMSPage: React.FC = () => {
<Card padding="md"> <Card padding="md">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })} {t('cms.editPage', { page: t(`legal.${selectedPage}`, { defaultValue: currentPage?.title_en || selectedPage }) })}
</h2> </h2>
{/* Language Tabs */} {/* Language Tabs */}
@@ -603,6 +631,60 @@ export const CMSPage: React.FC = () => {
isSaving={updateMutation.isPending} isSaving={updateMutation.isPending}
/> />
</div> </div>
{/* Per-page logo override (#324) */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('cms.pageLogo', 'Page Logo')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('cms.pageLogoHelp', 'Optional. If set, used in place of the global branding logo on this page.')}
</p>
<div className="flex items-center gap-4">
{editForm.logo_url ? (
<img
src={buildResourceUrl(editForm.logo_url)}
alt="Page logo"
className="h-16 w-auto object-contain bg-neutral-50 dark:bg-neutral-700 rounded border border-neutral-200 dark:border-neutral-600 px-3 py-1"
/>
) : (
<div className="h-16 w-32 flex items-center justify-center bg-neutral-50 dark:bg-neutral-700 rounded border border-dashed border-neutral-300 dark:border-neutral-600 text-xs text-neutral-500 dark:text-neutral-400">
{t('cms.noLogo', 'no override')}
</div>
)}
<input
ref={logoInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/svg+xml"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadLogoMutation.mutate(file);
if (logoInputRef.current) logoInputRef.current.value = '';
}}
/>
<Button
variant="outline"
size="sm"
leftIcon={<ImageIcon className="w-4 h-4" />}
onClick={() => logoInputRef.current?.click()}
isLoading={uploadLogoMutation.isPending}
>
{editForm.logo_url ? t('cms.replaceLogo', 'Replace Logo') : t('cms.uploadLogo', 'Upload Logo')}
</Button>
{editForm.logo_url && (
<Button
variant="ghost"
size="sm"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => clearLogoMutation.mutate()}
isLoading={clearLogoMutation.isPending}
>
{t('cms.clearLogo', 'Use site default')}
</Button>
)}
</div>
</div>
</div> </div>
{currentPage?.updated_at && ( {currentPage?.updated_at && (
+52 -6
View File
@@ -35,6 +35,7 @@ interface FormData {
event_date: string; event_date: string;
customer_name: string; customer_name: string;
customer_email: string; customer_email: string;
customer_phone: string;
admin_email: string; admin_email: string;
require_password: boolean; require_password: boolean;
password: string; password: string;
@@ -95,6 +96,7 @@ export const CreateEventPage: React.FC = () => {
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
customer_name: '', customer_name: '',
customer_email: '', customer_email: '',
customer_phone: '',
admin_email: '', admin_email: '',
require_password: true, require_password: true,
password: '', password: '',
@@ -177,6 +179,7 @@ export const CreateEventPage: React.FC = () => {
// Get field requirements (default to true if not set) // Get field requirements (default to true if not set)
const requireCustomerName = publicSettings?.event_require_customer_name !== false; const requireCustomerName = publicSettings?.event_require_customer_name !== false;
const requireCustomerEmail = publicSettings?.event_require_customer_email !== 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 requireAdminEmail = publicSettings?.event_require_admin_email !== false;
const requireEventDate = publicSettings?.event_require_event_date !== false; const requireEventDate = publicSettings?.event_require_event_date !== false;
const requireExpiration = publicSettings?.event_require_expiration !== false; const requireExpiration = publicSettings?.event_require_expiration !== false;
@@ -204,13 +207,46 @@ export const CreateEventPage: React.FC = () => {
})); }));
}, [publicSettings]); }, [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(() => { useEffect(() => {
// Find the selected event type's theme preset if (brandingThemeApplied.current) return;
const selectedType = availableEventTypes.find(t => t.value === formData.event_type); const brandingTheme = settings?.theme_config as ThemeConfig | undefined;
const recommendedPreset = selectedType?.theme_preset || 'default'; 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 => ({ setFormData(prev => ({
...prev, ...prev,
theme_preset: recommendedPreset, theme_preset: recommendedPreset,
@@ -318,6 +354,7 @@ export const CreateEventPage: React.FC = () => {
event_date: formData.event_date || undefined, event_date: formData.event_date || undefined,
customer_name: formData.customer_name, customer_name: formData.customer_name,
customer_email: formData.customer_email, customer_email: formData.customer_email,
...(phoneFieldEnabled && formData.customer_phone ? { customer_phone: formData.customer_phone.trim() } : {}),
admin_email: formData.admin_email, admin_email: formData.admin_email,
require_password: formData.require_password, require_password: formData.require_password,
password: formData.require_password ? formData.password : undefined, password: formData.require_password ? formData.password : undefined,
@@ -525,7 +562,6 @@ export const CreateEventPage: React.FC = () => {
onChange={handleThemeChange} onChange={handleThemeChange}
presetName={formData.theme_preset} presetName={formData.theme_preset}
onPresetChange={handlePresetChange} onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true} showGalleryLayouts={true}
hideActions={true} hideActions={true}
/> />
@@ -622,6 +658,16 @@ export const CreateEventPage: React.FC = () => {
/> />
</div> </div>
{phoneFieldEnabled && (
<Input
type="tel"
label={`${t('events.customerPhone', 'Customer Phone')} (${t('common.optional')})`}
placeholder={t('events.customerPhonePlaceholder', '+1 555 555 1234')}
value={formData.customer_phone}
onChange={handleInputChange('customer_phone')}
/>
)}
<Input <Input
type="email" type="email"
label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`} label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`}
@@ -149,6 +149,7 @@ export const EventDetailsPage: React.FC = () => {
hero_photo_id: number | null; hero_photo_id: number | null;
customer_name: string; customer_name: string;
customer_email: string; customer_email: string;
customer_phone: string;
source_mode: 'managed' | 'reference'; source_mode: 'managed' | 'reference';
external_path: string; external_path: string;
require_password: boolean; require_password: boolean;
@@ -184,6 +185,7 @@ export const EventDetailsPage: React.FC = () => {
hero_photo_id: null, hero_photo_id: null,
customer_name: '', customer_name: '',
customer_email: '', customer_email: '',
customer_phone: '',
source_mode: 'managed', source_mode: 'managed',
external_path: '', external_path: '',
require_password: true, require_password: true,
@@ -338,6 +340,7 @@ export const EventDetailsPage: React.FC = () => {
queryFn: () => publicSettingsService.getPublicSettings(), queryFn: () => publicSettingsService.getPublicSettings(),
}); });
const requireExpiration = publicSettings?.event_require_expiration !== false; const requireExpiration = publicSettings?.event_require_expiration !== false;
const phoneFieldEnabled = publicSettings?.event_phone_field_enabled === true;
// Fetch categories for the event // Fetch categories for the event
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
@@ -430,6 +433,7 @@ export const EventDetailsPage: React.FC = () => {
hero_photo_id: event.hero_photo_id || null, hero_photo_id: event.hero_photo_id || null,
customer_name: event.customer_name || '', customer_name: event.customer_name || '',
customer_email: event.customer_email || '', customer_email: event.customer_email || '',
customer_phone: (event as any).customer_phone || '',
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed', source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
external_path: event.external_path || '', external_path: event.external_path || '',
require_password: normalizeRequirePassword(event.require_password), 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()) { if (editForm.customer_email !== undefined && editForm.customer_email !== null && editForm.customer_email.trim()) {
updateData.customer_email = editForm.customer_email; 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) { if (editForm.new_password) {
updateData.password = editForm.new_password; updateData.password = editForm.new_password;
@@ -970,6 +979,20 @@ export const EventDetailsPage: React.FC = () => {
/> />
</div> </div>
{phoneFieldEnabled && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.customerPhone', 'Customer Phone')} ({t('common.optional')})
</label>
<Input
type="tel"
value={editForm.customer_phone}
onChange={(e) => setEditForm(prev => ({ ...prev, customer_phone: e.target.value }))}
placeholder={t('events.customerPhonePlaceholder', '+1 555 555 1234')}
/>
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.expirationDate')} {t('events.expirationDate')}
+5 -1
View File
@@ -14,9 +14,10 @@ import {
StylingTab, StylingTab,
SEOTab, SEOTab,
ThumbnailsTab, ThumbnailsTab,
ApiTokensTab,
} from '../../features/settings'; } from '../../features/settings';
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling'; type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling' | 'apiTokens';
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabType>('general'); const [activeTab, setActiveTab] = useState<TabType>('general');
@@ -81,6 +82,7 @@ export const SettingsPage: React.FC = () => {
{ key: 'analytics', label: t('settings.analytics.title') }, { key: 'analytics', label: t('settings.analytics.title') },
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') }, { key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS') }, { key: 'styling', label: t('settings.styling.title', 'Custom CSS') },
{ key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens') },
]; ];
return ( return (
@@ -185,6 +187,8 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'moderation' && <ModerationTab />} {activeTab === 'moderation' && <ModerationTab />}
{activeTab === 'styling' && <StylingTab />} {activeTab === 'styling' && <StylingTab />}
{activeTab === 'apiTokens' && <ApiTokensTab />}
</div> </div>
); );
}; };
+28 -2
View File
@@ -7,6 +7,15 @@ export interface CMSPage {
title_de: string; title_de: string;
content_en: string; content_en: string;
content_de: 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; updated_at: string;
} }
@@ -30,10 +39,27 @@ export const cmsService = {
}, },
// Get public CMS page (no auth required) // Get public CMS page (no auth required)
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> { async getPublicPage(slug: string, lang: string = 'en'): Promise<PublicCMSPage> {
const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, { const response = await api.get<PublicCMSPage>(`/public/pages/${slug}`, {
params: { lang } params: { lang }
}); });
return response.data; 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<void> {
await api.delete(`/admin/cms/pages/${slug}/logo`);
} }
}; };
@@ -33,6 +33,7 @@ export interface PublicSettings {
event_require_expiration?: boolean; event_require_expiration?: boolean;
event_default_require_password?: boolean; event_default_require_password?: boolean;
gallery_show_filter_bar?: boolean; gallery_show_filter_bar?: boolean;
event_phone_field_enabled?: boolean;
} }
export const publicSettingsService = { export const publicSettingsService = {
+65
View File
@@ -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."