From 40e8445848a0ab14224b478817cad7e4b31e6417 Mon Sep 17 00:00:00 2001 From: Jacob Lifshay Date: Mon, 8 Apr 2024 23:38:50 -0700 Subject: [PATCH] it all works! --- Cargo.lock | 78 +++++++++- Cargo.toml | 4 + build.rs | 3 + config.toml.sample | 1 + .../down.sql | 1 + .../up.sql | 4 + src/app.rs | 60 +++++++- src/cli.rs | 41 +++++- src/config.rs | 70 ++++++--- src/keys.rs | 139 ++++++++++++++++++ src/lib.rs | 71 +++++++-- src/models.rs | 33 +++++ src/schema.rs | 13 ++ 13 files changed, 471 insertions(+), 47 deletions(-) create mode 100644 build.rs create mode 100644 migrations/2024-04-09-022737_add-email-unsubscribe-url-key/down.sql create mode 100644 migrations/2024-04-09-022737_add-email-unsubscribe-url-key/up.sql create mode 100644 src/keys.rs diff --git a/Cargo.lock b/Cargo.lock index 00201a4..a841d33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -819,6 +819,17 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "diesel_migrations" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6036b3f0120c5961381b570ee20a02432d7e2d27ea60de9578799cf9156914ac" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + [[package]] name = "diesel_table_macro_syntax" version = "0.1.0" @@ -867,6 +878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", + "serde", "signature", ] @@ -878,6 +890,7 @@ checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core", "serde", "sha2", "subtle", @@ -1548,6 +1561,27 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +[[package]] +name = "migrations_internals" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f23f71580015254b020e856feac3df5878c2c7a8812297edd6c0a485ac9dada" +dependencies = [ + "serde", + "toml 0.7.8", +] + +[[package]] +name = "migrations_macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cce3325ac70e67bbab5bd837a31cae01f1a6db64e0e744a33cb03a543469ef08" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -2482,6 +2516,8 @@ dependencies = [ "clio", "color-eyre", "diesel", + "diesel_migrations", + "ed25519-dalek", "env_logger", "eyre", "futures", @@ -2492,9 +2528,11 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_urlencoded", + "serde_with", "tinytemplate", "tokio", - "toml", + "toml 0.8.12", ] [[package]] @@ -2714,6 +2752,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit 0.19.15", +] + [[package]] name = "toml" version = "0.8.12" @@ -2724,7 +2774,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_edit 0.22.9", ] [[package]] @@ -2736,6 +2786,19 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.2.6", + "serde", + "serde_spanned", + "toml_datetime", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.9" @@ -2746,7 +2809,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "winnow", + "winnow 0.6.5", ] [[package]] @@ -3224,6 +3287,15 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.6.5" diff --git a/Cargo.toml b/Cargo.toml index db2d81d..606876c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ clap = { version = "4.5.4", features = ["derive"] } clio = { version = "0.3.5", features = ["clap-parse"] } color-eyre = "0.6.3" diesel = { version = "2.1.5", features = ["sqlite", "chrono"] } +diesel_migrations = { version = "2.1.0", features = ["sqlite"] } +ed25519-dalek = { version = "2.1.1", features = ["rand_core", "serde"] } env_logger = "0.11.3" eyre = "0.6.12" futures = "0.3.30" @@ -23,6 +25,8 @@ openidconnect = "3.5.0" reqwest = "0.11.27" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" +serde_urlencoded = "0.7.1" +serde_with = { version = "3.7.0", features = ["base64"] } tinytemplate = "1.2.1" tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] } toml = { version = "0.8.12", features = ["preserve_order"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3a8149e --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/config.toml.sample b/config.toml.sample index 649d1ae..5b7d36e 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -1,4 +1,5 @@ sqlite_db = "subscribe-list.db" +server_base_url = "https://my-site/" [oidc.google] pretty_name = "Google" diff --git a/migrations/2024-04-09-022737_add-email-unsubscribe-url-key/down.sql b/migrations/2024-04-09-022737_add-email-unsubscribe-url-key/down.sql new file mode 100644 index 0000000..031d0dc --- /dev/null +++ b/migrations/2024-04-09-022737_add-email-unsubscribe-url-key/down.sql @@ -0,0 +1 @@ +DROP TABLE "keys"; \ No newline at end of file diff --git a/migrations/2024-04-09-022737_add-email-unsubscribe-url-key/up.sql b/migrations/2024-04-09-022737_add-email-unsubscribe-url-key/up.sql new file mode 100644 index 0000000..96af126 --- /dev/null +++ b/migrations/2024-04-09-022737_add-email-unsubscribe-url-key/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE "keys" ( + "id" INTEGER PRIMARY KEY CHECK ("id" = 0) NOT NULL DEFAULT 0, + "email-unsubscribe-url" BLOB NOT NULL +) WITHOUT ROWID; \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 92ebac5..496eecd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,10 @@ -use crate::{client::async_http_client, config::Config, db::DbThread, models::Account}; +use crate::{ + client::async_http_client, + config::Config, + db::DbThread, + keys::Signed, + models::{Account, Keys}, +}; use actix_session::Session; use actix_web::{ get, @@ -11,6 +17,7 @@ use openidconnect::{ core::{CoreAuthenticationFlow, CoreUserInfoClaims}, AuthorizationCode, CsrfToken, EndUserEmail, Nonce, OAuth2TokenResponse, TokenResponse, }; +use reqwest::Url; use serde::{Deserialize, Serialize}; use std::marker::PhantomData; use tinytemplate::TinyTemplate; @@ -95,6 +102,14 @@ As an anti-spam measure, you need to register using a 3rd-party account: pub struct SubscriptionLoggedInTemplate { pub email: EndUserEmail, } + #[text = r#"Subscription + +

Bad Signature.

+
+Register or Sign In +"#] + #[derive(Debug, Serialize)] + pub struct SubscriptionBadSignature {} } pub fn make_templates() -> TinyTemplate<'static> { @@ -273,6 +288,46 @@ pub async fn login( resp } +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct EmailUnsubscribeUrlQueryParams { + pub signed_email: Signed, +} + +impl EmailUnsubscribeUrlQueryParams { + pub fn new(email: &EndUserEmail, keys: &Keys) -> Self { + Self { + signed_email: Signed::sign(email, keys.email_unsubscribe_url.secret()).unwrap(), + } + } + pub fn make_url(&self, config: &Config) -> Url { + let mut retval = config.server_base_url.join("/subscription/email").unwrap(); + retval.set_query(Some(&serde_urlencoded::to_string(&self).unwrap())); + retval + } +} + +#[get("/subscription/email")] +pub async fn email_unsubscribe( + keys: web::Data, + session: Session, + query: web::Query, +) -> impl Responder { + let Ok(Ok(email)) = query + .signed_email + .verify(keys.email_unsubscribe_url.secret()) + else { + return HttpResponse::Forbidden() + .content_type(ContentType::html()) + .body(SubscriptionBadSignature {}.render().unwrap()); + }; + SessionState { email }.set(&session); + let mut resp = HttpResponse::SeeOther().body(""); + resp.headers_mut() + .insert(LOCATION, "/subscription".parse().unwrap()); + resp +} + #[get("/subscription/logout")] pub async fn logout(session: Session) -> impl Responder { session.purge(); @@ -341,7 +396,8 @@ pub fn all_services(cfg: &mut ServiceConfig) { .service(login) .service(callback) .service(logout) - .service(unsubscribe); + .service(unsubscribe) + .service(email_unsubscribe); } #[cfg(test)] diff --git a/src/cli.rs b/src/cli.rs index aec9713..f1398dd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,25 +1,52 @@ use crate::config; -use clap::{ArgAction, Args, Parser}; +use clap::{ArgAction, Args, Parser, Subcommand}; use eyre::ensure; -use futures::try_join; +use futures::future::try_join_all; use listenfd::ListenFd; -use std::{net::TcpListener, os::unix::net::UnixListener, path::PathBuf}; + +use std::{future::Future, net::TcpListener, os::unix::net::UnixListener, path::PathBuf, pin::Pin}; #[derive(Parser, Debug)] pub struct CLI { - #[arg(long, value_parser, value_name = "path/to/config.toml")] + #[arg(long, value_name = "path/to/config.toml")] pub config: config::Config, - #[command(flatten)] - pub listen: Listen, + #[command(subcommand)] + pub subcommand: CLISubcommand, } impl CLI { pub async fn resolve(&mut self, listenfd: &mut ListenFd) -> eyre::Result<()> { - try_join!(self.config.resolve(), self.listen.resolve(listenfd))?; + let mut futures: Vec> + Send>>> = vec![]; + let resolve_provider_metadata; + match &mut self.subcommand { + CLISubcommand::Serve { listen } => { + resolve_provider_metadata = true; + futures.push(Box::pin(listen.resolve(listenfd))); + } + CLISubcommand::GenerateUnsubscribeUrls { emails: _ } => { + resolve_provider_metadata = false; + } + } + futures.push(Box::pin(self.config.resolve(resolve_provider_metadata))); + try_join_all(futures).await?; + if resolve_provider_metadata { + log::info!("retrieved config from auth servers"); + } Ok(()) } } +#[derive(Subcommand, Debug)] +pub enum CLISubcommand { + Serve { + #[command(flatten)] + listen: Listen, + }, + GenerateUnsubscribeUrls { + emails: Vec, + }, +} + #[derive(Args, Debug)] #[group(required = true)] pub struct Listen { diff --git a/src/config.rs b/src/config.rs index 85fbd61..043f46e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,13 +1,14 @@ use crate::client::async_http_client; use clap::builder::{TryMapValueParser, TypedValueParser, ValueParserFactory}; use clio::CachedInput; -use eyre::Context; +use eyre::{ensure, Context, OptionExt}; use futures::future::try_join_all; use indexmap::IndexMap; use openidconnect::{ core::{CoreClient, CoreProviderMetadata}, ClientId, ClientSecret, IssuerUrl, RedirectUrl, Scope, }; +use reqwest::Url; use serde::{Deserialize, Serialize}; use std::num::NonZeroU16; @@ -20,17 +21,18 @@ pub struct Config { pub sqlite_db: String, #[serde(default = "default_db_thread_channel_capacity")] pub db_thread_channel_capacity: NonZeroU16, + pub server_base_url: Url, pub oidc: IndexMap, } impl ValueParserFactory for Config { type Parser = TryMapValueParser< ::Parser, - fn(CachedInput) -> eyre::Result, + fn(CachedInput) -> Result, >; fn value_parser() -> Self::Parser { - CachedInput::value_parser().try_map(Config::load) + CachedInput::value_parser().try_map(|v| Config::load(&v).map_err(|e| format!("{e:#}"))) } } @@ -42,13 +44,28 @@ impl Config { toml::from_str(input).wrap_err_with(|| path.to_string()) } - pub fn load(input: CachedInput) -> eyre::Result { + pub fn load(input: &CachedInput) -> eyre::Result { + log::debug!("loading config from: {}", input.path()); let s = std::str::from_utf8(input.get_data()).wrap_err_with(|| input.path().to_string())?; Self::load_str(s, input.path()) } - pub async fn resolve(&mut self) -> eyre::Result<()> { - try_join_all(self.oidc.iter_mut().map(|(_, provider)| provider.resolve())).await?; + pub async fn resolve(&mut self, resolve_provider_metadata: bool) -> eyre::Result<()> { + let expected_server_base_url: Url = self + .server_base_url + .origin() + .ascii_serialization() + .parse() + .ok() + .ok_or_eyre("invalid server_base_url")?; + ensure!( + self.server_base_url == expected_server_base_url, + "invalid server_base_url -- expected {expected_server_base_url}" + ); + try_join_all(self.oidc.iter_mut().map(|(name, provider)| { + provider.resolve(name, resolve_provider_metadata, &self.server_base_url) + })) + .await?; Ok(()) } } @@ -75,21 +92,34 @@ impl OIDCProvider { pub fn state(&self) -> &OIDCProviderState { self.state.as_ref().expect("resolve called by main") } - pub async fn resolve(&mut self) -> eyre::Result<()> { - let provider_metadata = - CoreProviderMetadata::discover_async(self.issuer_url.clone(), async_http_client) - .await?; - let client = CoreClient::from_provider_metadata( - provider_metadata.clone(), - self.client_id.clone(), - Some(self.secret.clone()), + pub async fn resolve( + &mut self, + name: &str, + resolve_provider_metadata: bool, + server_base_url: &Url, + ) -> eyre::Result<()> { + let expected_redirect_url = + server_base_url.join(&format!("/subscription/callback/{name}"))?; + ensure!( + self.redirect_url.as_str() == expected_redirect_url.as_str(), + "oidc.{name:?}.redirect_url should be {expected_redirect_url}" ); - let client = client.disable_openid_scope(); - let client = client.set_redirect_uri(self.redirect_url.clone()); - self.state = Some(OIDCProviderState { - client, - provider_metadata, - }); + if resolve_provider_metadata { + let provider_metadata = + CoreProviderMetadata::discover_async(self.issuer_url.clone(), async_http_client) + .await?; + let client = CoreClient::from_provider_metadata( + provider_metadata.clone(), + self.client_id.clone(), + Some(self.secret.clone()), + ); + let client = client.disable_openid_scope(); + let client = client.set_redirect_uri(self.redirect_url.clone()); + self.state = Some(OIDCProviderState { + client, + provider_metadata, + }); + } Ok(()) } } diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..d0969e7 --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,139 @@ +use crate::models::{Keys, OnlyZero}; +use core::fmt; +use diesel::{ + expression::TypedExpressionType, sql_types::SqlType, Connection, RunQueryDsl, SqliteConnection, +}; +use ed25519_dalek::{ + ed25519::{signature::rand_core::OsRng, SignatureEncoding}, + SignatureError, Signer, Verifier, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_with::{ + base64::{Base64, UrlSafe}, + formats::Unpadded, + serde_as, +}; +use std::marker::PhantomData; + +#[derive(Copy, Clone)] +pub struct Key(T); + +impl fmt::Debug for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Key([redacted])") + } +} + +impl Key { + pub fn new(v: T) -> Self { + Self(v) + } + pub fn secret(&self) -> &T { + &self.0 + } +} + +impl diesel::Queryable for Key +where + Vec: diesel::Queryable, +{ + type Row = as diesel::Queryable>::Row; + + fn build(row: Self::Row) -> diesel::deserialize::Result { + let v = as diesel::Queryable>::build(row)?; + Ok(Key(ed25519_dalek::SigningKey::try_from(&*v)?)) + } +} + +impl diesel::expression::AsExpression + for Key +where + Vec: diesel::expression::AsExpression, +{ + type Expression = as diesel::expression::AsExpression>::Expression; + + fn as_expression(self) -> Self::Expression { + Vec::::from(self.0.to_bytes()).as_expression() + } +} + +impl<'a, T: SqlType + TypedExpressionType> diesel::expression::AsExpression + for &'a Key +where + &'a [u8]: diesel::expression::AsExpression, +{ + type Expression = <&'a [u8] as diesel::expression::AsExpression>::Expression; + + fn as_expression(self) -> Self::Expression { + self.0.as_bytes()[..].as_expression() + } +} + +impl<'a, T: SqlType + TypedExpressionType> diesel::expression::AsExpression + for &'a &'_ Key +where + &'a [u8]: diesel::expression::AsExpression, +{ + type Expression = <&'a [u8] as diesel::expression::AsExpression>::Expression; + + fn as_expression(self) -> Self::Expression { + self.0.as_bytes()[..].as_expression() + } +} + +pub fn get_or_make_keys(db: &mut SqliteConnection) -> eyre::Result { + db.transaction(|db| -> eyre::Result { + let keys: Option = crate::schema::keys::dsl::keys.load(db)?.get(0).cloned(); + if let Some(keys) = keys { + return Ok(keys); + } + let keys = Keys { + id: OnlyZero::Zero, + email_unsubscribe_url: Key::new(ed25519_dalek::SigningKey::generate(&mut OsRng)), + }; + diesel::insert_into(crate::schema::keys::dsl::keys) + .values(keys.clone()) + .execute(db)?; + Ok(keys) + }) +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +pub struct Signed { + #[serde(rename = "v")] + value: String, + #[serde(rename = "s", bound(deserialize = "S::Repr: TryFrom>"))] + #[serde_as(as = "Base64")] + signature: S::Repr, + #[serde(skip)] + _phantom: PhantomData<(fn() -> T, S)>, +} + +impl Signed { + pub fn sign(value: &T, key: &K) -> Result + where + K: Signer, + { + let value = serde_json::to_string(value)?; + let signature = key.sign(value.as_bytes()).to_bytes(); + Ok(Self { + value, + signature, + _phantom: PhantomData, + }) + } + pub fn verify(&self, key: &K) -> eyre::Result> + where + K: Verifier, + for<'a> eyre::Report: From<>::Error>, + { + if let Err(e) = key.verify( + &self.value.as_bytes(), + &S::try_from(self.signature.as_ref())?, + ) { + return Ok(Err(e)); + } + Ok(Ok(serde_json::from_str(&self.value)?)) + } +} diff --git a/src/lib.rs b/src/lib.rs index ae458de..0887a55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,33 +1,38 @@ use crate::{ - cli::{Listen, ListenFdSocket, ListenFdSockets}, + app::EmailUnsubscribeUrlQueryParams, + cli::{CLISubcommand, Listen, ListenFdSocket, ListenFdSockets}, + config::Config, db::DbThread, + keys::get_or_make_keys, + models::Keys, }; use actix_session::{storage::CookieSessionStore, SessionMiddleware}; use actix_web::{cookie::Key, web, App, HttpServer}; use clap::Parser; use diesel::Connection; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +use eyre::eyre; use listenfd::ListenFd; +use openidconnect::EndUserEmail; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); mod app; mod cli; mod client; mod config; mod db; +mod keys; mod models; mod schema; -pub async fn main() -> eyre::Result<()> { - env_logger::init(); - let mut listenfd = ListenFd::from_env(); - color_eyre::install()?; - let mut args = cli::CLI::parse(); - let db = web::Data::new(DbThread::new( - diesel::SqliteConnection::establish(&args.config.sqlite_db)?, - args.config.db_thread_channel_capacity.get().into(), - )); - args.resolve(&mut listenfd).await?; - log::info!("retrieved config from auth servers"); - let config = web::Data::new(args.config); +pub async fn run_server( + config: Config, + db: web::Data, + keys: web::Data, + listen: Listen, +) -> eyre::Result<()> { + let config = web::Data::new(config); let cookie_session_key = Key::generate(); let mut server = HttpServer::new(move || { let cookie_session = @@ -38,13 +43,14 @@ pub async fn main() -> eyre::Result<()> { .wrap(cookie_session) .app_data(config.clone()) .app_data(db.clone()) + .app_data(keys.clone()) .configure(app::all_services) }); let Listen { listen_tcp, listen_unix, listen_fd, - } = &mut args.listen; + } = listen; for sock in listen_tcp.iter() { server = server.bind_auto_h2c(sock)?; } @@ -52,7 +58,7 @@ pub async fn main() -> eyre::Result<()> { server = server.bind_uds(sock)?; } if let Some(ListenFdSockets(sockets)) = listen_fd { - for sock in sockets.drain(..) { + for sock in sockets.into_iter() { server = match sock { ListenFdSocket::Tcp(sock) => server.listen_auto_h2c(sock)?, ListenFdSocket::Unix(sock) => server.listen_uds(sock)?, @@ -63,3 +69,38 @@ pub async fn main() -> eyre::Result<()> { server.run().await?; Ok(()) } + +pub async fn generate_unsubscribe_urls( + config: Config, + keys: web::Data, + emails: Vec, +) -> eyre::Result<()> { + for email in emails { + let email = EndUserEmail::new(email); + let url = EmailUnsubscribeUrlQueryParams::new(&email, &keys).make_url(&config); + println!("{url}"); + } + Ok(()) +} + +pub async fn main() -> eyre::Result<()> { + env_logger::init(); + let mut listenfd = ListenFd::from_env(); + color_eyre::install()?; + let mut cli = cli::CLI::parse(); + let mut db = diesel::SqliteConnection::establish(&cli.config.sqlite_db)?; + db.run_pending_migrations(MIGRATIONS) + .map_err(|e| eyre!(e))?; + let keys = web::Data::new(get_or_make_keys(&mut db)?); + let db = web::Data::new(DbThread::new( + db, + cli.config.db_thread_channel_capacity.get().into(), + )); + cli.resolve(&mut listenfd).await?; + match cli.subcommand { + CLISubcommand::Serve { listen } => run_server(cli.config, db, keys, listen).await, + CLISubcommand::GenerateUnsubscribeUrls { emails } => { + generate_unsubscribe_urls(cli.config, keys, emails).await + } + } +} diff --git a/src/models.rs b/src/models.rs index d0a643a..502ab72 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,5 +1,7 @@ +use crate::keys::Key; use chrono::NaiveDateTime; use diesel::prelude::*; +use eyre::ensure; #[derive(Queryable, Selectable, Insertable)] #[diesel(table_name = crate::schema::accounts)] @@ -8,3 +10,34 @@ pub struct Account { pub email: String, pub date: NaiveDateTime, } + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OnlyZero { + #[default] + Zero = 0, +} + +impl From for i32 { + fn from(_value: OnlyZero) -> Self { + 0 + } +} + +impl TryFrom for OnlyZero { + type Error = eyre::Report; + + fn try_from(value: i32) -> Result { + ensure!(value == 0, "tried to convert nonzero value into OnlyZero"); + Ok(OnlyZero::Zero) + } +} + +#[derive(Queryable, Selectable, Insertable, Clone)] +#[diesel(table_name = crate::schema::keys)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Keys { + #[diesel(serialize_as = i32)] + #[diesel(deserialize_as = i32)] + pub id: OnlyZero, + pub email_unsubscribe_url: Key, +} diff --git a/src/schema.rs b/src/schema.rs index 65fc72d..93f31d2 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -6,3 +6,16 @@ diesel::table! { date -> Timestamp, } } + +diesel::table! { + keys (id) { + id -> Integer, + #[sql_name = "email-unsubscribe-url"] + email_unsubscribe_url -> Binary, + } +} + +diesel::allow_tables_to_appear_in_same_query!( + accounts, + keys, +);