Add presigned document URLs to stop leaking permanent Backblaze links
All checks were successful
build-and-release / build (employees) (push) Successful in 1m34s
build-and-release / build (catering-services) (push) Successful in 1m43s
build-and-release / build (cron) (push) Successful in 1m59s
build-and-release / build (companies) (push) Successful in 2m4s
build-and-release / build (customers) (push) Successful in 2m26s
build-and-release / build (developers) (push) Successful in 2m38s
build-and-release / build (fitness-trainers) (push) Successful in 1m31s
build-and-release / build (gateway) (push) Successful in 1m29s
build-and-release / build (jobs) (push) Successful in 1m9s
build-and-release / build (graphic-designers) (push) Successful in 2m19s
build-and-release / build (job-seekers) (push) Successful in 2m16s
build-and-release / build (photographers) (push) Successful in 1m43s
build-and-release / build (makeup-artists) (push) Successful in 3m3s
build-and-release / build (social-media-managers) (push) Successful in 2m36s
build-and-release / build (ugc-content-creators) (push) Successful in 2m46s
build-and-release / build (payments) (push) Successful in 4m15s
build-and-release / build (video-editors) (push) Successful in 2m53s
build-and-release / build (tutors) (push) Successful in 4m24s
build-and-release / build (users) (push) Successful in 7m40s

Verification documents were being stored/rendered as permanent, unsigned
B2 URLs across every role's admin review and self-service dashboard.
Add StorageClient::presign() plus two mediating endpoints (admin and
self-service) so viewers always get a short-lived signed URL instead of
the raw storage link.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-07-27 18:07:15 +05:30
parent cd695514fe
commit 80acfdd64f
3 changed files with 105 additions and 0 deletions

View file

@ -20,6 +20,7 @@ pub fn router() -> Router<AppState> {
.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<AppState> {
@ -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<AppState>,
Json(payload): Json<PresignOwnDocumentPayload>,
) -> impl IntoResponse {
let owns: Option<Uuid> = 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,

View file

@ -66,6 +66,7 @@ async fn create_approval_request_from_verification(
pub fn router() -> Router<AppState> {
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<AppState> {
.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<AppState>,
Json(payload): Json<PresignPayload>,
) -> 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<String>,

View file

@ -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<String> {
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 };