From ef8dfc67c133c28959e8b5c5070ec9d4aa5030e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 29 Jul 2026 14:03:46 +0200 Subject: [PATCH] license and some functions --- Cargo.lock | 2 +- Cargo.toml | 2 +- LICENSE.md | 11 ++ README.md | 2 +- src/api/access_tokens.rs | 10 +- src/api/error.rs | 9 + src/api/files.rs | 12 +- src/api/mod.rs | 114 +++++++++-- src/api/pads_public.rs | 126 +++++++++--- src/app/mod.rs | 22 ++- src/app/pages.rs | 42 +++- src/assets.rs | 9 + src/auth/ldap.rs | 13 +- src/auth/local.rs | 9 + src/auth/mod.rs | 82 +++++--- src/config/mod.rs | 80 +++++--- src/config/smtp.rs | 14 +- src/config/values.rs | 180 +++++++++++++---- src/database.rs | 9 + src/db/files.rs | 10 +- src/db/mod.rs | 10 +- src/db/public_pages.rs | 183 ++++++++++++++--- src/main.rs | 57 ++++-- src/queries/mod.rs | 32 ++- src/queries/mysql.rs | 389 ++++++++++++++++++++++++++++--------- src/queries/postgres.rs | 369 ++++++++++++++++++++++++++--------- src/queries/sqlite.rs | 377 ++++++++++++++++++++++++++--------- src/row_decode.rs | 11 +- src/state.rs | 9 + src/storage.rs | 9 + static/css/styles.css | 274 +++++++++++++++++++++++--- static/home.html | 5 +- static/js/api.js | 194 ++++++++++++++++-- static/js/auth-ui.js | 9 + static/js/authorship.js | 9 + static/js/clipboard.js | 9 + static/js/editor-format.js | 9 + static/js/emoji-data.js | 9 + static/js/emoji-picker.js | 9 + static/js/home.js | 40 +++- static/js/image-upload.js | 9 + static/js/logger.js | 9 + static/js/markdown.js | 9 + static/js/modal.js | 9 + static/js/note-api.js | 11 +- static/js/note-editor.js | 19 +- static/js/note-files.js | 64 ++++-- static/js/note.js | 9 + static/js/pad.js | 9 + static/js/public.js | 9 + static/js/security.js | 9 + static/js/session.js | 9 + static/js/socket.js | 9 + static/js/toast.js | 160 ++++++++++++++- static/js/url-state.js | 9 + static/js/workspace.js | 9 + 56 files changed, 2586 insertions(+), 547 deletions(-) create mode 100644 LICENSE.md diff --git a/Cargo.lock b/Cargo.lock index 67e50f5..1dfe47b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.25" +version = "0.1.27" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 88e35c5..653bb08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.25" +version = "0.1.27" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dd0fcbe --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,11 @@ +Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License. + +COMMERCIAL LICENSE NOTICE: +Commercial use in proprietary applications (including by [NAZWA FIRMY]) +is not permitted under GPLv3 without an explicit Commercial License Agreement. +To purchase a commercial license, contact: linuxiarz>linuxiarz.pl + diff --git a/README.md b/README.md index e075491..c7325e1 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ LDAP_OPERATION_TIMEOUT_SECONDS=10 For Active Directory, the default external identifier is `objectGUID`. Set `LDAP_LINK_EXISTING_BY_EMAIL=true` only during an intentional migration of existing local or legacy LDAP accounts; otherwise an e-mail collision is rejected. -### Test LDAP on 10.87.2.6 +### Test LDAP on 10.0.0.1 (example) ```bash cd docker/ldap diff --git a/src/api/access_tokens.rs b/src/api/access_tokens.rs index 400bd3a..e1a62b2 100644 --- a/src/api/access_tokens.rs +++ b/src/api/access_tokens.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::*; #[derive(Debug, Deserialize)] @@ -92,4 +101,3 @@ pub async fn verify_resource_access_token( pub(super) fn hash_access_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) } - diff --git a/src/api/error.rs b/src/api/error.rs index 99531ed..0ad3f12 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use axum::{ Json, http::StatusCode, diff --git a/src/api/files.rs b/src/api/files.rs index a72f303..f4562cf 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -1,5 +1,14 @@ -use super::*; +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::pads_public::authorized_pad; +use super::*; pub async fn upload_pad_file( State(state): State, @@ -485,4 +494,3 @@ fn sanitize_filename(value: &str) -> String { clean.chars().take(160).collect() } } - diff --git a/src/api/mod.rs b/src/api/mod.rs index 7af3f90..3ac645b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + mod access_tokens; mod error; mod files; @@ -178,7 +187,11 @@ pub struct EditorSettingsRequest { colors_enabled: bool, } -async fn editor_settings(state: &SharedState, kind: &str, slug: &str) -> Result<(String, bool), ApiError> { +async fn editor_settings( + state: &SharedState, + kind: &str, + slug: &str, +) -> Result<(String, bool), ApiError> { let row: Option<(String, i64)> = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_EDITOR_SETTINGS_SELECT, @@ -187,27 +200,70 @@ async fn editor_settings(state: &SharedState, kind: &str, slug: &str) -> Result< .bind(slug) .fetch_optional(state.db.pool()) .await?; - Ok(row.map(|(mode, colors)| (if mode == "full" { "full".into() } else { "simple".into() }, colors != 0)) + Ok(row + .map(|(mode, colors)| { + ( + if mode == "full" { + "full".into() + } else { + "simple".into() + }, + colors != 0, + ) + }) .unwrap_or_else(|| ("simple".into(), true))) } async fn save_editor_settings( - state: &SharedState, headers: &HeaderMap, permission_kind: &str, permission_slug: &str, - settings_kind: &str, settings_slug: &str, payload: EditorSettingsRequest, + state: &SharedState, + headers: &HeaderMap, + permission_kind: &str, + permission_slug: &str, + settings_kind: &str, + settings_slug: &str, + payload: EditorSettingsRequest, ) -> Result, ApiError> { - let permission = crate::auth::resource_permission(state, permission_kind, permission_slug, bearer_token(headers)) - .await.map_err(|e| ApiError::forbidden(&e.message))?; + let permission = crate::auth::resource_permission( + state, + permission_kind, + permission_slug, + bearer_token(headers), + ) + .await + .map_err(|e| ApiError::forbidden(&e.message))?; if permission.as_deref() != Some("rw") { - return Err(ApiError::forbidden("Read and write access is required to save editor settings")); + return Err(ApiError::forbidden( + "Read and write access is required to save editor settings", + )); } - let mode = match payload.authorship_mode.as_str() { "simple" => "simple", "full" | "advanced" => "full", _ => return Err(ApiError::bad_request("Invalid authorship mode")) }; + let mode = match payload.authorship_mode.as_str() { + "simple" => "simple", + "full" | "advanced" => "full", + _ => return Err(ApiError::bad_request("Invalid authorship mode")), + }; let mut tx = state.db.pool().begin().await?; - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_EDITOR_SETTINGS_DELETE)) - .bind(settings_kind).bind(settings_slug).execute(&mut *tx).await?; - sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_EDITOR_SETTINGS_INSERT)) - .bind(settings_kind).bind(settings_slug).bind(mode).bind(payload.colors_enabled).execute(&mut *tx).await?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_EDITOR_SETTINGS_DELETE, + )) + .bind(settings_kind) + .bind(settings_slug) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_EDITOR_SETTINGS_INSERT, + )) + .bind(settings_kind) + .bind(settings_slug) + .bind(mode) + .bind(payload.colors_enabled) + .execute(&mut *tx) + .await?; tx.commit().await?; - Ok(Json(serde_json::json!({"authorship_mode": mode, "colors_enabled": payload.colors_enabled}))) + Ok(Json( + serde_json::json!({"authorship_mode": mode, "colors_enabled": payload.colors_enabled}), + )) } pub async fn create_workspace( @@ -485,9 +541,22 @@ pub async fn note_info( let color_slug = format!("{}/{}", workspace_slug, note_slug); let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; let (authorship_mode, colors_enabled) = editor_settings(&state, "note", &color_slug).await?; - let can_save_editor_settings = crate::auth::resource_permission(&state, "workspace", &workspace_slug, bearer_token(&headers)).await.ok().flatten().as_deref() == Some("rw"); + let can_save_editor_settings = crate::auth::resource_permission( + &state, + "workspace", + &workspace_slug, + bearer_token(&headers), + ) + .await + .ok() + .flatten() + .as_deref() + == Some("rw"); - if workspace.is_private == 0 && !db::note_public_page_disabled(&state.db, note.id).await? && !db::note_public_page_enabled(&state.db, note.id).await? { + if workspace.is_private == 0 + && !db::note_public_page_disabled(&state.db, note.id).await? + && !db::note_public_page_enabled(&state.db, note.id).await? + { db::publish_note(&state.db, note.id).await?; } Ok(Json(NoteInfo { @@ -533,12 +602,22 @@ pub async fn note_info( } pub async fn set_note_editor_settings( - State(state): State, headers: HeaderMap, + State(state): State, + headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { let settings_slug = format!("{}/{}", workspace_slug, note_slug); - save_editor_settings(&state, &headers, "workspace", &workspace_slug, "note", &settings_slug, payload).await + save_editor_settings( + &state, + &headers, + "workspace", + &workspace_slug, + "note", + &settings_slug, + payload, + ) + .await } pub async fn history( @@ -840,4 +919,3 @@ async fn unique_note_slug( } Err(ApiError::internal("Failed to create a unique address")) } - diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs index eb3cd96..3c28f2f 100644 --- a/src/api/pads_public.rs +++ b/src/api/pads_public.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::*; #[derive(Debug, Deserialize)] @@ -84,8 +93,17 @@ pub async fn pad_info( .await?; let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; let (authorship_mode, colors_enabled) = editor_settings(&state, "pad", &slug).await?; - let can_save_editor_settings = crate::auth::resource_permission(&state, "pad", &slug, bearer_token(&headers)).await.ok().flatten().as_deref() == Some("rw"); - if pad.is_private == 0 && !db::pad_public_page_disabled(&state.db, pad.id).await? && !db::pad_public_page_enabled(&state.db, pad.id).await? { + let can_save_editor_settings = + crate::auth::resource_permission(&state, "pad", &slug, bearer_token(&headers)) + .await + .ok() + .flatten() + .as_deref() + == Some("rw"); + if pad.is_private == 0 + && !db::pad_public_page_disabled(&state.db, pad.id).await? + && !db::pad_public_page_enabled(&state.db, pad.id).await? + { db::publish_pad(&state.db, pad.id).await?; } Ok(Json(PadInfo { @@ -115,7 +133,9 @@ pub async fn pad_info( } pub async fn set_pad_editor_settings( - State(state): State, headers: HeaderMap, Path(slug): Path, + State(state): State, + headers: HeaderMap, + Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { save_editor_settings(&state, &headers, "pad", &slug, "pad", &slug, payload).await @@ -207,14 +227,18 @@ pub async fn publish_pad_page( if !enabled { db::unpublish_pad(&state.db, pad.id).await?; db::set_pad_public_page_disabled(&state.db, pad.id, true).await?; - return Ok(Json(PublishResponse { url: None, enabled: false })); + return Ok(Json(PublishResponse { + url: None, + enabled: false, + })); } db::set_pad_public_page_disabled(&state.db, pad.id, false).await?; let token = db::publish_pad(&state.db, pad.id).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; db::set_pad_public_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { - url: Some(format!("/s/{token}")), enabled: true, + url: Some(format!("/s/{token}")), + enabled: true, })) } @@ -252,50 +276,99 @@ pub async fn publish_note_page( if !enabled { db::unpublish_note(&state.db, note.id).await?; db::set_note_public_page_disabled(&state.db, note.id, true).await?; - return Ok(Json(PublishResponse { url: None, enabled: false })); + return Ok(Json(PublishResponse { + url: None, + enabled: false, + })); } db::set_note_public_page_disabled(&state.db, note.id, false).await?; let token = db::publish_note(&state.db, note.id).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; db::set_note_public_page_unprotected(&state.db, note.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { - url: Some(format!("/s/{token}")), enabled: true, + url: Some(format!("/s/{token}")), + enabled: true, })) } - fn page_password(headers: &HeaderMap) -> Option<&str> { - headers.get("x-rustpad-page-password").and_then(|value| value.to_str().ok()).map(str::trim).filter(|value| !value.is_empty()) + headers + .get("x-rustpad-page-password") + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) } -async fn ensure_public_page_access(state: &SharedState, headers: &HeaderMap, page: &db::PublishedPage) -> Result<(), ApiError> { +async fn ensure_public_page_access( + state: &SharedState, + headers: &HeaderMap, + page: &db::PublishedPage, +) -> Result<(), ApiError> { let password = page_password(headers); let bearer = bearer_token(headers); if let Some(pad_id) = page.pad_id { - if db::pad_public_page_unprotected(&state.db, pad_id).await? { return Ok(()); } + if db::pad_public_page_unprotected(&state.db, pad_id).await? { + return Ok(()); + } let sql = match state.db.kind() { crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1", _ => "SELECT slug FROM pads WHERE id = ?", }; - let slug: Option = sqlx::query_scalar(sql).bind(pad_id).fetch_optional(state.db.pool()).await?; - let Some(slug) = slug else { return Err(ApiError::not_found_note()); }; - let pad = db::find_pad(&state.db, &slug).await?.ok_or_else(ApiError::not_found_note)?; - if db::verify_pad_password(&pad, password) { return Ok(()); } - if verify_resource_access_token(state, "pad", &slug, bearer).await? { return Ok(()); } - return if pad.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) }; + let slug: Option = sqlx::query_scalar(sql) + .bind(pad_id) + .fetch_optional(state.db.pool()) + .await?; + let Some(slug) = slug else { + return Err(ApiError::not_found_note()); + }; + let pad = db::find_pad(&state.db, &slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + if db::verify_pad_password(&pad, password) { + return Ok(()); + } + if verify_resource_access_token(state, "pad", &slug, bearer).await? { + return Ok(()); + } + return if pad.password_hash.is_some() { + Err(ApiError::forbidden("Password required or incorrect.")) + } else { + Err(ApiError::forbidden("This published page is protected.")) + }; } if let Some(note_id) = page.note_id { - if db::note_public_page_unprotected(&state.db, note_id).await? { return Ok(()); } + if db::note_public_page_unprotected(&state.db, note_id).await? { + return Ok(()); + } let sql = match state.db.kind() { - crate::database::DatabaseKind::Postgres => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1", - _ => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = ?", + crate::database::DatabaseKind::Postgres => { + "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1" + } + _ => { + "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = ?" + } + }; + let slug: Option = sqlx::query_scalar(sql) + .bind(note_id) + .fetch_optional(state.db.pool()) + .await?; + let Some(slug) = slug else { + return Err(ApiError::not_found_note()); + }; + let workspace = db::find_workspace(&state.db, &slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + if db::verify_workspace_password(&workspace, password) { + return Ok(()); + } + if verify_resource_access_token(state, "workspace", &slug, bearer).await? { + return Ok(()); + } + return if workspace.password_hash.is_some() { + Err(ApiError::forbidden("Password required or incorrect.")) + } else { + Err(ApiError::forbidden("This published page is protected.")) }; - let slug: Option = sqlx::query_scalar(sql).bind(note_id).fetch_optional(state.db.pool()).await?; - let Some(slug) = slug else { return Err(ApiError::not_found_note()); }; - let workspace = db::find_workspace(&state.db, &slug).await?.ok_or_else(ApiError::not_found_workspace)?; - if db::verify_workspace_password(&workspace, password) { return Ok(()); } - if verify_resource_access_token(state, "workspace", &slug, bearer).await? { return Ok(()); } - return if workspace.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) }; } Err(ApiError::not_found_note()) } @@ -461,4 +534,3 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result Respons response } - diff --git a/src/app/pages.rs b/src/app/pages.rs index f812f3b..f0d9cab 100644 --- a/src/app/pages.rs +++ b/src/app/pages.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use axum::{ extract::{Path, State}, http::{HeaderValue, StatusCode, header}, @@ -66,7 +75,10 @@ pub(super) async fn pad(State(state): State, Path(slug): Path, Path(token): Path) -> Response { +pub(super) async fn public_page( + State(state): State, + Path(token): Path, +) -> Response { match db::find_published_page(&state.db, &token).await { Ok(Some(_)) => assets::render_html( include_str!("../../static/public.html"), @@ -99,8 +111,14 @@ pub(super) async fn workspace( ) -> Response { match db::find_workspace(&state.db, &workspace_slug).await { Ok(Some(workspace)) => { - let html = include_str!("../../static/workspace.html") - .replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title })); + let html = include_str!("../../static/workspace.html").replace( + "__WORKSPACE_TITLE__", + &escape_html(if workspace.is_private != 0 { + "Workspace" + } else { + &workspace.title + }), + ); assets::render_html( &html, &state.asset_version, @@ -153,8 +171,22 @@ pub(super) async fn note( match db::find_note(&state.db, workspace.id, ¬e_slug).await { Ok(Some(note)) => { let html = include_str!("../../static/note.html") - .replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { ¬e.title })) - .replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title })) + .replace( + "__NOTE_TITLE__", + &escape_html(if workspace.is_private != 0 { + "Note" + } else { + ¬e.title + }), + ) + .replace( + "__WORKSPACE_TITLE__", + &escape_html(if workspace.is_private != 0 { + "Workspace" + } else { + &workspace.title + }), + ) .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); assets::render_html( &html, diff --git a/src/assets.rs b/src/assets.rs index fea10f7..924177f 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use axum::{ http::{HeaderValue, header}, response::{Html, IntoResponse, Response}, diff --git a/src/auth/ldap.rs b/src/auth/ldap.rs index 9477da7..75db200 100644 --- a/src/auth/ldap.rs +++ b/src/auth/ldap.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use chrono::Utc; use tracing::{debug, info, warn}; @@ -316,7 +325,9 @@ async fn sync_directory_user( identity: &LdapIdentity, email: &str, ) -> Result { - let nickname = available_directory_nickname(state, &identity.nickname, Some(user.id)).await?; + // Nickname is user-managed after provisioning. Directory synchronization must not + // overwrite a custom value on every LDAP/AD login. + let nickname = user.nickname.clone(); sqlx::query(queries::get( state.db.kind(), queries::AUTH_UPDATE_DIRECTORY_USER, diff --git a/src/auth/local.rs b/src/auth/local.rs index 5520429..64b3d05 100644 --- a/src/auth/local.rs +++ b/src/auth/local.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use tracing::{debug, info, warn}; use crate::state::SharedState; diff --git a/src/auth/mod.rs b/src/auth/mod.rs index c064569..7bd4a99 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + pub(crate) mod ldap; mod local; @@ -632,9 +641,16 @@ fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Opti .filter(|word| !word.is_empty()) .collect(); if words.len() >= 2 { - let first = words.first().copied().unwrap_or_default(); + let first = words + .first() + .and_then(|word| word.chars().next()) + .unwrap_or('u'); let last = words.last().copied().unwrap_or_default(); - let candidate = format!("{}.{}", first, last).to_lowercase(); + let candidate: String = format!("{first}.{last}") + .to_lowercase() + .chars() + .take(MAX_NICKNAME) + .collect(); if let Ok(value) = validate_nickname(&candidate) { return Some(value); } @@ -644,7 +660,14 @@ fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Opti .split('@') .next() .filter(|value| !value.trim().is_empty()) - .and_then(|value| validate_nickname(&value.to_lowercase()).ok()) + .map(|value| { + value + .to_lowercase() + .chars() + .take(MAX_NICKNAME) + .collect::() + }) + .and_then(|value| validate_nickname(&value).ok()) } pub async fn me( @@ -842,11 +865,14 @@ pub async fn confirm_account_action( ) -> Result, AuthError> { let now = Utc::now(); let hash = hash_token(req.token.trim()); - let row = sqlx::query(queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN)) - .bind(&hash) - .fetch_optional(state.db.pool()) - .await - .map_err(AuthError::database)?; + let row = sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_ACCOUNT_ACTION_BY_TOKEN, + )) + .bind(&hash) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; let row = row.ok_or_else(|| { AuthError::bad_request("The confirmation link is invalid or has expired.") })?; @@ -890,14 +916,12 @@ pub async fn confirm_account_action( .map_err(AuthError::database)?; "E-mail address changed." } else if action == "delete" { - 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 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( @@ -920,20 +944,17 @@ pub async fn confirm_account_action( .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)?; + 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.")); @@ -1945,7 +1966,6 @@ async fn find_user_by_external_id( .map_err(AuthError::database) } - async fn find_user_by_share_identifier( state: &SharedState, identifier: &str, diff --git a/src/config/mod.rs b/src/config/mod.rs index 688ee11..6a24168 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,8 +1,17 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + mod smtp; mod values; -use std::{net::IpAddr, path::Path}; use smtp::load_smtp; +use std::{net::IpAddr, path::Path}; use values::ConfigValues; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -14,7 +23,12 @@ pub enum AuthorizationType { impl AuthorizationType { fn from_values(values: &ConfigValues) -> Result> { - match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() { + match values + .get("AUTHORIZATION_TYPE", "local") + .trim() + .to_ascii_lowercase() + .as_str() + { "local" => Ok(Self::Local), "ldap" => Ok(Self::Ldap), "ad" => Ok(Self::Ad), @@ -63,13 +77,22 @@ impl Config { let port = values.get("APP_PORT", "3000").parse()?; let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?; let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?; - let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; + let anonymous_access_token_ttl_days = + values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?; - let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?; + let unconfirmed_account_ttl_days = + values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?; let files_dir = values.get("FILES_DIR", "data/files"); - let storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() { - "local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() }, + let storage = match values + .get("STORAGE_DRIVER", "local") + .trim() + .to_ascii_lowercase() + .as_str() + { + "local" => crate::storage::StorageConfig::Local { + root: files_dir.clone().into(), + }, "s3" => crate::storage::StorageConfig::S3 { endpoint: values.optional("S3_ENDPOINT"), region: values.get("S3_REGION", "us-east-1"), @@ -92,7 +115,10 @@ impl Config { let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str()); let (default_filter, default_username_attribute) = match authorization_type { AuthorizationType::Ldap => ("(uid={username})", "uid"), - AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"), + AuthorizationType::Ad => ( + "(|(sAMAccountName={username})(userPrincipalName={username}))", + "sAMAccountName", + ), AuthorizationType::Local => unreachable!(), }; Some(crate::auth::ldap::LdapConfig { @@ -102,21 +128,28 @@ impl Config { bind_password: values.get("LDAP_BIND_PASSWORD", ""), base_dn: values.required("LDAP_BASE_DN", &context)?, user_filter: values.get("LDAP_USER_FILTER", default_filter), - username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute), + username_attribute: values + .get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute), email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"), - display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"), - external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type { - AuthorizationType::Ldap => "entryUUID", - AuthorizationType::Ad => "objectGUID", - AuthorizationType::Local => unreachable!(), - }), + display_name_attribute: values + .get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"), + external_id_attribute: values.get( + "LDAP_EXTERNAL_ID_ATTRIBUTE", + match authorization_type { + AuthorizationType::Ldap => "entryUUID", + AuthorizationType::Ad => "objectGUID", + AuthorizationType::Local => unreachable!(), + }, + ), organization: values.get("LDAP_ORGANIZATION", "organization"), provider: authorization_type.as_str().to_owned(), email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?, link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?, tls_verify: values.bool("LDAP_TLS_VERIFY", true)?, - connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?, - operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?, + connect_timeout_seconds: values + .positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?, + operation_timeout_seconds: values + .positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?, }) } }; @@ -131,10 +164,14 @@ impl Config { static_dir: values.get("STATIC_DIR", "static"), files_dir, storage, - upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?, + upload_max_size_bytes: upload_max_size_mb + .checked_mul(1024 * 1024) + .ok_or("UPLOAD_MAX_SIZE_MB is too large")?, asset_version: env!("CARGO_PKG_VERSION").to_owned(), - asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, - file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, + asset_cache_max_age_seconds: values + .nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, + file_cache_max_age_seconds: values + .nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, smtp, registration_enabled: values.bool("REGISTRATION_ENABLED", false)?, account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, @@ -165,12 +202,11 @@ impl Config { return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); } if let Some(smtp) = &self.smtp { - if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) { + if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) + { return Err("PUBLIC_URL must start with http:// or https://".into()); } } Ok(()) } } - - diff --git a/src/config/smtp.rs b/src/config/smtp.rs index 39ebb4c..8c0d17a 100644 --- a/src/config/smtp.rs +++ b/src/config/smtp.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use lettre::message::Mailbox; use crate::state::{SmtpConfig, SmtpSecurity}; @@ -84,7 +93,10 @@ mod tests { #[test] fn accepts_supported_security_modes() { assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None); - assert_eq!(parse_smtp_security("starttls").unwrap(), SmtpSecurity::StartTls); + assert_eq!( + parse_smtp_security("starttls").unwrap(), + SmtpSecurity::StartTls + ); assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls); assert!(parse_smtp_security("auto").is_err()); } diff --git a/src/config/values.rs b/src/config/values.rs index 83e8e43..8c82dd4 100644 --- a/src/config/values.rs +++ b/src/config/values.rs @@ -1,19 +1,62 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use std::{collections::HashMap, env, path::Path}; const KNOWN_CONFIG_KEYS: &[&str] = &[ - "APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", - "STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", - "ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS", - "REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED", - "FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS", - "UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE", - "S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", - "S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_SECURITY", "SMTP_USERNAME", "SMTP_PASSWORD", - "SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN", - "LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE", - "LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE", - "LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL", - "LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS", + "APP_HOST", + "APP_PORT", + "DATABASE_URL", + "DATABASE_MAX_CONNECTIONS", + "STATIC_DIR", + "FILES_DIR", + "STORAGE_DRIVER", + "UPLOAD_MAX_SIZE_MB", + "ASSET_CACHE_MAX_AGE_SECONDS", + "FILE_CACHE_MAX_AGE_SECONDS", + "REGISTRATION_ENABLED", + "ACCOUNT_CONFIRMATION_REQUIRED", + "SHARE_CONFIRMATION_REQUIRED", + "FRONTEND_LOG_LEVEL", + "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", + "USER_SESSION_TTL_DAYS", + "UNCONFIRMED_ACCOUNT_TTL_DAYS", + "AUTHORIZATION_TYPE", + "S3_ENDPOINT", + "S3_REGION", + "S3_BUCKET", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_FORCE_PATH_STYLE", + "SMTP_HOST", + "SMTP_PORT", + "SMTP_SECURITY", + "SMTP_USERNAME", + "SMTP_PASSWORD", + "SMTP_FROM", + "PUBLIC_URL", + "LDAP_URL", + "LDAP_STARTTLS", + "LDAP_BIND_DN", + "LDAP_BIND_PASSWORD", + "LDAP_BASE_DN", + "LDAP_USER_FILTER", + "LDAP_USERNAME_ATTRIBUTE", + "LDAP_EMAIL_ATTRIBUTE", + "LDAP_DISPLAY_NAME_ATTRIBUTE", + "LDAP_EXTERNAL_ID_ATTRIBUTE", + "LDAP_ORGANIZATION", + "LDAP_EMAIL_REQUIRED", + "LDAP_LINK_EXISTING_BY_EMAIL", + "LDAP_TLS_VERIFY", + "LDAP_CONNECT_TIMEOUT_SECONDS", + "LDAP_OPERATION_TIMEOUT_SECONDS", ]; #[derive(Default)] @@ -23,7 +66,9 @@ pub(super) struct ConfigValues { impl ConfigValues { pub(super) fn load(path: Option<&Path>) -> Result> { - let Some(path) = path else { return Ok(Self::default()); }; + let Some(path) = path else { + return Ok(Self::default()); + }; let content = std::fs::read_to_string(path) .map_err(|error| format!("cannot read config file {}: {error}", path.display()))?; let file = parse_yaml_config(&content) @@ -37,26 +82,50 @@ impl ConfigValues { } pub(super) fn get(&self, name: &str, default: &str) -> String { - env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned()) + env::var(name) + .ok() + .or_else(|| self.file.get(name).cloned()) + .unwrap_or_else(|| default.to_owned()) } pub(super) fn optional(&self, name: &str) -> Option { - env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty()) + env::var(name) + .ok() + .or_else(|| self.file.get(name).cloned()) + .filter(|value| !value.trim().is_empty()) } - pub(super) fn required(&self, name: &str, context: &str) -> Result> { - self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into()) + pub(super) fn required( + &self, + name: &str, + context: &str, + ) -> Result> { + self.optional(name) + .ok_or_else(|| format!("{name} is required when {context}").into()) } - pub(super) fn bool(&self, name: &str, default: bool) -> Result> { - match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() { + pub(super) fn bool( + &self, + name: &str, + default: bool, + ) -> Result> { + match self + .get(name, if default { "true" } else { "false" }) + .trim() + .to_ascii_lowercase() + .as_str() + { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), _ => Err(format!("{name} must be true or false").into()), } } - pub(super) fn log_level(&self, name: &str, default: &str) -> Result> { + pub(super) fn log_level( + &self, + name: &str, + default: &str, + ) -> Result> { let value = self.get(name, default).trim().to_ascii_lowercase(); match value.as_str() { "off" | "error" | "warn" | "info" | "debug" => Ok(value), @@ -64,20 +133,44 @@ impl ConfigValues { } } - pub(super) fn positive_i64(&self, name: &str, default: i64) -> Result> { - let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?; - if value <= 0 { return Err(format!("{name} must be greater than 0").into()); } + pub(super) fn positive_i64( + &self, + name: &str, + default: i64, + ) -> Result> { + let value: i64 = self + .get(name, &default.to_string()) + .parse() + .map_err(|_| format!("{name} must be an integer"))?; + if value <= 0 { + return Err(format!("{name} must be greater than 0").into()); + } Ok(value) } - pub(super) fn positive_u64(&self, name: &str, default: u64) -> Result> { - let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?; - if value == 0 { return Err(format!("{name} must be greater than 0").into()); } + pub(super) fn positive_u64( + &self, + name: &str, + default: u64, + ) -> Result> { + let value: u64 = self + .get(name, &default.to_string()) + .parse() + .map_err(|_| format!("{name} must be a non-negative integer"))?; + if value == 0 { + return Err(format!("{name} must be greater than 0").into()); + } Ok(value) } - pub(super) fn nonnegative_u64(&self, name: &str, default: u64) -> Result> { - self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into()) + pub(super) fn nonnegative_u64( + &self, + name: &str, + default: u64, + ) -> Result> { + self.get(name, &default.to_string()) + .parse() + .map_err(|_| format!("{name} must be a non-negative integer").into()) } } @@ -88,7 +181,9 @@ fn parse_yaml_config(content: &str) -> Result, String> { for (index, original) in content.lines().enumerate() { let line_number = index + 1; if original.contains('\t') { - return Err(format!("line {line_number}: tabs are not allowed for indentation")); + return Err(format!( + "line {line_number}: tabs are not allowed for indentation" + )); } let without_comment = strip_yaml_comment(original); if without_comment.trim().is_empty() || without_comment.trim() == "---" { @@ -100,10 +195,17 @@ fn parse_yaml_config(content: &str) -> Result, String> { .split_once(':') .ok_or_else(|| format!("line {line_number}: expected key: value"))?; let key = raw_key.trim(); - if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + if key.is_empty() + || !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { return Err(format!("line {line_number}: invalid key {key:?}")); } - while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) { + while sections + .last() + .is_some_and(|(section_indent, _)| *section_indent >= indent) + { sections.pop(); } let normalized = key.to_ascii_uppercase().replace('-', "_"); @@ -112,14 +214,19 @@ fn parse_yaml_config(content: &str) -> Result, String> { sections.push((indent, normalized)); continue; } - if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) { - return Err(format!("line {line_number}: only scalar values and nested mappings are supported")); + if matches!( + value.chars().next(), + Some('[' | '{' | '|' | '>' | '&' | '*' | '!') + ) { + return Err(format!( + "line {line_number}: only scalar values and nested mappings are supported" + )); } let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect(); path.push(&normalized); let full_key = path.join("_"); - let parsed_value = parse_yaml_scalar(value) - .map_err(|error| format!("line {line_number}: {error}"))?; + let parsed_value = + parse_yaml_scalar(value).map_err(|error| format!("line {line_number}: {error}"))?; if output.insert(full_key.clone(), parsed_value).is_some() { return Err(format!("line {line_number}: duplicate key {full_key}")); } @@ -166,4 +273,3 @@ fn parse_yaml_scalar(value: &str) -> Result { } Ok(value.to_owned()) } - diff --git a/src/database.rs b/src/database.rs index f0a003b..eb2f663 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use crate::queries; use sqlx::{AnyPool, any::AnyPoolOptions}; use tracing::{debug, info}; diff --git a/src/db/files.rs b/src/db/files.rs index 17519a7..c195a3f 100644 --- a/src/db/files.rs +++ b/src/db/files.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::*; #[derive(Debug, Clone, Copy)] @@ -262,7 +271,6 @@ pub async fn delete_pad_file( Ok(()) } - impl<'r> sqlx::FromRow<'r, AnyRow> for NoteFile { fn from_row(row: &'r AnyRow) -> Result { let attached: i64 = row.try_get("is_attached")?; diff --git a/src/db/mod.rs b/src/db/mod.rs index c412b32..5b7edab 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use crate::{ database::{Database, DatabaseKind}, queries, @@ -466,7 +475,6 @@ pub async fn list_pad_revisions( .await } - impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { diff --git a/src/db/public_pages.rs b/src/db/public_pages.rs index 70ae6a7..38161de 100644 --- a/src/db/public_pages.rs +++ b/src/db/public_pages.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::*; #[derive(Debug, Clone, Serialize)] @@ -95,23 +104,39 @@ pub async fn publish_note(pool: &Database, note_id: i64) -> Result Result { - Ok(sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) - .bind(pad_id).fetch_optional(pool.pool()).await?.is_some()) + Ok( + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .is_some(), + ) } pub async fn note_public_page_enabled(pool: &Database, note_id: i64) -> Result { - Ok(sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019)) - .bind(note_id).fetch_optional(pool.pool()).await?.is_some()) + Ok( + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019)) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .is_some(), + ) } pub async fn unpublish_pad(pool: &Database, pad_id: i64) -> Result<(), sqlx::Error> { - let sql = match pool.kind() { DatabaseKind::Postgres => "DELETE FROM published_pages WHERE pad_id = $1", _ => "DELETE FROM published_pages WHERE pad_id = ?" }; + let sql = match pool.kind() { + DatabaseKind::Postgres => "DELETE FROM published_pages WHERE pad_id = $1", + _ => "DELETE FROM published_pages WHERE pad_id = ?", + }; sqlx::query(sql).bind(pad_id).execute(pool.pool()).await?; Ok(()) } pub async fn unpublish_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> { - let sql = match pool.kind() { DatabaseKind::Postgres => "DELETE FROM published_pages WHERE note_id = $1", _ => "DELETE FROM published_pages WHERE note_id = ?" }; + let sql = match pool.kind() { + DatabaseKind::Postgres => "DELETE FROM published_pages WHERE note_id = $1", + _ => "DELETE FROM published_pages WHERE note_id = ?", + }; sqlx::query(sql).bind(note_id).execute(pool.pool()).await?; Ok(()) } @@ -207,33 +232,80 @@ pub async fn set_note_public_task_updates( Ok(()) } - -pub async fn pad_public_page_unprotected(pool: &Database, pad_id: i64) -> Result { +pub async fn pad_public_page_unprotected( + pool: &Database, + pad_id: i64, +) -> Result { if pool.kind() == DatabaseKind::Postgres { - return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); + return Ok( + sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q048)) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(false), + ); } - Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0) + Ok( + sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q048)) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(0) + != 0, + ) } -pub async fn note_public_page_unprotected(pool: &Database, note_id: i64) -> Result { +pub async fn note_public_page_unprotected( + pool: &Database, + note_id: i64, +) -> Result { if pool.kind() == DatabaseKind::Postgres { - return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); + return Ok( + sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q049)) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(false), + ); } - Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0) + Ok( + sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q049)) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(0) + != 0, + ) } -pub async fn set_pad_public_page_unprotected(pool: &Database, pad_id: i64, value: bool) -> Result<(), sqlx::Error> { +pub async fn set_pad_public_page_unprotected( + pool: &Database, + pad_id: i64, + value: bool, +) -> Result<(), sqlx::Error> { publish_pad(pool, pad_id).await?; let mut query = sqlx::query(queries::get(pool.kind(), queries::Q050)); - query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) }; + query = if pool.kind() == DatabaseKind::Postgres { + query.bind(value) + } else { + query.bind(if value { 1i64 } else { 0i64 }) + }; query.bind(pad_id).execute(pool.pool()).await?; Ok(()) } -pub async fn set_note_public_page_unprotected(pool: &Database, note_id: i64, value: bool) -> Result<(), sqlx::Error> { +pub async fn set_note_public_page_unprotected( + pool: &Database, + note_id: i64, + value: bool, +) -> Result<(), sqlx::Error> { publish_note(pool, note_id).await?; let mut query = sqlx::query(queries::get(pool.kind(), queries::Q051)); - query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) }; + query = if pool.kind() == DatabaseKind::Postgres { + query.bind(value) + } else { + query.bind(if value { 1i64 } else { 0i64 }) + }; query.bind(note_id).execute(pool.pool()).await?; Ok(()) } @@ -337,7 +409,6 @@ 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 { @@ -366,27 +437,75 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow { } pub async fn pad_public_page_disabled(pool: &Database, pad_id: i64) -> Result { - let sql = match pool.kind() { DatabaseKind::Postgres => "SELECT public_page_disabled FROM pads WHERE id = $1", _ => "SELECT public_page_disabled FROM pads WHERE id = ?" }; - if pool.kind() == DatabaseKind::Postgres { return Ok(sqlx::query_scalar::<_, bool>(sql).bind(pad_id).fetch_one(pool.pool()).await?); } - Ok(sqlx::query_scalar::<_, i64>(sql).bind(pad_id).fetch_one(pool.pool()).await? != 0) + let sql = match pool.kind() { + DatabaseKind::Postgres => "SELECT public_page_disabled FROM pads WHERE id = $1", + _ => "SELECT public_page_disabled FROM pads WHERE id = ?", + }; + if pool.kind() == DatabaseKind::Postgres { + return Ok(sqlx::query_scalar::<_, bool>(sql) + .bind(pad_id) + .fetch_one(pool.pool()) + .await?); + } + Ok(sqlx::query_scalar::<_, i64>(sql) + .bind(pad_id) + .fetch_one(pool.pool()) + .await? + != 0) } pub async fn note_public_page_disabled(pool: &Database, note_id: i64) -> Result { - let sql = match pool.kind() { DatabaseKind::Postgres => "SELECT public_page_disabled FROM notes WHERE id = $1", _ => "SELECT public_page_disabled FROM notes WHERE id = ?" }; - if pool.kind() == DatabaseKind::Postgres { return Ok(sqlx::query_scalar::<_, bool>(sql).bind(note_id).fetch_one(pool.pool()).await?); } - Ok(sqlx::query_scalar::<_, i64>(sql).bind(note_id).fetch_one(pool.pool()).await? != 0) + let sql = match pool.kind() { + DatabaseKind::Postgres => "SELECT public_page_disabled FROM notes WHERE id = $1", + _ => "SELECT public_page_disabled FROM notes WHERE id = ?", + }; + if pool.kind() == DatabaseKind::Postgres { + return Ok(sqlx::query_scalar::<_, bool>(sql) + .bind(note_id) + .fetch_one(pool.pool()) + .await?); + } + Ok(sqlx::query_scalar::<_, i64>(sql) + .bind(note_id) + .fetch_one(pool.pool()) + .await? + != 0) } -pub async fn set_pad_public_page_disabled(pool: &Database, pad_id: i64, disabled: bool) -> Result<(), sqlx::Error> { - let sql = match pool.kind() { DatabaseKind::Postgres => "UPDATE pads SET public_page_disabled = $1 WHERE id = $2", _ => "UPDATE pads SET public_page_disabled = ? WHERE id = ?" }; +pub async fn set_pad_public_page_disabled( + pool: &Database, + pad_id: i64, + disabled: bool, +) -> Result<(), sqlx::Error> { + let sql = match pool.kind() { + DatabaseKind::Postgres => "UPDATE pads SET public_page_disabled = $1 WHERE id = $2", + _ => "UPDATE pads SET public_page_disabled = ? WHERE id = ?", + }; let mut query = sqlx::query(sql); - query = if pool.kind() == DatabaseKind::Postgres { query.bind(disabled) } else { query.bind(if disabled { 1i64 } else { 0i64 }) }; - query.bind(pad_id).execute(pool.pool()).await?; Ok(()) + query = if pool.kind() == DatabaseKind::Postgres { + query.bind(disabled) + } else { + query.bind(if disabled { 1i64 } else { 0i64 }) + }; + query.bind(pad_id).execute(pool.pool()).await?; + Ok(()) } -pub async fn set_note_public_page_disabled(pool: &Database, note_id: i64, disabled: bool) -> Result<(), sqlx::Error> { - let sql = match pool.kind() { DatabaseKind::Postgres => "UPDATE notes SET public_page_disabled = $1 WHERE id = $2", _ => "UPDATE notes SET public_page_disabled = ? WHERE id = ?" }; +pub async fn set_note_public_page_disabled( + pool: &Database, + note_id: i64, + disabled: bool, +) -> Result<(), sqlx::Error> { + let sql = match pool.kind() { + DatabaseKind::Postgres => "UPDATE notes SET public_page_disabled = $1 WHERE id = $2", + _ => "UPDATE notes SET public_page_disabled = ? WHERE id = ?", + }; let mut query = sqlx::query(sql); - query = if pool.kind() == DatabaseKind::Postgres { query.bind(disabled) } else { query.bind(if disabled { 1i64 } else { 0i64 }) }; - query.bind(note_id).execute(pool.pool()).await?; Ok(()) + query = if pool.kind() == DatabaseKind::Postgres { + query.bind(disabled) + } else { + query.bind(if disabled { 1i64 } else { 0i64 }) + }; + query.bind(note_id).execute(pool.pool()).await?; + Ok(()) } diff --git a/src/main.rs b/src/main.rs index ce6f1e5..5ba66d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,12 @@ -mod row_decode; +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + mod api; mod app; mod assets; @@ -7,6 +15,7 @@ mod config; mod database; mod db; mod queries; +mod row_decode; mod state; mod storage; mod websocket; @@ -20,7 +29,6 @@ use tokio::net::TcpListener; use tracing::{info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - #[tokio::main] async fn main() -> Result<(), Box> { rustls::crypto::ring::default_provider() @@ -33,10 +41,18 @@ async fn main() -> Result<(), Box> { let config = Config::load(cli.config.as_deref())?; if matches!(cli.command, Command::CheckConfig) { - println!("configuration is valid{}", cli.config.as_ref().map(|path| format!(" ({})", path.display())).unwrap_or_default()); + println!( + "configuration is valid{}", + cli.config + .as_ref() + .map(|path| format!(" ({})", path.display())) + .unwrap_or_default() + ); return Ok(()); } info!( + "RustPad version" = %config.asset_version, + Copyright="@linuxiarz.pl Mateusz Gruszczyński", host = %config.host, port = config.port, database_kind = %database_kind_label(&config.database_url), @@ -103,12 +119,25 @@ async fn main() -> Result<(), Box> { let mut interval = tokio::time::interval(std::time::Duration::from_secs(24 * 60 * 60)); loop { interval.tick().await; - let cutoff = (chrono::Utc::now() - chrono::Duration::days(cleanup_state.unconfirmed_account_ttl_days)).to_rfc3339(); - match sqlx::query(crate::queries::get(cleanup_state.db.kind(), crate::queries::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS)) - .bind(cutoff).execute(cleanup_state.db.pool()).await { - Ok(result) if result.rows_affected() > 0 => info!(deleted = result.rows_affected(), "removed expired unconfirmed accounts"), - Ok(_) => {}, - Err(error) => tracing::error!(%error, "failed to remove expired unconfirmed accounts"), + let cutoff = (chrono::Utc::now() + - chrono::Duration::days(cleanup_state.unconfirmed_account_ttl_days)) + .to_rfc3339(); + match sqlx::query(crate::queries::get( + cleanup_state.db.kind(), + crate::queries::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS, + )) + .bind(cutoff) + .execute(cleanup_state.db.pool()) + .await + { + Ok(result) if result.rows_affected() > 0 => info!( + deleted = result.rows_affected(), + "removed expired unconfirmed accounts" + ), + Ok(_) => {} + Err(error) => { + tracing::error!(%error, "failed to remove expired unconfirmed accounts") + } } } }); @@ -130,7 +159,11 @@ async fn main() -> Result<(), Box> { } #[derive(Clone, Copy)] -enum Command { Run, CheckConfig, Migrate } +enum Command { + Run, + CheckConfig, + Migrate, +} struct Cli { command: Command, @@ -170,7 +203,9 @@ fn parse_command() -> Result> { } command = Command::Migrate; } - _ if arg.starts_with('-') => return Err(format!("unknown option: {arg}; use --help").into()), + _ if arg.starts_with('-') => { + return Err(format!("unknown option: {arg}; use --help").into()); + } _ => return Err(format!("unknown command: {arg}; use --help").into()), } } diff --git a/src/queries/mod.rs b/src/queries/mod.rs index 356506b..202115f 100644 --- a/src/queries/mod.rs +++ b/src/queries/mod.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use crate::database::DatabaseKind; mod mysql; @@ -154,7 +163,8 @@ pub const SQLITE_JOURNAL_WAL: Query = Query::SQLITE_JOURNAL_WAL; pub const SQLITE_BUSY_TIMEOUT: Query = Query::SQLITE_BUSY_TIMEOUT; pub const SQLITE_LAST_INSERT_ID: Query = Query::SQLITE_LAST_INSERT_ID; pub const MYSQL_LAST_INSERT_ID: Query = Query::MYSQL_LAST_INSERT_ID; -pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: Query = Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID; +pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: Query = + Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID; pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: Query = Query::POSTGRES_PAD_REVISION_LAST_INSERT_ID; pub const AUTH_LATEST_CONFIRMATION_CREATED_AT: Query = Query::AUTH_LATEST_CONFIRMATION_CREATED_AT; pub const AUTH_UPDATE_NICKNAME: Query = Query::AUTH_UPDATE_NICKNAME; @@ -171,7 +181,8 @@ pub const AUTH_CONSUME_ACCOUNT_ACTION: Query = Query::AUTH_CONSUME_ACCOUNT_ACTIO pub const AUTH_UPDATE_EMAIL: Query = Query::AUTH_UPDATE_EMAIL; pub const AUTH_DELETE_ACCOUNT_ACTIONS: Query = Query::AUTH_DELETE_ACCOUNT_ACTIONS; pub const AUTH_INSERT_ACCOUNT_ACTION: Query = Query::AUTH_INSERT_ACCOUNT_ACTION; -pub const AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS: Query = Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS; +pub const AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS: Query = + Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS; pub const AUTH_INSERT_USER: Query = Query::AUTH_INSERT_USER; pub const AUTH_INSERT_DIRECTORY_USER: Query = Query::AUTH_INSERT_DIRECTORY_USER; pub const AUTH_UPDATE_DIRECTORY_USER: Query = Query::AUTH_UPDATE_DIRECTORY_USER; @@ -184,7 +195,8 @@ 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; -pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: Query = Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER; +pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: Query = + Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER; pub const AUTH_INSERT_CONFIRMATION_TOKEN: Query = Query::AUTH_INSERT_CONFIRMATION_TOKEN; pub const AUTH_FIND_CONFIRMATION_TOKEN: Query = Query::AUTH_FIND_CONFIRMATION_TOKEN; pub const AUTH_CONFIRM_USER: Query = Query::AUTH_CONFIRM_USER; @@ -212,7 +224,8 @@ pub const USER_SET_WORKSPACE_PASSWORD: Query = Query::USER_SET_WORKSPACE_PASSWOR pub const USER_SET_PAD_PASSWORD: Query = Query::USER_SET_PAD_PASSWORD; pub const USER_SET_WORKSPACE_PRIVACY: Query = Query::USER_SET_WORKSPACE_PRIVACY; pub const USER_SET_PAD_PRIVACY: Query = Query::USER_SET_PAD_PRIVACY; -pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query = Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE; +pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query = + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE; pub const RESOURCE_ACCESS_TOKENS_INSERT: Query = Query::RESOURCE_ACCESS_TOKENS_INSERT; pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT; pub const RESOURCE_PERMISSION_DELETE_USER: Query = Query::RESOURCE_PERMISSION_DELETE_USER; @@ -287,7 +300,16 @@ mod tests { use super::*; #[test] fn every_backend_has_explicit_queries() { - for query in [Q001, Q003, Q004, Q011, Q021, Q033, USER_LIST_WORKSPACES, USER_LIST_PADS] { + for query in [ + Q001, + Q003, + Q004, + Q011, + Q021, + Q033, + USER_LIST_WORKSPACES, + USER_LIST_PADS, + ] { assert!(!get(DatabaseKind::Sqlite, query).is_empty()); assert!(!get(DatabaseKind::Postgres, query).is_empty()); assert!(!get(DatabaseKind::MySql, query).is_empty()); diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index a99fb71..228b622 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::Query; pub fn get(query: Query) -> &'static str { @@ -7,132 +16,322 @@ pub fn get(query: Query) -> &'static str { Query::SQLITE_BUSY_TIMEOUT => r#"PRAGMA busy_timeout = 5000"#, Query::SQLITE_LAST_INSERT_ID => r#"SELECT last_insert_rowid()"#, Query::MYSQL_LAST_INSERT_ID => r#"SELECT LAST_INSERT_ID()"#, - Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID => r#"SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"#, - Query::POSTGRES_PAD_REVISION_LAST_INSERT_ID => r#"SELECT currval(pg_get_serial_sequence('revisions', 'id'))"#, - Query::AUTH_LATEST_CONFIRMATION_CREATED_AT => r#"SELECT created_at FROM account_confirmation_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1"#, - Query::AUTH_UPDATE_NICKNAME => r#"UPDATE users SET nickname = ?, nickname_key = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_UPDATE_EDITOR_COLOR => r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#, + Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID => { + r#"SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"# + } + Query::POSTGRES_PAD_REVISION_LAST_INSERT_ID => { + r#"SELECT currval(pg_get_serial_sequence('revisions', 'id'))"# + } + Query::AUTH_LATEST_CONFIRMATION_CREATED_AT => { + r#"SELECT created_at FROM account_confirmation_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1"# + } + Query::AUTH_UPDATE_NICKNAME => { + r#"UPDATE users SET nickname = ?, nickname_key = ?, updated_at = ? WHERE id = ?"# + } + Query::AUTH_UPDATE_EDITOR_COLOR => { + r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, - Query::RESOURCE_COLOR_BY_USER => r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_COLOR_DELETE => r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_EDITOR_SETTINGS_SELECT => r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_EDITOR_SETTINGS_DELETE => r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_EDITOR_SETTINGS_INSERT => r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"#, - Query::RESOURCE_COLOR_INSERT => r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#, - Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => r#"SELECT user_id, action, CAST(payload AS CHAR CHARACTER SET utf8mb4) AS payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"#, - Query::AUTH_CONSUME_ACCOUNT_ACTION => r#"UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL"#, - Query::AUTH_UPDATE_EMAIL => r#"UPDATE users SET email = ?, email_key = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_DELETE_ACCOUNT_ACTIONS => r#"DELETE FROM account_action_tokens WHERE user_id = ? AND action = ?"#, - Query::AUTH_INSERT_ACCOUNT_ACTION => r#"INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?)"#, - Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS => r#"DELETE FROM users WHERE confirmed_at IS NULL AND created_at < ?"#, - Query::AUTH_INSERT_USER => r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"#, - 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, directory_username) 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 = ?, directory_username = ?, 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, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE auth_provider = ? AND external_id = ?"#, + Query::RESOURCE_COLOR_BY_USER => { + r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_COLOR_DELETE => { + r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_EDITOR_SETTINGS_SELECT => { + r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_EDITOR_SETTINGS_DELETE => { + r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_EDITOR_SETTINGS_INSERT => { + r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"# + } + Query::RESOURCE_COLOR_INSERT => { + r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# + } + Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { + r#"SELECT user_id, action, CAST(payload AS CHAR CHARACTER SET utf8mb4) AS payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"# + } + Query::AUTH_CONSUME_ACCOUNT_ACTION => { + r#"UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL"# + } + Query::AUTH_UPDATE_EMAIL => { + r#"UPDATE users SET email = ?, email_key = ?, updated_at = ? WHERE id = ?"# + } + Query::AUTH_DELETE_ACCOUNT_ACTIONS => { + r#"DELETE FROM account_action_tokens WHERE user_id = ? AND action = ?"# + } + Query::AUTH_INSERT_ACCOUNT_ACTION => { + r#"INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?)"# + } + Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS => { + r#"DELETE FROM users WHERE confirmed_at IS NULL AND created_at < ?"# + } + Query::AUTH_INSERT_USER => { + r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"# + } + 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, directory_username) 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 = ?, directory_username = ?, 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, 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, directory_username = NULL, updated_at = ? 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, directory_username = 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_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 > ?"#, - Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER => r#"DELETE FROM account_confirmation_tokens WHERE user_id = ?"#, - Query::AUTH_INSERT_CONFIRMATION_TOKEN => r#"INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"#, - Query::AUTH_FIND_CONFIRMATION_TOKEN => r#"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"#, - Query::AUTH_CONFIRM_USER => r#"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_CONSUME_CONFIRMATION_TOKEN => r#"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"#, - Query::AUTH_DELETE_RESET_TOKENS_BY_USER => r#"DELETE FROM password_reset_tokens WHERE user_id = ?"#, - Query::AUTH_INSERT_RESET_TOKEN => r#"INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"#, - Query::AUTH_FIND_RESET_TOKEN => r#"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"#, - 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_REFRESH_SESSION => { + r#"UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?"# + } + Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER => { + r#"DELETE FROM account_confirmation_tokens WHERE user_id = ?"# + } + Query::AUTH_INSERT_CONFIRMATION_TOKEN => { + r#"INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"# + } + Query::AUTH_FIND_CONFIRMATION_TOKEN => { + r#"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"# + } + Query::AUTH_CONFIRM_USER => { + r#"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"# + } + Query::AUTH_CONSUME_CONFIRMATION_TOKEN => { + r#"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"# + } + Query::AUTH_DELETE_RESET_TOKENS_BY_USER => { + r#"DELETE FROM password_reset_tokens WHERE user_id = ?"# + } + Query::AUTH_INSERT_RESET_TOKEN => { + r#"INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"# + } + Query::AUTH_FIND_RESET_TOKEN => { + r#"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"# + } + 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, 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, 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::AUTH_USER_BY_SHARE_IDENTIFIER => 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 JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#, - 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"#, - Query::USER_LIST_PADS => r#"SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#, - Query::USER_OWNS_WORKSPACE => r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#, - Query::USER_OWNS_PAD => r#"SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"#, + 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, 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::AUTH_USER_BY_SHARE_IDENTIFIER => { + 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 JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(directory_username) = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"# + } + 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"# + } + Query::USER_LIST_PADS => { + r#"SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"# + } + Query::USER_OWNS_WORKSPACE => { + r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"# + } + Query::USER_OWNS_PAD => { + r#"SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"# + } Query::USER_DELETE_WORKSPACE => r#"DELETE FROM workspaces WHERE slug = ?"#, Query::USER_DELETE_PAD => r#"DELETE FROM pads WHERE slug = ?"#, - Query::USER_SET_WORKSPACE_PASSWORD => r#"UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::USER_SET_PAD_PASSWORD => r#"UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::USER_SET_WORKSPACE_PRIVACY => r#"UPDATE workspaces SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::USER_SET_PAD_PRIVACY => r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_ACCESS_TOKENS_INSERT => r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#, - Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT => r#"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"#, - Query::RESOURCE_PERMISSION_DELETE_USER => r#"DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#, - Query::RESOURCE_PERMISSION_INSERT => r#"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"#, - Query::SHARE_INVITATION_DELETE_USER => r#"DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#, - Query::SHARE_INVITATION_INSERT => r#"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"#, - Query::SHARE_INVITATION_DELETE_TOKEN => r#"DELETE FROM resource_share_invitations WHERE token_hash = ?"#, - Query::SHARE_INVITATION_FIND_TOKEN => r#"SELECT resource_kind, resource_slug, user_id, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(accepted_at AS CHAR CHARACTER SET utf8mb4) AS accepted_at FROM resource_share_invitations WHERE token_hash = ?"#, - Query::SHARE_INVITATION_ACCEPT => r#"UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"#, - Query::RESOURCE_SHARING_USERS => r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#, - Query::RESOURCE_SHARING_LINKS => r#"SELECT token_hash, CAST(token AS CHAR CHARACTER SET utf8mb4) AS token, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#, - Query::RESOURCE_SHARING_PENDING => r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#, - Query::SHARE_LINK_INSERT => r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#, - Query::SHARE_LINK_UPDATE => r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#, - Query::SHARE_LINK_REVOKE => r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_PERMISSION_BY_USER => r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#, - Query::SHARE_LINK_PERMISSION => r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#, - Query::Q001 => r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM workspaces WHERE slug = ?"#, + Query::USER_SET_WORKSPACE_PASSWORD => { + r#"UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::USER_SET_PAD_PASSWORD => { + r#"UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::USER_SET_WORKSPACE_PRIVACY => { + r#"UPDATE workspaces SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::USER_SET_PAD_PRIVACY => { + r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { + r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_ACCESS_TOKENS_INSERT => { + r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"# + } + Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT => { + r#"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"# + } + Query::RESOURCE_PERMISSION_DELETE_USER => { + r#"DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# + } + Query::RESOURCE_PERMISSION_INSERT => { + r#"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"# + } + Query::SHARE_INVITATION_DELETE_USER => { + r#"DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# + } + Query::SHARE_INVITATION_INSERT => { + r#"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"# + } + Query::SHARE_INVITATION_DELETE_TOKEN => { + r#"DELETE FROM resource_share_invitations WHERE token_hash = ?"# + } + Query::SHARE_INVITATION_FIND_TOKEN => { + r#"SELECT resource_kind, resource_slug, user_id, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(accepted_at AS CHAR CHARACTER SET utf8mb4) AS accepted_at FROM resource_share_invitations WHERE token_hash = ?"# + } + Query::SHARE_INVITATION_ACCEPT => { + r#"UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"# + } + Query::RESOURCE_SHARING_USERS => { + r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# + } + Query::RESOURCE_SHARING_LINKS => { + r#"SELECT token_hash, CAST(token AS CHAR CHARACTER SET utf8mb4) AS token, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# + } + Query::RESOURCE_SHARING_PENDING => { + r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"# + } + Query::SHARE_LINK_INSERT => { + r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"# + } + Query::SHARE_LINK_UPDATE => { + r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + } + Query::SHARE_LINK_REVOKE => { + r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_PERMISSION_BY_USER => { + r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# + } + Query::SHARE_LINK_PERMISSION => { + r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"# + } + Query::Q001 => { + r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM workspaces WHERE slug = ?"# + } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#, - Query::Q003 => r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#, - Query::Q004 => r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? AND slug = ?"#, - Query::Q005 => r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"#, - Query::Q006 => r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, + Query::Q003 => { + r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# + } + Query::Q004 => { + r#"SELECT id, workspace_id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS SIGNED) AS protected, CAST(created_by AS CHAR CHARACTER SET utf8mb4) AS created_by FROM notes WHERE workspace_id = ? AND slug = ?"# + } + Query::Q005 => { + r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"# + } + Query::Q006 => { + r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } Query::Q007 => r#"UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q008 => r#"INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"#, + Query::Q008 => { + r#"INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"# + } Query::Q009 => r#"SELECT updated_at FROM notes WHERE id = ?"#, - Query::Q010 => r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#, - Query::Q011 => r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM pads WHERE slug = ?"#, + Query::Q010 => { + r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"# + } + Query::Q011 => { + r#"SELECT id, slug, CAST(title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, created_at, updated_at, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS SIGNED) AS is_private FROM pads WHERE slug = ?"# + } Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#, - Query::Q013 => r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q014 => r#"INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"#, + Query::Q013 => { + r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::Q014 => { + r#"INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"# + } Query::Q015 => r#"SELECT updated_at FROM pads WHERE id = ?"#, - Query::Q016 => r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100"#, + Query::Q016 => { + r#"SELECT id, CAST(content AS CHAR CHARACTER SET utf8mb4) AS content, created_at, CAST(author AS CHAR CHARACTER SET utf8mb4) AS author, CAST(owner_map AS CHAR CHARACTER SET utf8mb4) AS owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100"# + } Query::Q017 => r#"SELECT token FROM published_pages WHERE pad_id = ?"#, Query::Q018 => r#"INSERT INTO published_pages (token, pad_id) VALUES (?, ?)"#, Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#, Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#, - Query::Q021 => r#"SELECT pp.token, pp.pad_id, pp.note_id, CAST(CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS SIGNED) AS allow_task_updates, CAST(COALESCE(p.title, n.title) AS CHAR CHARACTER SET utf8mb4) AS title, CAST(COALESCE(p.content, n.content) AS CHAR CHARACTER SET utf8mb4) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"#, + Query::Q021 => { + r#"SELECT pp.token, pp.pad_id, pp.note_id, CAST(CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS SIGNED) AS allow_task_updates, CAST(COALESCE(p.title, n.title) AS CHAR CHARACTER SET utf8mb4) AS title, CAST(COALESCE(p.content, n.content) AS CHAR CHARACTER SET utf8mb4) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"# + } Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#, Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#, Query::Q024 => r#"SELECT file_token FROM notes WHERE id = ?"#, Query::Q025 => r#"UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL"#, Query::Q026 => r#"SELECT id FROM pads WHERE file_token = ?"#, Query::Q027 => r#"SELECT id FROM notes WHERE file_token = ?"#, - Query::Q028 => r#"SELECT CAST(content AS CHAR CHARACTER SET utf8mb4) FROM note_revisions WHERE id = ? AND note_id = ?"#, - Query::Q029 => r#"SELECT CAST(content AS CHAR CHARACTER SET utf8mb4) FROM revisions WHERE id = ? AND pad_id = ?"#, - Query::Q030 => r#"SELECT CAST(owner_map AS CHAR CHARACTER SET utf8mb4) FROM revisions WHERE id = ? AND pad_id = ?"#, + Query::Q028 => { + r#"SELECT CAST(content AS CHAR CHARACTER SET utf8mb4) FROM note_revisions WHERE id = ? AND note_id = ?"# + } + Query::Q029 => { + r#"SELECT CAST(content AS CHAR CHARACTER SET utf8mb4) FROM revisions WHERE id = ? AND pad_id = ?"# + } + Query::Q030 => { + r#"SELECT CAST(owner_map AS CHAR CHARACTER SET utf8mb4) FROM revisions WHERE id = ? AND pad_id = ?"# + } Query::Q031 => r#"DELETE FROM notes WHERE id = ?"#, - Query::Q032 => r#"INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"#, - Query::Q033 => r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC"#, + Query::Q032 => { + r#"INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"# + } + Query::Q033 => { + r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC"# + } Query::Q034 => r#"UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?"#, - Query::Q035 => r#"INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"#, - Query::Q036 => r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC"#, + Query::Q035 => { + r#"INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"# + } + Query::Q036 => { + r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC"# + } Query::Q037 => r#"UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?"#, - Query::Q038 => r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"#, + Query::Q038 => { + r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"# + } Query::Q039 => r#"DELETE FROM note_files WHERE id = ? AND note_id = ?"#, - Query::Q046 => r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM pad_files WHERE id = ? AND pad_id = ?"#, + Query::Q046 => { + r#"SELECT id, CAST(filename AS CHAR CHARACTER SET utf8mb4) AS filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS SIGNED) AS is_attached, detached_at FROM pad_files WHERE id = ? AND pad_id = ?"# + } Query::Q047 => r#"DELETE FROM pad_files WHERE id = ? AND pad_id = ?"#, Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE pad_id = ?"#, Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE note_id = ?"#, - Query::Q042 => r#"UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q044 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#, - Query::Q045 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#, + Query::Q042 => { + r#"UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::Q043 => { + r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::Q044 => { + r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"# + } + Query::Q045 => { + r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"# + } - Query::Q048 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#, - Query::Q049 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#, + Query::Q048 => { + r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"# + } + Query::Q049 => { + r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"# + } Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#, Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#, } diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index 0ac3046..4128b55 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::Query; pub fn get(query: Query) -> &'static str { @@ -7,103 +16,267 @@ pub fn get(query: Query) -> &'static str { Query::SQLITE_BUSY_TIMEOUT => r#"PRAGMA busy_timeout = 5000"#, Query::SQLITE_LAST_INSERT_ID => r#"SELECT last_insert_rowid()"#, Query::MYSQL_LAST_INSERT_ID => r#"SELECT LAST_INSERT_ID()"#, - Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID => r#"SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"#, - Query::POSTGRES_PAD_REVISION_LAST_INSERT_ID => r#"SELECT currval(pg_get_serial_sequence('revisions', 'id'))"#, - Query::AUTH_LATEST_CONFIRMATION_CREATED_AT => r#"SELECT created_at FROM account_confirmation_tokens WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1"#, - Query::AUTH_UPDATE_NICKNAME => r#"UPDATE users SET nickname = $1, nickname_key = $2, updated_at = $3 WHERE id = $4"#, - Query::AUTH_UPDATE_EDITOR_COLOR => r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"#, + Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID => { + r#"SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"# + } + Query::POSTGRES_PAD_REVISION_LAST_INSERT_ID => { + r#"SELECT currval(pg_get_serial_sequence('revisions', 'id'))"# + } + Query::AUTH_LATEST_CONFIRMATION_CREATED_AT => { + r#"SELECT created_at FROM account_confirmation_tokens WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1"# + } + Query::AUTH_UPDATE_NICKNAME => { + r#"UPDATE users SET nickname = $1, nickname_key = $2, updated_at = $3 WHERE id = $4"# + } + Query::AUTH_UPDATE_EDITOR_COLOR => { + r#"UPDATE users SET editor_color = $1, updated_at = $2 WHERE id = $3"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = $1"#, - Query::RESOURCE_COLOR_BY_USER => r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#, - Query::RESOURCE_COLOR_DELETE => r#"DELETE FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"#, - Query::RESOURCE_EDITOR_SETTINGS_SELECT => r#"SELECT authorship_mode, (CASE WHEN colors_enabled THEN 1 ELSE 0 END)::BIGINT FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#, - Query::RESOURCE_EDITOR_SETTINGS_DELETE => r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"#, - Query::RESOURCE_EDITOR_SETTINGS_INSERT => r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES ($1, $2, $3, $4)"#, - Query::RESOURCE_COLOR_INSERT => r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"#, - Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = $1"#, - Query::AUTH_CONSUME_ACCOUNT_ACTION => r#"UPDATE account_action_tokens SET used_at = $1 WHERE token = $2 AND used_at IS NULL"#, - Query::AUTH_UPDATE_EMAIL => r#"UPDATE users SET email = $1, email_key = $2, updated_at = $3 WHERE id = $4"#, - Query::AUTH_DELETE_ACCOUNT_ACTIONS => r#"DELETE FROM account_action_tokens WHERE user_id = $1 AND action = $2"#, - Query::AUTH_INSERT_ACCOUNT_ACTION => r#"INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES ($1, $2, $3, $4, $5, $6)"#, - Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS => r#"DELETE FROM users WHERE confirmed_at IS NULL AND created_at < $1"#, - Query::AUTH_INSERT_USER => r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES ($1, $2, $3, $4, $5, $6)"#, - 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, directory_username) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"#, - 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, directory_username = $9, updated_at = $10 WHERE id = $11"#, - 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, 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::RESOURCE_COLOR_BY_USER => { + r#"SELECT color FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"# + } + Query::RESOURCE_COLOR_DELETE => { + r#"DELETE FROM user_resource_colors WHERE user_id = $1 AND resource_kind = $2 AND resource_slug = $3"# + } + Query::RESOURCE_EDITOR_SETTINGS_SELECT => { + r#"SELECT authorship_mode, (CASE WHEN colors_enabled THEN 1 ELSE 0 END)::BIGINT FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"# + } + Query::RESOURCE_EDITOR_SETTINGS_DELETE => { + r#"DELETE FROM resource_editor_settings WHERE resource_kind = $1 AND resource_slug = $2"# + } + Query::RESOURCE_EDITOR_SETTINGS_INSERT => { + r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES ($1, $2, $3, $4)"# + } + Query::RESOURCE_COLOR_INSERT => { + r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES ($1, $2, $3, $4)"# + } + Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { + r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = $1"# + } + Query::AUTH_CONSUME_ACCOUNT_ACTION => { + r#"UPDATE account_action_tokens SET used_at = $1 WHERE token = $2 AND used_at IS NULL"# + } + Query::AUTH_UPDATE_EMAIL => { + r#"UPDATE users SET email = $1, email_key = $2, updated_at = $3 WHERE id = $4"# + } + Query::AUTH_DELETE_ACCOUNT_ACTIONS => { + r#"DELETE FROM account_action_tokens WHERE user_id = $1 AND action = $2"# + } + Query::AUTH_INSERT_ACCOUNT_ACTION => { + r#"INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES ($1, $2, $3, $4, $5, $6)"# + } + Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS => { + r#"DELETE FROM users WHERE confirmed_at IS NULL AND created_at < $1"# + } + Query::AUTH_INSERT_USER => { + r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES ($1, $2, $3, $4, $5, $6)"# + } + 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, directory_username) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"# + } + 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, directory_username = $9, updated_at = $10 WHERE id = $11"# + } + 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, 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, directory_username = NULL, updated_at = $6 WHERE id = $7"#, + 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, directory_username = 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_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"#, - Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER => r#"DELETE FROM account_confirmation_tokens WHERE user_id = $1"#, - Query::AUTH_INSERT_CONFIRMATION_TOKEN => r#"INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES ($1, $2, $3)"#, - Query::AUTH_FIND_CONFIRMATION_TOKEN => r#"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = $1"#, - Query::AUTH_CONFIRM_USER => r#"UPDATE users SET confirmed_at = $1, updated_at = $2 WHERE id = $3"#, - Query::AUTH_CONSUME_CONFIRMATION_TOKEN => r#"UPDATE account_confirmation_tokens SET used_at = $1 WHERE token = $2 AND used_at IS NULL AND expires_at > $3"#, - Query::AUTH_DELETE_RESET_TOKENS_BY_USER => r#"DELETE FROM password_reset_tokens WHERE user_id = $1"#, - Query::AUTH_INSERT_RESET_TOKEN => r#"INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES ($1, $2, $3)"#, - Query::AUTH_FIND_RESET_TOKEN => r#"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = $1"#, - 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_REFRESH_SESSION => { + r#"UPDATE user_sessions SET expires_at = $1 WHERE token = $2 AND expires_at > $3"# + } + Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER => { + r#"DELETE FROM account_confirmation_tokens WHERE user_id = $1"# + } + Query::AUTH_INSERT_CONFIRMATION_TOKEN => { + r#"INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES ($1, $2, $3)"# + } + Query::AUTH_FIND_CONFIRMATION_TOKEN => { + r#"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = $1"# + } + Query::AUTH_CONFIRM_USER => { + r#"UPDATE users SET confirmed_at = $1, updated_at = $2 WHERE id = $3"# + } + Query::AUTH_CONSUME_CONFIRMATION_TOKEN => { + r#"UPDATE account_confirmation_tokens SET used_at = $1 WHERE token = $2 AND used_at IS NULL AND expires_at > $3"# + } + Query::AUTH_DELETE_RESET_TOKENS_BY_USER => { + r#"DELETE FROM password_reset_tokens WHERE user_id = $1"# + } + Query::AUTH_INSERT_RESET_TOKEN => { + r#"INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES ($1, $2, $3)"# + } + Query::AUTH_FIND_RESET_TOKEN => { + r#"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = $1"# + } + 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, 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, 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::AUTH_USER_BY_SHARE_IDENTIFIER => 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 is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 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"#, - Query::USER_LIST_PADS => r#"SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, p.updated_at, CAST(CASE WHEN p.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_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = $1 UNION SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = $2 ORDER BY updated_at DESC"#, - Query::USER_OWNS_WORKSPACE => r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 AND w.slug = $2"#, - Query::USER_OWNS_PAD => r#"SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = $1 AND p.slug = $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, 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::AUTH_USER_BY_SHARE_IDENTIFIER => { + 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 is_active = TRUE AND (email_key = $1 OR LOWER(directory_username) = $1 OR LOWER(external_id) = $1) LIMIT 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"# + } + Query::USER_LIST_PADS => { + r#"SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, p.updated_at, CAST(CASE WHEN p.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_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = $1 UNION SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = $2 ORDER BY updated_at DESC"# + } + Query::USER_OWNS_WORKSPACE => { + r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 AND w.slug = $2"# + } + Query::USER_OWNS_PAD => { + r#"SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = $1 AND p.slug = $2"# + } Query::USER_DELETE_WORKSPACE => r#"DELETE FROM workspaces WHERE slug = $1"#, Query::USER_DELETE_PAD => r#"DELETE FROM pads WHERE slug = $1"#, - Query::USER_SET_WORKSPACE_PASSWORD => r#"UPDATE workspaces SET password_hash = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"#, - Query::USER_SET_PAD_PASSWORD => r#"UPDATE pads SET password_hash = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"#, - Query::USER_SET_WORKSPACE_PRIVACY => r#"UPDATE workspaces SET is_private = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"#, - Query::USER_SET_PAD_PRIVACY => r#"UPDATE pads SET is_private = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"#, - Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"#, - Query::RESOURCE_ACCESS_TOKENS_INSERT => r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"#, - Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT => r#"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND expires_at > $4"#, - Query::RESOURCE_PERMISSION_DELETE_USER => r#"DELETE FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"#, - Query::RESOURCE_PERMISSION_INSERT => r#"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES ($1, $2, $3, $4)"#, - Query::SHARE_INVITATION_DELETE_USER => r#"DELETE FROM resource_share_invitations WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"#, - Query::SHARE_INVITATION_INSERT => r#"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7)"#, - Query::SHARE_INVITATION_DELETE_TOKEN => r#"DELETE FROM resource_share_invitations WHERE token_hash = $1"#, - Query::SHARE_INVITATION_FIND_TOKEN => r#"SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = $1"#, - Query::SHARE_INVITATION_ACCEPT => r#"UPDATE resource_share_invitations SET accepted_at = $1 WHERE token_hash = $2"#, - Query::RESOURCE_SHARING_USERS => r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#, - Query::RESOURCE_SHARING_LINKS => r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#, - Query::RESOURCE_SHARING_PENDING => r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"#, - Query::SHARE_LINK_INSERT => r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"#, - Query::SHARE_LINK_UPDATE => r#"UPDATE resource_share_links SET permission = $1, expires_at = $2 WHERE token_hash = $3 AND resource_kind = $4 AND resource_slug = $5 AND revoked_at IS NULL"#, - Query::SHARE_LINK_REVOKE => r#"UPDATE resource_share_links SET revoked_at = $1 WHERE token_hash = $2 AND resource_kind = $3 AND resource_slug = $4"#, - Query::RESOURCE_PERMISSION_BY_USER => r#"SELECT permission FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"#, - Query::SHARE_LINK_PERMISSION => r#"SELECT permission FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $4)"#, - Query::Q001 => r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = $1"#, + Query::USER_SET_WORKSPACE_PASSWORD => { + r#"UPDATE workspaces SET password_hash = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"# + } + Query::USER_SET_PAD_PASSWORD => { + r#"UPDATE pads SET password_hash = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"# + } + Query::USER_SET_WORKSPACE_PRIVACY => { + r#"UPDATE workspaces SET is_private = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"# + } + Query::USER_SET_PAD_PRIVACY => { + r#"UPDATE pads SET is_private = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE slug = $2"# + } + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { + r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"# + } + Query::RESOURCE_ACCESS_TOKENS_INSERT => { + r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"# + } + Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT => { + r#"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND expires_at > $4"# + } + Query::RESOURCE_PERMISSION_DELETE_USER => { + r#"DELETE FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"# + } + Query::RESOURCE_PERMISSION_INSERT => { + r#"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES ($1, $2, $3, $4)"# + } + Query::SHARE_INVITATION_DELETE_USER => { + r#"DELETE FROM resource_share_invitations WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"# + } + Query::SHARE_INVITATION_INSERT => { + r#"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7)"# + } + Query::SHARE_INVITATION_DELETE_TOKEN => { + r#"DELETE FROM resource_share_invitations WHERE token_hash = $1"# + } + Query::SHARE_INVITATION_FIND_TOKEN => { + r#"SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = $1"# + } + Query::SHARE_INVITATION_ACCEPT => { + r#"UPDATE resource_share_invitations SET accepted_at = $1 WHERE token_hash = $2"# + } + Query::RESOURCE_SHARING_USERS => { + r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"# + } + Query::RESOURCE_SHARING_LINKS => { + r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"# + } + Query::RESOURCE_SHARING_PENDING => { + r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"# + } + Query::SHARE_LINK_INSERT => { + r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"# + } + Query::SHARE_LINK_UPDATE => { + r#"UPDATE resource_share_links SET permission = $1, expires_at = $2 WHERE token_hash = $3 AND resource_kind = $4 AND resource_slug = $5 AND revoked_at IS NULL"# + } + Query::SHARE_LINK_REVOKE => { + r#"UPDATE resource_share_links SET revoked_at = $1 WHERE token_hash = $2 AND resource_kind = $3 AND resource_slug = $4"# + } + Query::RESOURCE_PERMISSION_BY_USER => { + r#"SELECT permission FROM resource_permissions WHERE resource_kind = $1 AND resource_slug = $2 AND user_id = $3"# + } + Query::SHARE_LINK_PERMISSION => { + r#"SELECT permission FROM resource_share_links WHERE token_hash = $1 AND resource_kind = $2 AND resource_slug = $3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > $4)"# + } + Query::Q001 => { + r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = $1"# + } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES ($1, $2, $3)"#, - Query::Q003 => r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"#, - Query::Q004 => r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 AND slug = $2"#, - Query::Q005 => r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES ($1, $2, $3, $4, $5)"#, - Query::Q006 => r#"UPDATE notes SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#, - Query::Q007 => r#"UPDATE workspaces SET updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $1"#, - Query::Q008 => r#"INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES ($1, $2, $3, $4)"#, + Query::Q003 => { + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 ORDER BY updated_at DESC, id DESC"# + } + Query::Q004 => { + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, CAST(CASE WHEN protected THEN 1 ELSE 0 END AS BIGINT) AS protected, created_by FROM notes WHERE workspace_id = $1 AND slug = $2"# + } + Query::Q005 => { + r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES ($1, $2, $3, $4, $5)"# + } + Query::Q006 => { + r#"UPDATE notes SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"# + } + Query::Q007 => { + r#"UPDATE workspaces SET updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $1"# + } + Query::Q008 => { + r#"INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES ($1, $2, $3, $4)"# + } Query::Q009 => r#"SELECT updated_at FROM notes WHERE id = $1"#, - Query::Q010 => r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = $1 ORDER BY id DESC LIMIT 100"#, - Query::Q011 => r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = $1"#, + Query::Q010 => { + r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = $1 ORDER BY id DESC LIMIT 100"# + } + Query::Q011 => { + r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = $1"# + } Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES ($1, $2, $3)"#, - Query::Q013 => r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"#, - Query::Q014 => r#"INSERT INTO revisions (pad_id, content, author, owner_map) VALUES ($1, $2, $3, $4)"#, + Query::Q013 => { + r#"UPDATE pads SET content = $1, owner_map = $2, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $3"# + } + Query::Q014 => { + r#"INSERT INTO revisions (pad_id, content, author, owner_map) VALUES ($1, $2, $3, $4)"# + } Query::Q015 => r#"SELECT updated_at FROM pads WHERE id = $1"#, - Query::Q016 => r#"SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = $1 ORDER BY id DESC LIMIT 100"#, + Query::Q016 => { + r#"SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = $1 ORDER BY id DESC LIMIT 100"# + } Query::Q017 => r#"SELECT token FROM published_pages WHERE pad_id = $1"#, Query::Q018 => r#"INSERT INTO published_pages (token, pad_id) VALUES ($1, $2)"#, Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = $1"#, Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES ($1, $2)"#, - Query::Q021 => r#"SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = $1"#, + Query::Q021 => { + r#"SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = $1"# + } Query::Q022 => r#"SELECT file_token FROM pads WHERE id = $1"#, Query::Q023 => r#"UPDATE pads SET file_token = $1 WHERE id = $2 AND file_token IS NULL"#, Query::Q024 => r#"SELECT file_token FROM notes WHERE id = $1"#, @@ -114,20 +287,36 @@ pub fn get(query: Query) -> &'static str { Query::Q029 => r#"SELECT content FROM revisions WHERE id = $1 AND pad_id = $2"#, Query::Q030 => r#"SELECT owner_map FROM revisions WHERE id = $1 AND pad_id = $2"#, Query::Q031 => r#"DELETE FROM notes WHERE id = $1"#, - Query::Q032 => r#"INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES ($1, $2, $3, $4, $5)"#, - Query::Q033 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM note_files WHERE note_id = $1 ORDER BY id DESC"#, + Query::Q032 => { + r#"INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES ($1, $2, $3, $4, $5)"# + } + Query::Q033 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM note_files WHERE note_id = $1 ORDER BY id DESC"# + } Query::Q034 => r#"UPDATE note_files SET is_attached = $1, detached_at = $2 WHERE id = $3"#, - Query::Q035 => r#"INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES ($1, $2, $3, $4, $5)"#, - Query::Q036 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM pad_files WHERE pad_id = $1 ORDER BY id DESC"#, + Query::Q035 => { + r#"INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES ($1, $2, $3, $4, $5)"# + } + Query::Q036 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM pad_files WHERE pad_id = $1 ORDER BY id DESC"# + } Query::Q037 => r#"UPDATE pad_files SET is_attached = $1, detached_at = $2 WHERE id = $3"#, - Query::Q038 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM note_files WHERE id = $1 AND note_id = $2"#, + Query::Q038 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM note_files WHERE id = $1 AND note_id = $2"# + } Query::Q039 => r#"DELETE FROM note_files WHERE id = $1 AND note_id = $2"#, - Query::Q046 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM pad_files WHERE id = $1 AND pad_id = $2"#, + Query::Q046 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, CAST(CASE WHEN is_attached THEN 1 ELSE 0 END AS BIGINT) AS is_attached, detached_at FROM pad_files WHERE id = $1 AND pad_id = $2"# + } Query::Q047 => r#"DELETE FROM pad_files WHERE id = $1 AND pad_id = $2"#, Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = $1 WHERE pad_id = $2"#, Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = $1 WHERE note_id = $2"#, - Query::Q042 => r#"UPDATE pads SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#, - Query::Q043 => r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#, + Query::Q042 => { + r#"UPDATE pads SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"# + } + Query::Q043 => { + r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"# + } Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#, Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#, diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index ff83152..91b8530 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use super::Query; pub fn get(query: Query) -> &'static str { @@ -7,103 +16,263 @@ pub fn get(query: Query) -> &'static str { Query::SQLITE_BUSY_TIMEOUT => r#"PRAGMA busy_timeout = 5000"#, Query::SQLITE_LAST_INSERT_ID => r#"SELECT last_insert_rowid()"#, Query::MYSQL_LAST_INSERT_ID => r#"SELECT LAST_INSERT_ID()"#, - Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID => r#"SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"#, - Query::POSTGRES_PAD_REVISION_LAST_INSERT_ID => r#"SELECT currval(pg_get_serial_sequence('revisions', 'id'))"#, - Query::AUTH_LATEST_CONFIRMATION_CREATED_AT => r#"SELECT created_at FROM account_confirmation_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1"#, - Query::AUTH_UPDATE_NICKNAME => r#"UPDATE users SET nickname = ?, nickname_key = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_UPDATE_EDITOR_COLOR => r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"#, + Query::POSTGRES_NOTE_REVISION_LAST_INSERT_ID => { + r#"SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"# + } + Query::POSTGRES_PAD_REVISION_LAST_INSERT_ID => { + r#"SELECT currval(pg_get_serial_sequence('revisions', 'id'))"# + } + Query::AUTH_LATEST_CONFIRMATION_CREATED_AT => { + r#"SELECT created_at FROM account_confirmation_tokens WHERE user_id = ? ORDER BY created_at DESC LIMIT 1"# + } + Query::AUTH_UPDATE_NICKNAME => { + r#"UPDATE users SET nickname = ?, nickname_key = ?, updated_at = ? WHERE id = ?"# + } + Query::AUTH_UPDATE_EDITOR_COLOR => { + r#"UPDATE users SET editor_color = ?, updated_at = ? WHERE id = ?"# + } Query::AUTH_EDITOR_COLOR_BY_USER => r#"SELECT editor_color FROM users WHERE id = ?"#, - Query::RESOURCE_COLOR_BY_USER => r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_COLOR_DELETE => r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_EDITOR_SETTINGS_SELECT => r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_EDITOR_SETTINGS_DELETE => r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_EDITOR_SETTINGS_INSERT => r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"#, - Query::RESOURCE_COLOR_INSERT => r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"#, - Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"#, - Query::AUTH_CONSUME_ACCOUNT_ACTION => r#"UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL"#, - Query::AUTH_UPDATE_EMAIL => r#"UPDATE users SET email = ?, email_key = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_DELETE_ACCOUNT_ACTIONS => r#"DELETE FROM account_action_tokens WHERE user_id = ? AND action = ?"#, - Query::AUTH_INSERT_ACCOUNT_ACTION => r#"INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?)"#, - Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS => r#"DELETE FROM users WHERE confirmed_at IS NULL AND created_at < ?"#, - Query::AUTH_INSERT_USER => r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"#, - 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, directory_username) 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 = ?, directory_username = ?, 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, is_active FROM users WHERE auth_provider = ? AND external_id = ?"#, + Query::RESOURCE_COLOR_BY_USER => { + r#"SELECT color FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_COLOR_DELETE => { + r#"DELETE FROM user_resource_colors WHERE user_id = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_EDITOR_SETTINGS_SELECT => { + r#"SELECT authorship_mode, CASE WHEN colors_enabled THEN 1 ELSE 0 END FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_EDITOR_SETTINGS_DELETE => { + r#"DELETE FROM resource_editor_settings WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_EDITOR_SETTINGS_INSERT => { + r#"INSERT INTO resource_editor_settings (resource_kind, resource_slug, authorship_mode, colors_enabled) VALUES (?, ?, ?, ?)"# + } + Query::RESOURCE_COLOR_INSERT => { + r#"INSERT INTO user_resource_colors (user_id, resource_kind, resource_slug, color) VALUES (?, ?, ?, ?)"# + } + Query::AUTH_ACCOUNT_ACTION_BY_TOKEN => { + r#"SELECT user_id, action, payload, expires_at, used_at FROM account_action_tokens WHERE token = ?"# + } + Query::AUTH_CONSUME_ACCOUNT_ACTION => { + r#"UPDATE account_action_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL"# + } + Query::AUTH_UPDATE_EMAIL => { + r#"UPDATE users SET email = ?, email_key = ?, updated_at = ? WHERE id = ?"# + } + Query::AUTH_DELETE_ACCOUNT_ACTIONS => { + r#"DELETE FROM account_action_tokens WHERE user_id = ? AND action = ?"# + } + Query::AUTH_INSERT_ACCOUNT_ACTION => { + r#"INSERT INTO account_action_tokens (token,user_id,action,payload,expires_at,created_at) VALUES (?, ?, ?, ?, ?, ?)"# + } + Query::AUTH_DELETE_EXPIRED_UNCONFIRMED_USERS => { + r#"DELETE FROM users WHERE confirmed_at IS NULL AND created_at < ?"# + } + Query::AUTH_INSERT_USER => { + r#"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"# + } + 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, directory_username) 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 = ?, directory_username = ?, 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, 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, directory_username = NULL, updated_at = ? 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, directory_username = 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_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 > ?"#, - Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER => r#"DELETE FROM account_confirmation_tokens WHERE user_id = ?"#, - Query::AUTH_INSERT_CONFIRMATION_TOKEN => r#"INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"#, - Query::AUTH_FIND_CONFIRMATION_TOKEN => r#"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"#, - Query::AUTH_CONFIRM_USER => r#"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"#, - Query::AUTH_CONSUME_CONFIRMATION_TOKEN => r#"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"#, - Query::AUTH_DELETE_RESET_TOKENS_BY_USER => r#"DELETE FROM password_reset_tokens WHERE user_id = ?"#, - Query::AUTH_INSERT_RESET_TOKEN => r#"INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"#, - Query::AUTH_FIND_RESET_TOKEN => r#"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"#, - 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_REFRESH_SESSION => { + r#"UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?"# + } + Query::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER => { + r#"DELETE FROM account_confirmation_tokens WHERE user_id = ?"# + } + Query::AUTH_INSERT_CONFIRMATION_TOKEN => { + r#"INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"# + } + Query::AUTH_FIND_CONFIRMATION_TOKEN => { + r#"SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"# + } + Query::AUTH_CONFIRM_USER => { + r#"UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"# + } + Query::AUTH_CONSUME_CONFIRMATION_TOKEN => { + r#"UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL AND expires_at > ?"# + } + Query::AUTH_DELETE_RESET_TOKENS_BY_USER => { + r#"DELETE FROM password_reset_tokens WHERE user_id = ?"# + } + Query::AUTH_INSERT_RESET_TOKEN => { + r#"INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"# + } + Query::AUTH_FIND_RESET_TOKEN => { + r#"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"# + } + 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, 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, 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::AUTH_USER_BY_SHARE_IDENTIFIER => r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#, - 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"#, - Query::USER_LIST_PADS => r#"SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#, - Query::USER_OWNS_WORKSPACE => r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#, - Query::USER_OWNS_PAD => r#"SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"#, + 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, 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::AUTH_USER_BY_SHARE_IDENTIFIER => { + r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(directory_username) = ?1 OR LOWER(external_id) = ?1) LIMIT 1"# + } + 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"# + } + Query::USER_LIST_PADS => { + r#"SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"# + } + Query::USER_OWNS_WORKSPACE => { + r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"# + } + Query::USER_OWNS_PAD => { + r#"SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"# + } Query::USER_DELETE_WORKSPACE => r#"DELETE FROM workspaces WHERE slug = ?"#, Query::USER_DELETE_PAD => r#"DELETE FROM pads WHERE slug = ?"#, - Query::USER_SET_WORKSPACE_PASSWORD => r#"UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::USER_SET_PAD_PASSWORD => r#"UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::USER_SET_WORKSPACE_PRIVACY => r#"UPDATE workspaces SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::USER_SET_PAD_PRIVACY => r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"#, - Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_ACCESS_TOKENS_INSERT => r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#, - Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT => r#"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"#, - Query::RESOURCE_PERMISSION_DELETE_USER => r#"DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#, - Query::RESOURCE_PERMISSION_INSERT => r#"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"#, - Query::SHARE_INVITATION_DELETE_USER => r#"DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#, - Query::SHARE_INVITATION_INSERT => r#"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"#, - Query::SHARE_INVITATION_DELETE_TOKEN => r#"DELETE FROM resource_share_invitations WHERE token_hash = ?"#, - Query::SHARE_INVITATION_FIND_TOKEN => r#"SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?"#, - Query::SHARE_INVITATION_ACCEPT => r#"UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"#, - Query::RESOURCE_SHARING_USERS => r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"#, - Query::RESOURCE_SHARING_LINKS => r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#, - Query::RESOURCE_SHARING_PENDING => r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#, - Query::SHARE_LINK_INSERT => r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#, - Query::SHARE_LINK_UPDATE => r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#, - Query::SHARE_LINK_REVOKE => r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#, - Query::RESOURCE_PERMISSION_BY_USER => r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"#, - Query::SHARE_LINK_PERMISSION => r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"#, - Query::Q001 => r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM workspaces WHERE slug = ?"#, + Query::USER_SET_WORKSPACE_PASSWORD => { + r#"UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::USER_SET_PAD_PASSWORD => { + r#"UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::USER_SET_WORKSPACE_PRIVACY => { + r#"UPDATE workspaces SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::USER_SET_PAD_PRIVACY => { + r#"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"# + } + Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { + r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_ACCESS_TOKENS_INSERT => { + r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"# + } + Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT => { + r#"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"# + } + Query::RESOURCE_PERMISSION_DELETE_USER => { + r#"DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# + } + Query::RESOURCE_PERMISSION_INSERT => { + r#"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"# + } + Query::SHARE_INVITATION_DELETE_USER => { + r#"DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# + } + Query::SHARE_INVITATION_INSERT => { + r#"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"# + } + Query::SHARE_INVITATION_DELETE_TOKEN => { + r#"DELETE FROM resource_share_invitations WHERE token_hash = ?"# + } + Query::SHARE_INVITATION_FIND_TOKEN => { + r#"SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?"# + } + Query::SHARE_INVITATION_ACCEPT => { + r#"UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"# + } + Query::RESOURCE_SHARING_USERS => { + r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"# + } + Query::RESOURCE_SHARING_LINKS => { + r#"SELECT token_hash, token, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"# + } + Query::RESOURCE_SHARING_PENDING => { + r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"# + } + Query::SHARE_LINK_INSERT => { + r#"INSERT INTO resource_share_links (token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"# + } + Query::SHARE_LINK_UPDATE => { + r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"# + } + Query::SHARE_LINK_REVOKE => { + r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"# + } + Query::RESOURCE_PERMISSION_BY_USER => { + r#"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"# + } + Query::SHARE_LINK_PERMISSION => { + r#"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"# + } + Query::Q001 => { + r#"SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM workspaces WHERE slug = ?"# + } Query::Q002 => r#"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"#, - Query::Q003 => r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"#, - Query::Q004 => r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"#, - Query::Q005 => r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"#, - Query::Q006 => r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, + Query::Q003 => { + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"# + } + Query::Q004 => { + r#"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"# + } + Query::Q005 => { + r#"INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"# + } + Query::Q006 => { + r#"UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } Query::Q007 => r#"UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q008 => r#"INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"#, + Query::Q008 => { + r#"INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"# + } Query::Q009 => r#"SELECT updated_at FROM notes WHERE id = ?"#, - Query::Q010 => r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"#, - Query::Q011 => r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM pads WHERE slug = ?"#, + Query::Q010 => { + r#"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"# + } + Query::Q011 => { + r#"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS INTEGER) AS is_private FROM pads WHERE slug = ?"# + } Query::Q012 => r#"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"#, - Query::Q013 => r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q014 => r#"INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"#, + Query::Q013 => { + r#"UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::Q014 => { + r#"INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"# + } Query::Q015 => r#"SELECT updated_at FROM pads WHERE id = ?"#, - Query::Q016 => r#"SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100"#, + Query::Q016 => { + r#"SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100"# + } Query::Q017 => r#"SELECT token FROM published_pages WHERE pad_id = ?"#, Query::Q018 => r#"INSERT INTO published_pages (token, pad_id) VALUES (?, ?)"#, Query::Q019 => r#"SELECT token FROM published_pages WHERE note_id = ?"#, Query::Q020 => r#"INSERT INTO published_pages (token, note_id) VALUES (?, ?)"#, - Query::Q021 => r#"SELECT pp.token, pp.pad_id, pp.note_id, CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"#, + Query::Q021 => { + r#"SELECT pp.token, pp.pad_id, pp.note_id, CASE WHEN pp.allow_task_updates THEN 1 ELSE 0 END AS allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?"# + } Query::Q022 => r#"SELECT file_token FROM pads WHERE id = ?"#, Query::Q023 => r#"UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"#, Query::Q024 => r#"SELECT file_token FROM notes WHERE id = ?"#, @@ -114,25 +283,49 @@ pub fn get(query: Query) -> &'static str { Query::Q029 => r#"SELECT content FROM revisions WHERE id = ? AND pad_id = ?"#, Query::Q030 => r#"SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?"#, Query::Q031 => r#"DELETE FROM notes WHERE id = ?"#, - Query::Q032 => r#"INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"#, - Query::Q033 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC"#, + Query::Q032 => { + r#"INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"# + } + Query::Q033 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC"# + } Query::Q034 => r#"UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?"#, - Query::Q035 => r#"INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"#, - Query::Q036 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC"#, + Query::Q035 => { + r#"INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"# + } + Query::Q036 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC"# + } Query::Q037 => r#"UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?"#, - Query::Q038 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"#, + Query::Q038 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"# + } Query::Q039 => r#"DELETE FROM note_files WHERE id = ? AND note_id = ?"#, - Query::Q046 => r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE id = ? AND pad_id = ?"#, + Query::Q046 => { + r#"SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE id = ? AND pad_id = ?"# + } Query::Q047 => r#"DELETE FROM pad_files WHERE id = ? AND pad_id = ?"#, Query::Q040 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE pad_id = ?"#, Query::Q041 => r#"UPDATE published_pages SET allow_task_updates = ? WHERE note_id = ?"#, - Query::Q042 => r#"UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, - Query::Q044 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#, - Query::Q045 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#, + Query::Q042 => { + r#"UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::Q043 => { + r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"# + } + Query::Q044 => { + r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"# + } + Query::Q045 => { + r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"# + } - Query::Q048 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#, - Query::Q049 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#, + Query::Q048 => { + r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"# + } + Query::Q049 => { + r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"# + } Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#, Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#, } diff --git a/src/row_decode.rs b/src/row_decode.rs index 045be14..d2c4b26 100644 --- a/src/row_decode.rs +++ b/src/row_decode.rs @@ -1,4 +1,13 @@ -use sqlx::{any::AnyRow, ColumnIndex, Error, Row}; +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + +use sqlx::{ColumnIndex, Error, Row, any::AnyRow}; pub fn text(row: &AnyRow, index: I) -> Result where diff --git a/src/state.rs b/src/state.rs index 3233889..50184e4 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use crate::database::Database; use serde::Serialize; use std::{ diff --git a/src/storage.rs b/src/storage.rs index 3957194..6dc9ae7 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + use std::{path::PathBuf, sync::Arc}; use aws_config::Region; diff --git a/static/css/styles.css b/static/css/styles.css index 209f8ab..f00e0bb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl + * Source-Available Code / Dual-Licensed. + * + * Free for non-commercial and evaluation use under terms of BSL/GPLv3. + * Commercial or production use requires a valid paid license. + * See LICENSE file in repository root for details. + */ + :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -2544,13 +2553,40 @@ dialog::backdrop { .home-footer__account { display: inline-flex; + min-width: 0; align-items: center; + flex-wrap: wrap; gap: 8px; } .home-footer__user { + display: grid; + min-width: 0; + max-width: min(360px, 42vw); + gap: 2px; color: var(--text); font-weight: 600; + line-height: 1.2; + text-align: left; +} + +.home-footer__user-primary, +.home-footer__user-secondary { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-footer__user-secondary { + color: var(--muted); + font-size: .68rem; + font-weight: 400; +} + +.home-footer__user-secondary[hidden] { + display: none; } .home-footer__author { @@ -2670,6 +2706,13 @@ dialog::backdrop { .home-footer__account { justify-content: center; + row-gap: 8px; + } + + .home-footer__user { + flex: 1 1 100%; + max-width: 100%; + text-align: center; } .home-footer__author { @@ -4167,7 +4210,9 @@ dialog::backdrop { display: flex; flex-direction: column; min-width: 0; - min-height: 100dvh; + height: 100vh; + height: 100dvh; + min-height: 0; overflow: hidden; } @@ -4287,10 +4332,14 @@ dialog::backdrop { } .pad-page .editor-layout { - flex: 1 1 auto; + flex: 1 1 0; width: 100%; - height: auto; - min-height: 320px; + height: 0; + min-height: 0; + } + + .pad-page .editor-panel { + height: 100%; } .pad-page .editor-toolbar { @@ -4326,32 +4375,6 @@ dialog::backdrop { background: #0d1015; } - .pad-page .view-switch button[data-view="split"] { - display: none; - } - - .pad-page .workspace, - .pad-page .workspace.view-split { - grid-template-columns: minmax(0, 1fr); - } - - .pad-page .workspace.view-edit .editor-column, - .pad-page .workspace.view-preview .preview-column { - display: grid; - opacity: 1; - transform: translateX(0); - } - - .pad-page .workspace.view-edit .preview-column, - .pad-page .workspace.view-preview .editor-column { - display: none; - opacity: 0; - } - - .pad-page .preview-column { - border-left: 0; - } - .pad-page textarea, .pad-page .preview, .pad-page .authorship-layer { @@ -4374,6 +4397,52 @@ dialog::backdrop { } } +@media (max-width: 760px) { + .pad-page .editor-toolbar { + grid-template-columns: minmax(0, 1fr); + } + + .pad-page .view-switch { + position: static; + display: grid; + width: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + box-sizing: border-box; + } + + .pad-page .view-switch button { + width: 100%; + } + + .pad-page .view-switch button[data-view="split"] { + display: none; + } + + .pad-page .workspace, + .pad-page .workspace.view-split { + grid-template-columns: minmax(0, 1fr); + } + + .pad-page .workspace.view-edit .editor-column, + .pad-page .workspace.view-preview .preview-column, + .pad-page .workspace.view-split .editor-column { + display: grid; + opacity: 1; + transform: translateX(0); + } + + .pad-page .workspace.view-edit .preview-column, + .pad-page .workspace.view-preview .editor-column, + .pad-page .workspace.view-split .preview-column { + display: none; + opacity: 0; + } + + .pad-page .preview-column { + border-left: 0; + } +} + @media (max-width: 720px) { .pad-page #save-state { display: none; @@ -5084,4 +5153,149 @@ dialog::backdrop { .share-link-row .share-link-info { grid-column: 1; } +} + +/* Upload progress toast --------------------------------------------------- */ +.toast--interactive { + width: min(420px, calc(100vw - 40px)); + box-sizing: border-box; + pointer-events: auto; +} + +.toast--upload { + display: grid; + gap: 9px; + padding: 13px 14px; +} + +.upload-toast__header, +.upload-toast__heading { + min-width: 0; +} + +.upload-toast__heading { + display: grid; + gap: 2px; +} + +.upload-toast__title { + font-size: .82rem; + line-height: 1.25; +} + +.upload-toast__filename { + overflow: hidden; + color: var(--muted); + font-size: .73rem; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-toast__progress { + position: relative; + height: 7px; + overflow: hidden; + border-radius: 999px; + background: #0b0e13; +} + +.upload-toast__progress>span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width .14s linear; +} + +.upload-toast__progress.is-complete>span { + background: var(--success, #42b883); +} + +.upload-toast__progress.is-error>span { + background: var(--danger); +} + +.upload-toast__progress.is-indeterminate>span { + animation: upload-toast-indeterminate 1.2s ease-in-out infinite; +} + +@keyframes upload-toast-indeterminate { + 0% { + transform: translateX(-120%); + } + + 50% { + transform: translateX(180%); + } + + 100% { + transform: translateX(420%); + } +} + +.upload-toast__meta { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted-2); + font-size: .7rem; + font-variant-numeric: tabular-nums; +} + +.upload-toast__meta span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.upload-toast__meta span:last-child { + flex: 0 0 auto; +} + +.upload-toast__error { + margin: 0; + color: #ffb6bd; + font-size: .73rem; + line-height: 1.4; +} + +.upload-toast__actions { + display: flex; + justify-content: flex-end; + gap: 7px; +} + +.upload-toast__actions[hidden] { + display: none; +} + +.upload-toast__actions button { + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--border-strong); + border-radius: 7px; + background: var(--surface-2); + color: var(--text); + font-size: .72rem; + cursor: pointer; +} + +.upload-toast__actions button:hover { + background: var(--surface-3); +} + +.upload-toast__retry { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border-strong)) !important; +} + +@media (max-width: 520px) { + .pad-page .toast--interactive { + right: 10px; + left: 10px; + width: auto; + } } \ No newline at end of file diff --git a/static/home.html b/static/home.html index 187573b..03e82b1 100644 --- a/static/home.html +++ b/static/home.html @@ -78,7 +78,10 @@ nickname