openapi: 3.0.3
info:
  title: GoLinks API
  description: |
    # GoLinks — Link & Content Management API

    GoLinks is a self-hosted link management system. It lets you create short links,
    bookmark pages with auto-extracted metadata, manage image collections, upload files,
    write blog posts, and serve random images from a personal library.

    ## Key concepts

    | Resource | What it is |
    |---|---|
    | **Page** | A bookmarked URL. When created, the server asynchronously fetches its title, description, and a screenshot. |
    | **Bookmark** | An archived social-media post (e.g. an x.com / Twitter tweet) saved with full preview data — text, author, images, video poster thumbnails, link cards, quotes — so the archive page renders offline. Bulk-import via `/api/bookmarks/import`. |
    | **Post** | A Markdown blog post, optionally tagged. |
    | **Collection** | A named group of uploaded images (e.g. a photo album). |
    | **Image** | An image file belonging to a Collection. |
    | **File** | A generic uploaded file. Files can be made public via a shareable token URL. |
    | **Random image** | Serves a random image from the server's local image library, resized on the fly. Great for placeholder images. |

    ## Common patterns

    ### Pagination
    All list endpoints accept `?page=` (1-based) and `?page_size=` query parameters.
    Responses follow the envelope `{ count, next, previous, results[] }`.

    ### Image embedding
    Use the random image endpoint as a live placeholder:
    ```
    ![placeholder](https://your-domain/api/images/random/400/300/?fit=crop&v=1234567890)
    ```
    Or import and self-host an external image:
    ```
    ![photo](https://your-domain/import/images/example.com/path/photo.jpg)
    ```

    ### File sharing
    Upload a file via `POST /api/files`, call `POST /api/files/{id}/toggle-public` to make
    it public, then share the returned `public_url` (`/public/files/{id}-{filename}`) —
    no authentication is required for the recipient. Set an expiry with
    `POST /api/files/{id}/set-expiry` to time-limit the link.

    ### Bookmarks bulk import
    Import (or upsert) many archived posts in one request. Each item accepts the
    full normalised export object from `x-bookmarks-exporter` (with nested
    `author`, `media`, `card`, `quoted`, `retweet`, counters); matching is by
    `tweet_id`:
    ```
    POST /api/bookmarks/import
    { "bookmarks": [ { "id": "123", "author": {"screen_name":"u"}, "text": "...",
                       "media": [...], ... }, ... ] }
    → { "created": 10, "updated": 0, "skipped": 0, "total": 769 }
    ```
    Use `PUT /api/bookmarks/bulk` with a bare JSON array of flat
    `Bookmark`-shaped objects for the same upsert behaviour.

    ## Authentication
    No authentication is currently required on any endpoint.
  version: 1.0.0
  x-logo:
    url: /static/images/logo.png

servers:
  - url: http://localhost:8000
    description: Local development server

tags:
  - name: Links
    description: Short links with click tracking. Supports listing all links and retrieving the most visited ones sorted by click count.
  - name: Pages
    description: |
      Bookmarked web pages. Creating a page triggers an async background job that fetches
      the page title, description, and screenshot automatically.
  - name: Posts
    description: Markdown blog posts with optional tag categorisation.
  - name: Bookmarks
    description: |
      Archived social-media posts (originally x.com / Twitter bookmarks), each saved with
      full preview data — text, author, media poster thumbnails (no embedded video streams),
      link cards, quotes — so the archive viewer page renders offline without further API calls.
      Use `POST /api/bookmarks/import` for bulk upsert by `tweet_id`.
  - name: Collections
    description: Named groups of images. Each collection can hold many uploaded image files.
  - name: Images
    description: |
      Image management and random image serving.
      The random image endpoint is useful as a live placeholder — pass `?v={timestamp}`
      to bust the browser cache on each render.
  - name: Files
    description: |
      Generic file uploads with optional public sharing via tokenised URLs.
      Files can be made public/private and given an expiry date.

paths:
  /api/links:
    get:
      operationId: listLinks
      tags: [Links]
      summary: List all links
      description: Retrieve a paginated list of all short links ordered by creation date (newest first).
      parameters:
        - in: query
          name: page
          schema:
            type: integer
            default: 1
          description: Page number (1-based).
        - in: query
          name: page_size
          schema:
            type: integer
            default: 10
            maximum: 100
          description: Number of results per page.
      responses:
        '200':
          description: Paginated list of links.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LinkList'

  /api/links/most-visited:
    get:
      operationId: listMostVisitedLinks
      tags: [Links]
      summary: Most visited links
      description: Return links that have at least one click, sorted by click count descending.
      parameters:
        - in: query
          name: page
          schema:
            type: integer
            default: 1
          description: Page number (1-based).
        - in: query
          name: page_size
          schema:
            type: integer
            default: 10
            maximum: 100
          description: Number of results per page.
      responses:
        '200':
          description: Paginated list of links sorted by click_count descending.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LinkList'

  /api/pages:
    get:
      operationId: listPages
      tags: [Pages]
      summary: List all pages
      description: Retrieve a paginated list of all bookmarked pages.
      parameters:
        - in: query
          name: page
          schema:
            type: integer
          description: Page number for pagination
        - in: query
          name: page_size
          schema:
            type: integer
          description: Number of items per page
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PageList'

    post:
      operationId: createPage
      tags: [Pages]
      summary: Create a new page
      description: |
        Bookmark a URL. After creation the server starts an async background job that fetches
        the page title, description, content, and a screenshot.
        Poll `GET /api/pages` or check `processing_status` to see when it completes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PageCreate'
      responses:
        '201':
          description: Page created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Page'

  /api/bookmarks:
    get:
      operationId: listBookmarks
      tags: [Bookmarks]
      summary: List archived bookmarks
      description: |
        Paginated list of archived social-media bookmarks, newest first. Supports
        full-text search (`q`), boolean media/type filters, author filter and
        ordering. Default page size is 24; use `?page_size=` up to 200.
      parameters:
        - in: query
          name: page
          schema: { type: integer }
          description: 1-based page number
        - in: query
          name: page_size
          schema: { type: integer, maximum: 200 }
          description: Items per page (default 24)
        - in: query
          name: q
          schema: { type: string }
          description: Case-insensitive search across `text`, `summary`, `author_screen_name`, `author_name`
        - in: query
          name: has_video
          schema: { type: boolean }
          description: Filter to bookmarks containing a video/animated GIF
        - in: query
          name: has_photo
          schema: { type: boolean }
        - in: query
          name: has_card
          schema: { type: boolean }
          description: Has an attached link preview card
        - in: query
          name: is_quote
          schema: { type: boolean }
        - in: query
          name: is_retweet
          schema: { type: boolean }
        - in: query
          name: author
          schema: { type: string }
          description: Exact (case-insensitive) screen name
        - in: query
          name: ordering
          schema:
            type: string
            enum: [tweet_created_at, -tweet_created_at, bookmark_created_at,
                   -bookmark_created_at, favorite_count, -favorite_count,
                   view_count, -view_count, created_at, -created_at]
          description: Sort field (prefix `-` for descending). Default `-tweet_created_at`
      responses:
        '200':
          description: Paginated bookmarks
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookmarkList'
    post:
      operationId: createBookmark
      tags: [Bookmarks]
      summary: Create a single bookmark
      description: |
        Create one archived bookmark from a flat `Bookmark`-shaped object.
        For importing many at once (e.g. from x-bookmarks-exporter), prefer
        `POST /api/bookmarks/import`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookmarkCreate'
      responses:
        '201':
          description: Bookmark created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Bookmark'

  /api/bookmarks/stats:
    get:
      operationId: bookmarkStats
      tags: [Bookmarks]
      summary: Aggregate bookmark counts
      description: Returns total count plus per-flag counts (used by the viewer's filter chips).
      responses:
        '200':
          description: Aggregate stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  has_video: { type: integer }
                  has_photo: { type: integer }
                  has_card: { type: integer }
                  is_quote: { type: integer }
                  is_retweet: { type: integer }

  /api/bookmarks/import:
    post:
      operationId: importBookmarks
      tags: [Bookmarks]
      summary: Bulk upsert bookmarks from export objects
      description: |
        Upserts many bookmarks in one request. Each item in `bookmarks` accepts the
        full normalised export object from `x-bookmarks-exporter` — with nested
        `author`, `media`, `card`, `quoted`, `retweet`, engagement counters and
        `created_at` (x.com-style RFC date string). Matching is by `tweet_id`
        (top-level `id` alias also accepted); existing entries are updated,
        new ones created. Returns `{ created, updated, skipped, total }`.

        Example body:
        ```json
        {
          "bookmarks": [
            {
              "id": "1234567890",
              "url": "https://x.com/user/status/1234567890",
              "author": {"screen_name":"user","name":"User","profile_image_url":"..."},
              "text": "Hello world",
              "media": [{"type":"video","media_url":"...","video":{"poster":"...","best_mp4":"...","duration_ms":12000}}],
              "favorite_count": 5, "view_count": 99
            }
          ]
        }
        ```
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bookmarks]
              properties:
                bookmarks:
                  type: array
                  items:
                    $ref: '#/components/schemas/BookmarkExportItem'
      responses:
        '200':
          description: Upsert summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  updated: { type: integer }
                  skipped: { type: integer }
                  total: { type: integer, description: Total bookmarks now in DB }

  /api/bookmarks/bulk:
    put:
      operationId: bulkUpsertBookmarks
      tags: [Bookmarks]
      summary: Bulk upsert from a flat JSON array
      description: |
        Like `/import` but accepts a bare JSON array (not wrapped in `{bookmarks:[]}`).
        Each item can be a flat `Bookmark`-shaped object (matching
        `BookmarkCreate`/`Bookmark`) or a normalised export object; `tweet_id`
        (or top-level `id`) is used as the upsert key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/BookmarkCreate'
      responses:
        '200':
          description: Upsert summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  updated: { type: integer }
                  skipped: { type: integer }
                  total: { type: integer }
    post:
      operationId: bulkUpsertBookmarksPost
      tags: [Bookmarks]
      summary: Bulk upsert from a flat JSON array (POST alias)
      description: Alias of `PUT /api/bookmarks/bulk` for clients that cannot send PUT.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '#/components/schemas/BookmarkCreate'
      responses:
        '200':
          description: Upsert summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  updated: { type: integer }
                  skipped: { type: integer }
                  total: { type: integer }

  /api/bookmarks/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: integer }
        description: Internal DB primary key (NOT the tweet_id)
    get:
      operationId: retrieveBookmark
      tags: [Bookmarks]
      summary: Retrieve a single bookmark
      responses:
        '200':
          description: Bookmark detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Bookmark'
        '404':
          description: Not found
    patch:
      operationId: updateBookmark
      tags: [Bookmarks]
      summary: Partially update a bookmark
      description: |
        Update any subset of fields. Common use by agents: write an AI-generated
        `summary` for the post via `{"summary": "..."}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookmarkCreate'
      responses:
        '200':
          description: Updated bookmark
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Bookmark'
    delete:
      operationId: deleteBookmark
      tags: [Bookmarks]
      summary: Delete a bookmark
      responses:
        '204':
          description: Deleted
        '404':
          description: Not found

  /api/posts:
    get:
      operationId: listPosts
      tags: [Posts]
      summary: List all posts
      description: Retrieve a paginated list of all Markdown blog posts.
      parameters:
        - in: query
          name: page
          schema:
            type: integer
          description: Page number for pagination
        - in: query
          name: page_size
          schema:
            type: integer
          description: Number of items per page
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostList'

    post:
      operationId: createPost
      tags: [Posts]
      summary: Create a new post
      description: Create a Markdown blog post. Pass tag slugs in the `tags` array to categorise it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostCreate'
      responses:
        '201':
          description: Post created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Post'

  /api/collections:
    get:
      operationId: listCollections
      tags: [Collections]
      summary: List all collections
      description: Retrieve a paginated list of all image collections.
      parameters:
        - in: query
          name: page
          schema:
            type: integer
          description: Page number for pagination
        - in: query
          name: page_size
          schema:
            type: integer
          description: Number of items per page
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CollectionList'

    post:
      operationId: createCollection
      tags: [Collections]
      summary: Create a new collection
      description: Create a new named image collection.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CollectionCreate'
      responses:
        '201':
          description: Collection created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CollectionResponse'

  /api/collections/{collection_id}:
    delete:
      operationId: deleteCollection
      tags: [Collections]
      summary: Delete a collection
      description: Permanently delete a collection and all its images.
      parameters:
        - in: path
          name: collection_id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the collection to delete
      responses:
        '200':
          description: Collection deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'

  /api/collections/{collection_id}/upload_images:
    post:
      operationId: uploadImagesToCollection
      tags: [Collections]
      summary: Upload images to a collection
      description: |
        Upload one or more images to a collection. Only `image/*` content types are accepted.
        Optionally provide a parallel `descriptions[]` array — each entry maps to the image at the same index.
      parameters:
        - in: path
          name: collection_id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the collection to upload images to
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: List of image files to upload. Only image/* content types are allowed.
                descriptions:
                  type: array
                  items:
                    type: string
                  description: Optional list of descriptions for the uploaded images. Each description corresponds to the image at the same index.
              required:
                - file
      responses:
        '201':
          description: Images uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [success]
                    example: success
                  message:
                    type: string
                    example: "Successfully uploaded 2 images"
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Image'

  /api/images/{image_id}:
    delete:
      operationId: deleteImage
      tags: [Images]
      summary: Delete an image
      description: Permanently delete a single image from its collection.
      parameters:
        - in: path
          name: image_id
          required: true
          schema:
            type: string
            format: uuid
          description: The ID of the image to delete
      responses:
        '200':
          description: Image deleted successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'

  /api/files:
    get:
      operationId: listFiles
      tags: [Files]
      summary: List all uploaded files
      description: Retrieve a paginated list of all uploaded files.
      parameters:
        - in: query
          name: page
          schema:
            type: integer
          description: Page number for pagination
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileList'

    post:
      operationId: uploadFiles
      tags: [Files]
      summary: Upload one or more files
      description: |
        Upload one or multiple files via `multipart/form-data`. Files are stored server-side
        in the configured `FILE_UPLOADS_FOLDER`. Returns an array of `FileUpload` objects.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - files
              properties:
                files:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: One or more files to upload
      responses:
        '201':
          description: Files uploaded successfully
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/FileUpload'
        '400':
          description: No files provided
        '500':
          description: >-
            None of the uploaded files could be fully persisted to storage
            (e.g. a storage write failure or truncated upload). The response
            body includes an `errors` array with a `name`/`error` entry per
            failed file.

  /api/files/{id}:
    get:
      operationId: getFile
      tags: [Files]
      summary: Get file details
      description: Fetch metadata for a single file by its UUID.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: File details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileUpload'
        '404':
          description: File not found

    delete:
      operationId: deleteFile
      tags: [Files]
      summary: Delete a file
      description: Permanently delete the file record and remove it from disk.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: File deleted
        '404':
          description: File not found

  /api/files/{id}/download:
    get:
      operationId: downloadFile
      tags: [Files]
      summary: Download or view a file
      description: |
        Serve the raw file bytes. Images, videos, and audio are returned with
        `Content-Disposition: inline` (viewable in browser); all other files use
        `attachment` (triggers download).

        **HTTP Range requests are supported.** Send a `Range: bytes=start-end`
        header to fetch a byte range; the server replies with `206 Partial Content`,
        a `Content-Range: bytes start-end/total` header, and `Accept-Ranges: bytes`.
        This is what enables seeking inside `<video>` / `<audio>` elements.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - in: header
          name: Range
          required: false
          schema:
            type: string
            example: bytes=0-1023
          description: |
            Optional byte range. When present the response is `206 Partial Content`
            with `Content-Range` and `Accept-Ranges` headers.
      responses:
        '200':
          description: Full file content (no Range header sent).
          headers:
            Content-Disposition:
              schema:
                type: string
              description: '`inline` for media, `attachment` for other files.'
            Accept-Ranges:
              schema:
                type: string
                enum: [bytes]
            Content-Length:
              schema:
                type: integer
        '206':
          description: Partial content in response to a `Range` request.
          headers:
            Content-Range:
              schema:
                type: string
                example: bytes 0-1023/9457629
            Accept-Ranges:
              schema:
                type: string
                enum: [bytes]
            Content-Length:
              schema:
                type: integer
        '416':
          description: Range Not Satisfiable (invalid byte range).
        '404':
          description: File not found

  /api/files/{id}/toggle-public:
    post:
      operationId: toggleFilePublic
      tags: [Files]
      summary: Toggle public/private visibility
      description: |
        Toggle a file between public and private:
        - **Private → Public**: returns `is_public: true` and a `public_url`
          of the form `/public/files/{id}-{filename}` that anyone can access
          without authentication.
        - **Public → Private**: returns `is_public: false` and `public_url: null`;
          existing `public_url` links immediately return 404.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Updated visibility state
          content:
            application/json:
              schema:
                type: object
                properties:
                  is_public:
                    type: boolean
                  public_url:
                    type: string
                    nullable: true
                    example: /public/files/550e8400-e29b-41d4-a716-446655440000-report.pdf
                required:
                  - is_public
                  - public_url

  /api/files/{id}/set-expiry:
    post:
      operationId: setFileExpiry
      tags: [Files]
      summary: Set or clear expiry date for a public file
      description: |
        Set an expiry datetime for a public file. After the expiry the `public_url` returns 404.
        Send `expires_at: null` (or omit the field) to remove the expiry and make the link permanent.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                expires_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: ISO 8601 datetime. Send null or omit to clear the expiry.
      responses:
        '200':
          description: Updated file details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileUpload'

  /public/files/{id}-{filename}:
    get:
      operationId: getPublicFile
      tags: [Files]
      summary: Access a public file by its shareable URL
      description: |
        Unauthenticated endpoint. Serves a publicly shared file. The URL is derived
        from the file's UUID and original filename (filename is cosmetic — the server
        only looks up the record by `id`). Images, videos, and audio are served
        `inline` (with HTTP Range support for seeking in media); other files use
        `attachment` (download).

        External requests (outside the internal network) only get served files that
        have `is_public: true` and have not expired. Internal-network requests can
        fetch any file regardless of the public flag.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
          description: The FileUpload UUID.
        - in: path
          name: filename
          required: true
          schema:
            type: string
          description: The original filename (cosmetic only; ignored by the server).
        - in: header
          name: Range
          required: false
          schema:
            type: string
          description: Optional byte range for media seeking.
      responses:
        '200':
          description: Full file content (no Range header).
          headers:
            Content-Disposition:
              schema:
                type: string
            Accept-Ranges:
              schema:
                type: string
                enum: [bytes]
        '206':
          description: Partial content in response to a `Range` request.
          headers:
            Content-Range:
              schema:
                type: string
            Accept-Ranges:
              schema:
                type: string
                enum: [bytes]
        '404':
          description: File not found, is private (from external network), or link has expired.

  /api/images/random/:
    get:
      operationId: getRandomImage
      tags: [Images]
      summary: Get a random image
      description: |
        Returns a randomly selected image from the server's image library, resized to the
        default dimensions. Each request returns a different image.

        **Cache busting:** Add a `?v={timestamp}` query parameter to prevent browsers from
        caching repeated calls (the server ignores this parameter).
      parameters:
        - in: query
          name: fit
          schema:
            type: string
            enum: [clip, crop, fill, scale]
            default: scale
          description: |
            How to fit the image into the requested dimensions.
            - `scale` — scale to fit, maintaining aspect ratio (may add letterbox)
            - `crop` — crop to exact dimensions
            - `clip` — clip to fit, no upscaling
            - `fill` — fill exact dimensions, may distort
        - in: query
          name: v
          schema:
            type: integer
          description: Cache-busting value (e.g. Unix timestamp). Ignored by the server.
          example: 1774152070
      responses:
        '200':
          description: Image file (JPEG or PNG)
          headers:
            Cache-Control:
              schema:
                type: string
              description: Caching directives (e.g. `public, max-age=3600`)
            X-Image-Source:
              schema:
                type: string
              description: Relative path of the source image that was served
          content:
            image/jpeg:
              schema:
                type: string
                format: binary
            image/png:
              schema:
                type: string
                format: binary
        '404':
          description: |
            No images available, folder not configured, or invalid parameters.

  /api/images/random/{width}/{height}/:
    get:
      operationId: getRandomImageSized
      tags: [Images]
      summary: Get a random image at specific dimensions
      description: |
        Returns a randomly selected image resized to `{width}×{height}` pixels.
        Minimum 300 px and maximum 4096 px for each dimension.

        **Cache busting:** Add a `?v={timestamp}` query parameter to prevent browsers from
        caching repeated calls (the server ignores this parameter).

        **Use in Markdown / posts:**
        ```
        ![Random image](http://your-domain/api/images/random/300/450/?fit=crop&v=1234567890)
        ```
      parameters:
        - in: path
          name: width
          required: true
          schema:
            type: integer
            minimum: 300
            maximum: 4096
          description: Target width in pixels
          example: 300
        - in: path
          name: height
          required: true
          schema:
            type: integer
            minimum: 300
            maximum: 4096
          description: Target height in pixels
          example: 450
        - in: query
          name: fit
          schema:
            type: string
            enum: [clip, crop, fill, scale]
            default: scale
          description: |
            How to fit the image into the requested dimensions.
            - `scale` — scale to fit, maintaining aspect ratio (may add letterbox)
            - `crop` — crop to exact dimensions
            - `clip` — clip to fit, no upscaling
            - `fill` — fill exact dimensions, may distort
        - in: query
          name: v
          schema:
            type: integer
          description: Cache-busting value (e.g. Unix timestamp). Ignored by the server.
          example: 1774152070
      responses:
        '200':
          description: Image file (JPEG or PNG) at the requested dimensions
          headers:
            Cache-Control:
              schema:
                type: string
            X-Image-Source:
              schema:
                type: string
              description: Relative path of the source image that was served
          content:
            image/jpeg:
              schema:
                type: string
                format: binary
            image/png:
              schema:
                type: string
                format: binary
        '404':
          description: |
            No images available, folder not configured, invalid dimensions, or invalid fit mode.

  /import/images/{image_url}:
    get:
      operationId: importExternalImage
      tags: [Files]
      summary: Import and cache an external image
      description: |
        Imports an external image by URL and saves it to the local file store.

        The `image_url` path parameter is the image URL **without** the `https://` scheme prefix
        (e.g. `example.com/path/to/image.jpg`). The server always tries `https://` first.

        **Worker mode** — this endpoint is non-blocking:
        - On the **first request** for a given URL the server creates a `FileUpload` record
          (marked `is_public: true`) and kicks off a background thread to download and save the file.
          The response immediately redirects (HTTP 302) to the original `https://` source URL so the
          image is visible right away.
        - On **subsequent requests**, once the background download has completed, the response
          redirects to the locally-saved public file URL (`/public/files/{uuid}-{filename}`) so the
          original host is no longer needed.

        **Idempotent** — the same external URL always resolves to the same `FileUpload` record;
        the file is only downloaded once.

        **Use in Markdown / posts:**
        ```
        ![Alt text](http://your-domain/import/images/example.com/path/to/image.jpg)
        ```
      parameters:
        - in: path
          name: image_url
          required: true
          schema:
            type: string
          description: |
            External image URL without the scheme prefix.
            Example: `graziamagazine.com/wp-content/uploads/2024/12/Elle-Fanning-Pigtails-scaled.jpg`
          example: graziamagazine.com/wp-content/uploads/2024/12/Elle-Fanning-Pigtails-scaled.jpg
      responses:
        '302':
          description: |
            Redirect to either the original source URL (while background download is in progress)
            or the locally-saved public file URL (once download has completed).
          headers:
            Location:
              schema:
                type: string
              description: URL to redirect to (original source or local `/public/files/…`)

components:
  schemas:
    Page:
      type: object
      properties:
        id:
          type: integer
        url:
          type: string
        title:
          type: string
        summary:
          type: string
        content:
          type: string
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PageCreate:
      type: object
      required:
        - url
        - title
      properties:
        url:
          type: string
        title:
          type: string
        summary:
          type: string
        content:
          type: string

    PageList:
      type: object
      properties:
        count:
          type: integer
        next:
          type: string
          nullable: true
        previous:
          type: string
          nullable: true
        results:
          type: array
          items:
            $ref: '#/components/schemas/Page'

    Post:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        content:
          type: string
        tag_details:
          type: array
          items:
            $ref: '#/components/schemas/Tag'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PostCreate:
      type: object
      required:
        - title
        - content
      properties:
        title:
          type: string
        content:
          type: string
        tags:
          type: array
          items:
            type: string
          description: List of tag slugs to associate with the post

    PostList:
      type: object
      properties:
        count:
          type: integer
        next:
          type: string
          nullable: true
        previous:
          type: string
          nullable: true
        results:
          type: array
          items:
            $ref: '#/components/schemas/Post'

    Tag:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        description:
          type: string

    Collection:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        image_count:
          type: integer
        created_at:
          type: string
          format: date-time

    CollectionCreate:
      type: object
      required:
        - name
      properties:
        name:
          type: string
        description:
          type: string

    CollectionList:
      type: object
      properties:
        count:
          type: integer
        next:
          type: string
          nullable: true
        previous:
          type: string
          nullable: true
        results:
          type: array
          items:
            $ref: '#/components/schemas/Collection'

    CollectionResponse:
      type: object
      properties:
        status:
          type: string
          enum: [success]
        message:
          type: string
        data:
          $ref: '#/components/schemas/Collection'

    Image:
      type: object
      properties:
        id:
          type: string
          format: uuid
        collection:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: string
        content_type:
          type: string
        size:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        url:
          type: string

    FileUpload:
      type: object
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
        name:
          type: string
          description: Original filename
        mime_type:
          type: string
          readOnly: true
        size:
          type: integer
          readOnly: true
          description: File size in bytes
        formatted_size:
          type: string
          readOnly: true
          description: Human-readable size (e.g. "1.4 MB")
        is_public:
          type: boolean
        public_token:
          type: string
          nullable: true
          readOnly: true
        public_url:
          type: string
          nullable: true
          readOnly: true
          example: /public/files/<token>/
        expires_at:
          type: string
          format: date-time
          nullable: true
        is_expired:
          type: boolean
          readOnly: true
        download_count:
          type: integer
          readOnly: true
        source_url:
          type: string
          format: uri
          nullable: true
          description: |
            Original external URL this file was imported from via `/import/images/…`.
            `null` for files uploaded directly.
          example: https://example.com/path/to/image.jpg
        created_at:
          type: string
          format: date-time
          readOnly: true
        updated_at:
          type: string
          format: date-time
          readOnly: true

    FileList:
      type: object
      properties:
        count:
          type: integer
        next:
          type: string
          nullable: true
        previous:
          type: string
          nullable: true
        results:
          type: array
          items:
            $ref: '#/components/schemas/FileUpload'

    SuccessResponse:
      type: object
      properties:
        status:
          type: string
          enum: [success]
        message:
          type: string

    TagSummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string

    Link:
      type: object
      properties:
        id:
          type: integer
          readOnly: true
        alias:
          type: string
          description: Unique slug used as the short-link identifier (e.g. /gh → github.com).
        original_url:
          type: string
          description: Target URL. May contain template parameters like `{param,default=value}`.
        description:
          type: string
          nullable: true
        link_type:
          type: string
          enum: [LINK, CUSTOM]
          description: LINK redirects to original_url; CUSTOM renders Markdown content.
        click_count:
          type: integer
          readOnly: true
          description: Number of times the short link has been visited.
        tags:
          type: array
          readOnly: true
          items:
            $ref: '#/components/schemas/TagSummary'
        created_at:
          type: string
          format: date-time
          readOnly: true
        updated_at:
          type: string
          format: date-time
          readOnly: true

    LinkList:
      type: object
      properties:
        count:
          type: integer
        next:
          type: string
          nullable: true
        previous:
          type: string
          nullable: true
        results:
          type: array
          items:
            $ref: '#/components/schemas/Link'

    Bookmark:
      type: object
      description: |
        An archived social-media post. The `data` field holds the complete original
        normalised export object (kept for round-tripping / the offline viewer page);
        the top-level denormalised fields (text, media, card_data, quoted_data,
        flags, counters) are the indexed/fast-path versions used by list & filter.
      properties:
        id: { type: integer, description: Internal DB primary key }
        tweet_id: { type: string, description: Source post id (unique) }
        url: { type: string, format: uri, description: Canonical x.com post URL }
        author_screen_name: { type: string }
        author_name: { type: string }
        author_profile_image_url: { type: string, format: uri }
        text: { type: string, description: Full post text }
        summary: { type: string, description: Optional human/agent-written summary }
        tweet_created_at: { type: string, format: date-time, nullable: true }
        bookmark_created_at: { type: string, format: date-time, nullable: true }
        view_count: { type: integer }
        favorite_count: { type: integer }
        retweet_count: { type: integer }
        reply_count: { type: integer }
        bookmark_count: { type: integer }
        quote_count: { type: integer }
        has_video: { type: boolean }
        has_photo: { type: boolean }
        has_card: { type: boolean }
        is_quote: { type: boolean }
        is_retweet: { type: boolean }
        media:
          type: array
          description: List of media objects (photo or video/animated_gif with poster)
          items: { type: object }
        card_data:
          type: object
          nullable: true
          description: Link preview card (title, description, site, image, url)
        quoted_data:
          type: object
          nullable: true
          description: Nested quoted post (same shape as an export item)
        data:
          type: object
          description: The complete normalised export object from x-bookmarks-exporter
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    BookmarkCreate:
      type: object
      required: [tweet_id]
      properties:
        tweet_id: { type: string }
        url: { type: string, format: uri }
        author_screen_name: { type: string }
        author_name: { type: string }
        author_profile_image_url: { type: string, format: uri }
        text: { type: string }
        summary: { type: string }
        tweet_created_at: { type: string, format: date-time, nullable: true }
        bookmark_created_at: { type: string, format: date-time, nullable: true }
        view_count: { type: integer }
        favorite_count: { type: integer }
        retweet_count: { type: integer }
        reply_count: { type: integer }
        bookmark_count: { type: integer }
        quote_count: { type: integer }
        has_video: { type: boolean }
        has_photo: { type: boolean }
        has_card: { type: boolean }
        is_quote: { type: boolean }
        is_retweet: { type: boolean }
        media:
          type: array
          items: { type: object }
        card_data:
          type: object
          nullable: true
        quoted_data:
          type: object
          nullable: true
        data:
          type: object

    BookmarkExportItem:
      type: object
      description: |
        One element of a bulk-import payload, as produced by x-bookmarks-exporter.
        All nested fields are optional except the source post id; the server maps
        the nested `author`, `media`, `card`, `quoted`, `retweet`, counters and
        `created_at` date string into the flat Bookmark columns and the `data`
        JSON blob.
      properties:
        id: { type: string, description: Source tweet id (also accepted as `tweet_id`) }
        url: { type: string, format: uri }
        author:
          type: object
          properties:
            screen_name: { type: string }
            name: { type: string }
            profile_image_url: { type: string, format: uri }
            verified: { type: boolean }
            description: { type: string }
        text: { type: string }
        summary: { type: string }
        created_at: { type: string, description: x.com-style "Wed Jul 30 12:00:00 +0000 2025" }
        bookmark_created_at: { type: string }
        view_count: { type: integer }
        favorite_count: { type: integer }
        retweet_count: { type: integer }
        reply_count: { type: integer }
        bookmark_count: { type: integer }
        quote_count: { type: integer }
        media:
          type: array
          description: |
            Each media object carries `type` (photo|video|animated_gif), image
            URL variants (`media_url`, `media_thumb`, `media_large`, `media_orig`)
            and, for video/gif, a `video` object with `poster`, `best_mp4`,
            `duration_ms`, and `variants[]`.
          items: { type: object }
        card:
          type: object
          nullable: true
          description: Link preview with title/description/site/image/url
        quoted:
          type: object
          nullable: true
          description: Nested quoted post (recursive BookmarkExportItem shape)
        retweet:
          type: object
          nullable: true
          description: Nested retweeted post

    BookmarkList:
      type: object
      properties:
        count: { type: integer }
        next: { type: string, nullable: true }
        previous: { type: string, nullable: true }
        results:
          type: array
          items:
            $ref: '#/components/schemas/Bookmark'
