Add the database schema and repository layer
The per-calendar JSON blob is gone. v1 kept every calendar's colour, every visibility toggle and a custom palette inside one calendar_colors TEXT column, so hiding one calendar rewrote the whole document -- which is where "Fix calendar visibility preservation during event updates" came from. Those are rows now, and set_visible touches visibility alone. Foreign keys are switched on. v1 declared external_calendars.user_id as INTEGER against a TEXT users.id; SQLite enforces neither the type nor the constraint unless asked, so it was decorative and could never match. Session tokens are stored as SHA-256, never in the clear, so a copy of the database cannot be used to impersonate anyone. The CalDAV password is the one secret that cannot be hashed -- it has to be replayed to the server -- so it gets an encrypted column with the algorithm recorded alongside, and one way in and one way out instead of v1's eight localStorage reads. Preferences are one column each with CHECK constraints, so a bad view or a nonsense time increment is refused whatever route it arrives by. A NULL display timezone means "follow the browser", which is a state v1 could not express -- as with a NULL calendar colour meaning "defer to the server", which is why it hashed paths to invent one. Feed caching stores a content hash beside the ETag, because a published Outlook feed sends neither ETag nor Last-Modified and staleness has to be detectable anyway. Thirty tests against real in-memory SQLite, not mocks. One caught that create() returned nanosecond timestamps while the column stores microseconds, so a session never compared equal to itself read back.
This commit is contained in:
@@ -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<String>,
|
||||
pub visible: bool,
|
||||
pub position: i64,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SettingRow {
|
||||
calendar_href: String,
|
||||
color: Option<String>,
|
||||
visible: i64,
|
||||
position: i64,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
impl TryFrom<SettingRow> for CalendarSetting {
|
||||
type Error = DbError;
|
||||
|
||||
fn try_from(row: SettingRow) -> Result<Self, Self::Error> {
|
||||
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<Vec<CalendarSetting>, DbError> {
|
||||
let rows: Vec<SettingRow> = 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<Option<CalendarSetting>, DbError> {
|
||||
let row: Option<SettingRow> = 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<bool, DbError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<u8>,
|
||||
pub nonce: Vec<u8>,
|
||||
pub algorithm: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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<u8>,
|
||||
nonce: Vec<u8>,
|
||||
algorithm: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
impl TryFrom<CredentialRow> for StoredCredential {
|
||||
type Error = DbError;
|
||||
|
||||
fn try_from(row: CredentialRow) -> Result<Self, Self::Error> {
|
||||
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<Option<StoredCredential>, DbError> {
|
||||
let row: Option<CredentialRow> = 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<bool, DbError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub visible: bool,
|
||||
pub position: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FeedRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
name: String,
|
||||
url: String,
|
||||
color: Option<String>,
|
||||
visible: i64,
|
||||
position: i64,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
impl TryFrom<FeedRow> for Feed {
|
||||
type Error = DbError;
|
||||
|
||||
fn try_from(row: FeedRow) -> Result<Self, Self::Error> {
|
||||
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<Feed, DbError> {
|
||||
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<Vec<Feed>, DbError> {
|
||||
let rows: Vec<FeedRow> =
|
||||
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<Option<Feed>, DbError> {
|
||||
let row: Option<FeedRow> = 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<Feed, DbError> {
|
||||
let row: Option<FeedRow> = 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<bool, DbError> {
|
||||
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<String>,
|
||||
pub last_modified: Option<String>,
|
||||
pub fetched_at: DateTime<Utc>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CacheRow {
|
||||
body: String,
|
||||
content_hash: String,
|
||||
etag: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
fetched_at: String,
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<CacheRow> for CachedFeed {
|
||||
type Error = DbError;
|
||||
|
||||
fn try_from(row: CacheRow) -> Result<Self, Self::Error> {
|
||||
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<Option<CachedFeed>, DbError> {
|
||||
let row: Option<CacheRow> = 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<Utc>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> 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<bool, DbError> {
|
||||
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()
|
||||
}
|
||||
@@ -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<String> 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");
|
||||
@@ -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<Sqlite>,
|
||||
}
|
||||
|
||||
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<Self, DbError> {
|
||||
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, DbError> {
|
||||
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<Sqlite> {
|
||||
&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<Sqlite> {
|
||||
&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<chrono::Utc>) -> String {
|
||||
at.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_stamp(text: &str) -> Result<chrono::DateTime<chrono::Utc>, DbError> {
|
||||
chrono::DateTime::parse_from_rfc3339(text)
|
||||
.map(|dt| dt.with_timezone(&chrono::Utc))
|
||||
.map_err(|e| DbError::Corrupt(format!("bad timestamp {text:?}: {e}")))
|
||||
}
|
||||
@@ -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<Self, DbError> {
|
||||
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<String>,
|
||||
/// Where a new event goes unless told otherwise.
|
||||
pub default_calendar: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
default_calendar: Option<String>,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
impl PreferencesRow {
|
||||
fn into_preferences(self) -> Result<(Preferences, DateTime<Utc>), 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<Preferences, DbError> {
|
||||
let row: Option<PreferencesRow> =
|
||||
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<Option<DateTime<Utc>>, DbError> {
|
||||
let row: Option<PreferencesRow> =
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub last_seen_at: DateTime<Utc>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SessionRow {
|
||||
user_id: String,
|
||||
created_at: String,
|
||||
expires_at: String,
|
||||
last_seen_at: String,
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<SessionRow> for Session {
|
||||
type Error = DbError;
|
||||
|
||||
fn try_from(row: SessionRow) -> Result<Self, Self::Error> {
|
||||
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<Session, DbError> {
|
||||
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<Option<Session>, DbError> {
|
||||
let now = Utc::now();
|
||||
let row: Option<SessionRow> = 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<Option<Session>, DbError> {
|
||||
let row: Option<SessionRow> =
|
||||
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<bool, DbError> {
|
||||
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<u64, DbError> {
|
||||
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<u64, DbError> {
|
||||
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<Vec<Session>, DbError> {
|
||||
let rows: Vec<SessionRow> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct UserRow {
|
||||
id: String,
|
||||
username: String,
|
||||
server_url: String,
|
||||
display_name: Option<String>,
|
||||
created_at: String,
|
||||
last_login_at: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<UserRow> for User {
|
||||
type Error = DbError;
|
||||
|
||||
fn try_from(row: UserRow) -> Result<Self, Self::Error> {
|
||||
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<User, DbError> {
|
||||
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<Option<User>, DbError> {
|
||||
let row: Option<UserRow> = 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<Option<User>, DbError> {
|
||||
let row: Option<UserRow> =
|
||||
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<Vec<User>, DbError> {
|
||||
let rows: Vec<UserRow> =
|
||||
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<bool, DbError> {
|
||||
let result = sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(id.as_str())
|
||||
.execute(self.db.pool())
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
//! Runway backend: a CalDAV proxy with sessions, preferences and ICS feeds.
|
||||
|
||||
pub mod db;
|
||||
|
||||
Reference in New Issue
Block a user