diff --git a/.env.example b/.env.example index 8979e7d..c03a131 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,40 @@ -# Serwer deweloperski +# Application APP_HOST=0.0.0.0 APP_PORT=3000 -# SQLite +# Port exposed by Docker Compose +RUSTPAD_PORT=8200 + +# Database + +# SQLite — default DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc + +# PostgreSQL +# DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad + +# MySQL +# DATABASE_URL=mysql://rustpad:rustpad@mysql:3306/rustpad + DATABASE_MAX_CONNECTIONS=8 -# Assety i logowanie +# Static assets STATIC_DIR=static ASSET_VERSION=0.0.1 + +# Logging RUST_LOG=rustpad=debug,tower_http=info -UPLOAD_MAX_SIZE_MB=20 \ No newline at end of file +# Maximum upload size +UPLOAD_MAX_SIZE_MB=20 + +# Optional PostgreSQL container configuration +POSTGRES_DB=rustpad +POSTGRES_USER=rustpad +POSTGRES_PASSWORD=rustpad + +# Optional MySQL container configuration +MYSQL_DATABASE=rustpad +MYSQL_USER=rustpad +MYSQL_PASSWORD=rustpad +MYSQL_ROOT_PASSWORD=rustpad_root \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6caa88d..2befdee 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ rustpad.db-wal .env.docker *.log data/db/.db* -data/files/* \ No newline at end of file +data/db/*/* +data/files/* +*.zip \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index e3c58e3..68825ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "rustpad" version = "0.0.1-dev" edition = "2024" rust-version = "1.85" -description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite" +description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" license = "MIT" [dependencies] @@ -17,7 +17,7 @@ rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" slug = "0.1" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "migrate"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" diff --git a/Dockerfile b/Dockerfile index 283f553..8b49134 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update \ WORKDIR /app COPY --from=builder /app/target/release/rustpad /usr/local/bin/rustpad COPY --from=builder /app/static ./static +COPY --from=builder /app/migrations ./migrations USER rustpad ENV APP_HOST=0.0.0.0 \ diff --git a/README.md b/README.md index dd5b4b0..a01fe3f 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,27 @@ Use the **Page** button in the editor. RustPad creates a permanent public `/s/ Result { + sqlx::any::install_default_drivers(); + let kind = DatabaseKind::from_url(url)?; + let pool = AnyPoolOptions::new() + .max_connections(max_connections) + .connect(url) + .await?; + if kind == DatabaseKind::Sqlite { + sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await?; + sqlx::query("PRAGMA journal_mode = WAL").execute(&pool).await?; + sqlx::query("PRAGMA busy_timeout = 5000").execute(&pool).await?; + } + Ok(Self { pool, kind }) + } + + pub fn pool(&self) -> &AnyPool { &self.pool } + pub fn kind(&self) -> DatabaseKind { self.kind } +} + +impl DatabaseKind { + fn from_url(url: &str) -> Result { + if url.starts_with("sqlite:") { Ok(Self::Sqlite) } + else if url.starts_with("postgres:") || url.starts_with("postgresql:") { Ok(Self::Postgres) } + else if url.starts_with("mysql:") { Ok(Self::MySql) } + else { Err(sqlx::Error::Configuration("DATABASE_URL must use sqlite://, postgres:// or mysql://".into())) } + } +} diff --git a/src/db.rs b/src/db.rs index 35002f0..3e9bdb0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -4,7 +4,23 @@ use argon2::{ use chrono::{DateTime, Utc}; use rand_core::{OsRng, RngCore}; use serde::Serialize; -use sqlx::{FromRow, SqlitePool}; +use sqlx::FromRow; +use crate::{database::{Database, DatabaseKind}, queries}; +use sqlx::{Any, Transaction}; + + +async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result { + let query = match kind { + DatabaseKind::Sqlite => "SELECT last_insert_rowid()", + DatabaseKind::MySql => "SELECT LAST_INSERT_ID()", + DatabaseKind::Postgres => match table { + "note_revisions" => "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))", + "revisions" => "SELECT currval(pg_get_serial_sequence('revisions', 'id'))", + _ => unreachable!("unsupported identity table"), + }, + }; + sqlx::query_scalar(query).fetch_one(&mut **tx).await +} #[derive(Debug, Clone, FromRow)] pub struct Workspace { @@ -39,36 +55,30 @@ pub struct Revision { pub owner_map: String, } -pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, Workspace>( - "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?", - ) +pub async fn find_workspace(pool: &Database, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) .bind(slug) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } pub async fn create_workspace( - pool: &SqlitePool, + pool: &Database, slug: &str, title: &str, password: Option<&str>, ) -> Result { let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); - let result = sqlx::query( - "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)", - ) + sqlx::query(queries::get(pool.kind(), queries::Q002)) .bind(slug) .bind(title) .bind(password_hash) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_as::<_, Workspace>( - "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE id = ?", - ) - .bind(result.last_insert_rowid()) - .fetch_one(pool) + sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) + .bind(slug) + .fetch_one(pool.pool()) .await } @@ -87,92 +97,84 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) } } -pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC", - ) +pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003)) .bind(workspace_id) - .fetch_all(pool) + .fetch_all(pool.pool()) .await } pub async fn find_note( - pool: &SqlitePool, + pool: &Database, workspace_id: i64, slug: &str, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?", - ) + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) .bind(workspace_id) .bind(slug) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } pub async fn create_note( - pool: &SqlitePool, + pool: &Database, workspace_id: i64, slug: &str, title: &str, ) -> Result { - let result = sqlx::query( - "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)", - ) + sqlx::query(queries::get(pool.kind(), queries::Q005)) .bind(workspace_id) .bind(slug) .bind(title) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE id = ?", - ) - .bind(result.last_insert_rowid()) - .fetch_one(pool) + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) + .bind(workspace_id) + .bind(slug) + .fetch_one(pool.pool()) .await } pub async fn save_revision( - pool: &SqlitePool, + pool: &Database, note_id: i64, workspace_id: i64, content: &str, author: Option<&str>, owner_map: &str, ) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.begin().await?; - sqlx::query("UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q006)) .bind(content) .bind(owner_map) .bind(note_id) .execute(&mut *tx) .await?; - sqlx::query("UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?") + sqlx::query(queries::get(pool.kind(), queries::Q007)) .bind(workspace_id) .execute(&mut *tx) .await?; - let result = sqlx::query("INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q008)) .bind(note_id) .bind(content) .bind(author) .bind(owner_map) .execute(&mut *tx) .await?; - let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?") + let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009)) .bind(note_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok((result.last_insert_rowid(), updated_at)) + Ok((revision_id, updated_at)) } -pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Revision>( - "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100", - ) +pub async fn list_revisions(pool: &Database, note_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010)) .bind(note_id) - .fetch_all(pool) + .fetch_all(pool.pool()) .await } @@ -213,36 +215,30 @@ pub struct Pad { pub owner_map: String, } -pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, Pad>( - "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?", - ) +pub async fn find_pad(pool: &Database, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) .bind(slug) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } pub async fn create_pad( - pool: &SqlitePool, + pool: &Database, slug: &str, title: &str, password: Option<&str>, ) -> Result { let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); - let result = sqlx::query( - "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)", - ) + sqlx::query(queries::get(pool.kind(), queries::Q012)) .bind(slug) .bind(title) .bind(password_hash) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_as::<_, Pad>( - "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE id = ?", - ) - .bind(result.last_insert_rowid()) - .fetch_one(pool) + sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) + .bind(slug) + .fetch_one(pool.pool()) .await } @@ -262,43 +258,42 @@ pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { } pub async fn save_pad_revision( - pool: &SqlitePool, + pool: &Database, pad_id: i64, content: &str, author: Option<&str>, owner_map: &str, ) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.begin().await?; - sqlx::query("UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q013)) .bind(content) .bind(owner_map) .bind(pad_id) .execute(&mut *tx) .await?; - let result = sqlx::query("INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q014)) .bind(pad_id) .bind(content) .bind(author) .bind(owner_map) .execute(&mut *tx) .await?; - let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?") + let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015)) .bind(pad_id) .fetch_one(&mut *tx) .await?; tx.commit().await?; - Ok((result.last_insert_rowid(), updated_at)) + Ok((revision_id, updated_at)) } pub async fn list_pad_revisions( - pool: &SqlitePool, + pool: &Database, pad_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, Revision>( - "SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100", - ) + sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) .bind(pad_id) - .fetch_all(pool) + .fetch_all(pool.pool()) .await } @@ -310,90 +305,88 @@ pub struct PublishedPage { pub updated_at: String, } -pub async fn publish_pad(pool: &SqlitePool, pad_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE pad_id = ?") +pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) .bind(pad_id) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(token); } let token = random_suffix(18); - sqlx::query("INSERT INTO published_pages (token, pad_id) VALUES (?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q018)) .bind(&token) .bind(pad_id) - .execute(pool) + .execute(pool.pool()) .await?; Ok(token) } -pub async fn publish_note(pool: &SqlitePool, note_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE note_id = ?") +pub async fn publish_note(pool: &Database, note_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019)) .bind(note_id) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(token); } let token = random_suffix(18); - sqlx::query("INSERT INTO published_pages (token, note_id) VALUES (?, ?)") + sqlx::query(queries::get(pool.kind(), queries::Q020)) .bind(&token) .bind(note_id) - .execute(pool) + .execute(pool.pool()) .await?; Ok(token) } -pub async fn find_published_page(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { - sqlx::query_as::<_, PublishedPage>( - "SELECT pp.token, 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 = ?", - ) +pub async fn find_published_page(pool: &Database, token: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, PublishedPage>(queries::get(pool.kind(), queries::Q021)) .bind(token) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await } -pub async fn pad_file_token(pool: &SqlitePool, pad_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, Option>("SELECT file_token FROM pads WHERE id = ?") +pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q022)) .bind(pad_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await? { return Ok(token); } let token = format!("p_{}", random_suffix(24)); - sqlx::query("UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL") + sqlx::query(queries::get(pool.kind(), queries::Q023)) .bind(&token) .bind(pad_id) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_scalar::<_, String>("SELECT file_token FROM pads WHERE id = ?") + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q022)) .bind(pad_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await } -pub async fn note_file_token(pool: &SqlitePool, note_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, Option>("SELECT file_token FROM notes WHERE id = ?") +pub async fn note_file_token(pool: &Database, note_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q024)) .bind(note_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await? { return Ok(token); } let token = format!("n_{}", random_suffix(24)); - sqlx::query("UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL") + sqlx::query(queries::get(pool.kind(), queries::Q025)) .bind(&token) .bind(note_id) - .execute(pool) + .execute(pool.pool()) .await?; - sqlx::query_scalar::<_, String>("SELECT file_token FROM notes WHERE id = ?") + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q024)) .bind(note_id) - .fetch_one(pool) + .fetch_one(pool.pool()) .await } @@ -410,17 +403,17 @@ pub struct FileOwner { pub id: i64, } -pub async fn find_file_owner(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { - if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM pads WHERE file_token = ?") +pub async fn find_file_owner(pool: &Database, token: &str) -> Result, sqlx::Error> { + if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q026)) .bind(token) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id })); } - if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM notes WHERE file_token = ?") + if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q027)) .bind(token) - .fetch_optional(pool) + .fetch_optional(pool.pool()) .await? { return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id })); diff --git a/src/main.rs b/src/main.rs index cfdc906..8775bb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,16 @@ mod api; mod app; mod config; +mod database; mod db; +mod queries; mod state; mod websocket; use std::{net::SocketAddr, sync::Arc}; use config::Config; -use sqlx::sqlite::SqlitePoolOptions; +use database::{Database, DatabaseKind}; use state::AppState; use tokio::net::TcpListener; use tracing::info; @@ -23,11 +25,8 @@ async fn main() -> Result<(), Box> { if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) { if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; } } - let db = SqlitePoolOptions::new() - .max_connections(config.database_max_connections) - .connect(&config.database_url) - .await?; - sqlx::migrate!().run(&db).await?; + let db = Database::connect(&config.database_url, config.database_max_connections).await?; + run_migrations(&db).await?; std::fs::create_dir_all(&config.files_dir)?; let state = Arc::new(AppState::new( @@ -78,3 +77,12 @@ async fn shutdown_signal() { let terminate = std::future::pending::<()>(); tokio::select! { () = ctrl_c => {}, () = terminate => {} } } + +async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> { + let path = match db.kind() { + DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"), + DatabaseKind::Postgres => std::path::Path::new("migrations/postgres"), + DatabaseKind::MySql => std::path::Path::new("migrations/mysql"), + }; + sqlx::migrate::Migrator::new(path).await?.run(db.pool()).await +} diff --git a/src/queries.rs b/src/queries.rs new file mode 100644 index 0000000..561321b --- /dev/null +++ b/src/queries.rs @@ -0,0 +1,55 @@ +use std::{collections::HashMap, sync::{Mutex, OnceLock}}; +use crate::database::DatabaseKind; + +pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?"; +pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; +pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; +pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?"; +pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)"; +pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; +pub const Q009: &str = "SELECT updated_at FROM notes WHERE id = ?"; +pub const Q010: &str = "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"; +pub const Q011: &str = "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?"; +pub const Q012: &str = "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"; +pub const Q013: &str = "UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q014: &str = "INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; +pub const Q015: &str = "SELECT updated_at FROM pads WHERE id = ?"; +pub const Q016: &str = "SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100"; +pub const Q017: &str = "SELECT token FROM published_pages WHERE pad_id = ?"; +pub const Q018: &str = "INSERT INTO published_pages (token, pad_id) VALUES (?, ?)"; +pub const Q019: &str = "SELECT token FROM published_pages WHERE note_id = ?"; +pub const Q020: &str = "INSERT INTO published_pages (token, note_id) VALUES (?, ?)"; +pub const Q021: &str = "SELECT pp.token, 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 = ?"; +pub const Q022: &str = "SELECT file_token FROM pads WHERE id = ?"; +pub const Q023: &str = "UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL"; +pub const Q024: &str = "SELECT file_token FROM notes WHERE id = ?"; +pub const Q025: &str = "UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL"; +pub const Q026: &str = "SELECT id FROM pads WHERE file_token = ?"; +pub const Q027: &str = "SELECT id FROM notes WHERE file_token = ?"; + +static POSTGRES_QUERIES: OnceLock>> = OnceLock::new(); + +pub fn get(kind: DatabaseKind, query: &'static str) -> &'static str { + if kind != DatabaseKind::Postgres { return query; } + let cache = POSTGRES_QUERIES.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().expect("query cache lock poisoned"); + if let Some(value) = cache.get(query) { return value; } + let cache_key = query; + let query = query.replace("CURRENT_TIMESTAMP", "(CURRENT_TIMESTAMP::text)"); + let mut index = 0; + let mut converted = String::with_capacity(query.len() + 8); + for ch in query.chars() { + if ch == '?' { + index += 1; + converted.push('$'); + converted.push_str(&index.to_string()); + } else { + converted.push(ch); + } + } + let converted = Box::leak(converted.into_boxed_str()); + cache.insert(cache_key, converted); + converted +} diff --git a/src/state.rs b/src/state.rs index ec704c1..b7572cf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,5 @@ use std::{collections::HashMap, sync::Arc}; -use sqlx::SqlitePool; +use crate::database::Database; use tokio::sync::{broadcast, RwLock}; const CHANNEL_CAPACITY: usize = 256; @@ -15,7 +15,7 @@ pub struct NoteUpdate { #[derive(Debug)] pub struct AppState { - pub db: SqlitePool, + pub db: Database, pub asset_version: String, pub files_dir: String, pub upload_max_size_bytes: usize, @@ -23,7 +23,7 @@ pub struct AppState { } impl AppState { - pub fn new(db: SqlitePool, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self { + pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self { Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) } } async fn channel_for_key(&self, key: String) -> broadcast::Sender { diff --git a/static/home.html b/static/home.html index 8f50e7b..44e58c1 100644 --- a/static/home.html +++ b/static/home.html @@ -59,5 +59,6 @@ + diff --git a/static/styles.css b/static/styles.css index 26b90bc..2b42177 100644 --- a/static/styles.css +++ b/static/styles.css @@ -239,3 +239,12 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .public-content { min-height: 240px; padding: 32px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); } .public-content img { cursor: zoom-in; } @media (max-width: 600px) { .public-document { padding-top: 32px; } .public-content { padding: 20px; } } + + +/* Layout safeguards and home footer */ +.app-header__main, .document-heading { min-width: 0; } +.document-url { overflow-wrap: anywhere; } +.site-footer { width: min(1040px, calc(100% - 32px)); margin: -36px auto 28px; color: var(--muted-2); font-size: .76rem; text-align: center; } +.site-footer a { color: var(--muted); text-decoration: none; } +.site-footer a:hover { color: white; } +@media (max-width: 760px) { .site-footer { width: min(100% - 24px, 560px); margin-top: -20px; } }