diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 75c69a68..7234c2da 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -31,7 +31,7 @@ use crate::{ use super::{ folders::FolderData, - sends::{SIZE_525_MB, send_visible}, + sends::{SIZE_525_MB, send_visible_to}, }; pub fn routes() -> Vec { @@ -130,7 +130,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option=2024.12.0").unwrap(); ver_match.matches(&client_version.0) } else { @@ -162,7 +162,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = Send::find_by_user(&headers.user.uuid, &conn) .await .iter() - .filter(|s| send_visible(s)) + .filter(|s| send_visible_to(s, &headers.device, client_version.as_ref())) .map(Send::to_json) .collect(); diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index f04b05fc..b6a737a1 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -21,7 +21,7 @@ use rocket::{Catcher, Route, serde::json::Json, serde::json::Value}; use crate::{ CONFIG, api::{EmptyResult, JsonResult, Notify, UpdateType}, - auth::Headers, + auth::{ClientVersion, Headers}, db::{ DbConn, models::{Membership, MembershipStatus, OrgPolicy, Organization, User}, @@ -208,7 +208,8 @@ fn get_api_webauthn(_headers: Headers) -> Json { } #[get("/config")] -fn config() -> Json { +#[expect(clippy::needless_pass_by_value, reason = "Rocket request guards are passed by value")] +fn config(client_version: Option) -> Json { let domain = CONFIG.domain(); // Official available feature flags can be found here: // Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135 @@ -220,6 +221,10 @@ fn config() -> Json { &FeatureFlagFilter::ValidOnly, ); feature_states.insert("pm-19148-innovation-archive".to_owned(), true); + // Older clients with only part of the item sharing feature could create Item Sends they can't handle + if !sends::client_version_supports_item_sends(client_version.as_ref()) { + feature_states.remove(sends::ITEM_SENDS_FEATURE_FLAG); + } Json(json!({ // Note: The clients use this version to handle backwards compatibility concerns diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 76c8273c..8922aca6 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, - auth::{Headers, Host, SendHeaders}, + auth::{ClientVersion, Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, @@ -174,7 +174,7 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { Ok(send) } -const ITEM_SENDS_FEATURE_FLAG: &str = "pm-34203-temporary-item-sharing"; +pub const ITEM_SENDS_FEATURE_FLAG: &str = "pm-34203-temporary-item-sharing"; // Clients can only decrypt (and rotate the keys of) Item Sends through the SDK const SDK_SENDS_FEATURE_FLAG: &str = "pm-30110-sdk-sends-api"; @@ -188,11 +188,28 @@ pub fn item_sends_enabled() -> bool { flags.contains_key(ITEM_SENDS_FEATURE_FLAG) && flags.contains_key(SDK_SENDS_FEATURE_FLAG) } -/// Whether a Send can be shown to its owner or a recipient, Item Sends are hidden while the feature is disabled. +/// Whether a Send can be accessed by a recipient, Item Sends are hidden while the feature is disabled. pub fn send_visible(send: &Send) -> bool { send.atype != SendType::Item as i32 || item_sends_enabled() } +/// Clients able to handle Item Sends. The mobile apps and the CLI don't know the type (an unknown Send type +/// breaks the whole Android sync), the other clients support it since 2026.10.0. +pub fn client_supports_item_sends(device: &Device, client_version: Option<&ClientVersion>) -> bool { + !device.is_mobile() && !device.is_cli() && client_version_supports_item_sends(client_version) +} + +pub fn client_version_supports_item_sends(client_version: Option<&ClientVersion>) -> bool { + static MIN_VERSION: LazyLock = + LazyLock::new(|| semver::VersionReq::parse(">=2026.10.0").unwrap()); + client_version.is_some_and(|v| MIN_VERSION.matches(&v.0)) +} + +/// Whether a Send can be shown to its owner on the requesting client. +pub fn send_visible_to(send: &Send, device: &Device, client_version: Option<&ClientVersion>) -> bool { + send.atype != SendType::Item as i32 || (item_sends_enabled() && client_supports_item_sends(device, client_version)) +} + /// Data of an Item Send. The vault item is encrypted client side into a single opaque blob, the item id /// is kept in clear so the server can check that the sender is allowed to share it. /// @@ -348,9 +365,14 @@ fn enforce_mail_enabled_for_email_auth(send: &Send) -> EmptyResult { } #[get("/sends")] -async fn get_sends(headers: Headers, conn: DbConn) -> Json { +async fn get_sends(headers: Headers, client_version: Option, conn: DbConn) -> Json { let sends = Send::find_by_user(&headers.user.uuid, &conn); - let sends_json: Vec = sends.await.iter().filter(|s| send_visible(s)).map(Send::to_json).collect(); + let sends_json: Vec = sends + .await + .iter() + .filter(|s| send_visible_to(s, &headers.device, client_version.as_ref())) + .map(Send::to_json) + .collect(); Json(json!({ "data": sends_json, @@ -360,9 +382,14 @@ async fn get_sends(headers: Headers, conn: DbConn) -> Json { } #[get("/sends/")] -async fn get_send(send_id: SendId, headers: Headers, conn: DbConn) -> JsonResult { +async fn get_send( + send_id: SendId, + headers: Headers, + client_version: Option, + conn: DbConn, +) -> JsonResult { if let Some(send) = Send::find_by_uuid_and_user(&send_id, &headers.user.uuid, &conn).await - && send_visible(&send) + && send_visible_to(&send, &headers.device, client_version.as_ref()) { Ok(Json(send.to_json())) } else {