Skip to content

IntelliPicHub HTTP API

Living reference for third-party / cron-job consumers of the IntelliPicHub backend. Every endpoint that carries @RequireScope on the server should have a matching section here. When you add a new scoped endpoint, add it in the right section with a request shape, a response shape, and a curl example. If a scope is added or renamed, update the Scopes table.

Base URL  https://<host>/api  ·  local dev: http://localhost:8123/api

Status: V1 — covers picture ingestion. Future revisions add picture read APIs, search, and notifications. Scope strings are immutable once shipped — see Stability guarantees below.


Table of contents


Authentication

There are two paths into the API. Endpoints don't care which one a request took as long as the resolved user has the required permissions.

PathWhere it comes fromLifetimeUse it for
Supabase JWTsupabase.auth browser session1 hour, refreshableBrowser UI, anything interactive
API keyPOST /auth/api-keys (this doc)Until revoked / expiredCron jobs, Workers, server-to-server

Both go in the same header:

Authorization: Bearer <token>

The server's ApiKeyAuthFilter runs first and recognises the API key prefix iph_live_ — anything else is handed off to JwtAuthFilter. So mixing both kinds of clients against the same endpoint just works.

API key acts as the user who created it

A key's effective permissions = (its owner's role permissions) ∩ (its granted scopes). A regular user's picture:upload key can only upload into spaces that user has PICTURE_UPLOAD permission on; it cannot reach an admin-only endpoint even if the scope string somehow matched. The admin:* scope is admin-grantable only.

Token format

iph_live_<32 random chars>

