license and some functions

This commit is contained in:
Mateusz Gruszczyński
2026-07-29 14:03:46 +02:00
parent 0655cfe48c
commit ef8dfc67c1
56 changed files with 2586 additions and 547 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.1.25"
version = "0.1.27"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -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"
+11
View File
@@ -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<at>>linuxiarz.pl
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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()))
}
+9
View File
@@ -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,
+10 -2
View File
@@ -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<SharedState>,
@@ -485,4 +494,3 @@ fn sanitize_filename(value: &str) -> String {
clean.chars().take(160).collect()
}
}
+96 -18
View File
@@ -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<Json<serde_json::Value>, 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<SharedState>, headers: HeaderMap,
State(state): State<SharedState>,
headers: HeaderMap,
Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, 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"))
}
+99 -27
View File
@@ -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<SharedState>, headers: HeaderMap, Path(slug): Path<String>,
State(state): State<SharedState>,
headers: HeaderMap,
Path(slug): Path<String>,
Json(payload): Json<EditorSettingsRequest>,
) -> Result<Json<serde_json::Value>, 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<String> = 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<String> = 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<String> = 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<String> = 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<String, ApiE
}
Err(ApiError::internal("Failed to create a unique address"))
}
+18 -4
View File
@@ -1,6 +1,14 @@
/*
* 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 pages;
use pages::*;
use axum::{
Router,
extract::{DefaultBodyLimit, Request},
@@ -9,6 +17,7 @@ use axum::{
response::Response,
routing::{get, post},
};
use pages::*;
use tower::{ServiceBuilder, service_fn};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
@@ -118,7 +127,10 @@ pub fn router(
"/api/pads/{slug}/editor-color",
get(api::pad_editor_color).post(api::set_pad_editor_color),
)
.route("/api/pads/{slug}/editor-settings", post(api::set_pad_editor_settings))
.route(
"/api/pads/{slug}/editor-settings",
post(api::set_pad_editor_settings),
)
.route("/api/pads/{slug}/publish", post(api::publish_pad_page))
.route("/api/pads/{slug}/restore", post(api::pad_restore))
.route(
@@ -147,7 +159,10 @@ pub fn router(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color",
get(api::note_editor_color).post(api::set_note_editor_color),
)
.route("/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-settings", post(api::set_note_editor_settings))
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-settings",
post(api::set_note_editor_settings),
)
.route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}/publish",
post(api::publish_note_page),
@@ -233,4 +248,3 @@ async fn add_non_asset_security_headers(request: Request, next: Next) -> Respons
response
}
+37 -5
View File
@@ -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<SharedState>, Path(slug): Path<Strin
}
}
pub(super) async fn public_page(State(state): State<SharedState>, Path(token): Path<String>) -> Response {
pub(super) async fn public_page(
State(state): State<SharedState>,
Path(token): Path<String>,
) -> 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, &note_slug).await {
Ok(Some(note)) => {
let html = include_str!("../../static/note.html")
.replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { &note.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 {
&note.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,
+9
View File
@@ -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},
+12 -1
View File
@@ -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<User, AuthError> {
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,
+9
View File
@@ -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;
+51 -31
View File
@@ -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::<String>()
})
.and_then(|value| validate_nickname(&value).ok())
}
pub async fn me(
@@ -842,11 +865,14 @@ pub async fn confirm_account_action(
) -> Result<Json<serde_json::Value>, 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<String> = 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<String> =
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,
+58 -22
View File
@@ -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<Self, Box<dyn std::error::Error>> {
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(())
}
}
+13 -1
View File
@@ -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());
}
+143 -37
View File
@@ -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<Self, Box<dyn std::error::Error>> {
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<String> {
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<String, Box<dyn std::error::Error>> {
self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into())
pub(super) fn required(
&self,
name: &str,
context: &str,
) -> Result<String, Box<dyn std::error::Error>> {
self.optional(name)
.ok_or_else(|| format!("{name} is required when {context}").into())
}
pub(super) fn bool(&self, name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
pub(super) fn log_level(
&self,
name: &str,
default: &str,
) -> Result<String, Box<dyn std::error::Error>> {
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<i64, Box<dyn std::error::Error>> {
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<i64, Box<dyn std::error::Error>> {
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<u64, Box<dyn std::error::Error>> {
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<u64, Box<dyn std::error::Error>> {
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<u64, Box<dyn std::error::Error>> {
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<u64, Box<dyn std::error::Error>> {
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<HashMap<String, String>, 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<HashMap<String, String>, 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<HashMap<String, String>, 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<String, String> {
}
Ok(value.to_owned())
}
+9
View File
@@ -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};
+9 -1
View File
@@ -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<Self, sqlx::Error> {
let attached: i64 = row.try_get("is_attached")?;
+9 -1
View File
@@ -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<Self, sqlx::Error> {
Ok(Self {
+151 -32
View File
@@ -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<String, sqlx:
}
pub async fn pad_public_page_enabled(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
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<bool, sqlx::Error> {
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<bool, sqlx::Error> {
pub async fn pad_public_page_unprotected(
pool: &Database,
pad_id: i64,
) -> Result<bool, sqlx::Error> {
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<bool, sqlx::Error> {
pub async fn note_public_page_unprotected(
pool: &Database,
note_id: i64,
) -> Result<bool, sqlx::Error> {
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<String, sq
.await
}
impl<'r> sqlx::FromRow<'r, AnyRow> for PublishedPageRow {
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
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<bool, sqlx::Error> {
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<bool, sqlx::Error> {
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(())
}
+46 -11
View File
@@ -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<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
@@ -33,10 +41,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
#[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<Cli, Box<dyn std::error::Error>> {
}
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()),
}
}
+27 -5
View File
@@ -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());
+294 -95
View File
@@ -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 = ?"#,
}
+279 -90
View File
@@ -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"#,
+285 -92
View File
@@ -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 = ?"#,
}
+10 -1
View File
@@ -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<I>(row: &AnyRow, index: I) -> Result<String, Error>
where
+9
View File
@@ -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::{
+9
View File
@@ -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;
+244 -30
View File
@@ -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;
}
}
+4 -1
View File
@@ -78,7 +78,10 @@
nickname</button>
</div>
<div id="footer-account-user" class="home-footer__account" hidden>
<span id="footer-user-label" class="home-footer__user"></span>
<span id="footer-user-label" class="home-footer__user">
<span class="home-footer__user-primary" data-account-primary></span>
<small class="home-footer__user-secondary" data-account-secondary hidden></small>
</span>
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
<button id="footer-profile" class="footer-action" type="button">Profile</button>
<button id="footer-logout" class="footer-action" type="button">Log out</button>
+183 -11
View File
@@ -1,5 +1,31 @@
/*
* 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.
*/
import { logDebug, logError, logWarn } from "@rustpad/logger";
const DEFAULT_ERRORS = {
400: "Invalid request.",
401: "Authentication required.",
403: "Access denied.",
404: "The requested resource was not found.",
405: "This operation is not allowed.",
408: "The request timed out. Try again.",
409: "The requested change conflicts with existing data.",
413: "The selected file exceeds the allowed upload limit.",
425: "The request was sent too early. Try again.",
429: "Too many requests. Try again later.",
500: "Server error. Try again later.",
502: "The server returned an invalid response. Try again.",
503: "Service temporarily unavailable.",
504: "The server took too long to respond. Try again.",
};
function formatBytes(bytes) {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes % (1024 * 1024) ? 1 : 0)} MB`;
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} KB`;
@@ -42,15 +68,33 @@ function validateUploadSize(body) {
}
}
function requestHeaders(options, body) {
const headers = new Headers(options.headers || {});
const authToken = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
if (authToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${authToken}`);
if (!(body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
return headers;
}
function requestError(status, data = {}) {
const error = new Error(data.error || DEFAULT_ERRORS[status] || `Request failed (${status}).`);
error.status = status;
return error;
}
function formDataFileSize(body) {
if (!(body instanceof FormData)) return 0;
let size = 0;
for (const value of body.values()) if (value instanceof File) size += value.size;
return size;
}
export async function api(path, options = {}) {
validateUploadSize(options.body);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000);
try {
const headers = new Headers(options.headers || {});
const authToken = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token");
if (authToken && !headers.has("authorization")) headers.set("authorization", `Bearer ${authToken}`);
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
const headers = requestHeaders(options, options.body);
const started = performance.now();
logDebug("api.request", { method: options.method || "GET", path });
const response = await fetch(path, { ...options, headers, signal: controller.signal });
@@ -60,19 +104,147 @@ export async function api(path, options = {}) {
const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
if (!response.ok) {
if (response.status === 401) await clearSessionIfInvalid();
const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The selected file exceeds the allowed upload limit.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
const requestError = new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
requestError.status = response.status;
logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: requestError.message });
throw requestError;
const error = requestError(response.status, data);
logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: error.message });
throw error;
}
return data;
} catch (error) {
if (error.name === "AbortError") { logWarn("api.timeout", { method: options.method || "GET", path }); throw new Error("Timed out"); }
if (error.name === "AbortError") {
logWarn("api.timeout", { method: options.method || "GET", path });
throw new Error("Timed out");
}
logError("api.network_error", error, { method: options.method || "GET", path });
if (options.body instanceof FormData && error instanceof TypeError) {
throw new Error("Upload failed before the server returned a response. The file may exceed the server or proxy upload limit.");
}
throw error;
} finally { clearTimeout(timeout); }
} finally {
clearTimeout(timeout);
}
}
export function uploadWithProgress(path, options = {}) {
validateUploadSize(options.body);
const method = options.method || "POST";
const fallbackTotal = formDataFileSize(options.body);
const stallTimeoutMs = Number(options.stallTimeoutMs) > 0 ? Number(options.stallTimeoutMs) : 90000;
const responseTimeoutMs = Number(options.responseTimeoutMs) > 0 ? Number(options.responseTimeoutMs) : 120000;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const headers = requestHeaders(options, options.body);
const started = performance.now();
let lastAt = started;
let lastLoaded = 0;
let speed = 0;
let stallTimer = null;
let responseTimer = null;
let stalled = false;
let responseTimedOut = false;
let externallyAborted = false;
const clearStallTimer = () => {
clearTimeout(stallTimer);
stallTimer = null;
};
const armStallTimer = () => {
clearStallTimer();
stallTimer = setTimeout(() => {
stalled = true;
xhr.abort();
}, stallTimeoutMs);
};
const cleanup = () => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = null;
options.signal?.removeEventListener("abort", abortFromSignal);
};
const abortFromSignal = () => {
externallyAborted = true;
xhr.abort();
};
const fail = error => {
cleanup();
reject(error);
};
xhr.open(method, path, true);
xhr.responseType = "text";
for (const [name, value] of headers.entries()) xhr.setRequestHeader(name, value);
xhr.upload.addEventListener("loadstart", () => {
armStallTimer();
options.onProgress?.({ loaded: 0, total: fallbackTotal, speed: 0, percent: 0 });
});
xhr.upload.addEventListener("progress", event => {
const now = performance.now();
const elapsedSeconds = Math.max((now - lastAt) / 1000, 0.001);
const deltaBytes = Math.max(0, event.loaded - lastLoaded);
const instantaneousSpeed = deltaBytes / elapsedSeconds;
speed = speed > 0 ? speed * 0.72 + instantaneousSpeed * 0.28 : instantaneousSpeed;
lastAt = now;
lastLoaded = event.loaded;
const total = event.lengthComputable ? event.total : fallbackTotal;
const percent = total > 0 ? Math.min(100, (event.loaded / total) * 100) : null;
options.onProgress?.({ loaded: event.loaded, total, speed, percent });
if (total > 0 && event.loaded >= total) clearStallTimer();
else armStallTimer();
});
xhr.upload.addEventListener("load", event => {
clearStallTimer();
clearTimeout(responseTimer);
responseTimer = setTimeout(() => {
responseTimedOut = true;
xhr.abort();
}, responseTimeoutMs);
const total = event.lengthComputable ? event.total : fallbackTotal;
options.onProgress?.({ loaded: total || lastLoaded, total, speed, percent: total > 0 ? 100 : null, phase: "processing" });
});
xhr.addEventListener("load", () => {
cleanup();
const durationMs = Math.round(performance.now() - started);
logDebug("api.response", { method, path, status: xhr.status, durationMs });
let data = {};
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : {}; } catch { }
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data);
return;
}
if (xhr.status === 401) void clearSessionIfInvalid();
const error = requestError(xhr.status, data);
logWarn("api.failed", { method, path, status: xhr.status, message: error.message });
reject(error);
});
xhr.addEventListener("error", () => {
const error = new Error("Upload failed before the server returned a response. Check the connection and try again.");
logError("api.network_error", error, { method, path });
fail(error);
});
xhr.addEventListener("abort", () => {
const error = new Error(stalled
? "Upload stopped making progress. Check the connection and try again."
: responseTimedOut ? "The file was sent, but the server did not finish processing it. Try again."
: externallyAborted ? "Upload cancelled." : "Upload interrupted. Try again.");
error.name = externallyAborted ? "AbortError" : "UploadError";
logWarn(stalled ? "api.upload_stalled" : responseTimedOut ? "api.upload_response_timeout" : "api.upload_aborted", { method, path });
fail(error);
});
if (options.signal) {
if (options.signal.aborted) {
externallyAborted = true;
const error = new Error("Upload cancelled.");
error.name = "AbortError";
fail(error);
return;
}
options.signal.addEventListener("abort", abortFromSignal, { once: true });
}
logDebug("api.request", { method, path });
xhr.send(options.body ?? null);
});
}
+9
View File
@@ -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.
*/
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askConfirm, askInput, showMessage } from "@rustpad/modal";
+9
View File
@@ -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.
*/
const VERSION = 2;
const OWNER_COLOR_SEPARATOR = "\u001f";
+9
View File
@@ -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.
*/
export async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
+9
View File
@@ -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.
*/
function selection(editor) {
return { start: editor.selectionStart, end: editor.selectionEnd };
}
File diff suppressed because one or more lines are too long
+9
View File
@@ -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.
*/
import { EMOJI_GROUPS } from "@rustpad/emoji-data";
const RECENTS_KEY = "rustpad:recent-emojis";
+39 -1
View File
@@ -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.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
@@ -89,6 +98,8 @@ const identityDialog = document.querySelector("#identity-dialog");
const guestAccount = document.querySelector("#footer-account-guest");
const userAccount = document.querySelector("#footer-account-user");
const userLabel = document.querySelector("#footer-user-label");
const userLabelPrimary = userLabel?.querySelector("[data-account-primary]");
const userLabelSecondary = userLabel?.querySelector("[data-account-secondary]");
const registerLink = document.querySelector("#footer-register");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
const resourcesDialog = document.querySelector("#resources-dialog");
@@ -251,7 +262,34 @@ function renderAccount(session) {
currentSession = session;
guestAccount.hidden = Boolean(session);
userAccount.hidden = !session;
if (session) userLabel.textContent = `Signed in as ${session.nickname}`;
if (session) {
const nickname = String(session.nickname || "").trim();
let primaryIdentity = String(session.email || nickname).trim();
let secondaryIdentity = nickname;
if (session.directory_managed) {
const organization = String(session.directory_organization || "").trim();
const displayName = String(session.directory_display_name || session.email || nickname).trim();
primaryIdentity = organization ? `${organization}\\${displayName}` : displayName;
const suggestedNickname = String(session.suggested_nickname || "").trim();
secondaryIdentity = nickname && suggestedNickname
&& nickname.toLowerCase() !== suggestedNickname.toLowerCase()
? nickname
: "";
}
userLabelPrimary.textContent = `Signed as ${primaryIdentity}`;
userLabelPrimary.title = `Signed as ${primaryIdentity}`;
userLabelSecondary.textContent = secondaryIdentity;
userLabelSecondary.title = secondaryIdentity;
userLabelSecondary.hidden = !secondaryIdentity;
} else if (userLabelPrimary && userLabelSecondary) {
userLabelPrimary.textContent = "";
userLabelPrimary.removeAttribute("title");
userLabelSecondary.textContent = "";
userLabelSecondary.removeAttribute("title");
userLabelSecondary.hidden = true;
}
registerLink.hidden = !registrationEnabled;
}
+9
View File
@@ -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.
*/
function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); }
function stem(name) { return name.replace(/\.[^.]+$/, "") || "image"; }
+9
View File
@@ -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.
*/
const PREFIX = "[RustPad]";
const LEVELS = Object.freeze({ off: 0, error: 1, warn: 2, info: 3, debug: 4 });
+9
View File
@@ -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.
*/
import { EMOJI_SHORTCODES } from "@rustpad/emoji-data";
function escapeHtml(value) {
+9
View File
@@ -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.
*/
function ensureDialog() {
let dialog = document.querySelector("#system-dialog");
if (dialog) return dialog;
+10 -1
View File
@@ -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.
*/
import { api } from "@rustpad/api";
import { askConfirm } from "@rustpad/modal";
import { NoteSocket, PadSocket } from "@rustpad/socket";
@@ -40,7 +49,7 @@ export function createPadAdapter() {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, revision_id: revisionId }),
}),
configureView() {},
configureView() { },
deleteNote: null,
};
}
+15 -4
View File
@@ -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.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
@@ -22,6 +31,7 @@ export function startNoteEditor(adapter) {
const notePreferenceKey = name => `rustpad:${name}:${adapter.access.kind}:${adapter.access.key}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true;
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
@@ -439,7 +449,7 @@ export function startNoteEditor(adapter) {
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function activeView() {
return compactLayoutQuery.matches ? compactView : uiState.view;
return singlePaneQuery.matches ? compactView : uiState.view;
}
function applyUi({ write = false, replace = false } = {}) {
@@ -448,7 +458,7 @@ export function startNoteEditor(adapter) {
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
document.querySelectorAll("[data-view]").forEach(button => {
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
@@ -555,7 +565,7 @@ export function startNoteEditor(adapter) {
}
document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => {
if (compactLayoutQuery.matches) {
if (singlePaneQuery.matches) {
compactView = button.dataset.view === "preview" ? "preview" : "edit";
applyUi();
return;
@@ -563,6 +573,7 @@ export function startNoteEditor(adapter) {
uiState = { ...uiState, view: button.dataset.view };
applyUi({ write: true });
}));
singlePaneQuery.addEventListener("change", () => applyUi());
compactLayoutQuery.addEventListener("change", () => applyUi());
const headerMenuToggle = document.querySelector("#header-menu-toggle");
const headerActions = document.querySelector("#header-actions");
@@ -621,7 +632,7 @@ export function startNoteEditor(adapter) {
mobileBubble.style.right = "auto";
mobileBubble.style.bottom = "auto";
}
try { placeMobileBubble(JSON.parse(localStorage.getItem(bubblePositionKey) || "null")); } catch {}
try { placeMobileBubble(JSON.parse(localStorage.getItem(bubblePositionKey) || "null")); } catch { }
mobileBubbleDrag?.addEventListener("pointerdown", event => {
if (!mobileBubble || !compactBubbleQuery.matches) return;
event.preventDefault();
+50 -14
View File
@@ -1,9 +1,19 @@
import { api } from "@rustpad/api";
/*
* 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.
*/
import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { prepareImageFile } from "@rustpad/image-upload";
import { askConfirm } from "@rustpad/modal";
import { getAuthToken } from "@rustpad/session";
import { safeAppUrl } from "@rustpad/security";
import { createUploadToast } from "@rustpad/toast";
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, character => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[character]));
@@ -57,22 +67,48 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, to
let file = event.target.files[0];
if (!file) return;
if (file.type.startsWith("image/")) {
file = await prepareImageFile(file);
try {
file = await prepareImageFile(file);
} catch (error) {
toast(error.message);
input.value = "";
return;
}
if (!file) { input.value = ""; return; }
}
const form = new FormData();
form.append("access_token", getAccessToken() || "");
form.append("file", file);
try {
const result = await api(endpoints.upload, { method: "POST", body: form, headers: {} });
const fileUrl = safeAppUrl(result.url);
const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`;
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
toast("File uploaded");
await loadFiles();
} catch (error) { toast(error.message); }
const uploadToast = createUploadToast(file.name);
let completed = false;
const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
const uploadFile = async () => {
uploadToast.start();
const form = new FormData();
form.append("access_token", getAccessToken() || "");
form.append("file", file);
try {
const result = await uploadWithProgress(endpoints.upload, {
method: "POST",
body: form,
headers: {},
onProgress: progress => uploadToast.update(progress),
});
if (completed) return;
completed = true;
const fileUrl = safeAppUrl(result.url);
const text = file.type.startsWith("image/") ? `![${file.name}](${fileUrl})` : `[${file.name}](${fileUrl})`;
editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
uploadToast.success();
await loadFiles();
} catch (error) {
const retryable = !error.status || retryableStatuses.has(error.status);
uploadToast.fail(error.message, { retryable, onRetry: uploadFile });
}
};
input.value = "";
await uploadFile();
});
document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true }));
+9
View File
@@ -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.
*/
import { createWorkspaceNoteAdapter } from "@rustpad/note-api";
import { startNoteEditor } from "@rustpad/note-editor";
+9
View File
@@ -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.
*/
import { createPadAdapter } from "@rustpad/note-api";
import { startNoteEditor } from "@rustpad/note-editor";
+9
View File
@@ -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.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
+9
View File
@@ -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.
*/
export function safeAppUrl(value, fallback = "/") {
try {
const url = new URL(String(value || ""), location.origin);
+9
View File
@@ -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.
*/
export function accessTokenKey(resourceKind, resourceSlug) { return `rustpad:access:${resourceKind}:${resourceSlug}`; }
export function getAccessToken(resourceKind, resourceSlug) {
return localStorage.getItem(accessTokenKey(resourceKind, resourceSlug)) || "";
+9
View File
@@ -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.
*/
import { logError, logInfo, logWarn } from "@rustpad/logger";
class RoomSocket {
+155 -5
View File
@@ -1,10 +1,160 @@
/*
* 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.
*/
let hideTimer;
export function toast(message, options = {}) {
const element = document.querySelector(options.selector || "#toast");
if (!element) return;
element.textContent = String(message ?? "");
function toastElement(selector = "#toast") {
return document.querySelector(selector);
}
function show(element, duration = null) {
element.classList.add("visible");
clearTimeout(hideTimer);
hideTimer = setTimeout(() => element.classList.remove("visible"), options.duration ?? 1800);
if (duration != null) hideTimer = setTimeout(() => element.classList.remove("visible"), duration);
}
function reset(element) {
element.className = "toast";
element.removeAttribute("aria-busy");
element.removeAttribute("aria-label");
}
function formatBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes >= 10240 ? 0 : 1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 1 : 2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function toast(message, options = {}) {
const element = toastElement(options.selector);
if (!element) return;
reset(element);
element.textContent = String(message ?? "");
show(element, options.duration ?? 1800);
}
export function createUploadToast(filename, options = {}) {
const element = toastElement(options.selector);
if (!element) {
return { start() { }, update() { }, fail() { }, success() { }, dismiss() { } };
}
reset(element);
element.classList.add("toast--upload", "toast--interactive");
element.setAttribute("aria-live", "polite");
element.innerHTML = `
<div class="upload-toast__header">
<div class="upload-toast__heading">
<strong class="upload-toast__title">Uploading file</strong>
<span class="upload-toast__filename"></span>
</div>
</div>
<div class="upload-toast__progress" role="progressbar" aria-label="File upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span></span></div>
<div class="upload-toast__meta"><span data-upload-amount>Preparing</span><span data-upload-speed></span></div>
<p class="upload-toast__error" data-upload-error hidden></p>
<div class="upload-toast__actions" data-upload-actions hidden>
<button type="button" class="upload-toast__retry">Retry</button>
<button type="button" class="upload-toast__dismiss">Dismiss</button>
</div>`;
const title = element.querySelector(".upload-toast__title");
const filenameElement = element.querySelector(".upload-toast__filename");
const progress = element.querySelector(".upload-toast__progress");
const progressFill = progress.querySelector("span");
const amount = element.querySelector("[data-upload-amount]");
const speedElement = element.querySelector("[data-upload-speed]");
const errorElement = element.querySelector("[data-upload-error]");
const actions = element.querySelector("[data-upload-actions]");
const retryButton = element.querySelector(".upload-toast__retry");
const dismissButton = element.querySelector(".upload-toast__dismiss");
let retryHandler = null;
filenameElement.textContent = String(filename || "file");
function dismiss() {
clearTimeout(hideTimer);
element.classList.remove("visible");
}
function start() {
reset(element);
element.classList.add("toast--upload", "toast--interactive", "visible");
element.setAttribute("aria-live", "polite");
element.setAttribute("aria-busy", "true");
title.textContent = "Uploading file";
filenameElement.textContent = String(filename || "file");
progress.classList.remove("is-error", "is-complete");
progress.setAttribute("aria-valuenow", "0");
progressFill.style.width = "0%";
amount.textContent = "Preparing…";
speedElement.textContent = "—";
errorElement.hidden = true;
errorElement.textContent = "";
actions.hidden = true;
retryButton.disabled = false;
clearTimeout(hideTimer);
}
function update({ loaded = 0, total = 0, speed = 0, percent = null, phase = "uploading" } = {}) {
const normalizedPercent = Number.isFinite(percent)
? Math.max(0, Math.min(100, percent))
: total > 0 ? Math.max(0, Math.min(100, (loaded / total) * 100)) : null;
if (normalizedPercent != null) {
progress.setAttribute("aria-valuenow", String(Math.round(normalizedPercent)));
progressFill.style.width = `${normalizedPercent}%`;
} else {
progress.removeAttribute("aria-valuenow");
progressFill.style.width = "24%";
progress.classList.add("is-indeterminate");
}
amount.textContent = total > 0
? `${Math.round(normalizedPercent || 0)}% · ${formatBytes(loaded)} / ${formatBytes(total)}`
: formatBytes(loaded);
speedElement.textContent = phase === "processing" ? "Processing…" : speed > 0 ? `${formatBytes(speed)}/s` : "Starting…";
}
function fail(message, { retryable = true, onRetry = null } = {}) {
element.removeAttribute("aria-busy");
title.textContent = "Upload failed";
progress.classList.remove("is-indeterminate", "is-complete");
progress.classList.add("is-error");
errorElement.textContent = String(message || "Upload failed.");
errorElement.hidden = false;
actions.hidden = false;
retryButton.hidden = !retryable;
retryHandler = typeof onRetry === "function" ? onRetry : null;
show(element);
}
function success(message = "File uploaded") {
element.removeAttribute("aria-busy");
title.textContent = message;
progress.classList.remove("is-error", "is-indeterminate");
progress.classList.add("is-complete");
progress.setAttribute("aria-valuenow", "100");
progressFill.style.width = "100%";
amount.textContent = "100%";
speedElement.textContent = "Complete";
errorElement.hidden = true;
actions.hidden = true;
show(element, 1800);
}
retryButton.addEventListener("click", async () => {
if (!retryHandler) return;
retryButton.disabled = true;
await retryHandler();
});
dismissButton.addEventListener("click", dismiss);
start();
return { start, update, fail, success, dismiss };
}
+9
View File
@@ -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.
*/
const VIEWS = new Set(["edit", "split", "preview"]);
const MODES = new Set(["markdown", "text"]);
+9
View File
@@ -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.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();