Packages
phoenix_kit
1.7.98
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
221 Versions
Jump to
Current section
221 Versions
Compare versions
24
files changed
+3489
additions
-2328
deletions
| @@ -1,3 +1,18 @@ | |
| 1 | + ## 1.7.98 - 2026-04-16 |
| 2 | + |
| 3 | + ### Added |
| 4 | + - V99 migration: `trashed_at` column on `phoenix_kit_files` with partial index for soft-delete (PR #497) |
| 5 | + - Media trash bucket: soft-delete files with restore/empty/permanent-delete actions and sidebar count badge |
| 6 | + - `PhoenixKit.Modules.Storage.Workers.PruneTrashJob` — daily Oban cron (3 AM) that permanently deletes files older than `trash_retention_days` (default 30) |
| 7 | + - Drag-drop upload: drop device files directly onto the folder content area (`FolderDropUpload` JS hook) |
| 8 | + - URL-param hydration on first mount so reloads don't flash the root view |
| 9 | + |
| 10 | + ### Fixed |
| 11 | + - Scope guard on `restore_selected` in MediaBrowser — a scoped embed could previously restore files outside its scope via a crafted `toggle_select` payload |
| 12 | + - Trash view, permanent-delete, and `empty_trash` now respect `scope_folder_id` via recursive CTE |
| 13 | + - `list_files/1` excludes trashed files |
| 14 | + - Breadcrumb and search bar moved inside card body so padding matches grid/list content |
| 15 | + |
| 1 16 | ## 1.7.97 - 2026-04-15 |
| 2 17 | |
| 3 18 | ### Added |
| @@ -1,6 +1,6 @@ | |
| 1 1 | {<<"links">>,[{<<"GitHub">>,<<"https://github.com/BeamLabEU/phoenix_kit">>}]}. |
| 2 2 | {<<"name">>,<<"phoenix_kit">>}. |
| 3 | - {<<"version">>,<<"1.7.97">>}. |
| 3 | + {<<"version">>,<<"1.7.98">>}. |
| 4 4 | {<<"description">>, |
| 5 5 | <<65,32,102,111,117,110,100,97,116,105,111,110,32,102,111,114,32,98,117,105, |
| 6 6 | 108,100,105,110,103,32,69,108,105,120,105,114,32,80,104,111,101,110,105, |
| @@ -83,6 +83,7 @@ | |
| 83 83 | <<"lib/modules/storage/workers/process_file_job.ex">>, |
| 84 84 | <<"lib/modules/storage/workers/delete_orphaned_file_job.ex">>, |
| 85 85 | <<"lib/modules/storage/workers/test_job.ex">>, |
| 86 | + <<"lib/modules/storage/workers/prune_trash_job.ex">>, |
| 86 87 | <<"lib/modules/storage/workers/sync_files_job.ex">>, |
| 87 88 | <<"lib/modules/storage/services">>, |
| 88 89 | <<"lib/modules/storage/services/pdf_processor.ex">>, |
| @@ -171,6 +172,7 @@ | |
| 171 172 | <<"lib/phoenix_kit_web/components/invitation_banner.ex">>, |
| 172 173 | <<"lib/phoenix_kit_web/components/admin_nav.ex">>, |
| 173 174 | <<"lib/phoenix_kit_web/components/oauth_buttons.ex">>, |
| 175 | + <<"lib/phoenix_kit_web/components/media_browser.ex">>, |
| 174 176 | <<"lib/phoenix_kit_web/components/multilang_form.ex">>, |
| 175 177 | <<"lib/phoenix_kit_web/components/layouts.ex">>, |
| 176 178 | <<"lib/phoenix_kit_web/components/core">>, |
| @@ -234,6 +236,7 @@ | |
| 234 236 | <<"lib/phoenix_kit_web/components/dashboard/context_selector.ex">>, |
| 235 237 | <<"lib/phoenix_kit_web/components/user_dashboard_nav.ex">>, |
| 236 238 | <<"lib/phoenix_kit_web/components/layout_wrapper.ex">>, |
| 239 | + <<"lib/phoenix_kit_web/components/media_browser.html.heex">>, |
| 237 240 | <<"lib/phoenix_kit_web/components/language_switcher.ex">>, |
| 238 241 | <<"lib/phoenix_kit_web/components/layouts">>, |
| 239 242 | <<"lib/phoenix_kit_web/components/layouts/admin.html.heex">>, |
| @@ -482,6 +485,7 @@ | |
| 482 485 | <<"lib/phoenix_kit/migrations/postgres/v98.ex">>, |
| 483 486 | <<"lib/phoenix_kit/migrations/postgres/v30.ex">>, |
| 484 487 | <<"lib/phoenix_kit/migrations/postgres/v31.ex">>, |
| 488 | + <<"lib/phoenix_kit/migrations/postgres/v99.ex">>, |
| 485 489 | <<"lib/phoenix_kit/migrations/postgres/v41.ex">>, |
| 486 490 | <<"lib/phoenix_kit/migrations/postgres/v56.ex">>, |
| 487 491 | <<"lib/phoenix_kit/migrations/postgres/v09.ex">>, |
| @@ -1,6 +1,6 @@ | |
| 1 1 | # Languages Module |
| 2 2 | |
| 3 | - The PhoenixKit Languages module provides multi-language support with a two-tier locale system (base codes for URLs, full dialects for translations). It provides a unified language configuration used by both the public-facing language switcher and the admin panel. |
| 3 | + The PhoenixKit Languages module provides multi-language support with a two-tier locale system (base codes for URLs, full dialects for translations). It provides a unified language configuration used across the whole app: the public-facing language switcher, the pre-login language dropdown on the sign-in page, and the Language section inside the admin panel's user (avatar) menu. The admin header no longer has a separate globe switcher — admins change locale from the user avatar menu. |
| 4 4 | |
| 5 5 | ## Quick Links |
| @@ -17,6 +17,7 @@ defmodule PhoenixKit.Modules.Storage.File do | |
| 17 17 | - `processing` - File is being processed (variants being generated) |
| 18 18 | - `active` - File is ready and available |
| 19 19 | - `failed` - Processing failed |
| 20 | + - `trashed` - File is in trash, pending restoration or permanent deletion |
| 20 21 | |
| 21 22 | ## Fields |
| 22 23 | |
| @@ -106,6 +107,7 @@ defmodule PhoenixKit.Modules.Storage.File do | |
| 106 107 | height: integer() | nil, |
| 107 108 | duration: integer() | nil, |
| 108 109 | status: String.t(), |
| 110 | + trashed_at: DateTime.t() | nil, |
| 109 111 | metadata: map() | nil, |
| 110 112 | user_uuid: UUIDv7.t(), |
| 111 113 | folder_uuid: UUIDv7.t() | nil, |
| @@ -130,6 +132,7 @@ defmodule PhoenixKit.Modules.Storage.File do | |
| 130 132 | field :height, :integer |
| 131 133 | field :duration, :integer |
| 132 134 | field :status, :string, default: "processing" |
| 135 | + field :trashed_at, :utc_datetime |
| 133 136 | field :metadata, :map |
| 134 137 | |
| 135 138 | belongs_to :user, PhoenixKit.Users.Auth.User, |
| @@ -187,6 +190,7 @@ defmodule PhoenixKit.Modules.Storage.File do | |
| 187 190 | :height, |
| 188 191 | :duration, |
| 189 192 | :status, |
| 193 | + :trashed_at, |
| 190 194 | :metadata, |
| 191 195 | :user_uuid, |
| 192 196 | :folder_uuid |
| @@ -203,7 +207,7 @@ defmodule PhoenixKit.Modules.Storage.File do | |
| 203 207 | :user_uuid |
| 204 208 | ]) |
| 205 209 | |> validate_inclusion(:file_type, ["image", "video", "document", "archive"]) |
| 206 | - |> validate_inclusion(:status, ["processing", "active", "failed"]) |
| 210 | + |> validate_inclusion(:status, ["processing", "active", "failed", "trashed"]) |
| 207 211 | |> validate_number(:size, greater_than: 0) |
| 208 212 | |> validate_number(:width, greater_than: 0) |
| 209 213 | |> validate_number(:height, greater_than: 0) |
| @@ -884,15 +884,40 @@ defmodule PhoenixKit.Modules.Storage do | |
| 884 884 | end) |
| 885 885 | end |
| 886 886 | |
| 887 | - @doc "Lists folders within a parent folder (nil = root)." |
| 888 | - def list_folders(parent_uuid \\ nil) |
| 887 | + @doc """ |
| 888 | + Returns folder tree rooted at scope_folder_id (exclusive of scope itself). |
| 889 | + For nil scope, returns the real-root tree. |
| 890 | + """ |
| 891 | + def list_folder_tree(scope_folder_id \\ nil) do |
| 892 | + all_folders = list_all_folders() |
| 893 | + by_parent = Enum.group_by(all_folders, & &1.parent_uuid) |
| 889 894 | |
| 890 | - def list_folders(nil) do |
| 895 | + if scope_folder_id do |
| 896 | + build_tree_nodes(by_parent, scope_folder_id) |
| 897 | + else |
| 898 | + build_tree_nodes(by_parent, nil) |
| 899 | + end |
| 900 | + end |
| 901 | + |
| 902 | + @doc """ |
| 903 | + Lists folders within a parent folder (nil = root). |
| 904 | + |
| 905 | + When parent_uuid is nil and scope_folder_id is set, returns children of |
| 906 | + scope_folder_id instead of real root. |
| 907 | + """ |
| 908 | + def list_folders(parent_uuid \\ nil, scope_folder_id \\ nil) |
| 909 | + |
| 910 | + def list_folders(nil, nil) do |
| 891 911 | from(f in Folder, where: is_nil(f.parent_uuid), order_by: [asc: f.name]) |
| 892 912 | |> repo().all() |
| 893 913 | end |
| 894 914 | |
| 895 | - def list_folders(parent_uuid) do |
| 915 | + def list_folders(nil, scope_folder_id) do |
| 916 | + from(f in Folder, where: f.parent_uuid == ^scope_folder_id, order_by: [asc: f.name]) |
| 917 | + |> repo().all() |
| 918 | + end |
| 919 | + |
| 920 | + def list_folders(parent_uuid, _scope_folder_id) do |
| 896 921 | from(f in Folder, where: f.parent_uuid == ^parent_uuid, order_by: [asc: f.name]) |
| 897 922 | |> repo().all() |
| 898 923 | end |
| @@ -901,15 +926,46 @@ defmodule PhoenixKit.Modules.Storage do | |
| 901 926 | def get_folder(nil), do: nil |
| 902 927 | def get_folder(uuid), do: repo().get(Folder, uuid) |
| 903 928 | |
| 904 | - @doc "Creates a new folder." |
| 905 | - def create_folder(attrs) do |
| 929 | + @doc """ |
| 930 | + Creates a new folder. |
| 931 | + |
| 932 | + When scope_folder_id is set: |
| 933 | + - If attrs.parent_uuid is outside scope, returns `{:error, :out_of_scope}`. |
| 934 | + - If attrs.parent_uuid is nil, rewrites to scope_folder_id (new folder at scope root). |
| 935 | + """ |
| 936 | + def create_folder(attrs, scope_folder_id \\ nil) |
| 937 | + |
| 938 | + def create_folder(attrs, nil) do |
| 906 939 | %Folder{} |
| 907 940 | |> Folder.changeset(attrs) |
| 908 941 | |> repo().insert() |
| 909 942 | end |
| 910 943 | |
| 911 | - @doc "Updates a folder (rename, move). Returns `{:error, :cycle}` if the move would create a circular reference." |
| 912 | - def update_folder(%Folder{} = folder, attrs) do |
| 944 | + def create_folder(attrs, scope_folder_id) do |
| 945 | + parent_uuid = attrs[:parent_uuid] || attrs["parent_uuid"] |
| 946 | + |
| 947 | + cond do |
| 948 | + is_nil(parent_uuid) -> |
| 949 | + attrs = Map.put(attrs, :parent_uuid, scope_folder_id) |
| 950 | + %Folder{} |> Folder.changeset(attrs) |> repo().insert() |
| 951 | + |
| 952 | + within_scope?(parent_uuid, scope_folder_id) -> |
| 953 | + %Folder{} |> Folder.changeset(attrs) |> repo().insert() |
| 954 | + |
| 955 | + true -> |
| 956 | + {:error, :out_of_scope} |
| 957 | + end |
| 958 | + end |
| 959 | + |
| 960 | + @doc """ |
| 961 | + Updates a folder (rename, color change, move). |
| 962 | + |
| 963 | + Returns `{:error, :cycle}` if the move would create a circular reference. |
| 964 | + Returns `{:error, :out_of_scope}` if the folder or new parent is outside scope. |
| 965 | + """ |
| 966 | + def update_folder(folder, attrs, scope_folder_id \\ nil) |
| 967 | + |
| 968 | + def update_folder(%Folder{} = folder, attrs, nil) do |
| 913 969 | new_parent = attrs[:parent_uuid] || attrs["parent_uuid"] |
| 914 970 | |
| 915 971 | if new_parent && new_parent != folder.parent_uuid && ancestor_of?(folder.uuid, new_parent) do |
| @@ -921,13 +977,49 @@ defmodule PhoenixKit.Modules.Storage do | |
| 921 977 | end |
| 922 978 | end |
| 923 979 | |
| 980 | + def update_folder(%Folder{} = folder, attrs, scope_folder_id) do |
| 981 | + if within_scope?(folder.uuid, scope_folder_id) do |
| 982 | + new_parent = attrs[:parent_uuid] || attrs["parent_uuid"] |
| 983 | + |
| 984 | + cond do |
| 985 | + new_parent && not within_scope?(new_parent, scope_folder_id) -> |
| 986 | + {:error, :out_of_scope} |
| 987 | + |
| 988 | + new_parent && new_parent != folder.parent_uuid && ancestor_of?(folder.uuid, new_parent) -> |
| 989 | + {:error, :cycle} |
| 990 | + |
| 991 | + true -> |
| 992 | + folder |
| 993 | + |> Folder.changeset(attrs) |
| 994 | + |> repo().update() |
| 995 | + end |
| 996 | + else |
| 997 | + {:error, :out_of_scope} |
| 998 | + end |
| 999 | + end |
| 1000 | + |
| 924 1001 | @doc """ |
| 925 1002 | Deletes a folder. |
| 926 1003 | |
| 927 1004 | Moves child folders and home files to the deleted folder's parent. |
| 928 1005 | Folder links are cascade-deleted by the database FK. |
| 1006 | + Returns `{:error, :out_of_scope}` if the folder is outside scope. |
| 929 1007 | """ |
| 930 | - def delete_folder(%Folder{} = folder) do |
| 1008 | + def delete_folder(folder, scope_folder_id \\ nil) |
| 1009 | + |
| 1010 | + def delete_folder(%Folder{} = folder, scope_folder_id) when not is_nil(scope_folder_id) do |
| 1011 | + if within_scope?(folder.uuid, scope_folder_id) do |
| 1012 | + do_delete_folder(folder) |
| 1013 | + else |
| 1014 | + {:error, :out_of_scope} |
| 1015 | + end |
| 1016 | + end |
| 1017 | + |
| 1018 | + def delete_folder(%Folder{} = folder, nil) do |
| 1019 | + do_delete_folder(folder) |
| 1020 | + end |
| 1021 | + |
| 1022 | + defp do_delete_folder(%Folder{} = folder) do |
| 931 1023 | repo().transaction(fn -> |
| 932 1024 | # Move child folders to parent |
| 933 1025 | from(f in Folder, where: f.parent_uuid == ^folder.uuid) |
| @@ -945,16 +1037,35 @@ defmodule PhoenixKit.Modules.Storage do | |
| 945 1037 | end) |
| 946 1038 | end |
| 947 1039 | |
| 948 | - @doc "Returns the ancestor chain from root to the given folder (for breadcrumbs)." |
| 949 | - def folder_breadcrumbs(folder_uuid), do: folder_breadcrumbs(folder_uuid, 50) |
| 1040 | + @doc """ |
| 1041 | + Returns the ancestor chain from root to the given folder (for breadcrumbs). |
| 950 1042 | |
| 951 | - defp folder_breadcrumbs(nil, _limit), do: [] |
| 952 | - defp folder_breadcrumbs(_uuid, 0), do: [] |
| 1043 | + When scope_folder_id is set, the chain stops before scope (scope itself not included — |
| 1044 | + it is the virtual root). |
| 1045 | + """ |
| 1046 | + def folder_breadcrumbs(folder_uuid, scope_folder_id \\ nil) |
| 953 1047 | |
| 954 | - defp folder_breadcrumbs(folder_uuid, limit) do |
| 1048 | + def folder_breadcrumbs(folder_uuid, nil) do |
| 1049 | + do_folder_breadcrumbs(folder_uuid, 50) |
| 1050 | + end |
| 1051 | + |
| 1052 | + def folder_breadcrumbs(folder_uuid, scope_folder_id) do |
| 1053 | + folder_uuid |
| 1054 | + |> do_folder_breadcrumbs(50) |
| 1055 | + |> Enum.drop_while(fn f -> f.uuid != scope_folder_id end) |
| 1056 | + |> case do |
| 1057 | + [] -> [] |
| 1058 | + [_ | rest] -> rest |
| 1059 | + end |
| 1060 | + end |
| 1061 | + |
| 1062 | + defp do_folder_breadcrumbs(nil, _limit), do: [] |
| 1063 | + defp do_folder_breadcrumbs(_uuid, 0), do: [] |
| 1064 | + |
| 1065 | + defp do_folder_breadcrumbs(folder_uuid, limit) do |
| 955 1066 | case get_folder(folder_uuid) do |
| 956 1067 | nil -> [] |
| 957 | - folder -> folder_breadcrumbs(folder.parent_uuid, limit - 1) ++ [folder] |
| 1068 | + folder -> do_folder_breadcrumbs(folder.parent_uuid, limit - 1) ++ [folder] |
| 958 1069 | end |
| 959 1070 | end |
| 960 1071 | |
| @@ -976,6 +1087,19 @@ defmodule PhoenixKit.Modules.Storage do | |
| 976 1087 | end |
| 977 1088 | end |
| 978 1089 | |
| 1090 | + @doc """ |
| 1091 | + Returns true if folder_uuid is within the given scope. |
| 1092 | + |
| 1093 | + - When scope_folder_id is nil, always returns true (no scope restriction). |
| 1094 | + - When folder_uuid equals scope_folder_id, returns true (scope is the virtual root). |
| 1095 | + - When scope_folder_id is an ancestor of folder_uuid, returns true (folder is a descendant). |
| 1096 | + - Returns false otherwise, including when folder_uuid is nil and scope is set |
| 1097 | + (real root is outside any non-nil scope). |
| 1098 | + """ |
| 1099 | + def within_scope?(_folder_uuid, nil), do: true |
| 1100 | + def within_scope?(folder_uuid, scope_folder_id) when folder_uuid == scope_folder_id, do: true |
| 1101 | + def within_scope?(folder_uuid, scope_folder_id), do: ancestor_of?(scope_folder_id, folder_uuid) |
| 1102 | + |
| 979 1103 | @doc "Counts files in a folder (home files + linked files)." |
| 980 1104 | def count_folder_contents(nil) do |
| 981 1105 | home = |
| @@ -1000,26 +1124,182 @@ defmodule PhoenixKit.Modules.Storage do | |
| 1000 1124 | (home || 0) + (links || 0) |
| 1001 1125 | end |
| 1002 1126 | |
| 1127 | + @doc """ |
| 1128 | + Lists files within the given scope with optional folder filter, search, and pagination. |
| 1129 | + |
| 1130 | + ## Options |
| 1131 | + - `:folder_uuid` — specific folder within scope; returns `{:error, :out_of_scope}` if outside. |
| 1132 | + - `:search` — ilike search on original_file_name; restricted to scope descendants when scope set. |
| 1133 | + - `:include_orphaned` — boolean (default false); only meaningful when scope is nil. |
| 1134 | + When true, returns only files with folder_uuid IS NULL. |
| 1135 | + `include_orphaned: true` is ignored when `scope_folder_id` is non-nil (orphans are always outside any scope). |
| 1136 | + - `:page` — page number (default 1). |
| 1137 | + - `:per_page` — page size (default 20). |
| 1138 | + |
| 1139 | + ## Returns |
| 1140 | + `{files, total_count}` or `{:error, :out_of_scope}`. |
| 1141 | + |
| 1142 | + When `scope_folder_id == nil` and no `folder_uuid` is specified, ALL files are returned (not |
| 1143 | + just orphans). To fetch orphans only at real root, pass `include_orphaned: true` AND use a |
| 1144 | + dedicated orphan-only branch. Task 4 callers must preserve the current `/admin/media` behavior |
| 1145 | + (list orphans only when `filter_orphaned` is on) via a separate code path. |
| 1146 | + """ |
| 1147 | + def list_files_in_scope(scope_folder_id, opts \\ []) do |
| 1148 | + folder_uuid = opts[:folder_uuid] |
| 1149 | + search = opts[:search] |
| 1150 | + include_orphaned = Keyword.get(opts, :include_orphaned, false) |
| 1151 | + page = Keyword.get(opts, :page, 1) |
| 1152 | + per_page = Keyword.get(opts, :per_page, 20) |
| 1153 | + |
| 1154 | + if not is_nil(folder_uuid) and not is_nil(scope_folder_id) and |
| 1155 | + not within_scope?(folder_uuid, scope_folder_id) do |
| 1156 | + {:error, :out_of_scope} |
| 1157 | + else |
| 1158 | + query = |
| 1159 | + build_scope_file_query(scope_folder_id, folder_uuid, search, include_orphaned) |
| 1160 | + |> where([f], f.status != "trashed") |
| 1161 | + |
| 1162 | + total = repo().aggregate(query, :count, :uuid) |
| 1163 | + |
| 1164 | + files = |
| 1165 | + query |
| 1166 | + |> order_by([f], desc: f.inserted_at) |
| 1167 | + |> offset(^((page - 1) * per_page)) |
| 1168 | + |> limit(^per_page) |
| 1169 | + |> repo().all() |
| 1170 | + |
| 1171 | + {files, total} |
| 1172 | + end |
| 1173 | + end |
| 1174 | + |
| 1175 | + defp build_scope_file_query(nil, nil, nil, false) do |
| 1176 | + from(f in PhoenixKit.Modules.Storage.File) |
| 1177 | + end |
| 1178 | + |
| 1179 | + defp build_scope_file_query(nil, nil, nil, true) do |
| 1180 | + from(f in PhoenixKit.Modules.Storage.File, where: is_nil(f.folder_uuid)) |
| 1181 | + end |
| 1182 | + |
| 1183 | + defp build_scope_file_query(nil, folder_uuid, search, _orphaned) when not is_nil(folder_uuid) do |
| 1184 | + from(f in PhoenixKit.Modules.Storage.File, where: f.folder_uuid == ^folder_uuid) |
| 1185 | + |> apply_file_search(search) |
| 1186 | + end |
| 1187 | + |
| 1188 | + defp build_scope_file_query(nil, nil, search, _orphaned) when not is_nil(search) do |
| 1189 | + from(f in PhoenixKit.Modules.Storage.File) |
| 1190 | + |> apply_file_search(search) |
| 1191 | + end |
| 1192 | + |
| 1193 | + defp build_scope_file_query(scope_folder_id, folder_uuid, search, _orphaned) do |
| 1194 | + if folder_uuid do |
| 1195 | + # Specific folder already validated within scope — no CTE needed |
| 1196 | + from(f in PhoenixKit.Modules.Storage.File, where: f.folder_uuid == ^folder_uuid) |
| 1197 | + else |
| 1198 | + # Use recursive CTE to find all descendant folders of scope (including scope itself) |
| 1199 | + cte_base = |
| 1200 | + from(f in Folder, |
| 1201 | + where: f.uuid == ^scope_folder_id, |
| 1202 | + select: %{uuid: f.uuid} |
| 1203 | + ) |
| 1204 | + |
| 1205 | + cte_recursive = |
| 1206 | + from(f in Folder, |
| 1207 | + join: d in "scope_descendants", |
| 1208 | + on: f.parent_uuid == d.uuid, |
| 1209 | + select: %{uuid: f.uuid} |
| 1210 | + ) |
| 1211 | + |
| 1212 | + cte = union_all(cte_base, ^cte_recursive) |
| 1213 | + |
| 1214 | + from(f in PhoenixKit.Modules.Storage.File, |
| 1215 | + join: d in "scope_descendants", |
| 1216 | + on: f.folder_uuid == d.uuid, |
| 1217 | + select: f |
| 1218 | + ) |
| 1219 | + |> recursive_ctes(true) |
| 1220 | + |> with_cte("scope_descendants", as: ^cte) |
| 1221 | + end |
| 1222 | + |> apply_file_search(search) |
| 1223 | + end |
| 1224 | + |
| 1225 | + defp apply_file_search(query, nil), do: query |
| 1226 | + defp apply_file_search(query, ""), do: query |
| 1227 | + |
| 1228 | + defp apply_file_search(query, search) do |
| 1229 | + term = "%#{search}%" |
| 1230 | + |
| 1231 | + where( |
| 1232 | + query, |
| 1233 | + [f], |
| 1234 | + ilike(f.original_file_name, ^term) or |
| 1235 | + fragment("CAST(? AS TEXT) ILIKE ?", f.uuid, ^term) |
| 1236 | + ) |
| 1237 | + end |
| 1238 | + |
| 1003 1239 | @doc "Moves a file's home folder." |
| 1004 | - def move_file_to_folder(file_uuid, folder_uuid) do |
| 1240 | + def move_file_to_folder(file_uuid, target_folder_uuid, scope_folder_id \\ nil) |
| 1241 | + |
| 1242 | + def move_file_to_folder(file_uuid, target_folder_uuid, nil) do |
| 1005 1243 | file = repo().get(PhoenixKit.Modules.Storage.File, file_uuid) |
| 1006 1244 | |
| 1007 1245 | if file do |
| 1008 1246 | file |
| 1009 | - |> Ecto.Changeset.change(%{folder_uuid: folder_uuid}) |
| 1247 | + |> Ecto.Changeset.change(%{folder_uuid: target_folder_uuid}) |
| 1010 1248 | |> repo().update() |
| 1011 1249 | else |
| 1012 1250 | {:error, :not_found} |
| 1013 1251 | end |
| 1014 1252 | end |
| 1015 1253 | |
| 1254 | + def move_file_to_folder(file_uuid, target_folder_uuid, scope_folder_id) do |
| 1255 | + file = repo().get(PhoenixKit.Modules.Storage.File, file_uuid) |
| 1256 | + |
| 1257 | + cond do |
| 1258 | + is_nil(file) -> |
| 1259 | + {:error, :not_found} |
| 1260 | + |
| 1261 | + not within_scope?(file.folder_uuid, scope_folder_id) -> |
| 1262 | + {:error, :out_of_scope} |
| 1263 | + |
| 1264 | + not within_scope?(target_folder_uuid, scope_folder_id) -> |
| 1265 | + {:error, :out_of_scope} |
| 1266 | + |
| 1267 | + true -> |
| 1268 | + file |
| 1269 | + |> Ecto.Changeset.change(%{folder_uuid: target_folder_uuid}) |
| 1270 | + |> repo().update() |
| 1271 | + end |
| 1272 | + end |
| 1273 | + |
| 1016 1274 | @doc "Creates a link (shortcut) of a file in a folder." |
| 1017 | - def create_folder_link(folder_uuid, file_uuid) do |
| 1275 | + def create_folder_link(folder_uuid, file_uuid, scope_folder_id \\ nil) |
| 1276 | + |
| 1277 | + def create_folder_link(folder_uuid, file_uuid, nil) do |
| 1018 1278 | %FolderLink{} |
| 1019 1279 | |> FolderLink.changeset(%{folder_uuid: folder_uuid, file_uuid: file_uuid}) |
| 1020 1280 | |> repo().insert() |
| 1021 1281 | end |
| 1022 1282 | |
| 1283 | + def create_folder_link(folder_uuid, file_uuid, scope_folder_id) do |
| 1284 | + file = repo().get(PhoenixKit.Modules.Storage.File, file_uuid) |
| 1285 | + |
| 1286 | + cond do |
| 1287 | + not within_scope?(folder_uuid, scope_folder_id) -> |
| 1288 | + {:error, :out_of_scope} |
| 1289 | + |
| 1290 | + is_nil(file) -> |
| 1291 | + {:error, :not_found} |
| 1292 | + |
| 1293 | + not within_scope?(file.folder_uuid, scope_folder_id) -> |
| 1294 | + {:error, :out_of_scope} |
| 1295 | + |
| 1296 | + true -> |
| 1297 | + %FolderLink{} |
| 1298 | + |> FolderLink.changeset(%{folder_uuid: folder_uuid, file_uuid: file_uuid}) |
| 1299 | + |> repo().insert() |
| 1300 | + end |
| 1301 | + end |
| 1302 | + |
| 1023 1303 | @doc "Removes a folder link." |
| 1024 1304 | def delete_folder_link(link_uuid) do |
| 1025 1305 | case repo().get(FolderLink, link_uuid) do |
| @@ -1043,6 +1323,7 @@ defmodule PhoenixKit.Modules.Storage do | |
| 1043 1323 | """ |
| 1044 1324 | def list_files(opts \\ []) do |
| 1045 1325 | PhoenixKit.Modules.Storage.File |
| 1326 | + |> where([f], f.status != "trashed") |
| 1046 1327 | |> maybe_filter_by_bucket(opts[:bucket_uuid]) |
| 1047 1328 | |> maybe_order_by(opts[:order_by]) |
| 1048 1329 | |> maybe_limit(opts[:limit]) |
| @@ -1153,8 +1434,15 @@ defmodule PhoenixKit.Modules.Storage do | |
| 1153 1434 | |
| 1154 1435 | @doc """ |
| 1155 1436 | Returns the count of orphaned files. |
| 1437 | + |
| 1438 | + When scope_folder_id is set, returns 0 because orphaned files (folder_uuid IS NULL) |
| 1439 | + are always outside any non-nil scope. |
| 1156 1440 | """ |
| 1157 | - def count_orphaned_files do |
| 1441 | + def count_orphaned_files(scope_folder_id \\ nil) |
| 1442 | + |
| 1443 | + def count_orphaned_files(scope_folder_id) when not is_nil(scope_folder_id), do: 0 |
| 1444 | + |
| 1445 | + def count_orphaned_files(nil) do |
| 1158 1446 | orphaned_files_query() |
| 1159 1447 | |> repo().aggregate(:count, :uuid) |
| 1160 1448 | end |
| @@ -1192,14 +1480,15 @@ defmodule PhoenixKit.Modules.Storage do | |
| 1192 1480 | existing = existing_optional_tables() |
| 1193 1481 | protected_uuids = protected_file_uuids() |
| 1194 1482 | |
| 1195 | - # Core check — phoenix_kit_users always exists |
| 1483 | + # Core check — phoenix_kit_users always exists; exclude trashed files |
| 1196 1484 | base = |
| 1197 1485 | from(f in PhoenixKit.Modules.Storage.File, |
| 1198 1486 | where: |
| 1199 | - fragment( |
| 1200 | - "NOT EXISTS (SELECT 1 FROM phoenix_kit_users u WHERE u.custom_fields->>'avatar_file_uuid' = ?::text)", |
| 1201 | - f.uuid |
| 1202 | - ) |
| 1487 | + f.status != "trashed" and |
| 1488 | + fragment( |
| 1489 | + "NOT EXISTS (SELECT 1 FROM phoenix_kit_users u WHERE u.custom_fields->>'avatar_file_uuid' = ?::text)", |
| 1490 | + f.uuid |
| 1491 | + ) |
| 1203 1492 | ) |
| 1204 1493 | |
| 1205 1494 | # Exclude UUIDs that the parent app has explicitly marked as protected |
| @@ -1670,6 +1959,117 @@ defmodule PhoenixKit.Modules.Storage do | |
| 1670 1959 | end |
| 1671 1960 | end |
| 1672 1961 | |
| 1962 | + # ===== TRASH ===== |
| 1963 | + |
| 1964 | + @doc "Moves a file to trash (soft-delete). Sets status to 'trashed' and records timestamp." |
| 1965 | + def trash_file(%PhoenixKit.Modules.Storage.File{} = file) do |
| 1966 | + file |
| 1967 | + |> Ecto.Changeset.change(%{ |
| 1968 | + status: "trashed", |
| 1969 | + trashed_at: DateTime.utc_now() |> DateTime.truncate(:second) |
| 1970 | + }) |
| 1971 | + |> repo().update() |
| 1972 | + end |
| 1973 | + |
| 1974 | + def trash_file(file_uuid) when is_binary(file_uuid) do |
| 1975 | + case get_file(file_uuid) do |
| 1976 | + nil -> {:error, :not_found} |
| 1977 | + file -> trash_file(file) |
| 1978 | + end |
| 1979 | + end |
| 1980 | + |
| 1981 | + @doc "Restores a trashed file back to active status." |
| 1982 | + def restore_file(%PhoenixKit.Modules.Storage.File{} = file) do |
| 1983 | + file |
| 1984 | + |> Ecto.Changeset.change(%{status: "active", trashed_at: nil}) |
| 1985 | + |> repo().update() |
| 1986 | + end |
| 1987 | + |
| 1988 | + def restore_file(file_uuid) when is_binary(file_uuid) do |
| 1989 | + case get_file(file_uuid) do |
| 1990 | + nil -> {:error, :not_found} |
| 1991 | + file -> restore_file(file) |
| 1992 | + end |
| 1993 | + end |
| 1994 | + |
| 1995 | + @doc "Returns trashed files ordered by trashed_at descending, with pagination and optional scope." |
| 1996 | + def list_trashed_files(scope \\ nil, opts \\ []) do |
| 1997 | + query = |
| 1998 | + build_trashed_query(scope) |
| 1999 | + |> order_by([f], desc: f.trashed_at) |
| 2000 | + |
| 2001 | + query = if opts[:limit], do: limit(query, ^opts[:limit]), else: query |
| 2002 | + query = if opts[:offset], do: offset(query, ^opts[:offset]), else: query |
| 2003 | + repo().all(query) |
| 2004 | + end |
| 2005 | + |
| 2006 | + @doc "Returns the count of trashed files, optionally scoped." |
| 2007 | + def count_trashed_files(scope \\ nil) do |
| 2008 | + build_trashed_query(scope) |
| 2009 | + |> repo().aggregate(:count, :uuid) |
| 2010 | + end |
| 2011 | + |
| 2012 | + defp build_trashed_query(nil) do |
| 2013 | + from(f in PhoenixKit.Modules.Storage.File, where: f.status == "trashed") |
| 2014 | + end |
| 2015 | + |
| 2016 | + defp build_trashed_query(scope_folder_id) do |
| 2017 | + cte_base = |
| 2018 | + from(f in Folder, |
| 2019 | + where: f.uuid == ^scope_folder_id, |
| 2020 | + select: %{uuid: f.uuid} |
| 2021 | + ) |
| 2022 | + |
| 2023 | + cte_recursive = |
| 2024 | + from(f in Folder, |
| 2025 | + join: d in "scope_descendants", |
| 2026 | + on: f.parent_uuid == d.uuid, |
| 2027 | + select: %{uuid: f.uuid} |
| 2028 | + ) |
| 2029 | + |
| 2030 | + cte = union_all(cte_base, ^cte_recursive) |
| 2031 | + |
| 2032 | + from(f in PhoenixKit.Modules.Storage.File, |
| 2033 | + join: d in "scope_descendants", |
| 2034 | + on: f.folder_uuid == d.uuid, |
| 2035 | + where: f.status == "trashed" |
| 2036 | + ) |
| 2037 | + |> with_cte("scope_descendants", as: ^cte) |
| 2038 | + end |
| 2039 | + |
| 2040 | + @doc "Permanently deletes all trashed files, optionally scoped." |
| 2041 | + def empty_trash(scope \\ nil) do |
| 2042 | + trashed = list_trashed_files(scope) |
| 2043 | + Enum.each(trashed, &delete_file_completely/1) |
| 2044 | + {:ok, length(trashed)} |
| 2045 | + end |
| 2046 | + |
| 2047 | + @doc "Permanently deletes trashed files older than the given number of days." |
| 2048 | + def prune_trash(days) do |
| 2049 | + cutoff = DateTime.utc_now() |> DateTime.add(-days * 86_400, :second) |
| 2050 | + |
| 2051 | + expired = |
| 2052 | + PhoenixKit.Modules.Storage.File |
| 2053 | + |> where([f], f.status == "trashed" and f.trashed_at < ^cutoff) |
| 2054 | + |> repo().all() |
| 2055 | + |
| 2056 | + Enum.each(expired, &delete_file_completely/1) |
| 2057 | + {:ok, length(expired)} |
| 2058 | + end |
| 2059 | + |
| 2060 | + @doc "Returns the configured trash retention period in days (default 30)." |
| 2061 | + def trash_retention_days do |
| 2062 | + Settings.get_setting("trash_retention_days", "30") |
| 2063 | + |> to_string() |
| 2064 | + |> Integer.parse() |
| 2065 | + |> case do |
| 2066 | + {n, _} -> n |
| 2067 | + :error -> 30 |
| 2068 | + end |
| 2069 | + rescue |
| 2070 | + _ -> 30 |
| 2071 | + end |
| 2072 | + |
| 1673 2073 | @doc """ |
| 1674 2074 | Deletes a file completely - physical data from all storage buckets and database record. |
Loading more files…