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:
Paul Nothaft
2026-04-27 22:38:00 +02:00
parent be6cb28c80
commit 808b15bafb
15 changed files with 2064 additions and 5 deletions
+416
View File
@@ -0,0 +1,416 @@
{
"openapi": "3.0.3",
"info": {
"title": "PicPeak API",
"version": "v1",
"description": "Public REST API for PicPeak — create gallery events, upload photos, fetch share links. Authenticate with a Bearer token issued via the admin **Settings → API Tokens** tab."
},
"servers": [
{
"url": "/api/v1",
"description": "Same-origin (production)"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "pp_live_*",
"description": "Long-lived API token. Issue via Settings → API Tokens. Token format: `pp_live_<random>`. Scopes: `read`, `write`, `admin`."
}
},
"schemas": {
"EventSummary": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"slug": {
"type": "string"
},
"event_name": {
"type": "string"
},
"event_type": {
"type": "string"
},
"event_date": {
"type": "string",
"format": "date",
"nullable": true
},
"expires_at": {
"type": "string",
"format": "date-time",
"nullable": true
},
"is_active": {
"type": "boolean"
},
"is_archived": {
"type": "boolean"
},
"is_draft": {
"type": "boolean"
},
"created_at": {
"type": "string",
"format": "date-time"
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/events": {
"post": {
"tags": [
"Events"
],
"summary": "Create a gallery event",
"description": "Returns the new event's id, slug, and absolute share URL.",
"security": [
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"event_name",
"event_type"
],
"properties": {
"event_name": {
"type": "string"
},
"event_type": {
"type": "string",
"enum": [
"wedding",
"birthday",
"corporate",
"other",
"family"
]
},
"event_date": {
"type": "string",
"format": "date",
"nullable": true
},
"customer_name": {
"type": "string",
"nullable": true
},
"customer_email": {
"type": "string",
"format": "email",
"nullable": true
},
"customer_phone": {
"type": "string",
"nullable": true,
"description": "Only persisted when the global phone-field setting is enabled."
},
"admin_email": {
"type": "string",
"format": "email",
"nullable": true
},
"require_password": {
"type": "boolean",
"default": true
},
"password": {
"type": "string",
"nullable": true,
"description": "Required when require_password is true."
},
"expires_at": {
"type": "string",
"format": "date-time",
"nullable": true
}
}
}
}
}
},
"responses": {
"201": {
"description": "Event created",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"slug": {
"type": "string"
},
"share_url": {
"type": "string",
"format": "uri"
},
"share_token": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Validation error"
},
"401": {
"description": "Missing/invalid token"
},
"403": {
"description": "Token lacks admin scope"
}
}
},
"get": {
"tags": [
"Events"
],
"summary": "List gallery events (paginated)",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"in": "query",
"name": "page",
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"in": "query",
"name": "limit",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 25
}
}
],
"responses": {
"200": {
"description": "Paginated list",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EventSummary"
}
},
"pagination": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"limit": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
}
}
}
}
}
}
}
}
},
"/events/{id}": {
"get": {
"tags": [
"Events"
],
"summary": "Get a single event",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Event details"
},
"404": {
"description": "Not found"
}
}
}
},
"/events/{id}/photos": {
"post": {
"tags": [
"Photos"
],
"summary": "Upload a single photo to an event",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"photo"
],
"properties": {
"photo": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"responses": {
"201": {
"description": "Photo uploaded",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"filename": {
"type": "string"
},
"path": {
"type": "string"
},
"thumbnail_path": {
"type": "string",
"nullable": true
},
"size_bytes": {
"type": "integer"
}
}
}
}
}
},
"400": {
"description": "No file or invalid type"
},
"404": {
"description": "Event not found"
}
}
}
},
"/events/{id}/share-link": {
"get": {
"tags": [
"Events"
],
"summary": "Get the absolute share URL for an event",
"security": [
{
"bearerAuth": []
}
],
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Share URL",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"slug": {
"type": "string"
},
"share_token": {
"type": "string"
},
"share_url": {
"type": "string",
"format": "uri"
}
}
}
}
}
},
"404": {
"description": "Not found"
}
}
}
}
},
"tags": []
}
+270
View File
@@ -0,0 +1,270 @@
openapi: 3.0.3
info:
title: PicPeak API
version: v1
description: >-
Public REST API for PicPeak — create gallery events, upload photos, fetch share links.
Authenticate with a Bearer token issued via the admin **Settings → API Tokens** tab.
servers:
- url: /api/v1
description: Same-origin (production)
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: pp_live_*
description: >-
Long-lived API token. Issue via Settings → API Tokens. Token format: `pp_live_<random>`.
Scopes: `read`, `write`, `admin`.
schemas:
EventSummary:
type: object
properties:
id:
type: integer
slug:
type: string
event_name:
type: string
event_type:
type: string
event_date:
type: string
format: date
nullable: true
expires_at:
type: string
format: date-time
nullable: true
is_active:
type: boolean
is_archived:
type: boolean
is_draft:
type: boolean
created_at:
type: string
format: date-time
security:
- bearerAuth: []
paths:
/events:
post:
tags:
- Events
summary: Create a gallery event
description: Returns the new event's id, slug, and absolute share URL.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- event_name
- event_type
properties:
event_name:
type: string
event_type:
type: string
enum:
- wedding
- birthday
- corporate
- other
- family
event_date:
type: string
format: date
nullable: true
customer_name:
type: string
nullable: true
customer_email:
type: string
format: email
nullable: true
customer_phone:
type: string
nullable: true
description: Only persisted when the global phone-field setting is enabled.
admin_email:
type: string
format: email
nullable: true
require_password:
type: boolean
default: true
password:
type: string
nullable: true
description: Required when require_password is true.
expires_at:
type: string
format: date-time
nullable: true
responses:
'201':
description: Event created
content:
application/json:
schema:
type: object
properties:
id:
type: integer
slug:
type: string
share_url:
type: string
format: uri
share_token:
type: string
'400':
description: Validation error
'401':
description: Missing/invalid token
'403':
description: Token lacks admin scope
get:
tags:
- Events
summary: List gallery events (paginated)
security:
- bearerAuth: []
parameters:
- in: query
name: page
schema:
type: integer
minimum: 1
default: 1
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 100
default: 25
responses:
'200':
description: Paginated list
content:
application/json:
schema:
type: object
properties:
events:
type: array
items:
$ref: '#/components/schemas/EventSummary'
pagination:
type: object
properties:
page:
type: integer
limit:
type: integer
total:
type: integer
/events/{id}:
get:
tags:
- Events
summary: Get a single event
security:
- bearerAuth: []
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Event details
'404':
description: Not found
/events/{id}/photos:
post:
tags:
- Photos
summary: Upload a single photo to an event
security:
- bearerAuth: []
parameters:
- in: path
name: id
required: true
schema:
type: integer
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- photo
properties:
photo:
type: string
format: binary
responses:
'201':
description: Photo uploaded
content:
application/json:
schema:
type: object
properties:
id:
type: integer
filename:
type: string
path:
type: string
thumbnail_path:
type: string
nullable: true
size_bytes:
type: integer
'400':
description: No file or invalid type
'404':
description: Event not found
/events/{id}/share-link:
get:
tags:
- Events
summary: Get the absolute share URL for an event
security:
- bearerAuth: []
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Share URL
content:
application/json:
schema:
type: object
properties:
slug:
type: string
share_token:
type: string
share_url:
type: string
format: uri
'404':
description: Not found
tags: []