# `PhoenixKit.Modules.Storage`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.40.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

## Folder conventions for modules

Modules that keep one folder per record (catalogue items, warehouse
documents, CRM records, machines, …) build on
`PhoenixKit.Modules.Storage.ResourceFolders`, which holds the convention:
the `:attachments_parent_folder` / `:attachments_folder_name` host hooks
(`fun(kind, actor_uuid, subject)`, with the `fun/2` fallback), the lookup
order (stored pointer → host name under the parent → deterministic
`<module>-<kind>-<uuid>` name under the parent, at the root, anywhere),
race-safe find-or-create, and attaching, listing and detaching files.
Moving or renaming existing folders stays with the host (adoption is a
host concern), and nothing creates folders for people's own use.

Hosts typically group containers (`Warehouse/Supplier orders`, `CRM/Contacts`)
and may re-parent a container that was created elsewhere; `update_folder/3`
with `parent_uuid` moves a folder (with cycle check), and the
`(name, parent_uuid)` unique index means the same name can exist under
different parents.

## Protecting host-owned files

Orphan detection (`find_orphaned_files/1`, `count_orphaned_files/1`,
`file_orphaned?/1`, and the `mix phoenix_kit.cleanup_orphaned_files` /
`DeleteOrphanedFileJob` pipeline built on them) only knows about the
references core itself ships with. A host whose own tables point at
files — an order, a project, any record with an image or attachment
column — is one cleanup run away from losing them unless it registers
itself through one of two hooks:

- `config :phoenix_kit, :protected_file_uuids, [...]` — a fixed list, a
  zero-arity function, or an `{module, function, args}` MFA returning the
  uuids that must never be treated as orphans. Simple, but the host has
  to enumerate every referenced uuid on every query.
- `config :phoenix_kit, :file_reference_sources, [...]` — a list of
  `{module, function}` / `{module, function, args}` entries, each
  returning a list of `Ecto.Query.dynamic/2` expressions over the file
  binding `f`. Every expression is ANDed onto the orphan query's
  `where` verbatim, so **each one must itself be the negative test** —
  a `NOT EXISTS (...)` fragment, the same shape core's own catalogue
  and shop checks use (see the example below). Core does not wrap or
  negate anything: a positively-phrased `EXISTS (...)` expression
  inverts the meaning and marks exactly the referenced files as the
  orphans.
  A plain `{table, column}` tuple is shorthand for a native column
  reference, and `{table, :jsonb_key, key}` for a `data->>'key'` pointer.
  A source that cannot be built — its table does not exist, its function
  raises, the entry is malformed — **fails closed**: a `Logger.error/1`
  names it and no file is treated as orphaned until it is fixed. Skipping
  it would drop that source's guard and hand cleanup every file only the
  host references.

Example:

    config :phoenix_kit, :file_reference_sources, [
      {MyApp.Media, :file_reference_sources}
    ]

    def file_reference_sources do
      [
        dynamic(
          [f],
          fragment(
            "NOT EXISTS (SELECT 1 FROM my_app_orders o WHERE o.data->>'featured_image_uuid' = ?::text)",
            f.uuid
          )
        )
      ]
    end

## 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 `target_uuid` or one of its ancestors
(and `target_uuid` exists). One recursive query, whatever the depth — a
walk that gave up after 50 levels let a deeper move make a cycle and
put a deep folder outside its own scope.

# `attach_file_to_folder`

Puts an existing file into `folder_uuid` the way every attach surface
should: a file with no home is adopted (its `folder_uuid` is set); a
file already homed there is left alone; a file homed ELSEWHERE gets a
`FolderLink` into `folder_uuid` (idempotent) rather than being moved
out from under whatever holds it. This is the rule the catalogue's
attachments and the media selector already followed; the media
browser's own upload path used to MOVE a content-duplicate's home
instead, silently emptying the folder that owned it.

# `authorized_url`

```elixir
@spec authorized_url(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  map(),
  String.t(),
  keyword()
) ::
  String.t() | nil
```

The URL of `file`'s `variant` for `scope`, or nil when `scope` may not
see the file (`Libraries.can?(scope, file, :read)`). A file in a private
library gets a time-window URL (`URLSigner.signed_url/3`'s `:private`);
any other the permanent one. Options go to `URLSigner.signed_url/3`
(`:version`, `:locale`).

This is how a module shows a file from a user library: the other URL
helpers here (`get_public_url*`, `list_image_set_variants*`) mint the
permanent token, which the file route refuses for a private file.

# `broadcast_file_deleted`

Broadcasts that `file_uuid` was permanently deleted.

See `subscribe_to_file_events/0` for the message shape.

# `broadcast_file_processed`

Broadcasts that background processing finished for `file_uuid`.

See `subscribe_to_file_events/0` for the message shape.

# `broadcast_file_restored`

Broadcasts that `file_uuid` was taken out of trash.

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.

# `broadcast_file_trashed`

Broadcasts that `file_uuid` was moved to trash.

See `subscribe_to_file_events/0` for the message shape.

# `broadcast_files_deleted`

Broadcasts that the files in `file_uuids` were permanently deleted by one
folder operation. Nothing is sent for an empty list.

# `broadcast_files_restored`

Broadcasts that the files in `file_uuids` were taken out of trash by one
folder operation. Nothing is sent for an empty list.

# `broadcast_files_trashed`

Broadcasts that the files in `file_uuids` were moved to trash by one folder
operation. Nothing is sent for an empty list.

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

  - library_uuid: The library the file is in (optional; nil or Media's
    uuid is Media)

## Returns
  String representing the SHA256 checksum of "user_uuid + file_checksum"
  for a file in Media, and of the uploader, the library and the checksum
  for a file in any other library.

This is the dedup key, and the unique index on it is what holds one copy
per uploader: a file in Media keeps the key every file has had since
before libraries existed, and a file in another library folds its library
in, so the same person may keep the same bytes in Media and in a library
of their own — one copy in each, never two in one.

# `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_details`

Returns a changeset for a file's title, alt text and description in one
language — the form data of the media editors. See
`PhoenixKit.Modules.Storage.FileDetails`.

## Options

  - `:lang` - the language the text is in (default: the primary language)
  - `:primary` - the site's primary language (default: the current one)

# `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 (and `:library_uuid`).

# `count_trashed_folders`

Counts trashed folders (with optional scope and `:library_uuid`).

# `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 the stored objects of a file's instances from every bucket —
except an object another file's instance row still references (a
cross-user deduplicated copy, an edited image's backup), which stays.
The rows themselves are left alone; see `delete_file_completely/1`.

# `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.

# `download_name`

```elixir
@spec download_name(String.t() | nil, String.t() | nil, String.t() | nil) ::
  String.t()
```

What a downloaded copy of `file` should be called, for `variant`.

Every variant of a picture used to answer with the uploader's own
filename, so a browser saving three of them ended up with `photo.jpg`,
`photo (1).jpg`, `photo (2).jpg` — three files whose only difference was
a number the desktop assigned, and nothing to say which was which.

The name says which copy it is: `photo-large.jpg`, `photo-original.jpg`,
and for the copies with the annotations drawn in,
`photo-large-annotated.jpg`. `ext` comes from the stored instance rather
than from the URL — a signed URL ends in a token, and a burn is a JPEG
even where the picture it was drawn on is a PNG.

# `empty_trash`

Permanently deletes all trashed files, optionally scoped — to a folder's
subtree, and with `library_uuid:` to one storage library.

# `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_link`

The `FolderLink` holding `file_uuid` in `folder_uuid`, or nil.

# `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.

`nil` for a system-managed file (an edited image's hidden unedited
original is never handed out). While an image edit is rendering or has
failed, the signed route is returned even for a public bucket: it answers
a placeholder, where the bucket would serve the bytes the edit replaces.

A file in a private library has no public object URL. This returns the
permanent app token, which the file route refuses; a viewer who may see
the file uses `authorized_url/4`.

# `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` - Only files with a copy in this bucket (an active location
  of any of their instances)
- `: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).
  - `:library_uuid` — only files in this storage library. Omitted means
    every library that is not private (`Libraries.exclude_private/1`).

## 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.

`opts[:library_uuid]` narrows the real root to one storage library (a
folder's children are always in its library). Omitted leaves out private
libraries.

# `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_between_folders`

Moves a file as SEEN in `from_folder_uuid` to `target_folder_uuid`.

A file that is merely linked into `from_folder_uuid` (its home is
another folder) has its LINK re-pointed at the target — the file
itself stays where it lives, and every other folder holding it keeps
it. A file whose home is `from_folder_uuid` (or that is viewed
outside any folder) moves as `move_file_to_folder/3` always has.
Folder listings show linked files (2026-09-12), so a move from a
folder must act on what that folder holds, not on the file's home.

# `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 moved to the trash, never deleted, after a 60-second delay
that protects against race conditions (another entity may reference the
file), and only if it is still orphaned then. From the trash it can be
restored until the daily prune deletes it after `trash_retention_days`.

# `remove_file_from_folder`

Removes a file from `folder_uuid`'s view — what "trash" means when
done FROM a folder listing, now that listings show linked files:

  * linked into `folder_uuid` (home elsewhere) → the link is deleted;
    the file and every other folder holding it are untouched
    (`{:ok, :unlinked, file}`);
  * home is `folder_uuid` and another folder links to it → the file is
    re-homed to that folder (consuming the link) rather than trashed
    out from under it (`{:ok, :rehomed, file}`);
  * home is `folder_uuid` and nothing else holds it, or the file is
    viewed outside any folder (`nil`) → soft-trashed
    (`{:ok, :trashed, file}`).

Trashing the record directly from a folder that only LINKED it would
destroy it for its owner — the same rule the catalogue's own attachment
removal has always applied.

