Files
tanabata/openapi.yaml
T
H1K0 96a903aaff docs(project): document duplicate detection endpoints
Adds GET /files/duplicates, POST /files/duplicates/dismiss and POST
/files/duplicates/resolve to the OpenAPI spec, plus the DuplicateCluster,
DuplicateClusterPage and DuplicateResolve (with MergeScalarChoice /
MergeRelationChoice) schemas describing the field-by-field merge contract.

Also fills a pre-existing gap in the File schema: it now documents the `tags`
array (always returned by the API) and marks the always-present fields required,
so generated clients type these as non-optional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:07:37 +03:00

2395 lines
63 KiB
YAML

openapi: 3.0.3
info:
title: Tanabata File Manager API
description: |
REST API for Tanabata File Manager — a multi-user, tag-based web file manager.
## Authentication
All endpoints except `POST /auth/login` require a Bearer JWT token
in the `Authorization` header.
## Pagination
- **Files**: cursor-based (`cursor` parameter, returned in `next_cursor`).
- **All other lists**: offset-based (`offset` + `limit`).
## Error format
```json
{
"code": "string",
"message": "string",
"details": [{ "field": "string", "message": "string" }]
}
```
## File filter DSL
Files can be filtered via a DSL in the `filter` query parameter.
Tokens are comma-separated inside braces: `{token1,token2,...}`.
Operators: `(`, `)`, `&` (AND), `|` (OR), `!` (NOT).
Conditions: `t=<tag_uuid>` (has tag), `t=00000000-0000-0000-0000-000000000000` (untagged),
`m=<mime_id>` (exact MIME), `m~<pattern>` (MIME LIKE pattern, e.g. `m~image%`),
`r=1` (needs review / not yet tagged-done), `r=0` (review done).
Example: `{t=uuid1,&,!,t=uuid2}` → has tag1 AND NOT tag2.
Example: `{r=1,&,m~image%}` → needs review AND is an image.
version: 1.0.0
license:
name: Proprietary
servers:
- url: /api/v1
security:
- bearerAuth: []
tags:
- name: Auth
description: Authentication and session management
- name: Files
description: File management, upload, trash
- name: Tags
description: Tag CRUD and tag rules
- name: Categories
description: Category CRUD
- name: Pools
description: Pool CRUD, file ordering
- name: ACL
description: Access control
- name: Users
description: User management (admin)
- name: Audit
description: Audit log (admin)
# ===========================================================================
# Paths
# ===========================================================================
paths:
# -------------------------------------------------------------------------
# Auth
# -------------------------------------------------------------------------
/auth/login:
post:
tags: [Auth]
summary: Log in
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, password]
properties:
name:
type: string
password:
type: string
responses:
'200':
description: JWT token pair
content:
application/json:
schema:
$ref: '#/components/schemas/TokenPair'
'401':
$ref: '#/components/responses/Unauthorized'
/auth/refresh:
post:
tags: [Auth]
summary: Refresh access token
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh_token]
properties:
refresh_token:
type: string
responses:
'200':
description: New token pair
content:
application/json:
schema:
$ref: '#/components/schemas/TokenPair'
'401':
$ref: '#/components/responses/Unauthorized'
/auth/logout:
post:
tags: [Auth]
summary: Log out (invalidate current session)
responses:
'204':
description: Logged out
/auth/sessions:
get:
tags: [Auth]
summary: List my active sessions
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
responses:
'200':
description: Session list
content:
application/json:
schema:
$ref: '#/components/schemas/SessionList'
/auth/sessions/{session_id}:
delete:
tags: [Auth]
summary: Terminate a session
parameters:
- name: session_id
in: path
required: true
schema:
type: integer
responses:
'204':
description: Session terminated
'404':
$ref: '#/components/responses/NotFound'
# -------------------------------------------------------------------------
# Files
# -------------------------------------------------------------------------
/files:
get:
tags: [Files]
summary: List files (cursor-based pagination)
parameters:
- name: cursor
in: query
description: Cursor from previous response's `next_cursor` or `prev_cursor`
schema:
type: string
- name: direction
in: query
description: Pagination direction relative to cursor
schema:
type: string
enum: [forward, backward]
default: forward
- name: anchor
in: query
description: |
File UUID to anchor the listing around. The response will
include this file and its neighbors. Use with `direction`
and `limit` to load items before/after the anchor.
Mutually exclusive with `cursor`.
schema:
type: string
format: uuid
- name: limit
in: query
schema:
type: integer
default: 50
maximum: 200
- name: sort
in: query
description: Sort field
schema:
type: string
enum: [content_datetime, created, original_name, mime]
default: created
- name: order
in: query
schema:
type: string
enum: [asc, desc]
default: desc
- name: filter
in: query
description: Filter DSL expression (see API description)
schema:
type: string
- name: search
in: query
description: Search by original_name (substring match)
schema:
type: string
- name: trash
in: query
description: If true, return only soft-deleted files (trash)
schema:
type: boolean
default: false
responses:
'200':
description: Paginated file list
content:
application/json:
schema:
$ref: '#/components/schemas/FileCursorPage'
post:
tags: [Files]
summary: Upload a new file
description: |
Upload a file via multipart form. If `content_datetime` is not provided,
it is extracted from EXIF data (DateTimeOriginal). EXIF metadata is
automatically extracted and stored in the `exif` field (immutable).
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
content_datetime:
type: string
format: date-time
notes:
type: string
metadata:
type: string
description: JSON string of key-value pairs
is_public:
type: boolean
default: false
tag_ids:
type: string
description: Comma-separated tag UUIDs to assign
responses:
'201':
description: File created
content:
application/json:
schema:
$ref: '#/components/schemas/File'
'400':
$ref: '#/components/responses/ValidationError'
'415':
description: Unsupported MIME type
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/files/{file_id}:
get:
tags: [Files]
summary: Get file metadata
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'200':
description: File metadata
content:
application/json:
schema:
$ref: '#/components/schemas/File'
'404':
$ref: '#/components/responses/NotFound'
patch:
tags: [Files]
summary: Update file metadata
parameters:
- $ref: '#/components/parameters/file_id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FileUpdate'
responses:
'200':
description: Updated file
content:
application/json:
schema:
$ref: '#/components/schemas/File'
'404':
$ref: '#/components/responses/NotFound'
delete:
tags: [Files]
summary: Soft-delete file (move to trash)
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'204':
description: Moved to trash
'404':
$ref: '#/components/responses/NotFound'
/files/{file_id}/content:
get:
tags: [Files]
summary: Download file content
description: >
Returns the original file bytes. Served as an attachment (download) by
default; pass inline=1 to serve it for in-tab viewing
(Content-Disposition: inline). For browser navigation/new-tab opens that
can't send the Authorization header, the access token may be supplied as
the access_token query parameter (GET only).
parameters:
- $ref: '#/components/parameters/file_id'
- name: inline
in: query
required: false
schema:
type: string
enum: ['1']
description: When '1', serve inline (view) instead of as a download.
- name: access_token
in: query
required: false
schema:
type: string
description: >
Access token or a file-scoped content token (obtained from POST
/files/{file_id}/content-token), as an alternative to the
Authorization header (GET only). A content token outlives the
access token, so long media keeps streaming past access-token expiry.
responses:
'200':
description: File binary
content:
application/octet-stream:
schema:
type: string
format: binary
'404':
$ref: '#/components/responses/NotFound'
put:
tags: [Files]
summary: Replace file content (keep same ID)
parameters:
- $ref: '#/components/parameters/file_id'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
responses:
'200':
description: File replaced
content:
application/json:
schema:
$ref: '#/components/schemas/File'
'415':
description: Unsupported MIME type
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/files/{file_id}/content-token:
post:
tags: [Files]
summary: Mint a content token for opening/streaming the original by URL
description: >
Returns a short-lived, single-file capability token to place in the
access_token query parameter of GET /files/{file_id}/content. Unlike the
access token it is scoped to this one file and is session-independent, so
it survives access-token expiry and refresh rotation — letting a long
video opened in a new tab keep streaming. Requires view permission on the
file. The token is a bearer credential for that file until it expires.
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'200':
description: Content token
content:
application/json:
schema:
type: object
required: [token, expires_in]
properties:
token:
type: string
description: Capability token for the access_token query parameter.
expires_in:
type: integer
description: Token lifetime in seconds.
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/files/{file_id}/thumbnail:
get:
tags: [Files]
summary: Get file thumbnail
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'200':
description: Thumbnail image
content:
image/jpeg:
schema:
type: string
format: binary
'404':
$ref: '#/components/responses/NotFound'
/files/{file_id}/preview:
get:
tags: [Files]
summary: Get file preview (larger than thumbnail)
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'200':
description: Preview image
content:
image/jpeg:
schema:
type: string
format: binary
'404':
$ref: '#/components/responses/NotFound'
/files/{file_id}/views:
post:
tags: [Files]
summary: Record that the current user viewed the file
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'204':
description: View recorded
'404':
$ref: '#/components/responses/NotFound'
/files/{file_id}/restore:
post:
tags: [Files]
summary: Restore file from trash
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'200':
description: File restored
content:
application/json:
schema:
$ref: '#/components/schemas/File'
'404':
$ref: '#/components/responses/NotFound'
/files/{file_id}/permanent:
delete:
tags: [Files]
summary: Permanently delete file (from trash only)
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'204':
description: Permanently deleted
'404':
$ref: '#/components/responses/NotFound'
'409':
description: File is not in trash
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# --- File-Tag relations ---
/files/{file_id}/tags:
get:
tags: [Files, Tags]
summary: List tags assigned to a file
parameters:
- $ref: '#/components/parameters/file_id'
responses:
'200':
description: Tag list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
put:
tags: [Files, Tags]
summary: Set tags on a file (replaces all)
parameters:
- $ref: '#/components/parameters/file_id'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tag_ids]
properties:
tag_ids:
type: array
items:
type: string
format: uuid
responses:
'200':
description: Updated tag list (including auto-applied tags)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
/files/{file_id}/tags/{tag_id}:
put:
tags: [Files, Tags]
summary: Add a tag to a file
parameters:
- $ref: '#/components/parameters/file_id'
- $ref: '#/components/parameters/tag_id'
responses:
'200':
description: Tags after addition (including auto-applied)
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Tag'
delete:
tags: [Files, Tags]
summary: Remove a tag from a file
parameters:
- $ref: '#/components/parameters/file_id'
- $ref: '#/components/parameters/tag_id'
responses:
'204':
description: Tag removed
# --- Bulk file operations ---
/files/bulk/tags:
post:
tags: [Files, Tags]
summary: Bulk add/remove tags on multiple files
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_ids, action, tag_ids]
properties:
file_ids:
type: array
items:
type: string
format: uuid
action:
type: string
enum: [add, remove]
tag_ids:
type: array
items:
type: string
format: uuid
responses:
'200':
description: Result with all applied tag IDs (including auto-applied, for add)
content:
application/json:
schema:
type: object
properties:
applied_tag_ids:
type: array
items:
type: string
format: uuid
/files/bulk/delete:
post:
tags: [Files]
summary: Bulk soft-delete files
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_ids]
properties:
file_ids:
type: array
items:
type: string
format: uuid
responses:
'204':
description: Files moved to trash
/files/bulk/review:
post:
tags: [Files]
summary: Set the review status on one or more files
description: >-
Marks the given files as needing review (`needs_review=true`) or as
review-done (`false`). A single-file toggle is just a one-element list.
Files the caller cannot edit are silently skipped.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_ids, needs_review]
properties:
file_ids:
type: array
items:
type: string
format: uuid
needs_review:
type: boolean
responses:
'204':
description: Review status updated
/files/bulk/common-tags:
post:
tags: [Files, Tags]
summary: Get tags for multiple files, split into common (all) and partial (some)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_ids]
properties:
file_ids:
type: array
items:
type: string
format: uuid
responses:
'200':
description: Tags grouped by coverage
content:
application/json:
schema:
type: object
properties:
common_tag_ids:
type: array
description: Tags present on ALL specified files
items:
type: string
format: uuid
partial_tag_ids:
type: array
description: Tags present on SOME but not all specified files
items:
type: string
format: uuid
# --- Duplicate detection ---
/files/duplicates:
get:
tags: [Files]
summary: List duplicate clusters
description: >-
Groups of perceptually similar files (within the server's hash-distance
threshold), read from a precomputed pairs table — this never compares all
files on each call. Pairs are (re)built offline by the dedup tool, so the
result reflects state as of the last rescan. Only files the caller may view
are included; dismissed and trashed pairs are excluded.
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 50
description: Maximum number of clusters to return
- name: offset
in: query
schema:
type: integer
default: 0
minimum: 0
responses:
'200':
description: A page of duplicate clusters
content:
application/json:
schema:
$ref: '#/components/schemas/DuplicateClusterPage'
/files/duplicates/dismiss:
post:
tags: [Files]
summary: Mark two files as not duplicates
description: >-
Records a global "not a duplicate" decision so the pair stops appearing in
the duplicates view (it survives future rescans). The caller must be able
to view both files.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_id_a, file_id_b]
properties:
file_id_a:
type: string
format: uuid
file_id_b:
type: string
format: uuid
responses:
'204':
description: Pair dismissed
/files/duplicates/resolve:
post:
tags: [Files]
summary: Resolve a duplicate by merging two files
description: >-
Keeps one file and folds the chosen fields in from the other, then (by
default) trashes the other. The caller must be able to edit both files. To
simply delete one/both or to keep both, use the bulk-delete and dismiss
endpoints instead. Returns the updated survivor.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DuplicateResolve'
responses:
'200':
description: The updated surviving file
content:
application/json:
schema:
$ref: '#/components/schemas/File'
# --- File import ---
/files/import:
post:
tags: [Files]
summary: Import files from a server directory
description: >
Admin only. Ingests supported files from the server's configured import
directory (optionally a subfolder of it). Subdirectories are skipped and
not recursed. A successfully imported file is removed from the import
folder. For files without an EXIF date, the source file's modified time
is used as content_datetime.
Progress is streamed as newline-delimited JSON (`application/x-ndjson`):
a single `start` event, one `file` event per directory entry as it is
processed, and a final `done` event with the tallies. A validation error
raised before any file is touched (e.g. import disabled, bad path) is
instead returned as a normal JSON error with a 4xx/5xx status.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
path:
type: string
description: Server directory path (uses user's configured import path if omitted)
responses:
'200':
description: >
A stream of newline-delimited JSON import progress events. The
schema below describes a single event line; the response body is one
such object per line.
content:
application/x-ndjson:
schema:
type: object
required: [type]
properties:
type:
type: string
enum: [start, file, done, error]
description: Event discriminator.
total:
type: integer
description: Entries to process (start) or processed (done).
index:
type: integer
description: 1-based position of this entry (file events).
filename:
type: string
description: Entry name (file events).
status:
type: string
enum: [imported, skipped, error]
description: Outcome for this entry (file events).
reason:
type: string
description: Detail for a skipped/error/warning entry.
imported:
type: integer
description: Total imported (done event).
skipped:
type: integer
description: Total skipped (done event).
errors:
type: integer
description: Total errors (done event).
# -------------------------------------------------------------------------
# Tags
# -------------------------------------------------------------------------
/tags:
get:
tags: [Tags]
summary: List tags
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
- name: sort
in: query
schema:
type: string
enum: [name, color, category_name, created]
default: created
- name: order
in: query
schema:
type: string
enum: [asc, desc]
default: desc
- name: search
in: query
description: Search by name (substring)
schema:
type: string
responses:
'200':
description: Tag list
content:
application/json:
schema:
$ref: '#/components/schemas/TagOffsetPage'
post:
tags: [Tags]
summary: Create a tag
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TagCreate'
responses:
'201':
description: Tag created
content:
application/json:
schema:
$ref: '#/components/schemas/Tag'
'400':
$ref: '#/components/responses/ValidationError'
'409':
description: Tag name already exists
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/tags/{tag_id}:
get:
tags: [Tags]
summary: Get a tag
parameters:
- $ref: '#/components/parameters/tag_id'
responses:
'200':
description: Tag
content:
application/json:
schema:
$ref: '#/components/schemas/Tag'
'404':
$ref: '#/components/responses/NotFound'
patch:
tags: [Tags]
summary: Update a tag
parameters:
- $ref: '#/components/parameters/tag_id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TagUpdate'
responses:
'200':
description: Updated tag
content:
application/json:
schema:
$ref: '#/components/schemas/Tag'
delete:
tags: [Tags]
summary: Delete a tag
parameters:
- $ref: '#/components/parameters/tag_id'
responses:
'204':
description: Tag deleted
/tags/{tag_id}/files:
get:
tags: [Tags, Files]
summary: List files with this tag
parameters:
- $ref: '#/components/parameters/tag_id'
- name: cursor
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 50
responses:
'200':
description: File list
content:
application/json:
schema:
$ref: '#/components/schemas/FileCursorPage'
# --- Tag rules ---
/tags/{tag_id}/rules:
get:
tags: [Tags]
summary: List tag rules for a tag (when this tag is applied, which tags follow)
parameters:
- $ref: '#/components/parameters/tag_id'
responses:
'200':
description: Tag rules
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/TagRule'
post:
tags: [Tags]
summary: Add a tag rule
parameters:
- $ref: '#/components/parameters/tag_id'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [then_tag_id]
properties:
then_tag_id:
type: string
format: uuid
is_active:
type: boolean
default: true
apply_to_existing:
type: boolean
default: true
description: Apply rule retroactively to files already tagged
responses:
'201':
description: Rule created
content:
application/json:
schema:
$ref: '#/components/schemas/TagRule'
/tags/{tag_id}/rules/{then_tag_id}:
patch:
tags: [Tags]
summary: Update a tag rule (activate / deactivate)
parameters:
- $ref: '#/components/parameters/tag_id'
- name: then_tag_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [is_active]
properties:
is_active:
type: boolean
apply_to_existing:
type: boolean
default: false
description: When activating, apply rule retroactively to files already tagged
responses:
'200':
description: Rule updated
content:
application/json:
schema:
$ref: '#/components/schemas/TagRule'
'404':
$ref: '#/components/responses/NotFound'
delete:
tags: [Tags]
summary: Remove a tag rule
parameters:
- $ref: '#/components/parameters/tag_id'
- name: then_tag_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'204':
description: Rule removed
# -------------------------------------------------------------------------
# Categories
# -------------------------------------------------------------------------
/categories:
get:
tags: [Categories]
summary: List categories
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
- name: sort
in: query
schema:
type: string
enum: [name, created]
default: created
- name: order
in: query
schema:
type: string
enum: [asc, desc]
default: desc
- name: search
in: query
schema:
type: string
responses:
'200':
description: Category list
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryOffsetPage'
post:
tags: [Categories]
summary: Create a category
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryCreate'
responses:
'201':
description: Category created
content:
application/json:
schema:
$ref: '#/components/schemas/Category'
'409':
description: Name already exists
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/categories/{category_id}:
get:
tags: [Categories]
summary: Get a category
parameters:
- $ref: '#/components/parameters/category_id'
responses:
'200':
description: Category
content:
application/json:
schema:
$ref: '#/components/schemas/Category'
'404':
$ref: '#/components/responses/NotFound'
patch:
tags: [Categories]
summary: Update a category
parameters:
- $ref: '#/components/parameters/category_id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CategoryUpdate'
responses:
'200':
description: Updated category
content:
application/json:
schema:
$ref: '#/components/schemas/Category'
delete:
tags: [Categories]
summary: Delete a category
parameters:
- $ref: '#/components/parameters/category_id'
responses:
'204':
description: Category deleted
/categories/{category_id}/tags:
get:
tags: [Categories, Tags]
summary: List tags in a category
parameters:
- $ref: '#/components/parameters/category_id'
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
responses:
'200':
description: Tag list
content:
application/json:
schema:
$ref: '#/components/schemas/TagOffsetPage'
# -------------------------------------------------------------------------
# Pools
# -------------------------------------------------------------------------
/pools:
get:
tags: [Pools]
summary: List pools
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
- name: sort
in: query
schema:
type: string
enum: [name, created]
default: created
- name: order
in: query
schema:
type: string
enum: [asc, desc]
default: desc
- name: search
in: query
schema:
type: string
responses:
'200':
description: Pool list
content:
application/json:
schema:
$ref: '#/components/schemas/PoolOffsetPage'
post:
tags: [Pools]
summary: Create a pool
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PoolCreate'
responses:
'201':
description: Pool created
content:
application/json:
schema:
$ref: '#/components/schemas/Pool'
/pools/{pool_id}:
get:
tags: [Pools]
summary: Get a pool
parameters:
- $ref: '#/components/parameters/pool_id'
responses:
'200':
description: Pool
content:
application/json:
schema:
$ref: '#/components/schemas/Pool'
'404':
$ref: '#/components/responses/NotFound'
patch:
tags: [Pools]
summary: Update a pool
parameters:
- $ref: '#/components/parameters/pool_id'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PoolUpdate'
responses:
'200':
description: Updated pool
content:
application/json:
schema:
$ref: '#/components/schemas/Pool'
delete:
tags: [Pools]
summary: Delete a pool
parameters:
- $ref: '#/components/parameters/pool_id'
responses:
'204':
description: Pool deleted
/pools/{pool_id}/views:
post:
tags: [Pools]
summary: Record that the current user viewed the pool
parameters:
- $ref: '#/components/parameters/pool_id'
responses:
'204':
description: View recorded
'404':
$ref: '#/components/responses/NotFound'
/pools/{pool_id}/files:
get:
tags: [Pools, Files]
summary: List files in a pool (ordered by position)
parameters:
- $ref: '#/components/parameters/pool_id'
- name: cursor
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 50
- name: filter
in: query
description: Filter DSL (same syntax as /files)
schema:
type: string
responses:
'200':
description: Ordered file list
content:
application/json:
schema:
$ref: '#/components/schemas/PoolFileCursorPage'
post:
tags: [Pools, Files]
summary: Add files to a pool
parameters:
- $ref: '#/components/parameters/pool_id'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_ids]
properties:
file_ids:
type: array
items:
type: string
format: uuid
position:
type: integer
description: Insert position for the first file (rest follow sequentially; appended at end if omitted)
responses:
'201':
description: Files added to pool
/pools/{pool_id}/files/remove:
post:
tags: [Pools, Files]
summary: Remove files from a pool
parameters:
- $ref: '#/components/parameters/pool_id'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_ids]
properties:
file_ids:
type: array
items:
type: string
format: uuid
responses:
'204':
description: Files removed from pool
/pools/{pool_id}/files/reorder:
put:
tags: [Pools]
summary: Reorder files in a pool
parameters:
- $ref: '#/components/parameters/pool_id'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [file_ids]
properties:
file_ids:
type: array
description: File UUIDs in desired order
items:
type: string
format: uuid
responses:
'204':
description: Reordered
# -------------------------------------------------------------------------
# ACL
# -------------------------------------------------------------------------
/acl/{object_type}/{object_id}:
get:
tags: [ACL]
summary: List permissions for an object
parameters:
- name: object_type
in: path
required: true
schema:
type: string
enum: [file, tag, category, pool]
- name: object_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Permission list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Permission'
put:
tags: [ACL]
summary: Set permissions for an object (replaces all)
parameters:
- name: object_type
in: path
required: true
schema:
type: string
enum: [file, tag, category, pool]
- name: object_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [permissions]
properties:
permissions:
type: array
items:
type: object
required: [user_id]
properties:
user_id:
type: integer
can_view:
type: boolean
default: true
can_edit:
type: boolean
default: false
responses:
'200':
description: Updated permissions
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Permission'
# -------------------------------------------------------------------------
# Users (admin)
# -------------------------------------------------------------------------
/users:
get:
tags: [Users]
summary: List users (admin only)
parameters:
- $ref: '#/components/parameters/offset'
- $ref: '#/components/parameters/limit'
responses:
'200':
description: User list
content:
application/json:
schema:
$ref: '#/components/schemas/UserOffsetPage'
'403':
$ref: '#/components/responses/Forbidden'
post:
tags: [Users]
summary: Create a user (admin only)
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UserCreate'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'403':
$ref: '#/components/responses/Forbidden'
/users/me:
get:
tags: [Users]
summary: Get current user profile
responses:
'200':
description: Current user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
patch:
tags: [Users]
summary: Update current user profile (name, password)
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
password:
type: string
responses:
'200':
description: Updated user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/users/{user_id}:
get:
tags: [Users]
summary: Get a user (admin only)
parameters:
- $ref: '#/components/parameters/user_id'
responses:
'200':
description: User
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'403':
$ref: '#/components/responses/Forbidden'
patch:
tags: [Users]
summary: Update user role/status (admin only)
parameters:
- $ref: '#/components/parameters/user_id'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
is_admin:
type: boolean
can_create:
type: boolean
is_blocked:
type: boolean
responses:
'200':
description: Updated user
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'403':
$ref: '#/components/responses/Forbidden'
delete:
tags: [Users]
summary: Delete a user (admin only)
parameters:
- $ref: '#/components/parameters/user_id'
responses:
'204':
description: User deleted
'403':
$ref: '#/components/responses/Forbidden'
# -------------------------------------------------------------------------
# Audit log (admin)
# -------------------------------------------------------------------------
/audit:
get:
tags: [Audit]
summary: Query audit log (admin only)
parameters:
- $ref: '#/components/parameters/offset'
- name: limit
in: query
schema:
type: integer
default: 50
maximum: 200
- name: user_id
in: query
schema:
type: integer
- name: action
in: query
description: Filter by action type name
schema:
type: string
- name: object_type
in: query
schema:
type: string
enum: [file, tag, category, pool]
- name: object_id
in: query
schema:
type: string
format: uuid
- name: from
in: query
schema:
type: string
format: date-time
- name: to
in: query
schema:
type: string
format: date-time
responses:
'200':
description: Audit log entries
content:
application/json:
schema:
$ref: '#/components/schemas/AuditLogOffsetPage'
'403':
$ref: '#/components/responses/Forbidden'
# ===========================================================================
# Components
# ===========================================================================
components:
# -------------------------------------------------------------------------
# Security schemes
# -------------------------------------------------------------------------
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
# -------------------------------------------------------------------------
# Parameters
# -------------------------------------------------------------------------
parameters:
file_id:
name: file_id
in: path
required: true
schema:
type: string
format: uuid
tag_id:
name: tag_id
in: path
required: true
schema:
type: string
format: uuid
category_id:
name: category_id
in: path
required: true
schema:
type: string
format: uuid
pool_id:
name: pool_id
in: path
required: true
schema:
type: string
format: uuid
user_id:
name: user_id
in: path
required: true
schema:
type: integer
offset:
name: offset
in: query
schema:
type: integer
default: 0
minimum: 0
limit:
name: limit
in: query
schema:
type: integer
default: 50
minimum: 1
maximum: 200
# -------------------------------------------------------------------------
# Responses
# -------------------------------------------------------------------------
responses:
Unauthorized:
description: Authentication required or token invalid
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: Insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ValidationError:
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
# -------------------------------------------------------------------------
# Schemas
# -------------------------------------------------------------------------
schemas:
# --- Error ---
Error:
type: object
required: [code, message]
properties:
code:
type: string
example: not_found
message:
type: string
example: File not found
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
# --- Auth ---
TokenPair:
type: object
properties:
access_token:
type: string
refresh_token:
type: string
expires_in:
type: integer
description: Access token TTL in seconds
Session:
type: object
properties:
id:
type: integer
user_agent:
type: string
started_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
nullable: true
last_activity:
type: string
format: date-time
is_current:
type: boolean
SessionList:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/Session'
total:
type: integer
# --- File ---
File:
type: object
required:
- id
- mime_type
- mime_extension
- content_datetime
- exif
- creator_id
- creator_name
- is_public
- is_deleted
- needs_review
- created_at
- tags
properties:
id:
type: string
format: uuid
original_name:
type: string
nullable: true
mime_type:
type: string
example: image/jpeg
mime_extension:
type: string
example: jpg
content_datetime:
type: string
format: date-time
notes:
type: string
nullable: true
metadata:
type: object
nullable: true
exif:
type: object
readOnly: true
description: EXIF data extracted at upload time (immutable, empty object if no EXIF)
phash:
type: integer
format: int64
nullable: true
creator_id:
type: integer
creator_name:
type: string
is_public:
type: boolean
is_deleted:
type: boolean
needs_review:
type: boolean
description: >-
True until the file's tagging is explicitly marked done. New uploads
and imports start true; cleared via POST /files/bulk/review. Filter
with `r=1` (needs review) / `r=0` (done).
created_at:
type: string
format: date-time
description: Extracted from UUID v7
tags:
type: array
description: Tags assigned to the file
items:
$ref: '#/components/schemas/Tag'
FileUpdate:
type: object
properties:
original_name:
type: string
content_datetime:
type: string
format: date-time
notes:
type: string
metadata:
type: object
is_public:
type: boolean
FileCursorPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/File'
next_cursor:
type: string
nullable: true
description: Cursor for loading next (forward) page; null if no more items ahead
prev_cursor:
type: string
nullable: true
description: Cursor for loading previous (backward) page; null if at the beginning
# --- Duplicates ---
DuplicateCluster:
type: object
properties:
files:
type: array
description: Two or more mutually similar files
items:
$ref: '#/components/schemas/File'
DuplicateClusterPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/DuplicateCluster'
total:
type: integer
description: Total number of clusters (not files)
offset:
type: integer
limit:
type: integer
MergeScalarChoice:
type: string
enum: [keep, discard]
default: keep
description: Take this field's value from the kept file or the discarded one
MergeRelationChoice:
type: string
enum: [keep, both]
default: keep
description: Keep only the survivor's relations, or union both files' relations
DuplicateResolve:
type: object
required: [keep, discard]
properties:
keep:
type: string
format: uuid
description: The file to keep (the survivor)
discard:
type: string
format: uuid
description: The other file in the pair
delete_discarded:
type: boolean
default: true
description: Move the discarded file to trash after merging
fields:
type: object
description: Per-field source for the merge; omitted fields default to "keep"
properties:
original_name:
$ref: '#/components/schemas/MergeScalarChoice'
notes:
$ref: '#/components/schemas/MergeScalarChoice'
content_datetime:
$ref: '#/components/schemas/MergeScalarChoice'
is_public:
$ref: '#/components/schemas/MergeScalarChoice'
metadata:
type: string
enum: [keep, discard, merge]
default: keep
description: >-
Keep or take the discarded file's metadata object, or shallow-merge
them with the survivor winning on key conflicts
tags:
$ref: '#/components/schemas/MergeRelationChoice'
pools:
$ref: '#/components/schemas/MergeRelationChoice'
# --- Tag ---
Tag:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
notes:
type: string
nullable: true
color:
type: string
nullable: true
example: "5DCAA5"
category_id:
type: string
format: uuid
nullable: true
category_name:
type: string
nullable: true
category_color:
type: string
nullable: true
metadata:
type: object
nullable: true
creator_id:
type: integer
creator_name:
type: string
is_public:
type: boolean
created_at:
type: string
format: date-time
TagCreate:
type: object
required: [name]
properties:
name:
type: string
notes:
type: string
color:
type: string
pattern: '^[A-Fa-f0-9]{6}$'
category_id:
type: string
format: uuid
metadata:
type: object
is_public:
type: boolean
default: false
TagUpdate:
type: object
properties:
name:
type: string
notes:
type: string
color:
type: string
nullable: true
description: Hex color or null to clear
category_id:
type: string
format: uuid
nullable: true
description: Category UUID or null to unassign
metadata:
type: object
is_public:
type: boolean
TagOffsetPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/Tag'
total:
type: integer
offset:
type: integer
limit:
type: integer
# --- Tag rule ---
TagRule:
type: object
properties:
when_tag_id:
type: string
format: uuid
then_tag_id:
type: string
format: uuid
then_tag_name:
type: string
is_active:
type: boolean
# --- Category ---
Category:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
notes:
type: string
nullable: true
color:
type: string
nullable: true
metadata:
type: object
nullable: true
creator_id:
type: integer
creator_name:
type: string
is_public:
type: boolean
created_at:
type: string
format: date-time
CategoryCreate:
type: object
required: [name]
properties:
name:
type: string
notes:
type: string
color:
type: string
pattern: '^[A-Fa-f0-9]{6}$'
metadata:
type: object
is_public:
type: boolean
default: false
CategoryUpdate:
type: object
properties:
name:
type: string
notes:
type: string
color:
type: string
nullable: true
metadata:
type: object
is_public:
type: boolean
CategoryOffsetPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/Category'
total:
type: integer
offset:
type: integer
limit:
type: integer
# --- Pool ---
Pool:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
notes:
type: string
nullable: true
metadata:
type: object
nullable: true
creator_id:
type: integer
creator_name:
type: string
is_public:
type: boolean
file_count:
type: integer
created_at:
type: string
format: date-time
PoolCreate:
type: object
required: [name]
properties:
name:
type: string
notes:
type: string
metadata:
type: object
is_public:
type: boolean
default: false
PoolUpdate:
type: object
properties:
name:
type: string
notes:
type: string
metadata:
type: object
is_public:
type: boolean
PoolOffsetPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/Pool'
total:
type: integer
offset:
type: integer
limit:
type: integer
PoolFile:
type: object
allOf:
- $ref: '#/components/schemas/File'
- type: object
properties:
position:
type: integer
PoolFileCursorPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/PoolFile'
next_cursor:
type: string
nullable: true
# --- ACL ---
Permission:
type: object
properties:
user_id:
type: integer
user_name:
type: string
can_view:
type: boolean
can_edit:
type: boolean
# --- User ---
User:
type: object
properties:
id:
type: integer
name:
type: string
is_admin:
type: boolean
can_create:
type: boolean
is_blocked:
type: boolean
UserCreate:
type: object
required: [name, password]
properties:
name:
type: string
password:
type: string
is_admin:
type: boolean
default: false
can_create:
type: boolean
default: false
UserOffsetPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/User'
total:
type: integer
offset:
type: integer
limit:
type: integer
# --- Audit ---
AuditLogEntry:
type: object
properties:
id:
type: integer
format: int64
user_id:
type: integer
user_name:
type: string
action:
type: string
example: file_create
object_type:
type: string
nullable: true
object_id:
type: string
format: uuid
nullable: true
details:
type: object
nullable: true
performed_at:
type: string
format: date-time
AuditLogOffsetPage:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/AuditLogEntry'
total:
type: integer
offset:
type: integer
limit:
type: integer