From da485e523913df28def6335be0836b1fc437617f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 19:32:30 -0400 Subject: Restrict login names. There's no good reason to use an empty string as your login name, or to use one so long as to annoy others. Names beginning or ending with whitespace, or containing runs of whitespace, are also a technical problem, so they're also prohibited. This change does not implement [UTS #39], as I haven't yet fully understood how to do so. [UTS #39]: https://www.unicode.org/reports/tr39/ --- src/login/mod.rs | 1 + 1 file changed, 1 insertion(+) (limited to 'src/login/mod.rs') diff --git a/src/login/mod.rs b/src/login/mod.rs index 279e9a6..6d10e17 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -6,6 +6,7 @@ pub mod password; pub mod repo; mod routes; mod snapshot; +pub mod validate; pub use self::{ event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login, -- cgit v1.2.3 From 9ae0faf4f027caaaf3bc4a42738d4ed31e67852d Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 20:26:47 -0400 Subject: Create a dedicated workflow type for creating logins. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nasty design corner. Logins need to be created in three places: 1. In tests, using app.logins().create(…); 2. On initial setup, using app.setup().initial(…); and 3. When accepting invites, using app.invites().accept(…). These three places do the same thing with respect to logins, but also do a varying mix of other things. Testing is the simplest and _only_ creates a login. Initial setup and invite acceptance both issue a token for the newly-created login. Accepting an invite also invalidates the invite. Previously, those three functions have been copy-pasted variations on a theme. Now that we have validation, the copy-paste approach is no longer tenable; it will become increasingly hard to ensure that the three functions (plus any future functions) remain in synch. To accommodate the variations while consolidating login creation, I've added a typestate-based state machine, which is driven by method calls: * A creation attempt begins with `let create = Create::begin()`. This always succeeds; it packages up arguments used in later steps, but does nothing else. * A creation attempt can be validated using `let validated = create.validate()?`. This may fail. Input validation and password hashing are carried out at this stage, making it potentially expensive. * A validated attempt can be stored in the DB, using `let stored = validated.store(&mut tx).await?`. This may fail. The login will be written to the DB; the caller is responsible for transaction demarcation, to allow other things to take place in the same transaction. * A fully-stored attempt can be used to publish events, using `let login = stored.publish(self.events)`. This always succeeds, and unwraps the state machine to its final product (a `login::History`). --- src/invite/app.rs | 33 +++++++++++-------- src/login/app.rs | 38 +++++++++++---------- src/login/create.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/login/mod.rs | 3 +- src/setup/app.rs | 32 +++++++++++------- 5 files changed, 158 insertions(+), 43 deletions(-) create mode 100644 src/login/create.rs (limited to 'src/login/mod.rs') diff --git a/src/invite/app.rs b/src/invite/app.rs index 182eb67..d4e877a 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -5,8 +5,11 @@ use super::{repo::Provider as _, Id, Invite, Summary}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, - event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, validate, Login, Password}, + event::Broadcaster, + login::{ + create::{self, Create}, + Login, Password, + }, name::Name, token::{repo::Provider as _, Secret}, }; @@ -44,9 +47,7 @@ impl<'a> Invites<'a> { password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { - if !validate::name(name) { - return Err(AcceptError::InvalidName(name.clone())); - } + let create = Create::begin(name, password, accepted_at); let mut tx = self.db.begin().await?; let invite = tx @@ -59,23 +60,20 @@ impl<'a> Invites<'a> { // the invite. Final validation is in the next tx. tx.commit().await?; - let password_hash = password.hash()?; + let validated = create.validate()?; let mut tx = self.db.begin().await?; // If the invite has been deleted or accepted in the interim, this step will // catch it. tx.invites().accept(&invite).await?; - let created = tx.sequence().next(accepted_at).await?; - let login = tx - .logins() - .create(name, &password_hash, &created) + let stored = validated + .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let secret = tx.tokens().issue(&login, accepted_at).await?; + let secret = tx.tokens().issue(stored.login(), accepted_at).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok((login.as_created(), secret)) } @@ -105,3 +103,12 @@ pub enum AcceptError { #[error(transparent)] PasswordHash(#[from] password_hash::Error), } + +impl From for AcceptError { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } +} diff --git a/src/login/app.rs b/src/login/app.rs index c1bfe6e..6da26e9 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -3,13 +3,12 @@ use sqlx::sqlite::SqlitePool; use super::repo::Provider as _; #[cfg(test)] -use super::{validate, Login, Password}; -#[cfg(test)] -use crate::{ - clock::DateTime, - event::{repo::Provider as _, Broadcaster, Event}, - name::Name, +use super::{ + create::{self, Create}, + Login, Password, }; +#[cfg(test)] +use crate::{clock::DateTime, event::Broadcaster, name::Name}; pub struct Logins<'a> { db: &'a SqlitePool, @@ -35,19 +34,14 @@ impl<'a> Logins<'a> { password: &Password, created_at: &DateTime, ) -> Result { - if !validate::name(name) { - return Err(CreateError::InvalidName(name.clone())); - } - - let password_hash = password.hash()?; + let create = Create::begin(name, password, created_at); + let validated = create.validate()?; let mut tx = self.db.begin().await?; - let created = tx.sequence().next(created_at).await?; - let login = tx.logins().create(name, &password_hash, &created).await?; + let stored = validated.store(&mut tx).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok(login.as_created()) } @@ -67,7 +61,17 @@ pub enum CreateError { #[error("invalid login name: {0}")] InvalidName(Name), #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] PasswordHash(#[from] password_hash::Error), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[cfg(test)] +impl From for CreateError { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } } diff --git a/src/login/create.rs b/src/login/create.rs new file mode 100644 index 0000000..693daaf --- /dev/null +++ b/src/login/create.rs @@ -0,0 +1,95 @@ +use sqlx::{sqlite::Sqlite, Transaction}; + +use super::{password::StoredHash, repo::Provider as _, validate, History, Password}; +use crate::{ + clock::DateTime, + event::{repo::Provider as _, Broadcaster, Event}, + name::Name, +}; + +pub struct Create<'a> { + name: &'a Name, + password: &'a Password, + created_at: &'a DateTime, +} + +impl<'a> Create<'a> { + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub fn begin(name: &'a Name, password: &'a Password, created_at: &'a DateTime) -> Self { + Self { + name, + password, + created_at, + } + } + + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub fn validate(self) -> Result, Error> { + let Self { + name, + password, + created_at, + } = self; + + if !validate::name(name) { + return Err(Error::InvalidName(name.clone())); + } + + let password_hash = password.hash()?; + + Ok(Validated { + name, + password_hash, + created_at, + }) + } +} + +pub struct Validated<'a> { + name: &'a Name, + password_hash: StoredHash, + created_at: &'a DateTime, +} + +impl<'a> Validated<'a> { + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub async fn store<'c>(self, tx: &mut Transaction<'c, Sqlite>) -> Result { + let Self { + name, + password_hash, + created_at, + } = self; + + let created = tx.sequence().next(created_at).await?; + let login = tx.logins().create(name, &password_hash, &created).await?; + + Ok(Stored { login }) + } +} + +pub struct Stored { + login: History, +} + +impl Stored { + #[must_use = "dropping a login creation attempt is likely a mistake"] + pub fn publish(self, events: &Broadcaster) -> History { + let Self { login } = self; + + events.broadcast(login.events().map(Event::from).collect::>()); + + login + } + + pub fn login(&self) -> &History { + &self.login + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid login name: {0}")] + InvalidName(Name), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} diff --git a/src/login/mod.rs b/src/login/mod.rs index 6d10e17..5a6d715 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod create; pub mod event; mod history; mod id; @@ -6,7 +7,7 @@ pub mod password; pub mod repo; mod routes; mod snapshot; -pub mod validate; +mod validate; pub use self::{ event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login, diff --git a/src/setup/app.rs b/src/setup/app.rs index cab7c4b..c1f7b69 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -3,8 +3,11 @@ use sqlx::sqlite::SqlitePool; use super::repo::Provider as _; use crate::{ clock::DateTime, - event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, validate, Login, Password}, + event::Broadcaster, + login::{ + create::{self, Create}, + Login, Password, + }, name::Name, token::{repo::Provider as _, Secret}, }; @@ -25,24 +28,20 @@ impl<'a> Setup<'a> { password: &Password, created_at: &DateTime, ) -> Result<(Login, Secret), Error> { - if !validate::name(name) { - return Err(Error::InvalidName(name.clone())); - } + let create = Create::begin(name, password, created_at); - let password_hash = password.hash()?; + let validated = create.validate()?; let mut tx = self.db.begin().await?; - let login = if tx.setup().completed().await? { + let stored = if tx.setup().completed().await? { Err(Error::SetupCompleted)? } else { - let created = tx.sequence().next(created_at).await?; - tx.logins().create(name, &password_hash, &created).await? + validated.store(&mut tx).await? }; - let secret = tx.tokens().issue(&login, created_at).await?; + let secret = tx.tokens().issue(stored.login(), created_at).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok((login.as_created(), secret)) } @@ -67,3 +66,12 @@ pub enum Error { #[error(transparent)] PasswordHash(#[from] password_hash::Error), } + +impl From for Error { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } +} -- cgit v1.2.3 From 70591c5ac10069a4ae649bd6f79d769da9e32a98 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 30 Oct 2024 01:25:04 -0400 Subject: Remove `hi-recanonicalize`. This utility was needed to support a database migration with existing data. I have it on good authority that no further databases exist that are in the state that made this tool necessary. --- ...29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json | 12 -- ...16293ea1bcc4913987ee751951e9d2f31bf495f305.json | 26 ---- ...1241dc35263ccfee9f3424111e7fa6014071f98a1e.json | 26 ---- ...42bfc6c82b1464afa98845e537e850d05deb328f06.json | 12 -- Cargo.toml | 1 - docs/internal-server-errors.md | 19 --- src/app.rs | 9 +- src/bin/hi-recanonicalize.rs | 9 -- src/bin/hi.rs | 9 -- src/channel/app.rs | 8 - src/channel/repo.rs | 32 ---- src/cli.rs | 170 ++++++++++++++++++++ src/cli/mod.rs | 172 --------------------- src/cli/recanonicalize.rs | 86 ----------- src/login/app.rs | 21 --- src/login/mod.rs | 1 + src/login/repo.rs | 32 ---- src/main.rs | 9 ++ 18 files changed, 183 insertions(+), 471 deletions(-) delete mode 100644 .sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json delete mode 100644 .sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json delete mode 100644 .sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json delete mode 100644 .sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json delete mode 100644 src/bin/hi-recanonicalize.rs delete mode 100644 src/bin/hi.rs create mode 100644 src/cli.rs delete mode 100644 src/cli/mod.rs delete mode 100644 src/cli/recanonicalize.rs create mode 100644 src/main.rs (limited to 'src/login/mod.rs') diff --git a/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json b/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json deleted file mode 100644 index 1105391..0000000 --- a/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update channel_name\n set canonical_name = $1\n where id = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8" -} diff --git a/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json b/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json deleted file mode 100644 index be5b784..0000000 --- a/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\"\n from channel_name\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Null" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false - ] - }, - "hash": "642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305" -} diff --git a/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json b/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json deleted file mode 100644 index fd601e9..0000000 --- a/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\"\n from login\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "display_name: String", - "ordinal": 1, - "type_info": "Text" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false - ] - }, - "hash": "676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e" -} diff --git a/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json b/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json deleted file mode 100644 index 677495b..0000000 --- a/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update login\n set canonical_name = $1\n where id = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06" -} diff --git a/Cargo.toml b/Cargo.toml index c8b37e1..83c3aa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ maintainer-scripts = "debian" assets = [ # Binaries ["target/release/hi", "/usr/bin/hi", "755"], - ["target/release/hi-recanonicalize", "/usr/bin/hi-recanonicalize", "755"], # Configuration ["debian/default", "/etc/default/hi", "644"], diff --git a/docs/internal-server-errors.md b/docs/internal-server-errors.md index 4f679b7..16d61a2 100644 --- a/docs/internal-server-errors.md +++ b/docs/internal-server-errors.md @@ -9,22 +9,3 @@ The server attempted two write transactions at the same time, and encountered [s This error will almost always resolve itself if clients re-try their requests; no further action is needed. This is a known issue. If you are encountering this consistently (or if you can trigger it on demand), let us know. We are aware of sqlite's features for mitigating this issue but have been unsuccessful in applying them; we're working on it, but patches _are_ welcome, if you have the opportunity. - -## stored canonical form […] does not match computed canonical form […] for name […] - -When `hi` applies the `migrations/20241019191531_canonical_names.sql` migration (from commit `3f9648eed48cd8b6cd35d0ae2ee5bbe25fa735ac`), this can leave existing names in a state where the stored canonical form is not the correct canonicalization of the stored display names of channels and logins. `hi` will abort requests when it encounters this situation, to avoid incorrect behaviours such as duplicate channels or duplicate logins. - -As channel and login names may be presented during client startup, this can render the service unusable until repaired. Treat this as an immediate outage if you see it. - -You can verify that login names are unique by running the following commands as the user the `hi` server runs as: - -* `sqlite3 .hi 'select display_name from login'` -* `sqlite3 .hi 'select display_name from channel_name'` - -Substitute `.hi` with the path to your `hi` database if it differs from the default. - -If the names are unique, you can repair the database: - -* Stop the `hi` server. -* Run `hi-recanonicalize`, as the same user the `hi` server runs as, with the same database options. -* Start the `hi` server. diff --git a/src/app.rs b/src/app.rs index bc1daa5..0dbf017 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,12 +5,14 @@ use crate::{ channel::app::Channels, event::{self, app::Events}, invite::app::Invites, - login::app::Logins, message::app::Messages, setup::app::Setup, token::{self, app::Tokens}, }; +#[cfg(test)] +use crate::login::app::Logins; + #[derive(Clone)] pub struct App { db: SqlitePool, @@ -47,11 +49,6 @@ impl App { Invites::new(&self.db, &self.events) } - #[cfg(not(test))] - pub const fn logins(&self) -> Logins { - Logins::new(&self.db) - } - #[cfg(test)] pub const fn logins(&self) -> Logins { Logins::new(&self.db, &self.events) diff --git a/src/bin/hi-recanonicalize.rs b/src/bin/hi-recanonicalize.rs deleted file mode 100644 index 4081276..0000000 --- a/src/bin/hi-recanonicalize.rs +++ /dev/null @@ -1,9 +0,0 @@ -use clap::Parser; - -use hi::cli; - -#[tokio::main] -async fn main() -> Result<(), cli::recanonicalize::Error> { - let args = cli::recanonicalize::Args::parse(); - args.run().await -} diff --git a/src/bin/hi.rs b/src/bin/hi.rs deleted file mode 100644 index d0830ff..0000000 --- a/src/bin/hi.rs +++ /dev/null @@ -1,9 +0,0 @@ -use clap::Parser; - -use hi::cli; - -#[tokio::main] -async fn main() -> Result<(), cli::Error> { - let args = cli::Args::parse(); - args.run().await -} diff --git a/src/channel/app.rs b/src/channel/app.rs index e32eb6c..21784e9 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -137,14 +137,6 @@ impl<'a> Channels<'a> { Ok(()) } - - pub async fn recanonicalize(&self) -> Result<(), sqlx::Error> { - let mut tx = self.db.begin().await?; - tx.channels().recanonicalize().await?; - tx.commit().await?; - - Ok(()) - } } #[derive(Debug, thiserror::Error)] diff --git a/src/channel/repo.rs b/src/channel/repo.rs index a49db52..f47e564 100644 --- a/src/channel/repo.rs +++ b/src/channel/repo.rs @@ -300,38 +300,6 @@ impl<'c> Channels<'c> { Ok(channels) } - - pub async fn recanonicalize(&mut self) -> Result<(), sqlx::Error> { - let channels = sqlx::query!( - r#" - select - id as "id: Id", - display_name as "display_name: String" - from channel_name - "#, - ) - .fetch_all(&mut *self.0) - .await?; - - for channel in channels { - let name = Name::from(channel.display_name); - let canonical_name = name.canonical(); - - sqlx::query!( - r#" - update channel_name - set canonical_name = $1 - where id = $2 - "#, - canonical_name, - channel.id, - ) - .execute(&mut *self.0) - .await?; - } - - Ok(()) - } } #[derive(Debug, thiserror::Error)] diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..0659851 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,170 @@ +//! The `hi` command-line interface. +//! +//! This module supports running `hi` as a freestanding program, via the +//! [`Args`] struct. + +use std::{future, io}; + +use axum::{ + http::header, + middleware, + response::{IntoResponse, Response}, + Router, +}; +use clap::{CommandFactory, Parser}; +use sqlx::sqlite::SqlitePool; +use tokio::net; + +use crate::{ + app::App, + boot, channel, clock, db, event, expire, invite, login, message, + setup::{self, middleware::setup_required}, + ui, +}; + +/// Command-line entry point for running the `hi` server. +/// +/// This is intended to be used as a Clap [Parser], to capture command-line +/// arguments for the `hi` server: +/// +/// ```no_run +/// # use hi::cli::Error; +/// # +/// # #[tokio::main] +/// # async fn main() -> Result<(), Error> { +/// use clap::Parser; +/// use hi::cli::Args; +/// +/// let args = Args::parse(); +/// args.run().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Parser)] +#[command( + version, + about = "Run the `hi` server.", + long_about = r#"Run the `hi` server. + +The database at `--database-url` will be created, or upgraded, automatically."# +)] +pub struct Args { + /// The network address `hi` should listen on + #[arg(short, long, env, default_value = "localhost")] + address: String, + + /// The network port `hi` should listen on + #[arg(short, long, env, default_value_t = 64209)] + port: u16, + + /// Sqlite URL or path for the `hi` database + #[arg(short, long, env, default_value = "sqlite://.hi")] + database_url: String, + + /// Sqlite URL or path for a backup of the `hi` database during upgrades + #[arg(short = 'D', long, env, default_value = "sqlite://.hi.backup")] + backup_database_url: String, +} + +impl Args { + /// Runs the `hi` server, using the parsed configuation in `self`. + /// + /// This will perform the following tasks: + /// + /// * Migrate the `hi` database (at `--database-url`). + /// * Start an HTTP server (on the interface and port controlled by + /// `--address` and `--port`). + /// * Print a status message. + /// * Wait for that server to shut down. + /// + /// # Errors + /// + /// Will return `Err` if the server is unable to start, or terminates + /// prematurely. The specific [`Error`] variant will expose the cause + /// of the failure. + pub async fn run(self) -> Result<(), Error> { + let pool = self.pool().await?; + + let app = App::from(pool); + let app = routers(&app) + .route_layer(middleware::from_fn_with_state( + app.clone(), + expire::middleware, + )) + .route_layer(middleware::from_fn(clock::middleware)) + .route_layer(middleware::map_response(Self::server_info())) + .with_state(app); + + let listener = self.listener().await?; + let started_msg = started_msg(&listener)?; + + let serve = axum::serve(listener, app); + println!("{started_msg}"); + serve.await?; + + Ok(()) + } + + async fn listener(&self) -> io::Result { + let listen_addr = self.listen_addr(); + let listener = tokio::net::TcpListener::bind(listen_addr).await?; + Ok(listener) + } + + fn listen_addr(&self) -> impl net::ToSocketAddrs + '_ { + (self.address.as_str(), self.port) + } + + async fn pool(&self) -> Result { + db::prepare(&self.database_url, &self.backup_database_url).await + } + + fn server_info() -> impl Clone + Fn(Response) -> future::Ready { + let command = Self::command(); + let name = command.get_name(); + let version = command.get_version().unwrap_or("unknown version"); + let version = format!("{name}/{version}"); + move |resp| { + let response = ([(header::SERVER, &version)], resp).into_response(); + future::ready(response) + } + } +} + +fn routers(app: &App) -> Router { + [ + [ + // API endpoints that require setup to function + boot::router(), + channel::router(), + event::router(), + invite::router(), + login::router(), + message::router(), + ] + .into_iter() + .fold(Router::default(), Router::merge) + .route_layer(middleware::from_fn_with_state(app.clone(), setup_required)), + // API endpoints that handle setup + setup::router(), + // The UI (handles setup state itself) + ui::router(app), + ] + .into_iter() + .fold(Router::default(), Router::merge) +} + +fn started_msg(listener: &net::TcpListener) -> io::Result { + let local_addr = listener.local_addr()?; + Ok(format!("listening on http://{local_addr}/")) +} + +/// Errors that can be raised by [`Args::run`]. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + /// Failure due to `io::Error`. See [`io::Error`]. + Io(#[from] io::Error), + /// Failure due to a database initialization error. See [`db::Error`]. + Database(#[from] db::Error), +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs deleted file mode 100644 index c75ce2b..0000000 --- a/src/cli/mod.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! The `hi` command-line interface. -//! -//! This module supports running `hi` as a freestanding program, via the -//! [`Args`] struct. - -use std::{future, io}; - -use axum::{ - http::header, - middleware, - response::{IntoResponse, Response}, - Router, -}; -use clap::{CommandFactory, Parser}; -use sqlx::sqlite::SqlitePool; -use tokio::net; - -use crate::{ - app::App, - boot, channel, clock, db, event, expire, invite, login, message, - setup::{self, middleware::setup_required}, - ui, -}; - -pub mod recanonicalize; - -/// Command-line entry point for running the `hi` server. -/// -/// This is intended to be used as a Clap [Parser], to capture command-line -/// arguments for the `hi` server: -/// -/// ```no_run -/// # use hi::cli::Error; -/// # -/// # #[tokio::main] -/// # async fn main() -> Result<(), Error> { -/// use clap::Parser; -/// use hi::cli::Args; -/// -/// let args = Args::parse(); -/// args.run().await?; -/// # Ok(()) -/// # } -/// ``` -#[derive(Parser)] -#[command( - version, - about = "Run the `hi` server.", - long_about = r#"Run the `hi` server. - -The database at `--database-url` will be created, or upgraded, automatically."# -)] -pub struct Args { - /// The network address `hi` should listen on - #[arg(short, long, env, default_value = "localhost")] - address: String, - - /// The network port `hi` should listen on - #[arg(short, long, env, default_value_t = 64209)] - port: u16, - - /// Sqlite URL or path for the `hi` database - #[arg(short, long, env, default_value = "sqlite://.hi")] - database_url: String, - - /// Sqlite URL or path for a backup of the `hi` database during upgrades - #[arg(short = 'D', long, env, default_value = "sqlite://.hi.backup")] - backup_database_url: String, -} - -impl Args { - /// Runs the `hi` server, using the parsed configuation in `self`. - /// - /// This will perform the following tasks: - /// - /// * Migrate the `hi` database (at `--database-url`). - /// * Start an HTTP server (on the interface and port controlled by - /// `--address` and `--port`). - /// * Print a status message. - /// * Wait for that server to shut down. - /// - /// # Errors - /// - /// Will return `Err` if the server is unable to start, or terminates - /// prematurely. The specific [`Error`] variant will expose the cause - /// of the failure. - pub async fn run(self) -> Result<(), Error> { - let pool = self.pool().await?; - - let app = App::from(pool); - let app = routers(&app) - .route_layer(middleware::from_fn_with_state( - app.clone(), - expire::middleware, - )) - .route_layer(middleware::from_fn(clock::middleware)) - .route_layer(middleware::map_response(Self::server_info())) - .with_state(app); - - let listener = self.listener().await?; - let started_msg = started_msg(&listener)?; - - let serve = axum::serve(listener, app); - println!("{started_msg}"); - serve.await?; - - Ok(()) - } - - async fn listener(&self) -> io::Result { - let listen_addr = self.listen_addr(); - let listener = tokio::net::TcpListener::bind(listen_addr).await?; - Ok(listener) - } - - fn listen_addr(&self) -> impl net::ToSocketAddrs + '_ { - (self.address.as_str(), self.port) - } - - async fn pool(&self) -> Result { - db::prepare(&self.database_url, &self.backup_database_url).await - } - - fn server_info() -> impl Clone + Fn(Response) -> future::Ready { - let command = Self::command(); - let name = command.get_name(); - let version = command.get_version().unwrap_or("unknown version"); - let version = format!("{name}/{version}"); - move |resp| { - let response = ([(header::SERVER, &version)], resp).into_response(); - future::ready(response) - } - } -} - -fn routers(app: &App) -> Router { - [ - [ - // API endpoints that require setup to function - boot::router(), - channel::router(), - event::router(), - invite::router(), - login::router(), - message::router(), - ] - .into_iter() - .fold(Router::default(), Router::merge) - .route_layer(middleware::from_fn_with_state(app.clone(), setup_required)), - // API endpoints that handle setup - setup::router(), - // The UI (handles setup state itself) - ui::router(app), - ] - .into_iter() - .fold(Router::default(), Router::merge) -} - -fn started_msg(listener: &net::TcpListener) -> io::Result { - let local_addr = listener.local_addr()?; - Ok(format!("listening on http://{local_addr}/")) -} - -/// Errors that can be raised by [`Args::run`]. -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum Error { - /// Failure due to `io::Error`. See [`io::Error`]. - Io(#[from] io::Error), - /// Failure due to a database initialization error. See [`db::Error`]. - Database(#[from] db::Error), -} diff --git a/src/cli/recanonicalize.rs b/src/cli/recanonicalize.rs deleted file mode 100644 index 9db5b77..0000000 --- a/src/cli/recanonicalize.rs +++ /dev/null @@ -1,86 +0,0 @@ -use sqlx::sqlite::SqlitePool; - -use crate::{app::App, db}; - -/// Command-line entry point for repairing canonical names in the `hi` database. -/// This command may be necessary after an upgrade, if the canonical forms of -/// names has changed. It will re-calculate the canonical form of each name in -/// the database, based on its display form, and store the results back to the -/// database. -/// -/// This is intended to be used as a Clap [Parser], to capture command-line -/// arguments for the `hi-recanonicalize` command: -/// -/// ```no_run -/// # use hi::cli::recanonicalize::Error; -/// # -/// # #[tokio::main] -/// # async fn main() -> Result<(), Error> { -/// use clap::Parser; -/// use hi::cli::recanonicalize::Args; -/// -/// let args = Args::parse(); -/// args.run().await?; -/// # Ok(()) -/// # } -/// ``` -#[derive(clap::Parser)] -#[command( - version, - about = "Recanonicalize names in the `hi` database.", - long_about = r#"Recanonicalize names in the `hi` database. - -The `hi` server must not be running while this command is run. - -The database at `--database-url` will also be created, or upgraded, automatically."# -)] -pub struct Args { - /// Sqlite URL or path for the `hi` database - #[arg(short, long, env, default_value = "sqlite://.hi")] - database_url: String, - - /// Sqlite URL or path for a backup of the `hi` database during upgrades - #[arg(short = 'D', long, env, default_value = "sqlite://.hi.backup")] - backup_database_url: String, -} - -impl Args { - /// Recanonicalizes the `hi` database, using the parsed configuation in - /// `self`. - /// - /// This will perform the following tasks: - /// - /// * Migrate the `hi` database (at `--database-url`). - /// * Recanonicalize names in the `login` and `channel` tables. - /// - /// # Errors - /// - /// Will return `Err` if the canonicalization or database upgrade processes - /// fail. The specific [`Error`] variant will expose the cause - /// of the failure. - pub async fn run(self) -> Result<(), Error> { - let pool = self.pool().await?; - - let app = App::from(pool); - app.logins().recanonicalize().await?; - app.channels().recanonicalize().await?; - - Ok(()) - } - - async fn pool(&self) -> Result { - db::prepare(&self.database_url, &self.backup_database_url).await - } -} - -/// Errors that can be raised by [`Args::run`]. -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum Error { - // /// Failure due to `io::Error`. See [`io::Error`]. - // Io(#[from] io::Error), - /// Failure due to a database initialization error. See [`db::Error`]. - Database(#[from] db::Error), - /// Failure due to a data manipulation error. See [`sqlx::Error`]. - Sqlx(#[from] sqlx::Error), -} diff --git a/src/login/app.rs b/src/login/app.rs index 6da26e9..f458561 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -1,33 +1,21 @@ use sqlx::sqlite::SqlitePool; -use super::repo::Provider as _; - -#[cfg(test)] use super::{ create::{self, Create}, Login, Password, }; -#[cfg(test)] use crate::{clock::DateTime, event::Broadcaster, name::Name}; pub struct Logins<'a> { db: &'a SqlitePool, - #[cfg(test)] events: &'a Broadcaster, } impl<'a> Logins<'a> { - #[cfg(not(test))] - pub const fn new(db: &'a SqlitePool) -> Self { - Self { db } - } - - #[cfg(test)] pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { Self { db, events } } - #[cfg(test)] pub async fn create( &self, name: &Name, @@ -45,17 +33,8 @@ impl<'a> Logins<'a> { Ok(login.as_created()) } - - pub async fn recanonicalize(&self) -> Result<(), sqlx::Error> { - let mut tx = self.db.begin().await?; - tx.logins().recanonicalize().await?; - tx.commit().await?; - - Ok(()) - } } -#[cfg(test)] #[derive(Debug, thiserror::Error)] pub enum CreateError { #[error("invalid login name: {0}")] diff --git a/src/login/mod.rs b/src/login/mod.rs index 5a6d715..006fa0c 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,3 +1,4 @@ +#[cfg(test)] pub mod app; pub mod create; pub mod event; diff --git a/src/login/repo.rs b/src/login/repo.rs index a972304..9439a25 100644 --- a/src/login/repo.rs +++ b/src/login/repo.rs @@ -143,38 +143,6 @@ impl<'c> Logins<'c> { Ok(logins) } - - pub async fn recanonicalize(&mut self) -> Result<(), sqlx::Error> { - let logins = sqlx::query!( - r#" - select - id as "id: Id", - display_name as "display_name: String" - from login - "#, - ) - .fetch_all(&mut *self.0) - .await?; - - for login in logins { - let name = Name::from(login.display_name); - let canonical_name = name.canonical(); - - sqlx::query!( - r#" - update login - set canonical_name = $1 - where id = $2 - "#, - canonical_name, - login.id, - ) - .execute(&mut *self.0) - .await?; - } - - Ok(()) - } } #[derive(Debug, thiserror::Error)] diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d0830ff --- /dev/null +++ b/src/main.rs @@ -0,0 +1,9 @@ +use clap::Parser; + +use hi::cli; + +#[tokio::main] +async fn main() -> Result<(), cli::Error> { + let args = cli::Args::parse(); + args.run().await +} -- cgit v1.2.3