diff --git a/Cargo.lock b/Cargo.lock index 34b1f09..d1eca00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2433,7 +2433,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.17" +version = "0.0.19" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 21a76e6..27a606d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.17" +version = "0.0.19" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/api.rs b/src/api.rs index 7316e5f..4f7751b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -129,6 +129,7 @@ pub struct NoteInfo { allow_public_task_updates: bool, created_at: String, updated_at: String, + can_delete_files: bool, } pub async fn create_workspace( @@ -258,6 +259,7 @@ pub async fn create_note( 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) @@ -277,6 +279,27 @@ pub async fn note_info( 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 + }, })) } @@ -483,6 +506,7 @@ pub struct PadInfo { allow_public_task_updates: bool, created_at: String, updated_at: String, + can_delete_files: bool, } pub async fn create_pad( @@ -521,6 +545,7 @@ pub async fn create_pad( pub async fn pad_info( State(state): State, + headers: HeaderMap, Path(slug): Path, ) -> Result, ApiError> { let pad = db::find_pad(&state.db, &slug) @@ -533,6 +558,14 @@ pub async fn pad_info( allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, created_at: db::normalize_timestamp(&pad.created_at), updated_at: db::normalize_timestamp(&pad.updated_at), + can_delete_files: crate::auth::is_resource_owner( + &state, + "pad", + &slug, + bearer_token(&headers), + ) + .await + .unwrap_or(false), })) } @@ -817,6 +850,35 @@ pub async fn pad_files( Ok(Json(files)) } +pub async fn delete_pad_file( + State(state): State, + headers: HeaderMap, + Path((slug, file_id)): Path<(String, i64)>, + Json(payload): Json, +) -> Result, ApiError> { + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; + if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers)) + .await + .unwrap_or(false) + { + return Err(ApiError::forbidden("Only the note owner can delete files")); + } + let file = db::find_pad_file(&state.db, pad.id, file_id) + .await? + .ok_or_else(ApiError::not_found_file)?; + crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url) + .await + .map_err(|_| ApiError::internal("Failed to delete the file"))?; + db::delete_pad_file(&state.db, pad.id, file_id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + pub async fn upload_note_file( State(state): State, Path((workspace_slug, note_slug)): Path<(String, String)>, @@ -920,6 +982,11 @@ pub async fn delete_note( "This note is protected and cannot be deleted", )); } + for file in db::list_note_files(&state.db, note.id).await? { + crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) + .await + .map_err(|_| ApiError::internal("Failed to delete note files"))?; + } db::delete_note(&state.db, note.id).await?; Ok(Json(serde_json::json!({"ok": true}))) } @@ -956,6 +1023,7 @@ pub async fn note_files( pub async fn delete_note_file( State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>, Json(payload): Json, ) -> Result, ApiError> { @@ -967,32 +1035,35 @@ pub async fn delete_note_file( payload.access_token.as_deref(), ) .await?; - if workspace.password_hash.is_none() - || payload.password.as_deref().unwrap_or_default().is_empty() - { - return Err(ApiError::unauthorized()); + 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); + if !workspace_owner && !note_owner { + return Err(ApiError::forbidden( + "Only the note owner or workspace owner can delete files", + )); } let file = db::find_note_file(&state.db, note.id, file_id) .await? .ok_or_else(ApiError::not_found_file)?; - let relative = file - .url - .trim_start_matches('/') - .split('/') - .collect::>(); - if relative.len() == 3 && relative[0] == "f" { - let key = crate::storage::object_key( - "notes", - note.id, - relative[1], - &sanitize_filename(relative[2]), - ); - state - .storage - .delete(&key) - .await - .map_err(|_| ApiError::internal("Failed to delete the file"))?; - } + crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) + .await + .map_err(|_| ApiError::internal("Failed to delete the file"))?; db::delete_note_file(&state.db, note.id, file_id).await?; Ok(Json(serde_json::json!({"ok": true}))) } @@ -1066,6 +1137,15 @@ 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() @@ -1132,13 +1212,16 @@ pub async fn create_resource_access_token( let token = hex::encode(bytes); let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT)) - .bind(hash_access_token(&token)) - .bind(kind) - .bind(slug) - .bind(&expires_at) - .execute(state.db.pool()) - .await?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_ACCESS_TOKENS_INSERT, + )) + .bind(hash_access_token(&token)) + .bind(kind) + .bind(slug) + .bind(&expires_at) + .execute(state.db.pool()) + .await?; Ok(Json(AccessTokenResponse { access_token: token, expires_at, @@ -1161,13 +1244,16 @@ pub async fn verify_resource_access_token( { return Ok(true); } - 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?; + 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) } diff --git a/src/app.rs b/src/app.rs index 9b0bc60..a186484 100644 --- a/src/app.rs +++ b/src/app.rs @@ -99,6 +99,10 @@ pub fn router( "/api/pads/{slug}/files", post(api::upload_pad_file).put(api::pad_files), ) + .route( + "/api/pads/{slug}/files/{file_id}", + axum::routing::delete(api::delete_pad_file), + ) .route("/api/workspaces", post(api::create_workspace)) .route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) .route( diff --git a/src/auth.rs b/src/auth.rs index fb73275..6de1c33 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -240,6 +240,15 @@ pub async fn register( let token = random_confirmation_token(); if state.account_confirmation_required { let expires = (Utc::now() + Duration::hours(24)).to_rfc3339(); + let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, + )) + .bind(user.id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN, @@ -247,9 +256,10 @@ pub async fn register( .bind(hash_token(&token)) .bind(user.id) .bind(expires) - .execute(state.db.pool()) + .execute(&mut *tx) .await .map_err(AuthError::database)?; + tx.commit().await.map_err(AuthError::database)?; confirmation_token = Some(token.as_str()); } if let Err(error) = @@ -362,19 +372,36 @@ pub async fn confirm_account( )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)) + let consumed = sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_CONSUME_CONFIRMATION_TOKEN, + )) + .bind(&now) + .bind(&token_hash) + .bind(&now) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + if consumed.rows_affected() != 1 { + return Err(AuthError::bad_request( + "The confirmation link is invalid or has expired.", + )); + } + let confirmed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)) .bind(&now) .bind(&now) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; + if confirmed.rows_affected() != 1 { + return Err(AuthError::internal("The account could not be confirmed.")); + } sqlx::query(queries::get( state.db.kind(), - queries::AUTH_MARK_CONFIRMATION_TOKEN_USED, + queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, )) - .bind(&now) - .bind(&token_hash) + .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; @@ -484,8 +511,48 @@ pub async fn delete_resource( let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { - "workspace" => queries::USER_DELETE_WORKSPACE, - "pad" => queries::USER_DELETE_PAD, + "workspace" => { + if let Some(workspace) = crate::db::find_workspace(&state.db, req.slug.trim()) + .await + .map_err(AuthError::database)? + { + let notes = crate::db::list_notes(&state.db, workspace.id) + .await + .map_err(AuthError::database)?; + for note in notes { + let files = crate::db::list_note_files(&state.db, note.id) + .await + .map_err(AuthError::database)?; + for file in files { + crate::storage::delete_url_file( + &state.storage, + "notes", + note.id, + &file.url, + ) + .await + .map_err(|_| AuthError::internal("Failed to delete workspace files."))?; + } + } + } + queries::USER_DELETE_WORKSPACE + } + "pad" => { + if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim()) + .await + .map_err(AuthError::database)? + { + let files = crate::db::list_pad_files(&state.db, pad.id) + .await + .map_err(AuthError::database)?; + for file in files { + crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url) + .await + .map_err(|_| AuthError::internal("Failed to delete pad files."))?; + } + } + queries::USER_DELETE_PAD + } _ => return Err(AuthError::bad_request("Unknown resource type.")), }; sqlx::query(queries::get( @@ -602,18 +669,45 @@ pub async fn share_resource_users( continue; } - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) - .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER)) - .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_DELETE_USER, + )) + .bind(&req.kind) + .bind(req.slug.trim()) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::SHARE_INVITATION_DELETE_USER, + )) + .bind(&req.kind) + .bind(req.slug.trim()) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; if state.share_confirmation_required { let token = random_token(); let token_hash = hash_token(&token); let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_INSERT)) - .bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at) - .execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::SHARE_INVITATION_INSERT, + )) + .bind(&token_hash) + .bind(&req.kind) + .bind(req.slug.trim()) + .bind(user.id) + .bind(permission) + .bind(owner.id) + .bind(&expires_at) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; if let Err(error) = send_share_invitation( state.smtp.as_ref().unwrap(), &owner, @@ -635,8 +729,17 @@ pub async fn share_resource_users( return Err(error); } } else { - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT)) - .bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_INSERT, + )) + .bind(&req.kind) + .bind(req.slug.trim()) + .bind(user.id) + .bind(permission) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; } } if !missing.is_empty() { @@ -655,8 +758,13 @@ pub async fn accept_share_invitation( AxumPath(token): AxumPath, ) -> Result { let token_hash = hash_token(token.trim()); - let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN)) - .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as( + queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN), + ) + .bind(&token_hash) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| { AuthError::bad_request("The sharing invitation is invalid or has expired.") })?; @@ -670,10 +778,27 @@ pub async fn accept_share_invitation( )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) - .bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT)) - .bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_DELETE_USER, + )) + .bind(&kind) + .bind(&slug) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_INSERT, + )) + .bind(&kind) + .bind(&slug) + .bind(user_id) + .bind(&permission) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_ACCEPT, @@ -702,10 +827,26 @@ pub async fn remove_resource_user( ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let email = normalize(&req.email); if let Some(user) = find_user_by_email(&state, &email).await? { - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) - .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER)) - .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_DELETE_USER, + )) + .bind(&req.kind) + .bind(req.slug.trim()) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::SHARE_INVITATION_DELETE_USER, + )) + .bind(&req.kind) + .bind(req.slug.trim()) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; } Ok(Json(serde_json::json!({"ok":true}))) } @@ -723,12 +864,33 @@ pub async fn resource_sharing( .get("slug") .ok_or_else(|| AuthError::bad_request("Missing slug."))?; ensure_owner(&state, owner.id, kind, slug).await?; - let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_USERS)) - .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - let links: Vec<(String,String,Option,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS)) - .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_PENDING)) - .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + let users: Vec<(String, String, String)> = sqlx::query_as(queries::get( + state.db.kind(), + queries::RESOURCE_SHARING_USERS, + )) + .bind(kind) + .bind(slug) + .fetch_all(state.db.pool()) + .await + .map_err(AuthError::database)?; + let links: Vec<(String, String, Option, String)> = sqlx::query_as(queries::get( + state.db.kind(), + queries::RESOURCE_SHARING_LINKS, + )) + .bind(kind) + .bind(slug) + .fetch_all(state.db.pool()) + .await + .map_err(AuthError::database)?; + let pending: Vec<(String, String, String, String)> = sqlx::query_as(queries::get( + state.db.kind(), + queries::RESOURCE_SHARING_PENDING, + )) + .bind(kind) + .bind(slug) + .fetch_all(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json( serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }), )) @@ -746,7 +908,15 @@ pub async fn create_share_link( let token = random_token(); let token_hash = hash_token(&token); sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) - .bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + .bind(token_hash) + .bind(&req.kind) + .bind(req.slug.trim()) + .bind(permission) + .bind(&req.expires_at) + .bind(owner.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) } else { @@ -767,7 +937,14 @@ pub async fn update_share_link( let permission = validate_permission(&req.permission)?; validate_share_expiration(req.expires_at.as_deref())?; let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE)) - .bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + .bind(permission) + .bind(&req.expires_at) + .bind(req.token.trim()) + .bind(&req.kind) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; if result.rows_affected() == 0 { return Err(AuthError::bad_request( "Share link was not found or is already revoked.", @@ -786,7 +963,13 @@ pub async fn revoke_share_link( let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) - .bind(Utc::now().to_rfc3339()).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + .bind(Utc::now().to_rfc3339()) + .bind(req.token.trim()) + .bind(&req.kind) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } @@ -810,6 +993,21 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> { Ok(()) } +pub async fn is_resource_owner( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result { + let Some(token) = token.filter(|v| !v.is_empty()) else { + return Ok(false); + }; + let Some(user) = user_from_token(state, token).await? else { + return Ok(false); + }; + Ok(ensure_owner(state, user.id, kind, slug).await.is_ok()) +} + pub async fn resource_permission( state: &SharedState, kind: &str, @@ -824,13 +1022,30 @@ pub async fn resource_permission( if owns { return Ok(Some("rw".into())); } - let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER)) - .bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + let permission: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::RESOURCE_PERMISSION_BY_USER, + )) + .bind(kind) + .bind(slug) + .bind(user.id) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; return Ok(permission); } let now = Utc::now().to_rfc3339(); - let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::SHARE_LINK_PERMISSION)) - .bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + let permission: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::SHARE_LINK_PERMISSION, + )) + .bind(hash_token(token)) + .bind(kind) + .bind(slug) + .bind(now) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(permission) } @@ -866,12 +1081,13 @@ pub async fn request_reset( if let Some(user) = find_user_by_email(&state, &email).await? { let token = random_token(); let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339(); + let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER, )) .bind(user.id) - .execute(state.db.pool()) + .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( @@ -881,9 +1097,10 @@ pub async fn request_reset( .bind(hash_token(&token)) .bind(user.id) .bind(expires) - .execute(state.db.pool()) + .execute(&mut *tx) .await .map_err(AuthError::database)?; + tx.commit().await.map_err(AuthError::database)?; send_reset(smtp, &user, &token).await?; info!(user_id = user.id, "password reset e-mail sent"); } else { @@ -929,6 +1146,21 @@ pub async fn confirm_reset( } let password_hash = hash_password(&req.password)?; let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; + let consumed = sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_CONSUME_RESET_TOKEN, + )) + .bind(&now) + .bind(&token_hash) + .bind(&now) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + if consumed.rows_affected() != 1 { + return Err(AuthError::bad_request( + "The reset link is invalid or has expired.", + )); + } let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD)) .bind(password_hash) .bind(&now) @@ -941,10 +1173,9 @@ pub async fn confirm_reset( } sqlx::query(queries::get( state.db.kind(), - queries::AUTH_MARK_RESET_TOKEN_USED, + queries::AUTH_DELETE_RESET_TOKENS_BY_USER, )) - .bind(&now) - .bind(&token_hash) + .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; diff --git a/src/db.rs b/src/db.rs index 839c032..3be13ab 100644 --- a/src/db.rs +++ b/src/db.rs @@ -902,3 +902,36 @@ pub async fn delete_note_file( .await?; Ok(()) } + +pub async fn find_pad_file( + pool: &Database, + pad_id: i64, + file_id: i64, +) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNoteFile>(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) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await +} + +pub async fn delete_pad_file( + pool: &Database, + pad_id: i64, + file_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query(queries::get(pool.kind(), queries::Q047)) + .bind(file_id) + .bind(pad_id) + .execute(pool.pool()) + .await?; + Ok(()) +} diff --git a/src/queries.rs b/src/queries.rs index c44dc11..3e8c79a 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -28,8 +28,8 @@ pub const AUTH_FIND_CONFIRMATION_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"; pub const AUTH_CONFIRM_USER: &str = "UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"; -pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str = - "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?"; +pub const AUTH_CONSUME_CONFIRMATION_TOKEN: &str = + "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"; pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?"; pub const AUTH_INSERT_RESET_TOKEN: &str = @@ -38,8 +38,8 @@ pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"; pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"; -pub const AUTH_MARK_RESET_TOKEN_USED: &str = - "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?"; +pub const AUTH_CONSUME_RESET_TOKEN: &str = + "UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"; pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?"; pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?"; pub const AUTH_INSERT_SESSION: &str = @@ -148,6 +148,8 @@ pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?"; pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"; pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?"; +pub const Q046: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE id = ? AND pad_id = ?"; +pub const Q047: &str = "DELETE FROM pad_files WHERE id = ? AND pad_id = ?"; static POSTGRES_QUERIES: OnceLock>> = OnceLock::new(); diff --git a/src/state.rs b/src/state.rs index 191d503..84a0c2b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -36,6 +36,12 @@ pub struct PresenceUser { pub color: Option, } +#[derive(Debug, Clone)] +struct PresenceConnection { + identity: String, + user: PresenceUser, +} + #[derive(Debug, Clone)] pub enum RoomEvent { Document(NoteUpdate), @@ -58,7 +64,7 @@ pub struct AppState { pub anonymous_access_token_ttl_days: i64, pub user_session_ttl_days: i64, channels: RwLock>>, - presence: RwLock>>, + presence: RwLock>>, next_connection_id: AtomicU64, } @@ -127,15 +133,20 @@ impl AppState { key: &str, nickname: String, color: Option, + identity: Option, ) -> (u64, Vec) { let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed); + let identity = identity.unwrap_or_else(|| format!("connection:{id}")); let mut presence = self.presence.write().await; let room = presence.entry(key.to_owned()).or_default(); room.insert( id, - PresenceUser { - name: nickname, - color, + PresenceConnection { + identity, + user: PresenceUser { + name: nickname, + color, + }, }, ); (id, sorted_users(room)) @@ -148,8 +159,12 @@ impl AppState { ) -> Vec { let mut presence = self.presence.write().await; if let Some(room) = presence.get_mut(key) { - if let Some(user) = room.get_mut(&id) { - user.color = color; + if let Some(identity) = room.get(&id).map(|connection| connection.identity.clone()) { + for connection in room.values_mut() { + if connection.identity == identity { + connection.user.color = color.clone(); + } + } } sorted_users(room) } else { @@ -172,8 +187,14 @@ impl AppState { } } -fn sorted_users(room: &HashMap) -> Vec { - let mut users: Vec = room.values().cloned().collect(); +fn sorted_users(room: &HashMap) -> Vec { + let mut by_identity: HashMap<&str, PresenceUser> = HashMap::new(); + for connection in room.values() { + by_identity + .entry(&connection.identity) + .or_insert_with(|| connection.user.clone()); + } + let mut users: Vec = by_identity.into_values().collect(); users.sort_by_key(|value| value.name.to_lowercase()); users } diff --git a/src/storage.rs b/src/storage.rs index 549b5e5..3957194 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -216,6 +216,29 @@ impl From for StorageError { } } +pub async fn delete_url_file( + storage: &Storage, + kind: &str, + owner_id: i64, + url: &str, +) -> Result<(), StorageError> { + let parts: Vec<&str> = url.trim_start_matches('/').split('/').collect(); + if parts.len() != 3 || parts[0] != "f" { + return Ok(()); + } + let filename = sanitize_storage_filename(parts[2]); + storage + .delete(&object_key(kind, owner_id, parts[1], &filename)) + .await +} + +fn sanitize_storage_filename(value: &str) -> String { + value + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + .collect::() +} + pub fn object_key(kind: &str, owner_id: i64, token: &str, filename: &str) -> String { format!("{kind}/{owner_id}_{token}/{filename}") } diff --git a/src/websocket.rs b/src/websocket.rs index da8a1e8..f879524 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -132,6 +132,14 @@ async fn handle_socket( 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 => None, + }; let permission = auth::resource_permission( &state, "workspace", @@ -176,7 +184,7 @@ async fn handle_socket( 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) + .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); @@ -358,6 +366,14 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri 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 => None, + }; let permission = auth::resource_permission( &state, "pad", @@ -409,7 +425,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri 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) + .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); diff --git a/static/css/styles.css b/static/css/styles.css index a50ee27..dd4f811 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -2304,14 +2304,10 @@ dialog::backdrop { color: #b9c2cf; } -/* Task-list layout. */ +/* Task-list layout. Keep the checkbox in the same marker gutter as a bullet. */ .markdown-body .task-list-item { position: relative; - display: grid; - grid-template-columns: 1em minmax(0, 1fr); - grid-template-rows: auto auto; - column-gap: .45em; - align-items: start; + display: block; min-height: 1.32em; margin: 0; padding: 0; @@ -2320,12 +2316,11 @@ dialog::backdrop { } .markdown-body .task-checkbox { - grid-column: 1; - grid-row: 1; - align-self: start; + position: absolute; + top: 0; + left: -1.45em; appearance: none; box-sizing: border-box; - flex: 0 0 1em; width: 1em; min-width: 1em; max-width: 1em; @@ -2358,9 +2353,7 @@ dialog::backdrop { box-shadow: 0 0 0 2px rgba(124, 104, 238, .3); } -.markdown-body .task-list-item > .list-item-content { - grid-column: 2; - grid-row: 1; +.markdown-body .task-list-item>.list-item-content { display: block; min-width: 0; margin: 0; @@ -2368,10 +2361,8 @@ dialog::backdrop { line-height: inherit; } -.markdown-body .task-list-item > ul, -.markdown-body .task-list-item > ol { - grid-column: 2; - grid-row: 2; +.markdown-body .task-list-item>ul, +.markdown-body .task-list-item>ol { min-width: 0; } @@ -3764,6 +3755,7 @@ dialog::backdrop { border-radius: 0 0 14px 14px; } } + /* Markdown alerts. */ .markdown-body .markdown-alert { margin: 1em 0; @@ -3772,15 +3764,40 @@ dialog::backdrop { border-left-width: 4px; border-radius: 8px; } -.markdown-body .markdown-alert > :first-child { margin-top: 0; } -.markdown-body .markdown-alert > :last-child { margin-bottom: 0; } -.markdown-body .markdown-alert--success { border-color: #2f855a; background: rgba(47,133,90,.14); } -.markdown-body .markdown-alert--info { border-color: #3182ce; background: rgba(49,130,206,.14); } -.markdown-body .markdown-alert--warning { border-color: #d69e2e; background: rgba(214,158,46,.14); } -.markdown-body .markdown-alert--danger { border-color: #c53030; background: rgba(197,48,48,.14); } + +.markdown-body .markdown-alert> :first-child { + margin-top: 0; +} + +.markdown-body .markdown-alert> :last-child { + margin-bottom: 0; +} + +.markdown-body .markdown-alert--success { + border-color: #2f855a; + background: rgba(47, 133, 90, .14); +} + +.markdown-body .markdown-alert--info { + border-color: #3182ce; + background: rgba(49, 130, 206, .14); +} + +.markdown-body .markdown-alert--warning { + border-color: #d69e2e; + background: rgba(214, 158, 46, .14); +} + +.markdown-body .markdown-alert--danger { + border-color: #c53030; + background: rgba(197, 48, 48, .14); +} /* Fenced code line numbers for every language: ```lang=, ```lang=101, ```= or ```=101. */ -.markdown-body pre.code-with-lines code { counter-reset: none; } +.markdown-body pre.code-with-lines code { + counter-reset: none; +} + .markdown-body pre.code-with-lines .code-line { display: block; min-height: 1.35em; @@ -3788,6 +3805,7 @@ dialog::backdrop { position: relative; white-space: pre; } + .markdown-body pre.code-with-lines .code-line::before { content: attr(data-line); position: absolute; @@ -3808,13 +3826,29 @@ dialog::backdrop { border-radius: 8px; background: var(--surface-2); } -.markdown-body .markdown-toc ol { margin: 0; padding-left: 1.4em; } -.markdown-body .markdown-toc li { margin: .25em 0; } -.markdown-body .markdown-toc .toc-level-2 { margin-left: 1em; } -.markdown-body .markdown-toc .toc-level-3 { margin-left: 2em; } + +.markdown-body .markdown-toc ol { + margin: 0; + padding-left: 1.4em; +} + +.markdown-body .markdown-toc li { + margin: .25em 0; +} + +.markdown-body .markdown-toc .toc-level-2 { + margin-left: 1em; +} + +.markdown-body .markdown-toc .toc-level-3 { + margin-left: 2em; +} + .markdown-body .markdown-toc .toc-level-4, .markdown-body .markdown-toc .toc-level-5, -.markdown-body .markdown-toc .toc-level-6 { margin-left: 3em; } +.markdown-body .markdown-toc .toc-level-6 { + margin-left: 3em; +} /* Nested Markdown lists keep markers and source-line numbers in separate gutters. */ .markdown-body ul, @@ -3832,7 +3866,6 @@ dialog::backdrop { word-break: break-word; } -.markdown-body .contains-task-items > .task-list-item { +.markdown-body .contains-task-items>.task-list-item { list-style: none; -} - +} \ No newline at end of file diff --git a/static/js/markdown.js b/static/js/markdown.js index 8f4ed75..c4ba8b9 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -28,7 +28,7 @@ function inline(value) { html = html.replace(/`([^`]+)`/g, (_, code) => stash(`${code}`)); html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - return stash(`${alt}`); + return stash(`${alt}`); }); html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; diff --git a/static/js/note.js b/static/js/note.js index 3a4d6fb..0e0c242 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -79,6 +79,12 @@ function markdownFromPreview(node){ if(tag==="sub")return `~${body}~`; if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`; if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`; + if(tag==="img"){ + const src=current.getAttribute("src")||""; + const alt=current.getAttribute("alt")||""; + const title=current.getAttribute("title"); + return `![${alt}](${src}${title?` "${title.replace(/"/g,""")}"`:""})`; + } if(tag==="br")return " "; return body; }; @@ -137,19 +143,20 @@ function applyUi({write=false,replace=false}={}){editorWorkspace.className=`work function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,accessToken,nickname,color:currentUserColor()||null,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onPresence:updatePresence,onLatency:updateLatency,onChat:appendChatMessage,onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} -function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;} +function formatBytes(bytes){const value=Math.max(0,Number(bytes)||0),units=["B","KB","MB","GB","TB"];let size=value,index=0;while(size>=1024&&index=10?1:2)} ${units[index]}`;} async function loadFiles({open=false}={}){ try{ const files=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})}); - document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`; + const totalSize=files.reduce((sum,file)=>sum+(Number(file.size_bytes)||0),0); + document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"} · ${formatBytes(totalSize)}`; const list=document.querySelector("#files-list"); - list.innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${file.is_attached?"in note":"removed from content"}
${info?.protected&&accessToken?``:""}
`).join(""):'

No files uploaded.

'; + list.innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${file.is_attached?"in note":"removed from content"}
${info?.can_delete_files?``:""}
`).join(""):'

No files uploaded.

'; if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal(); }catch(error){toast(error.message);} } bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();updateCurrentUser();if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}}); identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});}); -async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} +async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{}});document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}updateCurrentUser();document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!accessToken)passwordDialog.showModal();else{loadFiles();connect();}}catch(e){document.body.innerHTML=`

Note not found

${escapeHtml(e.message)}

`;}} document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});previewLineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:preview-line-numbers",previewLineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>{applyFormat(editor,b.dataset.format);b.closest("details")?.removeAttribute("open");}));bindFormatShortcuts(editor);document.querySelector("#shortcuts-button").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").showModal());document.querySelector("#close-shortcuts").addEventListener("click",()=>document.querySelector("#shortcuts-dialog").close());preview.addEventListener("change",event=>{const checkbox=event.target.closest(".task-checkbox");if(!checkbox)return;const lineIndex=Number(checkbox.dataset.sourceLine)-1;const lines=editor.value.split("\n");if(lineIndex<0||lineIndex>=lines.length)return;lines[lineIndex]=lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/,`$1${checkbox.checked?"x":" "}$2`);editor.value=lines.join("\n");editor.dispatchEvent(new Event("input",{bubbles:true}));});preview.addEventListener("keydown",event=>{const target=event.target.closest(".preview-editable");if(!target)return;if(event.key==="Enter"){event.preventDefault();target.blur();return;}if(event.key==="ArrowUp"||event.key==="ArrowDown"){if(movePreviewCaret(target,event.key==="ArrowUp"?-1:1))event.preventDefault();}});preview.addEventListener("blur",event=>{const target=event.target.closest(".preview-editable");if(!target)return;const lineIndex=Number(target.dataset.sourceLine)-1;if(lineIndex<0)return;const lines=editor.value.split("\n");const value=markdownFromPreview(target);let next;if(target.dataset.tableCell!==undefined)next=replaceTableCell(lines[lineIndex],Number(target.dataset.tableCell),value);else{const prefix=target.dataset.sourcePrefix||"",suffix=target.dataset.sourceSuffix||"";next=prefix+value+suffix;}if(lines[lineIndex]===next)return;lines[lineIndex]=next;editor.value=lines.join("\n");editor.setSelectionRange(editor.value.length,editor.value.length);editor.dispatchEvent(new Event("input",{bubbles:true}));},{capture:true}); @@ -167,6 +174,14 @@ userColorPicker.addEventListener("input",()=>{ socket?.setColor(userColorPicker.value); if(socket)socket.update(editor.value,JSON.stringify(owners)); }); +window.addEventListener("storage",event=>{ + if(event.key!==storedColorKey(nickname))return; + const replacement=currentOwner(); + owners=owners.map(owner=>ownerName(owner)===nickname?replacement:owner); + updateCurrentUser();render(); + socket?.setColor(currentUserColor()||null); + if(socket)socket.update(editor.value,JSON.stringify(owners)); +}); editor.addEventListener("keydown",continueIndentation);editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;renderGutter();});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); document.querySelector("#password-form").addEventListener("submit",async e=>{e.preventDefault();try{password=document.querySelector("#open-password").value;const result=await api("/api/access-token",{method:"POST",body:JSON.stringify({kind:"workspace",slug:workspaceSlug,password})});accessToken=result.access_token;setAccessToken("workspace",workspaceSlug,accessToken);password="";document.querySelector("#open-password").value="";document.querySelector("#password-error").textContent="";loadFiles();connect();}catch(error){document.querySelector("#password-error").textContent=error.message;}}); const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

Loading…

';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({access_token:accessToken||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
${escapeHtml(author)}

${snippet}

`;}).join(""):'

