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:
parent
fd2e29cede
commit
b496bde701
7 changed files with 103 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue