From 176e7e45542a06325e00ef4228b254efb3d7f382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 28 Jul 2026 10:53:36 +0200 Subject: [PATCH] fixes and new options --- Cargo.lock | 2 +- Cargo.toml | 2 +- migrations/mysql/0017_user_active.sql | 1 + migrations/postgres/0017_user_active.sql | 1 + migrations/sqlite/0017_user_active.sql | 1 + src/auth/ldap.rs | 18 +++++++-- src/auth/local.rs | 4 ++ src/auth/mod.rs | 47 ++++++++++++++++++++++-- src/queries/mod.rs | 6 +++ src/queries/mysql.rs | 11 ++++-- src/queries/postgres.rs | 11 ++++-- src/queries/sqlite.rs | 11 ++++-- static/js/note-files.js | 15 +++++++- 13 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 migrations/mysql/0017_user_active.sql create mode 100644 migrations/postgres/0017_user_active.sql create mode 100644 migrations/sqlite/0017_user_active.sql diff --git a/Cargo.lock b/Cargo.lock index 87c1443..b33ed15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.14" +version = "0.1.15" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index a57e4ef..57b463d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.14" +version = "0.1.15" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrations/mysql/0017_user_active.sql b/migrations/mysql/0017_user_active.sql new file mode 100644 index 0000000..58ef283 --- /dev/null +++ b/migrations/mysql/0017_user_active.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/migrations/postgres/0017_user_active.sql b/migrations/postgres/0017_user_active.sql new file mode 100644 index 0000000..58ef283 --- /dev/null +++ b/migrations/postgres/0017_user_active.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/migrations/sqlite/0017_user_active.sql b/migrations/sqlite/0017_user_active.sql new file mode 100644 index 0000000..95f60c5 --- /dev/null +++ b/migrations/sqlite/0017_user_active.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1; diff --git a/src/auth/ldap.rs b/src/auth/ldap.rs index 19cea69..fbb9150 100644 --- a/src/auth/ldap.rs +++ b/src/auth/ldap.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use tracing::{debug, info}; +use tracing::{debug, info, warn}; use crate::{queries, state::SharedState}; @@ -172,12 +172,20 @@ pub async fn authenticate( } fn directory_nickname(display_name: &str, email: &str, username: &str) -> String { - let words: Vec<&str> = display_name.split_whitespace().filter(|v| !v.is_empty()).collect(); + let words: Vec<&str> = display_name + .split_whitespace() + .filter(|v| !v.is_empty()) + .collect(); let candidate = if words.len() >= 2 { let first = words[0].chars().next().unwrap_or('u'); format!("{}.{}", first, words[words.len() - 1]) } else { - email.split('@').next().filter(|v| !v.is_empty()).unwrap_or(username).to_owned() + email + .split('@') + .next() + .filter(|v| !v.is_empty()) + .unwrap_or(username) + .to_owned() }; candidate.to_lowercase() } @@ -241,6 +249,10 @@ pub(super) async fn login( })? .ok_or_else(|| AuthError::unauthorized("Invalid organization login or password."))?; let user = provision_ldap_user(state, identity).await?; + if user.is_active == 0 { + warn!(user_id = user.id, "LDAP login rejected: inactive account"); + return Err(AuthError::forbidden("This account is inactive.")); + } let session = create_session(state, &user).await?; info!(user_id = user.id, nickname = %user.nickname, "LDAP login successful"); Ok(session) diff --git a/src/auth/local.rs b/src/auth/local.rs index eeafa5e..5520429 100644 --- a/src/auth/local.rs +++ b/src/auth/local.rs @@ -17,6 +17,10 @@ pub(super) async fn login( let user = find_user_by_email(state, &email) .await? .ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?; + if user.is_active == 0 { + warn!(user_id = user.id, "login rejected: inactive account"); + return Err(AuthError::forbidden("This account is inactive.")); + } if !verify_password(&user.password_hash, password) { warn!(user_id = user.id, "login rejected: invalid password"); return Err(AuthError::unauthorized( diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 24d9324..a9e37aa 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -38,6 +38,7 @@ pub struct User { pub email: String, pub password_hash: String, pub confirmed_at: Option, + pub is_active: i64, } #[derive(Deserialize)] @@ -123,6 +124,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for User { email: crate::row_decode::text(row, "email")?, password_hash: crate::row_decode::text(row, "password_hash")?, confirmed_at: crate::row_decode::optional_text(row, "confirmed_at")?, + is_active: row.try_get("is_active")?, }) } } @@ -827,12 +829,51 @@ pub async fn confirm_account_action( .map_err(AuthError::database)?; "E-mail address changed." } else if action == "delete" { - sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)) - .bind(user_id) + let original_nickname: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_NICKNAME_BY_ID, + )) + .bind(user_id) + .fetch_optional(&mut *tx) + .await + .map_err(AuthError::database)?; + let deleted_nickname = format!("Deleted user #{user_id}"); + if let Some(original_nickname) = original_nickname { + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_ANONYMIZE_NOTE_CREATORS, + )) + .bind(&deleted_nickname) + .bind(original_nickname) .execute(&mut *tx) .await .map_err(AuthError::database)?; - "Account deleted." + } + let deleted_email = format!("deleted-user-{user_id}@invalid.local"); + let deleted_password = hash_password(&random_token())?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_SESSIONS_BY_USER, + )) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_ANONYMIZE_USER, + )) + .bind(&deleted_nickname) + .bind(normalize(&deleted_nickname)) + .bind(&deleted_email) + .bind(normalize(&deleted_email)) + .bind(deleted_password) + .bind(now.to_rfc3339()) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + "Account deleted. Content has been preserved under an anonymized owner." } else { return Err(AuthError::bad_request("Unknown account action.")); }; diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 6f144e0..9c09ed3 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -33,6 +33,9 @@ pub enum Query { AUTH_DIRECTORY_PROFILE_BY_USER, AUTH_USER_BY_EXTERNAL_ID, AUTH_DELETE_USER, + AUTH_ANONYMIZE_USER, + AUTH_ANONYMIZE_NOTE_CREATORS, + AUTH_NICKNAME_BY_ID, AUTH_SESSION_EXPIRES_AT, AUTH_DELETE_SESSION_BY_TOKEN, AUTH_REFRESH_SESSION, @@ -164,6 +167,9 @@ pub const AUTH_UPDATE_DIRECTORY_USER: Query = Query::AUTH_UPDATE_DIRECTORY_USER; pub const AUTH_DIRECTORY_PROFILE_BY_USER: Query = Query::AUTH_DIRECTORY_PROFILE_BY_USER; pub const AUTH_USER_BY_EXTERNAL_ID: Query = Query::AUTH_USER_BY_EXTERNAL_ID; pub const AUTH_DELETE_USER: Query = Query::AUTH_DELETE_USER; +pub const AUTH_ANONYMIZE_USER: Query = Query::AUTH_ANONYMIZE_USER; +pub const AUTH_ANONYMIZE_NOTE_CREATORS: Query = Query::AUTH_ANONYMIZE_NOTE_CREATORS; +pub const AUTH_NICKNAME_BY_ID: Query = Query::AUTH_NICKNAME_BY_ID; pub const AUTH_SESSION_EXPIRES_AT: Query = Query::AUTH_SESSION_EXPIRES_AT; pub const AUTH_DELETE_SESSION_BY_TOKEN: Query = Query::AUTH_DELETE_SESSION_BY_TOKEN; pub const AUTH_REFRESH_SESSION: Query = Query::AUTH_REFRESH_SESSION; diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index 2d47d89..2840f90 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -26,8 +26,11 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_INSERT_DIRECTORY_USER => r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn, directory_display_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, Query::AUTH_UPDATE_DIRECTORY_USER => r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, directory_display_name = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_DIRECTORY_PROFILE_BY_USER => r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#, - Query::AUTH_USER_BY_EXTERNAL_ID => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?"#, + Query::AUTH_USER_BY_EXTERNAL_ID => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE auth_provider = ? AND external_id = ?"#, Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, + Query::AUTH_ANONYMIZE_USER => r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, updated_at = ? WHERE id = ?"#, + Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#, + Query::AUTH_ANONYMIZE_NOTE_CREATORS => r#"UPDATE notes SET created_by = ? WHERE created_by = ?"#, Query::AUTH_SESSION_EXPIRES_AT => r#"SELECT expires_at FROM user_sessions WHERE token = ?"#, Query::AUTH_DELETE_SESSION_BY_TOKEN => r#"DELETE FROM user_sessions WHERE token = ?"#, Query::AUTH_REFRESH_SESSION => r#"UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?"#, @@ -42,10 +45,10 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_UPDATE_PASSWORD => r#"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_CONSUME_RESET_TOKEN => r#"UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"#, Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#, - Query::AUTH_USER_BY_SESSION => r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS 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 > ?"#, + Query::AUTH_USER_BY_SESSION => r#"SELECT u.id, u.nickname, u.email, CAST(u.password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#, Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#, - Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at FROM users WHERE nickname_key = ?"#, - Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at FROM users WHERE email_key = ?"#, + Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE nickname_key = ?"#, + Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE email_key = ?"#, Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#, Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#, Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#, diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 2a974cb..fe6e6c3 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -26,8 +26,11 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_INSERT_DIRECTORY_USER => r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn, directory_display_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#, Query::AUTH_UPDATE_DIRECTORY_USER => r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, auth_provider = $5, external_id = $6, external_dn = $7, directory_display_name = $8, updated_at = $9 WHERE id = $10"#, Query::AUTH_DIRECTORY_PROFILE_BY_USER => r#"SELECT auth_provider, directory_display_name FROM users WHERE id = $1"#, - Query::AUTH_USER_BY_EXTERNAL_ID => r#"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = $1 AND external_id = $2"#, + Query::AUTH_USER_BY_EXTERNAL_ID => r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE auth_provider = $1 AND external_id = $2"#, Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = $1"#, + Query::AUTH_ANONYMIZE_USER => r#"UPDATE users SET nickname = $1, nickname_key = $2, email = $3, email_key = $4, password_hash = $5, is_active = FALSE, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, updated_at = $6 WHERE id = $7"#, + Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = $1"#, + Query::AUTH_ANONYMIZE_NOTE_CREATORS => r#"UPDATE notes SET created_by = $1 WHERE created_by = $2"#, Query::AUTH_SESSION_EXPIRES_AT => r#"SELECT expires_at FROM user_sessions WHERE token = $1"#, Query::AUTH_DELETE_SESSION_BY_TOKEN => r#"DELETE FROM user_sessions WHERE token = $1"#, Query::AUTH_REFRESH_SESSION => r#"UPDATE user_sessions SET expires_at = $1 WHERE token = $2 AND expires_at > $3"#, @@ -42,10 +45,10 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_UPDATE_PASSWORD => r#"UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3"#, Query::AUTH_CONSUME_RESET_TOKEN => r#"UPDATE password_reset_tokens SET used_at = $1 WHERE token = $2 AND used_at IS NULL AND expires_at > $3"#, Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = $1"#, - Query::AUTH_USER_BY_SESSION => r#"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 = $1 AND s.expires_at > $2"#, + Query::AUTH_USER_BY_SESSION => r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, CAST(CASE WHEN u.is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1 AND s.expires_at > $2 AND u.is_active = TRUE"#, Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#, - Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = $1"#, - Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = $1"#, + Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE nickname_key = $1"#, + Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE email_key = $1"#, Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#, Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT $1, id FROM pads WHERE slug = $2"#, Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(1 AS BIGINT) AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 UNION SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = $2 ORDER BY updated_at DESC"#, diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index 1c0b78a..8401939 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -26,8 +26,11 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_INSERT_DIRECTORY_USER => r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at, auth_provider, external_id, external_dn, directory_display_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, Query::AUTH_UPDATE_DIRECTORY_USER => r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, auth_provider = ?, external_id = ?, external_dn = ?, directory_display_name = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_DIRECTORY_PROFILE_BY_USER => r#"SELECT auth_provider, directory_display_name FROM users WHERE id = ?"#, - Query::AUTH_USER_BY_EXTERNAL_ID => r#"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE auth_provider = ? AND external_id = ?"#, + Query::AUTH_USER_BY_EXTERNAL_ID => r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE auth_provider = ? AND external_id = ?"#, Query::AUTH_DELETE_USER => r#"DELETE FROM users WHERE id = ?"#, + Query::AUTH_ANONYMIZE_USER => r#"UPDATE users SET nickname = ?, nickname_key = ?, email = ?, email_key = ?, password_hash = ?, is_active = 0, auth_provider = 'deleted', external_id = NULL, external_dn = NULL, directory_display_name = NULL, updated_at = ? WHERE id = ?"#, + Query::AUTH_NICKNAME_BY_ID => r#"SELECT nickname FROM users WHERE id = ?"#, + Query::AUTH_ANONYMIZE_NOTE_CREATORS => r#"UPDATE notes SET created_by = ? WHERE created_by = ?"#, Query::AUTH_SESSION_EXPIRES_AT => r#"SELECT expires_at FROM user_sessions WHERE token = ?"#, Query::AUTH_DELETE_SESSION_BY_TOKEN => r#"DELETE FROM user_sessions WHERE token = ?"#, Query::AUTH_REFRESH_SESSION => r#"UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?"#, @@ -42,10 +45,10 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_UPDATE_PASSWORD => r#"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"#, Query::AUTH_CONSUME_RESET_TOKEN => r#"UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"#, Query::AUTH_DELETE_SESSIONS_BY_USER => r#"DELETE FROM user_sessions WHERE user_id = ?"#, - Query::AUTH_USER_BY_SESSION => r#"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 > ?"#, + Query::AUTH_USER_BY_SESSION => r#"SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at, u.is_active FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ? AND u.is_active = 1"#, Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#, - Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = ?"#, - Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?"#, + Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE nickname_key = ?"#, + Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE email_key = ?"#, Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#, Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#, Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#, diff --git a/static/js/note-files.js b/static/js/note-files.js index c5a89c2..9a411c3 100644 --- a/static/js/note-files.js +++ b/static/js/note-files.js @@ -41,6 +41,7 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
+ ${canDelete() ? `` : ""}
@@ -78,13 +79,25 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to footer.addEventListener("click", () => loadFiles({ open: true })); document.querySelector("#close-files").addEventListener("click", () => dialog.close()); list.addEventListener("click", async event => { + const addButton = event.target.closest("[data-add-file-to-note]"); + if (addButton) { + const relative = safeAppUrl(addButton.dataset.url); + const text = addButton.dataset.mime?.startsWith("image/") ? `![${addButton.dataset.name}](${relative})` : `[${addButton.dataset.name}](${relative})`; + editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + toast("Added to note"); + return; + } const showButton = event.target.closest("[data-show-file-code]"); if (showButton) { const panel = showButton.closest(".file-row").querySelector(".file-code"); const output = panel.querySelector("textarea"); const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href; let text = absolute; - if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${absolute})` : `[${showButton.dataset.name}](${absolute})`; + if (showButton.dataset.showFileCode === "markdown") { + const relative = safeAppUrl(showButton.dataset.url); + text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${relative})` : `[${showButton.dataset.name}](${relative})`; + } output.value = text; panel.hidden = false; output.focus(); output.select(); return; } const copyButton = event.target.closest("[data-copy-generated]");