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,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: []
|
||||
Reference in New Issue
Block a user