Browse Source
* 2026.6.0 send support * Prevent creating and editing a Send with email verification * Review fixes --------- Co-authored-by: Timshel <timshel@users.noreply.github.com>pull/7363/merge
committed by
GitHub
6 changed files with 329 additions and 12 deletions
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,156 @@ |
|||||
|
use chrono::{TimeDelta, Utc}; |
||||
|
|
||||
|
use rocket::request::{FromRequest, Outcome, Request}; |
||||
|
|
||||
|
use crate::{ |
||||
|
api::ApiResult, |
||||
|
auth, |
||||
|
auth::{BasicJwtClaims, ClientIp}, |
||||
|
db::{ |
||||
|
DbConn, |
||||
|
models::{Send, SendId}, |
||||
|
}, |
||||
|
error::{Error, ErrorKind}, |
||||
|
}; |
||||
|
|
||||
|
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { |
||||
|
let time_now = Utc::now(); |
||||
|
BasicJwtClaims { |
||||
|
nbf: time_now.timestamp(), |
||||
|
exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(), |
||||
|
iss: auth::JWT_SEND_ISSUER.to_string(), |
||||
|
sub: format!("{send_id}"), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
#[derive(Debug, Serialize, Deserialize)] |
||||
|
pub struct SendTokens { |
||||
|
pub access_claims: BasicJwtClaims, |
||||
|
} |
||||
|
|
||||
|
impl SendTokens { |
||||
|
pub fn as_send_id(access_id: &str) -> Option<SendId> { |
||||
|
data_encoding::BASE64URL_NOPAD |
||||
|
.decode(access_id.as_bytes()) |
||||
|
.ok() |
||||
|
.and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string()))) |
||||
|
} |
||||
|
|
||||
|
pub fn to_json(&self) -> serde_json::Value { |
||||
|
json!({ |
||||
|
"access_token": self.access_claims.token(), |
||||
|
"expires_in": self.access_claims.expires_in(), |
||||
|
"token_type": "Bearer", |
||||
|
"scope": "api.send.access", |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> { |
||||
|
let err = json!({ |
||||
|
"kind": "expected_server", |
||||
|
"error": "invalid_request", |
||||
|
"send_access_error_type": error_type, |
||||
|
}); |
||||
|
|
||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent()) |
||||
|
} |
||||
|
|
||||
|
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> { |
||||
|
let err = json!({ |
||||
|
"kind": "expected_server", |
||||
|
"error": "invalid_grant", |
||||
|
"send_access_error_type": error_type, |
||||
|
}); |
||||
|
|
||||
|
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent)) |
||||
|
} |
||||
|
|
||||
|
pub async fn generate_tokens( |
||||
|
access_id: &str, |
||||
|
password: Option<String>, |
||||
|
ip: &ClientIp, |
||||
|
conn: &DbConn, |
||||
|
) -> ApiResult<SendTokens> { |
||||
|
let Some(send_id) = Self::as_send_id(access_id) else { |
||||
|
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); |
||||
|
}; |
||||
|
|
||||
|
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else { |
||||
|
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); |
||||
|
}; |
||||
|
|
||||
|
if let Some(max_access_count) = send.max_access_count |
||||
|
&& send.access_count >= max_access_count |
||||
|
{ |
||||
|
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); |
||||
|
} |
||||
|
|
||||
|
if let Some(expiration) = send.expiration_date |
||||
|
&& Utc::now().naive_utc() >= expiration |
||||
|
{ |
||||
|
return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true); |
||||
|
} |
||||
|
|
||||
|
if Utc::now().naive_utc() >= send.deletion_date { |
||||
|
return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true); |
||||
|
} |
||||
|
|
||||
|
if send.disabled { |
||||
|
return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true); |
||||
|
} |
||||
|
|
||||
|
if send.password_hash.is_some() { |
||||
|
match password { |
||||
|
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } |
||||
|
Some(_) => { |
||||
|
return Self::invalid_error( |
||||
|
&format!("Send {send_id}, Invalid password from {}", ip.ip), |
||||
|
"password_hash_b64_invalid", |
||||
|
false, |
||||
|
); |
||||
|
} |
||||
|
None => return Self::expected_error("Password required", "password_hash_b64_required"), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
send.access_count += 1; |
||||
|
send.save(conn).await?; |
||||
|
|
||||
|
Ok(Self { |
||||
|
access_claims: generate_send_access_claims(&send_id), |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
pub struct SendHeaders { |
||||
|
pub send_id: SendId, |
||||
|
} |
||||
|
|
||||
|
#[rocket::async_trait] |
||||
|
impl<'r> FromRequest<'r> for SendHeaders { |
||||
|
type Error = &'static str; |
||||
|
|
||||
|
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { |
||||
|
let headers = request.headers(); |
||||
|
|
||||
|
// Get access_token
|
||||
|
let access_token: &str = if let Some(a) = headers.get_one("Authorization") { |
||||
|
if let Some(split) = a.rsplit("Bearer ").next() { |
||||
|
split |
||||
|
} else { |
||||
|
err_handler!("No access token provided") |
||||
|
} |
||||
|
} else { |
||||
|
err_handler!("No access token provided") |
||||
|
}; |
||||
|
|
||||
|
// Check JWT token is valid and get send_id
|
||||
|
let Ok(claims) = auth::decode_send(access_token) else { |
||||
|
err_handler!("Invalid claim") |
||||
|
}; |
||||
|
|
||||
|
Outcome::Success(SendHeaders { |
||||
|
send_id: claims.sub.into(), |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue