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 | check("config", Ok("loaded".to_string())); | 58 | check("config", Ok("loaded".to_string())); |
| 59 | 59 | ||
| 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); |
| 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 | /// Verify the configured `git` binary is present and recent enough. | 94 | /// Verify the configured `git` binary is present and recent enough. |
| 75 | fn check_git(config: &Config) -> Result<String, String> { | 95 | fn 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 | |||
| 13 | config = { workspace = true } | 13 | config = { workspace = true } |
| 14 | store = { workspace = true } | 14 | store = { workspace = true } |
| 15 | model = { workspace = true } | 15 | model = { workspace = true } |
| 16 | blob = { workspace = true } | ||
| 16 | auth = { workspace = true } | 17 | auth = { workspace = true } |
| 17 | mail = { workspace = true } | 18 | mail = { workspace = true } |
| 18 | git = { workspace = true } | 19 | git = { 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/. |
| 4 | 4 | ||
| 5 | //! Avatars: an uploaded image when the user has one, else a deterministic | 5 | //! Avatars: an uploaded image when the user has one, else a deterministic |
| 6 | //! identicon generated from the username. Bytes live on disk under | 6 | //! identicon generated from the username. Bytes live in the `avatars` blob |
| 7 | //! `{storage.data_dir}/avatars/{id}`; the `users.avatar_mime` column records the | 7 | //! 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 and | 8 | //! 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). |
| 10 | 10 | ||
| 11 | use std::fmt::Write as _; | 11 | use std::fmt::Write as _; |
| 12 | use std::path::PathBuf; | ||
| 13 | 12 | ||
| 14 | use axum::extract::{Multipart, Path, State}; | 13 | use axum::extract::{Multipart, Path, State}; |
| 15 | use axum::http::{StatusCode, header}; | 14 | use axum::http::{StatusCode, header}; |
| 16 | use axum::response::{IntoResponse, Redirect, Response}; | 15 | use axum::response::{IntoResponse, Redirect, Response}; |
| 17 | use axum_extra::extract::cookie::CookieJar; | 16 | use axum_extra::extract::cookie::CookieJar; |
| 17 | use blob::Namespace; | ||
| 18 | 18 | ||
| 19 | use crate::AppState; | 19 | use crate::AppState; |
| 20 | use crate::error::AppError; | 20 | use 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. |
| 28 | const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; | 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 | /// `GET /avatar/{username}` — the user's uploaded avatar, or a generated | 30 | /// `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. |
| 37 | pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response { | 32 | pub 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) = &user | 34 | if let Some(user) = &user |
| 40 | && let Some(mime) = &user.avatar_mime | 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 | return ( | 38 | return ( |
| 44 | [ | 39 | [ |
| @@ -107,11 +102,11 @@ pub async fn upload( | |||
| 107 | .into_response(); | 102 | .into_response(); |
| 108 | } | 103 | } |
| 109 | 104 | ||
| 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.md | 19 | //! [Batch API]: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md |
| 20 | 20 | ||
| 21 | use std::collections::HashMap; | 21 | use std::collections::HashMap; |
| 22 | use std::path::PathBuf; | ||
| 23 | use std::sync::atomic::{AtomicU64, Ordering}; | 22 | use std::sync::atomic::{AtomicU64, Ordering}; |
| 24 | 23 | ||
| 25 | use axum::body::Body; | 24 | use axum::body::Body; |
| @@ -27,6 +26,7 @@ use axum::extract::{Path, State}; | |||
| 27 | use axum::http::{HeaderMap, StatusCode, header}; | 26 | use axum::http::{HeaderMap, StatusCode, header}; |
| 28 | use axum::response::{IntoResponse, Response}; | 27 | use axum::response::{IntoResponse, Response}; |
| 29 | use axum_extra::extract::cookie::CookieJar; | 28 | use axum_extra::extract::cookie::CookieJar; |
| 29 | use blob::Namespace; | ||
| 30 | use futures_util::TryStreamExt as _; | 30 | use futures_util::TryStreamExt as _; |
| 31 | use serde::{Deserialize, Serialize}; | 31 | use serde::{Deserialize, Serialize}; |
| 32 | use sha2::{Digest, Sha256}; | 32 | use 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 | } |
| 63 | 63 | ||
| 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. |
| 65 | fn 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) | 66 | fn object_key(oid: &str) -> String { |
| 67 | format!("{}/{}/{}", &oid[0..2], &oid[2..4], oid) | ||
| 67 | } | 68 | } |
| 68 | 69 | ||
| 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 | } |
| 387 | 386 | ||
| 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 | } |
| 407 | 407 | ||
| 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 | /// # Errors | 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 | 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 | .store | 629 | .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::OK | 82 | StatusCode::OK |