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,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
|
||||
};
|
||||
Reference in New Issue
Block a user