# `PhoenixKit.Modules.Storage`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.21.1/lib/modules/storage/storage.ex#L1)

Storage context for managing files, buckets, and dimensions.

Provides a distributed file storage system with support for multiple storage providers
(local filesystem, AWS S3, Backblaze B2, Cloudflare R2) with automatic redundancy
and failover capabilities.

## Features

- Multi-location storage with configurable redundancy (1-5 copies)
- Support for local, S3, B2, and R2 storage providers
- Automatic variant generation for images and videos
- Priority-based storage selection
- Built-in usage tracking and statistics
- PostgreSQL-backed file registry

## Module Status

This module is **always enabled** and cannot be disabled. It provides core
functionality for file management across PhoenixKit.

# `ancestor_of?`

Returns true if `folder_uuid` is an ancestor of `target_uuid`.

# `broadcast_file_processed`

Broadcasts that background processing finished for `file_uuid`.

See `subscribe_to_file_events/0` for the message shape.

# `broadcast_file_thumbnail_updated`

Broadcasts that how `file_uuid`'s thumbnail should render changed (a
rebaked annotated variant, or a new saved rotation).

See `subscribe_to_file_events/0` for the message shape.

# `build_folder_tree`

Builds a folder tree structure from a flat list of folders.

# `calculate_bucket_free_space`

Calculates free space for a bucket.

For local storage, checks actual disk space.
For cloud storage, returns the configured max_size_mb minus usage.

# `calculate_bucket_usage`

Calculates storage usage for a bucket in MB.

Returns total size of all files stored in this bucket by summing up all
file instances that have locations in this bucket.

# `calculate_user_file_checksum`

Calculates user-specific file checksum (salted with user_uuid).

This creates a unique checksum per user+file combination for duplicate detection,
while preserving the original file checksum for popularity queries.

## Parameters
  - user_uuid: The user UUID
  - file_checksum: The SHA256 checksum of the file content

## Returns
  String representing the SHA256 checksum of "user_uuid + file_checksum"

# `change_bucket`

Returns an `%Ecto.Changeset{}` for tracking bucket changes.

# `change_dimension`

Returns an `%Ecto.Changeset{}` for tracking dimension changes.

# `change_file`

Returns an `%Ecto.Changeset{}` for tracking file changes.

# `change_file_instance`

Returns an `%Ecto.Changeset{}` for tracking file instance changes.

# `count_folder_contents`

Counts files in a folder (home files + linked files).

# `count_orphaned_files`

Returns the count of orphaned files.

When scope_folder_id is set, returns 0 because orphaned files (folder_uuid IS NULL)
are always outside any non-nil scope.

# `count_trashed_files`

Returns the count of trashed files, optionally scoped.

# `count_trashed_folders`

Counts trashed folders (with optional scope).

# `create_bucket`

Creates a new bucket.

## Examples

    iex> create_bucket(%{name: "Local Storage", provider: "local"})
    {:ok, %Bucket{}}

    iex> create_bucket(%{name: nil})
    {:error, %Ecto.Changeset{}}

# `create_dimension`

Creates a new dimension.

## Examples

    iex> create_dimension(%{name: "thumbnail", width: 150, height: 150})
    {:ok, %Dimension{}}

    iex> create_dimension(%{name: nil})
    {:error, %Ecto.Changeset{}}

# `create_directory`

Creates a directory if it doesn't exist.

# `create_file`

Creates a new file record.

This only creates the database record. Use `store_file/4` to actually
store the file data in storage buckets.

# `create_file_instance`

Creates a new file instance.

# `create_file_locations_for_instance`

Creates file locations for a file instance across specified buckets.

Returns `{:ok, locations}` on success or `{:error, :file_locations_failed, errors}` if any insertions fail.

## Parameters

  * `file_instance_uuid` - The UUID of the file instance
  * `bucket_uuids` - List of bucket UUIDs to create locations for
  * `file_path` - The storage path for the file

## Examples

    iex> create_file_locations_for_instance(instance_uuid, [bucket_uuid], "path/to/file")
    {:ok, [%FileLocation{}]}

    iex> create_file_locations_for_instance(instance_uuid, [invalid_bucket], "path")
    {:error, :file_locations_failed, [{bucket_uuid, changeset}]}

# `create_folder`

Creates a new folder.

When scope_folder_id is set:
- If attrs.parent_uuid is outside scope, returns `{:error, :out_of_scope}`.
- If attrs.parent_uuid is nil, rewrites to scope_folder_id (new folder at scope root).

# `create_folder_link`

Creates a link (shortcut) of a file in a folder.

# `delete_bucket`

Deletes a bucket.

## Examples

    iex> delete_bucket(bucket)
    {:ok, %Bucket{}}

    iex> delete_bucket(bucket)
    {:error, %Ecto.Changeset{}}

# `delete_dimension`

Deletes a dimension.

## Examples

    iex> delete_dimension(dimension)
    {:ok, %Dimension{}}

    iex> delete_dimension(dimension)
    {:error, %Ecto.Changeset{}}

# `delete_file`

Deletes a file.

This only removes the database record. Use `delete_file_data/1` to
remove the actual file data from storage buckets.

# `delete_file_completely`

Deletes a file completely - physical data from all storage buckets and database record.

## Examples

    iex> delete_file_completely(file)
    {:ok, %File{}}

# `delete_file_data`

Deletes file data from all storage buckets for all variants.

# `delete_file_instance`

Deletes a file instance.

# `delete_folder`

Deletes a folder.

Moves child folders and home files to the deleted folder's parent.
Folder links are cascade-deleted by the database FK.
Returns `{:error, :out_of_scope}` if the folder is outside scope.

# `delete_folder_completely`

Permanently deletes a (presumably trashed) folder and everything
underneath it. Files are removed via `delete_file_completely/1` so
backend storage cleanup runs; folders are deleted from the DB in
bottom-up order so foreign-key constraints stay happy.

# `delete_folder_link`

Removes a folder link.

# `determine_file_type`

Classifies a file into the `file_type` the `File` schema stores:
`"image"`, `"video"`, `"audio"`, `"document"`, `"archive"` or `"other"`.

Every upload path must classify through here. Each surface used to carry its
own copy of this `cond` and the copies drifted — three of them had no
`audio/` clause at all, so an mp3 uploaded through the media browser was
stored as `"document"` while the audio filter, a plain
`file_type == "audio"` query, never saw it.

`filename` is the second line of defence, for the extensions whose mime type
neither the browser nor `MIME.from_path/1` knows.

    iex> determine_file_type("audio/mpeg")
    "audio"
    iex> determine_file_type("application/octet-stream", "song.m4a")
    "audio"

# `display_file_type`

The `file_type` a display surface should trust for a stored file.

Same evidence-over-claim rule the write boundary applies (see
`store_file_in_buckets/7`), but usable on rows written before that rule
existed: the column wins only when the row's own mime type and filename
don't contradict it. A row stored as `"image"` that is demonstrably a
`video/quicktime` renders as a video — a play-button tile instead of a
broken `<img>` pointed at a `.mov`.

Accepts a `File` struct or any map carrying `:file_type` / `:mime_type`
plus a filename under `:original_file_name`, `:filename` or `:file_name`.
System types (`"tile"`) pass through untouched, as does anything the
evidence can't improve on.

This corrects what the user *sees*; the column itself is corrected by the
repair migration, and `file_type`-filtered queries answer from the column.

# `empty_trash`

Permanently deletes all trashed files, optionally scoped.

# `ensure_default_bucket_exists`

Ensures at least one default bucket exists.

If no buckets exist, creates a default local storage bucket.

## Returns

- `{:created, bucket}` - If a new bucket was created
- `:exists` - If buckets already exist

## Examples

    iex> ensure_default_bucket_exists()
    {:created, %Bucket{name: "Local Storage"}}

    iex> ensure_default_bucket_exists()
    :exists

# `file_exists?`

Checks if a file exists in storage.

# `file_orphaned?`

Returns true if the given file UUID is not referenced by any known entity.

# `find_orphaned_files`

Returns a list of orphaned files (files not referenced by any known entity).

## Options

  - `:limit` - Maximum number of results
  - `:offset` - Number of results to skip

# `folder_breadcrumbs`

Returns the ancestor chain from root to the given folder (for breadcrumbs).

