diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 5ede67a..c3f8bfc 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -20,6 +20,7 @@ pub fn router() -> Router { .route("/", get(get_profile).patch(save_profile)) .route("/submit-for-verification", post(submit_for_verification)) .route("/photo", post(upload_photo)) + .route("/documents/presign", post(presign_own_document)) } pub fn me_router() -> Router { @@ -597,6 +598,47 @@ async fn submit_for_verification( } } +#[derive(Deserialize)] +pub struct PresignOwnDocumentPayload { + pub url: String, +} + +/// POST /api/profile/documents/presign +/// Mints a short-lived signed URL for a document the caller themselves submitted +/// (found via any of their own verification submissions), so their own dashboard +/// never has to render or link to the permanent storage URL directly. +async fn presign_own_document( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let owns: Option = sqlx::query_scalar( + r#" + SELECT id FROM verifications + WHERE user_id = $1 + AND (payload::text LIKE '%' || $2 || '%' OR documents::text LIKE '%' || $2 || '%') + LIMIT 1 + "#, + ) + .bind(auth.user_id) + .bind(&payload.url) + .fetch_optional(&state.pool) + .await + .unwrap_or(None); + + if owns.is_none() { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Document not found for this account" }))).into_response(); + } + + match state.storage.presign(&payload.url, std::time::Duration::from_secs(300)).await { + Ok(url) => (StatusCode::OK, Json(serde_json::json!({ "url": url, "expires_in": 300 }))).into_response(), + Err(e) => { + tracing::error!("Failed to presign own document URL: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to generate view URL" }))).into_response() + } + } +} + /// GET /api/me/verification-status?roleKey=PHOTOGRAPHER pub async fn verification_status( auth: AuthUser, diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index 2747e3a..9a7f999 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -66,6 +66,7 @@ async fn create_approval_request_from_verification( pub fn router() -> Router { Router::new() .route("/", get(list_verifications)) + .route("/presign", post(presign_document)) .route("/{id}", get(get_verification)) .route("/{id}/approve", post(approve_verification)) .route("/{id}/reject", post(reject_verification)) @@ -74,6 +75,33 @@ pub fn router() -> Router { .route("/{id}/request-revision", post(request_revision)) } +#[derive(Deserialize)] +pub struct PresignPayload { + pub url: String, +} + +/// POST /api/admin/verifications/presign +/// Mints a short-lived signed URL for a document/image previously submitted in a +/// verification's profile_data, so the admin panel never has to render or link to +/// the permanent storage URL directly. +async fn presign_document( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + if let Err(e) = require_admin(&auth) { + return e.into_response(); + } + + match state.storage.presign(&payload.url, std::time::Duration::from_secs(300)).await { + Ok(url) => (StatusCode::OK, Json(serde_json::json!({ "url": url, "expires_in": 300 }))).into_response(), + Err(e) => { + tracing::error!("Failed to presign document URL: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to generate view URL" }))).into_response() + } + } +} + #[derive(Deserialize)] pub struct ListQuery { pub status: Option, diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index bb2dfed..465b57a 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -15,8 +15,10 @@ use aws_config::{BehaviorVersion, Region}; use aws_credential_types::Credentials; use aws_sdk_s3::Client; use aws_sdk_s3::config::{Builder as S3ConfigBuilder, SharedCredentialsProvider}; +use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::primitives::ByteStream; use bytes::Bytes; +use std::time::Duration; use uuid::Uuid; #[derive(Clone)] @@ -99,6 +101,39 @@ impl StorageClient { Ok(format!("{}/{}", self.public_base_url, key)) } + /// Derive the object key from a previously-stored full public URL, or pass a bare + /// key straight through unchanged. + fn key_from_url(&self, stored: &str) -> String { + let prefix = format!("{}/", self.public_base_url); + stored.strip_prefix(prefix.as_str()).unwrap_or(stored).to_string() + } + + /// Mint a short-lived, signed URL for reading an object that was previously + /// uploaded via `upload()`. `stored` is whatever `upload()` returned (a full + /// public URL) or a bare object key. + /// + /// Callers must never persist or forward the plain stored URL/key directly to a + /// browser as a working link — always mediate access through this method so the + /// resulting URL expires and requires no reliance on the bucket being public. + pub async fn presign(&self, stored: &str, ttl: Duration) -> Result { + let key = self.key_from_url(stored); + + let Some(client) = &self.client else { + // MOCK_STORAGE=true / no client configured — nothing real to sign against. + return Ok(stored.to_string()); + }; + + let presigned = client + .get_object() + .bucket(&self.bucket) + .key(&key) + .presigned(PresigningConfig::expires_in(ttl).context("invalid presign expiry")?) + .await + .context("failed to presign B2 object")?; + + Ok(presigned.uri().to_string()) + } + /// Delete a file by its full public URL (best-effort — logs on failure). pub async fn delete_by_url(&self, url: &str) { let Some(client) = &self.client else { return };