improvements
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.36"
|
||||
version = "0.2.38"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -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
@@ -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));
|
||||
|
||||
+77
-2
@@ -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
@@ -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>`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user