Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions playwright/tests/send.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";

import * as utils from "../global-utils";
import { createAccount } from './setups/user';

let users = utils.loadEnv();

test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
});

test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});

test('Send', async ({ browser, page }) => {
await createAccount(test, page, users.user1);

const send_url = await test.step('Create', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();

await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();

await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test');
await page.getByRole('button', { name: 'Save' }).click();

await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();

return await page.evaluate(() => navigator.clipboard.readText());
});

const context2 = await browser.newContext();
const page2 = await context2.newPage();

await test.step('View', async () => {
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
});

const pwd_url = await test.step('Create with password', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();

await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();

await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password');
await page.getByRole('combobox', { name: 'Who can view' }).click();
await page.getByText('Anyone with a password set by you').click();
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password');

await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();

return await page.evaluate(() => navigator.clipboard.readText());
});

await test.step('View with password', async () => {
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page2.getByRole('button', { name: 'Continue' }).click();
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
});
});
65 changes: 54 additions & 11 deletions src/api/core/sends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host},
auth::{ClientIp, Headers, Host, SendHeaders},
config::PathType,
db::{
DbConn, DbPool,
Expand Down Expand Up @@ -48,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
post_send,
post_send_file,
post_access,
post_access_legacy,
post_access_file,
post_access_file_legacy,
put_send,
delete_send,
put_remove_password,
Expand Down Expand Up @@ -78,6 +80,7 @@ pub struct SendData {
deletion_date: DateTime<Utc>,
disabled: bool,
hide_email: Option<bool>,
emails: Option<String>,

// Data field
name: String,
Expand Down Expand Up @@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
);
}

if data.emails.is_some() {
err!("Sends with email verification is not supported");
}

let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
send.user_uuid = Some(user_id);
send.notes = data.notes;
Expand Down Expand Up @@ -371,7 +378,7 @@ pub struct SendFileData {
}

// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
async fn post_send_file_v2_data(
send_id: SendId,
file_id: SendFileId,
Expand Down Expand Up @@ -441,14 +448,23 @@ async fn post_send_file_v2_data(
Ok(())
}

#[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
process_access(send, conn, nt).await
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendAccessData {
pub password: Option<String>,
}

// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access(
async fn post_access_legacy(
access_id: &str,
data: Json<SendAccessData>,
conn: DbConn,
Expand Down Expand Up @@ -479,6 +495,13 @@ async fn post_access(
err_code!(SEND_INACCESSIBLE_MSG, 404)
}

// Files are incremented during the download
if send.atype == SendType::Text as i32 {
send.access_count += 1;
}

send.save(&conn).await?;

if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Expand All @@ -487,13 +510,10 @@ async fn post_access(
}
}

// Files are incremented during the download
if send.atype == SendType::Text as i32 {
send.access_count += 1;
}

send.save(&conn).await?;
process_access(send, conn, nt).await
}

async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update(
UpdateType::SyncSendUpdate,
&send,
Expand All @@ -506,8 +526,23 @@ async fn post_access(
Ok(Json(send.to_json_access(&conn).await))
}

#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
#[post("/sends/access/file/<file_id>", rank = 1)]
async fn post_access_file(
file_id: SendFileId,
headers: SendHeaders,
host: Host,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
process_access_file(send, file_id, host, conn, nt).await
}

// Legacy since web-2026.6.0
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId,
file_id: SendFileId,
data: Json<SendAccessData>,
Expand Down Expand Up @@ -551,6 +586,10 @@ async fn post_access_file(

send.save(&conn).await?;

process_access_file(send, file_id, host, conn, nt).await
}

async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update(
UpdateType::SyncSendUpdate,
&send,
Expand All @@ -563,7 +602,7 @@ async fn post_access_file(
Ok(Json(json!({
"object": "send-fileDownload",
"id": file_id,
"url": download_url(&host, &send_id, &file_id).await?,
"url": download_url(&host, &send.uuid, &file_id).await?,
})))
}

Expand Down Expand Up @@ -601,6 +640,10 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
err!("Send not found", "Send send_id is invalid or does not belong to user")
};

if data.emails.is_some() {
err!("Sends with email verification is not supported");
}

update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;

Ok(Json(send.to_json()))
Expand Down
21 changes: 19 additions & 2 deletions src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ use crate::{
DbConn,
models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
TwoFactorType, User, UserId,
},
},
error::MapResult,
Expand Down Expand Up @@ -108,6 +108,19 @@ async fn login(
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"send_access" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;

let tokens = auth::SendTokens::generate_tokens(
data.send_id.as_ref().unwrap(),
data.password_hash_b64,
&client_header.ip,
&conn,
)
.await?;
Ok(Json(tokens.to_json()))
}
t => err!("Invalid type", t),
};

Expand Down Expand Up @@ -1144,6 +1157,10 @@ struct ConnectData {
code: Option<OIDCCode>,
#[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>,

// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {
Expand Down
15 changes: 15 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
#[path = "auth/send.rs"]
pub mod send;
pub type SendTokens = send::SendTokens;
pub type SendHeaders = send::SendHeaders;

use std::{
env,
net::IpAddr,
Expand Down Expand Up @@ -487,6 +492,16 @@ pub struct BasicJwtClaims {
pub sub: String,
}

impl BasicJwtClaims {
pub fn expires_in(&self) -> i64 {
self.exp - Utc::now().timestamp()
}

pub fn token(&self) -> String {
encode_jwt(&self)
}
}

pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
Expand Down
Loading