PhoenixKit.Modules.Storage.Libraries (phoenix_kit v2.55.1)

Copy Markdown View Source

Storage libraries: partitions of the file store (V202).

Every stored file, media folder and folder link belongs to exactly one library (library_uuid). V202 put everything that existed into one system library, Media, under a fixed uuid (media_uuid/0), and made Media the column default — so a writer that names no library, in core or in any module, keeps landing there, and an install with a single library behaves exactly as it did before libraries existed.

More system libraries can be created by an admin (create_system_library/1). Each keeps its own folders (folder names are unique per library and parent) and gives the files stored in it an object-key prefix of its own (key_prefix).

User libraries (V203)

When the install turns them on (user_libraries_enabled?/0), a user with the "storage" permission uses the libraries they own or are a member of (list_user_libraries/1), and one with "storage.create_library" creates them (create_user_library/2, up to user_library_limit/0). A trashed one can be restored by its owner until it is purged (restore_library/2). A user library is private and has an owner and members (PhoenixKit.Modules.Storage.LibraryMember: manager, contributor, viewer; allows?/2 says who does what). Trashing one frees its name at once and purges it, bytes included, after the trash retention period; deleting a user trashes the libraries they own first (trash_owned_libraries/1).

Dedup is one copy per uploader per library: the same person may keep the same bytes in Media and in a library of their own (Storage.calculate_user_file_checksum/3).

Per-library storage (profiles, variant sets, user-owned buckets) is a later phase of dev_docs/plans/2026-09-22-storage-libraries.md.

Summary

Types

What someone is to a library: its owner, a member's role, or nothing.

A per-library setting.

A library with what it holds (list_system_libraries_with_stats/0).

Functions

Adds the user with email to a user library as role, for its owner or a manager. :no_such_user when nobody has that email, :owner when it is the owner's own.

Whether role may do action in a user library

Whether Media (/admin/media) is where scope browses their user libraries: they hold media and are not an Owner/Admin. Media's switcher then lists their own and shared libraries beside the site's (/admin/media/my/<id>); everyone else (only storage, or an Owner/Admin who also opens other people's libraries, audit-logged) uses /admin/libraries.

The path of the page that lists scope's libraries (see browse_path/2).

The (canonical, unprefixed) path of the page that browses library for scope: Media's for a holder of media, /admin/libraries otherwise.

Whether scope may do action to file. One predicate for every per-file check, so they stop drifting apart

Creates a system library. attrs takes a "name" (or :name). The URL slug comes from the name ("Brand Assets" → "brand-assets", then "brand-assets-2" … while one is taken) and the object-key prefix is generated, unless either is given.

Creates a user library owned by scope's user. attrs takes a "name" and optionally a "storage" (below). The first live library a user has becomes their default. Refused with :not_allowed without may_create_library?/1, and :limit_reached at user_library_limit/0 live libraries.

The user's default library, or nil (they have none yet).

Deletes a library that holds nothing. The default library is never deleted, and a library that still has a file or a folder — trashed ones included — is refused (:not_empty); the database refuses it too.

Drops rows whose library is private.

A library by uuid, or nil (also for anything that is not a uuid).

The live system library with this uuid, or nil — what a URL-supplied library id is checked against before anything is listed or stored in it.

The live system library whose URL slug is slug, or nil. The default library has no slug (it is the bare /admin/media).

list_system_libraries_with_stats/0 for the one live system library with this uuid (what a URL-supplied id is checked against), or nil.

A live user library scope may see, by its uuid or — for one of their own — its slug, with what they are to it. Nil otherwise, whatever the reason (missing, trashed, not theirs), so a URL gives nothing away.

A user library's members with their users, by email.

The live system libraries, the default (Media) first, then by name.

list_system_libraries/0 with what each one holds: files (live, visible files — not trashed, not system-managed tiles or edit backups), bytes (their total size), folders (live folders) and holds (any file or folder at all, trash and system-managed rows included — what delete_library/1 refuses). The live counts and holds are separate queries, whatever the number of libraries.

The user libraries user_uuid owns that are in the trash and not purged yet, newest first.

The live user libraries user_uuid owns or is a member of, with what they are to each: the ones they own first (their default first), then the rest, by name.

Every user library, trashed ones included, with its owner, member count and what it holds — what /admin/storage/libraries lists. Metadata only: nothing here opens a library's files.

Whether scope may create user libraries: may_use_libraries?/1, and the "storage.create_library" permission.

Whether scope may take part in user libraries at all — use the ones they own or are a member of, at /admin/libraries: user libraries are on, and the scope holds the "storage" permission.

Whether scope may put a new library on their own storage: may_create_library?/1, the site allowing it (user_buckets_enabled?/0), the "storage.own_storage" permission, and the "integrations" permission: the bucket's keys are a personal connection, which only a holder of it can make.

Whether uuid is Media's.

The uuid of Media, the default system library every existing file, folder and link was put into by V202, and the column default for new ones. Fixed on every install.

Whether files in a library are private (visibility: "private", every user library): their URLs carry a time-window token and are never answered with a redirect to a public object URL. Media is answered without a query; anything that is not a library is not private.

Which of library_uuids are private, in one query — for a page that builds URLs for many files at once.

Whether file (anything with a library_uuid) is in a private library.

Checks a bucket for scope's user to put a library on (what the wizard's "Test the bucket" asks, and what creating the library asks again): they may use their own storage at all, the fields pass Bucket.owned_changeset/4 (an S3-protocol provider, their own connection, a host the server may reach), and a small object can be written, read back and deleted.