When scope_folder_id is set, the chain stops before scope (scope itself not included —
it is the virtual root).

# `folder_subtree_uuids`

Walks the folder tree from `root_uuid` down and returns every descendant
folder uuid, including the root itself.

Used by trash, restore, and permanent-delete to determine the affected
subtree in one pass, and by scoped media listings that should include
files in nested folders.

# `get_absolute_path`

Gets the absolute path for local storage.

# `get_auto_generate_variants`

# `get_bucket`

Gets a single bucket by ID.

Returns `nil` if bucket does not exist. `secret_access_key` is returned
as stored (encrypted, see `PhoenixKit.Integrations.Encryption`) — this
accessor does not decrypt it. Decrypt only where the plaintext is
actually needed (e.g. `Providers.S3.resolve_credentials/1`).

# `get_bucket_by_name`

Gets a bucket by name.

# `get_config`

Gets the current storage configuration.

# `get_default_path`

Gets the default storage path for local file uploads (relative path).

Returns the configured relative path or the default "priv/uploads" if not set.

# `get_dimension`

Gets a single dimension by ID.

# `get_dimension_by_name`

Gets a dimension by name.

# `get_file`

Gets a single file by ID.

# `get_file_by_checksum`

Gets a file by its original content checksum (file_checksum).

This can find files uploaded by any user with the same content.
Useful for popularity queries.

# `get_file_by_user_checksum`

Gets a file by its user-specific checksum.

This checks for duplicates for a specific user.

# `get_file_instance`

Gets a single file instance by ID.

# `get_file_instance_bucket_uuids`

Gets the bucket UUIDs where a file instance is stored.

Returns a list of bucket UUIDs from the file_locations for the given file instance.

# `get_file_instance_by_name`

Gets a file instance by file UUID and variant name.

# `get_files`

Fetches multiple files by UUID in a single query and returns them ordered to
match `uuids`. Missing UUIDs are silently omitted from the result.

# `get_folder`

Gets a single folder by UUID.

# `get_health_report`

Returns a health report comparing file location counts against the redundancy target.

Groups by file (not instance) — a file is "under-replicated" if any of its
instances have fewer active locations than the redundancy target.

Returns a map with:
- `total` — total files
- `healthy` — files where all instances meet the redundancy target
- `under_replicated` — list of files with at least one under-replicated instance
- `health_percentage` — percentage of healthy files

# `get_public_url`

Gets a public URL for a file.

# `get_public_url_by_uuid`

Gets a public URL for a file by file ID.

Convenience function that fetches the file and returns its URL.

## Examples

    iex> get_public_url_by_uuid("018e3c4a-9f6b-7890-abcd-ef1234567890")
    "https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_original.jpg"

    iex> get_public_url_by_uuid("invalid-uuid")
    nil

# `get_public_url_by_uuid`

Gets a public URL for a specific file variant by file ID.

## Examples

    iex> get_public_url_by_uuid("018e3c4a-9f6b-7890-abcd-ef1234567890", "thumbnail")
    "https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_thumbnail.jpg"

# `get_public_url_by_variant`

Gets a public URL for a specific file variant.

## Variants

For images: "original", "thumbnail", "small", "medium", "large"
For videos: "original", "360p", "720p", "1080p", "video_thumbnail"

## Examples

    iex> get_public_url_by_variant(file, "thumbnail")
    "https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_thumbnail.jpg"

    iex> get_public_url_by_variant(file, "medium")
    "https://cdn.example.com/12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_medium.jpg"

# `list_all_folders`

Returns all non-trashed folders as a flat list ordered by name, for
building a tree. Trashed folders are excluded — they live in the
trash bucket alongside trashed files and are read via
`list_trashed_folders/2`.

# `list_buckets`

Returns a list of all storage buckets, ordered by priority.

# `list_dimensions`

Returns a list of all dimensions, ordered by size (width x height).

# `list_dimensions_for_type`

Returns enabled dimensions for a specific file type.

# `list_enabled_buckets`

Gets enabled buckets, ordered by priority.

# `list_file_instances`

Returns a list of file instances for a given file.

# `list_files`

Returns a list of files, optionally filtered by bucket.

## Options

