4.1 KiB · text 5af55d9
defmodule GitGud.Storage.Disk do
@moduledoc """
Filesystem-backed `GitGud.Storage` implementation.
Suitable for:
* single-node development (`priv/storage/`)
* single-node production (a real disk path)
* multi-node production where every node mounts the same NFS export
at a shared `:root` — the impl makes no node-local assumptions
beyond the path being readable & writable.
Objects live at `<root>/<bucket>/<key>`. Bucket and key are sanitized
so they can't escape the root via `..`.
Presigned URLs route through `GitGudWeb.StorageController`, which
verifies a `Phoenix.Token` signed with the endpoint secret. There's
no native presigning at the filesystem layer — the token URL is the
equivalent.
Configure with:
config :git_gud, GitGud.Storage, impl: GitGud.Storage.Disk
config :git_gud, GitGud.Storage.Disk, root: "priv/storage"
"""
@behaviour GitGud.Storage
@token_salt "gitgud.storage.disk"
@impl true
def put(bucket, key, body, _opts) do
path = object_path(bucket, key)
File.mkdir_p!(Path.dirname(path))
File.write(path, body)
end
@impl true
def get(bucket, key) do
path = object_path(bucket, key)
case File.read(path) do
{:ok, _} = ok -> ok
{:error, :enoent} -> {:error, :not_found}
{:error, _} = err -> err
end
end
@impl true
def delete(bucket, key) do
path = object_path(bucket, key)
case File.rm(path) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, _} = err -> err
end
end
@impl true
def presign_url(bucket, key, method, opts) when method in [:get, :put] do
expires_in = Keyword.get(opts, :expires_in, 300)
token = sign_token(bucket, key, method, expires_in)
url =
base_url() <>
"/_storage/" <> URI.encode(bucket) <> "/" <> URI.encode(key) <>
"?token=" <> token
{:ok, url}
end
# ── token helpers (used by StorageController too) ───────────────────
@doc """
Sign a token authorizing `method` on `bucket/key`. Embedded fields:
bucket, key, method (atom), expiry (unix seconds).
"""
def sign_token(bucket, key, method, expires_in_seconds) do
expires_at = System.system_time(:second) + expires_in_seconds
Phoenix.Token.sign(
GitGudWeb.Endpoint,
@token_salt,
%{bucket: bucket, key: key, method: method, exp: expires_at}
)
end
@doc """
Verify a token. `expected_method` must match the method it was signed
with. Returns `{:ok, %{bucket, key}}` or `{:error, reason}`.
"""
def verify_token(token, expected_method) when is_binary(token) do
# max_age 7 days; the embedded `:exp` is the canonical bound.
case Phoenix.Token.verify(GitGudWeb.Endpoint, @token_salt, token, max_age: 604_800) do
{:ok, %{bucket: b, key: k, method: ^expected_method, exp: exp}} ->
if System.system_time(:second) <= exp do
{:ok, %{bucket: b, key: k}}
else
{:error, :expired}
end
{:ok, _} ->
{:error, :wrong_method}
err ->
err
end
end
def verify_token(_, _), do: {:error, :no_token}
# ── filesystem helpers ──────────────────────────────────────────────
@doc """
Absolute path to an object on disk. Public so the StorageController
can serve files directly via `send_file`.
"""
def object_path(bucket, key) do
safe_bucket = sanitize(bucket)
safe_key = sanitize(key)
Path.join([root(), safe_bucket, safe_key])
end
defp sanitize(component) when is_binary(component) do
component
|> String.split(["/", "\\"])
|> Enum.reject(&(&1 in ["", ".", ".."]))
|> Path.join()
end
defp root do
Application.get_env(:git_gud, __MODULE__, [])
|> Keyword.get(:root, "priv/storage")
|> Path.expand()
end
defp base_url do
case Application.get_env(:git_gud, __MODULE__, [])[:public_base_url] do
url when is_binary(url) and url != "" -> String.trim_trailing(url, "/")
_ -> GitGudWeb.Endpoint.url() |> String.trim_trailing("/")
end
end
end