Merge pull request #26 from stevedylandev/chore/refactor-db
364948e8
chore: refactored db into create
25 file(s) · +639 −909
chore: refactored db into create
| 71 | 71 | ] |
|
| 72 | 72 | ||
| 73 | 73 | [[package]] |
|
| 74 | + | name = "andromeda-db" |
|
| 75 | + | version = "0.1.0" |
|
| 76 | + | dependencies = [ |
|
| 77 | + | "axum", |
|
| 78 | + | "rusqlite", |
|
| 79 | + | "tracing", |
|
| 80 | + | ] |
|
| 81 | + | ||
| 82 | + | [[package]] |
|
| 74 | 83 | name = "anstream" |
|
| 75 | 84 | version = "1.0.0" |
|
| 76 | 85 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 680 | 689 | version = "0.1.4" |
|
| 681 | 690 | dependencies = [ |
|
| 682 | 691 | "andromeda-auth", |
|
| 692 | + | "andromeda-db", |
|
| 683 | 693 | "askama 0.15.6", |
|
| 684 | 694 | "askama_web", |
|
| 685 | 695 | "axum", |
|
| 2195 | 2205 | version = "0.2.0" |
|
| 2196 | 2206 | dependencies = [ |
|
| 2197 | 2207 | "andromeda-auth", |
|
| 2208 | + | "andromeda-db", |
|
| 2198 | 2209 | "arboard", |
|
| 2199 | 2210 | "askama 0.15.6", |
|
| 2200 | 2211 | "askama_web", |
|
| 2900 | 2911 | version = "0.1.3" |
|
| 2901 | 2912 | dependencies = [ |
|
| 2902 | 2913 | "andromeda-auth", |
|
| 2914 | + | "andromeda-db", |
|
| 2903 | 2915 | "anyhow", |
|
| 2904 | 2916 | "askama 0.12.1", |
|
| 2905 | 2917 | "askama_axum", |
|
| 3117 | 3129 | version = "0.1.4" |
|
| 3118 | 3130 | dependencies = [ |
|
| 3119 | 3131 | "andromeda-auth", |
|
| 3132 | + | "andromeda-db", |
|
| 3120 | 3133 | "askama 0.15.6", |
|
| 3121 | 3134 | "askama_web", |
|
| 3122 | 3135 | "axum", |
|
| 3128 | 3141 | "rust-embed", |
|
| 3129 | 3142 | "serde", |
|
| 3130 | 3143 | "serde_json", |
|
| 3144 | + | "serde_rusqlite", |
|
| 3131 | 3145 | "subtle", |
|
| 3132 | 3146 | "tokio", |
|
| 3133 | 3147 | "tracing", |
|
| 4183 | 4197 | name = "sipp-so" |
|
| 4184 | 4198 | version = "0.1.6" |
|
| 4185 | 4199 | dependencies = [ |
|
| 4200 | + | "andromeda-db", |
|
| 4186 | 4201 | "arboard", |
|
| 4187 | 4202 | "askama 0.15.6", |
|
| 4188 | 4203 | "askama_web", |
|
| 9 | 9 | "apps/cellar", |
|
| 10 | 10 | "apps/posts", |
|
| 11 | 11 | "crates/auth", |
|
| 12 | + | "crates/db", |
|
| 12 | 13 | ] |
|
| 13 | 14 | resolver = "3" |
|
| 14 | 15 | ||
| 50 | 51 | ||
| 51 | 52 | # Workspace crates |
|
| 52 | 53 | andromeda-auth = { path = "crates/auth" } |
|
| 54 | + | andromeda-db = { path = "crates/db" } |
|
| 21 | 21 | tracing = { workspace = true } |
|
| 22 | 22 | tracing-subscriber = { workspace = true } |
|
| 23 | 23 | andromeda-auth = { workspace = true } |
|
| 24 | + | andromeda-db = { workspace = true, features = ["session"] } |
|
| 24 | 25 | askama = "0.15" |
|
| 25 | 26 | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 26 | 27 | reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } |
| 5 | 5 | # Copy workspace manifests |
|
| 6 | 6 | COPY Cargo.toml Cargo.lock . |
|
| 7 | 7 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 8 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 8 | 9 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 9 | 10 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 10 | 11 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 16 | 17 | ||
| 17 | 18 | # Create stubs for dependency caching |
|
| 18 | 19 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 20 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 19 | 21 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 20 | 22 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 21 | 23 | done |
|
| 24 | 26 | ||
| 25 | 27 | # Copy real source |
|
| 26 | 28 | COPY crates/auth/src crates/auth/src |
|
| 29 | + | COPY crates/db/src crates/db/src |
|
| 27 | 30 | COPY apps/cellar/src apps/cellar/src |
|
| 28 | 31 | COPY apps/cellar/static apps/cellar/static |
|
| 29 | 32 | COPY apps/cellar/templates apps/cellar/templates |
|
| 30 | 33 | ||
| 31 | - | RUN touch apps/cellar/src/*.rs crates/auth/src/*.rs && cargo build --release -p cellar |
|
| 34 | + | RUN touch apps/cellar/src/*.rs crates/auth/src/*.rs crates/db/src/*.rs && cargo build --release -p cellar |
|
| 32 | 35 | ||
| 33 | 36 | FROM debian:bookworm-slim |
|
| 34 | 37 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 1 | 1 | use nanoid::nanoid; |
|
| 2 | 2 | use rusqlite::{Connection, params}; |
|
| 3 | 3 | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::fmt; |
|
| 5 | 4 | use std::sync::{Arc, Mutex}; |
|
| 6 | 5 | ||
| 7 | - | pub type Db = Arc<Mutex<Connection>>; |
|
| 8 | - | ||
| 9 | - | #[derive(Debug)] |
|
| 10 | - | pub enum DbError { |
|
| 11 | - | Sqlite(rusqlite::Error), |
|
| 12 | - | LockPoisoned, |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | impl fmt::Display for DbError { |
|
| 16 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | - | match self { |
|
| 18 | - | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 19 | - | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 20 | - | } |
|
| 21 | - | } |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | impl std::error::Error for DbError {} |
|
| 25 | - | ||
| 26 | - | impl From<rusqlite::Error> for DbError { |
|
| 27 | - | fn from(e: rusqlite::Error) -> Self { |
|
| 28 | - | DbError::Sqlite(e) |
|
| 29 | - | } |
|
| 30 | - | } |
|
| 6 | + | pub use andromeda_db::{Db, DbError}; |
|
| 7 | + | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 31 | 8 | ||
| 32 | 9 | #[derive(Debug, Serialize, Deserialize, Clone)] |
|
| 33 | 10 | pub struct Wine { |
|
| 53 | 30 | pub wishlist: bool, |
|
| 54 | 31 | } |
|
| 55 | 32 | ||
| 33 | + | const SCHEMA: &str = " |
|
| 34 | + | CREATE TABLE IF NOT EXISTS wines ( |
|
| 35 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 36 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 37 | + | name TEXT NOT NULL, |
|
| 38 | + | origin TEXT NOT NULL, |
|
| 39 | + | grape TEXT NOT NULL, |
|
| 40 | + | notes TEXT NOT NULL, |
|
| 41 | + | image BLOB, |
|
| 42 | + | image_mime TEXT, |
|
| 43 | + | sweetness INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5), |
|
| 44 | + | acidity INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5), |
|
| 45 | + | tannin INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5), |
|
| 46 | + | alcohol INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5), |
|
| 47 | + | body INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5), |
|
| 48 | + | clarity INTEGER NOT NULL DEFAULT 3, |
|
| 49 | + | color_intensity INTEGER NOT NULL DEFAULT 3, |
|
| 50 | + | aroma_intensity INTEGER NOT NULL DEFAULT 3, |
|
| 51 | + | nose_complexity INTEGER NOT NULL DEFAULT 3, |
|
| 52 | + | background TEXT NOT NULL DEFAULT '', |
|
| 53 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 54 | + | wishlist INTEGER NOT NULL DEFAULT 0 |
|
| 55 | + | ); |
|
| 56 | + | ||
| 57 | + | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 58 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 59 | + | token TEXT NOT NULL UNIQUE, |
|
| 60 | + | expires_at TEXT NOT NULL |
|
| 61 | + | ); |
|
| 62 | + | "; |
|
| 63 | + | ||
| 56 | 64 | pub fn init_db() -> Db { |
|
| 57 | 65 | let path = std::env::var("CELLAR_DB_PATH").unwrap_or_else(|_| "cellar.sqlite".to_string()); |
|
| 58 | 66 | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 59 | 67 | ||
| 60 | - | conn.execute_batch( |
|
| 61 | - | "CREATE TABLE IF NOT EXISTS wines ( |
|
| 62 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 63 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 64 | - | name TEXT NOT NULL, |
|
| 65 | - | origin TEXT NOT NULL, |
|
| 66 | - | grape TEXT NOT NULL, |
|
| 67 | - | notes TEXT NOT NULL, |
|
| 68 | - | image BLOB, |
|
| 69 | - | image_mime TEXT, |
|
| 70 | - | sweetness INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5), |
|
| 71 | - | acidity INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5), |
|
| 72 | - | tannin INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5), |
|
| 73 | - | alcohol INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5), |
|
| 74 | - | body INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5), |
|
| 75 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 76 | - | ); |
|
| 68 | + | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 77 | 69 | ||
| 78 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 79 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 80 | - | token TEXT NOT NULL UNIQUE, |
|
| 81 | - | expires_at TEXT NOT NULL |
|
| 82 | - | );" |
|
| 83 | - | ) |
|
| 84 | - | .expect("Failed to create tables"); |
|
| 85 | - | ||
| 86 | - | // Migration: add background column if it doesn't exist |
|
| 70 | + | // Migrations for existing databases (idempotent — ALTER TABLE fails silently if column exists) |
|
| 87 | 71 | let _ = conn.execute("ALTER TABLE wines ADD COLUMN background TEXT NOT NULL DEFAULT ''", []); |
|
| 88 | - | ||
| 89 | - | // Migration: add appearance and nose tasting attributes |
|
| 90 | 72 | let _ = conn.execute("ALTER TABLE wines ADD COLUMN clarity INTEGER NOT NULL DEFAULT 3", []); |
|
| 91 | 73 | let _ = conn.execute("ALTER TABLE wines ADD COLUMN color_intensity INTEGER NOT NULL DEFAULT 3", []); |
|
| 92 | 74 | let _ = conn.execute("ALTER TABLE wines ADD COLUMN aroma_intensity INTEGER NOT NULL DEFAULT 3", []); |
|
| 93 | 75 | let _ = conn.execute("ALTER TABLE wines ADD COLUMN nose_complexity INTEGER NOT NULL DEFAULT 3", []); |
|
| 94 | - | ||
| 95 | - | // Migration: add wishlist flag |
|
| 96 | 76 | let _ = conn.execute("ALTER TABLE wines ADD COLUMN wishlist INTEGER NOT NULL DEFAULT 0", []); |
|
| 97 | 77 | ||
| 98 | 78 | Arc::new(Mutex::new(conn)) |
|
| 298 | 278 | Ok(rows > 0) |
|
| 299 | 279 | } |
|
| 300 | 280 | ||
| 301 | - | // Session functions |
|
| 302 | - | ||
| 303 | - | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 304 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 305 | - | conn.execute( |
|
| 306 | - | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 307 | - | params![token, expires_at], |
|
| 308 | - | )?; |
|
| 309 | - | Ok(()) |
|
| 310 | - | } |
|
| 311 | - | ||
| 312 | - | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 313 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 314 | - | match conn.query_row( |
|
| 315 | - | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 316 | - | params![token], |
|
| 317 | - | |row| row.get(0), |
|
| 318 | - | ) { |
|
| 319 | - | Ok(val) => Ok(Some(val)), |
|
| 320 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 321 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 322 | - | } |
|
| 323 | - | } |
|
| 324 | - | ||
| 325 | - | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 326 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 327 | - | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 328 | - | Ok(()) |
|
| 329 | - | } |
|
| 330 | - | ||
| 331 | - | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 332 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 333 | - | conn.execute( |
|
| 334 | - | "DELETE FROM sessions WHERE expires_at < datetime('now')", |
|
| 335 | - | [], |
|
| 336 | - | )?; |
|
| 337 | - | Ok(()) |
|
| 338 | - | } |
|
| 339 | - | ||
| 340 | 281 | #[cfg(test)] |
|
| 341 | 282 | mod tests { |
|
| 342 | 283 | use super::*; |
|
| 343 | 284 | ||
| 344 | 285 | fn test_db() -> Db { |
|
| 345 | 286 | let conn = Connection::open_in_memory().unwrap(); |
|
| 346 | - | conn.execute_batch( |
|
| 347 | - | "CREATE TABLE IF NOT EXISTS wines ( |
|
| 348 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 349 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 350 | - | name TEXT NOT NULL, |
|
| 351 | - | origin TEXT NOT NULL, |
|
| 352 | - | grape TEXT NOT NULL, |
|
| 353 | - | notes TEXT NOT NULL, |
|
| 354 | - | image BLOB, |
|
| 355 | - | image_mime TEXT, |
|
| 356 | - | sweetness INTEGER NOT NULL CHECK(sweetness BETWEEN 1 AND 5), |
|
| 357 | - | acidity INTEGER NOT NULL CHECK(acidity BETWEEN 1 AND 5), |
|
| 358 | - | tannin INTEGER NOT NULL CHECK(tannin BETWEEN 1 AND 5), |
|
| 359 | - | alcohol INTEGER NOT NULL CHECK(alcohol BETWEEN 1 AND 5), |
|
| 360 | - | body INTEGER NOT NULL CHECK(body BETWEEN 1 AND 5), |
|
| 361 | - | clarity INTEGER NOT NULL DEFAULT 3, |
|
| 362 | - | color_intensity INTEGER NOT NULL DEFAULT 3, |
|
| 363 | - | aroma_intensity INTEGER NOT NULL DEFAULT 3, |
|
| 364 | - | nose_complexity INTEGER NOT NULL DEFAULT 3, |
|
| 365 | - | background TEXT NOT NULL DEFAULT '', |
|
| 366 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 367 | - | wishlist INTEGER NOT NULL DEFAULT 0 |
|
| 368 | - | ); |
|
| 369 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 370 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 371 | - | token TEXT NOT NULL UNIQUE, |
|
| 372 | - | expires_at TEXT NOT NULL |
|
| 373 | - | );", |
|
| 374 | - | ) |
|
| 375 | - | .unwrap(); |
|
| 287 | + | conn.execute_batch(SCHEMA).unwrap(); |
|
| 376 | 288 | Arc::new(Mutex::new(conn)) |
|
| 377 | 289 | } |
|
| 378 | 290 | ||
| 6 | 6 | # Copy workspace manifests |
|
| 7 | 7 | COPY Cargo.toml Cargo.lock . |
|
| 8 | 8 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 9 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 9 | 10 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 10 | 11 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 11 | 12 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 17 | 18 | ||
| 18 | 19 | # Create stubs for dependency caching |
|
| 19 | 20 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 21 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 20 | 22 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 21 | 23 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 22 | 24 | done |
|
| 25 | 27 | ||
| 26 | 28 | # Copy real source |
|
| 27 | 29 | COPY crates/auth/src crates/auth/src |
|
| 30 | + | COPY crates/db/src crates/db/src |
|
| 28 | 31 | COPY apps/feeds/src apps/feeds/src |
|
| 29 | 32 | COPY apps/feeds/static apps/feeds/static |
|
| 30 | 33 | COPY apps/feeds/askama.toml apps/feeds/askama.toml |
|
| 31 | 34 | ||
| 32 | - | RUN touch apps/feeds/src/*.rs crates/auth/src/*.rs && cargo build --release -p feeds |
|
| 35 | + | RUN touch apps/feeds/src/*.rs crates/auth/src/*.rs crates/db/src/*.rs && cargo build --release -p feeds |
|
| 33 | 36 | ||
| 34 | 37 | FROM debian:bookworm-slim |
|
| 35 | 38 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 21 | 21 | tracing = { workspace = true } |
|
| 22 | 22 | tracing-subscriber = { workspace = true } |
|
| 23 | 23 | andromeda-auth = { workspace = true } |
|
| 24 | + | andromeda-db = { workspace = true, features = ["session", "axum"] } |
|
| 24 | 25 | askama = "0.15" |
|
| 25 | 26 | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 26 | 27 | pulldown-cmark = "0.12" |
| 6 | 6 | # Copy workspace manifests |
|
| 7 | 7 | COPY Cargo.toml Cargo.lock . |
|
| 8 | 8 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 9 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 9 | 10 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 10 | 11 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 11 | 12 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 17 | 18 | ||
| 18 | 19 | # Create stubs for dependency caching |
|
| 19 | 20 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 21 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 20 | 22 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 21 | 23 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 22 | 24 | done |
|
| 25 | 27 | ||
| 26 | 28 | # Copy real source |
|
| 27 | 29 | COPY crates/auth/src crates/auth/src |
|
| 30 | + | COPY crates/db/src crates/db/src |
|
| 28 | 31 | COPY apps/jotts/src apps/jotts/src |
|
| 29 | 32 | COPY apps/jotts/static apps/jotts/static |
|
| 30 | 33 | COPY apps/jotts/templates apps/jotts/templates |
|
| 31 | 34 | ||
| 32 | - | RUN touch apps/jotts/src/*.rs crates/auth/src/*.rs && cargo build --release -p jotts |
|
| 35 | + | RUN touch apps/jotts/src/*.rs crates/auth/src/*.rs crates/db/src/*.rs && cargo build --release -p jotts |
|
| 33 | 36 | ||
| 34 | 37 | FROM debian:bookworm-slim |
|
| 35 | 38 | COPY --from=builder /app/target/release/jotts /usr/local/bin/jotts |
|
| 1 | 1 | use nanoid::nanoid; |
|
| 2 | 2 | use rusqlite::{Connection, OptionalExtension, Row, params}; |
|
| 3 | 3 | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::fmt; |
|
| 5 | 4 | use std::sync::{Arc, Mutex}; |
|
| 6 | 5 | ||
| 7 | - | pub type Db = Arc<Mutex<Connection>>; |
|
| 6 | + | pub use andromeda_db::{Db, DbError}; |
|
| 7 | + | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 8 | 8 | ||
| 9 | 9 | const NOTE_COLUMNS: &str = "id, short_id, title, content, created_at, updated_at"; |
|
| 10 | 10 | ||
| 24 | 24 | expires_at TEXT NOT NULL |
|
| 25 | 25 | ); |
|
| 26 | 26 | "; |
|
| 27 | - | ||
| 28 | - | #[derive(Debug)] |
|
| 29 | - | pub enum DbError { |
|
| 30 | - | Sqlite(rusqlite::Error), |
|
| 31 | - | LockPoisoned, |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | impl fmt::Display for DbError { |
|
| 35 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 36 | - | match self { |
|
| 37 | - | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 38 | - | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 39 | - | } |
|
| 40 | - | } |
|
| 41 | - | } |
|
| 42 | - | ||
| 43 | - | impl std::error::Error for DbError {} |
|
| 44 | - | ||
| 45 | - | impl From<rusqlite::Error> for DbError { |
|
| 46 | - | fn from(e: rusqlite::Error) -> Self { |
|
| 47 | - | DbError::Sqlite(e) |
|
| 48 | - | } |
|
| 49 | - | } |
|
| 50 | 27 | ||
| 51 | 28 | #[derive(Debug, Serialize, Deserialize)] |
|
| 52 | 29 | pub struct Note { |
|
| 156 | 133 | Ok(rows > 0) |
|
| 157 | 134 | } |
|
| 158 | 135 | ||
| 159 | - | // Session functions |
|
| 160 | - | ||
| 161 | - | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 162 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 163 | - | conn.execute( |
|
| 164 | - | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 165 | - | params![token, expires_at], |
|
| 166 | - | )?; |
|
| 167 | - | Ok(()) |
|
| 168 | - | } |
|
| 169 | - | ||
| 170 | - | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 171 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 172 | - | match conn.query_row( |
|
| 173 | - | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 174 | - | params![token], |
|
| 175 | - | |row| row.get(0), |
|
| 176 | - | ) { |
|
| 177 | - | Ok(val) => Ok(Some(val)), |
|
| 178 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 179 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 180 | - | } |
|
| 181 | - | } |
|
| 182 | - | ||
| 183 | - | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 184 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 185 | - | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 186 | - | Ok(()) |
|
| 187 | - | } |
|
| 188 | - | ||
| 189 | - | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 190 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 191 | - | conn.execute( |
|
| 192 | - | "DELETE FROM sessions WHERE expires_at < datetime('now')", |
|
| 193 | - | [], |
|
| 194 | - | )?; |
|
| 195 | - | Ok(()) |
|
| 196 | - | } |
|
| 197 | 136 | ||
| 198 | 137 | #[cfg(test)] |
|
| 199 | 138 | mod tests { |
|
| 14 | 14 | use crate::auth; |
|
| 15 | 15 | use crate::db::{self, Db, DbError, Note, NoteInput}; |
|
| 16 | 16 | ||
| 17 | - | impl IntoResponse for DbError { |
|
| 18 | - | fn into_response(self) -> Response { |
|
| 19 | - | tracing::error!("{}", self); |
|
| 20 | - | (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response() |
|
| 21 | - | } |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | 17 | fn redirect_with_cookie(target: &str, cookie: String) -> Response { |
|
| 25 | 18 | let mut resp = Redirect::to(target).into_response(); |
|
| 26 | 19 | resp.headers_mut().insert( |
| 6 | 6 | # Copy workspace manifests |
|
| 7 | 7 | COPY Cargo.toml Cargo.lock . |
|
| 8 | 8 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 9 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 9 | 10 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 10 | 11 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 11 | 12 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 17 | 18 | ||
| 18 | 19 | # Create stubs for dependency caching |
|
| 19 | 20 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 21 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 20 | 22 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 21 | 23 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 22 | 24 | done |
|
| 22 | 22 | tracing = { workspace = true } |
|
| 23 | 23 | tracing-subscriber = { workspace = true, features = ["env-filter"] } |
|
| 24 | 24 | andromeda-auth = { workspace = true } |
|
| 25 | + | andromeda-db = { workspace = true, features = ["session"] } |
|
| 25 | 26 | rusqlite = { workspace = true } |
|
| 26 | 27 | reqwest = { version = "0.12", features = ["json"] } |
|
| 27 | 28 | askama = { version = "0.12", features = ["with-axum"] } |
| 6 | 6 | # Copy workspace manifests |
|
| 7 | 7 | COPY Cargo.toml Cargo.lock . |
|
| 8 | 8 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 9 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 9 | 10 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 10 | 11 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 11 | 12 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 17 | 18 | ||
| 18 | 19 | # Create stubs for dependency caching |
|
| 19 | 20 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 21 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 20 | 22 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 21 | 23 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 22 | 24 | done |
|
| 25 | 27 | ||
| 26 | 28 | # Copy real source |
|
| 27 | 29 | COPY crates/auth/src crates/auth/src |
|
| 30 | + | COPY crates/db/src crates/db/src |
|
| 28 | 31 | COPY apps/parcels/src apps/parcels/src |
|
| 29 | 32 | COPY apps/parcels/templates apps/parcels/templates |
|
| 30 | 33 | COPY apps/parcels/static apps/parcels/static |
|
| 31 | 34 | ||
| 32 | - | RUN touch apps/parcels/src/*.rs crates/auth/src/*.rs && cargo build --release -p parcels |
|
| 35 | + | RUN touch apps/parcels/src/*.rs crates/auth/src/*.rs crates/db/src/*.rs && cargo build --release -p parcels |
|
| 33 | 36 | ||
| 34 | 37 | FROM debian:bookworm-slim |
|
| 35 | 38 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* |
|
| 1 | - | use rusqlite::{Connection, params}; |
|
| 2 | - | use std::fmt; |
|
| 1 | + | use rusqlite::{Connection, OptionalExtension, params}; |
|
| 3 | 2 | use std::sync::{Arc, Mutex}; |
|
| 4 | 3 | ||
| 5 | - | pub type Db = Arc<Mutex<Connection>>; |
|
| 6 | - | ||
| 7 | - | // ── Error ─────────────────────────────────────────────────────────────────── |
|
| 8 | - | ||
| 9 | - | #[derive(Debug)] |
|
| 10 | - | pub enum DbError { |
|
| 11 | - | Sqlite(rusqlite::Error), |
|
| 12 | - | LockPoisoned, |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | impl fmt::Display for DbError { |
|
| 16 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | - | match self { |
|
| 18 | - | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 19 | - | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 20 | - | } |
|
| 21 | - | } |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | impl std::error::Error for DbError {} |
|
| 25 | - | ||
| 26 | - | impl From<rusqlite::Error> for DbError { |
|
| 27 | - | fn from(e: rusqlite::Error) -> Self { |
|
| 28 | - | DbError::Sqlite(e) |
|
| 29 | - | } |
|
| 30 | - | } |
|
| 4 | + | pub use andromeda_db::{Db, DbError}; |
|
| 5 | + | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 31 | 6 | ||
| 32 | 7 | // ── Types ─────────────────────────────────────────────────────────────────── |
|
| 33 | 8 | ||
| 59 | 34 | ||
| 60 | 35 | // ── Pool Setup ────────────────────────────────────────────────────────────── |
|
| 61 | 36 | ||
| 37 | + | const SCHEMA: &str = " |
|
| 38 | + | CREATE TABLE IF NOT EXISTS packages ( |
|
| 39 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 40 | + | tracking_number TEXT NOT NULL UNIQUE, |
|
| 41 | + | label TEXT, |
|
| 42 | + | status TEXT, |
|
| 43 | + | status_category TEXT, |
|
| 44 | + | status_summary TEXT, |
|
| 45 | + | mail_class TEXT, |
|
| 46 | + | expected_delivery TEXT, |
|
| 47 | + | last_refreshed_at TEXT, |
|
| 48 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 49 | + | ); |
|
| 50 | + | CREATE TABLE IF NOT EXISTS tracking_events ( |
|
| 51 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 52 | + | package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE, |
|
| 53 | + | event_timestamp TEXT, |
|
| 54 | + | event_type TEXT, |
|
| 55 | + | event_city TEXT, |
|
| 56 | + | event_state TEXT, |
|
| 57 | + | event_zip TEXT, |
|
| 58 | + | event_code TEXT |
|
| 59 | + | ); |
|
| 60 | + | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 61 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 62 | + | token TEXT NOT NULL UNIQUE, |
|
| 63 | + | expires_at TEXT NOT NULL |
|
| 64 | + | ); |
|
| 65 | + | "; |
|
| 66 | + | ||
| 62 | 67 | pub fn init_db() -> Db { |
|
| 63 | 68 | let path = "parcels.db"; |
|
| 64 | 69 | let conn = Connection::open(path).expect("Failed to open database"); |
|
| 65 | 70 | conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") |
|
| 66 | 71 | .expect("Failed to set PRAGMAs"); |
|
| 67 | - | conn.execute_batch(" |
|
| 68 | - | CREATE TABLE IF NOT EXISTS packages ( |
|
| 69 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 70 | - | tracking_number TEXT NOT NULL UNIQUE, |
|
| 71 | - | label TEXT, |
|
| 72 | - | status TEXT, |
|
| 73 | - | status_category TEXT, |
|
| 74 | - | status_summary TEXT, |
|
| 75 | - | mail_class TEXT, |
|
| 76 | - | expected_delivery TEXT, |
|
| 77 | - | last_refreshed_at TEXT, |
|
| 78 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 79 | - | ); |
|
| 80 | - | CREATE TABLE IF NOT EXISTS tracking_events ( |
|
| 81 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 82 | - | package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE, |
|
| 83 | - | event_timestamp TEXT, |
|
| 84 | - | event_type TEXT, |
|
| 85 | - | event_city TEXT, |
|
| 86 | - | event_state TEXT, |
|
| 87 | - | event_zip TEXT, |
|
| 88 | - | event_code TEXT |
|
| 89 | - | ); |
|
| 90 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 91 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 92 | - | token TEXT NOT NULL UNIQUE, |
|
| 93 | - | expires_at TEXT NOT NULL |
|
| 94 | - | ); |
|
| 95 | - | ").expect("Failed to create tables"); |
|
| 72 | + | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 96 | 73 | Arc::new(Mutex::new(conn)) |
|
| 97 | 74 | } |
|
| 98 | 75 | ||
| 76 | + | // ── Row Helpers ──────────────────────────────────────────────────────────── |
|
| 77 | + | ||
| 78 | + | const PACKAGE_COLS: &str = "id, tracking_number, label, status, status_category, status_summary, mail_class, expected_delivery, last_refreshed_at, created_at"; |
|
| 79 | + | ||
| 80 | + | fn package_from_row(row: &rusqlite::Row) -> rusqlite::Result<Package> { |
|
| 81 | + | Ok(Package { |
|
| 82 | + | id: row.get(0)?, |
|
| 83 | + | tracking_number: row.get(1)?, |
|
| 84 | + | label: row.get(2)?, |
|
| 85 | + | status: row.get(3)?, |
|
| 86 | + | status_category: row.get(4)?, |
|
| 87 | + | status_summary: row.get(5)?, |
|
| 88 | + | mail_class: row.get(6)?, |
|
| 89 | + | expected_delivery: row.get(7)?, |
|
| 90 | + | last_refreshed_at: row.get(8)?, |
|
| 91 | + | created_at: row.get(9)?, |
|
| 92 | + | }) |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | const EVENT_COLS: &str = "id, package_id, event_timestamp, event_type, event_city, event_state, event_zip, event_code"; |
|
| 96 | + | ||
| 97 | + | fn event_from_row(row: &rusqlite::Row) -> rusqlite::Result<TrackingEvent> { |
|
| 98 | + | Ok(TrackingEvent { |
|
| 99 | + | id: row.get(0)?, |
|
| 100 | + | package_id: row.get(1)?, |
|
| 101 | + | event_timestamp: row.get(2)?, |
|
| 102 | + | event_type: row.get(3)?, |
|
| 103 | + | event_city: row.get(4)?, |
|
| 104 | + | event_state: row.get(5)?, |
|
| 105 | + | event_zip: row.get(6)?, |
|
| 106 | + | event_code: row.get(7)?, |
|
| 107 | + | }) |
|
| 108 | + | } |
|
| 109 | + | ||
| 99 | 110 | // ── Package Queries ───────────────────────────────────────────────────────── |
|
| 100 | 111 | ||
| 101 | 112 | pub fn list_packages(db: &Db) -> Result<Vec<Package>, DbError> { |
|
| 102 | 113 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 103 | 114 | let mut stmt = conn.prepare( |
|
| 104 | - | "SELECT id, tracking_number, label, status, status_category, status_summary, |
|
| 105 | - | mail_class, expected_delivery, last_refreshed_at, created_at |
|
| 106 | - | FROM packages ORDER BY created_at DESC", |
|
| 115 | + | &format!("SELECT {} FROM packages ORDER BY created_at DESC", PACKAGE_COLS), |
|
| 107 | 116 | )?; |
|
| 108 | 117 | let packages = stmt |
|
| 109 | - | .query_map([], |row| { |
|
| 110 | - | Ok(Package { |
|
| 111 | - | id: row.get(0)?, |
|
| 112 | - | tracking_number: row.get(1)?, |
|
| 113 | - | label: row.get(2)?, |
|
| 114 | - | status: row.get(3)?, |
|
| 115 | - | status_category: row.get(4)?, |
|
| 116 | - | status_summary: row.get(5)?, |
|
| 117 | - | mail_class: row.get(6)?, |
|
| 118 | - | expected_delivery: row.get(7)?, |
|
| 119 | - | last_refreshed_at: row.get(8)?, |
|
| 120 | - | created_at: row.get(9)?, |
|
| 121 | - | }) |
|
| 122 | - | })? |
|
| 123 | - | .filter_map(|r| r.ok()) |
|
| 124 | - | .collect(); |
|
| 118 | + | .query_map([], package_from_row)? |
|
| 119 | + | .collect::<Result<Vec<_>, _>>()?; |
|
| 125 | 120 | Ok(packages) |
|
| 126 | 121 | } |
|
| 127 | 122 | ||
| 128 | 123 | pub fn get_package(db: &Db, id: i64) -> Result<Option<Package>, DbError> { |
|
| 129 | 124 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 130 | - | match conn.query_row( |
|
| 131 | - | "SELECT id, tracking_number, label, status, status_category, status_summary, |
|
| 132 | - | mail_class, expected_delivery, last_refreshed_at, created_at |
|
| 133 | - | FROM packages WHERE id = ?1", |
|
| 134 | - | params![id], |
|
| 135 | - | |row| { |
|
| 136 | - | Ok(Package { |
|
| 137 | - | id: row.get(0)?, |
|
| 138 | - | tracking_number: row.get(1)?, |
|
| 139 | - | label: row.get(2)?, |
|
| 140 | - | status: row.get(3)?, |
|
| 141 | - | status_category: row.get(4)?, |
|
| 142 | - | status_summary: row.get(5)?, |
|
| 143 | - | mail_class: row.get(6)?, |
|
| 144 | - | expected_delivery: row.get(7)?, |
|
| 145 | - | last_refreshed_at: row.get(8)?, |
|
| 146 | - | created_at: row.get(9)?, |
|
| 147 | - | }) |
|
| 148 | - | }, |
|
| 149 | - | ) { |
|
| 150 | - | Ok(pkg) => Ok(Some(pkg)), |
|
| 151 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 152 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 153 | - | } |
|
| 125 | + | let pkg = conn |
|
| 126 | + | .query_row( |
|
| 127 | + | &format!("SELECT {} FROM packages WHERE id = ?1", PACKAGE_COLS), |
|
| 128 | + | params![id], |
|
| 129 | + | package_from_row, |
|
| 130 | + | ) |
|
| 131 | + | .optional()?; |
|
| 132 | + | Ok(pkg) |
|
| 154 | 133 | } |
|
| 155 | 134 | ||
| 156 | 135 | pub fn insert_package(db: &Db, tracking_number: &str, label: Option<&str>) -> Result<i64, DbError> { |
|
| 219 | 198 | pub fn get_events_for_package(db: &Db, package_id: i64) -> Result<Vec<TrackingEvent>, DbError> { |
|
| 220 | 199 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 221 | 200 | let mut stmt = conn.prepare( |
|
| 222 | - | "SELECT id, package_id, event_timestamp, event_type, event_city, event_state, |
|
| 223 | - | event_zip, event_code |
|
| 224 | - | FROM tracking_events WHERE package_id = ?1 |
|
| 225 | - | ORDER BY event_timestamp DESC", |
|
| 201 | + | &format!("SELECT {} FROM tracking_events WHERE package_id = ?1 ORDER BY event_timestamp DESC", EVENT_COLS), |
|
| 226 | 202 | )?; |
|
| 227 | 203 | let events = stmt |
|
| 228 | - | .query_map(params![package_id], |row| { |
|
| 229 | - | Ok(TrackingEvent { |
|
| 230 | - | id: row.get(0)?, |
|
| 231 | - | package_id: row.get(1)?, |
|
| 232 | - | event_timestamp: row.get(2)?, |
|
| 233 | - | event_type: row.get(3)?, |
|
| 234 | - | event_city: row.get(4)?, |
|
| 235 | - | event_state: row.get(5)?, |
|
| 236 | - | event_zip: row.get(6)?, |
|
| 237 | - | event_code: row.get(7)?, |
|
| 238 | - | }) |
|
| 239 | - | })? |
|
| 240 | - | .filter_map(|r| r.ok()) |
|
| 241 | - | .collect(); |
|
| 204 | + | .query_map(params![package_id], event_from_row)? |
|
| 205 | + | .collect::<Result<Vec<_>, _>>()?; |
|
| 242 | 206 | Ok(events) |
|
| 243 | 207 | } |
|
| 244 | 208 | ||
| 245 | - | // ── Session Queries ────────────────────────────────────────────────────────── |
|
| 246 | - | ||
| 247 | - | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 248 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 249 | - | conn.execute( |
|
| 250 | - | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 251 | - | params![token, expires_at], |
|
| 252 | - | )?; |
|
| 253 | - | Ok(()) |
|
| 254 | - | } |
|
| 255 | - | ||
| 256 | - | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 257 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 258 | - | match conn.query_row( |
|
| 259 | - | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 260 | - | params![token], |
|
| 261 | - | |row| row.get(0), |
|
| 262 | - | ) { |
|
| 263 | - | Ok(expires_at) => Ok(Some(expires_at)), |
|
| 264 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 265 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 266 | - | } |
|
| 267 | - | } |
|
| 268 | - | ||
| 269 | - | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 270 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 271 | - | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 272 | - | Ok(()) |
|
| 273 | - | } |
|
| 274 | - | ||
| 275 | - | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 276 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 277 | - | conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?; |
|
| 278 | - | Ok(()) |
|
| 279 | - | } |
|
| 280 | - | ||
| 281 | 209 | #[cfg(test)] |
|
| 282 | 210 | mod tests { |
|
| 283 | 211 | use super::*; |
|
| 285 | 213 | fn test_db() -> Db { |
|
| 286 | 214 | let conn = Connection::open_in_memory().unwrap(); |
|
| 287 | 215 | conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap(); |
|
| 288 | - | conn.execute_batch( |
|
| 289 | - | "CREATE TABLE IF NOT EXISTS packages ( |
|
| 290 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 291 | - | tracking_number TEXT NOT NULL UNIQUE, |
|
| 292 | - | label TEXT, |
|
| 293 | - | status TEXT, |
|
| 294 | - | status_category TEXT, |
|
| 295 | - | status_summary TEXT, |
|
| 296 | - | mail_class TEXT, |
|
| 297 | - | expected_delivery TEXT, |
|
| 298 | - | last_refreshed_at TEXT, |
|
| 299 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 300 | - | ); |
|
| 301 | - | CREATE TABLE IF NOT EXISTS tracking_events ( |
|
| 302 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 303 | - | package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE, |
|
| 304 | - | event_timestamp TEXT, |
|
| 305 | - | event_type TEXT, |
|
| 306 | - | event_city TEXT, |
|
| 307 | - | event_state TEXT, |
|
| 308 | - | event_zip TEXT, |
|
| 309 | - | event_code TEXT |
|
| 310 | - | ); |
|
| 311 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 312 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 313 | - | token TEXT NOT NULL UNIQUE, |
|
| 314 | - | expires_at TEXT NOT NULL |
|
| 315 | - | );", |
|
| 316 | - | ) |
|
| 317 | - | .unwrap(); |
|
| 216 | + | conn.execute_batch(SCHEMA).unwrap(); |
|
| 318 | 217 | Arc::new(Mutex::new(conn)) |
|
| 319 | 218 | } |
|
| 320 | 219 | ||
| 21 | 21 | tracing = { workspace = true } |
|
| 22 | 22 | tracing-subscriber = { workspace = true } |
|
| 23 | 23 | andromeda-auth = { workspace = true } |
|
| 24 | + | andromeda-db = { workspace = true, features = ["session"] } |
|
| 25 | + | serde_rusqlite = "0.41" |
|
| 24 | 26 | askama = "0.15" |
|
| 25 | 27 | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 26 | 28 | pulldown-cmark = "0.12" |
| 6 | 6 | # Copy workspace manifests |
|
| 7 | 7 | COPY Cargo.toml Cargo.lock . |
|
| 8 | 8 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 9 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 9 | 10 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 10 | 11 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 11 | 12 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 17 | 18 | ||
| 18 | 19 | # Create stubs for dependency caching |
|
| 19 | 20 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 21 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 20 | 22 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 21 | 23 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 22 | 24 | done |
|
| 25 | 27 | ||
| 26 | 28 | # Copy real source |
|
| 27 | 29 | COPY crates/auth/src crates/auth/src |
|
| 30 | + | COPY crates/db/src crates/db/src |
|
| 28 | 31 | COPY apps/posts/src apps/posts/src |
|
| 29 | 32 | COPY apps/posts/static apps/posts/static |
|
| 30 | 33 | COPY apps/posts/templates apps/posts/templates |
|
| 31 | 34 | ||
| 32 | - | RUN touch apps/posts/src/*.rs crates/auth/src/*.rs && cargo build --release -p posts |
|
| 35 | + | RUN touch apps/posts/src/*.rs crates/auth/src/*.rs crates/db/src/*.rs && cargo build --release -p posts |
|
| 33 | 36 | ||
| 34 | 37 | FROM debian:bookworm-slim |
|
| 35 | 38 | COPY --from=builder /app/target/release/posts /usr/local/bin/posts |
|
| 1 | 1 | use nanoid::nanoid; |
|
| 2 | - | use rusqlite::{Connection, params}; |
|
| 2 | + | use rusqlite::{Connection, OptionalExtension, params}; |
|
| 3 | 3 | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::fmt; |
|
| 5 | 4 | use std::sync::{Arc, Mutex}; |
|
| 6 | 5 | ||
| 7 | - | pub type Db = Arc<Mutex<Connection>>; |
|
| 8 | - | ||
| 9 | - | #[derive(Debug)] |
|
| 10 | - | pub enum DbError { |
|
| 11 | - | Sqlite(rusqlite::Error), |
|
| 12 | - | LockPoisoned, |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | impl fmt::Display for DbError { |
|
| 16 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | - | match self { |
|
| 18 | - | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 19 | - | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 20 | - | } |
|
| 21 | - | } |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | impl std::error::Error for DbError {} |
|
| 6 | + | pub use andromeda_db::{Db, DbError}; |
|
| 7 | + | pub use andromeda_db::session::{insert_session, get_session_expiry, delete_session, prune_expired_sessions}; |
|
| 25 | 8 | ||
| 26 | - | impl From<rusqlite::Error> for DbError { |
|
| 27 | - | fn from(e: rusqlite::Error) -> Self { |
|
| 28 | - | DbError::Sqlite(e) |
|
| 29 | - | } |
|
| 9 | + | fn from_row<T: serde::de::DeserializeOwned>(row: &rusqlite::Row) -> rusqlite::Result<T> { |
|
| 10 | + | serde_rusqlite::from_row::<T>(row).map_err(|e| { |
|
| 11 | + | rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Null, Box::new(e)) |
|
| 12 | + | }) |
|
| 30 | 13 | } |
|
| 31 | 14 | ||
| 32 | 15 | #[derive(Debug, Serialize, Deserialize, Clone)] |
|
| 48 | 31 | pub updated_at: String, |
|
| 49 | 32 | } |
|
| 50 | 33 | ||
| 34 | + | #[derive(Serialize)] |
|
| 35 | + | pub struct PostInput<'a> { |
|
| 36 | + | pub title: &'a str, |
|
| 37 | + | pub slug: &'a str, |
|
| 38 | + | pub content: &'a str, |
|
| 39 | + | pub status: &'a str, |
|
| 40 | + | pub alias: Option<&'a str>, |
|
| 41 | + | pub canonical_url: Option<&'a str>, |
|
| 42 | + | pub published_date: Option<&'a str>, |
|
| 43 | + | pub meta_description: Option<&'a str>, |
|
| 44 | + | pub meta_image: Option<&'a str>, |
|
| 45 | + | pub lang: &'a str, |
|
| 46 | + | pub tags: Option<&'a str>, |
|
| 47 | + | } |
|
| 48 | + | ||
| 51 | 49 | #[derive(Debug, Serialize, Deserialize, Clone)] |
|
| 52 | 50 | pub struct Page { |
|
| 53 | 51 | pub id: i64, |
|
| 72 | 70 | pub created_at: String, |
|
| 73 | 71 | } |
|
| 74 | 72 | ||
| 75 | - | pub fn init_db() -> Db { |
|
| 76 | - | let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string()); |
|
| 77 | - | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 73 | + | const SCHEMA: &str = " |
|
| 74 | + | CREATE TABLE IF NOT EXISTS posts ( |
|
| 75 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 76 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 77 | + | title TEXT NOT NULL, |
|
| 78 | + | slug TEXT NOT NULL UNIQUE, |
|
| 79 | + | alias TEXT, |
|
| 80 | + | canonical_url TEXT, |
|
| 81 | + | published_date TEXT, |
|
| 82 | + | meta_description TEXT, |
|
| 83 | + | meta_image TEXT, |
|
| 84 | + | lang TEXT NOT NULL DEFAULT 'en', |
|
| 85 | + | tags TEXT, |
|
| 86 | + | content TEXT NOT NULL, |
|
| 87 | + | status TEXT NOT NULL DEFAULT 'draft', |
|
| 88 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 89 | + | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 90 | + | ); |
|
| 91 | + | ||
| 92 | + | CREATE TABLE IF NOT EXISTS pages ( |
|
| 93 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 94 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 95 | + | title TEXT NOT NULL, |
|
| 96 | + | slug TEXT NOT NULL UNIQUE, |
|
| 97 | + | content TEXT NOT NULL, |
|
| 98 | + | is_published INTEGER NOT NULL DEFAULT 0, |
|
| 99 | + | nav_order INTEGER NOT NULL DEFAULT 0, |
|
| 100 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 101 | + | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 102 | + | ); |
|
| 103 | + | ||
| 104 | + | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 105 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 106 | + | token TEXT NOT NULL UNIQUE, |
|
| 107 | + | expires_at TEXT NOT NULL |
|
| 108 | + | ); |
|
| 78 | 109 | ||
| 79 | - | conn.execute_batch( |
|
| 80 | - | "CREATE TABLE IF NOT EXISTS posts ( |
|
| 81 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 82 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 83 | - | title TEXT NOT NULL, |
|
| 84 | - | slug TEXT NOT NULL UNIQUE, |
|
| 85 | - | alias TEXT, |
|
| 86 | - | canonical_url TEXT, |
|
| 87 | - | published_date TEXT, |
|
| 88 | - | meta_description TEXT, |
|
| 89 | - | meta_image TEXT, |
|
| 90 | - | lang TEXT NOT NULL DEFAULT 'en', |
|
| 91 | - | tags TEXT, |
|
| 92 | - | content TEXT NOT NULL, |
|
| 93 | - | status TEXT NOT NULL DEFAULT 'draft', |
|
| 94 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 95 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 96 | - | ); |
|
| 110 | + | CREATE TABLE IF NOT EXISTS settings ( |
|
| 111 | + | key TEXT PRIMARY KEY, |
|
| 112 | + | value TEXT NOT NULL |
|
| 113 | + | ); |
|
| 97 | 114 | ||
| 98 | - | CREATE TABLE IF NOT EXISTS pages ( |
|
| 99 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 100 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 101 | - | title TEXT NOT NULL, |
|
| 102 | - | slug TEXT NOT NULL UNIQUE, |
|
| 103 | - | content TEXT NOT NULL, |
|
| 104 | - | is_published INTEGER NOT NULL DEFAULT 0, |
|
| 105 | - | nav_order INTEGER NOT NULL DEFAULT 0, |
|
| 106 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 107 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 108 | - | ); |
|
| 115 | + | CREATE TABLE IF NOT EXISTS files ( |
|
| 116 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 117 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 118 | + | filename TEXT NOT NULL UNIQUE, |
|
| 119 | + | original_name TEXT NOT NULL, |
|
| 120 | + | content_type TEXT NOT NULL DEFAULT 'application/octet-stream', |
|
| 121 | + | size INTEGER NOT NULL, |
|
| 122 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 123 | + | ); |
|
| 124 | + | "; |
|
| 109 | 125 | ||
| 110 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 111 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 112 | - | token TEXT NOT NULL UNIQUE, |
|
| 113 | - | expires_at TEXT NOT NULL |
|
| 114 | - | ); |
|
| 126 | + | const DEFAULT_SETTINGS: &[(&str, &str)] = &[ |
|
| 127 | + | ("blog_title", "My Blog"), |
|
| 128 | + | ("blog_description", "A simple blog"), |
|
| 129 | + | ("intro_content", ""), |
|
| 130 | + | ("nav_links", "[blog](/) [posts](/posts)"), |
|
| 131 | + | ("custom_css", ""), |
|
| 132 | + | ("favicon_url", ""), |
|
| 133 | + | ("og_image_url", ""), |
|
| 134 | + | ("custom_header", ""), |
|
| 135 | + | ("custom_footer", "<div> |
|
| 136 | + | <a href=\"/feed.xml\" class=\"rss-link\" title=\"RSS Feed\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" fill=\"currentColor\" viewBox=\"0 0 256 256\"><path fill=\"currentColor\" d=\"M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8\"/></svg></a> |
|
| 137 | + | </div>"), |
|
| 138 | + | ]; |
|
| 115 | 139 | ||
| 116 | - | CREATE TABLE IF NOT EXISTS settings ( |
|
| 117 | - | key TEXT PRIMARY KEY, |
|
| 118 | - | value TEXT NOT NULL |
|
| 119 | - | ); |
|
| 140 | + | pub fn init_db() -> Db { |
|
| 141 | + | let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string()); |
|
| 142 | + | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 120 | 143 | ||
| 121 | - | CREATE TABLE IF NOT EXISTS files ( |
|
| 122 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 123 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 124 | - | filename TEXT NOT NULL UNIQUE, |
|
| 125 | - | original_name TEXT NOT NULL, |
|
| 126 | - | content_type TEXT NOT NULL DEFAULT 'application/octet-stream', |
|
| 127 | - | size INTEGER NOT NULL, |
|
| 128 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 129 | - | );" |
|
| 130 | - | ) |
|
| 131 | - | .expect("Failed to create tables"); |
|
| 144 | + | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 132 | 145 | ||
| 133 | - | // Seed default settings |
|
| 134 | - | conn.execute( |
|
| 135 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_title', 'My Blog')", |
|
| 136 | - | [], |
|
| 137 | - | ) |
|
| 138 | - | .ok(); |
|
| 139 | - | conn.execute( |
|
| 140 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('blog_description', 'A simple blog')", |
|
| 141 | - | [], |
|
| 142 | - | ) |
|
| 143 | - | .ok(); |
|
| 144 | - | conn.execute( |
|
| 145 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('intro_content', '')", |
|
| 146 | - | [], |
|
| 147 | - | ) |
|
| 148 | - | .ok(); |
|
| 149 | - | conn.execute( |
|
| 150 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('nav_links', '[blog](/) [posts](/posts)')", |
|
| 151 | - | [], |
|
| 152 | - | ) |
|
| 153 | - | .ok(); |
|
| 154 | - | conn.execute( |
|
| 155 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('custom_css', '')", |
|
| 156 | - | [], |
|
| 157 | - | ) |
|
| 158 | - | .ok(); |
|
| 159 | - | conn.execute( |
|
| 160 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('favicon_url', '')", |
|
| 161 | - | [], |
|
| 162 | - | ) |
|
| 163 | - | .ok(); |
|
| 164 | - | conn.execute( |
|
| 165 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('og_image_url', '')", |
|
| 166 | - | [], |
|
| 167 | - | ) |
|
| 168 | - | .ok(); |
|
| 169 | - | conn.execute( |
|
| 170 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('custom_header', '')", |
|
| 171 | - | [], |
|
| 172 | - | ) |
|
| 173 | - | .ok(); |
|
| 174 | - | conn.execute( |
|
| 175 | - | "INSERT OR IGNORE INTO settings (key, value) VALUES ('custom_footer', '<div> |
|
| 176 | - | <a href=\"/feed.xml\" class=\"rss-link\" title=\"RSS Feed\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" fill=\"currentColor\" viewBox=\"0 0 256 256\"><path fill=\"currentColor\" d=\"M104.08 151.92A67.52 67.52 0 0 1 124 200a4 4 0 0 1-8 0a60 60 0 0 0-60-60a4 4 0 0 1 0-8a67.52 67.52 0 0 1 48.08 19.92M56 84a4 4 0 0 0 0 8a108 108 0 0 1 108 108a4 4 0 0 0 8 0A116 116 0 0 0 56 84m116 0A162.92 162.92 0 0 0 56 36a4 4 0 0 0 0 8a155 155 0 0 1 110.31 45.69A155 155 0 0 1 212 200a4 4 0 0 0 8 0a162.92 162.92 0 0 0-48-116M60 188a8 8 0 1 0 8 8a8 8 0 0 0-8-8\"/></svg></a> |
|
| 177 | - | </div>')", |
|
| 178 | - | [], |
|
| 179 | - | ) |
|
| 180 | - | .ok(); |
|
| 146 | + | for (key, value) in DEFAULT_SETTINGS { |
|
| 147 | + | conn.execute( |
|
| 148 | + | "INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)", |
|
| 149 | + | params![key, value], |
|
| 150 | + | ) |
|
| 151 | + | .ok(); |
|
| 152 | + | } |
|
| 181 | 153 | ||
| 182 | 154 | Arc::new(Mutex::new(conn)) |
|
| 183 | 155 | } |
|
| 184 | 156 | ||
| 185 | 157 | // --- Post CRUD --- |
|
| 186 | 158 | ||
| 187 | - | fn row_to_post(row: &rusqlite::Row) -> rusqlite::Result<Post> { |
|
| 188 | - | Ok(Post { |
|
| 189 | - | id: row.get(0)?, |
|
| 190 | - | short_id: row.get(1)?, |
|
| 191 | - | title: row.get(2)?, |
|
| 192 | - | slug: row.get(3)?, |
|
| 193 | - | alias: row.get(4)?, |
|
| 194 | - | canonical_url: row.get(5)?, |
|
| 195 | - | published_date: row.get(6)?, |
|
| 196 | - | meta_description: row.get(7)?, |
|
| 197 | - | meta_image: row.get(8)?, |
|
| 198 | - | lang: row.get(9)?, |
|
| 199 | - | tags: row.get(10)?, |
|
| 200 | - | content: row.get(11)?, |
|
| 201 | - | status: row.get(12)?, |
|
| 202 | - | created_at: row.get(13)?, |
|
| 203 | - | updated_at: row.get(14)?, |
|
| 204 | - | }) |
|
| 205 | - | } |
|
| 206 | - | ||
| 207 | 159 | const POST_COLS: &str = "id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at"; |
|
| 208 | 160 | ||
| 209 | - | pub fn create_post( |
|
| 210 | - | db: &Db, |
|
| 211 | - | title: &str, |
|
| 212 | - | slug: &str, |
|
| 213 | - | content: &str, |
|
| 214 | - | status: &str, |
|
| 215 | - | alias: Option<&str>, |
|
| 216 | - | canonical_url: Option<&str>, |
|
| 217 | - | published_date: Option<&str>, |
|
| 218 | - | meta_description: Option<&str>, |
|
| 219 | - | meta_image: Option<&str>, |
|
| 220 | - | lang: &str, |
|
| 221 | - | tags: Option<&str>, |
|
| 222 | - | ) -> Result<Post, DbError> { |
|
| 161 | + | pub fn create_post(db: &Db, input: &PostInput) -> Result<Post, DbError> { |
|
| 223 | 162 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 224 | 163 | let short_id = nanoid!(10); |
|
| 164 | + | let named = serde_rusqlite::to_params_named(input) |
|
| 165 | + | .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; |
|
| 166 | + | let mut bindings = named.to_slice(); |
|
| 167 | + | bindings.push((":short_id", &short_id)); |
|
| 225 | 168 | conn.execute( |
|
| 226 | 169 | "INSERT INTO posts (short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags) |
|
| 227 | - | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", |
|
| 228 | - | params![short_id, title, slug, content, status, alias, canonical_url, published_date, meta_description, meta_image, lang, tags], |
|
| 170 | + | VALUES (:short_id, :title, :slug, :content, :status, :alias, :canonical_url, :published_date, :meta_description, :meta_image, :lang, :tags)", |
|
| 171 | + | bindings.as_slice(), |
|
| 229 | 172 | )?; |
|
| 230 | 173 | let id = conn.last_insert_rowid(); |
|
| 231 | 174 | let post = conn.query_row( |
|
| 232 | 175 | &format!("SELECT {} FROM posts WHERE id = ?1", POST_COLS), |
|
| 233 | 176 | params![id], |
|
| 234 | - | row_to_post, |
|
| 177 | + | from_row, |
|
| 235 | 178 | )?; |
|
| 236 | 179 | Ok(post) |
|
| 237 | 180 | } |
|
| 238 | 181 | ||
| 239 | 182 | pub fn get_post_by_short_id(db: &Db, short_id: &str) -> Result<Option<Post>, DbError> { |
|
| 240 | 183 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 241 | - | match conn.query_row( |
|
| 242 | - | &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS), |
|
| 243 | - | params![short_id], |
|
| 244 | - | row_to_post, |
|
| 245 | - | ) { |
|
| 246 | - | Ok(post) => Ok(Some(post)), |
|
| 247 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 248 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 249 | - | } |
|
| 184 | + | let post = conn |
|
| 185 | + | .query_row( |
|
| 186 | + | &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS), |
|
| 187 | + | params![short_id], |
|
| 188 | + | from_row, |
|
| 189 | + | ) |
|
| 190 | + | .optional()?; |
|
| 191 | + | Ok(post) |
|
| 250 | 192 | } |
|
| 251 | 193 | ||
| 252 | 194 | pub fn get_post_by_slug(db: &Db, slug: &str) -> Result<Option<Post>, DbError> { |
|
| 253 | 195 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 254 | - | match conn.query_row( |
|
| 255 | - | &format!("SELECT {} FROM posts WHERE slug = ?1", POST_COLS), |
|
| 256 | - | params![slug], |
|
| 257 | - | row_to_post, |
|
| 258 | - | ) { |
|
| 259 | - | Ok(post) => Ok(Some(post)), |
|
| 260 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 261 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 262 | - | } |
|
| 196 | + | let post = conn |
|
| 197 | + | .query_row( |
|
| 198 | + | &format!("SELECT {} FROM posts WHERE slug = ?1", POST_COLS), |
|
| 199 | + | params![slug], |
|
| 200 | + | from_row, |
|
| 201 | + | ) |
|
| 202 | + | .optional()?; |
|
| 203 | + | Ok(post) |
|
| 263 | 204 | } |
|
| 264 | 205 | ||
| 265 | 206 | pub fn get_all_posts(db: &Db) -> Result<Vec<Post>, DbError> { |
|
| 268 | 209 | &format!("SELECT {} FROM posts ORDER BY id DESC", POST_COLS), |
|
| 269 | 210 | )?; |
|
| 270 | 211 | let posts = stmt |
|
| 271 | - | .query_map([], row_to_post)? |
|
| 212 | + | .query_map([], from_row)? |
|
| 272 | 213 | .collect::<Result<Vec<_>, _>>()?; |
|
| 273 | 214 | Ok(posts) |
|
| 274 | 215 | } |
|
| 279 | 220 | &format!("SELECT {} FROM posts WHERE status = 'published' ORDER BY published_date DESC, id DESC", POST_COLS), |
|
| 280 | 221 | )?; |
|
| 281 | 222 | let posts = stmt |
|
| 282 | - | .query_map([], row_to_post)? |
|
| 223 | + | .query_map([], from_row)? |
|
| 283 | 224 | .collect::<Result<Vec<_>, _>>()?; |
|
| 284 | 225 | Ok(posts) |
|
| 285 | 226 | } |
|
| 286 | 227 | ||
| 287 | - | pub fn update_post( |
|
| 288 | - | db: &Db, |
|
| 289 | - | short_id: &str, |
|
| 290 | - | title: &str, |
|
| 291 | - | slug: &str, |
|
| 292 | - | content: &str, |
|
| 293 | - | status: &str, |
|
| 294 | - | alias: Option<&str>, |
|
| 295 | - | canonical_url: Option<&str>, |
|
| 296 | - | published_date: Option<&str>, |
|
| 297 | - | meta_description: Option<&str>, |
|
| 298 | - | meta_image: Option<&str>, |
|
| 299 | - | lang: &str, |
|
| 300 | - | tags: Option<&str>, |
|
| 301 | - | ) -> Result<Option<Post>, DbError> { |
|
| 228 | + | pub fn update_post(db: &Db, short_id: &str, input: &PostInput) -> Result<Option<Post>, DbError> { |
|
| 302 | 229 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 303 | - | let effective_published_date = if status == "published" { |
|
| 304 | - | Some(published_date.unwrap_or("")) |
|
| 230 | + | let effective_published_date = if input.status == "published" { |
|
| 231 | + | Some(input.published_date.unwrap_or("")) |
|
| 305 | 232 | } else { |
|
| 306 | - | published_date |
|
| 233 | + | input.published_date |
|
| 307 | 234 | }; |
|
| 308 | 235 | let rows = conn.execute( |
|
| 309 | 236 | "UPDATE posts SET title = ?1, slug = ?2, content = ?3, status = ?4, alias = ?5, canonical_url = ?6, |
|
| 310 | 237 | published_date = CASE WHEN ?4 = 'published' THEN COALESCE(?7, published_date, datetime('now')) ELSE ?7 END, |
|
| 311 | 238 | meta_description = ?8, meta_image = ?9, lang = ?10, tags = ?11, |
|
| 312 | 239 | updated_at = datetime('now') WHERE short_id = ?12", |
|
| 313 | - | params![title, slug, content, status, alias, canonical_url, effective_published_date, meta_description, meta_image, lang, tags, short_id], |
|
| 240 | + | params![input.title, input.slug, input.content, input.status, input.alias, input.canonical_url, effective_published_date, input.meta_description, input.meta_image, input.lang, input.tags, short_id], |
|
| 314 | 241 | )?; |
|
| 315 | 242 | if rows == 0 { |
|
| 316 | 243 | return Ok(None); |
|
| 317 | 244 | } |
|
| 318 | - | match conn.query_row( |
|
| 319 | - | &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS), |
|
| 320 | - | params![short_id], |
|
| 321 | - | row_to_post, |
|
| 322 | - | ) { |
|
| 323 | - | Ok(post) => Ok(Some(post)), |
|
| 324 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 325 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 326 | - | } |
|
| 245 | + | let post = conn |
|
| 246 | + | .query_row( |
|
| 247 | + | &format!("SELECT {} FROM posts WHERE short_id = ?1", POST_COLS), |
|
| 248 | + | params![short_id], |
|
| 249 | + | from_row, |
|
| 250 | + | ) |
|
| 251 | + | .optional()?; |
|
| 252 | + | Ok(post) |
|
| 327 | 253 | } |
|
| 328 | 254 | ||
| 329 | 255 | pub fn delete_post(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 334 | 260 | ||
| 335 | 261 | pub fn toggle_post_status(db: &Db, short_id: &str) -> Result<Option<String>, DbError> { |
|
| 336 | 262 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 337 | - | let current: String = match conn.query_row( |
|
| 338 | - | "SELECT status FROM posts WHERE short_id = ?1", |
|
| 339 | - | params![short_id], |
|
| 340 | - | |row| row.get(0), |
|
| 341 | - | ) { |
|
| 342 | - | Ok(s) => s, |
|
| 343 | - | Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), |
|
| 344 | - | Err(e) => return Err(DbError::Sqlite(e)), |
|
| 263 | + | let current: Option<String> = conn |
|
| 264 | + | .query_row( |
|
| 265 | + | "SELECT status FROM posts WHERE short_id = ?1", |
|
| 266 | + | params![short_id], |
|
| 267 | + | |row| row.get(0), |
|
| 268 | + | ) |
|
| 269 | + | .optional()?; |
|
| 270 | + | let current = match current { |
|
| 271 | + | Some(s) => s, |
|
| 272 | + | None => return Ok(None), |
|
| 345 | 273 | }; |
|
| 346 | 274 | let new_status = if current == "published" { "draft" } else { "published" }; |
|
| 347 | 275 | if new_status == "published" { |
|
| 360 | 288 | ||
| 361 | 289 | pub fn find_alias_redirect(db: &Db, alias: &str) -> Result<Option<String>, DbError> { |
|
| 362 | 290 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 363 | - | match conn.query_row( |
|
| 364 | - | "SELECT slug FROM posts WHERE alias = ?1 AND status = 'published'", |
|
| 365 | - | params![alias], |
|
| 366 | - | |row| row.get::<_, String>(0), |
|
| 367 | - | ) { |
|
| 368 | - | Ok(slug) => Ok(Some(format!("/posts/{}", slug))), |
|
| 369 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 370 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 371 | - | } |
|
| 291 | + | let slug: Option<String> = conn |
|
| 292 | + | .query_row( |
|
| 293 | + | "SELECT slug FROM posts WHERE alias = ?1 AND status = 'published'", |
|
| 294 | + | params![alias], |
|
| 295 | + | |row| row.get(0), |
|
| 296 | + | ) |
|
| 297 | + | .optional()?; |
|
| 298 | + | Ok(slug.map(|s| format!("/posts/{}", s))) |
|
| 372 | 299 | } |
|
| 373 | 300 | ||
| 374 | 301 | // --- Page CRUD --- |
|
| 375 | 302 | ||
| 376 | - | fn row_to_page(row: &rusqlite::Row) -> rusqlite::Result<Page> { |
|
| 377 | - | Ok(Page { |
|
| 378 | - | id: row.get(0)?, |
|
| 379 | - | short_id: row.get(1)?, |
|
| 380 | - | title: row.get(2)?, |
|
| 381 | - | slug: row.get(3)?, |
|
| 382 | - | content: row.get(4)?, |
|
| 383 | - | is_published: row.get::<_, i64>(5)? != 0, |
|
| 384 | - | nav_order: row.get(6)?, |
|
| 385 | - | created_at: row.get(7)?, |
|
| 386 | - | updated_at: row.get(8)?, |
|
| 387 | - | }) |
|
| 388 | - | } |
|
| 389 | - | ||
| 390 | 303 | const PAGE_COLS: &str = "id, short_id, title, slug, content, is_published, nav_order, created_at, updated_at"; |
|
| 391 | 304 | ||
| 392 | 305 | pub fn create_page( |
|
| 407 | 320 | let page = conn.query_row( |
|
| 408 | 321 | &format!("SELECT {} FROM pages WHERE id = ?1", PAGE_COLS), |
|
| 409 | 322 | params![id], |
|
| 410 | - | row_to_page, |
|
| 323 | + | from_row, |
|
| 411 | 324 | )?; |
|
| 412 | 325 | Ok(page) |
|
| 413 | 326 | } |
|
| 414 | 327 | ||
| 415 | 328 | pub fn get_page_by_short_id(db: &Db, short_id: &str) -> Result<Option<Page>, DbError> { |
|
| 416 | 329 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 417 | - | match conn.query_row( |
|
| 418 | - | &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS), |
|
| 419 | - | params![short_id], |
|
| 420 | - | row_to_page, |
|
| 421 | - | ) { |
|
| 422 | - | Ok(page) => Ok(Some(page)), |
|
| 423 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 424 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 425 | - | } |
|
| 330 | + | let page = conn |
|
| 331 | + | .query_row( |
|
| 332 | + | &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS), |
|
| 333 | + | params![short_id], |
|
| 334 | + | from_row, |
|
| 335 | + | ) |
|
| 336 | + | .optional()?; |
|
| 337 | + | Ok(page) |
|
| 426 | 338 | } |
|
| 427 | 339 | ||
| 428 | 340 | pub fn get_page_by_slug(db: &Db, slug: &str) -> Result<Option<Page>, DbError> { |
|
| 429 | 341 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 430 | - | match conn.query_row( |
|
| 431 | - | &format!("SELECT {} FROM pages WHERE slug = ?1", PAGE_COLS), |
|
| 432 | - | params![slug], |
|
| 433 | - | row_to_page, |
|
| 434 | - | ) { |
|
| 435 | - | Ok(page) => Ok(Some(page)), |
|
| 436 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 437 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 438 | - | } |
|
| 342 | + | let page = conn |
|
| 343 | + | .query_row( |
|
| 344 | + | &format!("SELECT {} FROM pages WHERE slug = ?1", PAGE_COLS), |
|
| 345 | + | params![slug], |
|
| 346 | + | from_row, |
|
| 347 | + | ) |
|
| 348 | + | .optional()?; |
|
| 349 | + | Ok(page) |
|
| 439 | 350 | } |
|
| 440 | 351 | ||
| 441 | 352 | pub fn get_all_pages(db: &Db) -> Result<Vec<Page>, DbError> { |
|
| 444 | 355 | &format!("SELECT {} FROM pages ORDER BY nav_order ASC, id ASC", PAGE_COLS), |
|
| 445 | 356 | )?; |
|
| 446 | 357 | let pages = stmt |
|
| 447 | - | .query_map([], row_to_page)? |
|
| 358 | + | .query_map([], from_row)? |
|
| 448 | 359 | .collect::<Result<Vec<_>, _>>()?; |
|
| 449 | 360 | Ok(pages) |
|
| 450 | 361 | } |
|
| 455 | 366 | &format!("SELECT {} FROM pages WHERE is_published = 1 ORDER BY nav_order ASC, id ASC", PAGE_COLS), |
|
| 456 | 367 | )?; |
|
| 457 | 368 | let pages = stmt |
|
| 458 | - | .query_map([], row_to_page)? |
|
| 369 | + | .query_map([], from_row)? |
|
| 459 | 370 | .collect::<Result<Vec<_>, _>>()?; |
|
| 460 | 371 | Ok(pages) |
|
| 461 | 372 | } |
|
| 477 | 388 | if rows == 0 { |
|
| 478 | 389 | return Ok(None); |
|
| 479 | 390 | } |
|
| 480 | - | match conn.query_row( |
|
| 481 | - | &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS), |
|
| 482 | - | params![short_id], |
|
| 483 | - | row_to_page, |
|
| 484 | - | ) { |
|
| 485 | - | Ok(page) => Ok(Some(page)), |
|
| 486 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 487 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 488 | - | } |
|
| 391 | + | let page = conn |
|
| 392 | + | .query_row( |
|
| 393 | + | &format!("SELECT {} FROM pages WHERE short_id = ?1", PAGE_COLS), |
|
| 394 | + | params![short_id], |
|
| 395 | + | from_row, |
|
| 396 | + | ) |
|
| 397 | + | .optional()?; |
|
| 398 | + | Ok(page) |
|
| 489 | 399 | } |
|
| 490 | 400 | ||
| 491 | 401 | pub fn delete_page(db: &Db, short_id: &str) -> Result<bool, DbError> { |
|
| 498 | 408 | ||
| 499 | 409 | pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> { |
|
| 500 | 410 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 501 | - | match conn.query_row( |
|
| 502 | - | "SELECT value FROM settings WHERE key = ?1", |
|
| 503 | - | params![key], |
|
| 504 | - | |row| row.get(0), |
|
| 505 | - | ) { |
|
| 506 | - | Ok(val) => Ok(Some(val)), |
|
| 507 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 508 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 509 | - | } |
|
| 411 | + | let val = conn |
|
| 412 | + | .query_row( |
|
| 413 | + | "SELECT value FROM settings WHERE key = ?1", |
|
| 414 | + | params![key], |
|
| 415 | + | |row| row.get(0), |
|
| 416 | + | ) |
|
| 417 | + | .optional()?; |
|
| 418 | + | Ok(val) |
|
| 510 | 419 | } |
|
| 511 | 420 | ||
| 512 | 421 | pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> { |
|
| 527 | 436 | Ok(settings) |
|
| 528 | 437 | } |
|
| 529 | 438 | ||
| 530 | - | // --- Session functions --- |
|
| 531 | - | ||
| 532 | - | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 533 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 534 | - | conn.execute( |
|
| 535 | - | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 536 | - | params![token, expires_at], |
|
| 537 | - | )?; |
|
| 538 | - | Ok(()) |
|
| 539 | - | } |
|
| 540 | - | ||
| 541 | - | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 542 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 543 | - | match conn.query_row( |
|
| 544 | - | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 545 | - | params![token], |
|
| 546 | - | |row| row.get(0), |
|
| 547 | - | ) { |
|
| 548 | - | Ok(val) => Ok(Some(val)), |
|
| 549 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 550 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 551 | - | } |
|
| 552 | - | } |
|
| 553 | - | ||
| 554 | - | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 555 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 556 | - | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 557 | - | Ok(()) |
|
| 558 | - | } |
|
| 559 | - | ||
| 560 | - | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 561 | - | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 562 | - | conn.execute( |
|
| 563 | - | "DELETE FROM sessions WHERE expires_at < datetime('now')", |
|
| 564 | - | [], |
|
| 565 | - | )?; |
|
| 566 | - | Ok(()) |
|
| 567 | - | } |
|
| 568 | - | ||
| 569 | 439 | // --- File CRUD --- |
|
| 570 | 440 | ||
| 571 | - | fn row_to_file(row: &rusqlite::Row) -> rusqlite::Result<UploadedFile> { |
|
| 572 | - | Ok(UploadedFile { |
|
| 573 | - | id: row.get(0)?, |
|
| 574 | - | short_id: row.get(1)?, |
|
| 575 | - | filename: row.get(2)?, |
|
| 576 | - | original_name: row.get(3)?, |
|
| 577 | - | content_type: row.get(4)?, |
|
| 578 | - | size: row.get(5)?, |
|
| 579 | - | created_at: row.get(6)?, |
|
| 580 | - | }) |
|
| 581 | - | } |
|
| 582 | - | ||
| 583 | 441 | const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at"; |
|
| 584 | 442 | ||
| 585 | 443 | pub fn create_file( |
|
| 599 | 457 | let file = conn.query_row( |
|
| 600 | 458 | &format!("SELECT {} FROM files WHERE id = ?1", FILE_COLS), |
|
| 601 | 459 | params![id], |
|
| 602 | - | row_to_file, |
|
| 460 | + | from_row, |
|
| 603 | 461 | )?; |
|
| 604 | 462 | Ok(file) |
|
| 605 | 463 | } |
|
| 610 | 468 | &format!("SELECT {} FROM files ORDER BY id DESC", FILE_COLS), |
|
| 611 | 469 | )?; |
|
| 612 | 470 | let files = stmt |
|
| 613 | - | .query_map([], row_to_file)? |
|
| 471 | + | .query_map([], from_row)? |
|
| 614 | 472 | .collect::<Result<Vec<_>, _>>()?; |
|
| 615 | 473 | Ok(files) |
|
| 616 | 474 | } |
|
| 617 | 475 | ||
| 618 | 476 | pub fn delete_file(db: &Db, short_id: &str) -> Result<Option<UploadedFile>, DbError> { |
|
| 619 | 477 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 620 | - | let file = match conn.query_row( |
|
| 621 | - | &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS), |
|
| 622 | - | params![short_id], |
|
| 623 | - | row_to_file, |
|
| 624 | - | ) { |
|
| 625 | - | Ok(f) => f, |
|
| 626 | - | Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), |
|
| 627 | - | Err(e) => return Err(DbError::Sqlite(e)), |
|
| 628 | - | }; |
|
| 629 | - | conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?; |
|
| 630 | - | Ok(Some(file)) |
|
| 478 | + | let file: Option<UploadedFile> = conn |
|
| 479 | + | .query_row( |
|
| 480 | + | &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS), |
|
| 481 | + | params![short_id], |
|
| 482 | + | from_row, |
|
| 483 | + | ) |
|
| 484 | + | .optional()?; |
|
| 485 | + | match file { |
|
| 486 | + | Some(f) => { |
|
| 487 | + | conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?; |
|
| 488 | + | Ok(Some(f)) |
|
| 489 | + | } |
|
| 490 | + | None => Ok(None), |
|
| 491 | + | } |
|
| 631 | 492 | } |
|
| 632 | 493 | ||
| 633 | 494 | #[cfg(test)] |
|
| 636 | 497 | ||
| 637 | 498 | fn test_db() -> Db { |
|
| 638 | 499 | let conn = Connection::open_in_memory().unwrap(); |
|
| 639 | - | conn.execute_batch( |
|
| 640 | - | "CREATE TABLE IF NOT EXISTS posts ( |
|
| 641 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 642 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 643 | - | title TEXT NOT NULL, |
|
| 644 | - | slug TEXT NOT NULL UNIQUE, |
|
| 645 | - | alias TEXT, |
|
| 646 | - | canonical_url TEXT, |
|
| 647 | - | published_date TEXT, |
|
| 648 | - | meta_description TEXT, |
|
| 649 | - | meta_image TEXT, |
|
| 650 | - | lang TEXT NOT NULL DEFAULT 'en', |
|
| 651 | - | tags TEXT, |
|
| 652 | - | content TEXT NOT NULL, |
|
| 653 | - | status TEXT NOT NULL DEFAULT 'draft', |
|
| 654 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 655 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 656 | - | ); |
|
| 657 | - | CREATE TABLE IF NOT EXISTS pages ( |
|
| 658 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 659 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 660 | - | title TEXT NOT NULL, |
|
| 661 | - | slug TEXT NOT NULL UNIQUE, |
|
| 662 | - | content TEXT NOT NULL, |
|
| 663 | - | is_published INTEGER NOT NULL DEFAULT 0, |
|
| 664 | - | nav_order INTEGER NOT NULL DEFAULT 0, |
|
| 665 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 666 | - | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 667 | - | ); |
|
| 668 | - | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 669 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 670 | - | token TEXT NOT NULL UNIQUE, |
|
| 671 | - | expires_at TEXT NOT NULL |
|
| 672 | - | ); |
|
| 673 | - | CREATE TABLE IF NOT EXISTS settings ( |
|
| 674 | - | key TEXT PRIMARY KEY, |
|
| 675 | - | value TEXT NOT NULL |
|
| 676 | - | ); |
|
| 677 | - | CREATE TABLE IF NOT EXISTS files ( |
|
| 678 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 679 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 680 | - | filename TEXT NOT NULL UNIQUE, |
|
| 681 | - | original_name TEXT NOT NULL, |
|
| 682 | - | content_type TEXT NOT NULL DEFAULT 'application/octet-stream', |
|
| 683 | - | size INTEGER NOT NULL, |
|
| 684 | - | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 685 | - | );", |
|
| 686 | - | ) |
|
| 687 | - | .unwrap(); |
|
| 500 | + | conn.execute_batch(SCHEMA).unwrap(); |
|
| 688 | 501 | Arc::new(Mutex::new(conn)) |
|
| 689 | 502 | } |
|
| 690 | 503 | ||
| 504 | + | fn test_post_input<'a>(title: &'a str, slug: &'a str, content: &'a str, status: &'a str) -> PostInput<'a> { |
|
| 505 | + | PostInput { |
|
| 506 | + | title, |
|
| 507 | + | slug, |
|
| 508 | + | content, |
|
| 509 | + | status, |
|
| 510 | + | alias: None, |
|
| 511 | + | canonical_url: None, |
|
| 512 | + | published_date: None, |
|
| 513 | + | meta_description: None, |
|
| 514 | + | meta_image: None, |
|
| 515 | + | lang: "en", |
|
| 516 | + | tags: None, |
|
| 517 | + | } |
|
| 518 | + | } |
|
| 519 | + | ||
| 691 | 520 | // ── Post CRUD ────────────────────────────────────────────────────── |
|
| 692 | 521 | ||
| 693 | 522 | #[test] |
|
| 694 | 523 | fn create_and_get_post() { |
|
| 695 | 524 | let db = test_db(); |
|
| 696 | - | let post = create_post( |
|
| 697 | - | &db, "Hello World", "hello-world", "# Hello", "draft", |
|
| 698 | - | None, None, None, None, None, "en", None, |
|
| 699 | - | ) |
|
| 700 | - | .unwrap(); |
|
| 525 | + | let post = create_post(&db, &test_post_input("Hello World", "hello-world", "# Hello", "draft")).unwrap(); |
|
| 701 | 526 | assert_eq!(post.title, "Hello World"); |
|
| 702 | 527 | assert_eq!(post.slug, "hello-world"); |
|
| 703 | 528 | assert_eq!(post.status, "draft"); |
|
| 709 | 534 | #[test] |
|
| 710 | 535 | fn get_post_by_slug_works() { |
|
| 711 | 536 | let db = test_db(); |
|
| 712 | - | create_post( |
|
| 713 | - | &db, "Test", "test-slug", "content", "published", |
|
| 714 | - | None, None, Some("2024-01-01"), None, None, "en", None, |
|
| 715 | - | ) |
|
| 716 | - | .unwrap(); |
|
| 537 | + | let mut input = test_post_input("Test", "test-slug", "content", "published"); |
|
| 538 | + | input.published_date = Some("2024-01-01"); |
|
| 539 | + | create_post(&db, &input).unwrap(); |
|
| 717 | 540 | ||
| 718 | 541 | let post = get_post_by_slug(&db, "test-slug").unwrap().unwrap(); |
|
| 719 | 542 | assert_eq!(post.title, "Test"); |
|
| 722 | 545 | #[test] |
|
| 723 | 546 | fn duplicate_slug_fails() { |
|
| 724 | 547 | let db = test_db(); |
|
| 725 | - | create_post(&db, "A", "same-slug", "a", "draft", None, None, None, None, None, "en", None).unwrap(); |
|
| 726 | - | let result = create_post(&db, "B", "same-slug", "b", "draft", None, None, None, None, None, "en", None); |
|
| 548 | + | create_post(&db, &test_post_input("A", "same-slug", "a", "draft")).unwrap(); |
|
| 549 | + | let result = create_post(&db, &test_post_input("B", "same-slug", "b", "draft")); |
|
| 727 | 550 | assert!(result.is_err()); |
|
| 728 | 551 | } |
|
| 729 | 552 | ||
| 730 | 553 | #[test] |
|
| 731 | 554 | fn get_all_posts_ordered_desc() { |
|
| 732 | 555 | let db = test_db(); |
|
| 733 | - | create_post(&db, "First", "first", "a", "draft", None, None, None, None, None, "en", None).unwrap(); |
|
| 734 | - | create_post(&db, "Second", "second", "b", "draft", None, None, None, None, None, "en", None).unwrap(); |
|
| 556 | + | create_post(&db, &test_post_input("First", "first", "a", "draft")).unwrap(); |
|
| 557 | + | create_post(&db, &test_post_input("Second", "second", "b", "draft")).unwrap(); |
|
| 735 | 558 | ||
| 736 | 559 | let all = get_all_posts(&db).unwrap(); |
|
| 737 | 560 | assert_eq!(all.len(), 2); |
|
| 742 | 565 | #[test] |
|
| 743 | 566 | fn get_published_posts_filters() { |
|
| 744 | 567 | let db = test_db(); |
|
| 745 | - | create_post(&db, "Draft", "draft", "a", "draft", None, None, None, None, None, "en", None).unwrap(); |
|
| 746 | - | create_post(&db, "Published", "pub", "b", "published", None, None, Some("2024-01-01"), None, None, "en", None).unwrap(); |
|
| 568 | + | create_post(&db, &test_post_input("Draft", "draft", "a", "draft")).unwrap(); |
|
| 569 | + | let mut input = test_post_input("Published", "pub", "b", "published"); |
|
| 570 | + | input.published_date = Some("2024-01-01"); |
|
| 571 | + | create_post(&db, &input).unwrap(); |
|
| 747 | 572 | ||
| 748 | 573 | let published = get_published_posts(&db).unwrap(); |
|
| 749 | 574 | assert_eq!(published.len(), 1); |
|
| 753 | 578 | #[test] |
|
| 754 | 579 | fn delete_post_works() { |
|
| 755 | 580 | let db = test_db(); |
|
| 756 | - | let post = create_post(&db, "Del", "del", "x", "draft", None, None, None, None, None, "en", None).unwrap(); |
|
| 581 | + | let post = create_post(&db, &test_post_input("Del", "del", "x", "draft")).unwrap(); |
|
| 757 | 582 | assert!(delete_post(&db, &post.short_id).unwrap()); |
|
| 758 | 583 | assert!(get_post_by_short_id(&db, &post.short_id).unwrap().is_none()); |
|
| 759 | 584 | } |
|
| 761 | 586 | #[test] |
|
| 762 | 587 | fn toggle_post_status_draft_to_published() { |
|
| 763 | 588 | let db = test_db(); |
|
| 764 | - | let post = create_post(&db, "Toggle", "toggle", "x", "draft", None, None, None, None, None, "en", None).unwrap(); |
|
| 589 | + | let post = create_post(&db, &test_post_input("Toggle", "toggle", "x", "draft")).unwrap(); |
|
| 765 | 590 | let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap(); |
|
| 766 | 591 | assert_eq!(new_status, "published"); |
|
| 767 | 592 | ||
| 773 | 598 | #[test] |
|
| 774 | 599 | fn toggle_post_status_published_to_draft() { |
|
| 775 | 600 | let db = test_db(); |
|
| 776 | - | let post = create_post(&db, "Toggle", "toggle", "x", "published", None, None, Some("2024-01-01"), None, None, "en", None).unwrap(); |
|
| 601 | + | let mut input = test_post_input("Toggle", "toggle", "x", "published"); |
|
| 602 | + | input.published_date = Some("2024-01-01"); |
|
| 603 | + | let post = create_post(&db, &input).unwrap(); |
|
| 777 | 604 | let new_status = toggle_post_status(&db, &post.short_id).unwrap().unwrap(); |
|
| 778 | 605 | assert_eq!(new_status, "draft"); |
|
| 779 | 606 | } |
|
| 781 | 608 | #[test] |
|
| 782 | 609 | fn find_alias_redirect_found() { |
|
| 783 | 610 | let db = test_db(); |
|
| 784 | - | create_post(&db, "Aliased", "aliased-post", "x", "published", Some("old-url"), None, Some("2024-01-01"), None, None, "en", None).unwrap(); |
|
| 611 | + | let mut input = test_post_input("Aliased", "aliased-post", "x", "published"); |
|
| 612 | + | input.alias = Some("old-url"); |
|
| 613 | + | input.published_date = Some("2024-01-01"); |
|
| 614 | + | create_post(&db, &input).unwrap(); |
|
| 785 | 615 | let redirect = find_alias_redirect(&db, "old-url").unwrap(); |
|
| 786 | 616 | assert_eq!(redirect, Some("/posts/aliased-post".to_string())); |
|
| 787 | 617 | } |
|
| 795 | 625 | #[test] |
|
| 796 | 626 | fn find_alias_redirect_only_published() { |
|
| 797 | 627 | let db = test_db(); |
|
| 798 | - | create_post(&db, "Draft Alias", "draft-alias", "x", "draft", Some("my-alias"), None, None, None, None, "en", None).unwrap(); |
|
| 628 | + | let mut input = test_post_input("Draft Alias", "draft-alias", "x", "draft"); |
|
| 629 | + | input.alias = Some("my-alias"); |
|
| 630 | + | create_post(&db, &input).unwrap(); |
|
| 799 | 631 | assert!(find_alias_redirect(&db, "my-alias").unwrap().is_none()); |
|
| 800 | 632 | } |
|
| 801 | 633 | ||
| 116 | 116 | attrs.published_date.trim().to_string() |
|
| 117 | 117 | }; |
|
| 118 | 118 | ||
| 119 | - | match db::create_post( |
|
| 120 | - | &state.db, |
|
| 119 | + | let input = db::PostInput { |
|
| 121 | 120 | title, |
|
| 122 | - | &slug, |
|
| 123 | - | &form.content, |
|
| 121 | + | slug: &slug, |
|
| 122 | + | content: &form.content, |
|
| 124 | 123 | status, |
|
| 125 | - | opt_str(&attrs.alias), |
|
| 126 | - | None, |
|
| 127 | - | Some(&published_date), |
|
| 128 | - | opt_str(&attrs.meta_description), |
|
| 129 | - | opt_str(&attrs.meta_image), |
|
| 124 | + | alias: opt_str(&attrs.alias), |
|
| 125 | + | canonical_url: None, |
|
| 126 | + | published_date: Some(&published_date), |
|
| 127 | + | meta_description: opt_str(&attrs.meta_description), |
|
| 128 | + | meta_image: opt_str(&attrs.meta_image), |
|
| 130 | 129 | lang, |
|
| 131 | - | opt_str(&attrs.tags), |
|
| 132 | - | ) { |
|
| 130 | + | tags: opt_str(&attrs.tags), |
|
| 131 | + | }; |
|
| 132 | + | match db::create_post(&state.db, &input) { |
|
| 133 | 133 | Ok(_) => Redirect::to("/admin").into_response(), |
|
| 134 | 134 | Err(e) => { |
|
| 135 | 135 | tracing::error!("Failed to create post: {}", e); |
|
| 184 | 184 | Some(attrs.published_date.trim().to_string()) |
|
| 185 | 185 | }; |
|
| 186 | 186 | ||
| 187 | - | match db::update_post( |
|
| 188 | - | &state.db, |
|
| 189 | - | &short_id, |
|
| 187 | + | let input = db::PostInput { |
|
| 190 | 188 | title, |
|
| 191 | - | &slug, |
|
| 192 | - | &form.content, |
|
| 189 | + | slug: &slug, |
|
| 190 | + | content: &form.content, |
|
| 193 | 191 | status, |
|
| 194 | - | opt_str(&attrs.alias), |
|
| 195 | - | None, |
|
| 196 | - | published_date.as_deref(), |
|
| 197 | - | opt_str(&attrs.meta_description), |
|
| 198 | - | opt_str(&attrs.meta_image), |
|
| 192 | + | alias: opt_str(&attrs.alias), |
|
| 193 | + | canonical_url: None, |
|
| 194 | + | published_date: published_date.as_deref(), |
|
| 195 | + | meta_description: opt_str(&attrs.meta_description), |
|
| 196 | + | meta_image: opt_str(&attrs.meta_image), |
|
| 199 | 197 | lang, |
|
| 200 | - | opt_str(&attrs.tags), |
|
| 201 | - | ) { |
|
| 198 | + | tags: opt_str(&attrs.tags), |
|
| 199 | + | }; |
|
| 200 | + | match db::update_post(&state.db, &short_id, &input) { |
|
| 202 | 201 | Ok(Some(_)) => Redirect::to("/admin").into_response(), |
|
| 203 | 202 | Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(), |
|
| 204 | 203 | Err(e) => { |
|
| 6 | 6 | # Copy workspace manifests |
|
| 7 | 7 | COPY Cargo.toml Cargo.lock . |
|
| 8 | 8 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 9 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 9 | 10 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 10 | 11 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 11 | 12 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 17 | 18 | ||
| 18 | 19 | # Create stubs for dependency caching |
|
| 19 | 20 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 21 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 20 | 22 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 21 | 23 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 22 | 24 | done |
|
| 28 | 28 | dotenvy = { workspace = true } |
|
| 29 | 29 | subtle = { workspace = true } |
|
| 30 | 30 | rusqlite = { workspace = true } |
|
| 31 | + | andromeda-db = { workspace = true } |
|
| 31 | 32 | askama = "0.15.4" |
|
| 32 | 33 | askama_web = { version = "0.15.1", features = ["axum-0.8"] } |
|
| 33 | 34 | ratatui = "0.30" |
| 6 | 6 | # Copy workspace manifests |
|
| 7 | 7 | COPY Cargo.toml Cargo.lock . |
|
| 8 | 8 | COPY crates/auth/Cargo.toml crates/auth/ |
|
| 9 | + | COPY crates/db/Cargo.toml crates/db/ |
|
| 9 | 10 | COPY apps/sipp/Cargo.toml apps/sipp/ |
|
| 10 | 11 | COPY apps/feeds/Cargo.toml apps/feeds/ |
|
| 11 | 12 | COPY apps/parcels/Cargo.toml apps/parcels/ |
|
| 17 | 18 | ||
| 18 | 19 | # Create stubs for dependency caching |
|
| 19 | 20 | RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \ |
|
| 21 | + | && mkdir -p crates/db/src && echo '' > crates/db/src/lib.rs \ |
|
| 20 | 22 | && for app in sipp feeds parcels jotts og shrink cellar posts; do \ |
|
| 21 | 23 | mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \ |
|
| 22 | 24 | done |
|
| 25 | 27 | ||
| 26 | 28 | # Copy real source |
|
| 27 | 29 | COPY crates/auth/src crates/auth/src |
|
| 30 | + | COPY crates/db/src crates/db/src |
|
| 28 | 31 | COPY apps/sipp/src apps/sipp/src |
|
| 29 | 32 | COPY apps/sipp/static apps/sipp/static |
|
| 30 | 33 | COPY apps/sipp/templates apps/sipp/templates |
|
| 31 | 34 | ||
| 32 | - | RUN touch apps/sipp/src/*.rs crates/auth/src/*.rs && cargo build --release -p sipp-so |
|
| 35 | + | RUN touch apps/sipp/src/*.rs crates/auth/src/*.rs crates/db/src/*.rs && cargo build --release -p sipp-so |
|
| 33 | 36 | ||
| 34 | 37 | FROM debian:bookworm-slim |
|
| 35 | 38 | COPY --from=builder /app/target/release/sipp /usr/local/bin/sipp |
|
| 1 | 1 | use nanoid::nanoid; |
|
| 2 | - | use rusqlite::{Connection, params}; |
|
| 2 | + | use rusqlite::{Connection, OptionalExtension, params}; |
|
| 3 | 3 | use serde::{Deserialize, Serialize}; |
|
| 4 | - | use std::fmt; |
|
| 5 | 4 | use std::sync::{Arc, Mutex}; |
|
| 6 | 5 | ||
| 7 | - | pub type Db = Arc<Mutex<Connection>>; |
|
| 8 | - | ||
| 9 | - | #[derive(Debug)] |
|
| 10 | - | pub enum DbError { |
|
| 11 | - | Sqlite(rusqlite::Error), |
|
| 12 | - | LockPoisoned, |
|
| 13 | - | } |
|
| 14 | - | ||
| 15 | - | impl fmt::Display for DbError { |
|
| 16 | - | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 17 | - | match self { |
|
| 18 | - | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 19 | - | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 20 | - | } |
|
| 21 | - | } |
|
| 22 | - | } |
|
| 23 | - | ||
| 24 | - | impl std::error::Error for DbError {} |
|
| 25 | - | ||
| 26 | - | impl From<rusqlite::Error> for DbError { |
|
| 27 | - | fn from(e: rusqlite::Error) -> Self { |
|
| 28 | - | DbError::Sqlite(e) |
|
| 29 | - | } |
|
| 30 | - | } |
|
| 6 | + | pub use andromeda_db::{Db, DbError}; |
|
| 31 | 7 | ||
| 32 | 8 | #[derive(Serialize, Deserialize)] |
|
| 33 | 9 | pub struct Snippet { |
|
| 37 | 13 | pub name: String, |
|
| 38 | 14 | } |
|
| 39 | 15 | ||
| 16 | + | const SNIPPET_COLS: &str = "id, short_id, content, name"; |
|
| 17 | + | ||
| 18 | + | fn snippet_from_row(row: &rusqlite::Row) -> rusqlite::Result<Snippet> { |
|
| 19 | + | Ok(Snippet { |
|
| 20 | + | id: row.get(0)?, |
|
| 21 | + | short_id: row.get(1)?, |
|
| 22 | + | content: row.get(2)?, |
|
| 23 | + | name: row.get(3)?, |
|
| 24 | + | }) |
|
| 25 | + | } |
|
| 26 | + | ||
| 40 | 27 | fn generate_short_id() -> String { |
|
| 41 | 28 | nanoid!(10) |
|
| 42 | 29 | } |
|
| 45 | 32 | std::env::var("SIPP_DB_PATH").unwrap_or_else(|_| "sipp.sqlite".to_string()) |
|
| 46 | 33 | } |
|
| 47 | 34 | ||
| 35 | + | const SCHEMA: &str = " |
|
| 36 | + | CREATE TABLE IF NOT EXISTS snippets ( |
|
| 37 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 38 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 39 | + | content TEXT NOT NULL, |
|
| 40 | + | name TEXT NOT NULL |
|
| 41 | + | ); |
|
| 42 | + | "; |
|
| 43 | + | ||
| 48 | 44 | pub fn init_db() -> Result<Db, DbError> { |
|
| 49 | 45 | let conn = Connection::open(db_path())?; |
|
| 50 | - | conn.execute( |
|
| 51 | - | "CREATE TABLE IF NOT EXISTS snippets ( |
|
| 52 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 53 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 54 | - | content TEXT NOT NULL, |
|
| 55 | - | name TEXT NOT NULL |
|
| 56 | - | )", |
|
| 57 | - | [], |
|
| 58 | - | )?; |
|
| 46 | + | conn.execute_batch(SCHEMA)?; |
|
| 59 | 47 | Ok(Arc::new(Mutex::new(conn))) |
|
| 60 | 48 | } |
|
| 61 | 49 | ||
| 77 | 65 | ||
| 78 | 66 | pub fn get_snippet_by_short_id(db: &Db, short_id: &str) -> Result<Option<Snippet>, DbError> { |
|
| 79 | 67 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 80 | - | match conn.query_row( |
|
| 81 | - | "SELECT id, short_id, content, name FROM snippets WHERE short_id = ?1", |
|
| 82 | - | params![short_id], |
|
| 83 | - | |row| { |
|
| 84 | - | Ok(Snippet { |
|
| 85 | - | id: row.get(0)?, |
|
| 86 | - | short_id: row.get(1)?, |
|
| 87 | - | content: row.get(2)?, |
|
| 88 | - | name: row.get(3)?, |
|
| 89 | - | }) |
|
| 90 | - | }, |
|
| 91 | - | ) { |
|
| 92 | - | Ok(snippet) => Ok(Some(snippet)), |
|
| 93 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 94 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 95 | - | } |
|
| 68 | + | let snippet = conn |
|
| 69 | + | .query_row( |
|
| 70 | + | &format!("SELECT {} FROM snippets WHERE short_id = ?1", SNIPPET_COLS), |
|
| 71 | + | params![short_id], |
|
| 72 | + | snippet_from_row, |
|
| 73 | + | ) |
|
| 74 | + | .optional()?; |
|
| 75 | + | Ok(snippet) |
|
| 96 | 76 | } |
|
| 97 | 77 | ||
| 98 | 78 | pub fn get_all_snippets(db: &Db) -> Result<Vec<Snippet>, DbError> { |
|
| 99 | 79 | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 100 | - | let mut stmt = conn |
|
| 101 | - | .prepare("SELECT id, short_id, content, name FROM snippets ORDER BY id DESC")?; |
|
| 102 | - | let snippets = stmt.query_map([], |row| { |
|
| 103 | - | Ok(Snippet { |
|
| 104 | - | id: row.get(0)?, |
|
| 105 | - | short_id: row.get(1)?, |
|
| 106 | - | content: row.get(2)?, |
|
| 107 | - | name: row.get(3)?, |
|
| 108 | - | }) |
|
| 109 | - | })? |
|
| 110 | - | .filter_map(|r| r.ok()) |
|
| 111 | - | .collect(); |
|
| 80 | + | let mut stmt = conn.prepare( |
|
| 81 | + | &format!("SELECT {} FROM snippets ORDER BY id DESC", SNIPPET_COLS), |
|
| 82 | + | )?; |
|
| 83 | + | let snippets = stmt |
|
| 84 | + | .query_map([], snippet_from_row)? |
|
| 85 | + | .collect::<Result<Vec<_>, _>>()?; |
|
| 112 | 86 | Ok(snippets) |
|
| 113 | 87 | } |
|
| 114 | 88 | ||
| 135 | 109 | if rows_affected == 0 { |
|
| 136 | 110 | return Ok(None); |
|
| 137 | 111 | } |
|
| 138 | - | match conn.query_row( |
|
| 139 | - | "SELECT id, short_id, content, name FROM snippets WHERE short_id = ?1", |
|
| 140 | - | params![short_id], |
|
| 141 | - | |row| { |
|
| 142 | - | Ok(Snippet { |
|
| 143 | - | id: row.get(0)?, |
|
| 144 | - | short_id: row.get(1)?, |
|
| 145 | - | content: row.get(2)?, |
|
| 146 | - | name: row.get(3)?, |
|
| 147 | - | }) |
|
| 148 | - | }, |
|
| 149 | - | ) { |
|
| 150 | - | Ok(snippet) => Ok(Some(snippet)), |
|
| 151 | - | Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), |
|
| 152 | - | Err(e) => Err(DbError::Sqlite(e)), |
|
| 153 | - | } |
|
| 112 | + | let snippet = conn |
|
| 113 | + | .query_row( |
|
| 114 | + | &format!("SELECT {} FROM snippets WHERE short_id = ?1", SNIPPET_COLS), |
|
| 115 | + | params![short_id], |
|
| 116 | + | snippet_from_row, |
|
| 117 | + | ) |
|
| 118 | + | .optional()?; |
|
| 119 | + | Ok(snippet) |
|
| 154 | 120 | } |
|
| 155 | 121 | ||
| 156 | 122 | #[cfg(test)] |
|
| 159 | 125 | ||
| 160 | 126 | fn test_db() -> Db { |
|
| 161 | 127 | let conn = Connection::open_in_memory().unwrap(); |
|
| 162 | - | conn.execute( |
|
| 163 | - | "CREATE TABLE IF NOT EXISTS snippets ( |
|
| 164 | - | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 165 | - | short_id TEXT NOT NULL UNIQUE, |
|
| 166 | - | content TEXT NOT NULL, |
|
| 167 | - | name TEXT NOT NULL |
|
| 168 | - | )", |
|
| 169 | - | [], |
|
| 170 | - | ) |
|
| 171 | - | .unwrap(); |
|
| 128 | + | conn.execute_batch(SCHEMA).unwrap(); |
|
| 172 | 129 | Arc::new(Mutex::new(conn)) |
|
| 173 | 130 | } |
|
| 174 | 131 | ||
| 1 | + | [package] |
|
| 2 | + | name = "andromeda-db" |
|
| 3 | + | version = "0.1.0" |
|
| 4 | + | edition = "2024" |
|
| 5 | + | description = "Shared database types and session management for Andromeda apps" |
|
| 6 | + | license = "MIT" |
|
| 7 | + | repository = "https://github.com/stevedylandev/andromeda" |
|
| 8 | + | homepage = "https://github.com/stevedylandev/andromeda" |
|
| 9 | + | ||
| 10 | + | [dependencies] |
|
| 11 | + | rusqlite = { workspace = true } |
|
| 12 | + | axum = { workspace = true, optional = true } |
|
| 13 | + | tracing = { workspace = true, optional = true } |
|
| 14 | + | ||
| 15 | + | [features] |
|
| 16 | + | default = [] |
|
| 17 | + | session = [] |
|
| 18 | + | axum = ["dep:axum", "dep:tracing"] |
| 1 | + | use rusqlite::Connection; |
|
| 2 | + | use std::fmt; |
|
| 3 | + | use std::sync::{Arc, Mutex}; |
|
| 4 | + | ||
| 5 | + | pub type Db = Arc<Mutex<Connection>>; |
|
| 6 | + | ||
| 7 | + | pub trait HasDb { |
|
| 8 | + | fn db(&self) -> &Db; |
|
| 9 | + | } |
|
| 10 | + | ||
| 11 | + | #[derive(Debug)] |
|
| 12 | + | pub enum DbError { |
|
| 13 | + | Sqlite(rusqlite::Error), |
|
| 14 | + | LockPoisoned, |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | impl fmt::Display for DbError { |
|
| 18 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 19 | + | match self { |
|
| 20 | + | DbError::Sqlite(e) => write!(f, "Database error: {}", e), |
|
| 21 | + | DbError::LockPoisoned => write!(f, "Database lock poisoned"), |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | impl std::error::Error for DbError {} |
|
| 27 | + | ||
| 28 | + | impl From<rusqlite::Error> for DbError { |
|
| 29 | + | fn from(e: rusqlite::Error) -> Self { |
|
| 30 | + | DbError::Sqlite(e) |
|
| 31 | + | } |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | #[cfg(feature = "axum")] |
|
| 35 | + | impl axum::response::IntoResponse for DbError { |
|
| 36 | + | fn into_response(self) -> axum::response::Response { |
|
| 37 | + | tracing::error!("{}", self); |
|
| 38 | + | ( |
|
| 39 | + | axum::http::StatusCode::INTERNAL_SERVER_ERROR, |
|
| 40 | + | "Server error", |
|
| 41 | + | ) |
|
| 42 | + | .into_response() |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | #[cfg(feature = "session")] |
|
| 47 | + | pub mod session; |
| 1 | + | use rusqlite::params; |
|
| 2 | + | ||
| 3 | + | use crate::{Db, DbError}; |
|
| 4 | + | ||
| 5 | + | pub const SESSION_SCHEMA: &str = " |
|
| 6 | + | CREATE TABLE IF NOT EXISTS sessions ( |
|
| 7 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 8 | + | token TEXT NOT NULL UNIQUE, |
|
| 9 | + | expires_at TEXT NOT NULL |
|
| 10 | + | ); |
|
| 11 | + | "; |
|
| 12 | + | ||
| 13 | + | pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> { |
|
| 14 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 15 | + | conn.execute( |
|
| 16 | + | "INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", |
|
| 17 | + | params![token, expires_at], |
|
| 18 | + | )?; |
|
| 19 | + | Ok(()) |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> { |
|
| 23 | + | use rusqlite::OptionalExtension; |
|
| 24 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 25 | + | let val = conn |
|
| 26 | + | .query_row( |
|
| 27 | + | "SELECT expires_at FROM sessions WHERE token = ?1", |
|
| 28 | + | params![token], |
|
| 29 | + | |row| row.get(0), |
|
| 30 | + | ) |
|
| 31 | + | .optional()?; |
|
| 32 | + | Ok(val) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> { |
|
| 36 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 37 | + | conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?; |
|
| 38 | + | Ok(()) |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> { |
|
| 42 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 43 | + | conn.execute( |
|
| 44 | + | "DELETE FROM sessions WHERE expires_at < datetime('now')", |
|
| 45 | + | [], |
|
| 46 | + | )?; |
|
| 47 | + | Ok(()) |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | #[cfg(test)] |
|
| 51 | + | mod tests { |
|
| 52 | + | use super::*; |
|
| 53 | + | use rusqlite::Connection; |
|
| 54 | + | use std::sync::{Arc, Mutex}; |
|
| 55 | + | ||
| 56 | + | fn test_db() -> Db { |
|
| 57 | + | let conn = Connection::open_in_memory().unwrap(); |
|
| 58 | + | conn.execute_batch(SESSION_SCHEMA).unwrap(); |
|
| 59 | + | Arc::new(Mutex::new(conn)) |
|
| 60 | + | } |
|
| 61 | + | ||
| 62 | + | #[test] |
|
| 63 | + | fn session_lifecycle() { |
|
| 64 | + | let db = test_db(); |
|
| 65 | + | insert_session(&db, "tok", "2099-12-31 23:59:59").unwrap(); |
|
| 66 | + | assert_eq!( |
|
| 67 | + | get_session_expiry(&db, "tok").unwrap(), |
|
| 68 | + | Some("2099-12-31 23:59:59".to_string()) |
|
| 69 | + | ); |
|
| 70 | + | delete_session(&db, "tok").unwrap(); |
|
| 71 | + | assert!(get_session_expiry(&db, "tok").unwrap().is_none()); |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | #[test] |
|
| 75 | + | fn prune_expired_sessions_works() { |
|
| 76 | + | let db = test_db(); |
|
| 77 | + | insert_session(&db, "old", "2000-01-01 00:00:00").unwrap(); |
|
| 78 | + | insert_session(&db, "new", "2099-01-01 00:00:00").unwrap(); |
|
| 79 | + | prune_expired_sessions(&db).unwrap(); |
|
| 80 | + | assert!(get_session_expiry(&db, "old").unwrap().is_none()); |
|
| 81 | + | assert!(get_session_expiry(&db, "new").unwrap().is_some()); |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | #[test] |
|
| 85 | + | fn missing_token_returns_none() { |
|
| 86 | + | let db = test_db(); |
|
| 87 | + | assert!(get_session_expiry(&db, "nonexistent").unwrap().is_none()); |
|
| 88 | + | } |
|
| 89 | + | } |