From 7989a873e701dc0da2d143057d6bfb48a9c4d9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 6 Aug 2026 09:08:02 +0200 Subject: [PATCH] new functions and fixes --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/api/files.rs | 42 ++++++++++------- src/api/mod.rs | 94 ++++++++++++++++++++++++++++++++------- src/tests/api.rs | 9 ++++ static/css/styles.css | 24 +++++++++- static/js/image-upload.js | 14 +++++- static/js/note-api.js | 6 ++- static/js/note-editor.js | 8 ++++ static/js/note-files.js | 24 ++++++++-- static/js/workspace.js | 8 +++- 11 files changed, 191 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4993cd6..c0585d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.45" +version = "0.2.46" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index b810024..fb55f72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.45" +version = "0.2.46" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/api/files.rs b/src/api/files.rs index a791af8..6234784 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -400,9 +400,9 @@ pub async fn delete_note( .await? }; require_write(level)?; - if note.protected { - return Err(ApiError::bad_request( - "This note is protected and cannot be deleted", + if note.protected && !requester_owns_note(&state, &headers, &workspace, ¬e).await? { + return Err(ApiError::forbidden( + "This note is protected. Only its owner can delete it.", )); } for file in db::list_note_files(&state.db, note.id).await? { @@ -478,20 +478,30 @@ pub async fn delete_note_file( &headers, ) .await?; - let workspace_owner = crate::auth::is_resource_owner( - &state, - "workspace", - &workspace.slug, - bearer_token(&headers), - ) - .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?; - if !workspace_owner && !note_owner && !password_write_access { + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { + AccessLevel::Write + } else { + request_access_level( + &state, + &headers, + "workspace", + &workspace_slug, + resource_request_token( + &headers, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + ), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + if note.protected && !requester_owns_note(&state, &headers, &workspace, ¬e).await? { return Err(ApiError::forbidden( - "Only the note owner, workspace owner, or password holder can delete files", + "This note is protected. Only its owner can delete files.", )); } let file = db::find_note_file(&state.db, note.id, file_id) diff --git a/src/api/mod.rs b/src/api/mod.rs index 8d8dc64..e2729cf 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -136,6 +136,25 @@ fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace guest_owner_is_requester(headers, workspace.created_by_guest_id.as_deref()) } +async fn requester_owns_note( + state: &SharedState, + headers: &HeaderMap, + workspace: &db::Workspace, + note: &db::Note, +) -> Result { + let workspace_account_owner = crate::auth::is_resource_owner( + state, + "workspace", + &workspace.slug, + user_session_token(headers), + ) + .await + .unwrap_or(false); + Ok(workspace_account_owner + || workspace_creator_is_requester(headers, workspace) + || note_creator_is_requester(state, headers, note).await?) +} + async fn can_set_workspace_password( state: &SharedState, headers: &HeaderMap, @@ -399,6 +418,7 @@ pub struct NoteListItem { updated_at: String, url: String, protected: bool, + can_delete: bool, created_by: Option, participant_count: i64, file_count: i64, @@ -421,6 +441,7 @@ pub struct NoteInfo { private: bool, created_at: String, updated_at: String, + can_delete: bool, can_delete_files: bool, can_upload_files: bool, upload_max_size_bytes: Option, @@ -741,6 +762,32 @@ pub async fn open_workspace( &headers, ) .await?; + let mut access_level = effective_header_access_level( + &state, + &headers, + "workspace", + &workspace.slug, + workspace.is_private, + workspace.password_hash.is_some(), + ) + .await?; + if db::verify_workspace_password(&workspace, payload.password.as_deref()) { + access_level = AccessLevel::Write; + } + let workspace_account_owner = crate::auth::is_resource_owner( + &state, + "workspace", + &workspace.slug, + bearer_token(&headers), + ) + .await + .unwrap_or(false); + let workspace_owner = + workspace_account_owner || workspace_creator_is_requester(&headers, &workspace); + let requester_user = session_user(&state, &headers).await?; + let requester_nickname = requester_user.as_ref().map(|user| user.nickname.as_str()); + let requester_guest = requester_guest_id(&headers); + let stats = db::list_note_stats(&state.db, workspace.id) .await? .into_iter() @@ -763,6 +810,19 @@ pub async fn open_workspace( }) .map(|note| { let stats = stats.get(¬e.id); + let note_owner = if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() { + requester_guest.is_some_and(|requester| requester == owner_guest_id) + } else { + note.created_by + .as_deref() + .zip(requester_nickname) + .is_some_and(|(owner, requester)| owner == requester) + }; + let can_delete = can_delete_workspace_note( + access_level, + note.protected, + workspace_owner || note_owner, + ); NoteListItem { url: format!("/w/{}/n/{}", workspace.slug, note.slug), slug: note.slug, @@ -770,6 +830,7 @@ pub async fn open_workspace( created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), protected: note.protected, + can_delete, created_by: note.created_by, participant_count: stats.map_or(0, |value| value.participant_count), file_count: stats.map_or(0, |value| value.file_count), @@ -788,18 +849,6 @@ pub async fn open_workspace( let start = (page - 1) * per_page; let notes = notes.into_iter().skip(start).take(per_page).collect(); - let mut access_level = effective_header_access_level( - &state, - &headers, - "workspace", - &workspace.slug, - workspace.is_private, - workspace.password_hash.is_some(), - ) - .await?; - if db::verify_workspace_password(&workspace, payload.password.as_deref()) { - access_level = AccessLevel::Write; - } let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; Ok(Json(WorkspaceOpenResponse { workspace: workspace_info_from(&workspace, access_level, can_set_password), @@ -911,6 +960,7 @@ pub async fn create_note( created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), protected: note.protected, + can_delete: true, created_by: note.created_by, participant_count: 0, file_count: 0, @@ -1050,9 +1100,14 @@ 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 = - can_manage_resource_settings(workspace_owner, note_owner, password_write_access); - let can_delete_files = can_manage_authorship; + let requester_is_owner = workspace_owner || workspace_guest_owner || note_owner; + let can_manage_authorship = can_manage_resource_settings( + workspace_owner || workspace_guest_owner, + note_owner, + password_write_access, + ); + let can_delete_files = + can_delete_workspace_note(access_level, note.protected, requester_is_owner); let upload_max_size_bytes = resource_upload_limit(&state, &headers, "workspace", &workspace_slug).await?; let can_upload_files = upload_max_size_bytes.is_some(); @@ -1080,6 +1135,7 @@ 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: can_delete_files, can_delete_files, can_upload_files, upload_max_size_bytes, @@ -1404,6 +1460,14 @@ async fn effective_header_access_level( Ok(level) } +fn can_delete_workspace_note( + level: AccessLevel, + note_protected: bool, + requester_is_owner: bool, +) -> bool { + level >= AccessLevel::Write && (!note_protected || requester_is_owner) +} + fn require_write(level: AccessLevel) -> Result<(), ApiError> { if level >= AccessLevel::Write { Ok(()) diff --git a/src/tests/api.rs b/src/tests/api.rs index dd5a489..7e434c6 100644 --- a/src/tests/api.rs +++ b/src/tests/api.rs @@ -51,3 +51,12 @@ fn settings_allow_owner_or_verified_password_holder() { assert!(can_manage_resource_settings(false, false, true)); assert!(!can_manage_resource_settings(false, false, false)); } + +#[test] +fn workspace_note_deletion_follows_rw_protection_rules() { + assert!(can_delete_workspace_note(AccessLevel::Write, false, false)); + assert!(can_delete_workspace_note(AccessLevel::Write, true, true)); + assert!(!can_delete_workspace_note(AccessLevel::Write, true, false)); + assert!(!can_delete_workspace_note(AccessLevel::Read, false, true)); + assert!(!can_delete_workspace_note(AccessLevel::None, false, true)); +} diff --git a/static/css/styles.css b/static/css/styles.css index a628e0b..fbf86d4 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -2510,6 +2510,17 @@ dialog::backdrop { min-width: 74px; } +.file-delete-notice { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--border-strong); + border-radius: 9px; + background: var(--surface-2); + color: var(--text-secondary); + font-size: .8rem; + line-height: 1.45; +} + @media (max-width: 760px) { .file-actions { flex-wrap: wrap; @@ -2567,13 +2578,24 @@ dialog::backdrop { text-align: left; } +.video-choice-actions strong { + color: inherit; +} + .video-choice-actions span { - color: var(--muted); font-size: .76rem; font-weight: 500; line-height: 1.4; } +.video-choice-actions > .action-button--primary span { + color: var(--on-accent); +} + +.video-choice-actions > .action-button--secondary span { + color: var(--text-secondary); +} + @media (max-width: 600px) { .video-choice-actions { grid-template-columns: minmax(0, 1fr); diff --git a/static/js/image-upload.js b/static/js/image-upload.js index 71ffa4e..aecd53b 100644 --- a/static/js/image-upload.js +++ b/static/js/image-upload.js @@ -9,6 +9,9 @@ function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function stem(name) { return name.replace(/\.[^.]+$/, "") || "image"; } +function imageNeedsProcessing(width, height, aspect, maxSize) { + return aspect !== "original" || (maxSize > 0 && Math.max(width, height) > maxSize); +} export async function prepareImageFile(file) { if (!file.type.startsWith("image/")) return file; @@ -61,8 +64,15 @@ export async function prepareImageFile(file) { const result = new Promise(resolve => { dialog.addEventListener("close", () => { URL.revokeObjectURL(url); dialog.remove(); resolve(accepted); }, { once: true }); dialog.querySelector("[data-apply]").addEventListener("click", async () => { - const box = cropBox(), maxSize = Number(sizeSelect.value), out = document.createElement("canvas"); - if (aspectSelect.value === "original") { + const box = cropBox(), maxSize = Number(sizeSelect.value); + const wholeImage = aspectSelect.value === "original"; + if (!imageNeedsProcessing(image.naturalWidth, image.naturalHeight, aspectSelect.value, maxSize)) { + accepted = file; + dialog.close(); + return; + } + const out = document.createElement("canvas"); + if (wholeImage) { const ratio = Math.min(1, maxSize ? maxSize / Math.max(image.naturalWidth, image.naturalHeight) : 1); out.width = Math.max(1, Math.round(image.naturalWidth * ratio)); out.height = Math.max(1, Math.round(image.naturalHeight * ratio)); out.getContext("2d").drawImage(image, 0, 0, out.width, out.height); diff --git a/static/js/note-api.js b/static/js/note-api.js index 7c905ad..62370cd 100644 --- a/static/js/note-api.js +++ b/static/js/note-api.js @@ -102,7 +102,11 @@ export function createWorkspaceNoteAdapter() { }), configureView(info) { const button = document.querySelector("#delete-note"); - if (button) button.hidden = info.note_protected; + if (!button) return; + button.hidden = !info.can_delete; + button.title = info.note_protected + ? "Delete protected note (owner only)" + : "Delete note"; }, async deleteNote(info, accessToken) { if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, { diff --git a/static/js/note-editor.js b/static/js/note-editor.js index da20742..ed6fa11 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -1354,6 +1354,14 @@ export function startNoteEditor(adapter) { editor, toast, getAccessToken: () => accessToken, getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0, canDelete: () => Boolean(info?.can_delete_files), + getDeleteRestrictionMessage: () => { + if (info?.note_protected && !info?.can_delete_files) { + return "This note is protected. Only its owner can delete files."; + } + if (info?.access_level !== "write") return "Read-write access is required to delete files."; + if (!info?.can_delete_files) return "Only the note owner can delete files."; + return ""; + }, canUpload: () => Boolean(info?.can_upload_files), canEdit: canEditDocument, endpoints: adapter.fileEndpoints, diff --git a/static/js/note-files.js b/static/js/note-files.js index dcce389..fa615bf 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -79,6 +79,17 @@ function safeAttachmentUrl(value) { : safePublicUrl(raw, { allowMailto: false }); } +function downloadAttachmentUrl(value) { + const safe = safeAttachmentUrl(value); + try { + const url = new URL(safe, location.origin); + if (/^\/f\/[^/]+\/[^/]+$/.test(url.pathname)) url.searchParams.set("download", "1"); + return url.href; + } catch { + return safe; + } +} + function createVideoInsertDialog() { const dialog = document.createElement("dialog"); dialog.className = "app-dialog video-insert-dialog"; @@ -102,7 +113,7 @@ function createVideoInsertDialog() { return dialog; } -export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, toast, onFilesChanged = () => { } }) { +export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxSize = () => 0, canDelete, canUpload, canEdit = () => true, getDeleteRestrictionMessage = () => "", toast, onFilesChanged = () => { } }) { const dialog = document.querySelector("#files-dialog"); const list = document.querySelector("#files-list"); const input = document.querySelector("#file-input"); @@ -163,7 +174,11 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) }); const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`; - list.innerHTML = files.length ? files.map(file => ` + const restrictionMessage = getDeleteRestrictionMessage(); + const restrictionNotice = restrictionMessage + ? `

${escapeHtml(restrictionMessage)}

` + : ""; + const fileRows = files.length ? files.map(file => `
${escapeHtml(file.filename)}
@@ -172,6 +187,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS
${fileActionButtons(file)}
`).join("") : '

No files uploaded.

'; + list.innerHTML = restrictionNotice + fileRows; onFilesChanged(files); if (open && !dialog.open) dialog.showModal(); } catch (error) { @@ -314,7 +330,9 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, getUploadMaxS const output = panel.querySelector("textarea"); const safeUrl = safeAttachmentUrl(showButton.dataset.url); const absolute = new URL(safeUrl, location.origin).href; - let text = absolute; + let text = showButton.dataset.showFileCode === "link" && isVideo(showButton.dataset.mime) + ? downloadAttachmentUrl(showButton.dataset.url) + : absolute; if (showButton.dataset.showFileCode === "alias") { text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime); } else if (showButton.dataset.showFileCode === "markdown") { diff --git a/static/js/workspace.js b/static/js/workspace.js index 3cd78ee..67bc049 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -165,9 +165,13 @@ function setNotesView(view) { }); } function deleteButton(note, inline = false) { - const disabled = note.protected; + const disabled = !note.can_delete; const classes = `note-delete-button${inline ? " note-delete-button--inline" : ""}`; - const reason = disabled ? "Protected notes cannot be deleted" : `Delete ${note.title}`; + const reason = !disabled + ? `Delete ${note.title}` + : note.protected + ? "Protected note: only its owner can delete it" + : "Read-write access is required to delete this note"; return ``; } function renderNotes(notes = notesCache) {