diff --git a/Cargo.lock b/Cargo.lock index c98b995..6220bee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.40" +version = "0.0.41" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 2d44a96..ffbc61b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.40" +version = "0.0.41" 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 8c06ae2..ff5b8c3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,7 +1,7 @@ use axum::{ Json, extract::{Multipart, Path, State}, - http::{HeaderMap, HeaderValue, StatusCode, header}, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, }; use chrono::{Duration, Utc}; @@ -232,6 +232,12 @@ pub async fn create_note( bearer_token(&headers), ) .await?; + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; let title = validate_name(&payload.name, "Note name")?; let base = slugify(title); if base.is_empty() { @@ -366,6 +372,12 @@ pub async fn restore( bearer_token(&headers), ) .await?; + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) .bind(payload.revision_id) .bind(note.id) @@ -395,6 +407,86 @@ pub async fn restore( Ok(Json(serde_json::json!({"ok": true}))) } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum AccessLevel { + None, + Read, + Write, +} + +fn permission_level(permission: Option<&str>) -> AccessLevel { + match permission { + Some("rw") => AccessLevel::Write, + Some("ro") => AccessLevel::Read, + _ => AccessLevel::None, + } +} + +async fn anonymous_access_token_valid( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result { + 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(hash_access_token(token)) + .bind(kind) + .bind(slug) + .bind(Utc::now().to_rfc3339()) + .fetch_one(state.db.pool()) + .await?; + Ok(count > 0) +} + +async fn token_access_level( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result { + let permission = crate::auth::resource_permission(state, kind, slug, token) + .await + .map_err(|error| ApiError::forbidden(&error.message))?; + let level = permission_level(permission.as_deref()); + if level != AccessLevel::None { + return Ok(level); + } + if anonymous_access_token_valid(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); + } + Ok(AccessLevel::None) +} + +async fn combined_token_access_level( + state: &SharedState, + kind: &str, + slug: &str, + access_token: Option<&str>, + bearer: Option<&str>, +) -> Result { + Ok(std::cmp::max( + token_access_level(state, kind, slug, access_token).await?, + token_access_level(state, kind, slug, bearer).await?, + )) +} + +fn require_write(level: AccessLevel) -> Result<(), ApiError> { + if level >= AccessLevel::Write { + Ok(()) + } else { + Err(ApiError::forbidden("Read-only access.")) + } +} + async fn ensure_private_resource_access( state: &SharedState, kind: &str, @@ -421,14 +513,15 @@ pub async fn authorized_workspace( 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? - || verify_resource_access_token(state, "workspace", slug, bearer).await?; - if workspace.is_private != 0 && !token_access { + let token_level = combined_token_access_level( + state, "workspace", slug, access_token, bearer, + ).await?; + if workspace.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::forbidden("This workspace is private.")); } if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password) - && !token_access + && token_level == AccessLevel::None { return Err(ApiError::unauthorized()); } @@ -635,6 +728,12 @@ pub async fn publish_pad_page( bearer_token(&headers), ) .await?; + let level = if db::verify_pad_password(&pad, payload.password.as_deref()) + || (pad.is_private == 0 && pad.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; let token = db::publish_pad(&state.db, pad.id).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; Ok(Json(PublishResponse { @@ -648,7 +747,7 @@ pub async fn publish_note_page( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (_, note) = authorized_note( + let (workspace, note) = authorized_note( &state, &workspace_slug, ¬e_slug, @@ -657,6 +756,12 @@ pub async fn publish_note_page( bearer_token(&headers), ) .await?; + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; let token = db::publish_note(&state.db, note.id).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; Ok(Json(PublishResponse { @@ -742,6 +847,12 @@ pub async fn pad_restore( bearer_token(&headers), ) .await?; + let level = if db::verify_pad_password(&pad, payload.password.as_deref()) + || (pad.is_private == 0 && pad.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "pad", &slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) .bind(payload.revision_id) .bind(pad.id) @@ -781,12 +892,16 @@ async fn authorized_pad( 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? - || verify_resource_access_token(state, "pad", slug, bearer).await?; - if pad.is_private != 0 && !token_access { + let token_level = combined_token_access_level( + state, "pad", slug, access_token, bearer, + ).await?; + if pad.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::forbidden("This note is private.")); } - if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access { + if pad.password_hash.is_some() + && !db::verify_pad_password(&pad, password) + && token_level == AccessLevel::None + { return Err(ApiError::unauthorized()); } Ok(pad) @@ -847,6 +962,12 @@ pub async fn upload_pad_file( } } let pad = authorized_pad(&state, &slug, password.as_deref(), access_token.as_deref(), bearer_token(&headers)).await?; + let level = if db::verify_pad_password(&pad, password.as_deref()) + || (pad.is_private == 0 && pad.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "pad", &slug, access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; 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?; @@ -986,7 +1107,7 @@ pub async fn upload_note_file( file = Some((filename, bytes.to_vec())); } } - let (_workspace, note) = authorized_note( + let (workspace, note) = authorized_note( &state, &workspace_slug, ¬e_slug, @@ -995,6 +1116,13 @@ pub async fn upload_note_file( bearer_token(&headers), ) .await?; + + let level = if db::verify_workspace_password(&workspace, password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "workspace", &workspace_slug, access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::note_file_token(&state.db, note.id).await?; @@ -1038,7 +1166,7 @@ pub async fn delete_note( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (_workspace, note) = authorized_note( + let (workspace, note) = authorized_note( &state, &workspace_slug, ¬e_slug, @@ -1047,6 +1175,13 @@ pub async fn delete_note( bearer_token(&headers), ) .await?; + + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { AccessLevel::Write } else { + combined_token_access_level(&state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers)).await? + }; + require_write(level)?; if note.protected { return Err(ApiError::bad_request( "This note is protected and cannot be deleted", @@ -1199,6 +1334,14 @@ async fn serve_token_file( header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), ); + response.headers_mut().insert( + HeaderName::from_static("x-robots-tag"), + HeaderValue::from_static("noindex, nofollow, noarchive, nosnippet"), + ); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); response.headers_mut().insert( header::CACHE_CONTROL, HeaderValue::from_str(&format!( diff --git a/src/app.rs b/src/app.rs index eed4353..6d13ea0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -45,6 +45,7 @@ pub fn router( .route("/w/{workspace_slug}/n/{note_slug}", get(note)) .route("/errors/private-workspace", get(private_workspace_error)) .route("/health", get(health)) + .route("/robots.txt", get(robots_txt)) .route("/f/{token}/{filename}", get(api::download_file)) .route( "/files/{directory}/{filename}", @@ -175,6 +176,15 @@ async fn health() -> &'static str { "ok" } +async fn robots_txt() -> Response { + let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + async fn home(State(state): State) -> Response { assets::render_html( include_str!("../static/home.html"), diff --git a/src/websocket.rs b/src/websocket.rs index 749bc70..28ba898 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -148,21 +148,26 @@ async fn handle_socket( .map(|name| format!("guest:{id}:{}", name.to_lowercase())) }), }; + let supplied_token = session_token.as_deref().or(access_token.as_deref()); let permission = auth::resource_permission( &state, "workspace", &workspace_slug, - session_token.as_deref().or(access_token.as_deref()), + supplied_token, ) .await .ok() .flatten(); + let anonymous_token_ok = permission.is_none() + && crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, supplied_token) + .await + .unwrap_or(false); let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); - if workspace.is_private != 0 && permission.is_none() { + if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok { let _ = send_error(&mut socket, "This workspace is private").await; return; } - if workspace.password_hash.is_some() && !password_ok && permission.is_none() { + if workspace.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok { warn!( workspace_id = workspace.id, note_id = note.id, @@ -171,7 +176,10 @@ async fn handle_socket( let _ = send_error(&mut socket, "Invalid password").await; return; } - let write_allowed = password_ok || permission.as_deref() != Some("ro"); + let write_allowed = permission.as_deref() == Some("rw") + || anonymous_token_ok + || password_ok + || (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none()); info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated"); if send( &mut socket, @@ -398,17 +406,22 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri .map(|name| format!("guest:{id}:{}", name.to_lowercase())) }), }; + let supplied_token = session_token.as_deref().or(access_token.as_deref()); let permission = auth::resource_permission( &state, "pad", &slug, - session_token.as_deref().or(access_token.as_deref()), + supplied_token, ) .await .ok() .flatten(); + let anonymous_token_ok = permission.is_none() + && crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token) + .await + .unwrap_or(false); let password_ok = db::verify_pad_password(&pad, password.as_deref()); - if pad.is_private != 0 && permission.is_none() { + if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok { let _ = send_pad( &mut socket, &PadServerMessage::Error { @@ -418,7 +431,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri .await; return; } - if pad.password_hash.is_some() && !password_ok && permission.is_none() { + if pad.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok { warn!(pad_id = pad.id, "pad websocket rejected: invalid password"); let _ = send_pad( &mut socket, @@ -429,7 +442,10 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri .await; return; } - let write_allowed = password_ok || permission.as_deref() != Some("ro"); + let write_allowed = permission.as_deref() == Some("rw") + || anonymous_token_ok + || password_ok + || (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none()); info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); if send_pad( &mut socket,