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<
58 check("config", Ok("loaded".to_string()));58 check("config", Ok("loaded".to_string()));
5959
60 check("git", check_git(&loaded.config));60 check("git", check_git(&loaded.config));
61 check("storage", Ok(describe_storage(&loaded.config)));
61 let database = block_on(async { anyhow::Ok(check_database(&loaded.config).await) })?;62 let database = block_on(async { anyhow::Ok(check_database(&loaded.config).await) })?;
62 check("database", database);63 check("database", database);
6364
@@ -71,6 +72,25 @@ pub(crate) fn doctor(config_path: Option<&Path>, quiet: bool) -> anyhow::Result<
71 }72 }
72}73}
7374
75/// Describe the upload storage backend (config is already validated by load).
76fn 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
74/// Verify the configured `git` binary is present and recent enough.94/// Verify the configured `git` binary is present and recent enough.
75fn check_git(config: &Config) -> Result<String, String> {95fn check_git(config: &Config) -> Result<String, String> {
76 let output = Command::new(&config.git.binary)96 let output = Command::new(&config.git.binary)
crates/web/Cargo.toml +1 −0
@@ -13,6 +13,7 @@ publish.workspace = true
13config = { workspace = true }13config = { workspace = true }
14store = { workspace = true }14store = { workspace = true }
15model = { workspace = true }15model = { workspace = true }
16blob = { workspace = true }
16auth = { workspace = true }17auth = { workspace = true }
17mail = { workspace = true }18mail = { workspace = true }
18git = { workspace = true }19git = { workspace = true }
crates/web/src/avatar.rs +11 −16
@@ -3,18 +3,18 @@
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44
5//! Avatars: an uploaded image when the user has one, else a deterministic5//! Avatars: an uploaded image when the user has one, else a deterministic
6//! identicon generated from the username. Bytes live on disk under6//! identicon generated from the username. Bytes live in the `avatars` blob
7//! `{storage.data_dir}/avatars/{id}`; the `users.avatar_mime` column records the7//! namespace (local disk or S3), keyed by user id; the `users.avatar_mime`
8//! content type (its presence means "serve the file"). No network requests and8//! column records the content type (its presence means "serve the file"). No
9//! no remote gravatar (§9.5).9//! network requests and no remote gravatar (§9.5).
1010
11use std::fmt::Write as _;11use std::fmt::Write as _;
12use std::path::PathBuf;
1312
14use axum::extract::{Multipart, Path, State};13use axum::extract::{Multipart, Path, State};
15use axum::http::{StatusCode, header};14use axum::http::{StatusCode, header};
16use axum::response::{IntoResponse, Redirect, Response};15use axum::response::{IntoResponse, Redirect, Response};
17use axum_extra::extract::cookie::CookieJar;16use axum_extra::extract::cookie::CookieJar;
17use blob::Namespace;
1818
19use crate::AppState;19use crate::AppState;
20use crate::error::AppError;20use crate::error::AppError;
@@ -27,18 +27,13 @@ const MAX_AVATAR_BYTES: usize = 1024 * 1024;
27/// avoid serving user-supplied markup; identicons are our own SVG.27/// avoid serving user-supplied markup; identicons are our own SVG.
28const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];28const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
2929
30/// The on-disk path of a user's uploaded avatar.
31fn avatar_path(state: &AppState, user_id: &str) -> PathBuf {
32 state.config.storage.data_dir.join("avatars").join(user_id)
33}
34
35/// `GET /avatar/{username}` — the user's uploaded avatar, or a generated30/// `GET /avatar/{username}` — the user's uploaded avatar, or a generated
36/// identicon. Always renders an image so `<img>` tags never break.31/// identicon. Always renders an image so `<img>` tags never break.
37pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response {32pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response {
38 let user = state.store.user_by_username(&username).await.ok().flatten();33 let user = state.store.user_by_username(&username).await.ok().flatten();
39 if let Some(user) = &user34 if let Some(user) = &user
40 && let Some(mime) = &user.avatar_mime35 && let Some(mime) = &user.avatar_mime
41 && let Ok(bytes) = tokio::fs::read(avatar_path(&state, &user.id)).await36 && let Ok(Some(bytes)) = state.blobs.get_bytes(Namespace::Avatars, &user.id).await
42 {37 {
43 return (38 return (
44 [39 [
@@ -107,11 +102,11 @@ pub async fn upload(
107 .into_response();102 .into_response();
108 }103 }
109104
110 let dir = state.config.storage.data_dir.join("avatars");105 if let Err(e) = state
111 if let Err(e) = tokio::fs::create_dir_all(&dir).await {106 .blobs
112 return AppError::internal(e).into_response();107 .put_bytes(Namespace::Avatars, &user.id, bytes, &mime)
113 }108 .await
114 if let Err(e) = tokio::fs::write(dir.join(&user.id), &bytes).await {109 {
115 return AppError::internal(e).into_response();110 return AppError::internal(e).into_response();
116 }111 }
117 if let Err(e) = state.store.set_avatar_mime(&user.id, Some(&mime)).await {112 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 @@
19//! [Batch API]: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md19//! [Batch API]: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
2020
21use std::collections::HashMap;21use std::collections::HashMap;
22use std::path::PathBuf;
23use std::sync::atomic::{AtomicU64, Ordering};22use std::sync::atomic::{AtomicU64, Ordering};
2423
25use axum::body::Body;24use axum::body::Body;
@@ -27,6 +26,7 @@ use axum::extract::{Path, State};
27use axum::http::{HeaderMap, StatusCode, header};26use axum::http::{HeaderMap, StatusCode, header};
28use axum::response::{IntoResponse, Response};27use axum::response::{IntoResponse, Response};
29use axum_extra::extract::cookie::CookieJar;28use axum_extra::extract::cookie::CookieJar;
29use blob::Namespace;
30use futures_util::TryStreamExt as _;30use futures_util::TryStreamExt as _;
31use serde::{Deserialize, Serialize};31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};32use sha2::{Digest, Sha256};
@@ -61,9 +61,10 @@ fn valid_oid(oid: &str) -> bool {
61 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))61 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
62}62}
6363
64/// The on-disk path for an object, sharded by the first two byte-pairs of the oid.64/// The blob key for an object, sharded by the first two byte-pairs of the oid.
65fn object_path(lfs_dir: &std::path::Path, oid: &str) -> PathBuf {65/// Callers pass only validated 64-char oids, so the slices are in bounds.
66 lfs_dir.join(&oid[0..2]).join(&oid[2..4]).join(oid)66fn object_key(oid: &str) -> String {
67 format!("{}/{}/{}", &oid[0..2], &oid[2..4], oid)
67}68}
6869
69/// Hex-encode a byte slice (lowercase), for comparing a computed digest to an oid.70/// Hex-encode a byte slice (lowercase), for comparing a computed digest to an oid.
@@ -332,16 +333,14 @@ pub(crate) async fn download(
332 {333 {
333 return StatusCode::NOT_FOUND.into_response();334 return StatusCode::NOT_FOUND.into_response();
334 }335 }
335 let path = object_path(&state.config.storage.lfs_dir, &oid);336 let Ok(Some(read)) = state.blobs.open(Namespace::Lfs, &object_key(&oid)).await else {
336 let Ok(file) = tokio::fs::File::open(&path).await else {
337 return StatusCode::NOT_FOUND.into_response();337 return StatusCode::NOT_FOUND.into_response();
338 };338 };
339 let len = file.metadata().await.map_or(0, |m| m.len());339 let body = Body::from_stream(ReaderStream::new(read.reader));
340 let body = Body::from_stream(ReaderStream::new(file));
341 (340 (
342 [341 [
343 (header::CONTENT_TYPE, "application/octet-stream".to_string()),342 (header::CONTENT_TYPE, "application/octet-stream".to_string()),
344 (header::CONTENT_LENGTH, len.to_string()),343 (header::CONTENT_LENGTH, read.size.to_string()),
345 ],344 ],
346 body,345 body,
347 )346 )
@@ -385,13 +384,14 @@ pub(crate) async fn upload(
385 return StatusCode::OK.into_response();384 return StatusCode::OK.into_response();
386 }385 }
387386
388 let lfs_dir = &state.config.storage.lfs_dir;387 // Stream to a local scratch file to hash and size it before it becomes
389 let tmp_dir = lfs_dir.join("tmp");388 // servable; `data_dir` is always local, whatever the blob backend is.
389 let tmp_dir = state.config.storage.data_dir.join("tmp");
390 if tokio::fs::create_dir_all(&tmp_dir).await.is_err() {390 if tokio::fs::create_dir_all(&tmp_dir).await.is_err() {
391 return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error");391 return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error");
392 }392 }
393 let tmp = tmp_dir.join(format!(393 let tmp = tmp_dir.join(format!(
394 "{}.{}.tmp",394 "lfs-{}.{}.tmp",
395 std::process::id(),395 std::process::id(),
396 TMP_SEQ.fetch_add(1, Ordering::Relaxed)396 TMP_SEQ.fetch_add(1, Ordering::Relaxed)
397 ));397 ));
@@ -405,11 +405,18 @@ pub(crate) async fn upload(
405 return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "oid mismatch");405 return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "oid mismatch");
406 }406 }
407407
408 let dest = object_path(lfs_dir, &oid);408 // Move the verified object into the blob store (local rename or S3 upload).
409 if let Some(parent) = dest.parent() {409 if state
410 let _ = tokio::fs::create_dir_all(parent).await;410 .blobs
411 }411 .put_file(
412 if tokio::fs::rename(&tmp, &dest).await.is_err() {412 Namespace::Lfs,
413 &object_key(&oid),
414 &tmp,
415 "application/octet-stream",
416 )
417 .await
418 .is_err()
419 {
413 let _ = tokio::fs::remove_file(&tmp).await;420 let _ = tokio::fs::remove_file(&tmp).await;
414 return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error");421 return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error");
415 }422 }
crates/web/src/lib.rs +10 −1
@@ -61,6 +61,9 @@ pub enum WebError {
61 /// The mailer could not be constructed from configuration.61 /// The mailer could not be constructed from configuration.
62 #[error("mail configuration error: {0}")]62 #[error("mail configuration error: {0}")]
63 Mail(String),63 Mail(String),
64 /// The upload storage backend could not be constructed from configuration.
65 #[error("storage configuration error: {0}")]
66 Blob(String),
64 /// The server socket failed to bind or serve.67 /// The server socket failed to bind or serve.
65 #[error(transparent)]68 #[error(transparent)]
66 Io(#[from] std::io::Error),69 Io(#[from] std::io::Error),
@@ -74,6 +77,8 @@ pub struct AppState {
74 pub config: Arc<Config>,77 pub config: Arc<Config>,
75 /// The database handle.78 /// The database handle.
76 pub store: Store,79 pub store: Store,
80 /// Upload storage (avatars, release assets, LFS) — local disk or S3.
81 pub blobs: blob::BlobStore,
77 /// The configured mailer (invite emails).82 /// The configured mailer (invite emails).
78 pub mailer: Arc<Mailer>,83 pub mailer: Arc<Mailer>,
79 /// The instance signing secret (API tokens, future session signing).84 /// The instance signing secret (API tokens, future session signing).
@@ -107,13 +112,17 @@ impl AppState {
107 ///112 ///
108 /// # Errors113 /// # Errors
109 ///114 ///
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.
111 pub fn build(config: Arc<Config>, store: Store, secret: String) -> Result<Self, WebError> {117 pub fn build(config: Arc<Config>, store: Store, secret: String) -> Result<Self, WebError> {
112 let mailer = Mailer::build(&config.mail).map_err(|e| WebError::Mail(e.to_string()))?;118 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()))?;
113 let assets = Assets::new(config.ui.assets_dir.clone(), config.ui.themes_dir.clone());121 let assets = Assets::new(config.ui.assets_dir.clone(), config.ui.themes_dir.clone());
114 Ok(Self {122 Ok(Self {
115 config,123 config,
116 store,124 store,
125 blobs,
117 mailer: Arc::new(mailer),126 mailer: Arc::new(mailer),
118 secret: Arc::new(secret),127 secret: Arc::new(secret),
119 signatures: git::SignatureCache::new(4096),128 signatures: git::SignatureCache::new(4096),
crates/web/src/releases.rs +21 −7
@@ -550,7 +550,7 @@ pub async fn delete(
550 let result = async {550 let result = async {
551 let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;551 let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
552 for a in state.store.list_release_assets(&release.id).await? {552 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;
554 }554 }
555 state.store.delete_release(&release.id).await?;555 state.store.delete_release(&release.id).await?;
556 Ok::<String, AppError>(format!("/{}/{}/-/releases", user.username, repo.path))556 Ok::<String, AppError>(format!("/{}/{}/-/releases", user.username, repo.path))
@@ -629,9 +629,15 @@ pub async fn asset_upload(
629 .store629 .store
630 .add_release_asset(&release.id, &name, size, &ctype)630 .add_release_asset(&release.id, &name, size, &ctype)
631 .await?;631 .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.
633 let tmp = assets_dir(&state).join("tmp").join(&tmp_name);634 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 {
635 let _ = tokio::fs::remove_file(&tmp).await;641 let _ = tokio::fs::remove_file(&tmp).await;
636 let _ = state.store.delete_release_asset(&asset.id).await;642 let _ = state.store.delete_release_asset(&asset.id).await;
637 return Err(AppError::internal("storage error"));643 return Err(AppError::internal("storage error"));
@@ -664,8 +670,12 @@ pub async fn asset_download(
664 .ok_or(AppError::NotFound)?;670 .ok_or(AppError::NotFound)?;
665 // The viewer must be able to read the repo the release belongs to.671 // The viewer must be able to read the repo the release belongs to.
666 let _ = require_readable_repo(&state, viewer.as_ref(), &release.repo_id).await?;672 let _ = require_readable_repo(&state, viewer.as_ref(), &release.repo_id).await?;
667 let path = assets_dir(&state).join(&asset.id);673 let Some(read) = state
668 let Ok(file) = tokio::fs::File::open(&path).await else {674 .blobs
675 .open(blob::Namespace::Releases, &asset.id)
676 .await
677 .map_err(AppError::internal)?
678 else {
669 return Err(AppError::NotFound);679 return Err(AppError::NotFound);
670 };680 };
671 let _ = state.store.bump_asset_download(&asset.id).await;681 let _ = state.store.bump_asset_download(&asset.id).await;
@@ -674,8 +684,9 @@ pub async fn asset_download(
674 [684 [
675 (header::CONTENT_TYPE, asset.content_type.clone()),685 (header::CONTENT_TYPE, asset.content_type.clone()),
676 (header::CONTENT_DISPOSITION, disposition),686 (header::CONTENT_DISPOSITION, disposition),
687 (header::CONTENT_LENGTH, read.size.to_string()),
677 ],688 ],
678 Body::from_stream(ReaderStream::new(file)),689 Body::from_stream(ReaderStream::new(read.reader)),
679 )690 )
680 .into_response())691 .into_response())
681}692}
@@ -700,7 +711,10 @@ pub async fn asset_delete(
700 .ok_or(AppError::NotFound)?;711 .ok_or(AppError::NotFound)?;
701 let (release, _repo, _user) =712 let (release, _repo, _user) =
702 writer_release(&state, viewer.as_ref(), &asset.release_id).await?;713 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;
704 state.store.delete_release_asset(&asset.id).await?;718 state.store.delete_release_asset(&asset.id).await?;
705 Ok::<String, AppError>(format!("/release/{}/edit", release.id))719 Ok::<String, AppError>(format!("/release/{}/edit", release.id))
706 }720 }
crates/web/src/serve.rs +10 −0
@@ -62,11 +62,21 @@ pub async fn healthz(State(state): State<AppState>) -> Response {
62 let git = git_version(&state.config.git.binary);62 let git = git_version(&state.config.git.binary);
63 let db_ok = state.store.migrate().await.is_ok();63 let db_ok = state.store.migrate().await.is_ok();
64 let healthy = git.is_some() && db_ok;64 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 };
65 let body = serde_json::json!({74 let body = serde_json::json!({
66 "status": if healthy { "ok" } else { "degraded" },75 "status": if healthy { "ok" } else { "degraded" },
67 "git": git,76 "git": git,
68 "git_binary": state.config.git.binary,77 "git_binary": state.config.git.binary,
69 "database": if db_ok { "ok" } else { "error" },78 "database": if db_ok { "ok" } else { "error" },
79 "storage": storage,
70 });80 });
71 let status = if healthy {81 let status = if healthy {
72 StatusCode::OK82 StatusCode::OK