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=` (has tag), `t=00000000-0000-0000-0000-000000000000` (untagged), `m=` (exact MIME), `m~` (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