defmodule GitGud.Lfs do
@moduledoc """
Git LFS — server-side. Implements the [Batch API](https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md)
on top of the pluggable `GitGud.Storage` behaviour.
Object storage layout: bucket `gitgud-lfs`, key
`<repo_id>/<oid_first_2>/<oid_rest>`. Sharding by the first two hex
chars matches what reference LFS implementations do and keeps any
one directory under a sane fan-out on filesystem backends.
An `lfs_objects` row is created at batch time with no `uploaded_at`;
the client must call `/verify` after upload for the row to become
active. Until verification, the row is a placeholder so concurrent
Batch calls don't return conflicting upload actions.
Permission model mirrors smart-HTTP: read implies download, write
implies upload. The caller (controller) enforces auth.
"""
import Ecto.Query, warn: false
alias GitGud.Lfs.LfsObject
alias GitGud.Repo
alias GitGud.Repositories.Repository
alias GitGud.Storage
@bucket "gitgud-lfs"
@upload_expiry 600
@download_expiry 300
@transfer_adapter "basic"
@doc """
Run a Batch API operation. `operation` is "download" or "upload",
`objects` is a list of `%{"oid" => hex, "size" => int}` maps.
Returns the full batch response map (ready to JSON-encode).
"""
def batch(%Repository{} = repo, operation, objects) when is_list(objects) do
%{
"transfer" => @transfer_adapter,
"objects" => Enum.map(objects, &one_object(repo, operation, &1))
}
end
defp one_object(repo, "download", %{"oid" => oid, "size" => size}) do
case get_uploaded(repo, oid) do
%LfsObject{} = obj ->
{:ok, url} =
Storage.presign_url(obj.storage_bucket, obj.storage_key, :get,
expires_in: @download_expiry
)
%{
"oid" => oid,
"size" => size,
"authenticated" => true,
"actions" => %{
"download" => %{
"href" => url,
"expires_in" => @download_expiry
}
}
}
nil ->
%{
"oid" => oid,
"size" => size,
"error" => %{"code" => 404, "message" => "Object does not exist"}
}
end
end
defp one_object(repo, "upload", %{"oid" => oid, "size" => size}) do
case get(repo, oid) do
%LfsObject{uploaded_at: t} when not is_nil(t) ->
# Already on the server — no upload action needed.
%{"oid" => oid, "size" => size, "authenticated" => true}
_ ->
with {:ok, obj} <- ensure_placeholder(repo, oid, size),
{:ok, upload_url} <-
Storage.presign_url(obj.storage_bucket, obj.storage_key, :put,
expires_in: @upload_expiry
) do
%{
"oid" => oid,
"size" => size,
"authenticated" => true,
"actions" => %{
"upload" => %{
"href" => upload_url,
"expires_in" => @upload_expiry
},
"verify" => %{
"href" => verify_url(repo)
}
}
}
else
{:error, reason} ->
%{
"oid" => oid,
"size" => size,
"error" => %{"code" => 422, "message" => "Could not prepare upload: #{inspect(reason)}"}
}
end
end
end
defp one_object(_repo, _op, _bad), do: %{"error" => %{"code" => 422, "message" => "bad object"}}
@doc """
Mark an oid as uploaded. The LFS client calls this after a successful
PUT to the upload URL. Validates the row exists and the size matches.
"""
def verify(%Repository{} = repo, oid, size) when is_binary(oid) and is_integer(size) do
case get(repo, oid) do
nil ->
{:error, :unknown_oid}
%LfsObject{size: ^size} = obj ->
obj |> LfsObject.mark_uploaded() |> Repo.update()
%LfsObject{} ->
{:error, :size_mismatch}
end
end
@doc "Look up a tracked LFS object — returns nil if absent."
def get(%Repository{id: rid}, oid),
do: Repo.get_by(LfsObject, repository_id: rid, oid: oid)
defp get_uploaded(repo, oid) do
case get(repo, oid) do
%LfsObject{uploaded_at: t} = obj when not is_nil(t) -> obj
_ -> nil
end
end
defp ensure_placeholder(repo, oid, size) do
case get(repo, oid) do
%LfsObject{} = existing ->
{:ok, existing}
nil ->
%LfsObject{}
|> LfsObject.create_changeset(%{
repository_id: repo.id,
oid: oid,
size: size,
storage_bucket: @bucket,
storage_key: storage_key(repo.id, oid)
})
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:repository_id, :oid],
returning: true
)
end
end
defp storage_key(repo_id, <<a::binary-size(2), rest::binary>>) when byte_size(rest) == 62 do
Integer.to_string(repo_id) <> "/" <> a <> "/" <> rest
end
defp verify_url(%Repository{} = repo) do
repo = Repo.preload(repo, [:owner, :organization])
GitGudWeb.Endpoint.url() <>
"/" <> GitGud.Repositories.Storage.repo_handle(repo) <>
"/" <> repo.name <> ".git/info/lfs/verify"
end
@doc "The storage bucket all LFS objects live under."
def bucket, do: @bucket
end
5.3 KiB · text
5af55d9