ac27ea3176
Add the GNU AGPL-3.0 license text, point the openapi.yaml license metadata at AGPL-3.0-or-later (was the "Proprietary" placeholder), and note the license in the README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2465 lines
65 KiB
YAML
2465 lines
65 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 require a Bearer JWT token in the `Authorization` header,
|
||
except `POST /auth/login`, `POST /auth/refresh` (which carry their own
|
||
credentials) and `GET /health`.
|
||
|
||
## 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: 3.0.0
|
||
license:
|
||
name: AGPL-3.0-or-later
|
||
url: https://www.gnu.org/licenses/agpl-3.0.html
|
||
|
||
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)
|
||
- name: System
|
||
description: Service health and liveness
|
||
|
||
# ===========================================================================
|
||
# Paths
|
||
# ===========================================================================
|
||
paths:
|
||
|
||
# -------------------------------------------------------------------------
|
||
# System
|
||
# -------------------------------------------------------------------------
|
||
/health:
|
||
# Served at the server root, outside the /api/v1 prefix — override the
|
||
# global server so the documented path is /health, not /api/v1/health.
|
||
servers:
|
||
- url: /
|
||
get:
|
||
tags: [System]
|
||
summary: Health check
|
||
description: |
|
||
Liveness probe. Requires no authentication and is used by the container
|
||
HEALTHCHECK. Always returns 200 while the process is serving.
|
||
security: []
|
||
responses:
|
||
'200':
|
||
description: Service is healthy.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [status]
|
||
properties:
|
||
status:
|
||
type: string
|
||
example: ok
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 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'
|
||
distances:
|
||
type: array
|
||
description: >-
|
||
Stored perceptual-hash (Hamming) distances between pairs of files in
|
||
the cluster. A pair linked only transitively (through an intermediate
|
||
file) has no stored distance and is omitted.
|
||
items:
|
||
$ref: '#/components/schemas/DuplicatePairDistance'
|
||
|
||
DuplicatePairDistance:
|
||
type: object
|
||
required: [a, b, distance]
|
||
properties:
|
||
a:
|
||
type: string
|
||
format: uuid
|
||
b:
|
||
type: string
|
||
format: uuid
|
||
distance:
|
||
type: integer
|
||
description: Hamming distance (0–64) between the two files' perceptual hashes
|
||
|
||
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
|
||
sort_key:
|
||
type: string
|
||
enum: [manual, content_datetime, created, original_name]
|
||
description: >-
|
||
How the pool's files are ordered. "manual" keeps the user-defined
|
||
order (drag-to-reorder); any other key sorts the files automatically
|
||
by that file field and disables manual reordering.
|
||
sort_order:
|
||
type: string
|
||
enum: [asc, desc]
|
||
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
|
||
sort_key:
|
||
type: string
|
||
enum: [manual, content_datetime, created, original_name]
|
||
sort_order:
|
||
type: string
|
||
enum: [asc, desc]
|
||
|
||
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
|