- `:bucket_uuid` - Filter by bucket UUID
- `:limit` - Maximum number of results
- `:offset` - Number of results to skip
- `:order_by` - Ordering (default: `[desc: :inserted_at]`)

# `list_files_in_scope`

Lists files within the given scope with optional folder filter, search, and pagination.

## Options
  - `:folder_uuid` — specific folder within scope; returns `{:error, :out_of_scope}` if outside.
  - `:search` — ilike search on original_file_name; restricted to scope descendants when scope set.
  - `:include_orphaned` — boolean (default false); only meaningful when scope is nil.
    When true, returns only files with folder_uuid IS NULL.
    `include_orphaned: true` is ignored when `scope_folder_id` is non-nil (orphans are always outside any scope).
  - `:page` — page number (default 1).
  - `:per_page` — page size (default 20).

## Returns
  `{files, total_count}` or `{:error, :out_of_scope}`.

When `scope_folder_id == nil` and no `folder_uuid` is specified, ALL files are returned (not
just orphans). To fetch orphans only at real root, pass `include_orphaned: true` AND use a
dedicated orphan-only branch. Task 4 callers must preserve the current `/admin/media` behavior
(list orphans only when `filter_orphaned` is on) via a separate code path.

# `list_folder_tree`

Returns folder tree rooted at scope_folder_id (exclusive of scope itself).
For nil scope, returns the real-root tree.

# `list_folders`

Lists folders within a parent folder (nil = root).

When parent_uuid is nil and scope_folder_id is set, returns children of
scope_folder_id instead of real root.

# `list_image_set_variants`

Returns variant data for building an `<.image_set>` `<picture>` element.

Returns a list of maps with `:variant_name`, `:mime_type`, `:width`, and `:url`
for all completed image instances of the given file.

# `list_image_set_variants_for_files`

Bulk version of `list_image_set_variants/1` for multiple files.

Returns a map of `%{file_uuid => [variant_maps]}`. Uses a single DB query.

# `list_trashed_files`

Returns trashed files ordered by trashed_at descending, with pagination and optional scope.

# `list_trashed_folders`

Returns trashed folders ordered by trashed_at descending, with optional scope.

# `module_enabled?`

Checks if the Storage module is enabled.

This module is always enabled and cannot be disabled.

## Examples

    iex> PhoenixKit.Modules.Storage.module_enabled?()
    true

# `move_file_to_folder`

Moves a file's home folder.

# `prune_trash`

Permanently deletes trashed files older than the given number of days.

# `queue_file_cleanup`

Queues a list of file UUIDs for orphan cleanup via Oban.

Each file is scheduled for deletion after a 60-second delay to protect
against race conditions (another entity may reference the file).
Only files that are still orphaned at job execution time will be deleted.

# `repair_storage_module`

Repairs the storage module by resetting configuration to defaults.

This is a safe, non-destructive operation that:
1. Creates a default local bucket if no buckets exist
2. Resets dimensions to 8 defaults (4 image + 4 video)
3. Resets storage settings to recommended defaults

All existing files are preserved.

## Returns

- `{:ok, repairs}` - List of repairs performed
- `{:error, reason}` - If repair failed

## Examples

    iex> repair_storage_module()
    {:ok, [{:bucket_created, "Local Storage"}, {:dimensions_reset, 8}, {:settings_reset, 3}]}

# `reset_dimensions_to_defaults`

Resets all dimensions to default seeded values.
Deletes all current dimensions and recreates the 8 default ones.

# `reset_settings_to_defaults`

Resets storage settings to their default values.

Resets:
- `storage_redundancy_copies` to "1"
- `storage_auto_generate_variants` to "true"
- `storage_default_bucket_uuid` to nil

## Returns

- `:ok`

# `restore_file`

Restores a trashed file back to active status.

# `restore_folder`

Restores a previously trashed folder and everything underneath it.
Reverses `trash_folder/2` — clears `trashed_at` on all subtree folders
and resets `status: "active", trashed_at: nil` on all files in the
subtree.

# `retrieve_file`

Retrieves a file from storage by file UUID.

Will try buckets in priority order until the file is found.

# `retrieve_file_by_hash`

Retrieves a file by its hash.

# `search_folders`

Lists non-trashed folders whose name matches `search` (case-insensitive
`ilike`), within the same scope the media-browser file search uses:

  * a specific `folder_uuid` → its direct child folders
  * scope root (scope set, no `folder_uuid`) → folders anywhere under the scope
  * real root (no scope, no `folder_uuid`) → all folders

Returns `[]` for a blank search.

# `store_file`

Stores a file in the storage system.

This will:
1. Store the file in multiple buckets based on redundancy settings
2. Generate variants if enabled
3. Create database records for the file and its variants

## Options

- `:filename` - Original filename (required)
- `:content_type` - MIME type (required)
- `:size_bytes` - File size in bytes (required)
- `:user_uuid` - **Required unless the file is system-owned.** The changeset
  rejects a non-system file with no owner, so omitting this returns a
  changeset error rather than an unowned file.
- `:metadata` - Additional metadata map

## ⚠️ How to actually serve the stored file

This is where hosts get stuck and hand-roll their own uploads instead.

**`public_url/2` returns `nil` for the local provider.** That is not a bug or
a missing feature — local files are deliberately not served from a public
directory. Serve them through the signed route instead:

    PhoenixKit.Modules.Storage.URLSigner.signed_url(file.uuid, "original")
    #=> "/phoenix_kit/file/<uuid>/original/<token>"

which the router answers at `/file/:uuid/:variant/:token`. Pass a variant
name (`"original"`, `"medium"`, …) to get that rendition.

### What the signature is and is not

Treat these URLs as *obscured*, **not** as capability URLs, and do not use
them for material where unauthorized access matters:

- the token is the first 4 hex characters of an MD5 — a ~65k space, and
  brute-forceable for a targeted file;
- tokens **never expire**, though the 401 says "Invalid or expired token";
- with no `secret_key_base` the token degrades to a predictable no-secret hash.

`/api/files/:uuid/info` no longer hands these URLs out anonymously — it now
requires authentication and only answers for a file the caller owns (or an
Owner/Admin). The token-strength and expiry points above remain open.

These are recorded as known work in core's `AGENTS.md` under "Signed file-URL
hardening". Current usage (public images) is within what the scheme actually
provides; sensitive files are not.

# `store_file_in_buckets`

Stores a file in buckets with hierarchical path structure.

## Path Structure

Files are stored using the pattern:
`{user_uuid[0..1]}/{hash[0..1]}/{full_hash}/{full_hash}_{variant}.{format}`