Purges a trashed library: every file in it (trashed and system-managed ones included) through the normal delete path, which deletes the bytes no other file still names; then its folders; then the library row (its members go with it). A live library is refused.

Sets a library's own value of key; nil removes it, so the library follows its default again (the site setting for annotated thumbnails, the rendition set for deep zoom). Only the key changes (a concurrent change to another one is not lost), and a value of the wrong type is refused.

Queues the purge of every user library trashed more than days ago, and of every one whose owner is gone. Run by the daily trash prune.

Removes a member, for the library's owner or a manager, or the member themselves (leaving).

Renames a user library, for its owner or a manager.

Takes a trashed user library out of the trash, for its owner, until it is purged. It gets a URL slug again (the old one may have been taken), and becomes the default when the owner has none. A live library of the owner that has taken its name meanwhile refuses it ({:error, changeset}).

What user_uuid is to library: :owner, a member's role, or nil. A trashed library has no one.

Makes a user library its owner's default, for the owner.

A library's own value of key, or nil when it has none and follows the site-wide default. Takes a library or a library uuid (nil is Media).

The own value of key of each library in library_uuids, in one query — for a page that decides for many files at once. A library with none is missing from the map; nil stands for Media.

Whether the sidebar lists a separate "Libraries" entry for scope: user libraries are on for them, and they do not already have them in Media.

Whether a library is a system library. Media always is; anything else is read. A missing library is not.

Trashes a user library, for its owner. Its members lose it at once; its files are purged, bytes included, after the trash retention period (Storage.trash_retention_days/0). Its name and URL are free again right away. When it was the default, the owner's next library by name becomes the default.

Trashes every live library user_uuid owns and queues their purge — the step Auth.delete_user/2 takes before deleting the user. The database refuses to delete a user whose live library still names them (V203), so this cannot be skipped. Returns how many were trashed.

Changes a member's role, for the library's owner or a manager.

How a user library is named in a URL for user_uuid: its slug when it is theirs, its uuid when it is shared with them (slugs are only unique among one owner's libraries).

Whether the site lets users keep a library on their own bucket (the storage_user_buckets_enabled setting, off by default: the site answers for nothing that lands outside its own storage until it says so).

Whether user libraries are on for this install (the storage_user_libraries_enabled setting, off by default). Existing sites do not start offering a feature they never planned for.

How many live libraries one user may own (storage_user_library_limit, default 10).

Types

role()

@type role() :: :owner | :manager | :contributor | :viewer | nil

What someone is to a library: its owner, a member's role, or nothing.

setting()

@type setting() :: :annotated_thumbnails | :deep_zoom

A per-library setting.

stats()

@type stats() :: %{
  library: PhoenixKit.Modules.Storage.Library.t(),
  files: non_neg_integer(),
  bytes: non_neg_integer(),
  folders: non_neg_integer(),
  holds: boolean()
}

A library with what it holds (list_system_libraries_with_stats/0).

Functions

add_member(scope, library, email, role)

@spec add_member(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Modules.Storage.Library.t(),
  String.t(),
  String.t()
) ::
  {:ok, PhoenixKit.Modules.Storage.LibraryMember.t()}
  | {:error, :not_allowed | :no_such_user | :owner | Ecto.Changeset.t()}

Adds the user with email to a user library as role, for its owner or a manager. :no_such_user when nobody has that email, :owner when it is the owner's own.

allows?(role, action)

@spec allows?(role(), atom()) :: boolean()

Whether role may do action in a user library:

  • :read — see its files: everyone
  • :upload — add files: owner, manager, contributor
  • :edit_any — change or trash anyone's files: owner, manager
  • :members — add, change and remove members: owner, manager
  • :rename — owner, manager
  • :own — trash the library, make it the default: owner only

browse_in_media?(scope)

@spec browse_in_media?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()

Whether Media (/admin/media) is where scope browses their user libraries: they hold media and are not an Owner/Admin. Media's switcher then lists their own and shared libraries beside the site's (/admin/media/my/<id>); everyone else (only storage, or an Owner/Admin who also opens other people's libraries, audit-logged) uses /admin/libraries.

browse_index_path(scope)

@spec browse_index_path(PhoenixKit.Users.Auth.Scope.t() | nil) :: String.t()

The path of the page that lists scope's libraries (see browse_path/2).

browse_path(scope, library)

The (canonical, unprefixed) path of the page that browses library for scope: Media's for a holder of media, /admin/libraries otherwise.

can?(scope, file, action)

@spec can?(PhoenixKit.Users.Auth.Scope.t() | nil, map(), :read | :edit) :: boolean()

Whether scope may do action to file. One predicate for every per-file check, so they stop drifting apart:

  • :read — the file's info and signed URLs: its uploader, an Owner/Admin (Scope.system_role?/1), or — for a file in a user library — that library's owner or any of its members. Deliberately NOT the "media" permission: a single permission must not open every other user's file metadata (issue #687).
  • :edit — change the picture (image editing, annotation burn-in, the unedited original): the uploader, an Owner/Admin, a holder of the "media" permission with "media.view_all" when the file is in a system library, or the owner or a manager of the user library it is in.

Anything else, and a scope without a user, is refused.

create_system_library(attrs, opts \\ [])

@spec create_system_library(map(), keyword()) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()} | {:error, Ecto.Changeset.t()}

Creates a system library. attrs takes a "name" (or :name). The URL slug comes from the name ("Brand Assets" → "brand-assets", then "brand-assets-2" … while one is taken) and the object-key prefix is generated, unless either is given.

create_user_library(scope, attrs, opts \\ [])

@spec create_user_library(PhoenixKit.Users.Auth.Scope.t() | nil, map(), keyword()) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()}
  | {:error,
     :not_allowed
     | :limit_reached
     | :no_site_storage
     | {:storage, String.t() | Ecto.Changeset.t()}
     | Ecto.Changeset.t()}

Creates a user library owned by scope's user. attrs takes a "name" and optionally a "storage" (below). The first live library a user has becomes their default. Refused with :not_allowed without may_create_library?/1, and :limit_reached at user_library_limit/0 live libraries.

Where the library keeps its files

By default on the site's storage (the Default profile). With "storage" a map the user chooses their own instead, and the choice is final: the library's profile can never be changed afterwards (Profiles.set_library_profile/2 refuses it).

%{"name" => "Photos",
  "storage" => %{
    "mode" => "only",               # or "backup"
    "integration_uuid" => "…",      # one of the user's own connections
    "provider" => "s3",             # s3 | b2 | r2 | tigris
    "bucket_name" => "my-photos",
    "region" => "eu-central-1",     # and/or
    "endpoint" => "https://…"}}

