diff --git a/backend/.env.example b/backend/.env.example index 8614a8c..d7732e1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,9 @@ S3_ENDPOINT=http://localhost:9000 S3_BUCKET=lovisual-avatars S3_ACCESS_KEY=minioadmin S3_SECRET_KEY=minioadmin +# Optional browser-facing avatar prefix (e.g. https://visual.loki-code.dev/api/media). +# Leave empty for local dev: avatars then resolve to / directly. +AVATAR_PUBLIC_BASE_URL= # Shared secret between gateway and internal services (openssl rand -hex 32) INTERNAL_KEY= # Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev diff --git a/backend/accounts-service/src/avatars/handlers.rs b/backend/accounts-service/src/avatars/handlers.rs index 20b8c89..8c783f4 100644 --- a/backend/accounts-service/src/avatars/handlers.rs +++ b/backend/accounts-service/src/avatars/handlers.rs @@ -4,7 +4,9 @@ use crate::avatars::storage::S3Storage; use crate::error::AppError; use axum::{ Json, - extract::{Multipart, State}, + extract::{Multipart, Path, State}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, }; use common::internal::GatewayIdentity; use serde::Serialize; @@ -67,3 +69,50 @@ pub async fn upload( avatar_url: format!("{}/{key}", state.base_url), })) } + +/// Only serve the keys this service writes: `avatars/.png`. +/// Anything else (traversal, other buckets/paths) is a 404. The gateway blocks +/// dot-segments upstream, but the service validates independently. +fn is_serveable_key(key: &str) -> bool { + key.starts_with("avatars/") + && key.ends_with(".png") + && !key.contains("..") + && key.matches('/').count() == 1 +} + +/// Public avatar read: streams the object out of private MinIO. Placed behind +/// the gateway's internal-key guard like every other route, but deliberately +/// takes no `GatewayIdentity` so anonymous profile pages can load avatars. +pub async fn serve(State(state): State, Path(key): Path) -> Response { + if !is_serveable_key(&key) { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + match state.storage.get(&key).await { + Ok(bytes) => ( + [ + (header::CONTENT_TYPE, "image/png"), + (header::CACHE_CONTROL, "public, max-age=300"), + ], + bytes, + ) + .into_response(), + Err(err) => { + tracing::warn!("avatar serve miss for {key}: {err}"); + (StatusCode::NOT_FOUND, "not found").into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::is_serveable_key; + + #[test] + fn serves_only_the_expected_avatar_key_shape() { + assert!(is_serveable_key("avatars/6a7c.png")); + assert!(!is_serveable_key("avatars/a/../b.png")); + assert!(!is_serveable_key("avatars/nested/deep.png")); + assert!(!is_serveable_key("secrets/token.png")); + assert!(!is_serveable_key("avatars/nope.jpg")); + } +} diff --git a/backend/accounts-service/src/avatars/storage.rs b/backend/accounts-service/src/avatars/storage.rs index 11ecd27..1a33bc5 100644 --- a/backend/accounts-service/src/avatars/storage.rs +++ b/backend/accounts-service/src/avatars/storage.rs @@ -37,4 +37,18 @@ impl S3Storage { .await?; Ok(()) } + + /// Reads a stored object back. Used to serve avatars through the service so + /// MinIO itself never has to be exposed to browsers. + pub async fn get(&self, key: &str) -> anyhow::Result> { + let out = self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .send() + .await?; + let aggregated = out.body.collect().await?; + Ok(aggregated.into_bytes().to_vec()) + } } diff --git a/backend/accounts-service/src/config.rs b/backend/accounts-service/src/config.rs index 423467a..aa86dce 100644 --- a/backend/accounts-service/src/config.rs +++ b/backend/accounts-service/src/config.rs @@ -12,6 +12,11 @@ pub struct Config { pub s3_access_key: String, pub s3_secret_key: String, pub cookie_secure: bool, + /// Browser-reachable prefix for stored avatars. When empty, avatars fall + /// back to the internal `/` (dev, where MinIO is + /// port-forwarded). Production sets this to the same-origin `/api/media` + /// path served back through the gateway, keeping MinIO private. + pub avatar_public_base_url: String, } impl Config { @@ -35,6 +40,7 @@ impl Config { cookie_secure: std::env::var("COOKIE_SECURE") .map(|v| v != "false") .unwrap_or(true), + avatar_public_base_url: std::env::var("AVATAR_PUBLIC_BASE_URL").unwrap_or_default(), }) } } @@ -54,8 +60,13 @@ impl Config { Ok(()) } - /// Public prefix of stored avatars: `/`. + /// Public prefix of stored avatars. Prefers the browser-facing + /// `AVATAR_PUBLIC_BASE_URL`; otherwise `/` (dev). pub fn avatar_base_url(&self) -> String { + let public = self.avatar_public_base_url.trim().trim_end_matches('/'); + if !public.is_empty() { + return public.to_string(); + } format!( "{}/{}", self.s3_endpoint.trim_end_matches('/'), @@ -80,6 +91,7 @@ mod tests { s3_access_key: String::new(), s3_secret_key: String::new(), cookie_secure: false, + avatar_public_base_url: String::new(), } } @@ -107,4 +119,16 @@ mod tests { cfg.s3_bucket = "avatars".into(); assert_eq!(cfg.avatar_base_url(), "http://localhost:9000/avatars"); } + + #[test] + fn avatar_base_url_prefers_public_base_when_set() { + let mut cfg = config_with_secret(&"x".repeat(32)); + cfg.s3_endpoint = "http://minio:9000".into(); + cfg.s3_bucket = "lovisual-avatars".into(); + cfg.avatar_public_base_url = "https://visual.loki-code.dev/api/media/".into(); + assert_eq!( + cfg.avatar_base_url(), + "https://visual.loki-code.dev/api/media" + ); + } } diff --git a/backend/accounts-service/src/lib.rs b/backend/accounts-service/src/lib.rs index 825e32e..384fd4e 100644 --- a/backend/accounts-service/src/lib.rs +++ b/backend/accounts-service/src/lib.rs @@ -58,6 +58,9 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router { let avatar_routes = Router::new() .route("/avatars", post(avatars::handlers::upload)) + // Public avatar read served back through the gateway; the `{*key}` + // wildcard is the storage key (`avatars/.png`). + .route("/media/{*key}", get(avatars::handlers::serve)) // Hard transport cap slightly above the 5 MB business limit (413 beyond it). .layer(DefaultBodyLimit::max(6 * 1024 * 1024)) .with_state(avatar_state); diff --git a/backend/docker-compose.prod.yml b/backend/docker-compose.prod.yml index b6d2fbb..f955079 100644 --- a/backend/docker-compose.prod.yml +++ b/backend/docker-compose.prod.yml @@ -73,6 +73,9 @@ services: environment: DATABASE_URL: postgres://lovisual:${POSTGRES_PASSWORD}@postgres:5432/accounts_db S3_ENDPOINT: http://minio:9000 + # Browser-facing avatar prefix. Served same-origin through the site's + # /api proxy -> gateway -> accounts-service, so MinIO stays private. + AVATAR_PUBLIC_BASE_URL: https://visual.loki-code.dev/api/media PORT: 8081 GRPC_PORT: 50051 networks: [lovisual-internal] diff --git a/backend/gateway/src/proxy/routes.rs b/backend/gateway/src/proxy/routes.rs index ed329ef..6a2598b 100644 --- a/backend/gateway/src/proxy/routes.rs +++ b/backend/gateway/src/proxy/routes.rs @@ -6,7 +6,7 @@ pub enum Upstream { pub fn upstream_for(path: &str) -> Option { match path.trim_start_matches('/').split('/').next()? { - "auth" | "device" | "avatars" | "me" | "users" => Some(Upstream::Accounts), + "auth" | "device" | "avatars" | "media" | "me" | "users" => Some(Upstream::Accounts), "configs" | "showcase" => Some(Upstream::Configs), _ => None, } @@ -20,6 +20,10 @@ mod tests { fn routes_by_first_segment_only() { assert_eq!(upstream_for("/auth/login"), Some(Upstream::Accounts)); assert_eq!(upstream_for("/me"), Some(Upstream::Accounts)); + assert_eq!( + upstream_for("/media/avatars/abc.png"), + Some(Upstream::Accounts) + ); assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs)); assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs)); assert_eq!(upstream_for("/authx"), None);