use crate::AppState;
use crate::api::models::users::CurrentUser;
use crate::errors::{Error, Result};
use crate::image_normalizer::{ImageToken, TokenParseError};
use axum::{
extract::{Path, State},
http::{HeaderValue, StatusCode, header::LOCATION},
response::{IntoResponse, Response},
};
use sqlx_pool_router::PoolProvider;
use std::time::Duration;
use tracing::warn;
#[tracing::instrument(skip_all)]
pub async fn get_image<P: PoolProvider + Clone + Send + Sync>(
State(state): State<AppState<P>>,
current_user: CurrentUser,
Path(sha256_hex): Path<String>,
) -> Result<Response> {
let token: ImageToken = sha256_hex.parse().map_err(|e: TokenParseError| Error::BadRequest {
message: format!("invalid image hash: {e}"),
})?;
let config = state.current_config();
if !config.image_normalizer.enabled {
return Err(Error::NotFound {
resource: "image".to_string(),
id: sha256_hex,
});
}
let sha_bytes: Vec<u8> = token.0.to_vec();
let mut conn = state.db.write().acquire().await.map_err(|e| Error::Database(e.into()))?;
let authorized = is_authorized_to_view(&mut conn, &sha_bytes, current_user.id, current_user.active_organization)
.await
.map_err(|e: sqlx::Error| Error::Database(e.into()))?;
drop(conn);
if !authorized {
return Err(Error::NotFound {
resource: "image".to_string(),
id: sha256_hex,
});
}
let ttl = Duration::from_secs(config.image_normalizer.signing.dashboard_ttl_secs);
let signed = state.image_normalizer.sign(token, ttl).await.map_err(|e| {
warn!(error = %e, "image_normalizer.sign failed for dashboard view");
Error::Internal {
operation: format!("image signing failed: {e}"),
}
})?;
let mut response = (StatusCode::FOUND, "").into_response();
response.headers_mut().insert(
LOCATION,
HeaderValue::from_str(&signed.url).map_err(|e| Error::Internal {
operation: format!("invalid signed URL: {e}"),
})?,
);
response
.headers_mut()
.insert(axum::http::header::CACHE_CONTROL, HeaderValue::from_static("no-store, private"));
Ok(response)
}
#[derive(Debug, Clone, Copy)]
pub struct ImageAttribution {
pub user_id: uuid::Uuid,
pub organization_id: Option<uuid::Uuid>,
}
pub async fn resolve_image_attribution(pool: &sqlx::PgPool, api_key: &str) -> Option<ImageAttribution> {
let row = sqlx::query!(
r#"
SELECT created_by AS "created_by!", user_id AS "user_id!"
FROM api_keys
WHERE secret = $1 AND is_deleted = FALSE
LIMIT 1
"#,
api_key,
)
.fetch_optional(pool)
.await
.ok()??;
let organization_id = (row.created_by != row.user_id).then_some(row.user_id);
Some(ImageAttribution {
user_id: row.created_by,
organization_id,
})
}
pub async fn record_image_access(pool: &sqlx::PgPool, attribution: ImageAttribution, token: ImageToken, mime: &str, bytes_len: u64) {
let sha_bytes: Vec<u8> = token.0.to_vec();
let bytes_len_i64 = bytes_len as i64;
if let Err(e) = sqlx::query!(
r#"
INSERT INTO image_access (user_id, organization_id, sha256, mime, bytes_len, first_seen_at, last_seen_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (user_id, sha256) DO UPDATE
SET last_seen_at = NOW(),
mime = EXCLUDED.mime,
bytes_len = EXCLUDED.bytes_len,
organization_id = COALESCE(EXCLUDED.organization_id, image_access.organization_id)
"#,
attribution.user_id,
attribution.organization_id,
sha_bytes,
mime,
bytes_len_i64,
)
.execute(pool)
.await
{
warn!(error = %e, "failed to record image_access row (non-fatal)");
}
}
async fn is_authorized_to_view(
conn: &mut sqlx::PgConnection,
sha256: &[u8],
viewer: uuid::Uuid,
active_org: Option<uuid::Uuid>,
) -> std::result::Result<bool, sqlx::Error> {
let row = sqlx::query!(
r#"
SELECT 1 AS "exists!"
FROM image_access
WHERE sha256 = $1
AND (user_id = $2 OR organization_id = $3)
LIMIT 1
"#,
sha256,
viewer,
active_org,
)
.fetch_optional(conn)
.await?;
Ok(row.is_some())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::models::api_keys::ApiKeyCreate;
use crate::api::models::users::Role;
use crate::db::handlers::Organizations;
use crate::db::handlers::api_keys::ApiKeys;
use crate::db::handlers::repository::Repository;
use crate::db::models::api_keys::{ApiKeyCreateDBRequest, ApiKeyPurpose};
use crate::test::utils::{create_test_api_key_for_user, create_test_org, create_test_user};
use sqlx::PgPool;
fn token(b: u8) -> ImageToken {
ImageToken([b; 32])
}
async fn can_view(pool: &PgPool, tok: ImageToken, viewer: uuid::Uuid, active_org: Option<uuid::Uuid>) -> bool {
let mut conn = pool.acquire().await.unwrap();
is_authorized_to_view(&mut conn, &tok.0.to_vec(), viewer, active_org).await.unwrap()
}
async fn create_org_api_key(pool: &PgPool, org_id: uuid::Uuid, member_id: uuid::Uuid) -> String {
let mut conn = pool.acquire().await.unwrap();
let create = ApiKeyCreate {
name: format!("Org key {}", uuid::Uuid::new_v4().simple()),
description: None,
purpose: ApiKeyPurpose::Realtime,
requests_per_second: None,
burst_size: None,
member_id: None,
};
let req = ApiKeyCreateDBRequest::new(org_id, member_id, create);
ApiKeys::new(&mut conn).create(&req).await.unwrap().secret
}
#[sqlx::test]
async fn personal_image_is_private_org_image_is_org_visible(pool: PgPool) {
let p = create_test_user(&pool, Role::StandardUser).await; let q = create_test_user(&pool, Role::StandardUser).await; let org = create_test_org(&pool, p.id).await; {
let mut conn = pool.acquire().await.unwrap();
Organizations::new(&mut conn).add_member(org.id, q.id, "member").await.unwrap();
}
let personal = token(1);
let org_img = token(2);
record_image_access(
&pool,
ImageAttribution {
user_id: p.id,
organization_id: None,
},
personal,
"image/png",
10,
)
.await;
record_image_access(
&pool,
ImageAttribution {
user_id: q.id,
organization_id: Some(org.id),
},
org_img,
"image/png",
20,
)
.await;
assert!(can_view(&pool, personal, p.id, None).await, "P views own personal image");
assert!(
can_view(&pool, personal, p.id, Some(org.id)).await,
"P views own personal even in org context"
);
assert!(!can_view(&pool, personal, q.id, None).await, "Q cannot view P's personal image");
assert!(
!can_view(&pool, personal, q.id, Some(org.id)).await,
"a personal image must never be visible via the org"
);
assert!(
can_view(&pool, org_img, q.id, None).await,
"submitter sees own org image without org context too"
);
assert!(
can_view(&pool, org_img, q.id, Some(org.id)).await,
"submitter sees own org image in org context"
);
assert!(
can_view(&pool, org_img, p.id, Some(org.id)).await,
"another org member (in org context) sees it"
);
assert!(
!can_view(&pool, org_img, p.id, None).await,
"org member WITHOUT org context does not see it"
);
let outsider = create_test_user(&pool, Role::StandardUser).await;
assert!(
!can_view(&pool, org_img, outsider.id, None).await,
"a non-member never sees the org image"
);
}
#[sqlx::test]
async fn resolve_attribution_distinguishes_personal_and_org_keys(pool: PgPool) {
let person = create_test_user(&pool, Role::StandardUser).await;
let personal_key = create_test_api_key_for_user(&pool, person.id).await;
let attr = resolve_image_attribution(&pool, &personal_key.secret).await.expect("known key");
assert_eq!(attr.user_id, person.id);
assert_eq!(attr.organization_id, None);
let member = create_test_user(&pool, Role::StandardUser).await;
let org = create_test_org(&pool, member.id).await;
let org_secret = create_org_api_key(&pool, org.id, member.id).await;
let attr = resolve_image_attribution(&pool, &org_secret).await.expect("known org key");
assert_eq!(attr.user_id, member.id, "acting user is the member (created_by)");
assert_eq!(attr.organization_id, Some(org.id), "org is the key owner (user_id)");
assert!(resolve_image_attribution(&pool, "sk-does-not-exist").await.is_none());
}
}