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