feat: public v1 API + token management + OpenAPI docs (#322)
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.
This commit is contained in:
@@ -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');
|
||||
}
|
||||
};
|
||||
Generated
+205
-4
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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: []
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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<TabType>('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' && <ModerationTab />}
|
||||
|
||||
{activeTab === 'styling' && <StylingTab />}
|
||||
|
||||
{activeTab === 'apiTokens' && <ApiTokensTab />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Executable
+65
@@ -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."
|
||||
Reference in New Issue
Block a user