Packages

phoenix_kit

1.7.102
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
phoenix_kit lib phoenix_kit_web controllers file_controller.ex
Raw

lib/phoenix_kit_web/controllers/file_controller.ex

defmodule PhoenixKitWeb.FileController do
@moduledoc """
File serving controller with signed URL support.
Handles secure file retrieval with token-based authentication and cache headers.
"""
use PhoenixKitWeb, :controller
require Logger
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Modules.Storage.{Manager, ProcessFileJob, URLSigner}
alias PhoenixKit.Utils.Routes
@doc """
Serve a file variant by ID with signed URL token.
## Request
GET /file/:file_uuid/:variant/:token
## Parameters
- `file_uuid`: UUID of the file
- `variant`: Variant name (e.g., "original", "thumbnail", "medium")
- `token`: Signed token for authentication
## Response
Success (200):
- File streamed to client with appropriate headers:
- `Cache-Control: public, max-age=31536000, immutable` (1 year)
- `ETag: "md5-hash"`
- `Content-Type: <mime-type>`
- `Content-Disposition: inline; filename="..."`
Not Modified (304):
- Returned when request includes `If-None-Match` matching the file ETag
Error (401):
"Invalid or expired token"
Error (404):
"File or variant not found"
"""
def show(conn, %{"file_uuid" => file_uuid, "variant" => variant, "token" => token}) do
with {:ok, file} <- get_file(file_uuid),
:ok <- verify_token(file_uuid, variant, token),
{:ok, instance} <- get_file_instance(file_uuid, variant),
result <- get_file_access(instance) do
case result do
{:local, file_path} ->
serve_file(conn, file, instance, file_path)
{:redirect, url} ->
redirect(conn, external: url)
{:proxy, file_name} ->
proxy_remote_file(conn, file, instance, file_name)
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> text("File or variant not found")
{:error, reason} ->
conn
|> put_status(:internal_server_error)
|> text("Error retrieving file: #{inspect(reason)}")
end
else
{:error, :invalid_token} ->
conn
|> put_status(:unauthorized)
|> text("Invalid or expired token")
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> text("File or variant not found")
end
end
@doc """
Get file information without serving the file.
## Request
GET /api/files/:file_uuid/info
## Response
Success (200):
{
"file_uuid": "uuid",
"original_filename": "photo.jpg",
"mime_type": "image/jpeg",
"file_type": "image",
"size": 1234567,
"status": "active",
"variants": [
{
"variant_name": "original",
"mime_type": "image/jpeg",
"size": 1234567,
"width": 1920,
"height": 1080,
"url": "/file/uuid/original/token"
}
]
}
"""
def info(conn, %{"file_uuid" => file_uuid}) do
case get_file(file_uuid) do
{:ok, file} ->
instances = Storage.list_file_instances(file_uuid)
variant_urls =
Enum.map(instances, fn instance ->
token = URLSigner.generate_token(file_uuid, instance.variant_name)
file_path = "/file/#{file_uuid}/#{instance.variant_name}/#{token}"
url = Routes.path(file_path)
%{
variant_name: instance.variant_name,
mime_type: instance.mime_type,
size: instance.size,
width: instance.width,
height: instance.height,
url: url
}
end)
json(conn, %{
file_uuid: file.uuid,
original_filename: file.original_file_name,
mime_type: file.mime_type,
file_type: file.file_type,
size: file.size,
status: file.status,
variants: variant_urls
})
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "FILE_NOT_FOUND", message: "File not found"})
end
end
defp get_file(file_uuid) do
case Storage.get_file(file_uuid) do
nil -> {:error, :not_found}
file -> {:ok, file}
end
end
defp get_file_instance(file_uuid, variant) do
case Storage.get_file_instance_by_name(file_uuid, variant) do
nil ->
# Variant doesn't exist, try to get the original to queue generation
case Storage.get_file_instance_by_name(file_uuid, "original") do
nil ->
{:error, :not_found}
original_instance ->
# Queue the variant for generation if not already requested
queue_missing_variant(file_uuid, variant, original_instance)
# Return the original for now
{:ok, original_instance}
end
instance ->
{:ok, instance}
end
end
defp queue_missing_variant(file_uuid, _variant, original_instance) do
# Queue background job to generate the missing variant
Task.start(fn ->
case Storage.get_file(file_uuid) do
nil ->
:error
file ->
%{
file_uuid: file_uuid,
user_uuid: file.user_uuid,
filename: original_instance.file_name
}
|> ProcessFileJob.new()
|> Oban.insert()
end
end)
end
defp verify_token(file_uuid, variant, token) do
if URLSigner.verify_token(file_uuid, variant, token) do
:ok
else
{:error, :invalid_token}
end
end
# Get file access info with retry logic for bucket cache race conditions
# Returns {:local, path} | {:redirect, url} | {:proxy, file_name} | {:error, reason}
defp get_file_access(instance) do
get_file_access_with_retry(instance, 5)
end
defp get_file_access_with_retry(instance, retries) do
case Manager.get_file_access(instance.file_name) do
{:local, _} = result ->
result
{:redirect, _} = result ->
result
{:proxy, _} = result ->
result
{:error, :not_found} when retries > 1 ->
# Race condition during bucket cache init - retry with delay
Logger.debug(
"[FileController] File not found, retrying (#{retries - 1} left): #{instance.file_name}"
)
Process.sleep(100)
get_file_access_with_retry(instance, retries - 1)
error ->
error
end
end
# Serve a local file with proper headers
defp serve_file(conn, file, instance, file_path) do
etag = ~s("#{instance.checksum}")
if etag in Plug.Conn.get_req_header(conn, "if-none-match") do
conn
|> put_resp_header("etag", etag)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_resp(304, "")
else
conn
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> put_resp_header(
"content-disposition",
~s(inline; filename="#{file.original_file_name}")
)
|> put_resp_content_type(instance.mime_type)
|> send_file(200, file_path)
end
end
# Proxy a remote file through the server (for private buckets)
defp proxy_remote_file(conn, file, instance, file_name) do
temp_path =
Path.join(System.tmp_dir!(), "phoenix_kit_#{instance.uuid}_#{:rand.uniform(1_000_000)}")
case Manager.retrieve_file(file_name, destination_path: temp_path) do
{:ok, _} ->
conn = serve_file(conn, file, instance, temp_path)
File.rm(temp_path)
conn
{:error, _reason} ->
conn
|> put_status(:not_found)
|> text("File or variant not found")
end
end
end