From f357032ee31c2464bee8ba63eb0b3fbd2040c2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Fri, 13 Nov 2026 10:12:49 +0100 Subject: [PATCH] List attachments in sync with a stable URL Sync and the cipher responses list each attachment with an unsigned URL of upstream's form, which no longer changes on every sync nor costs a presign per attachment on S3, and which isn't served without a token. The attachment routes sign it: the cipher route and its admin variant, and the emergency access view's route the web vault downloads through, which is added. --- src/api/core/ciphers.rs | 4 ++- src/api/core/emergency_access.rs | 46 ++++++++++++++++++++++++++++++-- src/db/models/attachment.rs | 25 ++++++++++++++--- src/db/models/cipher.rs | 4 +-- 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 410c7ab5..54c4b444 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -1581,7 +1581,9 @@ async fn get_attachment_data( } match Attachment::find_by_id(&attachment_id, &conn).await { - Some(attachment) if cipher_id == attachment.cipher_uuid => Ok(Json(attachment.to_json(&headers.host).await?)), + Some(attachment) if cipher_id == attachment.cipher_uuid => { + Ok(Json(attachment.to_json_for_download(&headers.host).await?)) + } Some(_) => err!("Attachment doesn't belong to cipher"), None => err!("Attachment doesn't exist"), } diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 773697e5..b2d8fb59 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -15,8 +15,9 @@ use crate::{ db::{ DbConn, DbPool, models::{ - Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, - EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, + Attachment, AttachmentId, Cipher, CipherId, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, + EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, Membership, MembershipType, + OrgPolicy, TwoFactor, User, UserId, }, }, mail, @@ -42,6 +43,7 @@ pub fn routes() -> Vec { takeover_emergency_access, password_emergency_access, view_emergency_access, + get_emergency_access_attachment, policies_emergency_access, ] } @@ -616,6 +618,46 @@ async fn view_emergency_access(emer_id: EmergencyAccessId, headers: Headers, con }))) } +/// An attachment of the grantor's for a grantee with approved view access, with a `url` to download it from: the view +/// lists the attachments with one that only names the file. Only the grantor's own ciphers, the ones the view lists. +/// +/// Ref: +#[get("/emergency-access///attachment/")] +async fn get_emergency_access_attachment( + emer_id: EmergencyAccessId, + cipher_id: CipherId, + attachment_id: AttachmentId, + headers: Headers, + conn: DbConn, +) -> JsonResult { + check_emergency_access_enabled()?; + + let Some(emergency_access) = + EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &headers.user.uuid, &conn).await + else { + err!("Emergency access not valid.") + }; + + if !is_valid_request(&emergency_access, &headers.user.uuid, EmergencyAccessType::View) { + err!("Emergency access not valid.") + } + + let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { + err!("Cipher doesn't exist") + }; + if cipher.user_uuid.as_ref() != Some(&emergency_access.grantor_uuid) || cipher.organization_uuid.is_some() { + err!("Cipher is not accessible") + } + + match Attachment::find_by_id(&attachment_id, &conn).await { + Some(attachment) if attachment.cipher_uuid == cipher_id => { + Ok(Json(attachment.to_json_for_download(&headers.host).await?)) + } + Some(_) => err!("Attachment doesn't belong to cipher"), + None => err!("Attachment doesn't exist"), + } +} + #[post("/emergency-access//takeover")] async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, conn: DbConn) -> JsonResult { check_emergency_access_enabled()?; diff --git a/src/db/models/attachment.rs b/src/db/models/attachment.rs index 659648d0..52d6d207 100644 --- a/src/db/models/attachment.rs +++ b/src/db/models/attachment.rs @@ -65,16 +65,33 @@ impl Attachment { } } - pub async fn to_json(&self, host: &str) -> Result { - Ok(json!({ + /// The attachment as sync and the cipher responses list it. Its `url` is a leftover from the old download flow. + /// Clients get a signed URL from the attachment routes (`to_json_for_download`) instead, only the web vault and the + /// CLI read this one, as a fallback when those routes answer 404. + /// + /// Ref: + pub fn to_json(&self, host: &str) -> Value { + self.json_with_url(&format!("{host}/attachments/{}/{}", self.cipher_uuid, self.id)) + } + + /// The attachment with a `url` the client can download it from for a short while, for the attachment routes + /// (`GET /ciphers/{id}/attachment/{attachmentId}`, its admin variant, the emergency access one). + /// + /// Ref: + pub async fn to_json_for_download(&self, host: &str) -> Result { + Ok(self.json_with_url(&self.get_url(host).await?)) + } + + fn json_with_url(&self, url: &str) -> Value { + json!({ "id": self.id, - "url": self.get_url(host).await?, + "url": url, "fileName": self.file_name, "size": self.file_size.to_string(), "sizeName": crate::util::get_display_size(self.file_size), "key": self.akey, "object": "attachment" - })) + }) } } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 68f97c13..97807279 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -270,7 +270,7 @@ impl Cipher { { let mut attachments_json_vec = vec![]; for attachment in attachments { - attachments_json_vec.push(attachment.to_json(host).await?); + attachments_json_vec.push(attachment.to_json(host)); } attachments_json = Value::Array(attachments_json_vec); } @@ -279,7 +279,7 @@ impl Cipher { if !attachments.is_empty() { let mut attachments_json_vec = vec![]; for attachment in attachments { - attachments_json_vec.push(attachment.to_json(host).await?); + attachments_json_vec.push(attachment.to_json(host)); } attachments_json = Value::Array(attachments_json_vec); }