From 0abc8e871d1ac26d9dd8f929f284cb9ed96144ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 14 Oct 2026 14:55:04 +0200 Subject: [PATCH] 0.2.89 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/auth/mod.rs | 7 ++--- static/js/api.js | 4 ++- static/js/note-editor.js | 4 ++- tests/api-error-classification-test.mjs | 35 +++++++++++++++++++++++++ 6 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 tests/api-error-classification-test.mjs diff --git a/Cargo.lock b/Cargo.lock index b646590..395ccf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,7 +2674,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.88" +version = "0.2.89" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 119ee92..ccec80b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.88" +version = "0.2.89" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 057d705..1c663d8 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1830,9 +1830,10 @@ pub async fn resource_mentions( .await .map_err(AuthError::database)?; if access.is_none() { - return Err(AuthError::forbidden( - "This resource was not shared with your account.", - )); + // A signed-in visitor can read public resources without a direct share. + // An empty list keeps member identities private and avoids treating + // unavailable mention suggestions as a failed page request. + return Ok(Json(serde_json::json!({ "users": [] }))); } } let rows = sqlx::query(queries::get(state.db.kind(), query)) diff --git a/static/js/api.js b/static/js/api.js index 60f60bb..bfd87f4 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -164,7 +164,9 @@ export async function api(path, options = {}) { logWarn("api.timeout", { method: options.method || "GET", path }); throw new Error(translateApiMessage("Timed out")); } - logError("api.network_error", error, { method: options.method || "GET", path }); + // HTTP failures have already been logged as api.failed above. + // Only transport failures should be reported as network errors. + if (!error?.status) logError("api.network_error", error, { method: options.method || "GET", path }); if (options.body instanceof FormData && error instanceof TypeError) { throw new Error(translateApiMessage("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit.")); } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 8120e12..fbfacc7 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -277,7 +277,9 @@ export function startNoteEditor(adapter) { setMarkdownMentions([]); mentionAutocomplete.setUsers([]); mentionNavigator.setUsers([]); - if (!accountSession) { render(); return; } + // Session validation can finish before loadNoteInfo(); avoid a premature + // request (and a second request when note info becomes available). + if (!accountSession || !info) { render(); return; } try { const params = new URLSearchParams({ kind: adapter.access.kind, slug: adapter.access.key }); const response = await api(`/api/auth/resources/mentions?${params}`); diff --git a/tests/api-error-classification-test.mjs b/tests/api-error-classification-test.mjs new file mode 100644 index 0000000..7dbbc4e --- /dev/null +++ b/tests/api-error-classification-test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const events = globalThis.__apiEvents = []; +let source = readFileSync('static/js/api.js', 'utf8') + .replace('import { logDebug, logError, logWarn } from "@rustpad/logger";', + 'const logDebug = () => {}; const logError = (...args) => globalThis.__apiEvents.push(["error", ...args]); const logWarn = (...args) => globalThis.__apiEvents.push(["warn", ...args]);') + .replace('import { formatNumber, restoreGuestLanguage, translateApiMessage } from "@rustpad/i18n";', + 'const formatNumber = value => String(value); const restoreGuestLanguage = () => {}; const translateApiMessage = message => message;') + .replace('import { restoreGuestTheme } from "@rustpad/theme";', + 'const restoreGuestTheme = () => {};'); +const { api } = await import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source)); + +globalThis.window = {}; +globalThis.fetch = async () => new Response(JSON.stringify({ error: 'Access denied.' }), { + status: 403, + headers: { 'content-type': 'application/json' }, +}); +await assert.rejects(api('/api/auth/resources/mentions?kind=pad&slug=test'), error => error.status === 403); +assert.equal(events.filter(([level, name]) => level === 'warn' && name === 'api.failed').length, 1); +assert.equal(events.filter(([level, name]) => level === 'error' && name === 'api.network_error').length, 0); + +events.length = 0; +globalThis.fetch = async () => new Response(JSON.stringify({ users: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, +}); +assert.deepEqual(await api('/api/auth/resources/mentions?kind=pad&slug=test'), { users: [] }); +assert.equal(events.length, 0); + +events.length = 0; +globalThis.fetch = async () => { throw new TypeError('Network down'); }; +await assert.rejects(api('/api/auth/resources/mentions?kind=pad&slug=test'), TypeError); +assert.equal(events.filter(([level, name]) => level === 'error' && name === 'api.network_error').length, 1); +console.log('PASS: HTTP 403 logged once, empty mentions succeeds, transport failures logged as network errors');