Skip to content

Commit 64d943b

Browse files
committed
2026.6.0 send support
1 parent d6a3d53 commit 64d943b

6 files changed

Lines changed: 342 additions & 18 deletions

File tree

playwright/tests/send.spec.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { test, expect, type Page, type TestInfo } from '@playwright/test';
2+
import * as OTPAuth from "otpauth";
3+
4+
import * as utils from "../global-utils";
5+
import { createAccount } from './setups/user';
6+
7+
let users = utils.loadEnv();
8+
9+
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
10+
await utils.startVault(browser, testInfo, {});
11+
});
12+
13+
test.afterAll('Teardown', async ({}) => {
14+
utils.stopVault();
15+
});
16+
17+
test('Send', async ({ browser, page }) => {
18+
await createAccount(test, page, users.user1);
19+
20+
const send_url = await test.step('Create', async () => {
21+
await page.getByRole('link', { name: 'Send' }).click();
22+
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
23+
24+
await page.getByRole('button', { name: 'New', exact: true }).click();
25+
await page.getByRole('menuitem', { name: 'Text' }).click();
26+
27+
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test');
28+
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test');
29+
await page.getByRole('button', { name: 'Save' }).click();
30+
31+
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
32+
33+
return await page.evaluate(() => navigator.clipboard.readText());
34+
});
35+
36+
const context2 = await browser.newContext();
37+
const page2 = await context2.newPage();
38+
39+
await test.step('View', async () => {
40+
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
41+
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
42+
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
43+
});
44+
45+
const pwd_url = await test.step('Create with password', async () => {
46+
await page.getByRole('link', { name: 'Send' }).click();
47+
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
48+
49+
await page.getByRole('button', { name: 'New', exact: true }).click();
50+
await page.getByRole('menuitem', { name: 'Text' }).click();
51+
52+
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password');
53+
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password');
54+
await page.getByRole('combobox', { name: 'Who can view' }).click();
55+
await page.getByText('Anyone with a password set by you').click();
56+
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password');
57+
58+
await page.getByRole('button', { name: 'Save' }).click();
59+
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
60+
61+
return await page.evaluate(() => navigator.clipboard.readText());
62+
});
63+
64+
await test.step('View with password', async () => {
65+
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
66+
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
67+
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password');
68+
await page2.getByRole('button', { name: 'Continue' }).click();
69+
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
70+
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
71+
});
72+
});

src/api/core/sends.rs

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use serde_json::Value;
1212
use crate::{
1313
CONFIG,
1414
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
15-
auth::{ClientIp, Headers, Host},
15+
auth::{ClientIp, Headers, Host, SendHeaders},
1616
config::PathType,
1717
db::{
1818
DbConn, DbPool,
@@ -48,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
4848
post_send,
4949
post_send_file,
5050
post_access,
51+
post_access_legacy,
5152
post_access_file,
53+
post_access_file_legacy,
5254
put_send,
5355
delete_send,
5456
put_remove_password,
@@ -371,7 +373,7 @@ pub struct SendFileData {
371373
}
372374

373375
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
374-
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
376+
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
375377
async fn post_send_file_v2_data(
376378
send_id: SendId,
377379
file_id: SendFileId,
@@ -441,14 +443,23 @@ async fn post_send_file_v2_data(
441443
Ok(())
442444
}
443445

446+
#[post("/sends/access")]
447+
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
448+
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
449+
err_code!(SEND_INACCESSIBLE_MSG, 404)
450+
};
451+
process_access(send, conn, nt).await
452+
}
453+
444454
#[derive(Deserialize)]
445455
#[serde(rename_all = "camelCase")]
446456
pub struct SendAccessData {
447457
pub password: Option<String>,
448458
}
449459

460+
// Legacy since web-2026.6.0
450461
#[post("/sends/access/<access_id>", data = "<data>")]
451-
async fn post_access(
462+
async fn post_access_legacy(
452463
access_id: &str,
453464
data: Json<SendAccessData>,
454465
conn: DbConn,
@@ -479,6 +490,13 @@ async fn post_access(
479490
err_code!(SEND_INACCESSIBLE_MSG, 404)
480491
}
481492

493+
// Files are incremented during the download
494+
if send.atype == SendType::Text as i32 {
495+
send.access_count += 1;
496+
}
497+
498+
send.save(&conn).await?;
499+
482500
if send.password_hash.is_some() {
483501
match data.into_inner().password {
484502
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
@@ -487,13 +505,10 @@ async fn post_access(
487505
}
488506
}
489507

490-
// Files are incremented during the download
491-
if send.atype == SendType::Text as i32 {
492-
send.access_count += 1;
493-
}
494-
495-
send.save(&conn).await?;
508+
process_access(send, conn, nt).await
509+
}
496510

511+
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
497512
nt.send_send_update(
498513
UpdateType::SyncSendUpdate,
499514
&send,
@@ -506,8 +521,23 @@ async fn post_access(
506521
Ok(Json(send.to_json_access(&conn).await))
507522
}
508523

509-
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
524+
#[post("/sends/access/file/<file_id>", rank = 1)]
510525
async fn post_access_file(
526+
file_id: SendFileId,
527+
headers: SendHeaders,
528+
host: Host,
529+
conn: DbConn,
530+
nt: Notify<'_>,
531+
) -> JsonResult {
532+
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
533+
err_code!(SEND_INACCESSIBLE_MSG, 404)
534+
};
535+
process_access_file(send, file_id, host, conn, nt).await
536+
}
537+
538+
// Legacy since web-2026.6.0
539+
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
540+
async fn post_access_file_legacy(
511541
send_id: SendId,
512542
file_id: SendFileId,
513543
data: Json<SendAccessData>,
@@ -551,6 +581,10 @@ async fn post_access_file(
551581

552582
send.save(&conn).await?;
553583

584+
process_access_file(send, file_id, host, conn, nt).await
585+
}
586+
587+
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
554588
nt.send_send_update(
555589
UpdateType::SyncSendUpdate,
556590
&send,
@@ -563,7 +597,7 @@ async fn post_access_file(
563597
Ok(Json(json!({
564598
"object": "send-fileDownload",
565599
"id": file_id,
566-
"url": download_url(&host, &send_id, &file_id).await?,
600+
"url": download_url(&host, &send.uuid, &file_id).await?,
567601
})))
568602
}
569603

src/api/identity.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ use crate::{
3131
DbConn,
3232
models::{
3333
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
34-
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
35-
UserId,
34+
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
35+
TwoFactorType, User, UserId,
3636
},
3737
},
3838
error::MapResult,
@@ -108,6 +108,19 @@ async fn login(
108108
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
109109
}
110110
"authorization_code" => err!("SSO sign-in is not available"),
111+
"send_access" => {
112+
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
113+
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
114+
115+
let tokens = auth::SendTokens::generate_tokens(
116+
data.send_id.as_ref().unwrap(),
117+
data.password_hash_b64,
118+
&client_header.ip,
119+
&conn,
120+
)
121+
.await?;
122+
Ok(Json(tokens.to_json()))
123+
}
111124
t => err!("Invalid type", t),
112125
};
113126

@@ -1144,6 +1157,10 @@ struct ConnectData {
11441157
code: Option<OIDCCode>,
11451158
#[field(name = uncased("code_verifier"))]
11461159
code_verifier: Option<OIDCCodeVerifier>,
1160+
1161+
// Needed for send access
1162+
send_id: Option<SendId>,
1163+
password_hash_b64: Option<String>,
11471164
}
11481165
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
11491166
if value.is_none() {

src/auth.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
#[path = "auth/send.rs"]
2+
pub mod send;
3+
pub type SendTokens = send::SendTokens;
4+
pub type SendHeaders = send::SendHeaders;
5+
16
use std::{
27
env,
38
net::IpAddr,
@@ -487,6 +492,16 @@ pub struct BasicJwtClaims {
487492
pub sub: String,
488493
}
489494

495+
impl BasicJwtClaims {
496+
pub fn expires_in(&self) -> i64 {
497+
self.exp - Utc::now().timestamp()
498+
}
499+
500+
pub fn token(&self) -> String {
501+
encode_jwt(&self)
502+
}
503+
}
504+
490505
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
491506
let time_now = Utc::now();
492507
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());

0 commit comments

Comments
 (0)