diff --git a/src/api/access_tokens.rs b/src/api/access_tokens.rs index 741a0b4..400bd3a 100644 --- a/src/api/access_tokens.rs +++ b/src/api/access_tokens.rs @@ -1,3 +1,5 @@ +use super::*; + #[derive(Debug, Deserialize)] pub struct AccessTokenRequest { kind: String, @@ -87,86 +89,7 @@ pub async fn verify_resource_access_token( Ok(count > 0) } -fn hash_access_token(token: &str) -> String { +pub(super) fn hash_access_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) } -pub struct ApiError { - status: StatusCode, - message: String, -} - -impl ApiError { - fn bad_request(message: &str) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - message: message.into(), - } - } - fn payload_too_large(max_bytes: usize) -> Self { - let max_mb = max_bytes / (1024 * 1024); - Self { - status: StatusCode::PAYLOAD_TOO_LARGE, - message: format!("The file may be at most {max_mb} MB"), - } - } - fn not_found_file() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "File not found".into(), - } - } - fn unauthorized() -> Self { - Self { - status: StatusCode::UNAUTHORIZED, - message: "Invalid password".into(), - } - } - fn forbidden(message: &str) -> Self { - Self { - status: StatusCode::FORBIDDEN, - message: message.into(), - } - } - fn not_found_workspace() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "Workspace not found".into(), - } - } - fn not_found_note() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "Note not found".into(), - } - } - fn not_found_revision() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "Revision not found".into(), - } - } - fn internal(message: &str) -> Self { - Self { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: message.into(), - } - } -} - -impl From for ApiError { - fn from(error: sqlx::Error) -> Self { - tracing::error!(%error, "database error"); - Self::internal("Database error") - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - ( - self.status, - Json(serde_json::json!({"error": self.message})), - ) - .into_response() - } -} diff --git a/src/api/error.rs b/src/api/error.rs new file mode 100644 index 0000000..99531ed --- /dev/null +++ b/src/api/error.rs @@ -0,0 +1,85 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +pub struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + pub(crate) fn bad_request(message: &str) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + pub(crate) fn payload_too_large(max_bytes: usize) -> Self { + let max_mb = max_bytes / (1024 * 1024); + Self { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: format!("The file may be at most {max_mb} MB"), + } + } + pub(crate) fn not_found_file() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "File not found".into(), + } + } + pub(crate) fn unauthorized() -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: "Invalid password".into(), + } + } + pub(crate) fn forbidden(message: &str) -> Self { + Self { + status: StatusCode::FORBIDDEN, + message: message.into(), + } + } + pub(crate) fn not_found_workspace() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Workspace not found".into(), + } + } + pub(crate) fn not_found_note() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Note not found".into(), + } + } + pub(crate) fn not_found_revision() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Revision not found".into(), + } + } + pub(crate) fn internal(message: &str) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } +} + +impl From for ApiError { + fn from(error: sqlx::Error) -> Self { + tracing::error!(%error, "database error"); + Self::internal("Database error") + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(serde_json::json!({"error": self.message})), + ) + .into_response() + } +} diff --git a/src/api/files.rs b/src/api/files.rs index 35ffcda..9acfd99 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -1,3 +1,6 @@ +use super::*; +use super::pads_public::authorized_pad; + pub async fn upload_pad_file( State(state): State, headers: HeaderMap, @@ -465,15 +468,6 @@ async fn serve_token_file( Ok(response) } -fn bearer_token(headers: &HeaderMap) -> Option<&str> { - headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - fn sanitize_filename(value: &str) -> String { let name = std::path::Path::new(value) .file_name() diff --git a/src/api/mod.rs b/src/api/mod.rs index 99bf426..581c262 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,13 @@ +mod access_tokens; +mod error; +mod files; +mod pads_public; + +pub use access_tokens::*; +pub use error::ApiError; +pub use files::*; +pub use pads_public::*; + use axum::{ Json, extract::{Multipart, Path, State}, @@ -20,8 +30,726 @@ const MIN_PASSWORD_LENGTH: usize = 8; const MAX_PASSWORD_LENGTH: usize = 128; const MIN_WORKSPACE_SLUG_LENGTH: usize = 6; +fn bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +#[derive(Debug, Serialize)] +pub struct PublishResponse { + url: String, +} + +#[derive(Debug, Serialize)] +pub struct PublicPageResponse { + title: String, + content: String, + updated_at: String, + allow_task_updates: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateWorkspaceRequest { + name: String, + #[serde(default)] + password: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateWorkspaceResponse { + slug: String, + url: String, +} + +#[derive(Debug, Deserialize)] +pub struct PasswordRequest { + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PublishRequest { + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, + #[serde(default)] + allow_task_updates: bool, +} + +#[derive(Debug, Deserialize)] +pub struct PublicTaskUpdateRequest { + source_line: usize, + checked: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateNoteRequest { + name: String, + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, + #[serde(default)] + protect: bool, + #[serde(default)] + created_by: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RestoreRequest { + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, + revision_id: i64, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceInfo { + slug: String, + title: String, + protected: bool, + created_at: String, + updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceOpenResponse { + workspace: WorkspaceInfo, + notes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct NoteListItem { + slug: String, + title: String, + created_at: String, + updated_at: String, + url: String, + protected: bool, + created_by: Option, +} + +#[derive(Debug, Serialize)] +pub struct NoteInfo { + workspace_slug: String, + workspace_title: String, + slug: String, + title: String, + protected: bool, + note_protected: bool, + allow_public_task_updates: bool, + created_at: String, + updated_at: String, + can_delete_files: bool, + global_color: Option, + note_color: Option, +} + +#[derive(Debug, Deserialize)] +pub struct EditorColorRequest { + color: Option, +} + +pub async fn create_workspace( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let title = validate_name(&payload.name, "Workspace name")?; + let password = validate_password(payload.password.as_deref())?; + let slug = unique_workspace_slug(&state, title).await?; + + let workspace = db::create_workspace(&state.db, &slug, title, password).await?; + if let Some(user) = crate::auth::optional_user(&state, &headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + { + sqlx::query(queries::get( + state.db.kind(), + queries::USER_ATTACH_WORKSPACE, + )) + .bind(user.id) + .bind(&workspace.slug) + .execute(state.db.pool()) + .await?; + } + + Ok(( + StatusCode::CREATED, + Json(CreateWorkspaceResponse { + url: format!("/w/{slug}"), + slug, + }), + )) +} + +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> { + let workspace = authorized_workspace( + &state, + &workspace_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await?; + let notes = db::list_notes(&state.db, workspace.id) + .await? + .into_iter() + .map(|note| NoteListItem { + url: format!("/w/{}/n/{}", workspace.slug, note.slug), + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + protected: note.protected, + created_by: note.created_by, + }) + .collect(); + + Ok(Json(WorkspaceOpenResponse { + workspace: workspace_info_from(&workspace), + notes, + })) +} + +pub async fn create_note( + State(state): State, + headers: HeaderMap, + Path(workspace_slug): Path, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let workspace = authorized_workspace( + &state, + &workspace_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + 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() { + return Err(ApiError::bad_request( + "The name cannot be converted into a valid address", + )); + } + + 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 note = db::create_note( + &state.db, + workspace.id, + &slug, + title, + payload.protect, + created_by.as_deref(), + ) + .await?; + Ok(( + StatusCode::CREATED, + Json(NoteListItem { + url: format!("/w/{workspace_slug}/n/{slug}"), + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + protected: note.protected, + created_by: note.created_by, + }), + )) +} + +fn clean_editor_color(value: Option<&str>) -> Result, ApiError> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.len() == 7 + && value.starts_with('#') + && value[1..].chars().all(|c| c.is_ascii_hexdigit()) + { + Ok(Some(value.to_ascii_lowercase())) + } else { + Err(ApiError::bad_request("Invalid editor color")) + } +} + +async fn editor_colors( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, +) -> Result<(Option, Option), ApiError> { + let Some(user) = crate::auth::optional_user(state, headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + else { + return Ok((None, None)); + }; + let global: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_EDITOR_COLOR_BY_USER, + )) + .bind(user.id) + .fetch_one(state.db.pool()) + .await?; + let note: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_BY_USER, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .fetch_optional(state.db.pool()) + .await?; + Ok((global, note)) +} + +async fn save_editor_color( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, + color: Option<&str>, +) -> Result, ApiError> { + let user = crate::auth::optional_user(state, headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + .ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?; + let color = clean_editor_color(color)?; + let mut tx = state.db.pool().begin().await?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_DELETE, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .execute(&mut *tx) + .await?; + if let Some(value) = color.as_deref() { + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_INSERT, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .bind(value) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(Json(serde_json::json!({"color": color}))) +} + +pub async fn note_info( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> 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?; + let note = db::find_note(&state.db, workspace.id, ¬e_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + let color_slug = format!("{}/{}", workspace_slug, note_slug); + let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; + + Ok(Json(NoteInfo { + workspace_slug: workspace.slug, + workspace_title: workspace.title, + slug: note.slug, + title: note.title, + protected: workspace.password_hash.is_some(), + note_protected: note.protected, + allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + can_delete_files: { + let workspace_owner = crate::auth::is_resource_owner( + &state, + "workspace", + &workspace_slug, + bearer_token(&headers), + ) + .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); + workspace_owner || note_owner + }, + global_color, + note_color, + })) +} + +pub async fn history( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result>, ApiError> { + let (workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await?; + let _ = workspace; + let revisions = db::list_revisions(&state.db, note.id) + .await? + .into_iter() + .map(|mut revision| { + revision.created_at = db::normalize_timestamp(&revision.created_at); + revision + }) + .collect(); + Ok(Json(revisions)) +} + +pub async fn restore( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let (workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + 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) + .fetch_optional(state.db.pool()) + .await?; + let content = content.ok_or_else(ApiError::not_found_revision)?; + let (revision_id, updated_at) = db::save_revision( + &state.db, + note.id, + workspace.id, + &content, + Some("restore"), + "[]", + ) + .await?; + let update = NoteUpdate { + content, + revision_id, + updated_at, + author: Some("restore".into()), + owner_map: "[]".into(), + }; + let _ = state + .note_channel(&workspace_slug, ¬e_slug) + .await + .send(RoomEvent::Document(update)); + 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(access_tokens::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, + 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_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_level == AccessLevel::None + { + return Err(ApiError::unauthorized()); + } + Ok(workspace) +} + +async fn authorized_note( + state: &SharedState, + workspace_slug: &str, + 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, bearer).await?; + let note = db::find_note(&state.db, workspace.id, note_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok((workspace, note)) +} + +fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo { + WorkspaceInfo { + slug: workspace.slug.clone(), + title: workspace.title.clone(), + protected: workspace.password_hash.is_some(), + created_at: db::normalize_timestamp(&workspace.created_at), + updated_at: db::normalize_timestamp(&workspace.updated_at), + } +} + +fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> { + let value = value.trim(); + if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH { + return Err(ApiError::bad_request(&format!( + "{field} must contain between 1 and {MAX_NAME_LENGTH} characters" + ))); + } + Ok(value) +} + +fn validate_password(password: Option<&str>) -> Result, ApiError> { + let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let length = password.chars().count(); + if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) { + return Err(ApiError::bad_request( + "Password must contain between 8 and 128 characters", + )); + } + Ok(Some(password)) +} + +async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result { + let base = slugify(title); + if base.is_empty() { + return Err(ApiError::bad_request( + "The name cannot be converted into a valid address", + )); + } + + let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH + || db::find_workspace(&state.db, &base).await?.is_some(); + if !needs_suffix { + return Ok(base); + } + + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(8)); + if db::find_workspace(&state.db, &candidate).await?.is_none() { + return Ok(candidate); + } + } + Err(ApiError::internal("Failed to create a unique address")) +} + +async fn unique_note_slug( + state: &SharedState, + workspace_id: i64, + base: &str, +) -> Result { + if db::find_note(&state.db, workspace_id, base) + .await? + .is_none() + { + return Ok(base.to_owned()); + } + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(6)); + if db::find_note(&state.db, workspace_id, &candidate) + .await? + .is_none() + { + return Ok(candidate); + } + } + Err(ApiError::internal("Failed to create a unique address")) +} -include!("workspace_notes.rs"); -include!("pads_public.rs"); -include!("files.rs"); -include!("access_tokens.rs"); diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index 605c22b..0edd5f1 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -1,3 +1,5 @@ +use super::*; + #[derive(Debug, Deserialize)] pub struct CreatePadRequest { name: String, @@ -343,7 +345,7 @@ pub async fn pad_restore( Ok(Json(serde_json::json!({"ok": true}))) } -async fn authorized_pad( +pub(super) async fn authorized_pad( state: &SharedState, slug: &str, password: Option<&str>, diff --git a/src/api/workspace_notes.rs b/src/api/workspace_notes.rs deleted file mode 100644 index 79b4b58..0000000 --- a/src/api/workspace_notes.rs +++ /dev/null @@ -1,714 +0,0 @@ -#[derive(Debug, Serialize)] -pub struct PublishResponse { - url: String, -} - -#[derive(Debug, Serialize)] -pub struct PublicPageResponse { - title: String, - content: String, - updated_at: String, - allow_task_updates: bool, -} - -#[derive(Debug, Deserialize)] -pub struct CreateWorkspaceRequest { - name: String, - #[serde(default)] - password: Option, -} - -#[derive(Debug, Serialize)] -pub struct CreateWorkspaceResponse { - slug: String, - url: String, -} - -#[derive(Debug, Deserialize)] -pub struct PasswordRequest { - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, -} - -#[derive(Debug, Deserialize)] -pub struct PublishRequest { - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, - #[serde(default)] - allow_task_updates: bool, -} - -#[derive(Debug, Deserialize)] -pub struct PublicTaskUpdateRequest { - source_line: usize, - checked: bool, -} - -#[derive(Debug, Deserialize)] -pub struct CreateNoteRequest { - name: String, - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, - #[serde(default)] - protect: bool, - #[serde(default)] - created_by: Option, -} - -#[derive(Debug, Deserialize)] -pub struct RestoreRequest { - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, - revision_id: i64, -} - -#[derive(Debug, Serialize)] -pub struct WorkspaceInfo { - slug: String, - title: String, - protected: bool, - created_at: String, - updated_at: String, -} - -#[derive(Debug, Serialize)] -pub struct WorkspaceOpenResponse { - workspace: WorkspaceInfo, - notes: Vec, -} - -#[derive(Debug, Serialize)] -pub struct NoteListItem { - slug: String, - title: String, - created_at: String, - updated_at: String, - url: String, - protected: bool, - created_by: Option, -} - -#[derive(Debug, Serialize)] -pub struct NoteInfo { - workspace_slug: String, - workspace_title: String, - slug: String, - title: String, - protected: bool, - note_protected: bool, - allow_public_task_updates: bool, - created_at: String, - updated_at: String, - can_delete_files: bool, - global_color: Option, - note_color: Option, -} - -#[derive(Debug, Deserialize)] -pub struct EditorColorRequest { - color: Option, -} - -pub async fn create_workspace( - State(state): State, - headers: HeaderMap, - Json(payload): Json, -) -> Result<(StatusCode, Json), ApiError> { - let title = validate_name(&payload.name, "Workspace name")?; - let password = validate_password(payload.password.as_deref())?; - let slug = unique_workspace_slug(&state, title).await?; - - let workspace = db::create_workspace(&state.db, &slug, title, password).await?; - if let Some(user) = crate::auth::optional_user(&state, &headers) - .await - .map_err(|e| ApiError::forbidden(&e.message))? - { - sqlx::query(queries::get( - state.db.kind(), - queries::USER_ATTACH_WORKSPACE, - )) - .bind(user.id) - .bind(&workspace.slug) - .execute(state.db.pool()) - .await?; - } - - Ok(( - StatusCode::CREATED, - Json(CreateWorkspaceResponse { - url: format!("/w/{slug}"), - slug, - }), - )) -} - -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> { - let workspace = authorized_workspace( - &state, - &workspace_slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await?; - let notes = db::list_notes(&state.db, workspace.id) - .await? - .into_iter() - .map(|note| NoteListItem { - url: format!("/w/{}/n/{}", workspace.slug, note.slug), - slug: note.slug, - title: note.title, - created_at: db::normalize_timestamp(¬e.created_at), - updated_at: db::normalize_timestamp(¬e.updated_at), - protected: note.protected, - created_by: note.created_by, - }) - .collect(); - - Ok(Json(WorkspaceOpenResponse { - workspace: workspace_info_from(&workspace), - notes, - })) -} - -pub async fn create_note( - State(state): State, - headers: HeaderMap, - Path(workspace_slug): Path, - Json(payload): Json, -) -> Result<(StatusCode, Json), ApiError> { - let workspace = authorized_workspace( - &state, - &workspace_slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - 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() { - return Err(ApiError::bad_request( - "The name cannot be converted into a valid address", - )); - } - - 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 note = db::create_note( - &state.db, - workspace.id, - &slug, - title, - payload.protect, - created_by.as_deref(), - ) - .await?; - Ok(( - StatusCode::CREATED, - Json(NoteListItem { - url: format!("/w/{workspace_slug}/n/{slug}"), - slug: note.slug, - title: note.title, - created_at: db::normalize_timestamp(¬e.created_at), - updated_at: db::normalize_timestamp(¬e.updated_at), - protected: note.protected, - created_by: note.created_by, - }), - )) -} - -fn clean_editor_color(value: Option<&str>) -> Result, ApiError> { - let Some(value) = value else { - return Ok(None); - }; - let value = value.trim(); - if value.len() == 7 - && value.starts_with('#') - && value[1..].chars().all(|c| c.is_ascii_hexdigit()) - { - Ok(Some(value.to_ascii_lowercase())) - } else { - Err(ApiError::bad_request("Invalid editor color")) - } -} - -async fn editor_colors( - state: &SharedState, - headers: &HeaderMap, - kind: &str, - slug: &str, -) -> Result<(Option, Option), ApiError> { - let Some(user) = crate::auth::optional_user(state, headers) - .await - .map_err(|e| ApiError::forbidden(&e.message))? - else { - return Ok((None, None)); - }; - let global: Option = sqlx::query_scalar(queries::get( - state.db.kind(), - queries::AUTH_EDITOR_COLOR_BY_USER, - )) - .bind(user.id) - .fetch_one(state.db.pool()) - .await?; - let note: Option = sqlx::query_scalar(queries::get( - state.db.kind(), - queries::RESOURCE_COLOR_BY_USER, - )) - .bind(user.id) - .bind(kind) - .bind(slug) - .fetch_optional(state.db.pool()) - .await?; - Ok((global, note)) -} - -async fn save_editor_color( - state: &SharedState, - headers: &HeaderMap, - kind: &str, - slug: &str, - color: Option<&str>, -) -> Result, ApiError> { - let user = crate::auth::optional_user(state, headers) - .await - .map_err(|e| ApiError::forbidden(&e.message))? - .ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?; - let color = clean_editor_color(color)?; - let mut tx = state.db.pool().begin().await?; - sqlx::query(queries::get( - state.db.kind(), - queries::RESOURCE_COLOR_DELETE, - )) - .bind(user.id) - .bind(kind) - .bind(slug) - .execute(&mut *tx) - .await?; - if let Some(value) = color.as_deref() { - sqlx::query(queries::get( - state.db.kind(), - queries::RESOURCE_COLOR_INSERT, - )) - .bind(user.id) - .bind(kind) - .bind(slug) - .bind(value) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(Json(serde_json::json!({"color": color}))) -} - -pub async fn note_info( - State(state): State, - headers: HeaderMap, - Path((workspace_slug, note_slug)): Path<(String, String)>, -) -> 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?; - let note = db::find_note(&state.db, workspace.id, ¬e_slug) - .await? - .ok_or_else(ApiError::not_found_note)?; - let color_slug = format!("{}/{}", workspace_slug, note_slug); - let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; - - Ok(Json(NoteInfo { - workspace_slug: workspace.slug, - workspace_title: workspace.title, - slug: note.slug, - title: note.title, - protected: workspace.password_hash.is_some(), - note_protected: note.protected, - allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, - created_at: db::normalize_timestamp(¬e.created_at), - updated_at: db::normalize_timestamp(¬e.updated_at), - can_delete_files: { - let workspace_owner = crate::auth::is_resource_owner( - &state, - "workspace", - &workspace_slug, - bearer_token(&headers), - ) - .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); - workspace_owner || note_owner - }, - global_color, - note_color, - })) -} - -pub async fn history( - State(state): State, - headers: HeaderMap, - Path((workspace_slug, note_slug)): Path<(String, String)>, - Json(payload): Json, -) -> Result>, ApiError> { - let (workspace, note) = authorized_note( - &state, - &workspace_slug, - ¬e_slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await?; - let _ = workspace; - let revisions = db::list_revisions(&state.db, note.id) - .await? - .into_iter() - .map(|mut revision| { - revision.created_at = db::normalize_timestamp(&revision.created_at); - revision - }) - .collect(); - Ok(Json(revisions)) -} - -pub async fn restore( - State(state): State, - headers: HeaderMap, - Path((workspace_slug, note_slug)): Path<(String, String)>, - Json(payload): Json, -) -> Result, ApiError> { - let (workspace, note) = authorized_note( - &state, - &workspace_slug, - ¬e_slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - 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) - .fetch_optional(state.db.pool()) - .await?; - let content = content.ok_or_else(ApiError::not_found_revision)?; - let (revision_id, updated_at) = db::save_revision( - &state.db, - note.id, - workspace.id, - &content, - Some("restore"), - "[]", - ) - .await?; - let update = NoteUpdate { - content, - revision_id, - updated_at, - author: Some("restore".into()), - owner_map: "[]".into(), - }; - let _ = state - .note_channel(&workspace_slug, ¬e_slug) - .await - .send(RoomEvent::Document(update)); - 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, - 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_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_level == AccessLevel::None - { - return Err(ApiError::unauthorized()); - } - Ok(workspace) -} - -async fn authorized_note( - state: &SharedState, - workspace_slug: &str, - 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, bearer).await?; - let note = db::find_note(&state.db, workspace.id, note_slug) - .await? - .ok_or_else(ApiError::not_found_note)?; - Ok((workspace, note)) -} - -fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo { - WorkspaceInfo { - slug: workspace.slug.clone(), - title: workspace.title.clone(), - protected: workspace.password_hash.is_some(), - created_at: db::normalize_timestamp(&workspace.created_at), - updated_at: db::normalize_timestamp(&workspace.updated_at), - } -} - -fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> { - let value = value.trim(); - if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH { - return Err(ApiError::bad_request(&format!( - "{field} must contain between 1 and {MAX_NAME_LENGTH} characters" - ))); - } - Ok(value) -} - -fn validate_password(password: Option<&str>) -> Result, ApiError> { - let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - let length = password.chars().count(); - if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) { - return Err(ApiError::bad_request( - "Password must contain between 8 and 128 characters", - )); - } - Ok(Some(password)) -} - -async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result { - let base = slugify(title); - if base.is_empty() { - return Err(ApiError::bad_request( - "The name cannot be converted into a valid address", - )); - } - - let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH - || db::find_workspace(&state.db, &base).await?.is_some(); - if !needs_suffix { - return Ok(base); - } - - for _ in 0..8 { - let candidate = format!("{base}-{}", db::random_suffix(8)); - if db::find_workspace(&state.db, &candidate).await?.is_none() { - return Ok(candidate); - } - } - Err(ApiError::internal("Failed to create a unique address")) -} - -async fn unique_note_slug( - state: &SharedState, - workspace_id: i64, - base: &str, -) -> Result { - if db::find_note(&state.db, workspace_id, base) - .await? - .is_none() - { - return Ok(base.to_owned()); - } - for _ in 0..8 { - let candidate = format!("{base}-{}", db::random_suffix(6)); - if db::find_note(&state.db, workspace_id, &candidate) - .await? - .is_none() - { - return Ok(candidate); - } - } - Err(ApiError::internal("Failed to create a unique address")) -} - diff --git a/src/db/files.rs b/src/db/files.rs index f4b0a33..17519a7 100644 --- a/src/db/files.rs +++ b/src/db/files.rs @@ -1,3 +1,5 @@ +use super::*; + #[derive(Debug, Clone, Copy)] pub enum FileOwnerKind { Pad, @@ -113,13 +115,15 @@ async fn list_files( owner_id: i64, ) -> Result, sqlx::Error> { if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query)) - .bind(owner_id) - .fetch_all(pool.pool()) - .await? - .into_iter() - .map(NoteFile::from) - .collect()); + return Ok( + sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query)) + .bind(owner_id) + .fetch_all(pool.pool()) + .await? + .into_iter() + .map(NoteFile::from) + .collect(), + ); } sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query)) .bind(owner_id) @@ -194,12 +198,14 @@ pub async fn find_note_file( file_id: i64, ) -> Result, sqlx::Error> { if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038)) - .bind(file_id) - .bind(note_id) - .fetch_optional(pool.pool()) - .await? - .map(NoteFile::from)); + return Ok( + sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038)) + .bind(file_id) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .map(NoteFile::from), + ); } sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038)) .bind(file_id) @@ -227,12 +233,14 @@ pub async fn find_pad_file( file_id: i64, ) -> Result, sqlx::Error> { if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046)) - .bind(file_id) - .bind(pad_id) - .fetch_optional(pool.pool()) - .await? - .map(NoteFile::from)); + return Ok( + sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046)) + .bind(file_id) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .map(NoteFile::from), + ); } sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046)) .bind(file_id) @@ -255,33 +263,18 @@ pub async fn delete_pad_file( } -impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace { - fn from_row(row: &'r AnyRow) -> Result { - Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, is_private: row.try_get("is_private")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for Note { - fn from_row(row: &'r AnyRow) -> Result { - let protected: i64 = row.try_get("protected")?; - Ok(Self { id: row.try_get("id")?, _workspace_id: row.try_get("workspace_id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, protected: protected != 0, created_by: crate::row_decode::optional_text(row, "created_by")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for Revision { - fn from_row(row: &'r AnyRow) -> Result { - Ok(Self { id: row.try_get("id")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, author: crate::row_decode::optional_text(row, "author")?, owner_map: crate::row_decode::text(row, "owner_map")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for Pad { - fn from_row(row: &'r AnyRow) -> Result { - Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, 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")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for PublishedPageRow { - fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow { - fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) } -} impl<'r> sqlx::FromRow<'r, AnyRow> for NoteFile { - fn from_row(row: &'r AnyRow) -> Result { let attached:i64=row.try_get("is_attached")?; Ok(Self { id:row.try_get("id")?, filename:crate::row_decode::text(row,"filename")?, url:crate::row_decode::text(row,"url")?, mime_type:crate::row_decode::text(row,"mime_type")?, size_bytes:row.try_get("size_bytes")?, created_at:crate::row_decode::text(row,"created_at")?, is_attached:attached!=0, detached_at:crate::row_decode::optional_text(row,"detached_at")? }) } + fn from_row(row: &'r AnyRow) -> Result { + let attached: i64 = row.try_get("is_attached")?; + Ok(Self { + id: row.try_get("id")?, + filename: crate::row_decode::text(row, "filename")?, + url: crate::row_decode::text(row, "url")?, + mime_type: crate::row_decode::text(row, "mime_type")?, + size_bytes: row.try_get("size_bytes")?, + created_at: crate::row_decode::text(row, "created_at")?, + is_attached: attached != 0, + detached_at: crate::row_decode::optional_text(row, "detached_at")?, + }) + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 5503017..1629d0d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -7,9 +7,463 @@ use chrono::{DateTime, NaiveDateTime, Utc}; use rand_core::{OsRng, RngCore}; use serde::Serialize; use sqlx::FromRow; -use sqlx::{any::AnyRow, Any, Row, Transaction}; +use sqlx::{Any, Row, Transaction, any::AnyRow}; + +mod files; +mod public_pages; + +pub use files::*; +pub use public_pages::*; + +async fn inserted_id( + kind: DatabaseKind, + tx: &mut Transaction<'_, Any>, + table: &str, +) -> Result { + let query = match kind { + DatabaseKind::Sqlite => queries::get(kind, queries::SQLITE_LAST_INSERT_ID), + DatabaseKind::MySql => queries::get(kind, queries::MYSQL_LAST_INSERT_ID), + DatabaseKind::Postgres => match table { + "note_revisions" => queries::get(kind, queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID), + "revisions" => queries::get(kind, queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID), + _ => unreachable!("unsupported identity table"), + }, + }; + sqlx::query_scalar(query).fetch_one(&mut **tx).await +} + +#[derive(Debug, Clone)] +pub struct Workspace { + pub id: i64, + pub slug: String, + pub title: String, + pub password_hash: Option, + pub created_at: String, + pub updated_at: String, + pub is_private: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Note { + pub id: i64, + #[serde(skip_serializing)] + pub _workspace_id: i64, + pub slug: String, + pub title: String, + #[serde(skip_serializing)] + pub content: String, + pub created_at: String, + pub updated_at: String, + pub owner_map: String, + pub protected: bool, + pub created_by: Option, +} + +#[derive(Debug, Clone, FromRow)] +struct SqliteNote { + id: i64, + workspace_id: i64, + slug: String, + title: String, + content: String, + created_at: String, + updated_at: String, + owner_map: String, + protected: i64, + created_by: Option, +} + +impl From for Note { + fn from(value: SqliteNote) -> Self { + Self { + id: value.id, + _workspace_id: value.workspace_id, + slug: value.slug, + title: value.title, + content: value.content, + created_at: value.created_at, + updated_at: value.updated_at, + owner_map: value.owner_map, + protected: value.protected != 0, + created_by: value.created_by, + } + } +} + +#[derive(Debug, Serialize)] +pub struct Revision { + pub id: i64, + pub content: String, + pub created_at: String, + pub author: Option, + pub owner_map: String, +} + +pub async fn find_workspace(pool: &Database, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) + .bind(slug) + .fetch_optional(pool.pool()) + .await +} + +pub async fn create_workspace( + pool: &Database, + slug: &str, + title: &str, + password: Option<&str>, +) -> Result { + let password_hash = password + .filter(|value| !value.is_empty()) + .map(hash_password); + sqlx::query(queries::get(pool.kind(), queries::Q002)) + .bind(slug) + .bind(title) + .bind(password_hash) + .execute(pool.pool()) + .await?; + + sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) + .bind(slug) + .fetch_one(pool.pool()) + .await +} + +pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool { + match ( + &workspace.password_hash, + password.filter(|value| !value.is_empty()), + ) { + (None, _) => true, + (Some(hash), Some(password)) => PasswordHash::new(hash) + .ok() + .and_then(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .ok() + }) + .is_some(), + (Some(_), None) => false, + } +} + +pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok( + sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q003)) + .bind(workspace_id) + .fetch_all(pool.pool()) + .await? + .into_iter() + .map(Note::from) + .collect(), + ); + } + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003)) + .bind(workspace_id) + .fetch_all(pool.pool()) + .await +} + +pub async fn find_note( + pool: &Database, + workspace_id: i64, + slug: &str, +) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok( + sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q004)) + .bind(workspace_id) + .bind(slug) + .fetch_optional(pool.pool()) + .await? + .map(Note::from), + ); + } + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) + .bind(workspace_id) + .bind(slug) + .fetch_optional(pool.pool()) + .await +} + +pub async fn create_note( + pool: &Database, + workspace_id: i64, + slug: &str, + title: &str, + protected: bool, + created_by: Option<&str>, +) -> Result { + sqlx::query(queries::get(pool.kind(), queries::Q005)) + .bind(workspace_id) + .bind(slug) + .bind(title) + .bind(protected) + .bind(created_by) + .execute(pool.pool()) + .await?; + + find_note(pool, workspace_id, slug) + .await? + .ok_or(sqlx::Error::RowNotFound) +} + +pub async fn save_revision( + pool: &Database, + note_id: i64, + workspace_id: i64, + content: &str, + author: Option<&str>, + owner_map: &str, +) -> Result<(i64, String), sqlx::Error> { + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q006)) + .bind(content) + .bind(owner_map) + .bind(note_id) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get(pool.kind(), queries::Q007)) + .bind(workspace_id) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get(pool.kind(), queries::Q008)) + .bind(note_id) + .bind(content) + .bind(author) + .bind(owner_map) + .execute(&mut *tx) + .await?; + let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009)) + .bind(note_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok((revision_id, updated_at)) +} + +pub async fn list_revisions(pool: &Database, note_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010)) + .bind(note_id) + .fetch_all(pool.pool()) + .await +} + +pub fn random_suffix(length: usize) -> String { + const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; + let mut bytes = vec![0_u8; length]; + let mut rng = OsRng; + rng.fill_bytes(&mut bytes); + bytes + .into_iter() + .map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char) + .collect() +} + +fn hash_password(password: &str) -> String { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .expect("Argon2 hashing should succeed") + .to_string() +} + +pub fn normalize_timestamp(value: &str) -> String { + let value = value.trim(); + + if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) { + return timestamp.with_timezone(&Utc).to_rfc3339(); + } + + // PostgreSQL renders TEXT timestamps as e.g. `2026-07-20 14:32:10.123456+00`. + // RFC 3339 requires `T` and a colon in the numeric offset. + let mut postgres = value.replacen(' ', "T", 1); + if postgres.len() >= 3 { + let offset_start = postgres.len() - 3; + let offset = &postgres[offset_start..]; + if (offset.starts_with('+') || offset.starts_with('-')) + && offset[1..] + .chars() + .all(|character| character.is_ascii_digit()) + { + postgres.push_str(":00"); + } + } + if let Ok(timestamp) = DateTime::parse_from_rfc3339(&postgres) { + return timestamp.with_timezone(&Utc).to_rfc3339(); + } + + for format in ["%Y-%m-%d %H:%M:%S%.f%:z", "%Y-%m-%dT%H:%M:%S%.f%:z"] { + if let Ok(timestamp) = DateTime::parse_from_str(value, format) { + return timestamp.with_timezone(&Utc).to_rfc3339(); + } + } + + for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] { + if let Ok(timestamp) = NaiveDateTime::parse_from_str(value, format) { + return timestamp.and_utc().to_rfc3339(); + } + } + + // Existing rows should still be readable even if they were stored without a zone. + value.to_string() +} + +#[derive(Debug, Clone)] +pub struct Pad { + pub id: i64, + pub slug: String, + pub title: String, + pub content: String, + pub password_hash: Option, + pub created_at: String, + pub updated_at: String, + pub owner_map: String, + pub is_private: i64, +} + +pub async fn find_pad(pool: &Database, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) + .bind(slug) + .fetch_optional(pool.pool()) + .await +} + +pub async fn create_pad( + pool: &Database, + slug: &str, + title: &str, + password: Option<&str>, +) -> Result { + let password_hash = password + .filter(|value| !value.is_empty()) + .map(hash_password); + sqlx::query(queries::get(pool.kind(), queries::Q012)) + .bind(slug) + .bind(title) + .bind(password_hash) + .execute(pool.pool()) + .await?; + + sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) + .bind(slug) + .fetch_one(pool.pool()) + .await +} + +pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { + match ( + &pad.password_hash, + password.filter(|value| !value.is_empty()), + ) { + (None, _) => true, + (Some(hash), Some(password)) => PasswordHash::new(hash) + .ok() + .and_then(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .ok() + }) + .is_some(), + (Some(_), None) => false, + } +} + +pub async fn save_pad_revision( + pool: &Database, + pad_id: i64, + content: &str, + author: Option<&str>, + owner_map: &str, +) -> Result<(i64, String), sqlx::Error> { + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q013)) + .bind(content) + .bind(owner_map) + .bind(pad_id) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get(pool.kind(), queries::Q014)) + .bind(pad_id) + .bind(content) + .bind(author) + .bind(owner_map) + .execute(&mut *tx) + .await?; + let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015)) + .bind(pad_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok((revision_id, updated_at)) +} + +pub async fn list_pad_revisions( + pool: &Database, + pad_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) + .bind(pad_id) + .fetch_all(pool.pool()) + .await +} -include!("workspace_notes.rs"); -include!("pads_public.rs"); -include!("files.rs"); +impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + id: row.try_get("id")?, + slug: crate::row_decode::text(row, "slug")?, + title: crate::row_decode::text(row, "title")?, + password_hash: crate::row_decode::optional_text(row, "password_hash")?, + created_at: crate::row_decode::text(row, "created_at")?, + updated_at: crate::row_decode::text(row, "updated_at")?, + is_private: row.try_get("is_private")?, + }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for Note { + fn from_row(row: &'r AnyRow) -> Result { + let protected: i64 = row.try_get("protected")?; + Ok(Self { + id: row.try_get("id")?, + _workspace_id: row.try_get("workspace_id")?, + slug: crate::row_decode::text(row, "slug")?, + title: crate::row_decode::text(row, "title")?, + content: crate::row_decode::text(row, "content")?, + created_at: crate::row_decode::text(row, "created_at")?, + updated_at: crate::row_decode::text(row, "updated_at")?, + owner_map: crate::row_decode::text(row, "owner_map")?, + protected: protected != 0, + created_by: crate::row_decode::optional_text(row, "created_by")?, + }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for Revision { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + id: row.try_get("id")?, + content: crate::row_decode::text(row, "content")?, + created_at: crate::row_decode::text(row, "created_at")?, + author: crate::row_decode::optional_text(row, "author")?, + owner_map: crate::row_decode::text(row, "owner_map")?, + }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for Pad { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + id: row.try_get("id")?, + slug: crate::row_decode::text(row, "slug")?, + title: crate::row_decode::text(row, "title")?, + content: crate::row_decode::text(row, "content")?, + password_hash: crate::row_decode::optional_text(row, "password_hash")?, + created_at: crate::row_decode::text(row, "created_at")?, + 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")?, + }) + } +} diff --git a/src/db/pads_public.rs b/src/db/public_pages.rs similarity index 70% rename from src/db/pads_public.rs rename to src/db/public_pages.rs index e5f36d3..18da564 100644 --- a/src/db/pads_public.rs +++ b/src/db/public_pages.rs @@ -1,102 +1,4 @@ -#[derive(Debug, Clone)] -pub struct Pad { - pub id: i64, - pub slug: String, - pub title: String, - pub content: String, - pub password_hash: Option, - pub created_at: String, - pub updated_at: String, - pub owner_map: String, - pub is_private: i64, -} - -pub async fn find_pad(pool: &Database, slug: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) - .bind(slug) - .fetch_optional(pool.pool()) - .await -} - -pub async fn create_pad( - pool: &Database, - slug: &str, - title: &str, - password: Option<&str>, -) -> Result { - let password_hash = password - .filter(|value| !value.is_empty()) - .map(hash_password); - sqlx::query(queries::get(pool.kind(), queries::Q012)) - .bind(slug) - .bind(title) - .bind(password_hash) - .execute(pool.pool()) - .await?; - - sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) - .bind(slug) - .fetch_one(pool.pool()) - .await -} - -pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { - match ( - &pad.password_hash, - password.filter(|value| !value.is_empty()), - ) { - (None, _) => true, - (Some(hash), Some(password)) => PasswordHash::new(hash) - .ok() - .and_then(|parsed| { - Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .ok() - }) - .is_some(), - (Some(_), None) => false, - } -} - -pub async fn save_pad_revision( - pool: &Database, - pad_id: i64, - content: &str, - author: Option<&str>, - owner_map: &str, -) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.pool().begin().await?; - sqlx::query(queries::get(pool.kind(), queries::Q013)) - .bind(content) - .bind(owner_map) - .bind(pad_id) - .execute(&mut *tx) - .await?; - sqlx::query(queries::get(pool.kind(), queries::Q014)) - .bind(pad_id) - .bind(content) - .bind(author) - .bind(owner_map) - .execute(&mut *tx) - .await?; - let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?; - let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015)) - .bind(pad_id) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - Ok((revision_id, updated_at)) -} - -pub async fn list_pad_revisions( - pool: &Database, - pad_id: i64, -) -> Result, sqlx::Error> { - sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) - .bind(pad_id) - .fetch_all(pool.pool()) - .await -} +use super::*; #[derive(Debug, Clone, Serialize)] pub struct PublishedPage { @@ -197,13 +99,14 @@ pub async fn find_published_page( token: &str, ) -> Result, sqlx::Error> { if pool.kind() == DatabaseKind::Postgres { - return Ok( - sqlx::query_as::<_, PostgresPublishedPageRow>(queries::get(pool.kind(), queries::Q021)) - .bind(token) - .fetch_optional(pool.pool()) - .await? - .map(Into::into), - ); + return Ok(sqlx::query_as::<_, PostgresPublishedPageRow>(queries::get( + pool.kind(), + queries::Q021, + )) + .bind(token) + .fetch_optional(pool.pool()) + .await? + .map(Into::into)); } Ok( sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021)) @@ -216,11 +119,13 @@ pub async fn find_published_page( pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result { if pool.kind() == DatabaseKind::Postgres { - return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q044)) - .bind(pad_id) - .fetch_optional(pool.pool()) - .await? - .unwrap_or(false)); + return Ok( + sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q044)) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(false), + ); } let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044)) .bind(pad_id) @@ -232,11 +137,13 @@ pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result Result { if pool.kind() == DatabaseKind::Postgres { - return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q045)) - .bind(note_id) - .fetch_optional(pool.pool()) - .await? - .unwrap_or(false)); + return Ok( + sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q045)) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(false), + ); } let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045)) .bind(note_id) @@ -377,3 +284,30 @@ pub async fn note_file_token(pool: &Database, note_id: i64) -> Result sqlx::FromRow<'r, AnyRow> for PublishedPageRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + token: crate::row_decode::text(row, "token")?, + pad_id: row.try_get("pad_id")?, + note_id: row.try_get("note_id")?, + allow_task_updates: row.try_get("allow_task_updates")?, + title: crate::row_decode::text(row, "title")?, + content: crate::row_decode::text(row, "content")?, + updated_at: crate::row_decode::text(row, "updated_at")?, + }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + token: crate::row_decode::text(row, "token")?, + pad_id: row.try_get("pad_id")?, + note_id: row.try_get("note_id")?, + allow_task_updates: row.try_get("allow_task_updates")?, + title: crate::row_decode::text(row, "title")?, + content: crate::row_decode::text(row, "content")?, + updated_at: crate::row_decode::text(row, "updated_at")?, + }) + } +} diff --git a/src/db/workspace_notes.rs b/src/db/workspace_notes.rs deleted file mode 100644 index 75b1f24..0000000 --- a/src/db/workspace_notes.rs +++ /dev/null @@ -1,291 +0,0 @@ -async fn inserted_id( - kind: DatabaseKind, - tx: &mut Transaction<'_, Any>, - table: &str, -) -> Result { - let query = match kind { - DatabaseKind::Sqlite => queries::get(kind, queries::SQLITE_LAST_INSERT_ID), - DatabaseKind::MySql => queries::get(kind, queries::MYSQL_LAST_INSERT_ID), - DatabaseKind::Postgres => match table { - "note_revisions" => queries::get(kind, queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID), - "revisions" => queries::get(kind, queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID), - _ => unreachable!("unsupported identity table"), - }, - }; - sqlx::query_scalar(query).fetch_one(&mut **tx).await -} - -#[derive(Debug, Clone)] -pub struct Workspace { - pub id: i64, - pub slug: String, - pub title: String, - pub password_hash: Option, - pub created_at: String, - pub updated_at: String, - pub is_private: i64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Note { - pub id: i64, - #[serde(skip_serializing)] - pub _workspace_id: i64, - pub slug: String, - pub title: String, - #[serde(skip_serializing)] - pub content: String, - pub created_at: String, - pub updated_at: String, - pub owner_map: String, - pub protected: bool, - pub created_by: Option, -} - -#[derive(Debug, Clone, FromRow)] -struct SqliteNote { - id: i64, - workspace_id: i64, - slug: String, - title: String, - content: String, - created_at: String, - updated_at: String, - owner_map: String, - protected: i64, - created_by: Option, -} - -impl From for Note { - fn from(value: SqliteNote) -> Self { - Self { - id: value.id, - _workspace_id: value.workspace_id, - slug: value.slug, - title: value.title, - content: value.content, - created_at: value.created_at, - updated_at: value.updated_at, - owner_map: value.owner_map, - protected: value.protected != 0, - created_by: value.created_by, - } - } -} - -#[derive(Debug, Serialize)] -pub struct Revision { - pub id: i64, - pub content: String, - pub created_at: String, - pub author: Option, - pub owner_map: String, -} - -pub async fn find_workspace(pool: &Database, slug: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) - .bind(slug) - .fetch_optional(pool.pool()) - .await -} - -pub async fn create_workspace( - pool: &Database, - slug: &str, - title: &str, - password: Option<&str>, -) -> Result { - let password_hash = password - .filter(|value| !value.is_empty()) - .map(hash_password); - sqlx::query(queries::get(pool.kind(), queries::Q002)) - .bind(slug) - .bind(title) - .bind(password_hash) - .execute(pool.pool()) - .await?; - - sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) - .bind(slug) - .fetch_one(pool.pool()) - .await -} - -pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool { - match ( - &workspace.password_hash, - password.filter(|value| !value.is_empty()), - ) { - (None, _) => true, - (Some(hash), Some(password)) => PasswordHash::new(hash) - .ok() - .and_then(|parsed| { - Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .ok() - }) - .is_some(), - (Some(_), None) => false, - } -} - -pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, sqlx::Error> { - if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q003)) - .bind(workspace_id) - .fetch_all(pool.pool()) - .await? - .into_iter() - .map(Note::from) - .collect()); - } - sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003)) - .bind(workspace_id) - .fetch_all(pool.pool()) - .await -} - -pub async fn find_note( - pool: &Database, - workspace_id: i64, - slug: &str, -) -> Result, sqlx::Error> { - if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q004)) - .bind(workspace_id) - .bind(slug) - .fetch_optional(pool.pool()) - .await? - .map(Note::from)); - } - sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) - .bind(workspace_id) - .bind(slug) - .fetch_optional(pool.pool()) - .await -} - -pub async fn create_note( - pool: &Database, - workspace_id: i64, - slug: &str, - title: &str, - protected: bool, - created_by: Option<&str>, -) -> Result { - sqlx::query(queries::get(pool.kind(), queries::Q005)) - .bind(workspace_id) - .bind(slug) - .bind(title) - .bind(protected) - .bind(created_by) - .execute(pool.pool()) - .await?; - - find_note(pool, workspace_id, slug) - .await? - .ok_or(sqlx::Error::RowNotFound) -} - -pub async fn save_revision( - pool: &Database, - note_id: i64, - workspace_id: i64, - content: &str, - author: Option<&str>, - owner_map: &str, -) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.pool().begin().await?; - sqlx::query(queries::get(pool.kind(), queries::Q006)) - .bind(content) - .bind(owner_map) - .bind(note_id) - .execute(&mut *tx) - .await?; - sqlx::query(queries::get(pool.kind(), queries::Q007)) - .bind(workspace_id) - .execute(&mut *tx) - .await?; - sqlx::query(queries::get(pool.kind(), queries::Q008)) - .bind(note_id) - .bind(content) - .bind(author) - .bind(owner_map) - .execute(&mut *tx) - .await?; - let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?; - let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009)) - .bind(note_id) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - Ok((revision_id, updated_at)) -} - -pub async fn list_revisions(pool: &Database, note_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010)) - .bind(note_id) - .fetch_all(pool.pool()) - .await -} - -pub fn random_suffix(length: usize) -> String { - const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; - let mut bytes = vec![0_u8; length]; - let mut rng = OsRng; - rng.fill_bytes(&mut bytes); - bytes - .into_iter() - .map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char) - .collect() -} - -fn hash_password(password: &str) -> String { - let salt = SaltString::generate(&mut OsRng); - Argon2::default() - .hash_password(password.as_bytes(), &salt) - .expect("Argon2 hashing should succeed") - .to_string() -} - -pub fn normalize_timestamp(value: &str) -> String { - let value = value.trim(); - - if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) { - return timestamp.with_timezone(&Utc).to_rfc3339(); - } - - // PostgreSQL renders TEXT timestamps as e.g. `2026-07-20 14:32:10.123456+00`. - // RFC 3339 requires `T` and a colon in the numeric offset. - let mut postgres = value.replacen(' ', "T", 1); - if postgres.len() >= 3 { - let offset_start = postgres.len() - 3; - let offset = &postgres[offset_start..]; - if (offset.starts_with('+') || offset.starts_with('-')) - && offset[1..] - .chars() - .all(|character| character.is_ascii_digit()) - { - postgres.push_str(":00"); - } - } - if let Ok(timestamp) = DateTime::parse_from_rfc3339(&postgres) { - return timestamp.with_timezone(&Utc).to_rfc3339(); - } - - for format in ["%Y-%m-%d %H:%M:%S%.f%:z", "%Y-%m-%dT%H:%M:%S%.f%:z"] { - if let Ok(timestamp) = DateTime::parse_from_str(value, format) { - return timestamp.with_timezone(&Utc).to_rfc3339(); - } - } - - for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] { - if let Ok(timestamp) = NaiveDateTime::parse_from_str(value, format) { - return timestamp.and_utc().to_rfc3339(); - } - } - - // Existing rows should still be readable even if they were stored without a zone. - value.to_string() -} - diff --git a/src/websocket/helpers.rs b/src/websocket/helpers.rs deleted file mode 100644 index e3bc461..0000000 --- a/src/websocket/helpers.rs +++ /dev/null @@ -1,73 +0,0 @@ - let _ = channel.send(RoomEvent::Presence(users)); - info!( - workspace_id = workspace.id, - note_id = note.id, - "note websocket disconnected" - ); -} -fn clean_nickname(value: Option) -> Option { - value - .map(|v| v.trim().chars().take(40).collect::()) - .filter(|v| !v.is_empty()) -} -fn clean_guest_id(value: Option) -> Option { - value - .map(|v| v.trim().chars().take(64).collect::()) - .filter(|v| { - v.len() >= 16 - && v.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - }) -} -fn clean_color(value: Option) -> Option { - value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| { - v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit()) - }) -} -fn clean_chat(value: String) -> String { - value - .chars() - .map(|c| { - if matches!(c, '\r' | '\n' | '\0') { - ' ' - } else { - c - } - }) - .collect::() - .trim() - .chars() - .take(1000) - .collect() -} -async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> { - send( - socket, - &ServerMessage::Error { - message: message.into(), - }, - ) - .await -} -async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> { - socket - .send(Message::Text( - serde_json::to_string(message).unwrap().into(), - )) - .await -} -async fn send_split( - sender: &mut futures_util::stream::SplitSink, - message: &ServerMessage, -) -> Result<(), axum::Error> { - sender - .send(Message::Text( - serde_json::to_string(message).unwrap().into(), - )) - .await -} - -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum PadServerMessage { - Authenticated { diff --git a/src/websocket/messages.rs b/src/websocket/messages.rs deleted file mode 100644 index d68a295..0000000 --- a/src/websocket/messages.rs +++ /dev/null @@ -1,57 +0,0 @@ -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ClientMessage { - Authenticate { - password: Option, - access_token: Option, - nickname: Option, - session_token: Option, - guest_id: Option, - color: Option, - }, - Update { - content: String, - owner_map: Option, - }, - Ping { - nonce: u64, - }, - Chat { - text: String, - }, - SetColor { - color: Option, - }, -} - -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum ServerMessage { - Authenticated { - workspace_title: String, - note_title: String, - content: String, - owner_map: String, - access_level: String, - }, - Document { - content: String, - revision_id: i64, - updated_at: String, - author: Option, - owner_map: String, - }, - Presence { - users: Vec, - }, - Chat { - sender: String, - text: String, - }, - Pong { - nonce: u64, - }, - Error { - message: String, - }, -} diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index 232865a..8906b02 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -14,7 +14,313 @@ use serde::{Deserialize, Serialize}; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; +mod pad; -include!("messages.rs"); -include!("note.rs"); -include!("pad.rs"); +pub use pad::upgrade_pad; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientMessage { + Authenticate { + password: Option, + access_token: Option, + nickname: Option, + session_token: Option, + guest_id: Option, + color: Option, + }, + Update { + content: String, + owner_map: Option, + }, + Ping { + nonce: u64, + }, + Chat { + text: String, + }, + SetColor { + color: Option, + }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ServerMessage { + Authenticated { + workspace_title: String, + note_title: String, + content: String, + owner_map: String, + access_level: String, + }, + Document { + content: String, + revision_id: i64, + updated_at: String, + author: Option, + owner_map: String, + }, + Presence { + users: Vec, + }, + Chat { + sender: String, + text: String, + }, + Pong { + nonce: u64, + }, + Error { + message: String, + }, +} + +// Merged from note.rs +pub async fn upgrade( + ws: WebSocketUpgrade, + Path((workspace_slug, note_slug)): Path<(String, String)>, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug)) +} + +async fn handle_socket( + mut socket: WebSocket, + state: SharedState, + workspace_slug: String, + note_slug: String, +) { + info!(%workspace_slug, %note_slug, "note websocket connected"); + let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) + .await + .ok() + .flatten() + else { + warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); + let _ = send_error(&mut socket, "Workspace not found").await; + return; + }; + let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug) + .await + .ok() + .flatten() + else { + warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); + let _ = send_error(&mut socket, "Note not found").await; + return; + }; + let (password, access_token, nickname, session_token, guest_id, color) = + match socket.recv().await { + Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { + Ok(ClientMessage::Authenticate { + password, + access_token, + nickname, + session_token, + guest_id, + color, + }) => ( + password, + access_token, + clean_nickname(nickname), + session_token, + clean_guest_id(guest_id), + clean_color(color), + ), + _ => { + let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await; + return; + } + }, + _ => return, + }; + let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { + Ok(value) => value, + Err(message) => { + let _ = send_error(&mut socket, &message).await; + return; + } + }; + let presence_identity = match session_token.as_deref() { + Some(token) => auth::user_from_token(&state, token) + .await + .ok() + .flatten() + .map(|user| format!("user:{}", user.id)), + None => guest_id.as_ref().and_then(|id| { + nickname + .as_ref() + .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, 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() && !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() + && !anonymous_token_ok + { + warn!( + workspace_id = workspace.id, + note_id = note.id, + "note websocket rejected: invalid workspace password" + ); + let _ = send_error(&mut socket, "Invalid password").await; + return; + } + 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, + &ServerMessage::Authenticated { + workspace_title: workspace.title.clone(), + note_title: note.title.clone(), + content: note.content.clone(), + owner_map: note.owner_map.clone(), + access_level: if write_allowed { + "full".into() + } else { + "read_only".into() + }, + }, + ) + .await + .is_err() + { + return; + } + let room_key = AppState::note_room_key(&workspace_slug, ¬e_slug); + let channel = state.note_channel(&workspace_slug, ¬e_slug).await; + let mut updates = channel.subscribe(); + let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); + let (connection_id, users) = state + .join_room(&room_key, display_name.clone(), color, presence_identity) + .await; + let _ = channel.send(RoomEvent::Presence(users)); + let mut last_chat = Instant::now() - Duration::from_secs(1); + let (mut sender, mut receiver) = socket.split(); + loop { + tokio::select! { + incoming=receiver.next()=>match incoming { + Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text) { + Ok(ClientMessage::Update{content,owner_map})=>{ + if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } + if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } + let owner_map=owner_map.unwrap_or_else(||"[]".into()); + match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { + Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));} + Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"), + } + } + Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; }, + Ok(ClientMessage::Chat{text})=>{ + let text=clean_chat(text); + if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } + } + Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, + Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"), + }, + Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;} + }, + update=updates.recv()=>match update { + Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;}, + Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;}, + Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, + Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + } + } + } + let users = state.leave_room(&room_key, connection_id).await; + let _ = channel.send(RoomEvent::Presence(users)); + info!( + workspace_id = workspace.id, + note_id = note.id, + "note websocket disconnected" + ); +} +fn clean_nickname(value: Option) -> Option { + value + .map(|v| v.trim().chars().take(40).collect::()) + .filter(|v| !v.is_empty()) +} +fn clean_guest_id(value: Option) -> Option { + value + .map(|v| v.trim().chars().take(64).collect::()) + .filter(|v| { + v.len() >= 16 + && v.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) +} +fn clean_color(value: Option) -> Option { + value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| { + v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit()) + }) +} +fn clean_chat(value: String) -> String { + value + .chars() + .map(|c| { + if matches!(c, '\r' | '\n' | '\0') { + ' ' + } else { + c + } + }) + .collect::() + .trim() + .chars() + .take(1000) + .collect() +} +async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> { + send( + socket, + &ServerMessage::Error { + message: message.into(), + }, + ) + .await +} +async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> { + socket + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await +} +async fn send_split( + sender: &mut futures_util::stream::SplitSink, + message: &ServerMessage, +) -> Result<(), axum::Error> { + sender + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await +} + +// Merged from pad.rs diff --git a/src/websocket/note.rs b/src/websocket/note.rs deleted file mode 100644 index 6f33bd3..0000000 --- a/src/websocket/note.rs +++ /dev/null @@ -1,236 +0,0 @@ -pub async fn upgrade( - ws: WebSocketUpgrade, - Path((workspace_slug, note_slug)): Path<(String, String)>, - State(state): State, -) -> Response { - ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug)) -} - -async fn handle_socket( - mut socket: WebSocket, - state: SharedState, - workspace_slug: String, - note_slug: String, -) { - info!(%workspace_slug, %note_slug, "note websocket connected"); - let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) - .await - .ok() - .flatten() - else { - warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); - let _ = send_error(&mut socket, "Workspace not found").await; - return; - }; - let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug) - .await - .ok() - .flatten() - else { - warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); - let _ = send_error(&mut socket, "Note not found").await; - return; - }; - let (password, access_token, nickname, session_token, guest_id, color) = - match socket.recv().await { - Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Authenticate { - password, - access_token, - nickname, - session_token, - guest_id, - color, - }) => ( - password, - access_token, - clean_nickname(nickname), - session_token, - clean_guest_id(guest_id), - clean_color(color), - ), - _ => { - let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await; - return; - } - }, - _ => return, - }; - let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { - Ok(value) => value, - Err(message) => { - let _ = send_error(&mut socket, &message).await; - return; - } - }; - let presence_identity = match session_token.as_deref() { - Some(token) => auth::user_from_token(&state, token) - .await - .ok() - .flatten() - .map(|user| format!("user:{}", user.id)), - None => guest_id.as_ref().and_then(|id| { - nickname - .as_ref() - .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, - 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() && !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() && !anonymous_token_ok { - warn!( - workspace_id = workspace.id, - note_id = note.id, - "note websocket rejected: invalid workspace password" - ); - let _ = send_error(&mut socket, "Invalid password").await; - return; - } - 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, - &ServerMessage::Authenticated { - workspace_title: workspace.title.clone(), - note_title: note.title.clone(), - content: note.content.clone(), - owner_map: note.owner_map.clone(), - access_level: if write_allowed { "full".into() } else { "read_only".into() }, - }, - ) - .await - .is_err() - { - return; - } - let room_key = AppState::note_room_key(&workspace_slug, ¬e_slug); - let channel = state.note_channel(&workspace_slug, ¬e_slug).await; - let mut updates = channel.subscribe(); - let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); - let (connection_id, users) = state - .join_room(&room_key, display_name.clone(), color, presence_identity) - .await; - let _ = channel.send(RoomEvent::Presence(users)); - let mut last_chat = Instant::now() - Duration::from_secs(1); - let (mut sender, mut receiver) = socket.split(); - loop { - tokio::select! { - incoming=receiver.next()=>match incoming { - Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text) { - Ok(ClientMessage::Update{content,owner_map})=>{ - if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } - if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } - let owner_map=owner_map.unwrap_or_else(||"[]".into()); - match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { - Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));} - Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"), - } - } - Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; }, - Ok(ClientMessage::Chat{text})=>{ - let text=clean_chat(text); - if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } - } - Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, - Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"), - }, - Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;} - }, - update=updates.recv()=>match update { - Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;}, - Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;}, - Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, - Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, - } - } - } - let users = state.leave_room(&room_key, connection_id).await; - let _ = channel.send(RoomEvent::Presence(users)); - info!( - workspace_id = workspace.id, - note_id = note.id, - "note websocket disconnected" - ); -} -fn clean_nickname(value: Option) -> Option { - value - .map(|v| v.trim().chars().take(40).collect::()) - .filter(|v| !v.is_empty()) -} -fn clean_guest_id(value: Option) -> Option { - value - .map(|v| v.trim().chars().take(64).collect::()) - .filter(|v| { - v.len() >= 16 - && v.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - }) -} -fn clean_color(value: Option) -> Option { - value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| { - v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit()) - }) -} -fn clean_chat(value: String) -> String { - value - .chars() - .map(|c| { - if matches!(c, '\r' | '\n' | '\0') { - ' ' - } else { - c - } - }) - .collect::() - .trim() - .chars() - .take(1000) - .collect() -} -async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> { - send( - socket, - &ServerMessage::Error { - message: message.into(), - }, - ) - .await -} -async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> { - socket - .send(Message::Text( - serde_json::to_string(message).unwrap().into(), - )) - .await -} -async fn send_split( - sender: &mut futures_util::stream::SplitSink, - message: &ServerMessage, -) -> Result<(), axum::Error> { - sender - .send(Message::Text( - serde_json::to_string(message).unwrap().into(), - )) - .await -} diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs index 9aa7726..32bbd86 100644 --- a/src/websocket/pad.rs +++ b/src/websocket/pad.rs @@ -1,3 +1,5 @@ +use super::*; + #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum PadServerMessage { @@ -99,15 +101,10 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri }), }; let supplied_token = session_token.as_deref().or(access_token.as_deref()); - let permission = auth::resource_permission( - &state, - "pad", - &slug, - supplied_token, - ) - .await - .ok() - .flatten(); + let permission = auth::resource_permission(&state, "pad", &slug, supplied_token) + .await + .ok() + .flatten(); let anonymous_token_ok = permission.is_none() && crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token) .await @@ -145,7 +142,11 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri title: pad.title.clone(), content: pad.content.clone(), owner_map: pad.owner_map.clone(), - access_level: if write_allowed { "full".into() } else { "read_only".into() }, + access_level: if write_allowed { + "full".into() + } else { + "read_only".into() + }, }, ) .await