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. |
    | **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, call `POST /api/files/{id}/toggle-public` to get a public token,
    then share `/public/files/{token}/` — no authentication required for the recipient.

    ## 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: 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/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

  /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 are returned with `Content-Disposition: inline`
        (viewable in browser); all other files use `attachment` (triggers download).
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: File content
        '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**: generates a `public_token` and returns a `public_url`
          (`/public/files/{token}/`) that anyone can access without authentication.
        - **Public → Private**: immediately revokes the token; existing `public_url` links stop working.
      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_token:
                    type: string
                    nullable: true
                  public_url:
                    type: string
                    nullable: true
                    example: /public/files/<token>/

  /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/{token}/:
    get:
      operationId: getPublicFile
      tags: [Files]
      summary: Access a public file by token
      description: |
        Unauthenticated endpoint. Serves a publicly shared file by its opaque token.
        Returns 404 if the file has been made private or if the expiry date has passed.
      parameters:
        - in: path
          name: token
          required: true
          schema:
            type: string
      responses:
        '200':
          description: File content (inline for images, attachment for others)
        '404':
          description: File not found, is private, 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'
