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("/", get(get_profile).patch(save_profile))
|
||||||
.route("/submit-for-verification", post(submit_for_verification))
|
.route("/submit-for-verification", post(submit_for_verification))
|
||||||
.route("/photo", post(upload_photo))
|
.route("/photo", post(upload_photo))
|
||||||
|
.route("/documents/presign", post(presign_own_document))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn me_router() -> Router<AppState> {
|
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
|
/// GET /api/me/verification-status?roleKey=PHOTOGRAPHER
|
||||||
pub async fn verification_status(
|
pub async fn verification_status(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ async fn create_approval_request_from_verification(
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_verifications))
|
.route("/", get(list_verifications))
|
||||||
|
.route("/presign", post(presign_document))
|
||||||
.route("/{id}", get(get_verification))
|
.route("/{id}", get(get_verification))
|
||||||
.route("/{id}/approve", post(approve_verification))
|
.route("/{id}/approve", post(approve_verification))
|
||||||
.route("/{id}/reject", post(reject_verification))
|
.route("/{id}/reject", post(reject_verification))
|
||||||
|
|
@ -74,6 +75,33 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/{id}/request-revision", post(request_revision))
|
.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)]
|
#[derive(Deserialize)]
|
||||||
pub struct ListQuery {
|
pub struct ListQuery {
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@ use aws_config::{BehaviorVersion, Region};
|
||||||
use aws_credential_types::Credentials;
|
use aws_credential_types::Credentials;
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::config::{Builder as S3ConfigBuilder, SharedCredentialsProvider};
|
use aws_sdk_s3::config::{Builder as S3ConfigBuilder, SharedCredentialsProvider};
|
||||||
|
use aws_sdk_s3::presigning::PresigningConfig;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use std::time::Duration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -99,6 +101,39 @@ impl StorageClient {
|
||||||
Ok(format!("{}/{}", self.public_base_url, key))
|
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).
|
/// Delete a file by its full public URL (best-effort — logs on failure).
|
||||||
pub async fn delete_by_url(&self, url: &str) {
|
pub async fn delete_by_url(&self, url: &str) {
|
||||||
let Some(client) = &self.client else { return };
|
let Some(client) = &self.client else { return };
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue