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_BUCKET=lovisual-avatars
|
||||||
S3_ACCESS_KEY=minioadmin
|
S3_ACCESS_KEY=minioadmin
|
||||||
S3_SECRET_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)
|
# Shared secret between gateway and internal services (openssl rand -hex 32)
|
||||||
INTERNAL_KEY=
|
INTERNAL_KEY=
|
||||||
# Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev
|
# 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 crate::error::AppError;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Multipart, State},
|
extract::{Multipart, Path, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use common::internal::GatewayIdentity;
|
use common::internal::GatewayIdentity;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
@ -67,3 +69,50 @@ pub async fn upload(
|
||||||
avatar_url: format!("{}/{key}", state.base_url),
|
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?;
|
.await?;
|
||||||
Ok(())
|
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_access_key: String,
|
||||||
pub s3_secret_key: String,
|
pub s3_secret_key: String,
|
||||||
pub cookie_secure: bool,
|
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 {
|
impl Config {
|
||||||
|
|
@ -35,6 +40,7 @@ impl Config {
|
||||||
cookie_secure: std::env::var("COOKIE_SECURE")
|
cookie_secure: std::env::var("COOKIE_SECURE")
|
||||||
.map(|v| v != "false")
|
.map(|v| v != "false")
|
||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
|
avatar_public_base_url: std::env::var("AVATAR_PUBLIC_BASE_URL").unwrap_or_default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,8 +60,13 @@ impl Config {
|
||||||
Ok(())
|
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 {
|
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!(
|
format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
self.s3_endpoint.trim_end_matches('/'),
|
self.s3_endpoint.trim_end_matches('/'),
|
||||||
|
|
@ -80,6 +91,7 @@ mod tests {
|
||||||
s3_access_key: String::new(),
|
s3_access_key: String::new(),
|
||||||
s3_secret_key: String::new(),
|
s3_secret_key: String::new(),
|
||||||
cookie_secure: false,
|
cookie_secure: false,
|
||||||
|
avatar_public_base_url: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,4 +119,16 @@ mod tests {
|
||||||
cfg.s3_bucket = "avatars".into();
|
cfg.s3_bucket = "avatars".into();
|
||||||
assert_eq!(cfg.avatar_base_url(), "http://localhost:9000/avatars");
|
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()
|
let avatar_routes = Router::new()
|
||||||
.route("/avatars", post(avatars::handlers::upload))
|
.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).
|
// Hard transport cap slightly above the 5 MB business limit (413 beyond it).
|
||||||
.layer(DefaultBodyLimit::max(6 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(6 * 1024 * 1024))
|
||||||
.with_state(avatar_state);
|
.with_state(avatar_state);
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://lovisual:${POSTGRES_PASSWORD}@postgres:5432/accounts_db
|
DATABASE_URL: postgres://lovisual:${POSTGRES_PASSWORD}@postgres:5432/accounts_db
|
||||||
S3_ENDPOINT: http://minio:9000
|
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
|
PORT: 8081
|
||||||
GRPC_PORT: 50051
|
GRPC_PORT: 50051
|
||||||
networks: [lovisual-internal]
|
networks: [lovisual-internal]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ pub enum Upstream {
|
||||||
|
|
||||||
pub fn upstream_for(path: &str) -> Option<Upstream> {
|
pub fn upstream_for(path: &str) -> Option<Upstream> {
|
||||||
match path.trim_start_matches('/').split('/').next()? {
|
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),
|
"configs" | "showcase" => Some(Upstream::Configs),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +20,10 @@ mod tests {
|
||||||
fn routes_by_first_segment_only() {
|
fn routes_by_first_segment_only() {
|
||||||
assert_eq!(upstream_for("/auth/login"), Some(Upstream::Accounts));
|
assert_eq!(upstream_for("/auth/login"), Some(Upstream::Accounts));
|
||||||
assert_eq!(upstream_for("/me"), 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("/configs/shared/ABC"), Some(Upstream::Configs));
|
||||||
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
|
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
|
||||||
assert_eq!(upstream_for("/authx"), None);
|
assert_eq!(upstream_for("/authx"), None);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue