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,
+);