Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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 | 58 | check("config", Ok("loaded".to_string())); | |
| 59 | 59 | ||
| 60 | 60 | check("git", check_git(&loaded.config)); | |
| 61 | + | check("storage", Ok(describe_storage(&loaded.config))); | |
| 61 | 62 | let database = block_on(async { anyhow::Ok(check_database(&loaded.config).await) })?; | |
| 62 | 63 | check("database", database); | |
| 63 | 64 | ||
| @@ -71,6 +72,25 @@ pub(crate) fn doctor(config_path: Option<&Path>, quiet: bool) -> anyhow::Result< | |||
| 71 | 72 | } | |
| 72 | 73 | } | |
| 73 | 74 | ||
| 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 | + | ||
| 74 | 94 | /// Verify the configured `git` binary is present and recent enough. | |
| 75 | 95 | fn check_git(config: &Config) -> Result<String, String> { | |
| 76 | 96 | let output = Command::new(&config.git.binary) | |
crates/web/Cargo.toml +1 −0
| @@ -13,6 +13,7 @@ publish.workspace = true | |||
| 13 | 13 | config = { workspace = true } | |
| 14 | 14 | store = { workspace = true } | |
| 15 | 15 | model = { workspace = true } | |
| 16 | + | blob = { workspace = true } | |
| 16 | 17 | auth = { workspace = true } | |
| 17 | 18 | mail = { workspace = true } | |
| 18 | 19 | git = { workspace = true } | |
crates/web/src/avatar.rs +11 −16
| @@ -3,18 +3,18 @@ | |||
| 3 | 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | 4 | ||
| 5 | 5 | //! 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). | |
| 10 | 10 | ||
| 11 | 11 | use std::fmt::Write as _; | |
| 12 | - | use std::path::PathBuf; | |
| 13 | 12 | ||
| 14 | 13 | use axum::extract::{Multipart, Path, State}; | |
| 15 | 14 | use axum::http::{StatusCode, header}; | |
| 16 | 15 | use axum::response::{IntoResponse, Redirect, Response}; | |
| 17 | 16 | use axum_extra::extract::cookie::CookieJar; | |
| 17 | + | use blob::Namespace; | |
| 18 | 18 | ||
| 19 | 19 | use crate::AppState; | |
| 20 | 20 | use crate::error::AppError; | |
| @@ -27,18 +27,13 @@ const MAX_AVATAR_BYTES: usize = 1024 * 1024; | |||
| 27 | 27 | /// avoid serving user-supplied markup; identicons are our own SVG. | |
| 28 | 28 | const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; | |
| 29 | 29 | ||
| 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 | - | ||
| 35 | 30 | /// `GET /avatar/{username}` — the user's uploaded avatar, or a generated | |
| 36 | 31 | /// identicon. Always renders an image so `<img>` tags never break. | |
| 37 | 32 | pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response { | |
| 38 | 33 | let user = state.store.user_by_username(&username).await.ok().flatten(); | |
| 39 | 34 | if let Some(user) = &user | |
| 40 | 35 | && 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 | |
| 42 | 37 | { | |
| 43 | 38 | return ( | |
| 44 | 39 | [ | |
| @@ -107,11 +102,11 @@ pub async fn upload( | |||
| 107 | 102 | .into_response(); | |
| 108 | 103 | } | |
| 109 | 104 | ||
| 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 | + | { | |
| 115 | 110 | return AppError::internal(e).into_response(); | |
| 116 | 111 | } | |
| 117 | 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 | 19 | //! [Batch API]: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md | |
| 20 | 20 | ||
| 21 | 21 | use std::collections::HashMap; | |
| 22 | - | use std::path::PathBuf; | |
| 23 | 22 | use std::sync::atomic::{AtomicU64, Ordering}; | |
| 24 | 23 | ||
| 25 | 24 | use axum::body::Body; | |
| @@ -27,6 +26,7 @@ use axum::extract::{Path, State}; | |||
| 27 | 26 | use axum::http::{HeaderMap, StatusCode, header}; | |
| 28 | 27 | use axum::response::{IntoResponse, Response}; | |
| 29 | 28 | use axum_extra::extract::cookie::CookieJar; | |
| 29 | + | use blob::Namespace; | |
| 30 | 30 | use futures_util::TryStreamExt as _; | |
| 31 | 31 | use serde::{Deserialize, Serialize}; | |
| 32 | 32 | use sha2::{Digest, Sha256}; | |
| @@ -61,9 +61,10 @@ fn valid_oid(oid: &str) -> bool { | |||
| 61 | 61 | .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 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) | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | 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 | 334 | return StatusCode::NOT_FOUND.into_response(); | |
| 334 | 335 | } | |
| 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 { | |
| 337 | 337 | return StatusCode::NOT_FOUND.into_response(); | |
| 338 | 338 | }; | |
| 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)); | |
| 341 | 340 | ( | |
| 342 | 341 | [ | |
| 343 | 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 | 345 | body, | |
| 347 | 346 | ) | |
| @@ -385,13 +384,14 @@ pub(crate) async fn upload( | |||
| 385 | 384 | return StatusCode::OK.into_response(); | |
| 386 | 385 | } | |
| 387 | 386 | ||
| 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"); | |
| 390 | 390 | if tokio::fs::create_dir_all(&tmp_dir).await.is_err() { | |
| 391 | 391 | return lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "storage error"); | |
| 392 | 392 | } | |
| 393 | 393 | let tmp = tmp_dir.join(format!( | |
| 394 | - | "{}.{}.tmp", | |
| 394 | + | "lfs-{}.{}.tmp", | |
| 395 | 395 | std::process::id(), | |
| 396 | 396 | TMP_SEQ.fetch_add(1, Ordering::Relaxed) | |
| 397 | 397 | )); | |
| @@ -405,11 +405,18 @@ pub(crate) async fn upload( | |||
| 405 | 405 | return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "oid mismatch"); | |
| 406 | 406 | } | |
| 407 | 407 | ||
| 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 | + | { | |
| 413 | 420 | let _ = tokio::fs::remove_file(&tmp).await; | |
| 414 | 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 | 61 | /// The mailer could not be constructed from configuration. | |
| 62 | 62 | #[error("mail configuration error: {0}")] | |
| 63 | 63 | Mail(String), | |
| 64 | + | /// The upload storage backend could not be constructed from configuration. | |
| 65 | + | #[error("storage configuration error: {0}")] | |
| 66 | + | Blob(String), | |
| 64 | 67 | /// The server socket failed to bind or serve. | |
| 65 | 68 | #[error(transparent)] | |
| 66 | 69 | Io(#[from] std::io::Error), | |
| @@ -74,6 +77,8 @@ pub struct AppState { | |||
| 74 | 77 | pub config: Arc<Config>, | |
| 75 | 78 | /// The database handle. | |
| 76 | 79 | pub store: Store, | |
| 80 | + | /// Upload storage (avatars, release assets, LFS) — local disk or S3. | |
| 81 | + | pub blobs: blob::BlobStore, | |
| 77 | 82 | /// The configured mailer (invite emails). | |
| 78 | 83 | pub mailer: Arc<Mailer>, | |
| 79 | 84 | /// The instance signing secret (API tokens, future session signing). | |
| @@ -107,13 +112,17 @@ impl AppState { | |||
| 107 | 112 | /// | |
| 108 | 113 | /// # 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 | 117 | pub fn build(config: Arc<Config>, store: Store, secret: String) -> Result<Self, WebError> { | |
| 112 | 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 | 121 | let assets = Assets::new(config.ui.assets_dir.clone(), config.ui.themes_dir.clone()); | |
| 114 | 122 | Ok(Self { | |
| 115 | 123 | config, | |
| 116 | 124 | store, | |
| 125 | + | blobs, | |
| 117 | 126 | mailer: Arc::new(mailer), | |
| 118 | 127 | secret: Arc::new(secret), | |
| 119 | 128 | signatures: git::SignatureCache::new(4096), | |
crates/web/src/releases.rs +21 −7
| @@ -550,7 +550,7 @@ pub async fn delete( | |||
| 550 | 550 | let result = async { | |
| 551 | 551 | let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; | |
| 552 | 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 | 555 | state.store.delete_release(&release.id).await?; | |
| 556 | 556 | Ok::<String, AppError>(format!("/{}/{}/-/releases", user.username, repo.path)) | |
| @@ -629,9 +629,15 @@ pub async fn asset_upload( | |||
| 629 | 629 | .store | |
| 630 | 630 | .add_release_asset(&release.id, &name, size, &ctype) | |
| 631 | 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 | 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 | 641 | let _ = tokio::fs::remove_file(&tmp).await; | |
| 636 | 642 | let _ = state.store.delete_release_asset(&asset.id).await; | |
| 637 | 643 | return Err(AppError::internal("storage error")); | |
| @@ -664,8 +670,12 @@ pub async fn asset_download( | |||
| 664 | 670 | .ok_or(AppError::NotFound)?; | |
| 665 | 671 | // The viewer must be able to read the repo the release belongs to. | |
| 666 | 672 | 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 { | |
| 669 | 679 | return Err(AppError::NotFound); | |
| 670 | 680 | }; | |
| 671 | 681 | let _ = state.store.bump_asset_download(&asset.id).await; | |
| @@ -674,8 +684,9 @@ pub async fn asset_download( | |||
| 674 | 684 | [ | |
| 675 | 685 | (header::CONTENT_TYPE, asset.content_type.clone()), | |
| 676 | 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 | 691 | .into_response()) | |
| 681 | 692 | } | |
| @@ -700,7 +711,10 @@ pub async fn asset_delete( | |||
| 700 | 711 | .ok_or(AppError::NotFound)?; | |
| 701 | 712 | let (release, _repo, _user) = | |
| 702 | 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 | 718 | state.store.delete_release_asset(&asset.id).await?; | |
| 705 | 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 | 62 | let git = git_version(&state.config.git.binary); | |
| 63 | 63 | let db_ok = state.store.migrate().await.is_ok(); | |
| 64 | 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 | 74 | let body = serde_json::json!({ | |
| 66 | 75 | "status": if healthy { "ok" } else { "degraded" }, | |
| 67 | 76 | "git": git, | |
| 68 | 77 | "git_binary": state.config.git.binary, | |
| 69 | 78 | "database": if db_ok { "ok" } else { "error" }, | |
| 79 | + | "storage": storage, | |
| 70 | 80 | }); | |
| 71 | 81 | let status = if healthy { | |
| 72 | 82 | StatusCode::OK | |