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
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:
parent
cd695514fe
commit
80acfdd64f
3 changed files with 105 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue