From a9d97fa763473066238cea5934af7d6ab40f5381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 30 Jul 2026 11:55:22 +0200 Subject: [PATCH] security upgrade --- migrations/mysql/0024_guest_owner.sql | 2 + migrations/postgres/0024_guest_owner.sql | 2 + migrations/sqlite/0024_guest_owner.sql | 2 + src/api/files.rs | 80 ++++-------- src/api/mod.rs | 147 ++++++++++++++++++----- src/api/pads_public.rs | 34 +++++- src/db/editor_preferences.rs | 5 +- src/db/mod.rs | 11 ++ src/queries/mysql.rs | 10 +- src/queries/postgres.rs | 10 +- src/queries/sqlite.rs | 10 +- src/security.rs | 2 +- static/js/home.js | 3 +- static/js/note-editor.js | 8 +- static/js/note-files.js | 7 +- static/js/workspace.js | 3 +- 16 files changed, 217 insertions(+), 119 deletions(-) create mode 100644 migrations/mysql/0024_guest_owner.sql create mode 100644 migrations/postgres/0024_guest_owner.sql create mode 100644 migrations/sqlite/0024_guest_owner.sql diff --git a/migrations/mysql/0024_guest_owner.sql b/migrations/mysql/0024_guest_owner.sql new file mode 100644 index 0000000..8eadb12 --- /dev/null +++ b/migrations/mysql/0024_guest_owner.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes ADD COLUMN created_by_guest_id VARCHAR(64) NULL; +ALTER TABLE pads ADD COLUMN created_by_guest_id VARCHAR(64) NULL; diff --git a/migrations/postgres/0024_guest_owner.sql b/migrations/postgres/0024_guest_owner.sql new file mode 100644 index 0000000..f0d00de --- /dev/null +++ b/migrations/postgres/0024_guest_owner.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT; +ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT; diff --git a/migrations/sqlite/0024_guest_owner.sql b/migrations/sqlite/0024_guest_owner.sql new file mode 100644 index 0000000..f0d00de --- /dev/null +++ b/migrations/sqlite/0024_guest_owner.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes ADD COLUMN created_by_guest_id TEXT; +ALTER TABLE pads ADD COLUMN created_by_guest_id TEXT; diff --git a/src/api/files.rs b/src/api/files.rs index 8e953fa..755c0b3 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -16,6 +16,7 @@ pub async fn upload_pad_file( Path(slug): Path, mut multipart: Multipart, ) -> Result, ApiError> { + require_upload_permission(&state, &headers).await?; let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; @@ -60,14 +61,6 @@ pub async fn upload_pad_file( &headers, ) .await?; - require_upload_permission( - &state, - &headers, - "pad", - &slug, - resource_request_token(&headers, "pad", &slug, access_token.as_deref()), - ) - .await?; let level = if db::verify_pad_password(&pad, password.as_deref()) || (pad.is_private == 0 && pad.password_hash.is_none()) { @@ -190,10 +183,18 @@ pub async fn delete_pad_file( &headers, ) .await?; - if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers)) - .await - .unwrap_or(false) - { + let account_owner = crate::auth::is_resource_owner( + &state, + "pad", + &pad.slug, + bearer_token(&headers), + ) + .await + .unwrap_or(false); + let guest_owner = pad_creator_is_requester(&headers, &pad); + let password_write_access = + has_password_write_access(&state, &headers, "pad", &pad.slug).await?; + if !account_owner && !guest_owner && !password_write_access { return Err(ApiError::forbidden("Only the note owner can delete files")); } let file = db::find_pad_file(&state.db, pad.id, file_id) @@ -212,6 +213,7 @@ pub async fn upload_note_file( Path((workspace_slug, note_slug)): Path<(String, String)>, mut multipart: Multipart, ) -> Result, ApiError> { + require_upload_permission(&state, &headers).await?; let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; @@ -258,20 +260,6 @@ pub async fn upload_note_file( ) .await?; - require_upload_permission( - &state, - &headers, - "workspace", - &workspace_slug, - resource_request_token( - &headers, - "workspace", - &workspace_slug, - access_token.as_deref(), - ), - ) - .await?; - let level = if db::verify_workspace_password(&workspace, password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) { @@ -433,19 +421,12 @@ pub async fn delete_note_file( ) .await .unwrap_or(false); - let note_owner = crate::auth::optional_user(&state, &headers) - .await - .ok() - .flatten() - .and_then(|user| { - note.created_by - .as_deref() - .map(|creator| creator == user.nickname) - }) - .unwrap_or(false); - if !workspace_owner && !note_owner { + 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?; + if !workspace_owner && !note_owner && !password_write_access { return Err(ApiError::forbidden( - "Only the note owner or workspace owner can delete files", + "Only the note owner, workspace owner, or password holder can delete files", )); } let file = db::find_note_file(&state.db, note.id, file_id) @@ -461,29 +442,16 @@ pub async fn delete_note_file( async fn require_upload_permission( state: &SharedState, headers: &HeaderMap, - kind: &str, - slug: &str, - resource_token: Option<&str>, ) -> Result<(), ApiError> { - let permission = crate::auth::share_link_permission(state, kind, slug, resource_token) - .await - .map_err(|error| ApiError::forbidden(&error.message))?; - if permission.as_deref() == Some("rw") { - return Ok(()); - } - let user = crate::auth::optional_user(state, headers) .await .map_err(|error| ApiError::forbidden(&error.message))?; if user.is_some() { - return Ok(()); - } - - match permission.as_deref() { - Some("ro") => Err(ApiError::forbidden("Read-only access.")), - _ => Err(ApiError::forbidden( - "Log in or use a read-write share link to upload files.", - )), + Ok(()) + } else { + Err(ApiError::forbidden( + "Log in with read-write access to upload files.", + )) } } diff --git a/src/api/mod.rs b/src/api/mod.rs index e3f9024..103fd11 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -76,6 +76,44 @@ async fn session_user( .map_err(|error| ApiError::forbidden(&error.message)) } +fn requester_guest_id(headers: &HeaderMap) -> Option<&str> { + crate::security::cookie_value(headers, "rustpad_guest_id") + .map(str::trim) + .filter(|value| { + (16..=64).contains(&value.len()) + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + }) +} + +async fn note_creator_is_requester( + state: &SharedState, + headers: &HeaderMap, + note: &db::Note, +) -> Result { + 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)); + } + let Some(user) = session_user(state, headers).await? else { + return Ok(false); + }; + Ok(note + .created_by + .as_deref() + .is_some_and(|creator| creator == user.nickname)) +} + +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 + }) +} + async fn has_write_permission( state: &SharedState, headers: &HeaderMap, @@ -94,9 +132,21 @@ async fn has_write_permission( } let session = crate::security::session_cookie_token(headers); if session != resource && session != authorization { - return Ok(account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write); + if account_token_access_level(state, kind, slug, session).await? >= AccessLevel::Write { + return Ok(true); + } + } + match kind { + "workspace" => Ok(db::find_workspace(&state.db, slug) + .await? + .is_some_and(|workspace| { + workspace.is_private == 0 && workspace.password_hash.is_none() + })), + "pad" => Ok(db::find_pad(&state.db, slug) + .await? + .is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())), + _ => Ok(false), } - Ok(false) } #[derive(Debug, Serialize)] @@ -262,6 +312,7 @@ pub struct NoteInfo { created_at: String, updated_at: String, can_delete_files: bool, + can_upload_files: bool, global_color: Option, note_color: Option, authorship_mode: String, @@ -327,6 +378,7 @@ async fn save_editor_settings( settings_kind: &str, settings_slug: &str, resource: db::EditorPreferenceResource, + creator_can_manage_authorship: bool, payload: EditorSettingsRequest, ) -> Result, ApiError> { if !has_write_permission( @@ -341,10 +393,6 @@ async fn save_editor_settings( "Read and write access is required to save editor preferences", )); } - let user = session_user(state, headers) - .await? - .ok_or_else(|| ApiError::forbidden("Log in to save personal editor preferences"))?; - let wants_personal_update = payload.compact_view.is_some() || payload.editor_line_numbers.is_some() || payload.preview_line_numbers.is_some() @@ -355,8 +403,15 @@ async fn save_editor_settings( if !wants_personal_update && !wants_global_update { return Err(ApiError::bad_request("No editor settings were provided")); } + let user = session_user(state, headers).await?; + if wants_personal_update && user.is_none() { + return Err(ApiError::forbidden( + "Log in to save personal editor preferences", + )); + } let can_manage_authorship = if wants_global_update { - crate::auth::is_resource_owner( + creator_can_manage_authorship + || crate::auth::is_resource_owner( state, permission_kind, permission_slug, @@ -374,7 +429,8 @@ async fn save_editor_settings( } let preferences = if wants_personal_update { - let mut preferences = db::load_editor_preferences(&state.db, user.id, resource) + let user_id = user.as_ref().expect("personal preferences require a user").id; + let mut preferences = db::load_editor_preferences(&state.db, user_id, resource) .await? .unwrap_or_default(); if let Some(value) = payload.compact_view { @@ -430,7 +486,7 @@ async fn save_editor_settings( db::save_editor_configuration( &state.db, - user.id, + user.as_ref().map(|value| value.id), resource, preferences.as_ref(), resource_settings @@ -583,12 +639,23 @@ pub async fn create_note( } let slug = unique_note_slug(&state, workspace.id, &base).await?; - let created_by = payload - .created_by - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(|v| v.chars().take(40).collect::()); + let account_user = session_user(&state, &headers).await?; + let created_by = account_user + .as_ref() + .map(|user| user.nickname.clone()) + .or_else(|| { + payload + .created_by + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.chars().take(40).collect::()) + }); + let created_by_guest_id = if account_user.is_none() { + requester_guest_id(&headers) + } else { + None + }; let note = db::create_note( &state.db, workspace.id, @@ -596,6 +663,7 @@ pub async fn create_note( title, payload.protect, created_by.as_deref(), + created_by_guest_id.as_deref(), ) .await?; Ok(( @@ -725,9 +793,7 @@ pub async fn note_info( .await?; let resource_editor_settings = db::load_resource_editor_settings(&state.db, "note", &color_slug).await?; - let can_save_editor_settings = personal_editor_settings - && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; - let can_manage_authorship = crate::auth::is_resource_owner( + let workspace_owner = crate::auth::is_resource_owner( &state, "workspace", &workspace_slug, @@ -735,6 +801,15 @@ pub async fn note_info( ) .await .unwrap_or(false); + 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_delete_files = can_manage_authorship; + let can_upload_files = session_user(&state, &headers).await?.is_some() + && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; + let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) + && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; if workspace.is_private == 0 && !db::note_public_page_disabled(&state.db, note.id).await? @@ -755,19 +830,8 @@ pub async fn note_info( private: workspace.is_private != 0, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), - can_delete_files: { - let note_owner = session_user(&state, &headers) - .await - .ok() - .flatten() - .and_then(|user| { - note.created_by - .as_deref() - .map(|creator| creator == user.nickname) - }) - .unwrap_or(false); - can_manage_authorship || note_owner - }, + can_delete_files, + can_upload_files, global_color, note_color, authorship_mode: resource_editor_settings.authorship_mode, @@ -797,6 +861,9 @@ 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?; save_editor_settings( &state, &headers, @@ -805,6 +872,7 @@ pub async fn set_note_editor_settings( "note", &format!("{workspace_slug}/{note_slug}"), db::EditorPreferenceResource::Note(note.id), + creator_can_manage_authorship, payload, ) .await @@ -935,6 +1003,23 @@ async fn anonymous_access_token_valid( Ok(count > 0) } +async fn has_password_write_access( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, +) -> Result { + for token in [ + crate::security::resource_token(headers, kind, slug), + authorization_token(headers), + ] { + if anonymous_access_token_valid(state, kind, slug, token).await? { + return Ok(true); + } + } + Ok(false) +} + async fn external_token_access_level( state: &SharedState, kind: &str, diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index fe43ae2..5b37759 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -34,6 +34,7 @@ pub struct PadInfo { created_at: String, updated_at: String, can_delete_files: bool, + can_upload_files: bool, global_color: Option, note_color: Option, authorship_mode: String, @@ -64,11 +65,23 @@ pub async fn create_pad( )); } let slug = unique_pad_slug(&state, &base).await?; - let pad = db::create_pad(&state.db, &slug, title, password).await?; - if let Some(user) = crate::auth::optional_user(&state, &headers) + let account_user = crate::auth::optional_user(&state, &headers) .await - .map_err(|e| ApiError::forbidden(&e.message))? - { + .map_err(|e| ApiError::forbidden(&e.message))?; + let created_by_guest_id = if account_user.is_none() { + requester_guest_id(&headers) + } else { + None + }; + let pad = db::create_pad( + &state.db, + &slug, + title, + password, + created_by_guest_id, + ) + .await?; + if let Some(user) = account_user { sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)) .bind(user.id) .bind(&pad.slug) @@ -109,7 +122,7 @@ pub async fn pad_info( .await?; let resource_editor_settings = db::load_resource_editor_settings(&state.db, "pad", &slug).await?; - let can_manage_authorship = crate::auth::is_resource_owner( + let account_owner = crate::auth::is_resource_owner( &state, "pad", &slug, @@ -117,7 +130,12 @@ pub async fn pad_info( ) .await .unwrap_or(false); - let can_save_editor_settings = personal_editor_settings + 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_upload_files = session_user(&state, &headers).await?.is_some() + && has_write_permission(&state, &headers, "pad", &slug).await?; + let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) && has_write_permission(&state, &headers, "pad", &slug).await?; if pad.is_private == 0 && !db::pad_public_page_disabled(&state.db, pad.id).await? @@ -136,6 +154,7 @@ pub async fn pad_info( created_at: db::normalize_timestamp(&pad.created_at), updated_at: db::normalize_timestamp(&pad.updated_at), can_delete_files: can_manage_authorship, + can_upload_files, global_color, note_color, authorship_mode: resource_editor_settings.authorship_mode, @@ -162,6 +181,8 @@ 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?; save_editor_settings( &state, &headers, @@ -170,6 +191,7 @@ pub async fn set_pad_editor_settings( "pad", &slug, db::EditorPreferenceResource::Pad(pad.id), + creator_can_manage_authorship, payload, ) .await diff --git a/src/db/editor_preferences.rs b/src/db/editor_preferences.rs index 55c5e83..b8b270c 100644 --- a/src/db/editor_preferences.rs +++ b/src/db/editor_preferences.rs @@ -102,7 +102,7 @@ pub async fn load_editor_preferences( pub async fn save_editor_configuration( pool: &Database, - user_id: i64, + user_id: Option, resource: EditorPreferenceResource, preferences: Option<&EditorPreferences>, resource_settings: Option<(&str, &str, &ResourceEditorSettings)>, @@ -110,6 +110,9 @@ pub async fn save_editor_configuration( let mut tx = pool.pool().begin().await?; if let Some(preferences) = preferences { + let user_id = user_id.ok_or_else(|| { + sqlx::Error::Protocol("user id is required for personal editor preferences".into()) + })?; sqlx::query(queries::get( pool.kind(), preference_upsert_query(resource), diff --git a/src/db/mod.rs b/src/db/mod.rs index 0f7f284..546cbeb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -68,6 +68,8 @@ pub struct Note { pub owner_map: String, pub protected: bool, pub created_by: Option, + #[serde(skip_serializing)] + pub created_by_guest_id: Option, } #[derive(Debug, Clone, FromRow)] @@ -82,6 +84,7 @@ struct SqliteNote { owner_map: String, protected: i64, created_by: Option, + created_by_guest_id: Option, } impl From for Note { @@ -97,6 +100,7 @@ impl From for Note { owner_map: value.owner_map, protected: value.protected != 0, created_by: value.created_by, + created_by_guest_id: value.created_by_guest_id, } } } @@ -260,6 +264,7 @@ pub async fn create_note( title: &str, protected: bool, created_by: Option<&str>, + created_by_guest_id: Option<&str>, ) -> Result { sqlx::query(queries::get(pool.kind(), queries::Q005)) .bind(workspace_id) @@ -267,6 +272,7 @@ pub async fn create_note( .bind(title) .bind(protected) .bind(created_by) + .bind(created_by_guest_id) .execute(pool.pool()) .await?; @@ -388,6 +394,7 @@ pub struct Pad { pub updated_at: String, pub owner_map: String, pub is_private: i64, + pub created_by_guest_id: Option, } pub async fn find_pad(pool: &Database, slug: &str) -> Result, sqlx::Error> { @@ -402,6 +409,7 @@ pub async fn create_pad( slug: &str, title: &str, password: Option<&str>, + created_by_guest_id: Option<&str>, ) -> Result { let password_hash = password .filter(|value| !value.is_empty()) @@ -410,6 +418,7 @@ pub async fn create_pad( .bind(slug) .bind(title) .bind(password_hash) + .bind(created_by_guest_id) .execute(pool.pool()) .await?; @@ -504,6 +513,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Note { owner_map: crate::row_decode::text(row, "owner_map")?, protected: protected != 0, created_by: crate::row_decode::optional_text(row, "created_by")?, + created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?, }) } } @@ -530,6 +540,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Pad { updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, is_private: row.try_get("is_private")?, + created_by_guest_id: crate::row_decode::optional_text(row, "created_by_guest_id")?, }) } } diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 45b96b3..f1b19e1 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -255,13 +255,13 @@ pub fn get(query: Query) -> &'static str { } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#, Query::Q003 => { - r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# + r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# } Query::Q004 => { - r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? AND slug = ?"# + r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"# } Query::Q005 => { - r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"# + r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"# } Query::Q006 => { r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# @@ -275,9 +275,9 @@ pub fn get(query: Query) -> &'static str { r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"# } Query::Q011 => { - r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM pads WHERE slug = ?"# + r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private, CAST(created_by_guest_id AS CHAR CHARACTER SET utf8mb4) AS created_by_guest_id FROM pads WHERE slug = ?"# } - Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#, + Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q013 => { r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index b7ef6b2..69591f2 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -257,13 +257,13 @@ pub fn get(query: Query) -> &'static str { } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES ($1, $2, $3)"#, Query::Q003 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"# } Query::Q004 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 AND slug = $2"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = $1 AND slug = $2"# } Query::Q005 => { - r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES ($1, $2, $3, $4, $5)"# + r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES ($1, $2, $3, $4, $5, $6)"# } Query::Q006 => { r#"UPDATE notes SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"# @@ -279,9 +279,9 @@ pub fn get(query: Query) -> &'static str { r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = $1 ORDER BY id DESC LIMIT 100"# } Query::Q011 => { - r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = $1"# + r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private, created_by_guest_id FROM pads WHERE slug = $1"# } - Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES ($1, $2, $3)"#, + Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES ($1, $2, $3, $4)"#, Query::Q013 => { r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"# } diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index b3d4156..624c843 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -255,13 +255,13 @@ pub fn get(query: Query) -> &'static str { } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#, Query::Q003 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# } Query::Q004 => { - r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"# + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by, created_by_guest_id FROM notes WHERE workspace_id = ? AND slug = ?"# } Query::Q005 => { - r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"# + r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by, created_by_guest_id) VALUES (?, ?, ?, ?, ?, ?)"# } Query::Q006 => { r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# @@ -275,9 +275,9 @@ pub fn get(query: Query) -> &'static str { r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"# } Query::Q011 => { - r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM pads WHERE slug = ?"# + r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private, created_by_guest_id FROM pads WHERE slug = ?"# } - Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#, + Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash, created_by_guest_id) VALUES (?, ?, ?, ?)"#, Query::Q013 => { r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# } diff --git a/src/security.rs b/src/security.rs index 62ed671..6854c73 100644 --- a/src/security.rs +++ b/src/security.rs @@ -107,7 +107,7 @@ fn resource_cookie_name(kind: &str, slug: &str) -> String { format!("__Host-rustpad_access_{}", hex::encode(&digest[..12])) } -fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { +pub fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { headers .get(header::COOKIE) .and_then(|value| value.to_str().ok()) diff --git a/static/js/home.js b/static/js/home.js index 5e70e25..d15b7e7 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -11,7 +11,7 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger"; installGlobalDiagnostics(); import { bindIdentityDialog, handleAccountActionToken, handleAccountConfirmationToken, handleResetToken, handleShareInvitationToken, logoutCurrentSession, validateCurrentSession } from "@rustpad/auth-ui"; -import { getAuthToken, setAccessToken } from "@rustpad/session"; +import { getAuthToken, getGuestId, setAccessToken } from "@rustpad/session"; import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { safeAppUrl } from "@rustpad/security"; @@ -38,6 +38,7 @@ function setBusy(button, busy, idleText, busyText) { button.textContent = busy ? busyText : idleText; } +getGuestId(); bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "note"); bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace"); diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 95eb115..c939c54 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -612,7 +612,9 @@ export function startNoteEditor(adapter) { } const { loadFiles } = bindNoteFiles({ - editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files), + editor, toast, getAccessToken: () => accessToken, + canDelete: () => Boolean(info?.can_delete_files), + canUpload: () => Boolean(info?.can_upload_files), endpoints: adapter.fileEndpoints, onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }), }); @@ -897,7 +899,7 @@ export function startNoteEditor(adapter) { } function scheduleEditorSettingsSave({ personal = false, authorship = false } = {}) { - if (personal) pendingPersonalSettingsSave = true; + if (personal && info?.personal_editor_settings) pendingPersonalSettingsSave = true; if (authorship && info?.can_manage_authorship) pendingAuthorshipSettingsSave = true; if (!info?.can_save_editor_settings || (!pendingPersonalSettingsSave && !pendingAuthorshipSettingsSave)) return; clearTimeout(editorSettingsSaveTimer); @@ -1141,7 +1143,7 @@ export function startNoteEditor(adapter) { document.querySelector("#open-password")?.focus(); } }); - document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); + document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

Loading…

'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `
${escapeHtml(author)}

${snippet}

`; }).join("") : '

No history yet.

'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `

${escapeHtml(e.message)}

`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } }); diff --git a/static/js/note-files.js b/static/js/note-files.js index a49d098..1cf8b1b 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -11,7 +11,6 @@ import { api, uploadWithProgress } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { prepareImageFile } from "@rustpad/image-upload"; import { askConfirm } from "@rustpad/modal"; -import { getAuthToken } from "@rustpad/session"; import { safeAppUrl } from "@rustpad/security"; import { createUploadToast } from "@rustpad/toast"; @@ -41,7 +40,7 @@ function markdownCode(url, label, mimeType) { return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`; } -export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast, onFilesChanged = () => {} }) { +export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) { const dialog = document.querySelector("#files-dialog"); const list = document.querySelector("#files-list"); const input = document.querySelector("#file-input"); @@ -75,8 +74,8 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to } document.querySelector("#upload-button").addEventListener("click", () => { - if (!getAuthToken() && !getAccessToken()) { - toast("Log in or use a read-write share link to upload files."); + if (!canUpload()) { + toast("Log in with read-write access to upload files."); return; } input.click(); diff --git a/static/js/workspace.js b/static/js/workspace.js index 425ad39..63fc134 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -12,7 +12,7 @@ installGlobalDiagnostics(); import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; -import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session"; +import { getNickname, getAccessToken, getAuthToken, getGuestId, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { askConfirm } from "@rustpad/modal"; import { safeAppUrl } from "@rustpad/security"; @@ -24,6 +24,7 @@ let info; const shareToken = new URLSearchParams(location.search).get("share"); let accessToken = shareToken || getAccessToken("workspace", slug); let nickname = getNickname(); +getGuestId(); const dialog = document.querySelector("#password-dialog"); const identityDialog = document.querySelector("#identity-dialog"); const workspaceContent = document.querySelector("#workspace-content");