From 808b15bafbcdab6ea55aff7f0e507153f513a70a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 18:58:54 +0200 Subject: [PATCH] feat: public v1 API + token management + OpenAPI docs (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a long-lived bearer-token mechanism + scoped REST surface designed for n8n-style automation: create a gallery, upload photos, fetch the share URL — all via documented HTTPS endpoints instead of poking at the admin UI's internal routes. API - Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK, last_used/expires/revoked timestamps). - New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`, resolves to the owner admin user, attaches `req.admin` so existing permission decorators (events.create etc.) still work. Token-level scope check (read/write/admin) layers on top as defence in depth — a leaked read-only token cannot mutate even if its owner is super_admin. - adminApiTokens route exposes list/create/revoke for admins (cookie- authed). Plaintext token is returned exactly once on creation. - v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id, POST /events/:id/photos (multipart, single file), GET /events/:id/share-link. Each endpoint annotated with @openapi JSDoc. Documentation - swagger-jsdoc + swagger-ui-express produce a live spec at /api/openapi.json and a Swagger UI at /api/docs (admin-gated). - backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml} to the repo so the spec is versioned. - scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and copies it into the picpeak-docs Nextra site at app/api/. Writes only, never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass). Frontend - New Settings → API Tokens tab: generate, list, revoke. Plaintext tokens are shown once with a copy-to-clipboard control. --- backend/migrations/core/081_add_api_tokens.js | 41 ++ backend/package-lock.json | 209 +++++++- backend/package.json | 2 + backend/scripts/generate-openapi.js | 30 ++ backend/server.js | 20 + backend/src/middleware/apiTokenAuth.js | 123 +++++ backend/src/openapi/spec.js | 70 +++ backend/src/routes/adminApiTokens.js | 117 +++++ backend/src/routes/v1/events.js | 447 ++++++++++++++++++ docs/openapi.json | 416 ++++++++++++++++ docs/openapi.yaml | 270 +++++++++++ frontend/src/features/settings/index.ts | 1 + .../features/settings/tabs/ApiTokensTab.tsx | 252 ++++++++++ frontend/src/pages/admin/SettingsPage.tsx | 6 +- scripts/sync-api-docs.sh | 65 +++ 15 files changed, 2064 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/core/081_add_api_tokens.js create mode 100644 backend/scripts/generate-openapi.js create mode 100644 backend/src/middleware/apiTokenAuth.js create mode 100644 backend/src/openapi/spec.js create mode 100644 backend/src/routes/adminApiTokens.js create mode 100644 backend/src/routes/v1/events.js create mode 100644 docs/openapi.json create mode 100644 docs/openapi.yaml create mode 100644 frontend/src/features/settings/tabs/ApiTokensTab.tsx create mode 100755 scripts/sync-api-docs.sh diff --git a/backend/migrations/core/081_add_api_tokens.js b/backend/migrations/core/081_add_api_tokens.js new file mode 100644 index 00000000..000dbfb9 --- /dev/null +++ b/backend/migrations/core/081_add_api_tokens.js @@ -0,0 +1,41 @@ +/** + * #322 — long-lived API tokens for programmatic access (n8n, custom + * integrations, external apps). Each token belongs to an admin user; the + * token's effective permissions are the *intersection* of the user's + * role permissions and the token's own scope flags. That way revoking + * the user revokes the token, and scope flags let an admin issue a + * read-only token even if their account is super_admin. + */ + +exports.up = async function up(knex) { + if (!(await knex.schema.hasTable('api_tokens'))) { + await knex.schema.createTable('api_tokens', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + // SHA-256 of the full token string (`pp_live_`). Lookup + // hashes the incoming Authorization header and queries by this. + table.string('hashed_token', 64).notNullable().unique(); + // Scope flags — comma-separated subset of: read, write, admin. + // 'read' allows GETs; 'write' adds POST/PATCH/DELETE on + // event/photo data; 'admin' allows creating/deleting events and + // anything else gated by admin.* permissions. + table.string('scopes', 64).notNullable().defaultTo('read'); + table.integer('created_by').notNullable() + .references('id').inTable('admin_users').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('expires_at').nullable(); + table.timestamp('last_used_at').nullable(); + table.timestamp('revoked_at').nullable(); + // Cosmetic for the admin UI: first 8 chars of the plaintext + // token (after the prefix) so admins can identify which token is + // which without seeing the secret half. + table.string('preview', 16).nullable(); + }); + } +}; + +exports.down = async function down(knex) { + if (await knex.schema.hasTable('api_tokens')) { + await knex.schema.dropTable('api_tokens'); + } +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 4eead2c4..aa148a96 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-backend", - "version": "3.24.1-beta.0", + "version": "3.28.3-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.24.1-beta.0", + "version": "3.28.3-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", @@ -45,6 +45,8 @@ "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", "winston": "^3.8.2", "zxcvbn": "^4.4.2" @@ -57,6 +59,50 @@ "supertest": "^6.3.3" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -2656,6 +2702,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2769,6 +2821,13 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -3697,6 +3756,12 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", @@ -4515,6 +4580,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5143,7 +5214,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -5570,7 +5640,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -5671,6 +5740,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8023,6 +8093,13 @@ "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8035,6 +8112,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -8066,6 +8150,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -9029,6 +9119,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10794,6 +10891,71 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.5.tgz", + "integrity": "sha512-7/FQfWe9A4qoyYFdAwy0chD0uDYidDp/ZT9VQ9LZlgD4AnnHJk8/+ytAA1HkJYOPySmK6helPDdJQMlcumt7HA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -11507,6 +11669,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -11571,6 +11742,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zip-stream": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index 6080ebef..5baaad29 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,8 @@ "sanitize-html": "^2.17.0", "sharp": "0.34.3", "sqlite3": "^5.1.6", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "uuid": "^11.1.0", "winston": "^3.8.2", "zxcvbn": "^4.4.2" diff --git a/backend/scripts/generate-openapi.js b/backend/scripts/generate-openapi.js new file mode 100644 index 00000000..a4a775b9 --- /dev/null +++ b/backend/scripts/generate-openapi.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Generate the OpenAPI spec from JSDoc annotations in src/routes/v1/* and + * write it as YAML + JSON to ../docs/. Used by scripts/sync-api-docs.sh + * to keep the picpeak-docs site in lockstep with the running API. + */ + +const fs = require('fs'); +const path = require('path'); + +// Need yaml — runtime require so the script fails clearly with an +// install hint instead of an opaque MODULE_NOT_FOUND. +let yaml; +try { + yaml = require('js-yaml'); +} catch { + console.error('generate-openapi: missing dependency `js-yaml`. Run `npm install --save-dev js-yaml` in /backend.'); + process.exit(2); +} + +const { getOpenApiSpec } = require('../src/openapi/spec'); + +const outDir = path.resolve(__dirname, '../../docs'); +fs.mkdirSync(outDir, { recursive: true }); + +const spec = getOpenApiSpec(); +fs.writeFileSync(path.join(outDir, 'openapi.json'), JSON.stringify(spec, null, 2)); +fs.writeFileSync(path.join(outDir, 'openapi.yaml'), yaml.dump(spec, { lineWidth: 100 })); + +console.log(`Wrote openapi.json + openapi.yaml to ${outDir}`); diff --git a/backend/server.js b/backend/server.js index ba62cc4a..2ee73c79 100644 --- a/backend/server.js +++ b/backend/server.js @@ -532,6 +532,26 @@ app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/admin/users', require('./src/routes/adminUsers')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); +app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); +// Public v1 API for n8n / external integrations (#322). Mounted under +// /api/v1; auth handled per-route via apiTokenAuth (Bearer tokens). +app.use('/api/v1', require('./src/routes/v1/events')); + +// Swagger UI for the v1 API. Admin-gated since it lists endpoint shapes +// that should not be enumerable to anonymous users (a common reduce-info-leak hardening). +{ + const swaggerUi = require('swagger-ui-express'); + const { adminAuth } = require('./src/middleware/auth'); + const { getOpenApiSpec } = require('./src/openapi/spec'); + app.get('/api/openapi.json', adminAuth, (_req, res) => res.json(getOpenApiSpec())); + app.use( + '/api/docs', + adminAuth, + swaggerUi.serve, + swaggerUi.setup(getOpenApiSpec(), { customSiteTitle: 'PicPeak API · v1' }) + ); +} + app.use('/api/invite', require('./src/routes/acceptInvite')); app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public', require('./src/routes/publicCMS')); diff --git a/backend/src/middleware/apiTokenAuth.js b/backend/src/middleware/apiTokenAuth.js new file mode 100644 index 00000000..c3d826af --- /dev/null +++ b/backend/src/middleware/apiTokenAuth.js @@ -0,0 +1,123 @@ +const crypto = require('crypto'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +const TOKEN_PREFIX = 'pp_live_'; +const VALID_SCOPES = ['read', 'write', 'admin']; + +function hashToken(plaintext) { + return crypto.createHash('sha256').update(plaintext).digest('hex'); +} + +/** + * Generate a new API token. Returns the plaintext (return once, never + * stored) plus the row payload to insert. Caller persists. + */ +function generateApiToken() { + const random = crypto.randomBytes(24).toString('base64url'); // 32 chars + const plaintext = `${TOKEN_PREFIX}${random}`; + return { + plaintext, + hashed: hashToken(plaintext), + preview: random.slice(0, 8) + }; +} + +function parseScopes(raw) { + if (!raw) return []; + return String(raw) + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter((s) => VALID_SCOPES.includes(s)); +} + +/** + * Middleware: authenticate via API token. Maps the token to its owner + * admin user, attaches { req.admin, req.apiToken }, then defers to the + * regular permission machinery on top. + * + * Mount this *instead* of `adminAuth` on /api/v1/* routes. Existing + * permission decorators (`requirePermission('events.create')`) still + * work because they read `req.admin.id`. + */ +async function apiTokenAuth(req, res, next) { + try { + const header = req.headers?.authorization || ''; + if (!header.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing API token', code: 'NO_TOKEN' }); + } + const token = header.slice(7).trim(); + if (!token.startsWith(TOKEN_PREFIX)) { + return res.status(401).json({ error: 'Invalid token format', code: 'INVALID_TOKEN' }); + } + + const hashed = hashToken(token); + const row = await db('api_tokens').where({ hashed_token: hashed }).first(); + if (!row) { + return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' }); + } + if (row.revoked_at) { + return res.status(401).json({ error: 'Token revoked', code: 'TOKEN_REVOKED' }); + } + if (row.expires_at && new Date(row.expires_at) <= new Date()) { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); + } + + const admin = await db('admin_users') + .where({ id: row.created_by, is_active: true }) + .select('id', 'username', 'email', 'role_id') + .first(); + if (!admin) { + return res.status(401).json({ error: 'Token owner unavailable', code: 'OWNER_INACTIVE' }); + } + + // Touch last_used_at — async, don't block the request. + db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() }) + .catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message })); + + req.admin = admin; + req.apiToken = { + id: row.id, + name: row.name, + scopes: parseScopes(row.scopes) + }; + return next(); + } catch (error) { + logger.error('apiTokenAuth error', { error: error.message }); + return res.status(500).json({ error: 'Authentication error' }); + } +} + +/** + * Middleware factory: require a specific scope on the API token. Use + * after apiTokenAuth — `requireApiScope('write')` rejects read-only + * tokens trying to mutate. + */ +function requireApiScope(scope) { + return (req, res, next) => { + const have = req.apiToken?.scopes || []; + // 'admin' implies write/read; 'write' implies read. + const expanded = new Set(have); + if (have.includes('admin')) ['write', 'read'].forEach((s) => expanded.add(s)); + if (have.includes('write')) expanded.add('read'); + if (!expanded.has(scope)) { + return res.status(403).json({ + error: `Token lacks required scope: ${scope}`, + code: 'INSUFFICIENT_SCOPE', + required: scope, + granted: have + }); + } + next(); + }; +} + +module.exports = { + apiTokenAuth, + requireApiScope, + generateApiToken, + hashToken, + parseScopes, + TOKEN_PREFIX, + VALID_SCOPES +}; diff --git a/backend/src/openapi/spec.js b/backend/src/openapi/spec.js new file mode 100644 index 00000000..62d8086d --- /dev/null +++ b/backend/src/openapi/spec.js @@ -0,0 +1,70 @@ +/** + * OpenAPI 3.1 spec for /api/v1/* (#322). Source of truth for the + * picpeak-docs reference page. Built from JSDoc `@openapi` blocks + * scattered through src/routes/v1 — those stay co-located with the + * routes they describe so the spec can't drift in isolation. + */ + +const swaggerJSDoc = require('swagger-jsdoc'); +const path = require('path'); + +const baseDoc = { + openapi: '3.0.3', + info: { + title: 'PicPeak API', + version: 'v1', + description: + 'Public REST API for PicPeak — create gallery events, upload photos, fetch share links. ' + + 'Authenticate with a Bearer token issued via the admin **Settings → API Tokens** tab.' + }, + servers: [ + { url: '/api/v1', description: 'Same-origin (production)' } + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'pp_live_*', + description: + 'Long-lived API token. Issue via Settings → API Tokens. ' + + 'Token format: `pp_live_`. Scopes: `read`, `write`, `admin`.' + } + }, + schemas: { + EventSummary: { + type: 'object', + properties: { + id: { type: 'integer' }, + slug: { type: 'string' }, + event_name: { type: 'string' }, + event_type: { type: 'string' }, + event_date: { type: 'string', format: 'date', nullable: true }, + expires_at: { type: 'string', format: 'date-time', nullable: true }, + is_active: { type: 'boolean' }, + is_archived: { type: 'boolean' }, + is_draft: { type: 'boolean' }, + created_at: { type: 'string', format: 'date-time' } + } + } + } + }, + security: [{ bearerAuth: [] }] +}; + +const options = { + definition: baseDoc, + // Pull @openapi blocks from every v1 route file. + apis: [path.join(__dirname, '../routes/v1/**/*.js')] +}; + +let cached = null; + +function getOpenApiSpec() { + if (!cached) { + cached = swaggerJSDoc(options); + } + return cached; +} + +module.exports = { getOpenApiSpec }; diff --git a/backend/src/routes/adminApiTokens.js b/backend/src/routes/adminApiTokens.js new file mode 100644 index 00000000..b2a74d2a --- /dev/null +++ b/backend/src/routes/adminApiTokens.js @@ -0,0 +1,117 @@ +/** + * Admin endpoints for managing API tokens (#322). Tokens are issued to + * an admin user; subsequent /api/v1/* calls authenticate via the token + * and act as the user that minted it (intersected with the token's + * scope set). Plaintext tokens are returned ONCE on creation. + */ + +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('./../middleware/auth'); +const { requirePermission } = require('./../middleware/permissions'); +const { generateApiToken, VALID_SCOPES } = require('./../middleware/apiTokenAuth'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +// List tokens for the current admin (or all, if super_admin) — without +// the plaintext, never recoverable after creation. +router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const tokens = await db('api_tokens') + .leftJoin('admin_users', 'admin_users.id', 'api_tokens.created_by') + .select( + 'api_tokens.id', + 'api_tokens.name', + 'api_tokens.scopes', + 'api_tokens.preview', + 'api_tokens.created_at', + 'api_tokens.expires_at', + 'api_tokens.last_used_at', + 'api_tokens.revoked_at', + 'admin_users.username as owner_username' + ) + .orderBy('api_tokens.created_at', 'desc'); + res.json(tokens); + } catch (error) { + logger.error('Failed to list API tokens', { error: error.message }); + res.status(500).json({ error: 'Failed to list tokens' }); + } +}); + +// Create a token. Returns plaintext exactly once. +router.post( + '/', + adminAuth, + requirePermission('settings.edit'), + [ + body('name').isString().trim().isLength({ min: 1, max: 100 }), + body('scopes').isArray({ min: 1 }).custom((arr) => { + const ok = arr.every((s) => VALID_SCOPES.includes(s)); + if (!ok) throw new Error(`Scopes must be a subset of: ${VALID_SCOPES.join(', ')}`); + return true; + }), + body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const { name, scopes, expires_at } = req.body; + const { plaintext, hashed, preview } = generateApiToken(); + + const insertResult = await db('api_tokens').insert({ + name, + hashed_token: hashed, + scopes: scopes.join(','), + preview, + created_by: req.admin.id, + expires_at: expires_at || null + }).returning('id'); + const id = insertResult[0]?.id || insertResult[0]; + + await logActivity('api_token_created', { name, scopes }, null, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + + // Return the plaintext exactly once. + res.status(201).json({ + id, + name, + scopes, + token: plaintext, + preview, + expires_at: expires_at || null, + created_at: new Date().toISOString(), + notice: 'Save this token now — it will not be shown again.' + }); + } catch (error) { + logger.error('Failed to create API token', { error: error.message }); + res.status(500).json({ error: 'Failed to create token' }); + } + } +); + +// Revoke a token (soft-delete; lookups still find it but reject). +router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const { id } = req.params; + const row = await db('api_tokens').where({ id }).first(); + if (!row) return res.status(404).json({ error: 'Token not found' }); + if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' }); + + await db('api_tokens').where({ id }).update({ revoked_at: new Date() }); + await logActivity('api_token_revoked', { name: row.name }, null, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + res.json({ id: Number(id), revoked: true }); + } catch (error) { + logger.error('Failed to revoke API token', { error: error.message }); + res.status(500).json({ error: 'Failed to revoke token' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js new file mode 100644 index 00000000..2c214f64 --- /dev/null +++ b/backend/src/routes/v1/events.js @@ -0,0 +1,447 @@ +/** + * Public v1 API — events + photo upload + share link. + * + * Surface chosen for the n8n / automation use case (#322): create gallery, + * upload photos, get a share URL. Intentionally narrow — update/delete + * are admin-only via the UI for v1. Mounts under /api/v1 with apiTokenAuth. + * + * Each route is annotated with @openapi JSDoc that swagger-jsdoc picks + * up to generate docs/openapi.yaml — the source of truth for picpeak-docs. + */ + +const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const fsSync = require('fs'); +const crypto = require('crypto'); +const multer = require('multer'); +const sharp = require('sharp'); +const { body, query, validationResult } = require('express-validator'); +const { db, logActivity } = require('../../database/db'); +const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth'); +const { buildShareLinkVariants } = require('../../services/shareLinkService'); +const { generateThumbnail } = require('../../services/imageProcessor'); +const logger = require('../../utils/logger'); + +const router = express.Router(); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); + +// ────────────────────────────────────────────────────────────────────────── +// Multer for single-photo upload. Lean — no replace-by-name, no batching. +// ────────────────────────────────────────────────────────────────────────── +const photoStorage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + const tempDir = path.join(getStoragePath(), 'temp'); + await fs.mkdir(tempDir, { recursive: true }); + cb(null, tempDir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `v1_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`); + } +}); +const photoUpload = multer({ + storage: photoStorage, + limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file for v1 + fileFilter: (_req, file, cb) => { + if (/^image\//.test(file.mimetype)) cb(null, true); + else cb(new Error('Only image uploads are accepted on this endpoint')); + } +}); + +const slugify = (s) => + String(s).toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + +// ────────────────────────────────────────────────────────────────────────── +// POST /events — create event +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events: + * post: + * tags: [Events] + * summary: Create a gallery event + * description: Returns the new event's id, slug, and absolute share URL. + * security: [{ bearerAuth: [] }] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [event_name, event_type] + * properties: + * event_name: { type: string } + * event_type: + * type: string + * enum: [wedding, birthday, corporate, other, family] + * event_date: { type: string, format: date, nullable: true } + * customer_name: { type: string, nullable: true } + * customer_email: { type: string, format: email, nullable: true } + * customer_phone: { type: string, nullable: true, description: "Only persisted when the global phone-field setting is enabled." } + * admin_email: { type: string, format: email, nullable: true } + * require_password: { type: boolean, default: true } + * password: { type: string, nullable: true, description: "Required when require_password is true." } + * expires_at: { type: string, format: date-time, nullable: true } + * responses: + * 201: + * description: Event created + * content: + * application/json: + * schema: + * type: object + * properties: + * id: { type: integer } + * slug: { type: string } + * share_url: { type: string, format: uri } + * share_token: { type: string } + * 400: { description: Validation error } + * 401: { description: Missing/invalid token } + * 403: { description: Token lacks admin scope } + */ +router.post( + '/events', + apiTokenAuth, + requireApiScope('admin'), + [ + body('event_name').isString().trim().notEmpty(), + body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']), + body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(), + body('customer_name').optional({ nullable: true }).isString(), + body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(), + body('customer_phone').optional({ nullable: true, checkFalsy: true }).isString().isLength({ max: 32 }), + body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(), + body('require_password').optional().isBoolean(), + body('password').optional({ nullable: true }).isString().isLength({ min: 6 }), + body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { + event_name, event_type, event_date, + customer_name = null, customer_email = null, customer_phone = null, + admin_email = null, require_password = true, password, + expires_at = null + } = req.body; + + if (require_password && (!password || password.length < 6)) { + return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' }); + } + + // Honour global phone-field toggle (#322). + let persistPhone = null; + if (customer_phone) { + const setting = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + const enabled = setting ? JSON.parse(setting.setting_value) === true : false; + persistPhone = enabled ? customer_phone : null; + } + + // Generate unique slug. + const baseSlug = `${event_type}-${slugify(event_name)}-${event_date || crypto.randomBytes(3).toString('hex')}`; + let slug = baseSlug; + let counter = 1; + while (await db('events').where({ slug }).first()) slug = `${baseSlug}-${counter++}`; + + const shareToken = crypto.randomBytes(16).toString('hex'); + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + + // password_hash is NOT NULL; use a random placeholder when no + // password is required so the column constraint is satisfied. + const bcrypt = require('bcrypt'); + const passwordHash = require_password + ? await bcrypt.hash(password, 10) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10); + + const insertResult = await db('events').insert({ + slug, + event_type, + event_name, + event_date: event_date || null, + host_name: customer_name, + host_email: customer_email, + admin_email, + password_hash: passwordHash, + require_password, + share_link: shareLinkToStore, + share_token: shareToken, + expires_at: expires_at || null, + created_at: new Date().toISOString(), + created_by: req.admin.id, + is_draft: false, + ...(customer_name ? { customer_name } : {}), + ...(customer_email ? { customer_email } : {}), + ...(persistPhone ? { customer_phone: persistPhone } : {}) + }).returning('id'); + const id = insertResult[0]?.id || insertResult[0]; + + await logActivity('event_created', { via: 'api_v1', event_type }, id, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + + res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken }); + } catch (error) { + logger.error('v1 POST /events failed', { error: error.message, stack: error.stack }); + res.status(500).json({ error: 'Failed to create event', detail: error.message }); + } + } +); + +// ────────────────────────────────────────────────────────────────────────── +// GET /events — list +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events: + * get: + * tags: [Events] + * summary: List gallery events (paginated) + * security: [{ bearerAuth: [] }] + * parameters: + * - in: query + * name: page + * schema: { type: integer, minimum: 1, default: 1 } + * - in: query + * name: limit + * schema: { type: integer, minimum: 1, maximum: 100, default: 25 } + * responses: + * 200: + * description: Paginated list + * content: + * application/json: + * schema: + * type: object + * properties: + * events: + * type: array + * items: { $ref: '#/components/schemas/EventSummary' } + * pagination: + * type: object + * properties: + * page: { type: integer } + * limit: { type: integer } + * total: { type: integer } + */ +router.get( + '/events', + apiTokenAuth, + requireApiScope('read'), + [ + query('page').optional().isInt({ min: 1 }).toInt(), + query('limit').optional().isInt({ min: 1, max: 100 }).toInt() + ], + async (req, res) => { + try { + const page = req.query.page || 1; + const limit = req.query.limit || 25; + const offset = (page - 1) * limit; + + const [events, totalRow] = await Promise.all([ + db('events') + .select('id', 'slug', 'event_name', 'event_type', 'event_date', 'expires_at', + 'is_active', 'is_archived', 'is_draft', 'created_at') + .orderBy('created_at', 'desc') + .limit(limit) + .offset(offset), + db('events').count('id as count').first() + ]); + const total = parseInt(totalRow?.count || 0, 10); + res.json({ events, pagination: { page, limit, total } }); + } catch (error) { + logger.error('v1 GET /events failed', { error: error.message }); + res.status(500).json({ error: 'Failed to list events' }); + } + } +); + +// ────────────────────────────────────────────────────────────────────────── +// GET /events/:id — read +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events/{id}: + * get: + * tags: [Events] + * summary: Get a single event + * security: [{ bearerAuth: [] }] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: { description: Event details } + * 404: { description: Not found } + */ +router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res) => { + try { + const event = await db('events').where({ id: req.params.id }).first(); + if (!event) return res.status(404).json({ error: 'Event not found' }); + delete event.password_hash; + delete event.client_password_hash; + res.json(event); + } catch (error) { + logger.error('v1 GET /events/:id failed', { error: error.message }); + res.status(500).json({ error: 'Failed to fetch event' }); + } +}); + +// ────────────────────────────────────────────────────────────────────────── +// POST /events/:id/photos — upload one photo +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events/{id}/photos: + * post: + * tags: [Photos] + * summary: Upload a single photo to an event + * security: [{ bearerAuth: [] }] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: [photo] + * properties: + * photo: { type: string, format: binary } + * responses: + * 201: + * description: Photo uploaded + * content: + * application/json: + * schema: + * type: object + * properties: + * id: { type: integer } + * filename: { type: string } + * path: { type: string } + * thumbnail_path: { type: string, nullable: true } + * size_bytes: { type: integer } + * 400: { description: No file or invalid type } + * 404: { description: Event not found } + */ +router.post( + '/events/:id/photos', + apiTokenAuth, + requireApiScope('write'), + photoUpload.single('photo'), + async (req, res) => { + let tempPath = null; + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded under field "photo"' }); + tempPath = req.file.path; + + const event = await db('events').where({ id: req.params.id }).first(); + if (!event) return res.status(404).json({ error: 'Event not found' }); + + const finalDir = path.join(getStoragePath(), 'events/active', event.slug); + await fs.mkdir(finalDir, { recursive: true }); + const ext = path.extname(req.file.originalname); + const finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`; + const finalPath = path.join(finalDir, finalName); + await fs.rename(tempPath, finalPath); + tempPath = null; + + const stat = fsSync.statSync(finalPath); + const relPath = path.relative(path.join(getStoragePath(), 'events/active'), finalPath); + + let thumbRel = null; + try { + const thumbPath = await generateThumbnail(finalPath); + thumbRel = path.relative(getStoragePath(), thumbPath); + } catch (err) { + logger.warn('v1 thumbnail generation failed', { err: err.message }); + } + + // Detect image dimensions for masonry layouts. + let width = null; + let height = null; + try { + const meta = await sharp(finalPath).metadata(); + width = meta.width || null; + height = meta.height || null; + } catch { /* non-fatal */ } + + const insertResult = await db('photos').insert({ + event_id: event.id, + filename: finalName, + original_filename: req.file.originalname, + path: relPath, + thumbnail_path: thumbRel, + type: 'individual', + size_bytes: stat.size, + width, + height, + media_type: 'image', + mime_type: req.file.mimetype, + uploaded_at: new Date().toISOString() + }).returning('id'); + const id = insertResult[0]?.id || insertResult[0]; + + await logActivity('photo_uploaded', { via: 'api_v1', filename: finalName }, event.id, { + type: 'admin', id: req.admin.id, name: req.admin.username + }); + + res.status(201).json({ id, filename: finalName, path: relPath, thumbnail_path: thumbRel, size_bytes: stat.size }); + } catch (error) { + logger.error('v1 POST /events/:id/photos failed', { error: error.message }); + if (tempPath) await fs.unlink(tempPath).catch(() => {}); + res.status(500).json({ error: 'Failed to upload photo' }); + } + } +); + +// ────────────────────────────────────────────────────────────────────────── +// GET /events/:id/share-link — full URL for sending to guests +// ────────────────────────────────────────────────────────────────────────── + +/** + * @openapi + * /events/{id}/share-link: + * get: + * tags: [Events] + * summary: Get the absolute share URL for an event + * security: [{ bearerAuth: [] }] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: Share URL + * content: + * application/json: + * schema: + * type: object + * properties: + * slug: { type: string } + * share_token: { type: string } + * share_url: { type: string, format: uri } + * 404: { description: Not found } + */ +router.get('/events/:id/share-link', apiTokenAuth, requireApiScope('read'), async (req, res) => { + try { + const event = await db('events').where({ id: req.params.id }).first(); + if (!event) return res.status(404).json({ error: 'Event not found' }); + const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token }); + res.json({ slug: event.slug, share_token: event.share_token, share_url: shareUrl }); + } catch (error) { + logger.error('v1 GET /events/:id/share-link failed', { error: error.message }); + res.status(500).json({ error: 'Failed to build share link' }); + } +}); + +module.exports = router; diff --git a/docs/openapi.json b/docs/openapi.json new file mode 100644 index 00000000..f340e68e --- /dev/null +++ b/docs/openapi.json @@ -0,0 +1,416 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "PicPeak API", + "version": "v1", + "description": "Public REST API for PicPeak — create gallery events, upload photos, fetch share links. Authenticate with a Bearer token issued via the admin **Settings → API Tokens** tab." + }, + "servers": [ + { + "url": "/api/v1", + "description": "Same-origin (production)" + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "pp_live_*", + "description": "Long-lived API token. Issue via Settings → API Tokens. Token format: `pp_live_`. Scopes: `read`, `write`, `admin`." + } + }, + "schemas": { + "EventSummary": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "event_name": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "event_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "is_active": { + "type": "boolean" + }, + "is_archived": { + "type": "boolean" + }, + "is_draft": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/events": { + "post": { + "tags": [ + "Events" + ], + "summary": "Create a gallery event", + "description": "Returns the new event's id, slug, and absolute share URL.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "event_name", + "event_type" + ], + "properties": { + "event_name": { + "type": "string" + }, + "event_type": { + "type": "string", + "enum": [ + "wedding", + "birthday", + "corporate", + "other", + "family" + ] + }, + "event_date": { + "type": "string", + "format": "date", + "nullable": true + }, + "customer_name": { + "type": "string", + "nullable": true + }, + "customer_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "customer_phone": { + "type": "string", + "nullable": true, + "description": "Only persisted when the global phone-field setting is enabled." + }, + "admin_email": { + "type": "string", + "format": "email", + "nullable": true + }, + "require_password": { + "type": "boolean", + "default": true + }, + "password": { + "type": "string", + "nullable": true, + "description": "Required when require_password is true." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Event created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "share_url": { + "type": "string", + "format": "uri" + }, + "share_token": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Validation error" + }, + "401": { + "description": "Missing/invalid token" + }, + "403": { + "description": "Token lacks admin scope" + } + } + }, + "get": { + "tags": [ + "Events" + ], + "summary": "List gallery events (paginated)", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "Paginated list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventSummary" + } + }, + "pagination": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + }, + "/events/{id}": { + "get": { + "tags": [ + "Events" + ], + "summary": "Get a single event", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Event details" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/events/{id}/photos": { + "post": { + "tags": [ + "Photos" + ], + "summary": "Upload a single photo to an event", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "photo" + ], + "properties": { + "photo": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Photo uploaded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "path": { + "type": "string" + }, + "thumbnail_path": { + "type": "string", + "nullable": true + }, + "size_bytes": { + "type": "integer" + } + } + } + } + } + }, + "400": { + "description": "No file or invalid type" + }, + "404": { + "description": "Event not found" + } + } + } + }, + "/events/{id}/share-link": { + "get": { + "tags": [ + "Events" + ], + "summary": "Get the absolute share URL for an event", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Share URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "share_token": { + "type": "string" + }, + "share_url": { + "type": "string", + "format": "uri" + } + } + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + } + }, + "tags": [] +} \ No newline at end of file diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 00000000..ddf8a8ea --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,270 @@ +openapi: 3.0.3 +info: + title: PicPeak API + version: v1 + description: >- + Public REST API for PicPeak — create gallery events, upload photos, fetch share links. + Authenticate with a Bearer token issued via the admin **Settings → API Tokens** tab. +servers: + - url: /api/v1 + description: Same-origin (production) +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: pp_live_* + description: >- + Long-lived API token. Issue via Settings → API Tokens. Token format: `pp_live_`. + Scopes: `read`, `write`, `admin`. + schemas: + EventSummary: + type: object + properties: + id: + type: integer + slug: + type: string + event_name: + type: string + event_type: + type: string + event_date: + type: string + format: date + nullable: true + expires_at: + type: string + format: date-time + nullable: true + is_active: + type: boolean + is_archived: + type: boolean + is_draft: + type: boolean + created_at: + type: string + format: date-time +security: + - bearerAuth: [] +paths: + /events: + post: + tags: + - Events + summary: Create a gallery event + description: Returns the new event's id, slug, and absolute share URL. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - event_name + - event_type + properties: + event_name: + type: string + event_type: + type: string + enum: + - wedding + - birthday + - corporate + - other + - family + event_date: + type: string + format: date + nullable: true + customer_name: + type: string + nullable: true + customer_email: + type: string + format: email + nullable: true + customer_phone: + type: string + nullable: true + description: Only persisted when the global phone-field setting is enabled. + admin_email: + type: string + format: email + nullable: true + require_password: + type: boolean + default: true + password: + type: string + nullable: true + description: Required when require_password is true. + expires_at: + type: string + format: date-time + nullable: true + responses: + '201': + description: Event created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + slug: + type: string + share_url: + type: string + format: uri + share_token: + type: string + '400': + description: Validation error + '401': + description: Missing/invalid token + '403': + description: Token lacks admin scope + get: + tags: + - Events + summary: List gallery events (paginated) + security: + - bearerAuth: [] + parameters: + - in: query + name: page + schema: + type: integer + minimum: 1 + default: 1 + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 100 + default: 25 + responses: + '200': + description: Paginated list + content: + application/json: + schema: + type: object + properties: + events: + type: array + items: + $ref: '#/components/schemas/EventSummary' + pagination: + type: object + properties: + page: + type: integer + limit: + type: integer + total: + type: integer + /events/{id}: + get: + tags: + - Events + summary: Get a single event + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Event details + '404': + description: Not found + /events/{id}/photos: + post: + tags: + - Photos + summary: Upload a single photo to an event + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - photo + properties: + photo: + type: string + format: binary + responses: + '201': + description: Photo uploaded + content: + application/json: + schema: + type: object + properties: + id: + type: integer + filename: + type: string + path: + type: string + thumbnail_path: + type: string + nullable: true + size_bytes: + type: integer + '400': + description: No file or invalid type + '404': + description: Event not found + /events/{id}/share-link: + get: + tags: + - Events + summary: Get the absolute share URL for an event + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Share URL + content: + application/json: + schema: + type: object + properties: + slug: + type: string + share_token: + type: string + share_url: + type: string + format: uri + '404': + description: Not found +tags: [] diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index 922978e5..ac572b66 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -15,3 +15,4 @@ export { ModerationTab } from './tabs/ModerationTab'; export { StylingTab } from './tabs/StylingTab'; export { SEOTab } from './tabs/SEOTab'; export { ThumbnailsTab } from './tabs/ThumbnailsTab'; +export { ApiTokensTab } from './tabs/ApiTokensTab'; diff --git a/frontend/src/features/settings/tabs/ApiTokensTab.tsx b/frontend/src/features/settings/tabs/ApiTokensTab.tsx new file mode 100644 index 00000000..a176eb77 --- /dev/null +++ b/frontend/src/features/settings/tabs/ApiTokensTab.tsx @@ -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>(['read']); + const [justCreatedToken, setJustCreatedToken] = useState(null); + + const { data: tokens, isLoading } = useQuery({ + queryKey: ['admin-api-tokens'], + queryFn: async () => { + const res = await api.get('/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 ( +
+ +
+ ); + } + + return ( +
+ +

+ + {t('settings.apiTokens.title', 'API Tokens')} +

+

+ {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.')} +

+ + {justCreatedToken && ( +
+
+ +
+

+ {t('settings.apiTokens.copyNow', 'Copy this token now — it will not be shown again.')} +

+
+ + {justCreatedToken} + + + +
+
+
+
+ )} + +
+
+ + setName(e.target.value)} + placeholder={t('settings.apiTokens.namePlaceholder', 'e.g. n8n production')} + /> +
+
+ +
+ {ALL_SCOPES.map((s) => ( + + ))} +
+
+
+ +
+
+

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

+
+ + +

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

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

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

+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index d05c42e2..d4bec8d5 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -14,9 +14,10 @@ import { StylingTab, SEOTab, ThumbnailsTab, + ApiTokensTab, } 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 = () => { const [activeTab, setActiveTab] = useState('general'); @@ -81,6 +82,7 @@ export const SettingsPage: React.FC = () => { { key: 'analytics', label: t('settings.analytics.title') }, { key: 'moderation', label: t('settings.moderation.title', 'Moderation') }, { key: 'styling', label: t('settings.styling.title', 'Custom CSS') }, + { key: 'apiTokens', label: t('settings.apiTokens.title', 'API Tokens') }, ]; return ( @@ -185,6 +187,8 @@ export const SettingsPage: React.FC = () => { {activeTab === 'moderation' && } {activeTab === 'styling' && } + + {activeTab === 'apiTokens' && } ); }; diff --git a/scripts/sync-api-docs.sh b/scripts/sync-api-docs.sh new file mode 100755 index 00000000..44f0aedd --- /dev/null +++ b/scripts/sync-api-docs.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Local-only API docs sync. Generates docs/openapi.{json,yaml} from +# the @openapi JSDoc blocks in backend/src/routes/v1/*, then copies the +# result into the picpeak-docs Nextra site at /Users/paul/Development/picpeak-docs/app/api/. +# +# Writes only — never commits or pushes the docs repo. Review the diff +# in picpeak-docs and commit there manually when ready. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOCS_REPO="${PICPEAK_DOCS_REPO:-/Users/paul/Development/picpeak-docs}" +SRC_DIR="$REPO_ROOT/docs" +TARGET_DIR="$DOCS_REPO/app/api" + +cd "$REPO_ROOT/backend" + +# 1. Generate fresh spec from JSDoc. +echo "▶ Generating OpenAPI spec from src/routes/v1/*" +node scripts/generate-openapi.js + +# 2. Verify docs repo is reachable. Soft-fail so this doesn't block a +# push when the docs repo isn't on this machine. +if [ ! -d "$DOCS_REPO" ]; then + echo "▶ Docs repo not found at $DOCS_REPO — skipping sync." + echo " (Set PICPEAK_DOCS_REPO to override, or create the path to enable sync.)" + exit 0 +fi +if [ ! -d "$TARGET_DIR" ]; then + echo "▶ Target dir $TARGET_DIR doesn't exist — creating." + mkdir -p "$TARGET_DIR" +fi + +# 3. Copy spec files into the docs repo. We do NOT git-add or commit +# here — the user reviews and commits picpeak-docs manually. +cp "$SRC_DIR/openapi.json" "$TARGET_DIR/openapi.json" +cp "$SRC_DIR/openapi.yaml" "$TARGET_DIR/openapi.yaml" +echo "▶ Wrote openapi.{json,yaml} to $TARGET_DIR" + +# 4. Brief drop-in MDX page that references the spec, so the Nextra +# nav has a stable target. Won't overwrite a hand-edited file — +# only writes if missing. +REF_MDX="$TARGET_DIR/reference.mdx" +if [ ! -f "$REF_MDX" ]; then + cat > "$REF_MDX" <<'EOF' +--- +title: API Reference +--- + +# API Reference + +The PicPeak v1 REST API is documented as an OpenAPI 3 spec. + +- [Download `openapi.yaml`](./openapi.yaml) +- [Download `openapi.json`](./openapi.json) +- A live, browseable Swagger UI is served by every PicPeak instance at + `/api/docs` (admin login required). + +This page is auto-generated from JSDoc annotations on the v1 route files. +Do not hand-edit. The narrative pages (auth, recipes) live alongside. +EOF + echo "▶ Created $REF_MDX (placeholder — replace with your preferred renderer)" +fi + +echo "✓ API docs synced. Review changes in $DOCS_REPO before committing."