diff --git a/Cargo.lock b/Cargo.lock index dd01d8b..c98b995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.39" +version = "0.0.40" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 6151ae7..2d44a96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.39" +version = "0.0.40" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/api.rs b/src/api.rs index 4f7751b..8c06ae2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -167,16 +167,26 @@ pub async fn create_workspace( pub async fn workspace_info( State(state): State, + headers: HeaderMap, Path(workspace_slug): Path, ) -> Result, ApiError> { let workspace = db::find_workspace(&state.db, &workspace_slug) .await? .ok_or_else(ApiError::not_found_workspace)?; + ensure_private_resource_access( + &state, + "workspace", + &workspace.slug, + workspace.is_private, + bearer_token(&headers), + ) + .await?; Ok(Json(workspace_info_from(&workspace))) } pub async fn open_workspace( State(state): State, + headers: HeaderMap, Path(workspace_slug): Path, Json(payload): Json, ) -> Result, ApiError> { @@ -185,6 +195,7 @@ pub async fn open_workspace( &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let notes = db::list_notes(&state.db, workspace.id) @@ -209,6 +220,7 @@ pub async fn open_workspace( pub async fn create_note( State(state): State, + headers: HeaderMap, Path(workspace_slug): Path, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { @@ -217,6 +229,7 @@ pub async fn create_note( &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let title = validate_name(&payload.name, "Note name")?; @@ -265,6 +278,14 @@ pub async fn note_info( let workspace = db::find_workspace(&state.db, &workspace_slug) .await? .ok_or_else(ApiError::not_found_workspace)?; + ensure_private_resource_access( + &state, + "workspace", + &workspace.slug, + workspace.is_private, + bearer_token(&headers), + ) + .await?; let note = db::find_note(&state.db, workspace.id, ¬e_slug) .await? .ok_or_else(ApiError::not_found_note)?; @@ -305,6 +326,7 @@ pub async fn note_info( pub async fn history( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { @@ -314,6 +336,7 @@ pub async fn history( ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let _ = workspace; @@ -330,6 +353,7 @@ pub async fn history( pub async fn restore( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { @@ -339,6 +363,7 @@ pub async fn restore( ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) @@ -370,16 +395,34 @@ pub async fn restore( Ok(Json(serde_json::json!({"ok": true}))) } +async fn ensure_private_resource_access( + state: &SharedState, + kind: &str, + slug: &str, + is_private: i64, + token: Option<&str>, +) -> Result<(), ApiError> { + if is_private == 0 { + return Ok(()); + } + if verify_resource_access_token(state, kind, slug, token).await? { + return Ok(()); + } + Err(ApiError::forbidden("This resource is private.")) +} + pub async fn authorized_workspace( state: &SharedState, slug: &str, password: Option<&str>, access_token: Option<&str>, + bearer: Option<&str>, ) -> Result { let workspace = db::find_workspace(&state.db, slug) .await? .ok_or_else(ApiError::not_found_workspace)?; - let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?; + let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await? + || verify_resource_access_token(state, "workspace", slug, bearer).await?; if workspace.is_private != 0 && !token_access { return Err(ApiError::forbidden("This workspace is private.")); } @@ -398,8 +441,9 @@ async fn authorized_note( note_slug: &str, password: Option<&str>, access_token: Option<&str>, + bearer: Option<&str>, ) -> Result<(db::Workspace, db::Note), ApiError> { - let workspace = authorized_workspace(state, workspace_slug, password, access_token).await?; + let workspace = authorized_workspace(state, workspace_slug, password, access_token, bearer).await?; let note = db::find_note(&state.db, workspace.id, note_slug) .await? .ok_or_else(ApiError::not_found_note)?; @@ -551,6 +595,14 @@ pub async fn pad_info( let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; + ensure_private_resource_access( + &state, + "pad", + &pad.slug, + pad.is_private, + bearer_token(&headers), + ) + .await?; Ok(Json(PadInfo { slug: pad.slug, title: pad.title, @@ -571,6 +623,7 @@ pub async fn pad_info( pub async fn publish_pad_page( State(state): State, + headers: HeaderMap, Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { @@ -579,6 +632,7 @@ pub async fn publish_pad_page( &slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let token = db::publish_pad(&state.db, pad.id).await?; @@ -590,6 +644,7 @@ pub async fn publish_pad_page( pub async fn publish_note_page( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { @@ -599,6 +654,7 @@ pub async fn publish_note_page( ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let token = db::publish_note(&state.db, note.id).await?; @@ -649,6 +705,7 @@ pub async fn update_public_task( pub async fn pad_history( State(state): State, + headers: HeaderMap, Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { @@ -657,6 +714,7 @@ pub async fn pad_history( &slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let revisions = db::list_pad_revisions(&state.db, pad.id) @@ -672,6 +730,7 @@ pub async fn pad_history( pub async fn pad_restore( State(state): State, + headers: HeaderMap, Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { @@ -680,6 +739,7 @@ pub async fn pad_restore( &slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) @@ -716,11 +776,13 @@ async fn authorized_pad( slug: &str, password: Option<&str>, access_token: Option<&str>, + bearer: Option<&str>, ) -> Result { let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; - let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?; + let token_access = verify_resource_access_token(state, "pad", slug, access_token).await? + || verify_resource_access_token(state, "pad", slug, bearer).await?; if pad.is_private != 0 && !token_access { return Err(ApiError::forbidden("This note is private.")); } @@ -745,6 +807,7 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result, + headers: HeaderMap, Path(slug): Path, mut multipart: Multipart, ) -> Result, ApiError> { @@ -783,7 +846,7 @@ pub async fn upload_pad_file( file = Some((filename, bytes.to_vec())); } } - let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref()).await?; + let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref(), bearer_token(&headers)).await?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::pad_file_token(&state.db, pad.id).await?; @@ -823,6 +886,7 @@ pub async fn upload_pad_file( pub async fn pad_files( State(state): State, + headers: HeaderMap, Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { @@ -831,6 +895,7 @@ pub async fn pad_files( &slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let mut files = db::list_pad_files(&state.db, pad.id).await?; @@ -861,6 +926,7 @@ pub async fn delete_pad_file( &slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers)) @@ -881,6 +947,7 @@ pub async fn delete_pad_file( pub async fn upload_note_file( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, mut multipart: Multipart, ) -> Result, ApiError> { @@ -925,6 +992,7 @@ pub async fn upload_note_file( ¬e_slug, password.as_deref(), access_token.as_deref(), + bearer_token(&headers), ) .await?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; @@ -966,6 +1034,7 @@ pub async fn upload_note_file( pub async fn delete_note( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { @@ -975,6 +1044,7 @@ pub async fn delete_note( ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; if note.protected { @@ -993,6 +1063,7 @@ pub async fn delete_note( pub async fn note_files( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { @@ -1002,6 +1073,7 @@ pub async fn note_files( ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let mut files = db::list_note_files(&state.db, note.id).await?; @@ -1033,6 +1105,7 @@ pub async fn delete_note_file( ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), + bearer_token(&headers), ) .await?; let workspace_owner = crate::auth::is_resource_owner( diff --git a/static/js/api.js b/static/js/api.js index b194620..ee52e0c 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -48,6 +48,8 @@ export async function api(path, options = {}) { const timeout = setTimeout(() => controller.abort(), 12000); try { const headers = new Headers(options.headers || {}); + const authToken = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token"); + if (authToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${authToken}`); if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json"); const started = performance.now(); logDebug("api.request", { method: options.method || "GET", path }); diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 16d10f5..65f8e66 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -33,7 +33,7 @@ export function startNoteEditor(adapter) { function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; } function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; } function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); } - function sessionHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } + function sessionHeaders() { const token = accessToken || getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } async function loadNoteInfo() { info = await adapter.loadInfo(sessionHeaders()); return info; } function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } } function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } diff --git a/static/js/workspace.js b/static/js/workspace.js index 8c0033e..391733a 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -102,7 +102,8 @@ async function openWorkspace() { } async function init() { try { - info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); + const headers = accessToken ? { Authorization: `Bearer ${accessToken}` } : {}; + info = await api(`/api/workspaces/${encodeURIComponent(slug)}`, { headers }); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();