"only" keeps everything in their bucket; "backup" keeps the site's storage as it is and copies the originals to theirs (see Profiles.create_user_profile/3). Needs may_use_own_storage?/1 ({:error, :not_allowed} otherwise).

The bucket is probed first, reading, writing and deleting a small object (:probe, a function of the bucket params, Storage.test_connection/1 by default): a library that could not store anything is not created, and the reason is {:error, {:storage, message}}. A bucket, profile or library that cannot be created undoes the rest. Problems with the bucket fields are {:error, {:storage, %Ecto.Changeset{}}}.

default_user_library(user_uuid)

@spec default_user_library(term()) :: PhoenixKit.Modules.Storage.Library.t() | nil

The user's default library, or nil (they have none yet).

delete_library(library, opts \\ [])

@spec delete_library(PhoenixKit.Modules.Storage.Library.t(), keyword()) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()}
  | {:error, :default | :not_empty}

Deletes a library that holds nothing. The default library is never deleted, and a library that still has a file or a folder — trashed ones included — is refused (:not_empty); the database refuses it too.

exclude_private(query)

@spec exclude_private(Ecto.Query.t()) :: Ecto.Query.t()

Drops rows whose library is private.

The file or folder is the query's first binding. Site listings (/admin/media, the media pickers, orphan cleanup) use this so a user library is not mixed into the site's media; pass that library's uuid to read it. A private library's files are not orphans: nothing in the site references them, and treating them as unreferenced would delete them.

get_library(uuid)

@spec get_library(term()) :: PhoenixKit.Modules.Storage.Library.t() | nil

A library by uuid, or nil (also for anything that is not a uuid).

get_system_library(uuid)

@spec get_system_library(term()) :: PhoenixKit.Modules.Storage.Library.t() | nil

The live system library with this uuid, or nil — what a URL-supplied library id is checked against before anything is listed or stored in it.

get_system_library_by_slug(slug)

@spec get_system_library_by_slug(term()) ::
  PhoenixKit.Modules.Storage.Library.t() | nil

The live system library whose URL slug is slug, or nil. The default library has no slug (it is the bare /admin/media).

get_system_library_with_stats(uuid)

@spec get_system_library_with_stats(term()) :: stats() | nil

list_system_libraries_with_stats/0 for the one live system library with this uuid (what a URL-supplied id is checked against), or nil.

get_user_library(scope, id)

@spec get_user_library(PhoenixKit.Users.Auth.Scope.t() | nil, term()) ::
  %{library: PhoenixKit.Modules.Storage.Library.t(), role: role()} | nil

A live user library scope may see, by its uuid or — for one of their own — its slug, with what they are to it. Nil otherwise, whatever the reason (missing, trashed, not theirs), so a URL gives nothing away.

list_members(library)

A user library's members with their users, by email.

list_system_libraries()

@spec list_system_libraries() :: [PhoenixKit.Modules.Storage.Library.t()]

The live system libraries, the default (Media) first, then by name.

list_system_libraries_with_stats()

@spec list_system_libraries_with_stats() :: [stats()]

list_system_libraries/0 with what each one holds: files (live, visible files — not trashed, not system-managed tiles or edit backups), bytes (their total size), folders (live folders) and holds (any file or folder at all, trash and system-managed rows included — what delete_library/1 refuses). The live counts and holds are separate queries, whatever the number of libraries.

list_trashed_user_libraries(user_uuid)

@spec list_trashed_user_libraries(term()) :: [PhoenixKit.Modules.Storage.Library.t()]

The user libraries user_uuid owns that are in the trash and not purged yet, newest first.

list_user_libraries(user_uuid)

@spec list_user_libraries(term()) :: [
  %{library: PhoenixKit.Modules.Storage.Library.t(), role: role()}
]

The live user libraries user_uuid owns or is a member of, with what they are to each: the ones they own first (their default first), then the rest, by name.

list_user_libraries_for_admin()

@spec list_user_libraries_for_admin() :: [map()]

Every user library, trashed ones included, with its owner, member count and what it holds — what /admin/storage/libraries lists. Metadata only: nothing here opens a library's files.

may_create_library?(scope)

@spec may_create_library?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()

Whether scope may create user libraries: may_use_libraries?/1, and the "storage.create_library" permission.

may_use_libraries?(scope)

@spec may_use_libraries?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()

Whether scope may take part in user libraries at all — use the ones they own or are a member of, at /admin/libraries: user libraries are on, and the scope holds the "storage" permission.

may_use_own_storage?(scope)

@spec may_use_own_storage?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()

Whether scope may put a new library on their own storage: may_create_library?/1, the site allowing it (user_buckets_enabled?/0), the "storage.own_storage" permission, and the "integrations" permission: the bucket's keys are a personal connection, which only a holder of it can make.

media?(uuid)

@spec media?(term()) :: boolean()

Whether uuid is Media's.

media_uuid()

@spec media_uuid() :: String.t()

The uuid of Media, the default system library every existing file, folder and link was put into by V202, and the column default for new ones. Fixed on every install.

private?(uuid)

@spec private?(term()) :: boolean()

Whether files in a library are private (visibility: "private", every user library): their URLs carry a time-window token and are never answered with a redirect to a public object URL. Media is answered without a query; anything that is not a library is not private.

private_among(library_uuids)

@spec private_among([term()]) :: [String.t()]

Which of library_uuids are private, in one query — for a page that builds URLs for many files at once.

private_file?(arg1)

@spec private_file?(map()) :: boolean()

Whether file (anything with a library_uuid) is in a private library.

probe_own_storage(scope, params, opts \\ [])

@spec probe_own_storage(PhoenixKit.Users.Auth.Scope.t() | nil, map(), keyword()) ::
  :ok | {:error, :not_allowed | String.t() | Ecto.Changeset.t()}

Checks a bucket for scope's user to put a library on (what the wizard's "Test the bucket" asks, and what creating the library asks again): they may use their own storage at all, the fields pass Bucket.owned_changeset/4 (an S3-protocol provider, their own connection, a host the server may reach), and a small object can be written, read back and deleted.

Returns :ok, {:error, :not_allowed}, {:error, message} from the bucket, or {:error, %Ecto.Changeset{}} for the fields. :probe replaces the network check (a function of the bucket params), for tests.

purge_library(library)

@spec purge_library(PhoenixKit.Modules.Storage.Library.t() | term()) ::
  :ok | {:error, :not_trashed | :not_found | :objects_remain}

Purges a trashed library: every file in it (trashed and system-managed ones included) through the normal delete path, which deletes the bytes no other file still names; then its folders; then the library row (its members go with it). A live library is refused.

A library on a user's own storage (V206) is purged more carefully: each file's objects are deleted BEFORE its rows, and a file whose objects could not be deleted is left as it was, along with the library, its profile and the bucket row (the record of where the objects are and how to reach them). The purge then returns {:error, :objects_remain}, so the job is retried, and nothing is forgotten that could not be cleaned up. If the user's credentials are gone (their account was deleted, taking their connections with it) nothing can be deleted from their bucket, and the purge goes on without waiting for it.

put_setting(library, key, value, opts \\ [])

@spec put_setting(
  PhoenixKit.Modules.Storage.Library.t(),
  setting(),
  term(),
  keyword()
) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()}
  | {:error, :unknown_setting | :invalid_value | :not_found}

Sets a library's own value of key; nil removes it, so the library follows its default again (the site setting for annotated thumbnails, the rendition set for deep zoom). Only the key changes (a concurrent change to another one is not lost), and a value of the wrong type is refused.

queue_expired_purges(days)

@spec queue_expired_purges(non_neg_integer()) :: non_neg_integer()

Queues the purge of every user library trashed more than days ago, and of every one whose owner is gone. Run by the daily trash prune.

remove_member(scope, library, user_uuid)

