openapi: 3.1.0

info:
  title: Freaking Fast File Drive API
  version: '2026.09.23'
  summary: Large-file upload, storage, and sharing.
  description: |
    Files are uploaded directly to object storage using a presigned POST policy, then recorded
    against the caller's account. Three calls per upload:

      1. `POST /generate-file-urls` — returns `uploadUrl` + `uploadForm`
      2. `POST` the file as multipart/form-data to `uploadUrl`, including every `uploadForm` field
      3. `POST /insert-file` — records the file and returns its id

    Step 3 re-reads the uploaded object's `Content-Length` and rejects the insert if it disagrees
    with the submitted `fileSize`, so the size must be exact.

    Large files can go up in parts instead of step 2, so a dropped connection costs one part rather
    than the whole file, and an interrupted upload can be resumed:

      1. `POST /create-multipart-upload` with the same body as `/generate-file-urls` — returns a
         `token`, the part plan, and any parts already stored under that key
      2. `POST /sign-upload-parts` for the parts still to send, then `PUT` each part's bytes to its
         `url`. Each URL accepts exactly its part's length, and nothing else
      3. `POST /complete-multipart-upload`, then `POST /insert-file` as above

    To resume, call `/create-multipart-upload` again with the same `objectKey`: it answers with the
    parts storage already has.

    Polymorphic bodies use `type` as the discriminator (built_value `StandardJsonPlugin`).

servers:
  - url: https://{host}
    variables:
      host:
        default: fffs.freakingfast.io

security:
  - bearerAuth: []

tags:
  - name: upload
  - name: download
  - name: library
  - name: trash
  - name: metadata