The decision is made from the file's row read fresh under a lock, not
from `file`: a listing's struct can be stale (the file re-homed since),
and a second removal deciding from the old home would trash a file
another folder now holds. The lock is also what
`ResourceFolders.point_at/6` holds while it checks a folder still has a
file.

# `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_file_into`

```elixir
@spec restore_file_into(PhoenixKit.Modules.Storage.File.t(), String.t() | nil) ::
  {:ok, PhoenixKit.Modules.Storage.File.t()} | {:error, :not_trashed}
```

Restores a trashed file into `folder_uuid`, or into no folder (`nil`) —
for bytes someone trashed and is now uploading again: they are wanted
where they are being uploaded, not back in the folder they were removed
from. `{:error, :not_trashed}` when the row is not trashed any more:
someone else restored it, and it keeps the home they gave it.

# `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.

# `retrieve_original`

Like `retrieve_file/1`, and also returns the `"original"` instance the
bytes were read from: `{:ok, temp_path, file, instance}`.

An image edit can replace a file's original at any moment, and the file
row and the instance are two reads. Something derived from the bytes (a
variant, the dimensions) belongs to the file only while
`instance.file_name` is still its original — check that when recording
the result (`original_key?/2`).

# `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.
  * `:library_uuid` — the storage library the new file goes into
    (`PhoenixKit.Modules.Storage.Libraries`); omitted means Media. A
    library with a `key_prefix` keys the file's objects under it
    (`{key_prefix}/{hash[0..1]}/{full_hash}/…`) instead of the uploader's
    prefix. A duplicate the same uploader already has is returned as it
    is, in whatever library it is in — compare `library_uuid` if that
    matters to you.

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.

  * `{:phoenix_kit_file_trashed, file_uuid}` — the file was moved to
    trash (`trash_file/1`, or a `trash_folder/2` that swept it up).
    Consumers showing the active (non-trash) view should stop showing
    it; an open viewer on it should close.

  * `{:phoenix_kit_file_restored, file_uuid}` — the file was taken out
    of trash (`restore_file/1`, or a `restore_folder/2`). Consumers
    showing the trash view should stop showing it.

  * `{:phoenix_kit_file_deleted, file_uuid}` — the file was permanently
    deleted (`delete_file_completely/1`). Consumers should drop it
    wherever it is rendered and close an open viewer on it.

  * `{:phoenix_kit_files_trashed | :phoenix_kit_files_restored |
    :phoenix_kit_files_deleted, [file_uuid]}` — the same three, for every
    file a FOLDER operation swept up at once (`trash_folder/2`,
    `restore_folder/2`, `delete_folder_completely/2`), sent once per
    operation rather than once per file: a folder of thousands of files
    would otherwise be thousands of messages, and as many re-renders, for
    every subscriber. A consumer that reacts to one file must match both
    shapes (`uuid in uuids` for the bulk one).

# `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}`.

# `translated_alt`

A file's alt text in `locale`, ready for an `alt` attribute — `""` when it has none.

# `translated_alt_by_uuid`

The alt text of the file `file_uuid` names, in `locale` — `""` when the
file has none, does not exist or is system-managed.

# `translated_alts`

The alt text of many files at once, for a page that renders a list of
images: `%{file_uuid => alt}` in `locale`, one query. A uuid with no row —
or naming a system-managed file, which is never listed — is absent; a file
with no alt text maps to `""`.

Takes the `:primary` option of `translated_alt/3`.

# `translated_description`

A file's description in `locale` — else the primary language's, else any — or `nil`.

# `translated_title`

A file's title in `locale` — else the primary language's, else any — or `nil`.

# `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_details`

Saves a file's title, alt text and description in one language.

`attrs` holds `"title"`, `"alt"` and `"description"`; an absent key keeps
its current value. The row is re-read and held for the write, and only
that language's text changes — so another language saved at the same
time, or a rotation or tag saved since `file` was loaded, survives.

Takes the options of `change_file_details/3`, and `:metadata` — other
`metadata` keys to set in the same held write (the detail page's tags), so
a form that saves both does not need a second, unlocked one. The three
text keys cannot be set through it.

Returns `{:ok, file}`,
`{:error, changeset}` (a `FileDetails` changeset, for the form) or
`{:error, :not_found}`.

# `update_file_instance`

Updates a file instance.

# `update_file_metadata`

Changes a file's `metadata` map from the row as it is NOW.

`fun` receives the current map and returns the new one, or `:unchanged`.
The row is re-read and held (`FOR UPDATE`) around it, so two writers of
different keys — a rotation, the tags, the title copy — cannot overwrite
each other with the map they each loaded earlier. Every read-modify-write
of `metadata` belongs here; `update_file/2` with a `metadata:` built from a
struct in hand is the lost update.

Returns `{:ok, file}`, `{:error, changeset}` or `{:error, :not_found}`.

# `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*
