diff --git a/Cargo.lock b/Cargo.lock index 1e04f10..2d167e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,7 +316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core", ] @@ -504,9 +504,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -2207,11 +2207,13 @@ version = "0.1.0" dependencies = [ "axum", "chrono", + "pretty_assertions", "reqwest", "runway-caldav", "runway-core", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "thiserror 2.0.20", "tokio", @@ -2477,7 +2479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -2499,7 +2501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] diff --git a/Cargo.toml b/Cargo.toml index 467ab42..53593b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,9 @@ uuid = { version = "1", features = ["v4", "serde"] } icalendar = "0.17" rrule = "0.14" +# Crypto +sha2 = "0.10" + # Errors + logging thiserror = "2" tracing = "0.1" diff --git a/crates/runway-server/Cargo.toml b/crates/runway-server/Cargo.toml index 4b74a22..92cd735 100644 --- a/crates/runway-server/Cargo.toml +++ b/crates/runway-server/Cargo.toml @@ -12,6 +12,7 @@ axum = { workspace = true } chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } sqlx = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } @@ -23,6 +24,7 @@ uuid = { workspace = true } [dev-dependencies] reqwest = { workspace = true } +pretty_assertions = { workspace = true } [lints] workspace = true diff --git a/crates/runway-server/migrations/0001_initial.sql b/crates/runway-server/migrations/0001_initial.sql new file mode 100644 index 0000000..763c273 --- /dev/null +++ b/crates/runway-server/migrations/0001_initial.sql @@ -0,0 +1,145 @@ +-- Runway's schema. +-- +-- Two things went wrong last time and both are structural rather than +-- incidental, so they are addressed here rather than in the code above. +-- +-- First, per-calendar settings lived in a single `calendar_colors TEXT` column +-- holding JSON: colours, visibility and a custom palette all in one blob. There +-- was no way to change one calendar's colour without rewriting every user's +-- whole preference document, no way to query it, and no way for the database to +-- reject nonsense. Those are three tables now. +-- +-- Second, `external_calendars.user_id` was declared INTEGER while `users.id` is +-- a TEXT uuid. SQLite does not enforce foreign key types, so the mismatch was +-- silent and the constraint could never match. Every key here is TEXT, and +-- foreign keys are enforced (see `PRAGMA foreign_keys` at connection time). +-- +-- Timestamps are RFC 3339 strings in UTC. SQLite has no date type; picking one +-- representation and using it everywhere beats v1's mix of TEXT and DATETIME. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + -- The CalDAV identity. A person is defined by the account they log into, + -- so the same username on two servers is two users. + username TEXT NOT NULL, + server_url TEXT NOT NULL, + display_name TEXT, + created_at TEXT NOT NULL, + last_login_at TEXT, + UNIQUE (username, server_url) +); + +-- The CalDAV password, encrypted at rest. +-- +-- v1 kept this in the browser's localStorage in cleartext and re-sent it on +-- every request in an `X-CalDAV-Password` header, read from eight different +-- call sites. The server has to be able to replay it to the CalDAV server, so +-- it cannot be hashed -- but it can be encrypted, and it can live in exactly +-- one place with one way in and one way out. +CREATE TABLE credentials ( + user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + -- Ciphertext and nonce as bytes, not base64: the encoding is the storage + -- layer's business and nothing else needs to know about it. + ciphertext BLOB NOT NULL, + nonce BLOB NOT NULL, + -- Named so the scheme can be changed later without guessing what old rows + -- were encrypted with. + algorithm TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Sessions, keyed on a hash of the token rather than the token. +-- +-- The cookie holds a random secret; this table holds only its SHA-256. A copy +-- of the database therefore cannot be used to impersonate anybody, which was +-- not true of v1's `sessions.token`. +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + -- Enough to show someone a list of their signed-in devices later. + user_agent TEXT +); + +CREATE INDEX idx_sessions_user ON sessions (user_id); +CREATE INDEX idx_sessions_expires ON sessions (expires_at); + +-- Preferences that are single values, one column each. +CREATE TABLE preferences ( + user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, + -- The two token axes: colour and shape/density. + theme TEXT NOT NULL DEFAULT 'default', + style TEXT NOT NULL DEFAULT 'default', + -- 'month' | 'week' | 'day' | 'agenda' | 'year' + view TEXT NOT NULL DEFAULT 'week', + -- Grid granularity in minutes. + time_increment INTEGER NOT NULL DEFAULT 30, + -- 0 = Sunday, matching chrono's Weekday::num_days_from_sunday. + week_starts_on INTEGER NOT NULL DEFAULT 0, + -- The IANA zone to render in. Never an offset: an offset cannot tell + -- January from July, which is what made v1's recurring events drift. + -- NULL means "use whatever the browser reports", which is the right + -- default for someone who moves. + display_timezone TEXT, + -- Where a new event goes by default. + default_calendar TEXT, + updated_at TEXT NOT NULL, + + CHECK (time_increment IN (5, 10, 15, 20, 30, 60)), + CHECK (week_starts_on BETWEEN 0 AND 6), + CHECK (view IN ('month', 'week', 'day', 'agenda', 'year')) +); + +-- Per-calendar settings: one row per calendar, not one JSON blob per user. +-- +-- `color` is nullable on purpose. NULL means "whatever the server says", which +-- is a different statement from any particular colour -- v1 could not express +-- it, so it hashed the calendar's path to invent one and disagreed with every +-- other client about what colour a calendar was. +CREATE TABLE calendar_settings ( + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + calendar_href TEXT NOT NULL, + color TEXT, + visible INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + + PRIMARY KEY (user_id, calendar_href), + CHECK (visible IN (0, 1)) +); + +-- A subscribed read-only ICS feed. +CREATE TABLE feeds ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + name TEXT NOT NULL, + url TEXT NOT NULL, + color TEXT, + visible INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + UNIQUE (user_id, url), + CHECK (visible IN (0, 1)) +); + +CREATE INDEX idx_feeds_user ON feeds (user_id); + +-- What a feed returned last time, and what to say when asking again. +-- +-- `etag` and `last_modified` are both nullable because a real published Outlook +-- calendar sends neither. `content_hash` is the fallback that makes staleness +-- detectable anyway: fetch, hash, compare, and skip the reparse when it matches. +CREATE TABLE feed_cache ( + feed_id TEXT PRIMARY KEY REFERENCES feeds (id) ON DELETE CASCADE, + body TEXT NOT NULL, + content_hash TEXT NOT NULL, + etag TEXT, + last_modified TEXT, + fetched_at TEXT NOT NULL, + -- From Cache-Control or Expires, when the server offers one. + expires_at TEXT +); diff --git a/crates/runway-server/src/db/calendars.rs b/crates/runway-server/src/db/calendars.rs new file mode 100644 index 0000000..f1f3c7c --- /dev/null +++ b/crates/runway-server/src/db/calendars.rs @@ -0,0 +1,161 @@ +//! Per-calendar settings: colour, visibility, order. +//! +//! This is the JSON blob unpacked. v1 stored all of it — every calendar's +//! colour, every visibility toggle, and a custom 16-colour palette — inside a +//! single `calendar_colors TEXT` column, so hiding one calendar rewrote the +//! whole document, nothing could be queried, and nothing could be validated. +//! +//! One row per calendar per user, with the database enforcing that `visible` is +//! a boolean and that a user cannot have two rows for the same calendar. + +use super::{Database, DbError, UserId, parse_stamp, stamp}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CalendarSetting { + pub calendar_href: String, + /// `None` means "whatever the server says". + /// + /// A distinct state from any particular colour, and one v1 could not + /// express: it hashed the calendar's path to invent one, so Runway + /// disagreed with every other client about what colour a calendar was. + pub color: Option, + pub visible: bool, + pub position: i64, + pub updated_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct SettingRow { + calendar_href: String, + color: Option, + visible: i64, + position: i64, + updated_at: String, +} + +impl TryFrom for CalendarSetting { + type Error = DbError; + + fn try_from(row: SettingRow) -> Result { + Ok(Self { + calendar_href: row.calendar_href, + color: row.color, + visible: row.visible != 0, + position: row.position, + updated_at: parse_stamp(&row.updated_at)?, + }) + } +} + +pub struct CalendarSettings<'a> { + db: &'a Database, +} + +impl<'a> CalendarSettings<'a> { + pub(crate) fn new(db: &'a Database) -> Self { + Self { db } + } + + /// Every setting a user has recorded, in display order. + /// + /// Calendars the user has never touched simply have no row; the caller + /// merges these onto what the CalDAV server reports rather than expecting + /// this to be a complete list. + pub async fn list(&self, user_id: &UserId) -> Result, DbError> { + let rows: Vec = sqlx::query_as( + "SELECT calendar_href, color, visible, position, updated_at + FROM calendar_settings WHERE user_id = ? + ORDER BY position, calendar_href", + ) + .bind(user_id.as_str()) + .fetch_all(self.db.pool()) + .await?; + rows.into_iter().map(CalendarSetting::try_from).collect() + } + + pub async fn get( + &self, + user_id: &UserId, + calendar_href: &str, + ) -> Result, DbError> { + let row: Option = sqlx::query_as( + "SELECT calendar_href, color, visible, position, updated_at + FROM calendar_settings WHERE user_id = ? AND calendar_href = ?", + ) + .bind(user_id.as_str()) + .bind(calendar_href) + .fetch_optional(self.db.pool()) + .await?; + row.map(CalendarSetting::try_from).transpose() + } + + /// Records settings for one calendar. + pub async fn set( + &self, + user_id: &UserId, + calendar_href: &str, + color: Option<&str>, + visible: bool, + position: i64, + ) -> Result<(), DbError> { + sqlx::query( + "INSERT INTO calendar_settings + (user_id, calendar_href, color, visible, position, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, calendar_href) DO UPDATE SET + color = excluded.color, + visible = excluded.visible, + position = excluded.position, + updated_at = excluded.updated_at", + ) + .bind(user_id.as_str()) + .bind(calendar_href) + .bind(color) + .bind(i64::from(visible)) + .bind(position) + .bind(stamp(Utc::now())) + .execute(self.db.pool()) + .await?; + Ok(()) + } + + /// Toggles one calendar without disturbing its colour or position. + /// + /// The operation the sidebar actually performs. Doing it as a read, a + /// modify and a write of the whole document is how v1 produced + /// "Fix calendar visibility preservation during event updates". + pub async fn set_visible( + &self, + user_id: &UserId, + calendar_href: &str, + visible: bool, + ) -> Result<(), DbError> { + sqlx::query( + "INSERT INTO calendar_settings + (user_id, calendar_href, visible, position, updated_at) + VALUES (?, ?, ?, 0, ?) + ON CONFLICT (user_id, calendar_href) DO UPDATE SET + visible = excluded.visible, + updated_at = excluded.updated_at", + ) + .bind(user_id.as_str()) + .bind(calendar_href) + .bind(i64::from(visible)) + .bind(stamp(Utc::now())) + .execute(self.db.pool()) + .await?; + Ok(()) + } + + pub async fn remove(&self, user_id: &UserId, calendar_href: &str) -> Result { + let result = + sqlx::query("DELETE FROM calendar_settings WHERE user_id = ? AND calendar_href = ?") + .bind(user_id.as_str()) + .bind(calendar_href) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected() > 0) + } +} diff --git a/crates/runway-server/src/db/credentials.rs b/crates/runway-server/src/db/credentials.rs new file mode 100644 index 0000000..f9ef8c8 --- /dev/null +++ b/crates/runway-server/src/db/credentials.rs @@ -0,0 +1,114 @@ +//! The CalDAV password, at rest. +//! +//! This is the one secret the server cannot hash, because it has to be replayed +//! to the CalDAV server on every request made on the user's behalf. So it is +//! encrypted instead, and this module is the only way in or out. +//! +//! v1 kept it in the browser's localStorage in cleartext and read it from eight +//! separate call sites in `app.rs` alone, resending it in an `X-CalDAV-Password` +//! header. The encryption itself lands with authentication; the storage shape +//! is here, and it names the algorithm so a future change does not have to +//! guess what old rows were encrypted with. + +use super::{Database, DbError, UserId, parse_stamp, stamp}; +use chrono::{DateTime, Utc}; + +/// An encrypted credential exactly as stored. +/// +/// Deliberately opaque: nothing here can read the password, and `Debug` shows +/// lengths rather than bytes so a stray log line cannot leak ciphertext. +#[derive(Clone, PartialEq, Eq)] +pub struct StoredCredential { + pub ciphertext: Vec, + pub nonce: Vec, + pub algorithm: String, + pub updated_at: DateTime, +} + +impl std::fmt::Debug for StoredCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredCredential") + .field("ciphertext", &format!("<{} bytes>", self.ciphertext.len())) + .field("nonce", &format!("<{} bytes>", self.nonce.len())) + .field("algorithm", &self.algorithm) + .field("updated_at", &self.updated_at) + .finish() + } +} + +#[derive(sqlx::FromRow)] +struct CredentialRow { + ciphertext: Vec, + nonce: Vec, + algorithm: String, + updated_at: String, +} + +impl TryFrom for StoredCredential { + type Error = DbError; + + fn try_from(row: CredentialRow) -> Result { + Ok(Self { + ciphertext: row.ciphertext, + nonce: row.nonce, + algorithm: row.algorithm, + updated_at: parse_stamp(&row.updated_at)?, + }) + } +} + +pub struct Credentials<'a> { + db: &'a Database, +} + +impl<'a> Credentials<'a> { + pub(crate) fn new(db: &'a Database) -> Self { + Self { db } + } + + /// Stores or replaces a user's credential. + pub async fn store( + &self, + user_id: &UserId, + ciphertext: &[u8], + nonce: &[u8], + algorithm: &str, + ) -> Result<(), DbError> { + sqlx::query( + "INSERT INTO credentials (user_id, ciphertext, nonce, algorithm, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (user_id) DO UPDATE SET + ciphertext = excluded.ciphertext, + nonce = excluded.nonce, + algorithm = excluded.algorithm, + updated_at = excluded.updated_at", + ) + .bind(user_id.as_str()) + .bind(ciphertext) + .bind(nonce) + .bind(algorithm) + .bind(stamp(Utc::now())) + .execute(self.db.pool()) + .await?; + Ok(()) + } + + pub async fn load(&self, user_id: &UserId) -> Result, DbError> { + let row: Option = sqlx::query_as( + "SELECT ciphertext, nonce, algorithm, updated_at + FROM credentials WHERE user_id = ?", + ) + .bind(user_id.as_str()) + .fetch_optional(self.db.pool()) + .await?; + row.map(StoredCredential::try_from).transpose() + } + + pub async fn delete(&self, user_id: &UserId) -> Result { + let result = sqlx::query("DELETE FROM credentials WHERE user_id = ?") + .bind(user_id.as_str()) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected() > 0) + } +} diff --git a/crates/runway-server/src/db/error.rs b/crates/runway-server/src/db/error.rs new file mode 100644 index 0000000..22ffce9 --- /dev/null +++ b/crates/runway-server/src/db/error.rs @@ -0,0 +1,25 @@ +//! Storage errors. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DbError { + /// The statement failed, or the database is unreachable. + #[error("database error: {0}")] + Sql(#[from] sqlx::Error), + + /// The schema could not be brought up to date. + #[error("migration failed: {0}")] + Migration(#[from] sqlx::migrate::MigrateError), + + /// A row held something the schema should have prevented. + /// + /// Separate from `Sql` on purpose: this means the data is wrong, not that + /// the query was, and the two want different responses. + #[error("stored data is not readable: {0}")] + Corrupt(String), + + /// A row that had to exist did not. + #[error("no {entity} with id {id}")] + NotFound { entity: &'static str, id: String }, +} diff --git a/crates/runway-server/src/db/feeds.rs b/crates/runway-server/src/db/feeds.rs new file mode 100644 index 0000000..450214b --- /dev/null +++ b/crates/runway-server/src/db/feeds.rs @@ -0,0 +1,267 @@ +//! Subscribed ICS feeds, and what they last returned. + +use super::{Database, DbError, FeedId, UserId, parse_stamp, stamp}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Feed { + pub id: FeedId, + pub user_id: UserId, + pub name: String, + pub url: String, + pub color: Option, + pub visible: bool, + pub position: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// What is needed to subscribe to a feed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NewFeed { + pub name: String, + pub url: String, + pub color: Option, +} + +#[derive(sqlx::FromRow)] +struct FeedRow { + id: String, + user_id: String, + name: String, + url: String, + color: Option, + visible: i64, + position: i64, + created_at: String, + updated_at: String, +} + +impl TryFrom for Feed { + type Error = DbError; + + fn try_from(row: FeedRow) -> Result { + Ok(Self { + id: FeedId(row.id), + user_id: UserId(row.user_id), + name: row.name, + url: row.url, + color: row.color, + visible: row.visible != 0, + position: row.position, + created_at: parse_stamp(&row.created_at)?, + updated_at: parse_stamp(&row.updated_at)?, + }) + } +} + +pub struct Feeds<'a> { + db: &'a Database, +} + +impl<'a> Feeds<'a> { + pub(crate) fn new(db: &'a Database) -> Self { + Self { db } + } + + pub async fn create(&self, user_id: &UserId, feed: &NewFeed) -> Result { + let now = Utc::now(); + let row: FeedRow = sqlx::query_as( + "INSERT INTO feeds + (id, user_id, name, url, color, visible, position, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 1, + (SELECT COALESCE(MAX(position) + 1, 0) FROM feeds WHERE user_id = ?), + ?, ?) + RETURNING *", + ) + .bind(FeedId::new().as_str()) + .bind(user_id.as_str()) + .bind(&feed.name) + .bind(&feed.url) + .bind(feed.color.as_deref()) + .bind(user_id.as_str()) + .bind(stamp(now)) + .bind(stamp(now)) + .fetch_one(self.db.pool()) + .await?; + row.try_into() + } + + pub async fn list(&self, user_id: &UserId) -> Result, DbError> { + let rows: Vec = + sqlx::query_as("SELECT * FROM feeds WHERE user_id = ? ORDER BY position, name") + .bind(user_id.as_str()) + .fetch_all(self.db.pool()) + .await?; + rows.into_iter().map(Feed::try_from).collect() + } + + pub async fn find(&self, id: &FeedId) -> Result, DbError> { + let row: Option = sqlx::query_as("SELECT * FROM feeds WHERE id = ?") + .bind(id.as_str()) + .fetch_optional(self.db.pool()) + .await?; + row.map(Feed::try_from).transpose() + } + + /// Updates the parts of a feed a person can edit. + pub async fn update( + &self, + id: &FeedId, + name: &str, + color: Option<&str>, + visible: bool, + ) -> Result { + let row: Option = sqlx::query_as( + "UPDATE feeds SET name = ?, color = ?, visible = ?, updated_at = ? + WHERE id = ? RETURNING *", + ) + .bind(name) + .bind(color) + .bind(i64::from(visible)) + .bind(stamp(Utc::now())) + .bind(id.as_str()) + .fetch_optional(self.db.pool()) + .await?; + + row.ok_or_else(|| DbError::NotFound { + entity: "feed", + id: id.to_string(), + })? + .try_into() + } + + /// Removes a feed and its cached body. + pub async fn delete(&self, id: &FeedId) -> Result { + let result = sqlx::query("DELETE FROM feeds WHERE id = ?") + .bind(id.as_str()) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected() > 0) + } +} + +/// A feed's last response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CachedFeed { + pub body: String, + /// SHA-256 of the body. + /// + /// The fallback that makes staleness detectable when a server offers no + /// validators — a published Outlook calendar sends neither `ETag` nor + /// `Last-Modified`, so without this every poll would be a full reparse. + pub content_hash: String, + pub etag: Option, + pub last_modified: Option, + pub fetched_at: DateTime, + pub expires_at: Option>, +} + +#[derive(sqlx::FromRow)] +struct CacheRow { + body: String, + content_hash: String, + etag: Option, + last_modified: Option, + fetched_at: String, + expires_at: Option, +} + +impl TryFrom for CachedFeed { + type Error = DbError; + + fn try_from(row: CacheRow) -> Result { + Ok(Self { + body: row.body, + content_hash: row.content_hash, + etag: row.etag, + last_modified: row.last_modified, + fetched_at: parse_stamp(&row.fetched_at)?, + expires_at: row.expires_at.as_deref().map(parse_stamp).transpose()?, + }) + } +} + +pub struct FeedCache<'a> { + db: &'a Database, +} + +impl<'a> FeedCache<'a> { + pub(crate) fn new(db: &'a Database) -> Self { + Self { db } + } + + pub async fn store(&self, feed_id: &FeedId, cached: &CachedFeed) -> Result<(), DbError> { + sqlx::query( + "INSERT INTO feed_cache + (feed_id, body, content_hash, etag, last_modified, fetched_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (feed_id) DO UPDATE SET + body = excluded.body, + content_hash = excluded.content_hash, + etag = excluded.etag, + last_modified = excluded.last_modified, + fetched_at = excluded.fetched_at, + expires_at = excluded.expires_at", + ) + .bind(feed_id.as_str()) + .bind(&cached.body) + .bind(&cached.content_hash) + .bind(cached.etag.as_deref()) + .bind(cached.last_modified.as_deref()) + .bind(stamp(cached.fetched_at)) + .bind(cached.expires_at.map(stamp)) + .execute(self.db.pool()) + .await?; + Ok(()) + } + + pub async fn load(&self, feed_id: &FeedId) -> Result, DbError> { + let row: Option = sqlx::query_as( + "SELECT body, content_hash, etag, last_modified, fetched_at, expires_at + FROM feed_cache WHERE feed_id = ?", + ) + .bind(feed_id.as_str()) + .fetch_optional(self.db.pool()) + .await?; + row.map(CachedFeed::try_from).transpose() + } + + /// Records that a conditional request came back "not modified". + /// + /// The body is untouched; only the freshness moves. Rewriting the body here + /// would be harmless but wasteful, and the distinction is worth keeping + /// visible. + pub async fn touch( + &self, + feed_id: &FeedId, + fetched_at: DateTime, + expires_at: Option>, + ) -> Result<(), DbError> { + sqlx::query("UPDATE feed_cache SET fetched_at = ?, expires_at = ? WHERE feed_id = ?") + .bind(stamp(fetched_at)) + .bind(expires_at.map(stamp)) + .bind(feed_id.as_str()) + .execute(self.db.pool()) + .await?; + Ok(()) + } + + pub async fn clear(&self, feed_id: &FeedId) -> Result { + let result = sqlx::query("DELETE FROM feed_cache WHERE feed_id = ?") + .bind(feed_id.as_str()) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected() > 0) + } +} + +/// SHA-256 of a feed body, as stored in `content_hash`. +pub fn hash_body(body: &str) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/crates/runway-server/src/db/ids.rs b/crates/runway-server/src/db/ids.rs new file mode 100644 index 0000000..bb185d4 --- /dev/null +++ b/crates/runway-server/src/db/ids.rs @@ -0,0 +1,60 @@ +//! Identifiers. +//! +//! Newtypes rather than bare `String`s, so a feed id cannot be passed where a +//! user id belongs. They are stored as TEXT: v1 declared +//! `external_calendars.user_id INTEGER` against a TEXT `users.id`, and because +//! SQLite does not enforce foreign key types the constraint silently could +//! never match. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +macro_rules! id_type { + ($name:ident, $entity:literal) => { + #[doc = concat!("The identity of a ", $entity, ".")] + #[derive( + Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, sqlx::Type, + )] + #[serde(transparent)] + #[sqlx(transparent)] + pub struct $name(pub String); + + impl $name { + /// A fresh random identifier. + pub fn new() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } + } + + impl From for $name { + fn from(value: String) -> Self { + Self(value) + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } + } + }; +} + +id_type!(UserId, "user"); +id_type!(FeedId, "subscribed feed"); diff --git a/crates/runway-server/src/db/mod.rs b/crates/runway-server/src/db/mod.rs new file mode 100644 index 0000000..c7280ab --- /dev/null +++ b/crates/runway-server/src/db/mod.rs @@ -0,0 +1,144 @@ +//! Storage. +//! +//! A thin repository over SQLite: one module per table group, every query +//! exercised by a test against a real database rather than a mock. There is no +//! ORM and no query builder — the statements are short enough to read, and +//! reading them is the point. +//! +//! Everything here takes and returns typed values. The previous iteration +//! stored per-calendar colours, visibility and a custom palette together in one +//! `calendar_colors TEXT` column of JSON, so changing one calendar's colour +//! meant rewriting the whole document and nothing could be queried or checked. + +mod calendars; +mod credentials; +mod error; +mod feeds; +mod ids; +mod preferences; +mod sessions; +mod users; + +pub use calendars::{CalendarSetting, CalendarSettings}; +pub use credentials::{Credentials, StoredCredential}; +pub use error::DbError; +pub use feeds::{CachedFeed, Feed, FeedCache, Feeds, NewFeed, hash_body}; +pub use ids::{FeedId, UserId}; +pub use preferences::{Preferences, PreferencesRepo, View}; +pub use sessions::{Session, Sessions}; +pub use users::{User, Users}; + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{Pool, Sqlite}; +use std::str::FromStr; +use std::time::Duration; + +/// A connection pool with the schema applied. +#[derive(Debug, Clone)] +pub struct Database { + pool: Pool, +} + +impl Database { + /// Opens (creating if needed) a database at a URL such as + /// `sqlite:runway.db`, and brings the schema up to date. + pub async fn connect(url: &str) -> Result { + let options = SqliteConnectOptions::from_str(url) + .map_err(DbError::from)? + .create_if_missing(true) + // Off by default in SQLite, which is how v1's mismatched + // `user_id INTEGER` referencing a TEXT primary key went unnoticed. + .foreign_keys(true) + // Concurrent readers alongside a writer, which a web server wants. + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(5)); + + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(options) + .await?; + + let database = Self { pool }; + database.migrate().await?; + Ok(database) + } + + /// A private database that exists only for the life of the process. + /// + /// Used by tests, so they run against real SQLite — the same engine, the + /// same constraints, the same type affinities — without a file to clean up. + pub async fn in_memory() -> Result { + Self::connect("sqlite::memory:").await + } + + /// Applies any migrations the database has not seen. + pub async fn migrate(&self) -> Result<(), DbError> { + sqlx::migrate!("./migrations").run(&self.pool).await?; + Ok(()) + } + + pub(crate) fn pool(&self) -> &Pool { + &self.pool + } + + /// Raw pool access, for tests that need to assert on what is *actually* + /// stored rather than on what the repository hands back. + /// + /// Two things are only checkable this way: that a session token never + /// reaches the disk, and that the schema's CHECK constraints reject bad + /// values by whatever route they arrive. Not for ordinary use — going + /// around the repository is how a codebase ends up with the same query + /// written four slightly different ways. + #[doc(hidden)] + pub fn pool_for_tests(&self) -> &Pool { + &self.pool + } + + pub fn users(&self) -> Users<'_> { + Users::new(self) + } + + pub fn credentials(&self) -> Credentials<'_> { + Credentials::new(self) + } + + pub fn sessions(&self) -> Sessions<'_> { + Sessions::new(self) + } + + pub fn preferences(&self) -> PreferencesRepo<'_> { + PreferencesRepo::new(self) + } + + pub fn calendar_settings(&self) -> CalendarSettings<'_> { + CalendarSettings::new(self) + } + + pub fn feeds(&self) -> Feeds<'_> { + Feeds::new(self) + } + + pub fn feed_cache(&self) -> FeedCache<'_> { + FeedCache::new(self) + } + + /// Closes the pool, waiting for in-flight statements. + pub async fn close(&self) { + self.pool.close().await; + } +} + +/// The one timestamp format the schema uses. +/// +/// SQLite has no date type. v1 wrote some columns as TEXT and others as +/// DATETIME and compared them against each other; picking one representation +/// and never deviating is most of what makes that class of bug impossible. +pub(crate) fn stamp(at: chrono::DateTime) -> String { + at.to_rfc3339_opts(chrono::SecondsFormat::Micros, true) +} + +pub(crate) fn parse_stamp(text: &str) -> Result, DbError> { + chrono::DateTime::parse_from_rfc3339(text) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .map_err(|e| DbError::Corrupt(format!("bad timestamp {text:?}: {e}"))) +} diff --git a/crates/runway-server/src/db/preferences.rs b/crates/runway-server/src/db/preferences.rs new file mode 100644 index 0000000..c89d809 --- /dev/null +++ b/crates/runway-server/src/db/preferences.rs @@ -0,0 +1,186 @@ +//! Single-valued preferences: one column each, not one JSON document. +//! +//! v1 kept these in localStorage *and* in a `user_preferences` table with no +//! defined source of truth — `calendar_view_mode`, `calendar_theme`, +//! `calendar_style`, `calendar_selected_date`, `calendar_time_increment`, +//! `calendar_colors` and a `user_preferences` blob containing the others, all +//! coexisting, some written to both places and some to one. +//! +//! There is one home for each of these now, and the database rejects values +//! that make no sense rather than trusting whatever the client sent. + +use super::{Database, DbError, UserId, parse_stamp, stamp}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Which view the calendar opens in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum View { + Month, + #[default] + Week, + Day, + Agenda, + Year, +} + +impl View { + pub fn as_str(self) -> &'static str { + match self { + Self::Month => "month", + Self::Week => "week", + Self::Day => "day", + Self::Agenda => "agenda", + Self::Year => "year", + } + } + + fn parse(text: &str) -> Result { + match text { + "month" => Ok(Self::Month), + "week" => Ok(Self::Week), + "day" => Ok(Self::Day), + "agenda" => Ok(Self::Agenda), + "year" => Ok(Self::Year), + other => Err(DbError::Corrupt(format!("unknown view {other:?}"))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Preferences { + pub theme: String, + pub style: String, + pub view: View, + /// Grid granularity in minutes. + pub time_increment: u16, + /// 0 = Sunday, matching `chrono::Weekday::num_days_from_sunday`. + pub week_starts_on: u8, + /// The IANA zone to render in. + /// + /// `None` means "follow the browser", which is the right default for + /// somebody who moves — and the reason this is a zone name and never an + /// offset. + pub display_timezone: Option, + /// Where a new event goes unless told otherwise. + pub default_calendar: Option, +} + +impl Default for Preferences { + fn default() -> Self { + Self { + theme: "default".to_owned(), + style: "default".to_owned(), + view: View::Week, + time_increment: 30, + week_starts_on: 0, + display_timezone: None, + default_calendar: None, + } + } +} + +#[derive(sqlx::FromRow)] +struct PreferencesRow { + theme: String, + style: String, + view: String, + time_increment: i64, + week_starts_on: i64, + display_timezone: Option, + default_calendar: Option, + updated_at: String, +} + +impl PreferencesRow { + fn into_preferences(self) -> Result<(Preferences, DateTime), DbError> { + let updated_at = parse_stamp(&self.updated_at)?; + Ok(( + Preferences { + theme: self.theme, + style: self.style, + view: View::parse(&self.view)?, + time_increment: u16::try_from(self.time_increment) + .map_err(|_| DbError::Corrupt("time_increment out of range".to_owned()))?, + week_starts_on: u8::try_from(self.week_starts_on) + .map_err(|_| DbError::Corrupt("week_starts_on out of range".to_owned()))?, + display_timezone: self.display_timezone, + default_calendar: self.default_calendar, + }, + updated_at, + )) + } +} + +pub struct PreferencesRepo<'a> { + db: &'a Database, +} + +impl<'a> PreferencesRepo<'a> { + pub(crate) fn new(db: &'a Database) -> Self { + Self { db } + } + + /// A user's preferences, or the defaults if they have never saved any. + /// + /// Never `None`: "no row yet" and "the defaults" are the same thing to + /// every caller, and collapsing them here means no caller has to remember. + pub async fn load(&self, user_id: &UserId) -> Result { + let row: Option = + sqlx::query_as("SELECT * FROM preferences WHERE user_id = ?") + .bind(user_id.as_str()) + .fetch_optional(self.db.pool()) + .await?; + match row { + Some(row) => Ok(row.into_preferences()?.0), + None => Ok(Preferences::default()), + } + } + + /// When these were last written, if ever. + pub async fn updated_at(&self, user_id: &UserId) -> Result>, DbError> { + let row: Option = + sqlx::query_as("SELECT * FROM preferences WHERE user_id = ?") + .bind(user_id.as_str()) + .fetch_optional(self.db.pool()) + .await?; + row.map(|r| r.into_preferences().map(|(_, at)| at)) + .transpose() + } + + /// Writes a user's preferences. + /// + /// The whole set at once, because that is what the client holds and a + /// partial update would need a way to say "leave this alone" that JSON + /// nulls cannot express unambiguously. + pub async fn save(&self, user_id: &UserId, preferences: &Preferences) -> Result<(), DbError> { + sqlx::query( + "INSERT INTO preferences + (user_id, theme, style, view, time_increment, week_starts_on, + display_timezone, default_calendar, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id) DO UPDATE SET + theme = excluded.theme, + style = excluded.style, + view = excluded.view, + time_increment = excluded.time_increment, + week_starts_on = excluded.week_starts_on, + display_timezone = excluded.display_timezone, + default_calendar = excluded.default_calendar, + updated_at = excluded.updated_at", + ) + .bind(user_id.as_str()) + .bind(&preferences.theme) + .bind(&preferences.style) + .bind(preferences.view.as_str()) + .bind(i64::from(preferences.time_increment)) + .bind(i64::from(preferences.week_starts_on)) + .bind(preferences.display_timezone.as_deref()) + .bind(preferences.default_calendar.as_deref()) + .bind(stamp(Utc::now())) + .execute(self.db.pool()) + .await?; + Ok(()) + } +} diff --git a/crates/runway-server/src/db/sessions.rs b/crates/runway-server/src/db/sessions.rs new file mode 100644 index 0000000..fc03182 --- /dev/null +++ b/crates/runway-server/src/db/sessions.rs @@ -0,0 +1,172 @@ +//! Sessions. +//! +//! The cookie carries a random secret. This table stores only its SHA-256, so +//! a leaked copy of the database cannot be used to impersonate anyone — v1 +//! stored the token itself, which meant read access to the file was read +//! access to every account. +//! +//! Hashing is enough here, unlike for a password: the token is long and random, +//! so there is nothing to guess and no need for a slow KDF. + +use super::{Database, DbError, UserId, parse_stamp, stamp}; +use chrono::{DateTime, TimeDelta, Utc}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Session { + pub user_id: UserId, + pub created_at: DateTime, + pub expires_at: DateTime, + pub last_seen_at: DateTime, + pub user_agent: Option, +} + +#[derive(sqlx::FromRow)] +struct SessionRow { + user_id: String, + created_at: String, + expires_at: String, + last_seen_at: String, + user_agent: Option, +} + +impl TryFrom for Session { + type Error = DbError; + + fn try_from(row: SessionRow) -> Result { + Ok(Self { + user_id: UserId(row.user_id), + created_at: parse_stamp(&row.created_at)?, + expires_at: parse_stamp(&row.expires_at)?, + last_seen_at: parse_stamp(&row.last_seen_at)?, + user_agent: row.user_agent, + }) + } +} + +/// The stored form of a session token. +/// +/// The only way a token becomes a database key, so there is no path that +/// accidentally stores the raw value. +pub(crate) fn hash_token(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub struct Sessions<'a> { + db: &'a Database, +} + +impl<'a> Sessions<'a> { + pub(crate) fn new(db: &'a Database) -> Self { + Self { db } + } + + /// Starts a session for a token the caller generated. + /// + /// The token is not returned, because this never had it in a form worth + /// keeping — the caller owns it and puts it in the cookie. + pub async fn create( + &self, + user_id: &UserId, + token: &str, + lifetime: TimeDelta, + user_agent: Option<&str>, + ) -> Result { + let now = Utc::now(); + let expires = now + lifetime; + // RETURNING rather than reconstructing the row in Rust. Stored + // timestamps are truncated to microseconds, so a hand-built return + // value would carry sub-microsecond digits the database does not have + // and would not compare equal to the same session read back. + let row: SessionRow = sqlx::query_as( + "INSERT INTO sessions + (token_hash, user_id, created_at, expires_at, last_seen_at, user_agent) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING *", + ) + .bind(hash_token(token)) + .bind(user_id.as_str()) + .bind(stamp(now)) + .bind(stamp(expires)) + .bind(stamp(now)) + .bind(user_agent) + .fetch_one(self.db.pool()) + .await?; + row.try_into() + } + + /// Looks a token up, touching `last_seen_at` if it is still valid. + /// + /// An expired session reads as absent rather than as an error: for the + /// caller there is no difference between a session that ran out and one + /// that never existed, and treating them alike removes a branch where a + /// mistake would let an expired session through. + pub async fn touch(&self, token: &str) -> Result, DbError> { + let now = Utc::now(); + let row: Option = sqlx::query_as( + "UPDATE sessions SET last_seen_at = ? + WHERE token_hash = ? AND expires_at > ? + RETURNING *", + ) + .bind(stamp(now)) + .bind(hash_token(token)) + .bind(stamp(now)) + .fetch_optional(self.db.pool()) + .await?; + row.map(Session::try_from).transpose() + } + + /// Reads a session without touching it. + pub async fn peek(&self, token: &str) -> Result, DbError> { + let row: Option = + sqlx::query_as("SELECT * FROM sessions WHERE token_hash = ? AND expires_at > ?") + .bind(hash_token(token)) + .bind(stamp(Utc::now())) + .fetch_optional(self.db.pool()) + .await?; + row.map(Session::try_from).transpose() + } + + /// Ends one session — a logout. + pub async fn delete(&self, token: &str) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE token_hash = ?") + .bind(hash_token(token)) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Ends every session for a user — a password change, or "sign out + /// everywhere". + pub async fn delete_for_user(&self, user_id: &UserId) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?") + .bind(user_id.as_str()) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected()) + } + + /// Removes expired rows. Expiry is enforced on lookup regardless; this is + /// housekeeping, not a security boundary. + pub async fn purge_expired(&self) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= ?") + .bind(stamp(Utc::now())) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected()) + } + + /// Every live session for a user, newest first. + pub async fn list_for_user(&self, user_id: &UserId) -> Result, DbError> { + let rows: Vec = sqlx::query_as( + "SELECT * FROM sessions WHERE user_id = ? AND expires_at > ? + ORDER BY created_at DESC", + ) + .bind(user_id.as_str()) + .bind(stamp(Utc::now())) + .fetch_all(self.db.pool()) + .await?; + rows.into_iter().map(Session::try_from).collect() + } +} diff --git a/crates/runway-server/src/db/users.rs b/crates/runway-server/src/db/users.rs new file mode 100644 index 0000000..b9a9d46 --- /dev/null +++ b/crates/runway-server/src/db/users.rs @@ -0,0 +1,125 @@ +//! Accounts. +//! +//! A user is a CalDAV identity: a username on a particular server. The same +//! username on two servers is two people as far as this is concerned, which is +//! what the unique constraint says. + +use super::{Database, DbError, UserId, parse_stamp, stamp}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct User { + pub id: UserId, + pub username: String, + pub server_url: String, + pub display_name: Option, + pub created_at: DateTime, + pub last_login_at: Option>, +} + +#[derive(sqlx::FromRow)] +struct UserRow { + id: String, + username: String, + server_url: String, + display_name: Option, + created_at: String, + last_login_at: Option, +} + +impl TryFrom for User { + type Error = DbError; + + fn try_from(row: UserRow) -> Result { + Ok(Self { + id: UserId(row.id), + username: row.username, + server_url: row.server_url, + display_name: row.display_name, + created_at: parse_stamp(&row.created_at)?, + last_login_at: row.last_login_at.as_deref().map(parse_stamp).transpose()?, + }) + } +} + +pub struct Users<'a> { + db: &'a Database, +} + +impl<'a> Users<'a> { + pub(crate) fn new(db: &'a Database) -> Self { + Self { db } + } + + /// Records a successful login, creating the account if this is the first. + /// + /// One statement rather than a read followed by a conditional write: two + /// logins arriving together would otherwise race, and the loser would hit + /// the unique constraint. + pub async fn record_login( + &self, + username: &str, + server_url: &str, + display_name: Option<&str>, + ) -> Result { + let now = Utc::now(); + let row: UserRow = sqlx::query_as( + "INSERT INTO users (id, username, server_url, display_name, created_at, last_login_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (username, server_url) DO UPDATE SET + last_login_at = excluded.last_login_at, + -- Only overwrite the display name when a new one was offered. + display_name = COALESCE(excluded.display_name, users.display_name) + RETURNING *", + ) + .bind(UserId::new().as_str()) + .bind(username) + .bind(server_url) + .bind(display_name) + .bind(stamp(now)) + .bind(stamp(now)) + .fetch_one(self.db.pool()) + .await?; + row.try_into() + } + + pub async fn find(&self, id: &UserId) -> Result, DbError> { + let row: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(id.as_str()) + .fetch_optional(self.db.pool()) + .await?; + row.map(User::try_from).transpose() + } + + pub async fn find_by_login( + &self, + username: &str, + server_url: &str, + ) -> Result, DbError> { + let row: Option = + sqlx::query_as("SELECT * FROM users WHERE username = ? AND server_url = ?") + .bind(username) + .bind(server_url) + .fetch_optional(self.db.pool()) + .await?; + row.map(User::try_from).transpose() + } + + pub async fn all(&self) -> Result, DbError> { + let rows: Vec = + sqlx::query_as("SELECT * FROM users ORDER BY username, server_url") + .fetch_all(self.db.pool()) + .await?; + rows.into_iter().map(User::try_from).collect() + } + + /// Removes an account and, by cascade, everything belonging to it. + pub async fn delete(&self, id: &UserId) -> Result { + let result = sqlx::query("DELETE FROM users WHERE id = ?") + .bind(id.as_str()) + .execute(self.db.pool()) + .await?; + Ok(result.rows_affected() > 0) + } +} diff --git a/crates/runway-server/src/lib.rs b/crates/runway-server/src/lib.rs index bd362cf..5d0cf73 100644 --- a/crates/runway-server/src/lib.rs +++ b/crates/runway-server/src/lib.rs @@ -1 +1,3 @@ //! Runway backend: a CalDAV proxy with sessions, preferences and ICS feeds. + +pub mod db; diff --git a/crates/runway-server/tests/db.rs b/crates/runway-server/tests/db.rs new file mode 100644 index 0000000..bf55ad3 --- /dev/null +++ b/crates/runway-server/tests/db.rs @@ -0,0 +1,793 @@ +//! The storage layer, against real SQLite. +//! +//! In-memory rather than mocked: the same engine, the same constraints, the +//! same type affinities, and the same `PRAGMA foreign_keys` behaviour the +//! deployed database has. A mock would agree with whatever the code believes, +//! which is not the question worth asking. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use chrono::{TimeDelta, Utc}; +use pretty_assertions::assert_eq; +use runway_server::db::*; + +async fn database() -> Database { + Database::in_memory().await.expect("schema applies cleanly") +} + +async fn a_user(db: &Database) -> User { + db.users() + .record_login("alex", "https://dav.example.org/", Some("Alex")) + .await + .unwrap() +} + +// -------------------------------------------------------------------- schema -- + +#[tokio::test] +async fn migrations_apply_to_an_empty_database() { + let db = database().await; + // Running them twice must be a no-op, which is what makes an ordinary + // restart safe. + db.migrate().await.unwrap(); + + assert!(db.users().all().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn foreign_keys_are_enforced() { + // v1 declared `external_calendars.user_id INTEGER` against a TEXT + // `users.id`. SQLite does not enforce foreign key types, and does not + // enforce foreign keys at all unless asked, so the constraint was decorative. + let db = database().await; + + let orphan = UserId::from("nobody"); + let result = db + .feeds() + .create( + &orphan, + &NewFeed { + name: "Work".to_owned(), + url: "https://example.org/work.ics".to_owned(), + color: None, + }, + ) + .await; + + assert!( + result.is_err(), + "a feed belonging to a user who does not exist must be refused", + ); +} + +#[tokio::test] +async fn deleting_a_user_takes_everything_with_it() { + let db = database().await; + let user = a_user(&db).await; + + db.credentials() + .store(&user.id, b"cipher", b"nonce", "test") + .await + .unwrap(); + db.sessions() + .create(&user.id, "token", TimeDelta::hours(1), None) + .await + .unwrap(); + db.preferences() + .save(&user.id, &Preferences::default()) + .await + .unwrap(); + db.calendar_settings() + .set(&user.id, "/cal/personal/", Some("#123456"), true, 0) + .await + .unwrap(); + let feed = db + .feeds() + .create( + &user.id, + &NewFeed { + name: "Work".to_owned(), + url: "https://example.org/work.ics".to_owned(), + color: None, + }, + ) + .await + .unwrap(); + + assert!(db.users().delete(&user.id).await.unwrap()); + + assert!(db.credentials().load(&user.id).await.unwrap().is_none()); + assert!(db.sessions().peek("token").await.unwrap().is_none()); + assert!( + db.calendar_settings() + .list(&user.id) + .await + .unwrap() + .is_empty() + ); + assert!(db.feeds().find(&feed.id).await.unwrap().is_none()); + assert!( + db.feed_cache().load(&feed.id).await.unwrap().is_none(), + "the cascade has to reach the cache too, or a deleted account leaves \ + its calendar data on disk", + ); +} + +// --------------------------------------------------------------------- users -- + +#[tokio::test] +async fn logging_in_twice_does_not_create_a_second_account() { + let db = database().await; + + let first = a_user(&db).await; + let second = a_user(&db).await; + + assert_eq!(first.id, second.id); + assert_eq!(db.users().all().await.unwrap().len(), 1); + assert!( + second.last_login_at >= first.last_login_at, + "and the login is recorded", + ); +} + +#[tokio::test] +async fn the_same_username_on_two_servers_is_two_people() { + let db = database().await; + + let here = db + .users() + .record_login("alex", "https://dav.example.org/", None) + .await + .unwrap(); + let there = db + .users() + .record_login("alex", "https://other.example.net/", None) + .await + .unwrap(); + + assert_ne!(here.id, there.id); + assert_eq!(db.users().all().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn a_login_without_a_display_name_does_not_erase_the_stored_one() { + let db = database().await; + a_user(&db).await; + + let later = db + .users() + .record_login("alex", "https://dav.example.org/", None) + .await + .unwrap(); + + assert_eq!(later.display_name.as_deref(), Some("Alex")); +} + +// ---------------------------------------------------------------- credentials -- + +#[tokio::test] +async fn a_credential_round_trips_as_bytes() { + let db = database().await; + let user = a_user(&db).await; + + db.credentials() + .store( + &user.id, + &[0xde, 0xad, 0xbe, 0xef], + &[1, 2, 3], + "xchacha20poly1305", + ) + .await + .unwrap(); + + let stored = db.credentials().load(&user.id).await.unwrap().unwrap(); + assert_eq!(stored.ciphertext, vec![0xde, 0xad, 0xbe, 0xef]); + assert_eq!(stored.nonce, vec![1, 2, 3]); + assert_eq!( + stored.algorithm, "xchacha20poly1305", + "the scheme is recorded so a later change does not have to guess what \ + old rows were encrypted with", + ); +} + +#[tokio::test] +async fn storing_a_credential_again_replaces_it() { + let db = database().await; + let user = a_user(&db).await; + + db.credentials() + .store(&user.id, b"old", b"n1", "v1") + .await + .unwrap(); + db.credentials() + .store(&user.id, b"new", b"n2", "v2") + .await + .unwrap(); + + let stored = db.credentials().load(&user.id).await.unwrap().unwrap(); + assert_eq!(stored.ciphertext, b"new".to_vec()); + assert_eq!(stored.algorithm, "v2"); +} + +#[tokio::test] +async fn a_credential_does_not_print_itself() { + let db = database().await; + let user = a_user(&db).await; + db.credentials() + .store(&user.id, b"super-secret-ciphertext", b"nonce", "test") + .await + .unwrap(); + + let stored = db.credentials().load(&user.id).await.unwrap().unwrap(); + let rendered = format!("{stored:?}"); + + assert!( + !rendered.contains("115"), // a byte of the ciphertext, were it printed + "{rendered}", + ); + assert!( + rendered.contains("<23 bytes>"), + "Debug should show the shape, not the contents: {rendered}", + ); +} + +// ------------------------------------------------------------------ sessions -- + +#[tokio::test] +async fn a_session_token_is_not_stored() { + let db = database().await; + let user = a_user(&db).await; + let token = "a-long-random-secret-from-the-cookie"; + + db.sessions() + .create(&user.id, token, TimeDelta::hours(24), Some("Firefox")) + .await + .unwrap(); + + // Read the raw column: nothing anywhere may equal the token itself. + let stored: Vec<(String,)> = sqlx::query_as("SELECT token_hash FROM sessions") + .fetch_all(db.pool_for_tests()) + .await + .unwrap(); + + assert_eq!(stored.len(), 1); + assert_ne!( + stored[0].0, token, + "a copy of the database must not be usable to impersonate anybody; v1 \ + stored the token itself", + ); + assert_eq!(stored[0].0.len(), 64, "a hex SHA-256"); + assert!(db.sessions().peek(token).await.unwrap().is_some()); +} + +#[tokio::test] +async fn an_expired_session_reads_as_absent() { + let db = database().await; + let user = a_user(&db).await; + + db.sessions() + .create(&user.id, "stale", TimeDelta::seconds(-1), None) + .await + .unwrap(); + + assert!(db.sessions().peek("stale").await.unwrap().is_none()); + assert!( + db.sessions().touch("stale").await.unwrap().is_none(), + "and touching it must not revive it", + ); +} + +#[tokio::test] +async fn touching_a_session_moves_last_seen_but_not_expiry() { + let db = database().await; + let user = a_user(&db).await; + let created = db + .sessions() + .create(&user.id, "live", TimeDelta::hours(24), None) + .await + .unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let touched = db.sessions().touch("live").await.unwrap().unwrap(); + + assert!(touched.last_seen_at > created.last_seen_at); + assert_eq!( + touched.expires_at, created.expires_at, + "a session has a fixed lifetime; sliding it on every request means it \ + never ends", + ); +} + +#[tokio::test] +async fn a_wrong_token_matches_nothing() { + let db = database().await; + let user = a_user(&db).await; + db.sessions() + .create(&user.id, "the-real-token", TimeDelta::hours(1), None) + .await + .unwrap(); + + assert!(db.sessions().peek("the-real-toke").await.unwrap().is_none()); + assert!(db.sessions().peek("").await.unwrap().is_none()); +} + +#[tokio::test] +async fn sessions_can_be_ended_one_at_a_time_or_all_at_once() { + let db = database().await; + let user = a_user(&db).await; + for token in ["laptop", "phone", "tablet"] { + db.sessions() + .create(&user.id, token, TimeDelta::hours(1), Some(token)) + .await + .unwrap(); + } + + assert!(db.sessions().delete("phone").await.unwrap()); + assert_eq!( + db.sessions().list_for_user(&user.id).await.unwrap().len(), + 2 + ); + + assert_eq!(db.sessions().delete_for_user(&user.id).await.unwrap(), 2); + assert!( + db.sessions() + .list_for_user(&user.id) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn purging_removes_only_expired_sessions() { + let db = database().await; + let user = a_user(&db).await; + db.sessions() + .create(&user.id, "live", TimeDelta::hours(1), None) + .await + .unwrap(); + db.sessions() + .create(&user.id, "dead", TimeDelta::seconds(-1), None) + .await + .unwrap(); + + assert_eq!(db.sessions().purge_expired().await.unwrap(), 1); + assert!(db.sessions().peek("live").await.unwrap().is_some()); +} + +// --------------------------------------------------------------- preferences -- + +#[tokio::test] +async fn a_user_who_has_saved_nothing_gets_the_defaults() { + let db = database().await; + let user = a_user(&db).await; + + let prefs = db.preferences().load(&user.id).await.unwrap(); + + assert_eq!(prefs, Preferences::default()); + assert_eq!(prefs.view, View::Week); + assert_eq!( + prefs.display_timezone, None, + "no zone means follow the browser, which is the right default for \ + somebody who moves", + ); + assert!( + db.preferences() + .updated_at(&user.id) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn preferences_round_trip() { + let db = database().await; + let user = a_user(&db).await; + let saved = Preferences { + theme: "nord".to_owned(), + style: "compact".to_owned(), + view: View::Month, + time_increment: 15, + week_starts_on: 1, + display_timezone: Some("America/Louisville".to_owned()), + default_calendar: Some("/cal/personal/".to_owned()), + }; + + db.preferences().save(&user.id, &saved).await.unwrap(); + + assert_eq!(db.preferences().load(&user.id).await.unwrap(), saved); + assert!( + db.preferences() + .updated_at(&user.id) + .await + .unwrap() + .is_some() + ); +} + +#[tokio::test] +async fn the_database_refuses_nonsense_preferences() { + // The checks are in the schema, so a bad value cannot arrive by any route — + // including a future handler that forgets to validate. + let db = database().await; + let user = a_user(&db).await; + db.preferences() + .save(&user.id, &Preferences::default()) + .await + .unwrap(); + + // Written out rather than generated: sqlx 0.9 will not accept a query built + // by format!, which is a good rule and not one worth working around. + let refused = [ + ( + "a time increment that is not on the list", + "UPDATE preferences SET time_increment = 7 WHERE user_id = ?", + ), + ( + "a day of the week that does not exist", + "UPDATE preferences SET week_starts_on = 9 WHERE user_id = ?", + ), + ( + "a view nobody implemented", + "UPDATE preferences SET view = 'sideways' WHERE user_id = ?", + ), + ]; + + for (what, statement) in refused { + let result = sqlx::query(statement) + .bind(user.id.as_str()) + .execute(db.pool_for_tests()) + .await; + + assert!(result.is_err(), "{what} should have been refused"); + } +} + +// --------------------------------------------------------- calendar settings -- + +#[tokio::test] +async fn calendar_settings_are_rows_not_a_json_blob() { + let db = database().await; + let user = a_user(&db).await; + + db.calendar_settings() + .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, 0) + .await + .unwrap(); + db.calendar_settings() + .set(&user.id, "/cal/work/", Some("#DC2626"), false, 1) + .await + .unwrap(); + + let all = db.calendar_settings().list(&user.id).await.unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].calendar_href, "/cal/personal/"); + assert_eq!(all[0].color.as_deref(), Some("#0CCE6B")); + assert!(!all[1].visible); +} + +#[tokio::test] +async fn hiding_one_calendar_leaves_the_others_alone() { + // v1 read the whole JSON document, changed one field and wrote it back, + // which is where "Fix calendar visibility preservation during event + // updates" came from. + let db = database().await; + let user = a_user(&db).await; + db.calendar_settings() + .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, 0) + .await + .unwrap(); + db.calendar_settings() + .set(&user.id, "/cal/work/", Some("#DC2626"), true, 1) + .await + .unwrap(); + + db.calendar_settings() + .set_visible(&user.id, "/cal/work/", false) + .await + .unwrap(); + + let personal = db + .calendar_settings() + .get(&user.id, "/cal/personal/") + .await + .unwrap() + .unwrap(); + let work = db + .calendar_settings() + .get(&user.id, "/cal/work/") + .await + .unwrap() + .unwrap(); + + assert!(personal.visible, "the other calendar must be untouched"); + assert!(!work.visible); + assert_eq!( + work.color.as_deref(), + Some("#DC2626"), + "and toggling visibility must not discard the colour", + ); + assert_eq!(work.position, 1, "or the ordering"); +} + +#[tokio::test] +async fn a_calendar_with_no_stored_colour_is_distinguishable_from_one_set_to_black() { + let db = database().await; + let user = a_user(&db).await; + + db.calendar_settings() + .set(&user.id, "/cal/server-says/", None, true, 0) + .await + .unwrap(); + db.calendar_settings() + .set(&user.id, "/cal/black/", Some("#000000"), true, 1) + .await + .unwrap(); + + let settings = db.calendar_settings().list(&user.id).await.unwrap(); + let unset = settings + .iter() + .find(|s| s.calendar_href == "/cal/server-says/") + .unwrap(); + let black = settings + .iter() + .find(|s| s.calendar_href == "/cal/black/") + .unwrap(); + + assert_eq!( + unset.color, None, + "\"defer to the server\" is a real state; v1 could not express it and \ + hashed the path to invent a colour instead", + ); + assert_eq!(black.color.as_deref(), Some("#000000")); +} + +#[tokio::test] +async fn two_users_keep_separate_settings_for_the_same_calendar() { + let db = database().await; + let alex = a_user(&db).await; + let sam = db + .users() + .record_login("sam", "https://dav.example.org/", None) + .await + .unwrap(); + + db.calendar_settings() + .set(&alex.id, "/cal/shared/", Some("#111111"), true, 0) + .await + .unwrap(); + db.calendar_settings() + .set(&sam.id, "/cal/shared/", Some("#222222"), false, 0) + .await + .unwrap(); + + assert_eq!( + db.calendar_settings() + .get(&alex.id, "/cal/shared/") + .await + .unwrap() + .unwrap() + .color + .as_deref(), + Some("#111111"), + ); + assert_eq!( + db.calendar_settings() + .get(&sam.id, "/cal/shared/") + .await + .unwrap() + .unwrap() + .color + .as_deref(), + Some("#222222"), + ); +} + +// --------------------------------------------------------------------- feeds -- + +#[tokio::test] +async fn feeds_are_created_in_order_and_listed_that_way() { + let db = database().await; + let user = a_user(&db).await; + + for (name, url) in [ + ("Work", "https://example.org/work.ics"), + ("Holidays", "https://example.org/holidays.ics"), + ] { + db.feeds() + .create( + &user.id, + &NewFeed { + name: name.to_owned(), + url: url.to_owned(), + color: None, + }, + ) + .await + .unwrap(); + } + + let feeds = db.feeds().list(&user.id).await.unwrap(); + assert_eq!( + feeds.iter().map(|f| f.name.as_str()).collect::>(), + vec!["Work", "Holidays"], + "ordered by position, not alphabetically -- the person chose this order", + ); + assert!(feeds.iter().all(|f| f.visible)); +} + +#[tokio::test] +async fn the_same_feed_cannot_be_subscribed_twice() { + let db = database().await; + let user = a_user(&db).await; + let feed = NewFeed { + name: "Work".to_owned(), + url: "https://example.org/work.ics".to_owned(), + color: None, + }; + + db.feeds().create(&user.id, &feed).await.unwrap(); + + assert!(db.feeds().create(&user.id, &feed).await.is_err()); +} + +#[tokio::test] +async fn updating_a_missing_feed_says_which_one() { + let db = database().await; + + let result = db + .feeds() + .update(&FeedId::from("nope"), "New name", None, true) + .await; + + match result { + Err(DbError::NotFound { entity, id }) => { + assert_eq!(entity, "feed"); + assert_eq!(id, "nope"); + } + other => panic!("expected a typed NotFound, got {other:?}"), + } +} + +// ---------------------------------------------------------------- feed cache -- + +#[tokio::test] +async fn a_cached_feed_round_trips() { + let db = database().await; + let user = a_user(&db).await; + let feed = db + .feeds() + .create( + &user.id, + &NewFeed { + name: "Work".to_owned(), + url: "https://example.org/work.ics".to_owned(), + color: None, + }, + ) + .await + .unwrap(); + + let body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; + let cached = CachedFeed { + body: body.to_owned(), + content_hash: hash_body(body), + etag: None, + last_modified: None, + fetched_at: Utc::now(), + expires_at: None, + }; + db.feed_cache().store(&feed.id, &cached).await.unwrap(); + + let loaded = db.feed_cache().load(&feed.id).await.unwrap().unwrap(); + assert_eq!(loaded.body, body); + assert_eq!( + loaded.content_hash, + hash_body(body), + "a published Outlook feed sends no ETag and no Last-Modified, so the \ + hash is the only way to notice nothing changed", + ); +} + +#[tokio::test] +async fn a_content_hash_changes_only_when_the_body_does() { + let one = "BEGIN:VCALENDAR\r\nX-WR-CALNAME:Work\r\nEND:VCALENDAR\r\n"; + let same = "BEGIN:VCALENDAR\r\nX-WR-CALNAME:Work\r\nEND:VCALENDAR\r\n"; + let other = "BEGIN:VCALENDAR\r\nX-WR-CALNAME:Home\r\nEND:VCALENDAR\r\n"; + + assert_eq!(hash_body(one), hash_body(same)); + assert_ne!(hash_body(one), hash_body(other)); +} + +#[tokio::test] +async fn a_not_modified_response_moves_freshness_without_touching_the_body() { + let db = database().await; + let user = a_user(&db).await; + let feed = db + .feeds() + .create( + &user.id, + &NewFeed { + name: "Work".to_owned(), + url: "https://example.org/work.ics".to_owned(), + color: None, + }, + ) + .await + .unwrap(); + + let body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; + let first = Utc::now() - TimeDelta::hours(2); + db.feed_cache() + .store( + &feed.id, + &CachedFeed { + body: body.to_owned(), + content_hash: hash_body(body), + etag: Some("\"abc\"".to_owned()), + last_modified: None, + fetched_at: first, + expires_at: None, + }, + ) + .await + .unwrap(); + + let now = Utc::now(); + db.feed_cache().touch(&feed.id, now, None).await.unwrap(); + + let loaded = db.feed_cache().load(&feed.id).await.unwrap().unwrap(); + assert_eq!(loaded.body, body); + assert_eq!(loaded.etag.as_deref(), Some("\"abc\"")); + assert!(loaded.fetched_at > first); +} + +#[tokio::test] +async fn removing_a_feed_removes_its_cache() { + let db = database().await; + let user = a_user(&db).await; + let feed = db + .feeds() + .create( + &user.id, + &NewFeed { + name: "Work".to_owned(), + url: "https://example.org/work.ics".to_owned(), + color: None, + }, + ) + .await + .unwrap(); + let body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; + db.feed_cache() + .store( + &feed.id, + &CachedFeed { + body: body.to_owned(), + content_hash: hash_body(body), + etag: None, + last_modified: None, + fetched_at: Utc::now(), + expires_at: None, + }, + ) + .await + .unwrap(); + + assert!(db.feeds().delete(&feed.id).await.unwrap()); + assert!(db.feed_cache().load(&feed.id).await.unwrap().is_none()); +} + +// ----------------------------------------------------------------------- ids -- + +#[tokio::test] +async fn identifiers_are_distinct_per_type() { + // The compiler enforces the rest; this just pins that they are not + // interchangeable strings by accident. + let user = UserId::new(); + let feed = FeedId::new(); + + assert_ne!(user.as_str(), feed.as_str()); + assert_eq!(user.as_str().len(), 36, "a uuid, stored as text"); +}