This document describes the HTTP APIs exposed by the Pluto project (Next.js + Cloudflare Workers). It is intended for App integration.
{BASE_URL}/api
BASE_URL should be your deployed site domain, e.g. https://example.com.
No authentication required.
Admin endpoints require a valid admin session cookie set by the login endpoint.
- Cookie name:
photos_admin - Session is an HTTP-only cookie.
Most endpoints return JSON with:
ok:true | false- On error:
errororerrmessage
Many list endpoints use:
page(default 1)pageSize(default 20)
Response typically includes:
totaltotalPages
GET /media/list
Query Parameters:
q: search keyword (filename/title/location)category: category slug or idtag: tag stringpage: numberpageSize: numbersort:date | likes | views(defaultdate)orientation:landscape | portrait | square
Sorting:
date: usesdatetime_originalfirst, fallback tocreated_at.
Response:
{
"ok": true,
"results": [
{
"id": "...",
"url": "...",
"url_thumb": "...",
"url_medium": "...",
"url_large": "...",
"filename": "...",
"alt": "...",
"size": 0,
"width": 0,
"height": 0,
"created_at": "2024-01-01T00:00:00.000Z",
"camera_make": "...",
"camera_model": "...",
"lens_model": "...",
"aperture": "...",
"shutter_speed": "...",
"iso": "...",
"focal_length": "...",
"datetime_original": "2024-01-01T00:00:00.000Z",
"location_name": "...",
"categories": [{ "id": "...", "name": "...", "slug": "..." }],
"category_ids": ["..."],
"tags": ["..."],
"likes": 0,
"view_count": 0,
"liked": false
}
],
"total": 0,
"page": 1,
"pageSize": 20,
"totalPages": 0
}GET /media/categories
Note: only categories with show_in_frontend = 1 are returned.
Response:
{
"ok": true,
"categories": [
{
"id": "...",
"name": "...",
"slug": "...",
"description": "...",
"media_count": 0
}
]
}GET /media/{id}
Note: Returns full details of a single photo. Only public photos (visibility = 'public' or NULL) are returned.
Response:
{
"ok": true,
"data": {
"id": "...",
"url": "...",
"url_thumb": "...",
"url_medium": "...",
"url_large": "...",
"filename": "...",
"title": "...",
"alt": "...",
"width": 1920,
"height": 1080,
"size": 123456,
"mime_type": "image/jpeg",
"camera_make": "...",
"camera_model": "...",
"lens_model": "...",
"aperture": "f/2.8",
"shutter_speed": "1/100",
"iso": "100",
"focal_length": "50mm",
"datetime_original": "...",
"location_name": "...",
"categories": [{ "id": "...", "name": "...", "slug": "..." }],
"tags": ["tag1", "tag2"],
"likes": 10,
"view_count": 120,
"liked": false,
"created_at": "2026-02-07T12:34:56.789Z"
}
}GET /media/{id}/view
POST /media/{id}/view
GET: fetches current view count.POST: records a view (bot user-agents are ignored; repeat views within 5 minutes are deduplicated when KV is configured).
Response:
{ "ok": true, "views": 123 }GET /media/{id}/like
POST /media/{id}/like
DELETE /media/{id}/like
- Like state is stored in an HTTP-only cookie per media id.
Response:
{ "ok": true, "likes": 0, "liked": true }GET /albums
Query Parameters:
pagepageSizeqcategory(category slug or id)
Response:
{
"ok": true,
"albums": [
{
"id": "...",
"title": "...",
"description": "...",
"cover_media_id": "...",
"cover_media": { "id": "...", "url": "...", "url_thumb": "...", "url_medium": "..." },
"created_at": "...",
"updated_at": "...",
"media_count": 0,
"views": 0,
"likes": 0,
"slug": "...",
"is_protected": false,
"categories": [{ "id": "...", "name": "...", "slug": "..." }],
"category_ids": ["..."]
}
],
"total": 0,
"totalPages": 0
}GET /albums/categories
Note: only categories with show_in_frontend = 1 are returned.
Response:
{
"ok": true,
"categories": [
{
"id": "...",
"name": "...",
"slug": "...",
"description": "...",
"media_count": 0
}
]
}GET /albums/{idOrSlug}
- For protected albums: requires
Authorization: Bearer {token}. - If missing/invalid, returns
403with{ code: "PASSWORD_REQUIRED" }.
Response:
{ "ok": true, "data": { ...album } }GET /albums/{idOrSlug}/media
Query Parameters:
-
page -
pageSize -
For protected albums: requires
Authorization: Bearer {token}.
Response:
{ "ok": true, "media": [ ...media ], "total": 0 }POST /albums/{idOrSlug}/unlock
Body:
{ "password": "..." }Response:
{ "ok": true, "token": "..." }GET /albums/{id}/view
POST /albums/{id}/view
POSTincrements view count with IP throttling.
Response:
{ "views": 0 }GET /albums/{id}/like
POST /albums/{id}/like
DELETE /albums/{id}/like
- Like state is stored in an HTTP-only cookie per album id.
Response:
{ "ok": true, "likes": 0, "liked": true }GET /albums/{id}/comments
Response:
{ "ok": true, "comments": [ ... ], "isAdmin": false }POST /albums/{id}/comments
Body:
{
"author_name": "...",
"author_email": "...",
"author_url": "...",
"content": "...",
"parent_id": "..."
}Response:
{ "ok": true, "data": { "id": "...", "status": "pending" } }POST /albums/{id}/comments/{commentId}/approve
DELETE /albums/{id}/comments/{commentId}
Note: currently no auth check for delete; should be restricted in production.
GET /subscribe
Response:
{ "ok": true, "enabled": true }POST /subscribe
Body:
{ "email": "user@example.com" }Response:
{ "ok": true, "token": "..." }All admin endpoints require the photos_admin session cookie.
-
POST /admin/login- Body:
{ "username": "...", "password": "..." } - Sets
photos_admincookie
- Body:
-
POST /admin/logout- Clears cookie
-
GET /admin/me- Returns
{ ok: true, user: "..." }
- Returns
-
GET /admin/media/list- Query:
q, category, tag, page, pageSize, sort sort:date | date_asc | name | likes | views
- Query:
-
GET /admin/media/{id} -
PUT /admin/media/{id}- Body:
{ title, alt, category_ids, tags, visibility }
- Body:
-
DELETE /admin/media/{id} -
POST /admin/media/uploadmultipart/form-data- Fields:
fileorfiles(multiple),provider,title,alt,folder,category_ids(comma),tags(comma),visibility
-
GET /admin/media/categories -
POST /admin/media/categories- Body:
{ name, slug, description, display_order, show_in_frontend }
- Body:
-
PUT /admin/media/categories/{id} -
Body:
{ name, slug, description, display_order, show_in_frontend } -
DELETE /admin/media/categories/{id}
GET /admin/media/tags
GET /admin/media/devices- Returns camera and lens counts.
GET /admin/providers- Returns list of available storage providers and default provider.
-
GET /admin/albums- Query:
page, pageSize, q
- Query:
-
POST /admin/albums- Body:
{ title, description, cover_media_id, slug, tags, category_ids, password, status }
- Body:
-
GET /admin/albums/{id} -
PUT /admin/albums/{id}- Body:
{ title, description, cover_media_id, slug, tags, category_ids, password, status }
- Body:
-
DELETE /admin/albums/{id} -
GET /admin/albums/{id}/media -
POST /admin/albums/{id}/media- Body:
{ media_ids: ["..."] }
- Body:
-
DELETE /admin/albums/{id}/media- Body:
{ media_ids: ["..."] }
- Body:
-
POST /admin/albums/{id}/otp- Returns
{ ok: true, otp: "..." }
- Returns
-
GET /admin/albums/categories -
POST /admin/albums/categories- Body:
{ name, slug, description, display_order, show_in_frontend }
- Body:
-
PUT /admin/albums/categories/{id}- Body:
{ name, slug, description, display_order, show_in_frontend }
- Body:
-
DELETE /admin/albums/categories/{id}
-
GET /admin/album-comments- Query:
page, pageSize, status(all | pending | approved)
- Query:
-
POST /admin/album-comments/{id}/approve -
DELETE /admin/album-comments/{id}
-
GET /admin/newsletters- Query:
page, pageSize
- Query:
-
POST /admin/newsletters- Create:
{ subject, content, type } - Send:
{ action: "send", id: "newsletterId" } - Send success response:
{ ok: true, sent, total, failed, failedRecipients? }
- Send error response:
{ ok: false, code, error, sent, total, failed, failedRecipients? }
- Common send error codes:
NOT_FOUND(newsletter not found / already sent)SERVER_ERROR(mail service/configuration/sending failure)
- Create:
GET /admin/subscribers- Query:
page, pageSize
- Query: