fabrica

hanna/fabrica

feat(web): route avatars, release assets, and LFS through the blob store

98dd84f · hanna committed on 2026-07-26

Wire the `BlobStore` into `AppState` and replace the direct filesystem calls in
the avatar, release-asset, and LFS handlers with namespaced blob operations, so
all three upload kinds honour `storage.backend`. Uploads still stream to a local
scratch file (to hash/size LFS objects) before being handed to the store, which
renames them into place locally or uploads them to S3. Surface the active
backend in `/healthz` and `fabrica doctor`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +97 −41UnifiedSplit
crates/cli/src/admin_cmd.rs +20 −0
@@ -58,6 +58,7 @@ pub(crate) fn doctor(config_path: Option<&Path>, quiet: bool) -> anyhow::Result<
5858 check("config", Ok("loaded".to_string()));
5959
6060 check("git", check_git(&loaded.config));
61+ check("storage", Ok(describe_storage(&loaded.config)));
6162 let database = block_on(async { anyhow::Ok(check_database(&loaded.config).await) })?;
6263 check("database", database);
6364
@@ -71,6 +72,25 @@ pub(crate) fn doctor(config_path: Option<&Path>, quiet: bool) -> anyhow::Result<
7172 }
7273 }
7374
75+/// Describe the upload storage backend (config is already validated by load).
76+fn describe_storage(config: &Config) -> String {
77+ match config.storage.backend {
78+ config::StorageBackend::Local => {
79+ format!("local ({})", config.storage.data_dir.display())
80+ }
81+ config::StorageBackend::S3 => match &config.storage.s3 {
82+ Some(s3) => {
83+ let endpoint = s3.endpoint.as_deref().unwrap_or("aws");
84+ format!(
85+ "s3 (bucket {}, region {}, {endpoint})",
86+ s3.bucket, s3.region
87+ )
88+ }
89+ None => "s3 (unconfigured)".to_string(),
90+ },
91+ }
92+}
93+
7494 /// Verify the configured `git` binary is present and recent enough.
7595 fn check_git(config: &Config) -> Result<String, String> {
7696 let output = Command::new(&config.git.binary)
crates/web/Cargo.toml +1 −0
@@ -13,6 +13,7 @@ publish.workspace = true
1313 config = { workspace = true }
1414 store = { workspace = true }
1515 model = { workspace = true }
16+blob = { workspace = true }
1617 auth = { workspace = true }
1718 mail = { workspace = true }
1819 git = { workspace = true }
crates/web/src/avatar.rs +11 −16
@@ -3,18 +3,18 @@
33 // file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
55 //! Avatars: an uploaded image when the user has one, else a deterministic
6-//! identicon generated from the username. Bytes live on disk under
7-//! `{storage.data_dir}/avatars/{id}`; the `users.avatar_mime` column records the
8-//! content type (its presence means "serve the file"). No network requests and
9-//! no remote gravatar (§9.5).
6+//! identicon generated from the username. Bytes live in the `avatars` blob
7+//! namespace (local disk or S3), keyed by user id; the `users.avatar_mime`
8+//! column records the content type (its presence means "serve the file"). No
9+//! network requests and no remote gravatar (§9.5).
1010
1111 use std::fmt::Write as _;
12-use std::path::PathBuf;
1312
1413 use axum::extract::{Multipart, Path, State};
1514 use axum::http::{StatusCode, header};
1615 use axum::response::{IntoResponse, Redirect, Response};
1716 use axum_extra::extract::cookie::CookieJar;
17+use blob::Namespace;
1818
1919 use crate::AppState;
2020 use crate::error::AppError;
@@ -27,18 +27,13 @@ const MAX_AVATAR_BYTES: usize = 1024 * 1024;
2727 /// avoid serving user-supplied markup; identicons are our own SVG.
2828 const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
2929
30-/// The on-disk path of a user's uploaded avatar.
31-fn avatar_path(state: &AppState, user_id: &str) -> PathBuf {
32- state.config.storage.data_dir.join("avatars").join(user_id)
33-}
34-
3530 /// `GET /avatar/{username}` — the user's uploaded avatar, or a generated
3631 /// identicon. Always renders an image so `<img>` tags never break.
3732 pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response {
3833 let user = state.store.user_by_username(&username).await.ok().flatten();
3934 if let Some(user) = &user
4035 && let Some(mime) = &user.avatar_mime
41- && let Ok(bytes) = tokio::fs::read(avatar_path(&state, &user.id)).await
36+ && let Ok(Some(bytes)) = state.blobs.get_bytes(Namespace::Avatars, &user.id).await
4237 {
4338 return (
4439 [
@@ -107,11 +102,11 @@ pub async fn upload(
107102 .into_response();
108103 }
109104
110- let dir = state.config.storage.data_dir.join("avatars");
111- if let Err(e) = tokio::fs::create_dir_all(&dir).await {
112- return AppError::internal(e).into_response();
113- }
114- if let Err(e) = tokio::fs::write(dir.join(&user.id), &bytes).await {
105+ if let Err(e) = state
106+ .blobs
107+ .put_bytes(Namespace::Avatars, &user.id, bytes, &mime)
108+ .await
109+ {
115110 return AppError::internal(e).into_response();
116111 }
117112 if let Err(e) = state.store.set_avatar_mime(&user.id, Some(&mime)).await {
crates/web/src/lfs.rs +24 −17
@@ -19,7 +19,6 @@
1919 //! [Batch API]: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
2020
2121 use std::collections::HashMap;
22-use std::path::PathBuf;
2322 use std::sync::atomic::{AtomicU64, Ordering};
2423
2524 use axum::body::Body;
@@ -27,6 +26,7 @@ use axum::extract::{Path, State};
2726 use axum::http::{HeaderMap, StatusCode, header};
2827 use axum::response::{IntoResponse, Response};
2928 use axum_extra::extract::cookie::CookieJar;
29+use blob::Namespace;
3030 use futures_util::TryStreamExt as _;
3131 use serde::{Deserialize, Serialize};
3232 use sha2::{Digest, Sha256};
@@ -61,9 +61,10 @@ fn valid_oid(oid: &str) -> bool {
6161 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
6262 }
6363
64-/// The on-disk path for an object, sharded by the first two byte-pairs of the oid.
65-fn object_path(lfs_dir: &std::path::Path, oid: &str) -> PathBuf {
66- lfs_dir.join(&oid[0..2]).join(&oid[2..4]).join(oid)
64+/// The blob key for an object, sharded by the first two byte-pairs of the oid.
65+/// Callers pass only validated 64-char oids, so the slices are in bounds.
66+fn object_key(oid: &str) -> String {
67+ format!("{}/{}/{}", &oid[0..2], &oid[2..4], oid)
6768 }
6869
6970 /// Hex-encode a byte slice (lowercase), for comparing a computed digest to an oid.
@@ -332,16 +333,14 @@ pub(crate) async fn download(
332333 {
333334 return StatusCode::NOT_FOUND.into_response();
334335 }
335- let path = object_path(&state.config.storage.lfs_dir, &oid);
336- let Ok(file) = tokio::fs::File::open(&path).await else {
336+ let Ok(Some(read)) = state.blobs.open(Namespace::Lfs, &object_key(&oid)).await else {
337337 return StatusCode::NOT_FOUND.into_response();
338338 };
339- let len = file.metadata().await.map_or(0, |m| m.len());
340- let body = Body::from_stream(ReaderStream::new(file));
339+ let body = Body::from_stream(ReaderStream::new(read.reader));
341340 (
342341 [
343342 (header::CONTENT_TYPE, "application/octet-stream".to_string()),
344- (header::CONTENT_LENGTH, len.to_string()),
343+ (header::CONTENT_LENGTH, read.size.to_string()),
345344 ],
346345 body,
347346 )
@@ -385,13 +384,14 @@ pub(crate) async fn upload(
385384 return StatusCode::OK.into_response();
386385 }
387386
388- let lfs_dir = &state.config.storage.lfs_dir;
389- let tmp_dir = lfs_dir.join("tmp");
387+ // Stream to a local scratch file to hash and size it before it becomes
388+ // servable; `data_dir` is always local, whatever the blob backend is.
389+ let tmp_dir = state.config.storage.data_dir.join("tmp");
390390 if tokio::fs::create_dir_all(&tmp_dir).await.is_err() {
391391 return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error");
392392 }
393393 let tmp = tmp_dir.join(format!(
394- "{}.{}.tmp",
394+ "lfs-{}.{}.tmp",
395395 std::process::id(),
396396 TMP_SEQ.fetch_add(1, Ordering::Relaxed)
397397 ));
@@ -405,11 +405,18 @@ pub(crate) async fn upload(
405405 return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "oid mismatch");
406406 }
407407
408- let dest = object_path(lfs_dir, &oid);
409- if let Some(parent) = dest.parent() {
410- let _ = tokio::fs::create_dir_all(parent).await;
411- }
412- if tokio::fs::rename(&tmp, &dest).await.is_err() {
408+ // Move the verified object into the blob store (local rename or S3 upload).
409+ if state
410+ .blobs
411+ .put_file(
412+ Namespace::Lfs,
413+ &object_key(&oid),
414+ &tmp,
415+ "application/octet-stream",
416+ )
417+ .await
418+ .is_err()
419+ {
413420 let _ = tokio::fs::remove_file(&tmp).await;
414421 return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error");
415422 }
crates/web/src/lib.rs +10 −1
@@ -61,6 +61,9 @@ pub enum WebError {
6161 /// The mailer could not be constructed from configuration.
6262 #[error("mail configuration error: {0}")]
6363 Mail(String),
64+ /// The upload storage backend could not be constructed from configuration.
65+ #[error("storage configuration error: {0}")]
66+ Blob(String),
6467 /// The server socket failed to bind or serve.
6568 #[error(transparent)]
6669 Io(#[from] std::io::Error),
@@ -74,6 +77,8 @@ pub struct AppState {
7477 pub config: Arc<Config>,
7578 /// The database handle.
7679 pub store: Store,
80+ /// Upload storage (avatars, release assets, LFS) — local disk or S3.
81+ pub blobs: blob::BlobStore,
7782 /// The configured mailer (invite emails).
7883 pub mailer: Arc<Mailer>,
7984 /// The instance signing secret (API tokens, future session signing).
@@ -107,13 +112,17 @@ impl AppState {
107112 ///
108113 /// # Errors
109114 ///
110- /// Returns [`WebError::Mail`] if the mailer cannot be constructed.
115+ /// Returns [`WebError::Mail`] if the mailer cannot be constructed, or
116+ /// [`WebError::Blob`] if the upload storage backend is misconfigured.
111117 pub fn build(config: Arc<Config>, store: Store, secret: String) -> Result<Self, WebError> {
112118 let mailer = Mailer::build(&config.mail).map_err(|e| WebError::Mail(e.to_string()))?;
119+ let blobs = blob::BlobStore::from_config(&config.storage)
120+ .map_err(|e| WebError::Blob(e.to_string()))?;
113121 let assets = Assets::new(config.ui.assets_dir.clone(), config.ui.themes_dir.clone());
114122 Ok(Self {
115123 config,
116124 store,
125+ blobs,
117126 mailer: Arc::new(mailer),
118127 secret: Arc::new(secret),
119128 signatures: git::SignatureCache::new(4096),
crates/web/src/releases.rs +21 −7
@@ -550,7 +550,7 @@ pub async fn delete(
550550 let result = async {
551551 let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
552552 for a in state.store.list_release_assets(&release.id).await? {
553- let _ = tokio::fs::remove_file(assets_dir(&state).join(&a.id)).await;
553+ let _ = state.blobs.delete(blob::Namespace::Releases, &a.id).await;
554554 }
555555 state.store.delete_release(&release.id).await?;
556556 Ok::<String, AppError>(format!("/{}/{}/-/releases", user.username, repo.path))
@@ -629,9 +629,15 @@ pub async fn asset_upload(
629629 .store
630630 .add_release_asset(&release.id, &name, size, &ctype)
631631 .await?;
632- let dest = assets_dir(&state).join(&asset.id);
632+ // Move the streamed temp file into the blob store (local rename or
633+ // S3 upload); a failure rolls the row back.
633634 let tmp = assets_dir(&state).join("tmp").join(&tmp_name);
634- if tokio::fs::rename(&tmp, &dest).await.is_err() {
635+ if state
636+ .blobs
637+ .put_file(blob::Namespace::Releases, &asset.id, &tmp, &ctype)
638+ .await
639+ .is_err()
640+ {
635641 let _ = tokio::fs::remove_file(&tmp).await;
636642 let _ = state.store.delete_release_asset(&asset.id).await;
637643 return Err(AppError::internal("storage error"));
@@ -664,8 +670,12 @@ pub async fn asset_download(
664670 .ok_or(AppError::NotFound)?;
665671 // The viewer must be able to read the repo the release belongs to.
666672 let _ = require_readable_repo(&state, viewer.as_ref(), &release.repo_id).await?;
667- let path = assets_dir(&state).join(&asset.id);
668- let Ok(file) = tokio::fs::File::open(&path).await else {
673+ let Some(read) = state
674+ .blobs
675+ .open(blob::Namespace::Releases, &asset.id)
676+ .await
677+ .map_err(AppError::internal)?
678+ else {
669679 return Err(AppError::NotFound);
670680 };
671681 let _ = state.store.bump_asset_download(&asset.id).await;
@@ -674,8 +684,9 @@ pub async fn asset_download(
674684 [
675685 (header::CONTENT_TYPE, asset.content_type.clone()),
676686 (header::CONTENT_DISPOSITION, disposition),
687+ (header::CONTENT_LENGTH, read.size.to_string()),
677688 ],
678- Body::from_stream(ReaderStream::new(file)),
689+ Body::from_stream(ReaderStream::new(read.reader)),
679690 )
680691 .into_response())
681692 }
@@ -700,7 +711,10 @@ pub async fn asset_delete(
700711 .ok_or(AppError::NotFound)?;
701712 let (release, _repo, _user) =
702713 writer_release(&state, viewer.as_ref(), &asset.release_id).await?;
703- let _ = tokio::fs::remove_file(assets_dir(&state).join(&asset.id)).await;
714+ let _ = state
715+ .blobs
716+ .delete(blob::Namespace::Releases, &asset.id)
717+ .await;
704718 state.store.delete_release_asset(&asset.id).await?;
705719 Ok::<String, AppError>(format!("/release/{}/edit", release.id))
706720 }
crates/web/src/serve.rs +10 −0
@@ -62,11 +62,21 @@ pub async fn healthz(State(state): State<AppState>) -> Response {
6262 let git = git_version(&state.config.git.binary);
6363 let db_ok = state.store.migrate().await.is_ok();
6464 let healthy = git.is_some() && db_ok;
65+ let storage = match state.config.storage.backend {
66+ config::StorageBackend::Local => "local".to_string(),
67+ config::StorageBackend::S3 => state
68+ .config
69+ .storage
70+ .s3
71+ .as_ref()
72+ .map_or_else(|| "s3".to_string(), |s3| format!("s3:{}", s3.bucket)),
73+ };
6574 let body = serde_json::json!({
6675 "status": if healthy { "ok" } else { "degraded" },
6776 "git": git,
6877 "git_binary": state.config.git.binary,
6978 "database": if db_ok { "ok" } else { "error" },
79+ "storage": storage,
7080 });
7181 let status = if healthy {
7282 StatusCode::OK