feat(backend): serve avatars through accounts-service behind the gateway

accounts-service returned avatar_url built from the internal S3_ENDPOINT
(http://minio:9000/<bucket>), which browsers cannot resolve, so avatars never
rendered and the re-crop fetch failed. MinIO is intentionally not exposed.

Add a public GET /media/{key} read route in accounts-service (behind the
gateway internal-key guard, no identity required so anonymous profile pages
work) that streams the object out of private MinIO. Introduce
AVATAR_PUBLIC_BASE_URL so the browser-facing prefix is decoupled from the
internal endpoint; prod sets it to the same-origin /api/media, gateway routes
the media segment to accounts.
This commit is contained in:
loki5512344 2026-09-29 10:14:18 +02:00
parent fd2e29cede
commit b496bde701
Signed by: boba
GPG key ID: 253067914055423B
7 changed files with 103 additions and 3 deletions

View file

@ -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 <S3_ENDPOINT>/<S3_BUCKET> 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

View file

@ -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/<account-uuid>.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<AvatarState>, Path(key): Path<String>) -> 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"));
}
}

View file

@ -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<Vec<u8>> {
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())
}
}

View file

@ -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 `<s3_endpoint>/<bucket>` (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: `<endpoint>/<bucket>`.
/// Public prefix of stored avatars. Prefers the browser-facing
/// `AVATAR_PUBLIC_BASE_URL`; otherwise `<endpoint>/<bucket>` (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"
);
}
}

View file

@ -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/<uuid>.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);

View file

@ -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]

View file

@ -6,7 +6,7 @@ pub enum Upstream {
pub fn upstream_for(path: &str) -> Option<Upstream> {
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);