paths:
  /generate-file-urls:
    post:
      tags: [upload]
      operationId: generateFileUrls
      summary: Get a presigned upload form for a new file.
      description: |
        Returns a short-lived S3 POST policy. The policy expires in 2 days and pins the exact
        content length, Content-Type, Content-Disposition, and Cache-Control — send the returned
        `uploadForm` fields verbatim or the upload is rejected by storage.
      parameters:
        - $ref: '#/components/parameters/AbsurdApp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileInput' }
      responses:
        '200':
          description: Presigned upload form.
          content:
            application/json:
              schema:
                type: object
                required: [uploadUrl, uploadForm]
                properties:
                  uploadUrl:
                    type: string
                    format: uri
                    description: POST the multipart body here.
                  uploadForm:
                    type: object
                    additionalProperties: { type: string }
                    description: Form fields to include before the file part.
                  downloadUrl:
                    type: [string, 'null']
                    format: uri
                    description: Static URL for public-bucket files; null for private files.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /insert-file:
    post:
      tags: [upload]
      operationId: insertFile
      summary: Record an uploaded file against the account.
      description: |
        Call only after the storage POST succeeds. `fileSize` must equal the stored object's real
        size. Audio, video, and image files get metadata extracted server-side; audio additionally
        kicks off asynchronous soundprint generation.
      parameters:
        - $ref: '#/components/parameters/AbsurdApp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileInsert' }
      responses:
        '200':
          description: The stored file record.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FileRecord' }
        '400':
          description: Size mismatch, storage limit reached, or missing fields.
        '403':
          $ref: '#/components/responses/Forbidden'

  /create-multipart-upload:
    post:
      tags: [upload]
      operationId: createMultipartUpload
      summary: Open a multipart upload, or resume the one open under this key.
      description: |
        Takes the same body as `/generate-file-urls` and passes the same checks, quota included.
        Calling it again with the same `objectKey` resumes: `parts` lists what storage already
        holds. An open upload whose parts do not fit this file's size is discarded and replaced.
        Parts are 64 MiB, larger only when a file would need more than 10,000.
      parameters:
        - $ref: '#/components/parameters/AbsurdApp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileInput' }
      responses:
        '200':
          description: The open upload.
          content:
            application/json:
              schema:
                type: object
                required: [token, uploadId, resumed, partSize, partCount, parts]
                properties:
                  token:
                    type: string
                    description: Passed to the other multipart calls. Valid for 7 days; open the upload again for a new one.
                  uploadId: { type: string }
                  resumed:
                    type: boolean
                    description: True when this picked up an upload already open under the key.
                  partSize:
                    type: integer
                    format: int64
                    description: Every part is this size except the last, which carries the remainder.
                  partCount: { type: integer }
                  parts:
                    type: array
                    description: Parts storage already holds, by number.
                    items: { $ref: '#/components/schemas/StoredPart' }
                  signedParts:
                    type: array
                    description: |
                      URLs for the first 100 parts storage does not hold yet, so a file of up to 100
                      parts never calls `/sign-upload-parts`.
                    items: { $ref: '#/components/schemas/SignedPart' }
                  expiresAt:
                    type: string
                    format: date-time
                    description: When the `signedParts` URLs stop working, a day after issue.
                  downloadUrl:
                    type: [string, 'null']
                    format: uri
                    description: Static URL for public-bucket files; null for private files.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /sign-upload-parts:
    post:
      tags: [upload]
      operationId: signUploadParts
      summary: Get upload URLs for parts of an open upload.
      description: |
        Up to 1,000 parts per call. `PUT` each part's bytes to its `url` within a day. The URL is
        signed for the part's exact length, so storage refuses a body one byte longer or shorter.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, partNumbers]
              properties:
                token: { type: string }
                partNumbers:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { type: integer, minimum: 1 }
      responses:
        '200':
          description: One signed URL per part.
          content:
            application/json:
              schema:
                type: object
                required: [parts, expiresAt]
                properties:
                  parts:
                    type: array
                    items: { $ref: '#/components/schemas/SignedPart' }
                  expiresAt: { type: string, format: date-time }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /complete-multipart-upload:
    post:
      tags: [upload]
      operationId: completeMultipartUpload
      summary: Join the parts into the finished object.
      description: |
        Reads the parts from storage, so no ETags need collecting. Every part must be there at its
        planned size. Safe to repeat: a completion whose response was lost reports success again.
        Call `/insert-file` afterwards to record the file.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UploadToken' }
      responses:
        '200':
          description: The object is complete.
          content:
            application/json:
              schema:
                type: object
                required: [bucket, objectKey, fileSize]
                properties:
                  bucket: { type: string }
                  objectKey: { type: string }
                  fileSize: { type: integer, format: int64 }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The upload is no longer open and no object was made (`upload_not_found`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }
        '409':
          description: Parts are missing (`parts_missing`) or do not match the plan (`parts_invalid`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }

  /abort-multipart-upload:
    post:
      tags: [upload]
      operationId: abortMultipartUpload
      summary: Discard an open upload and its parts.
      description: |
        For starting over rather than resuming. Aborting an upload that is already gone succeeds.
        An upload that gets no new part for 7 days is aborted automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UploadToken' }
      responses:
        '200':
          description: The upload is gone.
          content:
            application/json:
              schema:
                type: object
                properties:
                  aborted: { type: boolean }
        '403':
          $ref: '#/components/responses/Forbidden'

  /generate-download-url:
    post:
      tags: [download]
      operationId: generateDownloadUrl
      summary: Get a time-limited download URL for a file.
      description: Presigned GET valid for 6 hours. Public-bucket files may be fetched without a token.
      security:
        - bearerAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: string, format: uuid }
      responses:
        '200':
          description: File record with `downloadUrl` populated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FileRecord' }
        '400':
          description: Missing id, or private file requested without a token.
        '403':
          description: File not found for this caller.

  /select-user-files:
    post:
      tags: [library]
      operationId: selectUserFiles
      summary: List every file the caller created.
      description: |
        Newest first. Excludes trashed files and extracted derivatives (album art, video
        thumbnails). Takes no body.
      responses:
        '200':
          description: Files owned by the caller.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/FileRecord' }

  /get-user-storage-usage:
    post:
      tags: [library]
      operationId: getUserStorageUsage
      summary: Byte and object counts broken down by bucket and media kind.
      responses:
        '200':
          description: Usage totals.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StorageUsage' }

  /trash-files:
    post:
      tags: [trash]
      operationId: trashFiles
      summary: Move files to trash (recoverable).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              maxItems: 100
              items: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: Ids of the files that were trashed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  trashed:
                    type: array
                    items: { type: string, format: uuid }

  /restore-files:
    post:
      tags: [trash]
      operationId: restoreFiles
      summary: Restore files from trash.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              items: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: Ids of the files that were restored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  restored:
                    type: array
                    items: { type: string, format: uuid }

  /select-trashed-files:
    post:
      tags: [trash]
      operationId: selectTrashedFiles
      summary: List trashed files.
      responses:
        '200':
          description: Trashed files.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/FileRecord' }

  /purge-trashed-files:
    post:
      tags: [trash]
      operationId: purgeTrashedFiles
      summary: Permanently delete everything in trash.
      responses:
        '200': { description: Trash purged. }

  /delete-files:
    post:
      tags: [trash]
      operationId: deleteFiles
      summary: Permanently delete files and their stored objects.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              maxItems: 100
              items: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: Ids of the files that were deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: array
                    items: { type: string, format: uuid }

  /toggle-file-access:
    post:
      tags: [metadata]
      operationId: toggleFileAccess
      summary: Flip one file between public and private.
      description: |
        Moves the stored object to the other bucket. A file uploaded under `users/` is always
        inserted private, so this is how it becomes public.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: The file as it now stands. `downloadUrl` is its public link when it is public.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FileRecord' }

  /update-file-description:
    post:
      tags: [metadata]
      operationId: updateFileDescription
      summary: Set a file's description.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileId, description]
              properties:
                fileId: { type: string, format: uuid }
                description:
                  type: string
                  description: An empty string clears it.
      responses:
        '200': { description: Description updated. }

  /update-file-tags:
    post:
      tags: [metadata]
      operationId: updateFileTags
      summary: Add a tag to files, or remove it from them.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileIds, tagId, action]
              properties:
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { type: string, format: uuid }
                tagId: { type: string, format: uuid }
                action: { type: string, enum: [add, remove] }
      responses:
        '200': { description: Tags updated. }

  /create-archive-request:
    post:
      tags: [library]
      operationId: createArchiveRequest
      summary: Request a ZIP archive of files, a gallery, or a playlist.
      description: Send one of `fileIds`, `galleryId` or `playlistId`. The archive is built in the background.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fileIds:
                  type: array
                  items: { type: string, format: uuid }
                galleryId: { type: string, format: uuid }
                playlistId: { type: string, format: uuid }
                title: { type: string, default: Archive }
      responses:
        '200': { description: Archive request accepted. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A Gel session token (HS256, `sub` = identity UUID). CLI API tokens must first be exchanged
        for a session token via the auth service's `/exchange-token` endpoint.

  parameters:
    AbsurdApp:
      name: X-Absurd-App
      in: header
      required: true
      schema: { type: string }
      description: Calling application identifier. Rejected if unrecognized.

  responses:
    BadRequest:
      description: Missing or invalid field.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    Forbidden:
      description: Token rejected, path prefix not permitted, or subscription required.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }

  schemas:
    ApiError:
      type: object
      required: [error, code, retryable]
      properties:
        error:
          type: string
          description: Human-readable sentence. Free to change; do not branch on it.
        code:
          type: string
          description: |
            Stable machine handle. Branch on this, never on `error`. One of:
            - `admin_check_failed`
            - `admin_required`
            - `archive_too_large`
            - `authentication_required`
            - `batch_too_large`
            - `corrupt_record`
            - `create_archive_failed`
            - `creator_mismatch`
            - `delete_file_failed`
            - `delete_files_failed`
            - `delete_linked_failed`
            - `delete_version_failed`
            - `delete_work_failed`
            - `deleting_file_data_failed`
            - `empty_batch`
            - `empty_file`
            - `extract_audio_failed`
            - `extract_frame_failed`
            - `extract_image_failed`
            - `fetching_file_failed`
            - `fetching_image_failed`
            - `fetching_video_failed`
            - `file_not_found`
            - `file_too_large`
            - `inserting_file_failed`
            - `internal_error`
            - `invalid_app_header`
            - `invalid_body`
            - `invalid_bucket`
            - `invalid_field`
            - `invalid_file_type`
            - `invalid_object_key`
            - `invalid_part_number`
            - `invalid_token`
            - `invalid_visibility`
            - `listing_objects_failed`
            - `missing_app_header`
            - `missing_field`
            - `move_file_failed`
            - `multipart_abort_failed`
            - `multipart_complete_failed`
            - `multipart_create_failed`
            - `not_found`
            - `not_owner`
            - `origin_not_allowed`
            - `parts_invalid`
            - `parts_missing`
            - `purging_trashed_files_failed`
            - `restoring_files_failed`
            - `selecting_files_failed`
            - `selecting_trashed_files_failed`
            - `size_mismatch`
            - `storage_limit_reached`
            - `subscription_required`
            - `token_required`
            - `too_many_parts`
            - `trashing_files_failed`
            - `unknown_route`
            - `updating_file_authorized_failed`
            - `updating_file_description_failed`
            - `updating_file_tags_failed`
            - `updating_metadata_failed`
            - `updating_thumbnail_failed`
            - `upload_not_found`
            - `upload_token_invalid`
            - `user_lookup_failed`
            - `user_not_found`
            - `verify_file_failed`
        field:
          type: string
          description: Present when a specific request field caused the failure.
        retryable:
          type: boolean
          description: Whether retrying the identical request could plausibly succeed.

    StoredPart:
      type: object
      required: [partNumber, size]
      properties:
        partNumber: { type: integer }
        size: { type: integer, format: int64 }

    SignedPart:
      type: object
      required: [partNumber, size, url]
      properties:
        partNumber: { type: integer }
        size: { type: integer, format: int64 }
        url:
          type: string
          format: uri
          description: Accepts exactly `size` bytes, by `PUT`.

    UploadToken:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: From `/create-multipart-upload`.

    FileType:
      type: string
      enum: [Image, Video, Audio, Pdf, Latex, Blob]
      description: Discriminator for polymorphic file bodies.

    FileInput:
      type: object
      required: [type, objectKey, fileSize, fileName]
      properties:
        type: { $ref: '#/components/schemas/FileType' }
        fileName: { type: string }
        fileSize:
          type: integer
          format: int64
          maximum: 2000000000000
          description: Exact byte length. 2TB ceiling.
        mimeType: { type: string }
        bucket:
          type: string
          description: |
            Optional. Omit for the private bucket, which is where user uploads belong. When
            supplied it must be the configured public or private bucket.
        objectKey:
          type: string
          description: |
            `users/{userId}/...` (at least three segments, userId must be the caller) or
            `global/...` (admins only, public bucket only).

    FileInsert:
      allOf:
        - $ref: '#/components/schemas/FileInput'
        - type: object
          required: [isPublic, creator]
          properties:
            isPublic:
              type: boolean
              description: Must be true for `global/` keys and false for `users/` keys.
            creator:
              type: object
              required: [id]
              properties:
                id: { type: string, format: uuid }
              description: Must match the authenticated user for `users/` keys.
            downloadUrl: { type: string, format: uri }
            tags:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }

    FileRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { $ref: '#/components/schemas/FileType' }
        fileName: { type: string }
        fileSize: { type: integer, format: int64 }
        mimeType: { type: string }
        isPublic: { type: boolean }
        downloadUrl: { type: [string, 'null'], format: uri }
        objectKey: { type: string }
        bucket: { type: string }
        insertTime: { type: string, format: date-time }
        updateTime: { type: string, format: date-time }
        tags:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              text: { type: string }

    FileRef:
      type: object
      description: |
        A file as the mutation endpoints read it. The body is deserialized as a File, so `type`
        is required; take it from `select-user-files` or `select-trashed-files`.
      required: [type, id]
      properties:
        type: { $ref: '#/components/schemas/FileType' }
        id: { type: string, format: uuid }

    StorageUsage:
      type: object
      properties:
        totalBytes: { type: integer, format: int64 }
        totalObjects: { type: integer }
        publicBytes: { type: integer, format: int64 }
        publicObjects: { type: integer }
        privateBytes: { type: integer, format: int64 }
        privateObjects: { type: integer }
        audioBytes: { type: integer, format: int64 }
        audioObjects: { type: integer }
        videoBytes: { type: integer, format: int64 }
        videoObjects: { type: integer }
        imageBytes: { type: integer, format: int64 }
        imageObjects: { type: integer }
        archiveBytes: { type: integer, format: int64 }
        archiveObjects: { type: integer }
        documentBytes: { type: integer, format: int64 }
        documentObjects: { type: integer }