@spec remove_member(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Modules.Storage.Library.t(),
  term()
) :: :ok | {:error, :not_allowed | :not_found}

Removes a member, for the library's owner or a manager, or the member themselves (leaving).

rename_library(library, name, opts \\ [])

Renames a library.

rename_user_library(scope, library, name)

@spec rename_user_library(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Modules.Storage.Library.t(),
  String.t()
) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()}
  | {:error, :not_allowed | Ecto.Changeset.t()}

Renames a user library, for its owner or a manager.

restore_library(scope, library)

@spec restore_library(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Modules.Storage.Library.t()
) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()}
  | {:error, :not_allowed | :limit_reached | Ecto.Changeset.t()}

Takes a trashed user library out of the trash, for its owner, until it is purged. It gets a URL slug again (the old one may have been taken), and becomes the default when the owner has none. A live library of the owner that has taken its name meanwhile refuses it ({:error, changeset}).

role(library, user_uuid)

What user_uuid is to library: :owner, a member's role, or nil. A trashed library has no one.

set_default_library(scope, library)

@spec set_default_library(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Modules.Storage.Library.t()
) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()} | {:error, :not_allowed}

Makes a user library its owner's default, for the owner.

setting(uuid, key)

@spec setting(PhoenixKit.Modules.Storage.Library.t() | term(), setting()) :: term()

A library's own value of key, or nil when it has none and follows the site-wide default. Takes a library or a library uuid (nil is Media).

setting_among(library_uuids, key)

@spec setting_among([term()], setting()) :: %{required(String.t()) => term()}

The own value of key of each library in library_uuids, in one query — for a page that decides for many files at once. A library with none is missing from the map; nil stands for Media.

show_libraries_entry?(scope)

@spec show_libraries_entry?(PhoenixKit.Users.Auth.Scope.t() | nil) :: boolean()

Whether the sidebar lists a separate "Libraries" entry for scope: user libraries are on for them, and they do not already have them in Media.

system_library?(uuid)

@spec system_library?(term()) :: boolean()

Whether a library is a system library. Media always is; anything else is read. A missing library is not.

trash_library(scope, library)

@spec trash_library(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Modules.Storage.Library.t()
) ::
  {:ok, PhoenixKit.Modules.Storage.Library.t()} | {:error, :not_allowed}

Trashes a user library, for its owner. Its members lose it at once; its files are purged, bytes included, after the trash retention period (Storage.trash_retention_days/0). Its name and URL are free again right away. When it was the default, the owner's next library by name becomes the default.

trash_owned_libraries(user_uuid)

@spec trash_owned_libraries(term()) :: {:ok, non_neg_integer()}

Trashes every live library user_uuid owns and queues their purge — the step Auth.delete_user/2 takes before deleting the user. The database refuses to delete a user whose live library still names them (V203), so this cannot be skipped. Returns how many were trashed.

update_member_role(scope, library, user_uuid, role)

@spec update_member_role(
  PhoenixKit.Users.Auth.Scope.t() | nil,
  PhoenixKit.Modules.Storage.Library.t(),
  term(),
  String.t()
) ::
  {:ok, PhoenixKit.Modules.Storage.LibraryMember.t()}
  | {:error, :not_allowed | :not_found | Ecto.Changeset.t()}

Changes a member's role, for the library's owner or a manager.

url_id(library, user_uuid)

How a user library is named in a URL for user_uuid: its slug when it is theirs, its uuid when it is shared with them (slugs are only unique among one owner's libraries).

user_buckets_enabled?()

@spec user_buckets_enabled?() :: boolean()

Whether the site lets users keep a library on their own bucket (the storage_user_buckets_enabled setting, off by default: the site answers for nothing that lands outside its own storage until it says so).

user_libraries_enabled?()

@spec user_libraries_enabled?() :: boolean()

Whether user libraries are on for this install (the storage_user_libraries_enabled setting, off by default). Existing sites do not start offering a feature they never planned for.

user_library_limit()

@spec user_library_limit() :: non_neg_integer()

How many live libraries one user may own (storage_user_library_limit, default 10).