No history yet.

';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({access_token:accessToken||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

${escapeHtml(e.message)}

`;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); @@ -189,7 +204,7 @@ document.querySelector("#files-list").addEventListener("click",async event=>{ const deleteButton=event.target.closest("[data-delete-file]"); if(deleteButton){ if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return; - try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return; + try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{},body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return; } }); document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({access_token:accessToken||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}}); diff --git a/static/js/pad.js b/static/js/pad.js index c426da6..73e8cba 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -75,6 +75,12 @@ function markdownFromPreview(node){ if(tag==="sub")return `~${body}~`; if(tag==="sup"&&!current.classList.contains("footnote-ref"))return `^${body}^`; if(tag==="a")return `[${body}](${current.getAttribute("href")||"#"})`; + if(tag==="img"){ + const src=current.getAttribute("src")||""; + const alt=current.getAttribute("alt")||""; + const title=current.getAttribute("title"); + return `![${alt}](${src}${title?` "${title.replace(/"/g,""")}"`:""})`; + } if(tag==="br")return " "; return body; }; @@ -122,6 +128,8 @@ function continueIndentation(event){ editor.dispatchEvent(new Event("input",{bubbles:true})); } +function formatBytes(bytes){const value=Math.max(0,Number(bytes)||0),units=["B","KB","MB","GB","TB"];let size=value,index=0;while(size>=1024&&index=10?1:2)} ${units[index]}`;} + function replaceTableCell(line,index,value){ const leading=line.trimStart().startsWith("|"),trailing=line.trimEnd().endsWith("|"); let body=line.trim();if(leading)body=body.slice(1);if(trailing)body=body.slice(0,-1); @@ -135,8 +143,9 @@ function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null async function loadFiles({open=false}={}){ try{ const files=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"PUT",body:JSON.stringify({access_token:accessToken||null})}); - document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"}`; - document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${escapeHtml(file.mime_type)} · ${Math.max(1,Math.round(file.size_bytes/1024))} KB · ${formatDate(file.created_at)} · ${file.is_attached?"in note":"removed from content"}
`).join(""):'

No files uploaded.

'; + const totalSize=files.reduce((sum,file)=>sum+(Number(file.size_bytes)||0),0); + document.querySelector("#footer-files").textContent=`${files.length} ${files.length===1?"file":"files"} · ${formatBytes(totalSize)}`; + document.querySelector("#files-list").innerHTML=files.length?files.map(file=>`
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · ${file.is_attached?"in note":"removed from content"}
${info?.can_delete_files?``:""}
`).join(""):'

No files uploaded.

'; if(open)document.querySelector("#files-dialog").showModal(); }catch(error){if(open)toast(error.message);} } @@ -181,5 +190,10 @@ document.querySelector("#files-list").addEventListener("click",async event=>{ } const copyButton=event.target.closest("[data-copy-generated]"); if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;} + const deleteButton=event.target.closest("[data-delete-file]"); + if(deleteButton){ + if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return; + try{await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",headers:getAuthToken()?{Authorization:`Bearer ${getAuthToken()}`}:{},body:JSON.stringify({access_token:accessToken||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return; + } }); initialize(); diff --git a/static/note.html b/static/note.html index d0af508..e18fe9a 100644 --- a/static/note.html +++ b/static/note.html @@ -55,8 +55,8 @@ code + class="line-toggle"> Editor + lines
+ listAlt+1…4Headings H1–H4 + @@ -191,7 +193,7 @@

Protected workspace

Back + class="dialog-link" href="/">Cancel
diff --git a/static/pad.html b/static/pad.html index 9ff767f..efb66b5 100644 --- a/static/pad.html +++ b/static/pad.html @@ -54,8 +54,8 @@ codeBack + class="dialog-link" href="/">Cancel
diff --git a/static/workspace.html b/static/workspace.html index 369c7d5..6c67ee2 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -48,7 +48,7 @@

New note