improvements
This commit is contained in:
+2
-43
@@ -620,46 +620,5 @@ fn sanitize_filename(value: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{content_references_file, content_references_stored_file, is_safe_inline_image_mime};
|
||||
|
||||
#[test]
|
||||
fn only_raster_images_are_inline() {
|
||||
assert!(is_safe_inline_image_mime("image/png"));
|
||||
assert!(is_safe_inline_image_mime("image/jpeg"));
|
||||
assert!(!is_safe_inline_image_mime("image/svg+xml"));
|
||||
assert!(!is_safe_inline_image_mime("text/html"));
|
||||
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_image_alias_is_still_attached() {
|
||||
assert!(content_references_file(
|
||||
"[image=photo.jpg,Photo,a=left,size=640x400]",
|
||||
"photo.jpg",
|
||||
"/f/token/photo.jpg",
|
||||
));
|
||||
assert!(content_references_file(
|
||||
"[file=report.pdf,Quarterly report]",
|
||||
"report.pdf",
|
||||
"/f/token/report.pdf",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_references_survive_origin_changes() {
|
||||
let stored = "/f/token/image.png";
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
stored,
|
||||
None,
|
||||
));
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
"https://old-files.example.com/f/token/image.png",
|
||||
Some("https://new-files.example.com"),
|
||||
));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/api_files.rs"]
|
||||
mod tests;
|
||||
|
||||
+55
-44
@@ -89,14 +89,35 @@ fn requester_guest_id(headers: &HeaderMap) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
fn guest_owner_is_requester(headers: &HeaderMap, owner_guest_id: Option<&str>) -> bool {
|
||||
owner_guest_id
|
||||
.zip(requester_guest_id(headers))
|
||||
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
|
||||
}
|
||||
|
||||
fn can_set_resource_password(
|
||||
password_protected: bool,
|
||||
account_owner: bool,
|
||||
guest_owner: bool,
|
||||
) -> bool {
|
||||
!password_protected && (account_owner || guest_owner)
|
||||
}
|
||||
|
||||
fn can_manage_resource_settings(
|
||||
account_owner: bool,
|
||||
guest_owner: bool,
|
||||
password_write_access: bool,
|
||||
) -> bool {
|
||||
account_owner || guest_owner || password_write_access
|
||||
}
|
||||
|
||||
async fn note_creator_is_requester(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
note: &db::Note,
|
||||
) -> Result<bool, ApiError> {
|
||||
if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() {
|
||||
return Ok(requester_guest_id(headers)
|
||||
.is_some_and(|requester_guest_id| requester_guest_id == owner_guest_id));
|
||||
return Ok(guest_owner_is_requester(headers, Some(owner_guest_id)));
|
||||
}
|
||||
let Some(user) = session_user(state, headers).await? else {
|
||||
return Ok(false);
|
||||
@@ -108,18 +129,11 @@ async fn note_creator_is_requester(
|
||||
}
|
||||
|
||||
fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool {
|
||||
pad.created_by_guest_id
|
||||
.as_deref()
|
||||
.zip(requester_guest_id(headers))
|
||||
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
|
||||
guest_owner_is_requester(headers, pad.created_by_guest_id.as_deref())
|
||||
}
|
||||
|
||||
fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace) -> bool {
|
||||
workspace
|
||||
.created_by_guest_id
|
||||
.as_deref()
|
||||
.zip(requester_guest_id(headers))
|
||||
.is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id)
|
||||
guest_owner_is_requester(headers, workspace.created_by_guest_id.as_deref())
|
||||
}
|
||||
|
||||
async fn can_set_workspace_password(
|
||||
@@ -127,9 +141,6 @@ async fn can_set_workspace_password(
|
||||
headers: &HeaderMap,
|
||||
workspace: &db::Workspace,
|
||||
) -> bool {
|
||||
if workspace.password_hash.is_some() {
|
||||
return false;
|
||||
}
|
||||
let account_owner = crate::auth::is_resource_owner(
|
||||
state,
|
||||
"workspace",
|
||||
@@ -138,7 +149,11 @@ async fn can_set_workspace_password(
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
account_owner || workspace_creator_is_requester(headers, workspace)
|
||||
can_set_resource_password(
|
||||
workspace.password_hash.is_some(),
|
||||
account_owner,
|
||||
workspace_creator_is_requester(headers, workspace),
|
||||
)
|
||||
}
|
||||
|
||||
async fn has_write_permission(
|
||||
@@ -314,6 +329,8 @@ pub struct CreateNoteRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetWorkspacePasswordRequest {
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
client_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -685,9 +702,14 @@ pub async fn set_workspace_password(
|
||||
"Only the workspace owner can set its password.",
|
||||
));
|
||||
}
|
||||
let except_client_id =
|
||||
crate::websocket::clean_collaboration_client_id(payload.client_id);
|
||||
let password = validate_password(Some(payload.password.as_str()))?
|
||||
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
|
||||
db::set_workspace_password(&state.db, &workspace_slug, password).await?;
|
||||
state
|
||||
.notify_workspace_password_required(&workspace_slug, except_client_id)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok": true, "protected": true})))
|
||||
}
|
||||
|
||||
@@ -1021,7 +1043,8 @@ pub async fn note_info(
|
||||
let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?;
|
||||
let password_write_access =
|
||||
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||
let can_manage_authorship = workspace_owner || note_owner || password_write_access;
|
||||
let can_manage_authorship =
|
||||
can_manage_resource_settings(workspace_owner, note_owner, password_write_access);
|
||||
let can_delete_files = can_manage_authorship;
|
||||
let upload_max_size_bytes =
|
||||
resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?;
|
||||
@@ -1066,8 +1089,11 @@ pub async fn note_info(
|
||||
personal_editor_settings,
|
||||
can_save_editor_settings,
|
||||
can_manage_authorship,
|
||||
can_set_password: workspace.password_hash.is_none()
|
||||
&& (workspace_owner || workspace_guest_owner),
|
||||
can_set_password: can_set_resource_password(
|
||||
workspace.password_hash.is_some(),
|
||||
workspace_owner,
|
||||
workspace_guest_owner,
|
||||
),
|
||||
files: markdown_file_references(&state, None, Some(note.id), None).await?,
|
||||
}))
|
||||
}
|
||||
@@ -1084,8 +1110,11 @@ pub async fn set_note_editor_settings(
|
||||
let note = db::find_note(&state.db, workspace.id, ¬e_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, ¬e).await?
|
||||
|| has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?;
|
||||
let creator_can_manage_authorship = can_manage_resource_settings(
|
||||
false,
|
||||
note_creator_is_requester(&state, &headers, ¬e).await?,
|
||||
has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?,
|
||||
);
|
||||
save_editor_settings(
|
||||
&state,
|
||||
&headers,
|
||||
@@ -1255,28 +1284,6 @@ fn permission_level(permission: Option<&str>) -> AccessLevel {
|
||||
}
|
||||
}
|
||||
|
||||
async fn anonymous_access_token_valid(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<bool, ApiError> {
|
||||
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let count: i64 = sqlx::query_scalar(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
||||
))
|
||||
.bind(access_tokens::hash_access_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn has_password_write_access(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
@@ -1287,7 +1294,7 @@ async fn has_password_write_access(
|
||||
crate::security::resource_token(headers, kind, slug),
|
||||
authorization_token(headers),
|
||||
] {
|
||||
if anonymous_access_token_valid(state, kind, slug, token).await? {
|
||||
if verify_password_access_token(state, kind, slug, token).await? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -1307,7 +1314,7 @@ async fn external_token_access_level(
|
||||
if level != AccessLevel::None {
|
||||
return Ok(level);
|
||||
}
|
||||
if anonymous_access_token_valid(state, kind, slug, token).await? {
|
||||
if verify_password_access_token(state, kind, slug, token).await? {
|
||||
// A server-issued token created after a correct resource password
|
||||
// retains the historical read/write semantics of password access.
|
||||
return Ok(AccessLevel::Write);
|
||||
@@ -1612,3 +1619,7 @@ async fn unique_note_slug(
|
||||
}
|
||||
Err(ApiError::internal("Failed to create a unique address"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/api.rs"]
|
||||
mod guest_resource_access_tests;
|
||||
|
||||
+20
-5
@@ -21,6 +21,8 @@ pub struct CreatePadRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetPadPasswordRequest {
|
||||
password: String,
|
||||
#[serde(default)]
|
||||
client_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -135,7 +137,8 @@ pub async fn pad_info(
|
||||
.unwrap_or(false);
|
||||
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||
let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?;
|
||||
let can_manage_authorship = account_owner || guest_owner || password_write_access;
|
||||
let can_manage_authorship =
|
||||
can_manage_resource_settings(account_owner, guest_owner, password_write_access);
|
||||
let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?;
|
||||
let can_upload_files = upload_max_size_bytes.is_some();
|
||||
let can_save_editor_settings = (personal_editor_settings || can_manage_authorship)
|
||||
@@ -174,7 +177,11 @@ pub async fn pad_info(
|
||||
personal_editor_settings,
|
||||
can_save_editor_settings,
|
||||
can_manage_authorship,
|
||||
can_set_password: pad.password_hash.is_none() && (account_owner || guest_owner),
|
||||
can_set_password: can_set_resource_password(
|
||||
pad.password_hash.is_some(),
|
||||
account_owner,
|
||||
guest_owner,
|
||||
),
|
||||
files: markdown_file_references(&state, Some(pad.id), None, None).await?,
|
||||
}))
|
||||
}
|
||||
@@ -195,12 +202,17 @@ pub async fn set_pad_password(
|
||||
&state, "pad", &slug, user_session_token(&headers),
|
||||
).await.unwrap_or(false);
|
||||
let guest_owner = pad_creator_is_requester(&headers, &pad);
|
||||
if !account_owner && !guest_owner {
|
||||
if !can_set_resource_password(pad.password_hash.is_some(), account_owner, guest_owner) {
|
||||
return Err(ApiError::forbidden("Only the note owner can set its password."));
|
||||
}
|
||||
let except_client_id =
|
||||
crate::websocket::clean_collaboration_client_id(payload.client_id);
|
||||
let password = validate_password(Some(payload.password.as_str()))?
|
||||
.ok_or_else(|| ApiError::bad_request("Password is required."))?;
|
||||
db::set_pad_password(&state.db, &slug, password).await?;
|
||||
state
|
||||
.notify_pad_password_required(&slug, except_client_id)
|
||||
.await;
|
||||
Ok(Json(serde_json::json!({"ok": true, "protected": true})))
|
||||
}
|
||||
|
||||
@@ -213,8 +225,11 @@ pub async fn set_pad_editor_settings(
|
||||
let pad = db::find_pad(&state.db, &slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
let creator_can_manage_authorship = pad_creator_is_requester(&headers, &pad)
|
||||
|| has_password_write_access(&state, &headers, "pad", &slug).await?;
|
||||
let creator_can_manage_authorship = can_manage_resource_settings(
|
||||
false,
|
||||
pad_creator_is_requester(&headers, &pad),
|
||||
has_password_write_access(&state, &headers, "pad", &slug).await?,
|
||||
);
|
||||
save_editor_settings(
|
||||
&state,
|
||||
&headers,
|
||||
|
||||
+6
-114
@@ -215,6 +215,10 @@ pub fn router(
|
||||
axum::routing::delete(api::delete_note_file),
|
||||
)
|
||||
.route("/ws/p/{slug}", get(websocket::upgrade_pad))
|
||||
.route(
|
||||
"/ws/watch/workspace/{workspace_slug}",
|
||||
get(websocket::upgrade_workspace_watch),
|
||||
)
|
||||
.route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade))
|
||||
.route("/static", get(static_not_found))
|
||||
.route("/static/{*path}", get(static_not_found))
|
||||
@@ -333,117 +337,5 @@ fn is_icon_path(path: &str) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy};
|
||||
use axum::http::{HeaderMap, HeaderValue, header};
|
||||
|
||||
#[test]
|
||||
fn classifies_assets_and_icons_as_static_assets() {
|
||||
for path in [
|
||||
"/assets/app.js",
|
||||
"/assets",
|
||||
"/favicon.ico",
|
||||
"/icons/favicon.svg",
|
||||
"/icons/missing.svg",
|
||||
] {
|
||||
assert_eq!(
|
||||
response_header_policy(path),
|
||||
ResponseHeaderPolicy::StaticAsset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_file_routes_as_files() {
|
||||
for path in ["/f", "/f/token/image.png"] {
|
||||
assert_eq!(response_header_policy(path), ResponseHeaderPolicy::File);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_other_routes_as_application() {
|
||||
for path in [
|
||||
"/",
|
||||
"/api/auth/me",
|
||||
"/static/missing.css",
|
||||
"/files/legacy/image.png",
|
||||
"/unknown",
|
||||
] {
|
||||
assert_eq!(
|
||||
response_header_policy(path),
|
||||
ResponseHeaderPolicy::Application
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_asset_policy_only_adds_nosniff() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=3600"),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers);
|
||||
|
||||
assert_eq!(headers.len(), 2);
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||
assert!(!headers.contains_key("referrer-policy"));
|
||||
assert!(!headers.contains_key("permissions-policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_policy_keeps_file_headers_without_document_policies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static("attachment; filename=\"manual.pdf\""),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::File, &mut headers);
|
||||
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert_eq!(
|
||||
headers["content-security-policy"],
|
||||
"default-src 'none'; sandbox"
|
||||
);
|
||||
assert!(headers.contains_key(header::CONTENT_DISPOSITION));
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||
assert!(!headers.contains_key("referrer-policy"));
|
||||
assert!(!headers.contains_key("permissions-policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_policy_preserves_handler_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::Application, &mut headers);
|
||||
|
||||
assert_eq!(
|
||||
headers["content-security-policy"],
|
||||
"default-src 'none'; sandbox"
|
||||
);
|
||||
assert_eq!(headers["x-frame-options"], "DENY");
|
||||
assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
|
||||
assert_eq!(headers["cross-origin-resource-policy"], "same-origin");
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert_eq!(
|
||||
headers["referrer-policy"],
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
assert!(headers.contains_key("permissions-policy"));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/app.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-23
@@ -564,26 +564,5 @@ fn escape_html(value: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn share_exchange_redirects_to_canonical_resource_path() {
|
||||
let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap();
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_or_repeated_parameters_are_parsed_without_rejection() {
|
||||
let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap();
|
||||
assert_eq!(share_token_from_query(uri.query()), (true, Some("one".into())));
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_share_parameter_is_still_removed_from_the_url() {
|
||||
let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap();
|
||||
assert_eq!(share_token_from_query(uri.query()), (true, None));
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no");
|
||||
}
|
||||
}
|
||||
#[path = "../tests/app_pages.rs"]
|
||||
mod tests;
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ pub fn render_html(
|
||||
let mut response = Html(html).into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
HeaderValue::from_static("private, no-cache, no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
+21
-7
@@ -1299,6 +1299,8 @@ pub async fn update_resource(
|
||||
Json(req): Json<ResourceActionRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let user = require_user(&state, &headers).await?;
|
||||
let kind = req.kind.trim();
|
||||
let slug = req.slug.trim();
|
||||
let hash = match req
|
||||
.password
|
||||
.as_deref()
|
||||
@@ -1311,15 +1313,16 @@ pub async fn update_resource(
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
||||
let query = match req.kind.as_str() {
|
||||
let password_enabled = hash.is_some();
|
||||
ensure_owner(&state, user.id, kind, slug).await?;
|
||||
let query = match kind {
|
||||
"workspace" => queries::USER_SET_WORKSPACE_PASSWORD,
|
||||
"pad" => queries::USER_SET_PAD_PASSWORD,
|
||||
_ => return Err(AuthError::bad_request("Unknown resource type.")),
|
||||
};
|
||||
sqlx::query(queries::get(state.db.kind(), query))
|
||||
.bind(hash)
|
||||
.bind(req.slug.trim())
|
||||
.bind(slug)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
@@ -1327,12 +1330,21 @@ pub async fn update_resource(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
|
||||
))
|
||||
.bind(req.kind.as_str())
|
||||
.bind(req.slug.trim())
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
if password_enabled {
|
||||
match kind {
|
||||
"workspace" => state.notify_workspace_password_required(slug, None).await,
|
||||
"pad" => state.notify_pad_password_required(slug, None).await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"protected":password_enabled}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_resource(
|
||||
@@ -1826,7 +1838,9 @@ pub async fn create_share_link(
|
||||
.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
"no-store, max-age=0".parse().expect("valid cache-control"),
|
||||
"no-cache, no-store, max-age=0"
|
||||
.parse()
|
||||
.expect("valid cache-control"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
|
||||
+2
-85
@@ -822,88 +822,5 @@ pub fn owner_spans_from_map(content: &str, owner_map: &str) -> Vec<OwnerSpan> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn operation(components: Vec<OperationComponent>) -> TextOperation {
|
||||
TextOperation { components }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_insertions_have_stable_order() {
|
||||
let left = operation(vec![
|
||||
OperationComponent::Retain { count: 1 },
|
||||
OperationComponent::Insert {
|
||||
text: "X".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let right = operation(vec![
|
||||
OperationComponent::Retain { count: 1 },
|
||||
OperationComponent::Insert {
|
||||
text: "Y".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let left_prime = transform_operation(&left, &right, true).unwrap();
|
||||
let right_prime = transform_operation(&right, &left, false).unwrap();
|
||||
let after_right = apply_operation_to_document("aYb", "[]", &left_prime, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
let after_left = apply_operation_to_document("aXb", "[]", &right_prime, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(after_right, "aXYb");
|
||||
assert_eq!(after_left, "aXYb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_offsets_support_emoji() {
|
||||
let operation = operation(vec![
|
||||
OperationComponent::Retain { count: 3 },
|
||||
OperationComponent::Insert {
|
||||
text: "x".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result, "A😀xB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_from_edit_preserves_utf16_boundaries() {
|
||||
let operation = operation_from_edit("A😀B", "A😀xB", "[]");
|
||||
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result, "A😀xB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledgements_survive_history_compaction() {
|
||||
let mut document = CollaborativeDocument::new(String::new(), "[]".into(), 0);
|
||||
for update_id in 1..=MAX_OPERATION_HISTORY as u64 + 8 {
|
||||
let base_revision_id = document.revision_id;
|
||||
let revision_id = base_revision_id + 1;
|
||||
document.revision_id = revision_id;
|
||||
document.record(AppliedOperation {
|
||||
base_revision_id,
|
||||
revision_id,
|
||||
client_id: "client-123".into(),
|
||||
update_id,
|
||||
operation: TextOperation::default(),
|
||||
owner_replacements: Vec::new(),
|
||||
});
|
||||
}
|
||||
assert!(document.has_applied_update("client-123", 1));
|
||||
assert_eq!(
|
||||
document.acknowledged_updates("client-123"),
|
||||
vec![MAX_OPERATION_HISTORY as u64 + 8]
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "tests/collab.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-29
@@ -250,32 +250,5 @@ fn megabytes_to_bytes(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{megabytes_to_bytes, multipart_body_limit_bytes};
|
||||
|
||||
#[test]
|
||||
fn converts_upload_megabytes_to_bytes() {
|
||||
assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overflowing_upload_limit() {
|
||||
assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() {
|
||||
assert_eq!(
|
||||
multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024),
|
||||
21 * 1024 * 1024
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_limit_uses_larger_enabled_guest_limit() {
|
||||
assert_eq!(
|
||||
multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024),
|
||||
51 * 1024 * 1024
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/config.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-31
@@ -79,34 +79,5 @@ fn normalize_smtp_from(value: String) -> Result<String, Box<dyn std::error::Erro
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_security_from_standard_ports() {
|
||||
assert_eq!(smtp_security_for_port(25), SmtpSecurity::None);
|
||||
assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls);
|
||||
assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls);
|
||||
assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_supported_security_modes() {
|
||||
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
|
||||
assert_eq!(
|
||||
parse_smtp_security("starttls").unwrap(),
|
||||
SmtpSecurity::StartTls
|
||||
);
|
||||
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
|
||||
assert!(parse_smtp_security("auto").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_smtp_from() {
|
||||
assert_eq!(
|
||||
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
|
||||
"RustPad <rustpad@notes.example>"
|
||||
);
|
||||
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
|
||||
}
|
||||
}
|
||||
#[path = "../tests/config_smtp.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-55
@@ -715,58 +715,5 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Pad {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod password_verification_tests {
|
||||
use super::*;
|
||||
|
||||
fn workspace(password_hash: Option<String>) -> Workspace {
|
||||
Workspace {
|
||||
id: 1,
|
||||
slug: "private-workspace".into(),
|
||||
title: "Private workspace".into(),
|
||||
password_hash,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
is_private: 1,
|
||||
created_by_guest_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(password_hash: Option<String>) -> Pad {
|
||||
Pad {
|
||||
id: 1,
|
||||
slug: "private-pad".into(),
|
||||
title: "Private pad".into(),
|
||||
content: String::new(),
|
||||
password_hash,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
is_private: 1,
|
||||
created_by_guest_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_password_does_not_grant_password_access() {
|
||||
assert!(!verify_workspace_password(&workspace(None), None));
|
||||
assert!(!verify_workspace_password(
|
||||
&workspace(None),
|
||||
Some("anything")
|
||||
));
|
||||
assert!(!verify_pad_password(&pad(None), None));
|
||||
assert!(!verify_pad_password(&pad(None), Some("anything")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_password_is_verified() {
|
||||
let workspace = workspace(Some(hash_password("workspace-secret")));
|
||||
assert!(verify_workspace_password(
|
||||
&workspace,
|
||||
Some("workspace-secret")
|
||||
));
|
||||
assert!(!verify_workspace_password(&workspace, Some("wrong")));
|
||||
|
||||
let pad = pad(Some(hash_password("pad-secret")));
|
||||
assert!(verify_pad_password(&pad, Some("pad-secret")));
|
||||
assert!(!verify_pad_password(&pad, Some("wrong")));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/db.rs"]
|
||||
mod password_verification_tests;
|
||||
|
||||
+2
-59
@@ -87,62 +87,5 @@ pub fn public_file_url(public_base: Option<&str>, stored_url: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_bare_domain_and_http_origins() {
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("files.note.example.com".into())).unwrap(),
|
||||
Some("https://files.note.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("http://localhost:3001/".into())).unwrap(),
|
||||
Some("http://localhost:3001".into())
|
||||
);
|
||||
assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_origin_public_urls() {
|
||||
assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_canonical_path_from_relative_and_absolute_urls() {
|
||||
assert_eq!(
|
||||
canonical_file_path("/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png?download=1"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(canonical_file_path("/files/token/image.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_between_custom_origin_and_application_path() {
|
||||
let stored = "/f/token/manual.pdf";
|
||||
assert_eq!(public_file_url(None, stored), stored);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), stored),
|
||||
"https://files.example.com/f/token/manual.pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(None, "https://old.example.com/f/token/manual.pdf"),
|
||||
stored
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), "/invalid/path"),
|
||||
"/invalid/path"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "tests/file_urls.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-14
@@ -301,20 +301,8 @@ async fn shutdown_signal() {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_tests {
|
||||
use super::startup_credential;
|
||||
|
||||
#[test]
|
||||
fn startup_credential_contains_product_identity() {
|
||||
let credential = startup_credential();
|
||||
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
|
||||
assert!(
|
||||
credential
|
||||
.contains("https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md")
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "tests/main.rs"]
|
||||
mod startup_tests;
|
||||
|
||||
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
|
||||
let path = match db.kind() {
|
||||
|
||||
+2
-27
@@ -334,30 +334,5 @@ pub const Q058: Query = Query::Q058;
|
||||
pub const Q059: Query = Query::Q059;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn every_backend_has_explicit_queries() {
|
||||
for query in [
|
||||
Q001,
|
||||
Q003,
|
||||
Q004,
|
||||
Q011,
|
||||
Q021,
|
||||
Q033,
|
||||
USER_LIST_WORKSPACES,
|
||||
USER_LIST_PADS,
|
||||
SHARE_LINK_SESSION_SOURCE,
|
||||
SHARE_SESSION_INSERT,
|
||||
SHARE_SESSION_PERMISSION,
|
||||
SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
PAD_PUBLIC_PAGE_DISABLED,
|
||||
NOTE_PUBLIC_PAGE_DISABLED,
|
||||
] {
|
||||
assert!(!get(DatabaseKind::Sqlite, query).is_empty());
|
||||
assert!(!get(DatabaseKind::Postgres, query).is_empty());
|
||||
assert!(!get(DatabaseKind::MySql, query).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "../tests/queries.rs"]
|
||||
mod tests;
|
||||
|
||||
+2
-42
@@ -397,45 +397,5 @@ pub fn get(query: Query) -> &'static str {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn boolean_projections_are_normalized_for_sqlx_any() {
|
||||
// MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly.
|
||||
for (query, expected_casts) in [
|
||||
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
|
||||
(Query::EDITOR_PREFERENCES_SELECT_PAD, 4),
|
||||
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 4),
|
||||
(Query::AUTH_USER_BY_EXTERNAL_ID, 1),
|
||||
(Query::AUTH_USER_BY_SESSION, 1),
|
||||
(Query::AUTH_USER_BY_NICKNAME, 1),
|
||||
(Query::AUTH_USER_BY_EMAIL, 1),
|
||||
(Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1),
|
||||
(Query::USER_LIST_WORKSPACES, 6),
|
||||
(Query::USER_LIST_PADS, 6),
|
||||
(Query::PAD_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::NOTE_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::Q001, 1),
|
||||
(Query::Q003, 1),
|
||||
(Query::Q004, 1),
|
||||
(Query::Q011, 1),
|
||||
(Query::Q021, 1),
|
||||
(Query::Q033, 1),
|
||||
(Query::Q036, 1),
|
||||
(Query::Q038, 1),
|
||||
(Query::Q046, 1),
|
||||
(Query::Q044, 1),
|
||||
(Query::Q045, 1),
|
||||
(Query::Q048, 1),
|
||||
(Query::Q049, 1),
|
||||
] {
|
||||
let sql = get(query);
|
||||
assert_eq!(
|
||||
sql.matches("AS SIGNED").count(),
|
||||
expected_casts,
|
||||
"MySQL boolean projection is not normalized in {query:?}: {sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "../tests/queries_mysql.rs"]
|
||||
mod tests;
|
||||
|
||||
+3
-131
@@ -77,7 +77,7 @@ pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response {
|
||||
.insert(header::SET_COOKIE, csrf_cookie(&token));
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store, max-age=0"),
|
||||
HeaderValue::from_static("no-cache, no-store, max-age=0"),
|
||||
);
|
||||
response
|
||||
}
|
||||
@@ -256,133 +256,5 @@ fn first_header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str>
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn websocket_headers(origin: &'static str, host: &'static str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static(origin));
|
||||
headers.insert(header::HOST, HeaderValue::from_static(host));
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sessions_are_cookie_only() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer legacy-account-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), None);
|
||||
assert_eq!(bearer_token(&headers), Some("legacy-account-token"));
|
||||
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_static("__Host-rustpad_session=cookie-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), Some("cookie-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_proxy_controlled_real_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-for"),
|
||||
HeaderValue::from_static("203.0.113.10"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-real-ip"),
|
||||
HeaderValue::from_static("198.51.100.20"),
|
||||
);
|
||||
assert_eq!(client_key(&headers), "ip:198.51.100.20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_same_origin_websocket() {
|
||||
let headers = websocket_headers("https://pad.example.com", "pad.example.com");
|
||||
assert!(websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_origin_websocket() {
|
||||
let headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_trust_forwarded_host_for_websocket_origin() {
|
||||
let mut headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-host"),
|
||||
HeaderValue::from_static("evil.example"),
|
||||
);
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_origin_with_path() {
|
||||
let headers = websocket_headers("https://pad.example.com/other", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_websocket_origin() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::HOST, HeaderValue::from_static("pad.example.com"));
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_cookies_are_not_script_readable() {
|
||||
let value = session_cookie("abc123", 7).to_str().unwrap();
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_sessions_use_separate_scoped_opaque_cookies() {
|
||||
let value = share_session_cookie("workspace", "private-space", "opaque", 600)
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(value.starts_with("__Host-rustpad_share_"));
|
||||
assert!(value.contains("=opaque;"));
|
||||
assert!(value.contains("Max-Age=600"));
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_requires_matching_cookie_and_header() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_str(&token).unwrap(),
|
||||
);
|
||||
assert!(csrf_request_is_valid(&headers));
|
||||
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_static(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
),
|
||||
);
|
||||
assert!(!csrf_request_is_valid(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_cookie_is_strict_and_script_readable() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let value = csrf_cookie(&token).to_str().unwrap();
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Strict"));
|
||||
assert!(!value.contains("HttpOnly"));
|
||||
}
|
||||
}
|
||||
#[path = "tests/security.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -80,6 +80,14 @@ fn compact_presence_name(name: &str) -> String {
|
||||
format!("{initial}.{rest}")
|
||||
}
|
||||
|
||||
fn workspace_password_event_channel(
|
||||
channel_key: &str,
|
||||
workspace_key: &str,
|
||||
note_prefix: &str,
|
||||
) -> bool {
|
||||
channel_key == workspace_key || channel_key.starts_with(note_prefix)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PresenceConnection {
|
||||
identity: String,
|
||||
@@ -91,6 +99,7 @@ pub enum RoomEvent {
|
||||
Document(NoteUpdate),
|
||||
Presence(Vec<PresenceUser>),
|
||||
Chat { sender: String, text: String },
|
||||
PasswordRequired { except_client_id: Option<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -253,9 +262,16 @@ impl AppState {
|
||||
pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String {
|
||||
format!("workspace:{workspace_slug}/{note_slug}")
|
||||
}
|
||||
pub fn workspace_room_key(workspace_slug: &str) -> String {
|
||||
format!("workspace:{workspace_slug}")
|
||||
}
|
||||
pub fn pad_room_key(slug: &str) -> String {
|
||||
format!("pad:{slug}")
|
||||
}
|
||||
pub async fn workspace_channel(&self, workspace_slug: &str) -> broadcast::Sender<RoomEvent> {
|
||||
self.channel_for_key(Self::workspace_room_key(workspace_slug))
|
||||
.await
|
||||
}
|
||||
pub async fn note_channel(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
@@ -267,6 +283,40 @@ impl AppState {
|
||||
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> {
|
||||
self.channel_for_key(Self::pad_room_key(slug)).await
|
||||
}
|
||||
pub async fn notify_pad_password_required(
|
||||
&self,
|
||||
slug: &str,
|
||||
except_client_id: Option<String>,
|
||||
) {
|
||||
let key = Self::pad_room_key(slug);
|
||||
let sender = self.channels.read().await.get(&key).cloned();
|
||||
if let Some(sender) = sender {
|
||||
let _ = sender.send(RoomEvent::PasswordRequired { except_client_id });
|
||||
}
|
||||
}
|
||||
pub async fn notify_workspace_password_required(
|
||||
&self,
|
||||
workspace_slug: &str,
|
||||
except_client_id: Option<String>,
|
||||
) {
|
||||
let workspace_key = Self::workspace_room_key(workspace_slug);
|
||||
let prefix = format!("workspace:{workspace_slug}/");
|
||||
let senders = self
|
||||
.channels
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
workspace_password_event_channel(key.as_str(), &workspace_key, &prefix)
|
||||
})
|
||||
.map(|(_, sender)| sender.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for sender in senders {
|
||||
let _ = sender.send(RoomEvent::PasswordRequired {
|
||||
except_client_id: except_client_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
pub async fn join_room(
|
||||
&self,
|
||||
key: &str,
|
||||
@@ -327,6 +377,10 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/state.rs"]
|
||||
mod tests;
|
||||
|
||||
fn sorted_users(room: &HashMap<u64, PresenceConnection>) -> Vec<PresenceUser> {
|
||||
let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new();
|
||||
for connection in room.values() {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
fn headers_with_guest_id(guest_id: &str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("rustpad_guest_id={guest_id}"))
|
||||
.expect("valid cookie header"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guest_owner_requires_the_original_browser_identifier() {
|
||||
let owner_id = "0123456789abcdef0123456789abcdef";
|
||||
let owner_headers = headers_with_guest_id(owner_id);
|
||||
let visitor_headers = headers_with_guest_id("fedcba9876543210fedcba9876543210");
|
||||
|
||||
assert!(guest_owner_is_requester(&owner_headers, Some(owner_id)));
|
||||
assert!(!guest_owner_is_requester(&visitor_headers, Some(owner_id)));
|
||||
assert!(!guest_owner_is_requester(&HeaderMap::new(), Some(owner_id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_guest_identifier_does_not_grant_ownership() {
|
||||
let headers = headers_with_guest_id("too-short");
|
||||
assert!(!guest_owner_is_requester(&headers, Some("too-short")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_owner_can_set_the_first_password() {
|
||||
assert!(can_set_resource_password(false, true, false));
|
||||
assert!(can_set_resource_password(false, false, true));
|
||||
assert!(!can_set_resource_password(false, false, false));
|
||||
assert!(!can_set_resource_password(true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_allow_owner_or_verified_password_holder() {
|
||||
assert!(can_manage_resource_settings(true, false, false));
|
||||
assert!(can_manage_resource_settings(false, true, false));
|
||||
assert!(can_manage_resource_settings(false, false, true));
|
||||
assert!(!can_manage_resource_settings(false, false, false));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::{content_references_file, content_references_stored_file, is_safe_inline_image_mime};
|
||||
|
||||
#[test]
|
||||
fn only_raster_images_are_inline() {
|
||||
assert!(is_safe_inline_image_mime("image/png"));
|
||||
assert!(is_safe_inline_image_mime("image/jpeg"));
|
||||
assert!(!is_safe_inline_image_mime("image/svg+xml"));
|
||||
assert!(!is_safe_inline_image_mime("text/html"));
|
||||
assert!(!is_safe_inline_image_mime("application/xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_image_alias_is_still_attached() {
|
||||
assert!(content_references_file(
|
||||
"[image=photo.jpg,Photo,a=left,size=640x400]",
|
||||
"photo.jpg",
|
||||
"/f/token/photo.jpg",
|
||||
));
|
||||
assert!(content_references_file(
|
||||
"[file=report.pdf,Quarterly report]",
|
||||
"report.pdf",
|
||||
"/f/token/report.pdf",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_references_survive_origin_changes() {
|
||||
let stored = "/f/token/image.png";
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
stored,
|
||||
None,
|
||||
));
|
||||
assert!(content_references_stored_file(
|
||||
"",
|
||||
"image.png",
|
||||
"https://old-files.example.com/f/token/image.png",
|
||||
Some("https://new-files.example.com"),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::{ResponseHeaderPolicy, apply_response_headers, response_header_policy};
|
||||
use axum::http::{HeaderMap, HeaderValue, header};
|
||||
|
||||
#[test]
|
||||
fn classifies_assets_and_icons_as_static_assets() {
|
||||
for path in [
|
||||
"/assets/app.js",
|
||||
"/assets",
|
||||
"/favicon.ico",
|
||||
"/icons/favicon.svg",
|
||||
"/icons/missing.svg",
|
||||
] {
|
||||
assert_eq!(
|
||||
response_header_policy(path),
|
||||
ResponseHeaderPolicy::StaticAsset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_file_routes_as_files() {
|
||||
for path in ["/f", "/f/token/image.png"] {
|
||||
assert_eq!(response_header_policy(path), ResponseHeaderPolicy::File);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_other_routes_as_application() {
|
||||
for path in [
|
||||
"/",
|
||||
"/api/auth/me",
|
||||
"/static/missing.css",
|
||||
"/files/legacy/image.png",
|
||||
"/unknown",
|
||||
] {
|
||||
assert_eq!(
|
||||
response_header_policy(path),
|
||||
ResponseHeaderPolicy::Application
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_asset_policy_only_adds_nosniff() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=3600"),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::StaticAsset, &mut headers);
|
||||
|
||||
assert_eq!(headers.len(), 2);
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||
assert!(!headers.contains_key("referrer-policy"));
|
||||
assert!(!headers.contains_key("permissions-policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_policy_keeps_file_headers_without_document_policies() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static("attachment; filename=\"manual.pdf\""),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::File, &mut headers);
|
||||
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert_eq!(
|
||||
headers["content-security-policy"],
|
||||
"default-src 'none'; sandbox"
|
||||
);
|
||||
assert!(headers.contains_key(header::CONTENT_DISPOSITION));
|
||||
assert!(!headers.contains_key("x-frame-options"));
|
||||
assert!(!headers.contains_key("cross-origin-opener-policy"));
|
||||
assert!(!headers.contains_key("cross-origin-resource-policy"));
|
||||
assert!(!headers.contains_key("referrer-policy"));
|
||||
assert!(!headers.contains_key("permissions-policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_policy_preserves_handler_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("default-src 'none'; sandbox"),
|
||||
);
|
||||
|
||||
apply_response_headers(ResponseHeaderPolicy::Application, &mut headers);
|
||||
|
||||
assert_eq!(
|
||||
headers["content-security-policy"],
|
||||
"default-src 'none'; sandbox"
|
||||
);
|
||||
assert_eq!(headers["x-frame-options"], "DENY");
|
||||
assert_eq!(headers["cross-origin-opener-policy"], "same-origin");
|
||||
assert_eq!(headers["cross-origin-resource-policy"], "same-origin");
|
||||
assert_eq!(headers[header::X_CONTENT_TYPE_OPTIONS], "nosniff");
|
||||
assert_eq!(
|
||||
headers["referrer-policy"],
|
||||
"strict-origin-when-cross-origin"
|
||||
);
|
||||
assert!(headers.contains_key("permissions-policy"));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn share_exchange_redirects_to_canonical_resource_path() {
|
||||
let uri: Uri = "/w/private?view=all&share=secret&page=2".parse().unwrap();
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?view=all&page=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_or_repeated_parameters_are_parsed_without_rejection() {
|
||||
let uri: Uri = "/w/private?%73hare=one&share=two".parse().unwrap();
|
||||
assert_eq!(
|
||||
share_token_from_query(uri.query()),
|
||||
(true, Some("one".into()))
|
||||
);
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_share_parameter_is_still_removed_from_the_url() {
|
||||
let uri: Uri = "/w/private?share=%ZZ&keep=no".parse().unwrap();
|
||||
assert_eq!(share_token_from_query(uri.query()), (true, None));
|
||||
assert_eq!(canonical_resource_url(&uri), "/w/private?keep=no");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
fn operation(components: Vec<OperationComponent>) -> TextOperation {
|
||||
TextOperation { components }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_insertions_have_stable_order() {
|
||||
let left = operation(vec![
|
||||
OperationComponent::Retain { count: 1 },
|
||||
OperationComponent::Insert {
|
||||
text: "X".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let right = operation(vec![
|
||||
OperationComponent::Retain { count: 1 },
|
||||
OperationComponent::Insert {
|
||||
text: "Y".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let left_prime = transform_operation(&left, &right, true).unwrap();
|
||||
let right_prime = transform_operation(&right, &left, false).unwrap();
|
||||
let after_right = apply_operation_to_document("aYb", "[]", &left_prime, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
let after_left = apply_operation_to_document("aXb", "[]", &right_prime, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(after_right, "aXYb");
|
||||
assert_eq!(after_left, "aXYb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_offsets_support_emoji() {
|
||||
let operation = operation(vec![
|
||||
OperationComponent::Retain { count: 3 },
|
||||
OperationComponent::Insert {
|
||||
text: "x".into(),
|
||||
owners: Vec::new(),
|
||||
},
|
||||
OperationComponent::Retain { count: 1 },
|
||||
]);
|
||||
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result, "A😀xB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_from_edit_preserves_utf16_boundaries() {
|
||||
let operation = operation_from_edit("A😀B", "A😀xB", "[]");
|
||||
let result = apply_operation_to_document("A😀B", "[]", &operation, &[])
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result, "A😀xB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acknowledgements_survive_history_compaction() {
|
||||
let mut document = CollaborativeDocument::new(String::new(), "[]".into(), 0);
|
||||
for update_id in 1..=MAX_OPERATION_HISTORY as u64 + 8 {
|
||||
let base_revision_id = document.revision_id;
|
||||
let revision_id = base_revision_id + 1;
|
||||
document.revision_id = revision_id;
|
||||
document.record(AppliedOperation {
|
||||
base_revision_id,
|
||||
revision_id,
|
||||
client_id: "client-123".into(),
|
||||
update_id,
|
||||
operation: TextOperation::default(),
|
||||
owner_replacements: Vec::new(),
|
||||
});
|
||||
}
|
||||
assert!(document.has_applied_update("client-123", 1));
|
||||
assert_eq!(
|
||||
document.acknowledged_updates("client-123"),
|
||||
vec![MAX_OPERATION_HISTORY as u64 + 8]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::{megabytes_to_bytes, multipart_body_limit_bytes};
|
||||
|
||||
#[test]
|
||||
fn converts_upload_megabytes_to_bytes() {
|
||||
assert_eq!(megabytes_to_bytes("LIMIT", 5).unwrap(), 5 * 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_overflowing_upload_limit() {
|
||||
assert!(megabytes_to_bytes("LIMIT", u64::MAX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_limit_uses_user_limit_when_guest_uploads_are_disabled() {
|
||||
assert_eq!(
|
||||
multipart_body_limit_bytes(20 * 1024 * 1024, false, 50 * 1024 * 1024),
|
||||
21 * 1024 * 1024
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_limit_uses_larger_enabled_guest_limit() {
|
||||
assert_eq!(
|
||||
multipart_body_limit_bytes(20 * 1024 * 1024, true, 50 * 1024 * 1024),
|
||||
51 * 1024 * 1024
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_security_from_standard_ports() {
|
||||
assert_eq!(smtp_security_for_port(25), SmtpSecurity::None);
|
||||
assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls);
|
||||
assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls);
|
||||
assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_supported_security_modes() {
|
||||
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
|
||||
assert_eq!(
|
||||
parse_smtp_security("starttls").unwrap(),
|
||||
SmtpSecurity::StartTls
|
||||
);
|
||||
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
|
||||
assert!(parse_smtp_security("auto").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_smtp_from() {
|
||||
assert_eq!(
|
||||
normalize_smtp_from(" \"RustPad <rustpad@notes.example>\" ".to_owned()).unwrap(),
|
||||
"RustPad <rustpad@notes.example>"
|
||||
);
|
||||
assert!(normalize_smtp_from("RustPad".to_owned()).is_err());
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
fn workspace(password_hash: Option<String>) -> Workspace {
|
||||
Workspace {
|
||||
id: 1,
|
||||
slug: "private-workspace".into(),
|
||||
title: "Private workspace".into(),
|
||||
password_hash,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
is_private: 1,
|
||||
created_by_guest_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(password_hash: Option<String>) -> Pad {
|
||||
Pad {
|
||||
id: 1,
|
||||
slug: "private-pad".into(),
|
||||
title: "Private pad".into(),
|
||||
content: String::new(),
|
||||
password_hash,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
is_private: 1,
|
||||
created_by_guest_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_password_does_not_grant_password_access() {
|
||||
assert!(!verify_workspace_password(&workspace(None), None));
|
||||
assert!(!verify_workspace_password(
|
||||
&workspace(None),
|
||||
Some("anything")
|
||||
));
|
||||
assert!(!verify_pad_password(&pad(None), None));
|
||||
assert!(!verify_pad_password(&pad(None), Some("anything")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_password_is_verified() {
|
||||
let workspace = workspace(Some(hash_password("workspace-secret")));
|
||||
assert!(verify_workspace_password(
|
||||
&workspace,
|
||||
Some("workspace-secret")
|
||||
));
|
||||
assert!(!verify_workspace_password(&workspace, Some("wrong")));
|
||||
|
||||
let pad = pad(Some(hash_password("pad-secret")));
|
||||
assert!(verify_pad_password(&pad, Some("pad-secret")));
|
||||
assert!(!verify_pad_password(&pad, Some("wrong")));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_bare_domain_and_http_origins() {
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("files.note.example.com".into())).unwrap(),
|
||||
Some("https://files.note.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_public_base(Some("http://localhost:3001/".into())).unwrap(),
|
||||
Some("http://localhost:3001".into())
|
||||
);
|
||||
assert_eq!(normalize_public_base(Some(" ".into())).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_origin_public_urls() {
|
||||
assert!(normalize_public_base(Some("ftp://files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://files.example.com/path".into())).is_err());
|
||||
assert!(normalize_public_base(Some("https://user@files.example.com".into())).is_err());
|
||||
assert!(normalize_public_base(Some("files.example.com\\path".into())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_canonical_path_from_relative_and_absolute_urls() {
|
||||
assert_eq!(
|
||||
canonical_file_path("/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(
|
||||
canonical_file_path("https://files.example.com/f/token/image.png?download=1"),
|
||||
Some("/f/token/image.png".into())
|
||||
);
|
||||
assert_eq!(canonical_file_path("/files/token/image.png"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_between_custom_origin_and_application_path() {
|
||||
let stored = "/f/token/manual.pdf";
|
||||
assert_eq!(public_file_url(None, stored), stored);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), stored),
|
||||
"https://files.example.com/f/token/manual.pdf"
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(None, "https://old.example.com/f/token/manual.pdf"),
|
||||
stored
|
||||
);
|
||||
assert_eq!(
|
||||
public_file_url(Some("https://files.example.com"), "/invalid/path"),
|
||||
"/invalid/path"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::startup_credential;
|
||||
|
||||
#[test]
|
||||
fn startup_credential_contains_product_identity() {
|
||||
let credential = startup_credential();
|
||||
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
||||
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
|
||||
assert!(
|
||||
credential.contains("https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn every_backend_has_explicit_queries() {
|
||||
for query in [
|
||||
Q001,
|
||||
Q003,
|
||||
Q004,
|
||||
Q011,
|
||||
Q021,
|
||||
Q033,
|
||||
USER_LIST_WORKSPACES,
|
||||
USER_LIST_PADS,
|
||||
SHARE_LINK_SESSION_SOURCE,
|
||||
SHARE_SESSION_INSERT,
|
||||
SHARE_SESSION_PERMISSION,
|
||||
SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
PAD_PUBLIC_PAGE_DISABLED,
|
||||
NOTE_PUBLIC_PAGE_DISABLED,
|
||||
] {
|
||||
assert!(!get(DatabaseKind::Sqlite, query).is_empty());
|
||||
assert!(!get(DatabaseKind::Postgres, query).is_empty());
|
||||
assert!(!get(DatabaseKind::MySql, query).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn boolean_projections_are_normalized_for_sqlx_any() {
|
||||
// MySQL BOOLEAN is TINYINT(1), which sqlx::Any 0.8 cannot map directly.
|
||||
for (query, expected_casts) in [
|
||||
(Query::RESOURCE_EDITOR_SETTINGS_SELECT, 1),
|
||||
(Query::EDITOR_PREFERENCES_SELECT_PAD, 4),
|
||||
(Query::EDITOR_PREFERENCES_SELECT_NOTE, 4),
|
||||
(Query::AUTH_USER_BY_EXTERNAL_ID, 1),
|
||||
(Query::AUTH_USER_BY_SESSION, 1),
|
||||
(Query::AUTH_USER_BY_NICKNAME, 1),
|
||||
(Query::AUTH_USER_BY_EMAIL, 1),
|
||||
(Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1),
|
||||
(Query::USER_LIST_WORKSPACES, 6),
|
||||
(Query::USER_LIST_PADS, 6),
|
||||
(Query::PAD_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::NOTE_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::Q001, 1),
|
||||
(Query::Q003, 1),
|
||||
(Query::Q004, 1),
|
||||
(Query::Q011, 1),
|
||||
(Query::Q021, 1),
|
||||
(Query::Q033, 1),
|
||||
(Query::Q036, 1),
|
||||
(Query::Q038, 1),
|
||||
(Query::Q046, 1),
|
||||
(Query::Q044, 1),
|
||||
(Query::Q045, 1),
|
||||
(Query::Q048, 1),
|
||||
(Query::Q049, 1),
|
||||
] {
|
||||
let sql = get(query);
|
||||
assert_eq!(
|
||||
sql.matches("AS SIGNED").count(),
|
||||
expected_casts,
|
||||
"MySQL boolean projection is not normalized in {query:?}: {sql}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
fn websocket_headers(origin: &'static str, host: &'static str) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::ORIGIN, HeaderValue::from_static(origin));
|
||||
headers.insert(header::HOST, HeaderValue::from_static(host));
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sessions_are_cookie_only() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer legacy-account-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), None);
|
||||
assert_eq!(bearer_token(&headers), Some("legacy-account-token"));
|
||||
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_static("__Host-rustpad_session=cookie-token"),
|
||||
);
|
||||
assert_eq!(session_token(&headers), Some("cookie-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_proxy_controlled_real_ip() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-for"),
|
||||
HeaderValue::from_static("203.0.113.10"),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-real-ip"),
|
||||
HeaderValue::from_static("198.51.100.20"),
|
||||
);
|
||||
assert_eq!(client_key(&headers), "ip:198.51.100.20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_same_origin_websocket() {
|
||||
let headers = websocket_headers("https://pad.example.com", "pad.example.com");
|
||||
assert!(websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_origin_websocket() {
|
||||
let headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_trust_forwarded_host_for_websocket_origin() {
|
||||
let mut headers = websocket_headers("https://evil.example", "pad.example.com");
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static("x-forwarded-host"),
|
||||
HeaderValue::from_static("evil.example"),
|
||||
);
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_origin_with_path() {
|
||||
let headers = websocket_headers("https://pad.example.com/other", "pad.example.com");
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_websocket_origin() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::HOST, HeaderValue::from_static("pad.example.com"));
|
||||
assert!(!websocket_origin_allowed(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_cookies_are_not_script_readable() {
|
||||
let value = session_cookie("abc123", 7).to_str().unwrap();
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
assert!(value.starts_with("__Host-rustpad_session=abc123;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_sessions_use_separate_scoped_opaque_cookies() {
|
||||
let value = share_session_cookie("workspace", "private-space", "opaque", 600)
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(value.starts_with("__Host-rustpad_share_"));
|
||||
assert!(value.contains("=opaque;"));
|
||||
assert!(value.contains("Max-Age=600"));
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_requires_matching_cookie_and_header() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!("{CSRF_COOKIE}={token}")).unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_str(&token).unwrap(),
|
||||
);
|
||||
assert!(csrf_request_is_valid(&headers));
|
||||
|
||||
headers.insert(
|
||||
axum::http::HeaderName::from_static(CSRF_HEADER),
|
||||
HeaderValue::from_static(
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
),
|
||||
);
|
||||
assert!(!csrf_request_is_valid(&headers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csrf_cookie_is_strict_and_script_readable() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
let value = csrf_cookie(&token).to_str().unwrap();
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Strict"));
|
||||
assert!(!value.contains("HttpOnly"));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_password_event_reaches_the_workspace_page_and_its_notes() {
|
||||
let workspace_key = AppState::workspace_room_key("team");
|
||||
let note_prefix = "workspace:team/";
|
||||
|
||||
assert!(workspace_password_event_channel(
|
||||
"workspace:team",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
assert!(workspace_password_event_channel(
|
||||
"workspace:team/roadmap",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
assert!(!workspace_password_event_channel(
|
||||
"workspace:team-two/roadmap",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
assert!(!workspace_password_event_channel(
|
||||
"workspace:teams/roadmap",
|
||||
&workspace_key,
|
||||
note_prefix,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn password_event_only_excludes_the_matching_connection() {
|
||||
assert!(password_event_excludes_connection(
|
||||
Some("client_owner"),
|
||||
"client_owner",
|
||||
));
|
||||
assert!(!password_event_excludes_connection(
|
||||
Some("client_owner"),
|
||||
"client_visitor",
|
||||
));
|
||||
assert!(!password_event_excludes_connection(None, "client_owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_event_client_id_is_validated() {
|
||||
assert_eq!(
|
||||
clean_collaboration_client_id(Some("client_owner_123".into())),
|
||||
Some("client_owner_123".into()),
|
||||
);
|
||||
assert_eq!(clean_collaboration_client_id(Some("short".into())), None);
|
||||
assert_eq!(
|
||||
clean_collaboration_client_id(Some("invalid client id!".into())),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_watch_authentication_accepts_a_minimal_client_message() {
|
||||
let message = serde_json::from_str::<WorkspaceWatchClientMessage>(
|
||||
r#"{"type":"authenticate","access_token":null,"client_id":"workspace_watch_123"}"#,
|
||||
)
|
||||
.expect("workspace watch authentication should parse");
|
||||
|
||||
match message {
|
||||
WorkspaceWatchClientMessage::Authenticate {
|
||||
access_token,
|
||||
client_id,
|
||||
} => {
|
||||
assert!(access_token.is_none());
|
||||
assert_eq!(client_id.as_deref(), Some("workspace_watch_123"));
|
||||
}
|
||||
WorkspaceWatchClientMessage::Ping { .. } => panic!("unexpected ping message"),
|
||||
}
|
||||
}
|
||||
+321
-1
@@ -162,11 +162,37 @@ enum ServerMessage {
|
||||
Diagnostics {
|
||||
diagnostics: ConnectionDiagnostics,
|
||||
},
|
||||
PasswordRequired,
|
||||
PasswordChanged,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum WorkspaceWatchClientMessage {
|
||||
Authenticate {
|
||||
#[serde(default)]
|
||||
access_token: Option<String>,
|
||||
#[serde(default)]
|
||||
client_id: Option<String>,
|
||||
},
|
||||
Ping {
|
||||
nonce: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum WorkspaceWatchServerMessage {
|
||||
Watching,
|
||||
Pong { nonce: u64 },
|
||||
PasswordRequired,
|
||||
PasswordChanged,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
fn connection_diagnostics(
|
||||
request: &RequestClientContext,
|
||||
client: Option<ClientDiagnostics>,
|
||||
@@ -267,6 +293,13 @@ async fn password_access_from_tokens(
|
||||
false
|
||||
}
|
||||
|
||||
fn password_event_excludes_connection(
|
||||
except_client_id: Option<&str>,
|
||||
connection_client_id: &str,
|
||||
) -> bool {
|
||||
except_client_id.is_some_and(|except_client_id| except_client_id == connection_client_id)
|
||||
}
|
||||
|
||||
async fn current_resource_access(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
@@ -289,6 +322,244 @@ async fn current_resource_access(
|
||||
(read_allowed, write_allowed)
|
||||
}
|
||||
|
||||
pub async fn upgrade_workspace_watch(
|
||||
ws: WebSocketUpgrade,
|
||||
headers: HeaderMap,
|
||||
Path(workspace_slug): Path<String>,
|
||||
State(state): State<SharedState>,
|
||||
) -> Response {
|
||||
if !crate::security::websocket_origin_allowed(&headers) {
|
||||
warn!(%workspace_slug, "workspace watch websocket rejected: invalid origin");
|
||||
return (StatusCode::FORBIDDEN, "Invalid WebSocket origin").into_response();
|
||||
}
|
||||
let account_token = crate::security::session_token(&headers).map(str::to_owned);
|
||||
let share_session_token =
|
||||
crate::security::share_session_token(&headers, "workspace", &workspace_slug)
|
||||
.map(str::to_owned);
|
||||
let resource_token =
|
||||
crate::security::resource_token(&headers, "workspace", &workspace_slug).map(str::to_owned);
|
||||
ws.on_upgrade(move |socket| {
|
||||
handle_workspace_watch(
|
||||
socket,
|
||||
state,
|
||||
workspace_slug,
|
||||
account_token,
|
||||
share_session_token,
|
||||
resource_token,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn workspace_watch_access_lost_message(
|
||||
state: &SharedState,
|
||||
workspace_slug: &str,
|
||||
) -> WorkspaceWatchServerMessage {
|
||||
if db::find_workspace(&state.db, workspace_slug)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|workspace| workspace.password_hash.is_some())
|
||||
{
|
||||
WorkspaceWatchServerMessage::PasswordRequired
|
||||
} else {
|
||||
WorkspaceWatchServerMessage::Error {
|
||||
message: "Access expired or revoked".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_workspace_watch(
|
||||
mut socket: WebSocket,
|
||||
state: SharedState,
|
||||
workspace_slug: String,
|
||||
cookie_session_token: Option<String>,
|
||||
cookie_share_session_token: Option<String>,
|
||||
cookie_password_token: Option<String>,
|
||||
) {
|
||||
let (explicit_access_token, collaboration_client_id) = match socket.recv().await {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
match serde_json::from_str::<WorkspaceWatchClientMessage>(&text) {
|
||||
Ok(WorkspaceWatchClientMessage::Authenticate {
|
||||
access_token,
|
||||
client_id,
|
||||
}) => (
|
||||
access_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "cookie")
|
||||
.map(str::to_owned),
|
||||
clean_collaboration_client_id(client_id)
|
||||
.unwrap_or_else(|| format!("watch_{}", db::random_suffix(24))),
|
||||
),
|
||||
_ => {
|
||||
let _ = send_workspace_watch(
|
||||
&mut socket,
|
||||
&WorkspaceWatchServerMessage::Error {
|
||||
message: "Authentication required".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
let _ = send_workspace_watch(
|
||||
&mut socket,
|
||||
&WorkspaceWatchServerMessage::Error {
|
||||
message: "Workspace not found".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let session_token = cookie_session_token;
|
||||
let external_tokens = [
|
||||
explicit_access_token.as_deref(),
|
||||
cookie_share_session_token.as_deref(),
|
||||
cookie_password_token.as_deref(),
|
||||
];
|
||||
let channel = state.workspace_channel(&workspace_slug).await;
|
||||
let mut updates = channel.subscribe();
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
if !read_allowed {
|
||||
let message = if workspace.password_hash.is_some() {
|
||||
WorkspaceWatchServerMessage::PasswordRequired
|
||||
} else {
|
||||
WorkspaceWatchServerMessage::Error {
|
||||
message: "Workspace not found".into(),
|
||||
}
|
||||
};
|
||||
let _ = send_workspace_watch(&mut socket, &message).await;
|
||||
return;
|
||||
}
|
||||
if send_workspace_watch(&mut socket, &WorkspaceWatchServerMessage::Watching)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let mut access_refresh = tokio::time::interval(Duration::from_secs(10));
|
||||
access_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
incoming = receiver.next() => {
|
||||
match incoming {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
match serde_json::from_str::<WorkspaceWatchClientMessage>(&text) {
|
||||
Ok(WorkspaceWatchClientMessage::Ping { nonce }) => {
|
||||
if send_workspace_watch_split(
|
||||
&mut sender,
|
||||
&WorkspaceWatchServerMessage::Pong { nonce },
|
||||
).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(WorkspaceWatchClientMessage::Authenticate { .. }) => {}
|
||||
Err(error) => warn!(%error, %workspace_slug, "invalid workspace watch message"),
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => {
|
||||
debug!(%error, %workspace_slug, "workspace watch receive error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = access_refresh.tick() => {
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
false,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let message = workspace_watch_access_lost_message(
|
||||
&state,
|
||||
&workspace_slug,
|
||||
).await;
|
||||
let _ = send_workspace_watch_split(
|
||||
&mut sender,
|
||||
&message,
|
||||
).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
update = updates.recv() => {
|
||||
match update {
|
||||
Ok(RoomEvent::PasswordRequired { except_client_id }) => {
|
||||
if password_event_excludes_connection(
|
||||
except_client_id.as_deref(),
|
||||
&collaboration_client_id,
|
||||
) {
|
||||
let _ = send_workspace_watch_split(
|
||||
&mut sender,
|
||||
&WorkspaceWatchServerMessage::PasswordChanged,
|
||||
).await;
|
||||
break;
|
||||
}
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
false,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _ = send_workspace_watch_split(
|
||||
&mut sender,
|
||||
&WorkspaceWatchServerMessage::PasswordRequired,
|
||||
).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
false,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _ = send_workspace_watch_split(
|
||||
&mut sender,
|
||||
&WorkspaceWatchServerMessage::PasswordRequired,
|
||||
).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merged from note.rs
|
||||
pub async fn upgrade(
|
||||
ws: WebSocketUpgrade,
|
||||
@@ -760,6 +1031,28 @@ async fn handle_socket(
|
||||
}
|
||||
},
|
||||
update=updates.recv()=>{
|
||||
if let Ok(RoomEvent::PasswordRequired { except_client_id }) = &update {
|
||||
if password_event_excludes_connection(
|
||||
except_client_id.as_deref(),
|
||||
&collaboration_client_id,
|
||||
) {
|
||||
let _=send_split(&mut sender,&ServerMessage::PasswordChanged).await;
|
||||
break;
|
||||
}
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
&workspace_slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
false,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_split(&mut sender,&ServerMessage::PasswordRequired).await;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
@@ -776,6 +1069,7 @@ async fn handle_socket(
|
||||
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{base_revision_id:update.base_revision_id,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,client_id:update.client_id,update_id:update.update_id,operation:update.operation,owner_replacements:update.owner_replacements}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
|
||||
Ok(RoomEvent::PasswordRequired { .. })=>{},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>{
|
||||
let document=collaborative_document.lock().await;
|
||||
let snapshot=(document.content.clone(),document.revision_id,document.owner_map.clone(),document.acknowledged_updates(&collaboration_client_id));
|
||||
@@ -795,7 +1089,7 @@ async fn handle_socket(
|
||||
"note websocket disconnected"
|
||||
);
|
||||
}
|
||||
fn clean_collaboration_client_id(value: Option<String>) -> Option<String> {
|
||||
pub(crate) fn clean_collaboration_client_id(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().chars().take(64).collect::<String>())
|
||||
.filter(|value| {
|
||||
@@ -887,4 +1181,30 @@ async fn send_split(
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_workspace_watch(
|
||||
socket: &mut WebSocket,
|
||||
message: &WorkspaceWatchServerMessage,
|
||||
) -> Result<(), axum::Error> {
|
||||
socket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(message).unwrap().into(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_workspace_watch_split(
|
||||
sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
|
||||
message: &WorkspaceWatchServerMessage,
|
||||
) -> Result<(), axum::Error> {
|
||||
sender
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(message).unwrap().into(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
// Merged from pad.rs
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/websocket.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -42,6 +42,8 @@ enum PadServerMessage {
|
||||
Diagnostics {
|
||||
diagnostics: ConnectionDiagnostics,
|
||||
},
|
||||
PasswordRequired,
|
||||
PasswordChanged,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
@@ -528,6 +530,28 @@ async fn handle_pad_socket(
|
||||
}
|
||||
},
|
||||
update=updates.recv()=>{
|
||||
if let Ok(RoomEvent::PasswordRequired { except_client_id }) = &update {
|
||||
if password_event_excludes_connection(
|
||||
except_client_id.as_deref(),
|
||||
&collaboration_client_id,
|
||||
) {
|
||||
let _=send_pad_split(&mut sender,&PadServerMessage::PasswordChanged).await;
|
||||
break;
|
||||
}
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"pad",
|
||||
&slug,
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
false,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_pad_split(&mut sender,&PadServerMessage::PasswordRequired).await;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (read_allowed, _) = current_resource_access(
|
||||
&state,
|
||||
"pad",
|
||||
@@ -544,6 +568,7 @@ async fn handle_pad_socket(
|
||||
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{base_revision_id:u.base_revision_id,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,client_id:u.client_id,update_id:u.update_id,operation:u.operation,owner_replacements:u.owner_replacements}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
|
||||
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
|
||||
Ok(RoomEvent::PasswordRequired { .. })=>{},
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>{
|
||||
let document=collaborative_document.lock().await;
|
||||
let snapshot=(document.content.clone(),document.revision_id,document.owner_map.clone(),document.acknowledged_updates(&collaboration_client_id));
|
||||
|
||||
Reference in New Issue
Block a user