The 32-char body is drawn from a 56-symbol alphabet that excludes 0, O, 1, l, I (so a copy-paste error doesn't silently produce a different valid token). Roughly 186 bits of entropy.

Only SHA-256(token) is stored — there's no recovery flow. If you lose a token, revoke it and create a new one. The full plaintext is returned exactly once, in the response to POST /auth/api-keys. After that the API will only ever return the 13-char display prefix (e.g. iph_live_a8K9x).


Scopes

ScopeWho can grantWhat it covers
picture:uploadAny userThe R2 two-stage upload flow + admin batch URL ingestion
admin:*Admin user onlyResource-prefix wildcard — every admin endpoint (admin trash purge, admin batch import diagnostics, admin user ban, etc.). Not a super wildcard; does not cover picture:upload.

The wildcard rule used at runtime: a granted scope of a:b:* matches any required a:b:<anything>; granted * matches everything. So if you ever need a true super-key, grant * (currently not in the public catalog — gate it carefully).

The set of scopes a user can grant on POST /auth/api-keys is filtered by their role. Hit GET /auth/api-keys/available-scopes to find out exactly what a given caller may request.


Response envelope

Every JSON endpoint wraps its payload in:

json
{
  "code": 0,
  "data": { /* endpoint-specific */ },
  "message": "ok"
}

code: 0 means success. Any non-zero code is a business error and data is null. See Error codes below. SSE endpoints (/picture/upload/batch/selected) are the exception — they stream events directly, not the envelope.


Error codes

CodeMeaningTypical trigger
0OK
40000Invalid request parametersMissing/malformed field, body validation failure
40100Not logged inMissing/wrong/revoked/expired API key. All four cases return the same error so a caller can't enumerate which one happened.
40101No permission / missing scopeAuthenticated, but the key doesn't carry the scope the endpoint requires. Example: "API key missing required scope: picture:upload"
40300Access forbiddenUser-level permission check failed (e.g. PICTURE_UPLOAD permission on a space the caller doesn't belong to).
42301Embedding not readyPicture too new for /similar/list. Frontend falls back to tag-based recommendation.
50000System internal exceptionServer bug or downstream outage.

HTTP status is the standard 200 for success and 4xx/5xx for the matching error class. Always check code in the body — a 200 with code != 0 is a business error.


API key management

These five endpoints live under /auth/api-keys and only require a logged-in user (Supabase JWT works; an API key with the right scope would too, but there's no scope for self-management in V1 so use JWT).

POST /auth/api-keys — create

Request:

json
{
  "name": "ingest-worker-2026-05",
  "scopes": ["picture:upload"],
  "description": "Cloudflare Worker cron, Wikimedia CC0 ingest",
  "expiresInDays": 365
}
FieldTypeRequiredNotes
namestringyes1–255 chars, used in UI listing
scopesstring[]yesEach must appear in the catalog AND be grantable by the caller
descriptionstringnoFree-form admin note
expiresInDaysintnoOmit / 0 = never expires

Response (code: 0):

json
{
  "data": {
    "plaintext": "iph_live_...",
    "key": {
      "id": "1234567890",
      "name": "ingest-worker-2026-05",
      "prefix": "iph_live_a8K9",
      "scopes": ["picture:upload"],
      "expiresAt": "2027-05-04T00:00:00Z",
      "revokedAt": null,
      "lastUsedAt": null,
      "lastUsedIp": null,
      "totalRequests": 0,
      "createTime": "2026-05-04T13:02:11Z",
      "description": "Cloudflare Worker cron, Wikimedia CC0 ingest"
    }
  }
}

plaintext is shown once. Save it now.

curl:

bash
curl -X POST https://<host>/api/auth/api-keys \
  -H "Authorization: Bearer <jwt>" \
  -H "Content-Type: application/json" \
  -d '{"name":"ingest-worker-2026-05","scopes":["picture:upload"]}'

GET /auth/api-keys — list

Lists the caller's own keys (revoked ones included, with revokedAt set). Paginated.

GET /auth/api-keys?current=1&pageSize=20

Response shape: standard MyBatis-Plus Page<ApiKeyVO>records[], total, current, size. keyHash is never returned.

POST /auth/api-keys/{id}/revoke — revoke

Idempotent. A second call against an already-revoked key still returns true; the server doesn't differentiate "already revoked" from "just revoked now" so a curious caller can't probe state. A revoked key is rejected by the next request that uses it (returns 40100).

bash
curl -X POST https://<host>/api/auth/api-keys/1234567890/revoke \
  -H "Authorization: Bearer <jwt>"

POST /auth/api-keys/update — update metadata

Only name and description are mutable. scopes is deliberately immutable — if you need a different scope set, create a new key and revoke the old one. This prevents silent permission creep.

json
{
  "id": "1234567890",
  "name": "ingest-worker-rotated",
  "description": "rotated 2026-05-04, original token compromised"
}

GET /auth/api-keys/available-scopes — catalog

Returns the scopes the caller may grant. Frontend uses this to render the create-key dialog. Use it in scripts when you need to discover the current set programmatically — the catalog is intended to grow.

json
{
  "data": [
    {
      "value": "picture:upload",
      "label": "Upload pictures",
      "description": "Upload originals via the R2 two-stage flow + admin batch URL ingestion.",
      "requiredRole": "user"
    }
  ]
}

Picture upload (picture:upload)

These three endpoints all require an API key that carries picture:upload, AND the resolved user must have PICTURE_UPLOAD permission on the target space (gallery target = no space check). The key clamps to the user, the user clamps to the space — both gates apply.

POST /picture/upload/r2/check — stage 1

You hash the file locally, the server probes for dedupe, and either hands back an existing blobId (skip the upload) or a presigned R2 PUT URL.

Request:

json
{
  "sha256": "<64-char hex of the original file>",
  "size": 524288,
  "ext": "jpg",
  "contentType": "image/jpeg",
  "spaceId": null
}

Response on dedupe hit:

json
{
  "data": {
    "duplicate": true,
    "blobId": "987",
    "stagingKey": null,
    "putUrl": null,
    "thumb": { "stagingKey": "...", "putUrl": "..." },
    "preview": { "stagingKey": "...", "putUrl": "..." }
  }
}

Response on fresh upload:

json
{
  "data": {
    "duplicate": false,
    "blobId": null,
    "stagingKey": "staging/<uuid>.jpg",
    "putUrl": "https://<r2-host>/staging/<uuid>.jpg?X-Amz-Signature=...",
    "thumb": { "stagingKey": "...", "putUrl": "..." },
    "preview": { "stagingKey": "...", "putUrl": "..." }
  }
}

Then PUT the bytes directly to the presigned URL(s) and call finalize.

POST /picture/upload/r2/finalize — stage 2

Promotes staging objects to permanent, creates the picture row, bumps ref_count on the blob, and returns the picture VO. Transactional.

Required fields when the original was a fresh upload:

json
{
  "sha256": "...",
  "stagingKey": "<from check response>",
  "size": 524288,
  "format": "JPEG",
  "ext": "jpg",
  "thumbKey": "<from check>",
  "previewKey": "<from check>",
  "embeddingKey": null,
  "spaceId": null,
  "name": "Mt. Fuji at sunrise",
  "introduction": "Wikimedia Commons, CC0",
  "category": "landscape"
}

On a dedupe hit, omit stagingKey / format / thumbKey etc. — the server reads them from the existing blob. Send pictureId instead if you're cloning into a new space.

POST /picture/upload/batch/selected — admin URL ingestion

Server-side fetcher: hand it a list of public URLs and it downloads, dedupes, uploads to R2, and persists each as a public-gallery picture. Only admins can call this. Streams SSE (one event per URL plus a final summary), not the JSON envelope.

Request:

json
{
  "urlList": [
    "https://upload.wikimedia.org/wikipedia/commons/.../foo.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/.../bar.jpg"
  ],
  "namePrefix": "wikimedia-",
  "tags": ["nature", "cc0"]
}

Limit: 50 URLs per batch.

Per-URL event:

data: {"index":0,"url":"...","status":"success","message":"...","done":false}

Final event:

data: {"done":true,"total":50,"successCount":48}

curl example for a Worker / cron:

bash
curl -N -X POST https://<host>/api/picture/upload/batch/selected \
  -H "Authorization: Bearer iph_live_..." \
  -H "Content-Type: application/json" \
  -d '{"urlList":["https://...jpg"],"namePrefix":"cc0-","tags":["cc0"]}'

For unattended ingestion this is the simplest path — you don't have to handle R2 presigned PUTs yourself.


Stability guarantees

  • Scope strings are forever. Once a scope ships, it's never renamed. If a scope is deprecated, it's removed from the public catalog (so it can no longer be granted) but ApiScopes.hasScope keeps recognising it for keys that already hold it.
  • Token format iph_live_* is stable. Future environments may introduce additional prefixes (e.g. iph_test_*), but iph_live_* always refers to production-grade keys.
  • Endpoint paths under /api/... follow semver. Breaking changes to a shipped endpoint are announced and ride on a new major version in the path. Additive changes (new optional fields, new endpoints, new scopes) land any time.
  • Error code values are stable. 40100 is forever "not logged in"; 40101 is forever "no permission / missing scope".

Roadmap

The schema and filter chain were designed so the following land as pure additions, with no migration:

  • Per-key rate limitsrate_limit_rpm column + Bucket4j filter.
  • IP allowlistip_allowlist CIDR[] column + filter check.
  • Audit logapi_key_audit table + async listener.
  • Token rotationrotated_from_key_id column + rotate endpoint.
  • Publishable / restricted key types — already reserved via the key_type column on the existing schema.
  • OAuth-style third-party apps — separate flow, separate entity.

If you're building against this API and would benefit from any of the above sooner, open an issue with a concrete use case.

Released under the MIT License.