## Options

  * `:mime_type` — the mime type the caller actually observed (a browser
    upload's `client_type`, a multipart `content_type`). Pass it whenever
    you have it: it is stored verbatim on the row, where omitting it falls
    back to guessing from the extension — which is how every mp3 in the
    wild ended up as `application/octet-stream`. A blank or octet-stream
    value is treated as absent.

Whatever `file_type` the caller claims is cross-checked against the mime
evidence before the row is written (see `determine_file_type/2`) — a
contradicted generic claim is corrected, so no single call site can poison
the `file_type` column for every surface that filters on it.

## Examples

User ID: "12345678"
File hash: "a1b2c3d4e5f6..."
Original: "12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_original.jpg"
Thumbnail: "12/a1/a1b2c3d4e5f6/a1b2c3d4e5f6_thumbnail.jpg"

# `store_system_file`

Persists a system-managed chunk (e.g. a Tessera DZI tile or manifest)
into every configured bucket *and* into the storage DB as a File row
with a single `"original"` FileInstance.

System-managed Files:

  * have `system_managed: true`
  * carry a `parent_file_uuid` pointing at the source File that this
    chunk was derived from — used for cascade cleanup (the FK in V112
    is `ON DELETE :delete_all`)
  * have no `user_uuid` (the changeset's `validate_system_managed_invariants`
    requires the parent instead)
  * skip the variant pipeline (see `VariantGenerator.should_generate_variants?/1`)
  * are excluded from MediaBrowser listings

## Required opts

  * `:parent_file_uuid` — the source image's UUID
  * `:mime_type` — content type
  * `:size` — content size in bytes (taken from disk if omitted)

## Optional opts

  * `:file_type` — defaults to `"tile"`
  * `:width` / `:height` — dimensions, if known
  * `:metadata` — JSONB payload (e.g. tile coords)

Returns `{:ok, %{file: file, instance: instance}}` or `{:error, reason}`.

# `subscribe_to_file_events`

Subscribes the current process to file lifecycle events.

Messages delivered:

  * `{:phoenix_kit_file_processed, file_uuid}` — background processing
    finished for the file (dimensions extracted, variants generated —
    or processing failed; reload the row to see which). Broadcast by
    `PhoenixKit.Modules.Storage.ProcessFileJob` so open UIs can refresh
    a just-uploaded file without a page reload.

  * `{:phoenix_kit_file_thumbnail_updated, file_uuid}` — how the file's
    thumbnail should render changed: its baked annotated variant was
    regenerated/removed (`AnnotationThumbnailJob`), or its saved
    rotation moved (`MediaCanvasViewer`, which thumbnails apply as a CSS
    transform). Lighter than `file_processed`: consumers should refresh
    thumbnails only, not remount open viewers — the user is usually
    still working in one, and it is what emitted the change.

# `sync_under_replicated`

Syncs under-replicated files to meet the redundancy target.

For each under-replicated file, retrieves it from an existing bucket
and replicates it to the missing buckets. Returns a summary of results.

# `sync_under_replicated_with_progress`

Syncs under-replicated files with progress reporting via callback.

The callback receives a map with `:done`, `:total`, `:synced`, `:failed`,
and `:status` (`:in_progress` or `:complete`) after each file is processed.

# `test_connection`

Tests connectivity for a bucket configuration.

Builds a temporary Bucket struct from the given params and delegates
to the appropriate provider's `test_connection/1` callback.

Returns `:ok` or `{:error, reason}`.

# `trash_file`

Moves a file to trash (soft-delete). Sets status to 'trashed' and records timestamp.

# `trash_folder`

Soft-deletes a folder and everything underneath it (descendant folders +
files in the subtree). All affected rows get `trashed_at = now`; files
also get `status = "trashed"` to match the existing file-trash convention.
Restore via `restore_folder/2`, permanent delete via
`delete_folder_completely/2`.

Scope-guarded: returns `{:error, :out_of_scope}` if the folder is outside
the provided scope.

# `trash_retention_days`

Returns the configured trash retention period in days (default 30).

# `update_bucket`

Updates a bucket.

## Examples

    iex> update_bucket(bucket, %{name: "New Name"})
    {:ok, %Bucket{}}

    iex> update_bucket(bucket, %{name: nil})
    {:error, %Ecto.Changeset{}}

# `update_default_path`

Updates the default storage path.

# `update_dimension`

Updates a dimension.

## Examples

    iex> update_dimension(dimension, %{name: "New Name"})
    {:ok, %Dimension{}}

    iex> update_dimension(dimension, %{name: nil})
    {:error, %Ecto.Changeset{}}

# `update_file`

Updates a file.

# `update_file_instance`

Updates a file instance.

# `update_folder`

Updates a folder (rename, color change, move).

Returns `{:error, :cycle}` if the move would create a circular reference.
Returns `{:error, :out_of_scope}` if the folder or new parent is outside scope.

**`parent_uuid` semantics under scope:** omit `:parent_uuid` from `attrs`
for rename/recolor (no move is attempted). Pass an explicit value to
move — including `nil`, which means "move to the system's true root."
Under a non-nil scope, an explicit `parent_uuid: nil` fails with
`:out_of_scope` because the system root is outside the scope subtree.

# `update_instance_status`

Updates a file instance's processing status.

# `update_instance_with_file_info`

Updates a file instance with file information after processing.

# `validate_and_normalize_path`

Validates and normalizes a storage path.

Returns `{:ok, relative_path}` if valid, or error tuple if invalid.

# `within_scope?`

Returns true if folder_uuid is within the given scope.

- When scope_folder_id is nil, always returns true (no scope restriction).
- When folder_uuid equals scope_folder_id, returns true (scope is the virtual root).
- When scope_folder_id is an ancestor of folder_uuid, returns true (folder is a descendant).
- Returns false otherwise, including when folder_uuid is nil and scope is set
  (real root is outside any non-nil scope).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
