Packages
phoenix_kit
2.38.0
2.47.0
2.46.0
2.45.0
2.44.0
2.43.1
2.43.0
2.42.1
2.42.0
2.41.6
2.41.4
2.41.3
2.41.2
2.41.1
2.41.0
2.40.1
2.40.0
2.39.0
2.38.1
2.38.0
2.37.5
2.37.4
2.37.3
2.37.2
2.37.1
2.37.0
2.36.1
2.36.0
2.35.0
2.34.0
2.33.0
2.32.1
2.32.0
2.31.1
2.31.0
2.30.0
2.29.1
2.29.0
2.28.2
2.28.1
2.28.0
2.27.2
2.27.1
2.27.0
2.26.1
2.26.0
2.25.0
2.24.0
2.23.3
2.23.2
2.23.1
2.23.0
2.22.24
2.22.23
2.22.22
2.22.21
2.22.20
2.22.19
2.22.18
2.22.17
2.22.16
2.22.15
2.22.14
2.22.13
2.22.12
2.22.11
2.22.10
2.22.9
2.22.8
2.22.7
2.22.6
2.22.5
2.22.4
2.22.3
2.22.2
2.22.1
2.22.0
2.21.5
2.21.4
2.21.3
2.21.2
2.21.1
2.21.0
2.20.0
2.19.0
2.18.1
2.18.0
2.17.0
2.16.0
2.15.1
2.15.0
2.14.2
2.14.1
2.14.0
2.13.19
2.13.18
2.13.17
2.13.16
2.13.15
2.13.13
2.13.12
2.13.11
2.13.10
2.13.9
2.13.8
2.13.7
2.13.6
2.13.5
2.13.4
2.13.3
2.13.2
2.13.1
2.13.0
2.12.1
2.12.0
2.11.0
2.10.0
2.9.0
2.8.1
2.8.0
2.7.0
2.6.0
2.5.0
2.4.0
2.3.0
2.2.0
2.1.0
2.0.1
2.0.0
1.7.236
1.7.235
1.7.234
1.7.233
1.7.232
1.7.231
1.7.230
1.7.229
1.7.228
1.7.227
1.7.226
1.7.225
1.7.224
1.7.223
1.7.222
1.7.221
1.7.220
1.7.219
1.7.218
1.7.217
1.7.216
1.7.215
1.7.214
1.7.213
1.7.212
1.7.211
1.7.210
1.7.209
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/storage/libraries.ex
defmodule PhoenixKit.Modules.Storage.Libraries do
@moduledoc """
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, members and per-library storage are later
phases of `dev_docs/plans/2026-09-22-storage-libraries.md`.
## What a library does NOT change yet
Dedup is still per uploader across the whole install: uploading, into one
library, bytes the same person already stored in another returns the file
that exists — in the library it is in. `Storage.store_file_in_buckets/5`
reports that as `{:ok, file, :duplicate}` like any other duplicate; callers
that care (`MediaBrowser`) compare the libraries.
"""
import Ecto.Query
alias PhoenixKit.Modules.Storage.File, as: StorageFile
alias PhoenixKit.Modules.Storage.{Folder, Library}
alias PhoenixKit.Users.Auth.Scope
@media_uuid "00000000-0000-7000-8000-000000000001"
@doc """
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.
"""
@spec media_uuid() :: String.t()
def media_uuid, do: @media_uuid
@doc "Whether `uuid` is Media's."
@spec media?(term()) :: boolean()
def media?(uuid), do: to_string(uuid) == @media_uuid
@doc "A library by uuid, or nil (also for anything that is not a uuid)."
@spec get_library(term()) :: Library.t() | nil
def get_library(uuid) do
case Ecto.UUID.cast(uuid) do
{:ok, uuid} -> repo().get(Library, uuid)
:error -> nil
end
end
@doc """
The live system libraries, the default (Media) first, then by name.
"""
@spec list_system_libraries() :: [Library.t()]
def list_system_libraries do
from(l in Library,
where: l.kind == "system" and is_nil(l.trashed_at),
order_by: [desc: l.is_default, asc: fragment("lower(?)", l.name)]
)
|> repo().all()
end
@doc """
`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.
"""
@spec list_system_libraries_with_stats() :: [
%{
library: Library.t(),
files: non_neg_integer(),
bytes: non_neg_integer(),
folders: non_neg_integer(),
holds: boolean()
}
]
def list_system_libraries_with_stats do
libraries = list_system_libraries()
uuids = Enum.map(libraries, & &1.uuid)
files =
from(f in StorageFile,
where: f.library_uuid in ^uuids and f.system_managed == false and f.status != "trashed",
group_by: f.library_uuid,
select: {f.library_uuid, {count(f.uuid), coalesce(sum(f.size), 0)}}
)
|> repo().all()
|> Map.new()
folders =
from(f in Folder,
where: f.library_uuid in ^uuids and is_nil(f.trashed_at),
group_by: f.library_uuid,
select: {f.library_uuid, count(f.uuid)}
)
|> repo().all()
|> Map.new()
held = libraries_holding(uuids)
Enum.map(libraries, fn library ->
{count, bytes} = Map.get(files, library.uuid, {0, 0})
%{
library: library,
files: count,
bytes: to_integer(bytes),
folders: Map.get(folders, library.uuid, 0),
holds: library.uuid in held
}
end)
end
# Libraries that still have a row `delete_library/1` will refuse on.
# The live counts above hide trash and system-managed children, which
# would otherwise offer Delete on a library the database will not drop.
defp libraries_holding([]), do: []
defp libraries_holding(uuids) do
file_uuids =
from(f in StorageFile,
where: f.library_uuid in ^uuids,
group_by: f.library_uuid,
select: f.library_uuid
)
|> repo().all()
folder_uuids =
from(f in Folder,
where: f.library_uuid in ^uuids,
group_by: f.library_uuid,
select: f.library_uuid
)
|> repo().all()
Enum.uniq(file_uuids ++ folder_uuids)
end
defp to_integer(%Decimal{} = d), do: Decimal.to_integer(d)
defp to_integer(n) when is_integer(n), do: n
defp to_integer(_), do: 0
@doc """
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.
"""
@spec get_system_library(term()) :: Library.t() | nil
def get_system_library(uuid) do
case get_library(uuid) do
%Library{kind: "system", trashed_at: nil} = library -> library
_ -> nil
end
end
@doc """
The live system library whose URL slug is `slug`, or nil. The default
library has no slug (it is the bare `/admin/media`).
"""
@spec get_system_library_by_slug(term()) :: Library.t() | nil
def get_system_library_by_slug(slug) when is_binary(slug) do
repo().one(
from(l in Library,
where: l.kind == "system" and is_nil(l.trashed_at) and l.slug == ^slug
)
)
end
def get_system_library_by_slug(_slug), do: nil
@doc """
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.
"""
@spec create_system_library(map()) :: {:ok, Library.t()} | {:error, Ecto.Changeset.t()}
def create_system_library(attrs) do
attrs = Map.new(attrs, fn {k, v} -> {to_string(k), v} end)
attrs = Map.put_new_lazy(attrs, "key_prefix", &generate_key_prefix/0)
case attrs do
%{"slug" => _} -> insert_system_library(attrs)
_ -> insert_with_free_slug(attrs, Library.slugify(to_string(attrs["name"])), 1)
end
end
# Tries `base`, `base-2`, `base-3` … until the slug is free. Any other
# error (a taken name, a blank one) is returned as it is.
defp insert_with_free_slug(attrs, base, n) do
slug = if n == 1, do: base, else: "#{base}-#{n}"
case insert_system_library(Map.put(attrs, "slug", slug)) do
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
if Keyword.has_key?(errors, :slug) and not name_error?(changeset) and n < 100,
do: insert_with_free_slug(attrs, base, n + 1),
else: {:error, changeset}
result ->
result
end
end
defp name_error?(%Ecto.Changeset{errors: errors}), do: Keyword.has_key?(errors, :name)
defp insert_system_library(attrs) do
%Library{}
|> Library.create_system_changeset(attrs)
|> repo().insert()
end
@doc "Renames a library."
@spec rename_library(Library.t(), String.t()) ::
{:ok, Library.t()} | {:error, Ecto.Changeset.t()}
def rename_library(%Library{} = library, name) do
library
|> Library.rename_changeset(%{name: name})
|> repo().update()
end
@doc """
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.
"""
@spec delete_library(Library.t()) :: {:ok, Library.t()} | {:error, :default | :not_empty}
def delete_library(%Library{is_default: true}), do: {:error, :default}
def delete_library(%Library{uuid: uuid} = library) do
holds? =
repo().exists?(from(f in StorageFile, where: f.library_uuid == ^uuid)) or
repo().exists?(from(f in Folder, where: f.library_uuid == ^uuid))
if holds? do
{:error, :not_empty}
else
case repo().delete(library) do
{:ok, deleted} -> {:ok, deleted}
{:error, _changeset} -> {:error, :not_empty}
end
end
rescue
Ecto.ConstraintError -> {:error, :not_empty}
end
@doc """
Whether a library is a system library. Media always is; anything else is
read. A missing library is not.
"""
@spec system_library?(term()) :: boolean()
def system_library?(uuid) do
media?(uuid) or match?(%Library{kind: "system"}, get_library(uuid))
end
@doc """
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, or an
Owner/Admin (`Scope.system_role?/1`). 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, or a holder of the
`"media"` permission when the file is in a system library.
Anything else, and a scope without a user, is refused.
"""
@spec can?(Scope.t() | nil, map(), :read | :edit) :: boolean()
def can?(%Scope{} = scope, %{} = file, action) when action in [:read, :edit] do
uploader?(scope, file) or Scope.system_role?(scope) or
(action == :edit and Scope.has_module_access?(scope, "media") and
system_library?(Map.get(file, :library_uuid) || @media_uuid))
end
def can?(_scope, _file, _action), do: false
defp uploader?(scope, file) do
uuid = Scope.user_uuid(scope)
is_binary(uuid) and to_string(Map.get(file, :user_uuid)) == uuid
end
defp generate_key_prefix do
"lib-" <> Base.encode16(:crypto.strong_rand_bytes(6), case: :lower)
end
defp repo, do: PhoenixKit.RepoHelper.repo()
end