improvements

This commit is contained in:
Mateusz Gruszczyński
2026-08-05 09:53:28 +02:00
parent 248f4a3977
commit a9911da9a3
42 changed files with 1667 additions and 728 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.2.36"
version = "0.2.38"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.2.36"
version = "0.2.38"
edition = "2024"
rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+2 -43
View File
@@ -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(
"![diagram](https://old-files.example.com/f/token/image.png)",
"image.png",
stored,
None,
));
assert!(content_references_stored_file(
"![diagram](/f/token/image.png)",
"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
View File
@@ -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, &note).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, &note_slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, &note).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, &note).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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+54
View File
@@ -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() {
+53
View File
@@ -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));
}
+50
View File
@@ -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(
"![diagram](https://old-files.example.com/f/token/image.png)",
"image.png",
stored,
None,
));
assert!(content_references_stored_file(
"![diagram](/f/token/image.png)",
"image.png",
"https://old-files.example.com/f/token/image.png",
Some("https://new-files.example.com"),
));
}
+121
View File
@@ -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"));
}
+33
View File
@@ -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");
}
+92
View File
@@ -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]
);
}
+36
View File
@@ -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
);
}
+38
View File
@@ -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());
}
+62
View File
@@ -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")));
}
+66
View File
@@ -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"
);
}
+20
View File
@@ -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")
);
}
+34
View File
@@ -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());
}
}
+49
View File
@@ -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}"
);
}
}
+137
View File
@@ -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"));
}
+37
View File
@@ -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,
));
}
+55
View File
@@ -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
View File
@@ -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;
+25
View File
@@ -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));
+77 -2
View File
@@ -3556,15 +3556,86 @@ dialog::backdrop {
.resource-actions {
display: flex;
flex: 0 0 auto;
flex: 0 1 auto;
gap: 8px;
flex-wrap: wrap;
max-width: 100%;
margin-left: auto;
}
.resource-actions button {
padding: 7px 10px;
}
.resource-password-menu {
position: relative;
flex: 0 0 auto;
}
.resource-password-menu>summary {
list-style: none;
user-select: none;
}
.resource-password-menu>summary::-webkit-details-marker {
display: none;
}
.resource-password-menu__chevron {
font-size: .72em;
line-height: 1;
transition: transform .16s ease;
}
.resource-password-menu[open] .resource-password-menu__chevron {
transform: rotate(180deg);
}
.resource-password-menu__panel {
position: absolute;
z-index: 50;
top: calc(100% + 6px);
right: 0;
display: grid;
gap: 3px;
min-width: 180px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--panel);
box-shadow: 0 12px 30px var(--shadow-28);
white-space: nowrap;
}
.resource-password-menu__item {
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
line-height: 1.2;
text-align: left;
cursor: pointer;
}
.resource-password-menu__item:hover,
.resource-password-menu__item:focus-visible {
background: var(--surface-3);
}
.resource-password-menu__item--danger {
color: var(--danger);
}
.resource-password-menu__item--danger:hover,
.resource-password-menu__item--danger:focus-visible {
background: var(--danger-subtle-bg);
}
@media (max-width:640px) {
.resource-row {
align-items: flex-start;
@@ -3580,8 +3651,10 @@ dialog::backdrop {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
gap: 12px 16px;
width: 100%;
min-width: 0;
}
.resource-inline {
@@ -3630,6 +3703,7 @@ dialog::backdrop {
.resource-actions {
width: 100%;
margin-left: 0;
}
}
@@ -4348,6 +4422,7 @@ dialog::backdrop {
}
.resource-copy {
flex: 1 1 220px;
min-width: 0;
}
+40 -4
View File
@@ -126,6 +126,20 @@ function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
function renderResourcesPagination(meta) {
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
}
function closeResourcePasswordMenus(except = null) {
document.querySelectorAll(".resource-password-menu[open]").forEach(menu => {
if (menu !== except) menu.removeAttribute("open");
});
}
document.addEventListener("click", event => {
const menu = event.target.closest?.(".resource-password-menu");
closeResourcePasswordMenus(menu);
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") closeResourcePasswordMenus();
});
async function loadResources() {
resourcesError.textContent = ""; resourcesList.innerHTML = "<p>Loading…</p>";
try {
@@ -140,7 +154,10 @@ async function loadResources() {
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><button class="action-button action-button--secondary compact-button" type="button" data-password>Change password</button><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const passwordActions = item.protected
? `<button class="resource-password-menu__item" type="button" data-password>Change password</button><button class="resource-password-menu__item resource-password-menu__item--danger" type="button" data-remove-password>Remove password</button>`
: `<button class="resource-password-menu__item" type="button" data-password>Set password</button>`;
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${escapeHtml(safeAppUrl(item.url))}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button class="action-button action-button--secondary compact-button" type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button class="action-button action-button--primary compact-button" type="button" data-share>Share</button><details class="resource-password-menu"><summary class="action-button action-button--secondary compact-button">Password…<span class="resource-password-menu__chevron" aria-hidden="true">▾</span></summary><div class="resource-password-menu__panel">${passwordActions}</div></details><button class="action-button action-button--danger compact-button" type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
const inline = row.querySelector("[data-inline]");
const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; };
@@ -267,16 +284,17 @@ async function loadResources() {
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
});
row.querySelector("[data-password]")?.addEventListener("click", () => {
row.querySelector("[data-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>New password<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters"></label><p class="resource-inline-help">Leave empty to remove password protection.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
inline.innerHTML = `<form class="resource-password-form" autocomplete="off"><label>${item.protected ? "New password" : "Password"}<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters" required></label><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
const form = inline.querySelector("form");
const input = form.querySelector("input");
form.querySelector("[data-cancel]").addEventListener("click", closeInline);
form.addEventListener("submit", async event => {
event.preventDefault();
const password = input.value;
if (password && password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
if (password.length < 8) { setInlineMessage("Password must contain at least 8 characters.", "error"); return; }
const submit = form.querySelector('[type="submit"]');
submit.disabled = true;
setInlineMessage("");
@@ -291,6 +309,24 @@ async function loadResources() {
input.focus();
});
row.querySelector("[data-remove-password]")?.addEventListener("click", event => {
event.currentTarget.closest(".resource-password-menu")?.removeAttribute("open");
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Remove password protection from “${escapeHtml(item.title)}”?</p><p class="resource-inline-help">Anyone with the public link will be able to open it without a password.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-remove-password>Remove password</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.querySelector("[data-cancel]").addEventListener("click", closeInline);
inline.querySelector("[data-confirm-remove-password]").addEventListener("click", async event => {
event.currentTarget.disabled = true;
setInlineMessage("");
try {
await api("/api/auth/resources", { method: "PUT", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, password: "" }) });
await loadResources();
} catch (e) {
setInlineMessage(e.message, "error");
event.currentTarget.disabled = false;
}
});
});
row.querySelector("[data-delete]")?.addEventListener("click", () => {
inline.hidden = false;
inline.innerHTML = `<div class="resource-delete-confirm"><p>Delete “${escapeHtml(item.title)}” permanently?</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-delete>Delete</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
+6 -3
View File
@@ -38,7 +38,10 @@ export function createPadAdapter() {
method: "POST",
body: JSON.stringify({ kind: "pad", slug, password }),
}),
setPassword: password => api(`${base}/password`, { method: "POST", body: JSON.stringify({ password }) }),
setPassword: (password, clientId) => api(`${base}/password`, {
method: "POST",
body: JSON.stringify({ password, client_id: clientId || null }),
}),
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage, enabled }),
@@ -81,9 +84,9 @@ export function createWorkspaceNoteAdapter() {
method: "POST",
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
}),
setPassword: password => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
setPassword: (password, clientId) => api(`/api/workspaces/${encode(workspaceSlug)}/password`, {
method: "POST",
body: JSON.stringify({ password }),
body: JSON.stringify({ password, client_id: clientId || null }),
}),
publish: (accessToken, allowTaskUpdates, unprotectPage, enabled = true) => api(`${base}/publish`, {
method: "POST",
+19 -1
View File
@@ -1332,6 +1332,24 @@ export function startNoteEditor(adapter) {
onLatency: updateLatency,
onDiagnostics: renderConnectionDiagnostics,
onChat: appendChatMessage,
onPasswordRequired: async () => {
resourceUnlocked = false;
info = { ...info, protected: true, access_level: "none", can_set_password: false };
updatePageControls();
clearTimeout(saveTimer);
saveState.textContent = collaboration.hasPending()
? "Password required — pending changes kept"
: "Password required";
setDocumentReadOnly(true, "Password required");
document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue.";
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
try {
await loadNoteInfo();
adapter.configureView?.(info);
updatePageControls();
} catch { }
},
onError: message => {
hideConnectionNotice();
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
@@ -1932,7 +1950,7 @@ export function startNoteEditor(adapter) {
const submit = setPagePasswordForm.querySelector('button[type="submit"]');
submit.disabled = true;
try {
await adapter.setPassword(passwordValue);
await adapter.setPassword(passwordValue, collaborationClientId);
const access = await adapter.requestAccess(passwordValue);
setAccessToken(adapter.access.kind, adapter.access.key, access.granted);
accessToken = getAccessToken(adapter.access.kind, adapter.access.key) || shareToken;
+11
View File
@@ -109,6 +109,17 @@ class RoomSocket {
try { message = JSON.parse(event.data); } catch { return; }
this.lastMessageAt = Date.now();
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
if (message.type === "password_required") {
this.intentionalClose = true;
this.onPasswordRequired?.();
socket.close();
return;
}
if (message.type === "password_changed") {
this.intentionalClose = true;
socket.close();
return;
}
if (message.type === "error") {
this.intentionalClose = true;
this.onError?.(message.message);
+103 -5
View File
@@ -25,6 +25,12 @@ const shareToken = new URLSearchParams(location.search).get("share");
let accessToken = shareToken || getAccessToken("workspace", slug);
let nickname = getNickname();
getGuestId();
const workspaceWatchClientId = `workspace_watch_${crypto.randomUUID()}`;
let workspaceWatchSocket;
let workspaceWatchPingTimer;
let workspaceWatchReconnectTimer;
let workspaceWatchIntentionalClose = false;
let workspaceLockedForPassword = false;
const dialog = document.querySelector("#password-dialog");
const identityDialog = document.querySelector("#identity-dialog");
const workspaceContent = document.querySelector("#workspace-content");
@@ -45,6 +51,86 @@ function updateWorkspacePasswordControl() {
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
}
function stopWorkspaceWatch() {
clearInterval(workspaceWatchPingTimer);
clearTimeout(workspaceWatchReconnectTimer);
workspaceWatchPingTimer = undefined;
workspaceWatchReconnectTimer = undefined;
if (workspaceWatchSocket) {
workspaceWatchIntentionalClose = true;
workspaceWatchSocket.close();
workspaceWatchSocket = undefined;
}
}
function lockWorkspaceForPassword(message = "A password was set for this workspace. Enter it to continue.") {
workspaceLockedForPassword = true;
info = { ...info, protected: true, access_level: "none", can_set_password: false };
stopWorkspaceWatch();
workspaceContent.hidden = true;
document.querySelector("#password-error").textContent = message;
if (!dialog.open) dialog.showModal();
document.querySelector("#open-password")?.focus();
}
function scheduleWorkspaceWatchReconnect() {
clearTimeout(workspaceWatchReconnectTimer);
if (workspaceLockedForPassword || !nickname) return;
workspaceWatchReconnectTimer = window.setTimeout(connectWorkspaceWatch, 1500);
}
function connectWorkspaceWatch() {
if (workspaceLockedForPassword || !nickname) return;
if (workspaceWatchSocket?.readyState === WebSocket.OPEN || workspaceWatchSocket?.readyState === WebSocket.CONNECTING) return;
clearTimeout(workspaceWatchReconnectTimer);
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}/ws/watch/workspace/${encodeURIComponent(slug)}`);
workspaceWatchSocket = socket;
workspaceWatchIntentionalClose = false;
socket.addEventListener("open", () => {
if (socket !== workspaceWatchSocket) return;
socket.send(JSON.stringify({
type: "authenticate",
access_token: accessToken || null,
client_id: workspaceWatchClientId,
}));
clearInterval(workspaceWatchPingTimer);
workspaceWatchPingTimer = window.setInterval(() => {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping", nonce: Date.now() }));
}, 10000);
});
socket.addEventListener("message", event => {
if (socket !== workspaceWatchSocket) return;
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "password_required") {
workspaceWatchIntentionalClose = true;
lockWorkspaceForPassword();
return;
}
if (message.type === "password_changed") {
workspaceWatchIntentionalClose = true;
socket.close();
return;
}
if (message.type === "error") {
workspaceWatchIntentionalClose = true;
socket.close();
if (/password/i.test(message.message || "")) lockWorkspaceForPassword(message.message);
}
});
socket.addEventListener("close", () => {
if (socket !== workspaceWatchSocket) return;
clearInterval(workspaceWatchPingTimer);
workspaceWatchPingTimer = undefined;
workspaceWatchSocket = undefined;
if (!workspaceWatchIntentionalClose) scheduleWorkspaceWatchReconnect();
});
socket.addEventListener("error", () => {
if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) socket.close();
});
}
function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; }
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
@@ -143,11 +229,13 @@ async function openWorkspace() {
renderNotes();
renderNotesPagination(data.pagination);
updateWorkspacePasswordControl();
workspaceLockedForPassword = false;
workspaceContent.hidden = false;
if (dialog.open) dialog.close();
connectWorkspaceWatch();
} catch (e) {
if (info?.protected || e.message.toLowerCase().includes("password")) {
document.querySelector("#password-error").textContent = e.message;
if (!dialog.open) dialog.showModal();
lockWorkspaceForPassword(e.message);
} else if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
} else document.querySelector("#workspace-error").textContent = e.message;
@@ -160,7 +248,8 @@ async function init() {
document.querySelector("#workspace-title").textContent = info.title;
document.querySelector("#workspace-url").textContent = location.pathname;
updateWorkspacePasswordControl();
if (info.protected && info.access_level === "none") dialog.showModal(); else openWorkspace();
if (info.protected && info.access_level === "none") lockWorkspaceForPassword("Enter the workspace password to continue.");
else await openWorkspace();
} catch (e) {
if (e.status === 403 || e.status === 404) await showSystemNotFound();
else document.querySelector("#workspace-error").textContent = e.message;
@@ -175,7 +264,7 @@ document.querySelector("#password-form").addEventListener("submit", async e => {
accessToken = getAccessToken("workspace", slug);
document.querySelector("#open-password").value = "";
document.querySelector("#password-error").textContent = "";
openWorkspace();
await openWorkspace();
} catch (error) { document.querySelector("#password-error").textContent = error.message; }
});
workspacePasswordForm.addEventListener("submit", async event => {
@@ -191,7 +280,7 @@ workspacePasswordForm.addEventListener("submit", async event => {
try {
await api(`/api/workspaces/${encodeURIComponent(slug)}/password`, {
method: "POST",
body: JSON.stringify({ password }),
body: JSON.stringify({ password, client_id: workspaceWatchClientId }),
});
const result = await api("/api/access-token", {
method: "POST",
@@ -278,5 +367,14 @@ identityDialog.addEventListener("close", () => {
});
});
dialog.addEventListener("cancel", event => {
if (workspaceLockedForPassword) {
event.preventDefault();
document.querySelector("#open-password")?.focus();
}
});
window.addEventListener("beforeunload", stopWorkspaceWatch);
setNotesView(notesView);
startAuthorizedWorkspace();