From d6ebd3a4a6f8251a27c406051bece86a4b811a75 Mon Sep 17 00:00:00 2001 From: Jacob Lifshay Date: Mon, 8 Apr 2024 18:25:32 -0700 Subject: [PATCH] works! --- .gitignore | 3 +- Cargo.lock | 45 ++++++ Cargo.toml | 2 + config.toml.sample | 4 +- diesel.toml | 9 ++ migrations/.keep | 0 .../down.sql | 1 + .../2024-04-08-223926_create_accounts/up.sql | 4 + src/app.rs | 133 +++++++++++++++--- src/config.rs | 11 +- src/db.rs | 56 ++++++++ src/lib.rs | 14 +- src/models.rs | 10 ++ src/schema.rs | 8 ++ 14 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 diesel.toml create mode 100644 migrations/.keep create mode 100644 migrations/2024-04-08-223926_create_accounts/down.sql create mode 100644 migrations/2024-04-08-223926_create_accounts/up.sql create mode 100644 src/db.rs create mode 100644 src/models.rs create mode 100644 src/schema.rs diff --git a/.gitignore b/.gitignore index cab0673..7e5fef8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target /.vscode -/config.toml \ No newline at end of file +/config.toml +*.db \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f804b68..00201a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -795,6 +795,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "diesel" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559" +dependencies = [ + "chrono", + "diesel_derives", + "libsqlite3-sys", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d02eecb814ae714ffe61ddc2db2dd03e6c49a42e269b5001355500d431cce0c" +dependencies = [ + "diesel_table_macro_syntax", + "proc-macro2", + "quote", + "syn 2.0.58", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" +dependencies = [ + "syn 2.0.58", +] + [[package]] name = "digest" version = "0.10.7" @@ -1449,6 +1482,16 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -2434,9 +2477,11 @@ version = "0.1.0" dependencies = [ "actix-session", "actix-web", + "chrono", "clap", "clio", "color-eyre", + "diesel", "env_logger", "eyre", "futures", diff --git a/Cargo.toml b/Cargo.toml index 807610c..db2d81d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,11 @@ edition = "2021" [dependencies] actix-session = { version = "0.9.0", features = ["cookie-session"] } actix-web = { version = "4.5.1", features = ["secure-cookies"] } +chrono = { version = "0.4.37", features = ["now"] } 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"] } env_logger = "0.11.3" eyre = "0.6.12" futures = "0.3.30" diff --git a/config.toml.sample b/config.toml.sample index da14b88..649d1ae 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -1,3 +1,5 @@ +sqlite_db = "subscribe-list.db" + [oidc.google] pretty_name = "Google" issuer_url = "https://accounts.google.com" @@ -12,4 +14,4 @@ issuer_url = "https://salsa.debian.org" client_id = "" secret = "" redirect_url = "https://my-site/subscription/callback/debian-salsa" -scopes = ["email"] +scopes = ["email", "openid"] diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..c028f4a --- /dev/null +++ b/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId"] + +[migrations_directory] +dir = "migrations" diff --git a/migrations/.keep b/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/2024-04-08-223926_create_accounts/down.sql b/migrations/2024-04-08-223926_create_accounts/down.sql new file mode 100644 index 0000000..b882e7c --- /dev/null +++ b/migrations/2024-04-08-223926_create_accounts/down.sql @@ -0,0 +1 @@ +DROP TABLE "accounts" \ No newline at end of file diff --git a/migrations/2024-04-08-223926_create_accounts/up.sql b/migrations/2024-04-08-223926_create_accounts/up.sql new file mode 100644 index 0000000..db9059a --- /dev/null +++ b/migrations/2024-04-08-223926_create_accounts/up.sql @@ -0,0 +1,4 @@ +CREATE TABLE "accounts" ( + "email" TEXT NOT NULL PRIMARY KEY ASC, + "date" TIMESTAMP NOT NULL +) WITHOUT ROWID; \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 7186e7d..92ebac5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,4 @@ -use crate::{ - client::async_http_client, - config::{Config, OIDCProvider}, -}; +use crate::{client::async_http_client, config::Config, db::DbThread, models::Account}; use actix_session::Session; use actix_web::{ get, @@ -9,10 +6,10 @@ use actix_web::{ web::{self, ServiceConfig}, HttpResponse, Responder, }; -use eyre::{ensure, Context, OptionExt}; +use eyre::{ensure, OptionExt}; use openidconnect::{ core::{CoreAuthenticationFlow, CoreUserInfoClaims}, - AuthorizationCode, CsrfToken, EndUserEmail, Nonce, OAuth2TokenResponse, + AuthorizationCode, CsrfToken, EndUserEmail, Nonce, OAuth2TokenResponse, TokenResponse, }; use serde::{Deserialize, Serialize}; use std::marker::PhantomData; @@ -74,6 +71,8 @@ pub struct SubscriptionLoggedOutTemplateProviders { templates! { #[text = r#"Subscription +

Register or Sign In:

+As an anti-spam measure, you need to register using a 3rd-party account:
    {{- for provider in providers -}}
  • {provider.pretty_name}
  • @@ -86,7 +85,11 @@ templates! { } #[text = r#"Subscription -

    Logged in as {email}

    +

    Signed in as {email}

    + "#] #[derive(Debug, Serialize)] pub struct SubscriptionLoggedInTemplate { @@ -129,7 +132,44 @@ impl SessionState { } } -impl OIDCProvider {} +pub async fn get_or_insert_account( + email: EndUserEmail, + session: &Session, + db: web::Data, +) -> SessionState { + let state = SessionState { + email: email.clone(), + }; + db.run_on(move |connection| { + use crate::schema::accounts::dsl::accounts; + use diesel::prelude::*; + let account = Account { + email: email.to_string(), + date: chrono::Utc::now().naive_utc(), + }; + match diesel::insert_into(accounts) + .values(&account) + .on_conflict_do_nothing() + .execute(connection) + { + Ok(0) => { + log::debug!("found existing account: {}", account.email.as_str()); + } + Ok(_) => { + log::info!("created new account: {}", account.email.as_str()); + } + Err(e) => { + log::error!( + "failed to get/insert account: {}\n{e}", + account.email.as_str() + ) + } + } + }) + .await; + state.set(session); + state +} const NONCE: &'static str = "nonce"; const CSRF_TOKEN: &'static str = "csrf_token"; @@ -146,6 +186,7 @@ pub async fn callback( session: Session, path: web::Path<(String,)>, query: web::Query, + db: web::Data, ) -> impl Responder { let mut resp = HttpResponse::SeeOther().body(""); resp.headers_mut() @@ -155,15 +196,15 @@ pub async fn callback( } let body = async { let csrf_token = session - .get::(CSRF_TOKEN) - .context("invalid csrf token")? - .ok_or_eyre("missing csrf token")?; + .remove_as::(CSRF_TOKEN) + .ok_or_eyre("missing csrf token")? + .ok() + .ok_or_eyre("invalid csrf token")?; let nonce = session - .get::(NONCE) - .context("invalid nonce")? - .ok_or_eyre("missing nonce")?; - session.remove(CSRF_TOKEN); - session.remove(NONCE); + .remove_as::(NONCE) + .ok_or_eyre("missing nonce")? + .ok() + .ok_or_eyre("invalid nonce")?; ensure!( csrf_token.secret() == query.state.secret(), "csrf token doesn't match" @@ -175,6 +216,14 @@ pub async fn callback( .exchange_code(query.into_inner().code) .request_async(async_http_client) .await?; + if let Some(id_token) = token.id_token() { + let id_token_claims = + id_token.claims(&provider.state().client.id_token_verifier(), &nonce)?; + if let Some(email) = id_token_claims.email().cloned() { + get_or_insert_account(email, &session, db).await; + return Ok(()); + } + } let user_info: CoreUserInfoClaims = provider .state() .client @@ -182,7 +231,7 @@ pub async fn callback( .request_async(async_http_client) .await?; let email = user_info.email().ok_or_eyre("no email provided")?.clone(); - SessionState { email }.set(&session); + get_or_insert_account(email, &session, db).await; Ok(()) }; let result: eyre::Result<()> = body.await; @@ -224,10 +273,50 @@ pub async fn login( resp } +#[get("/subscription/logout")] +pub async fn logout(session: Session) -> impl Responder { + session.purge(); + let mut resp = HttpResponse::SeeOther().body(""); + resp.headers_mut() + .insert(LOCATION, "/subscription".parse().unwrap()); + resp +} + +#[get("/subscription/unsubscribe")] +pub async fn unsubscribe(session: Session, db: web::Data) -> impl Responder { + if let Some(state) = SessionState::get(&session) { + db.run_on(move |connection| { + use crate::schema::accounts::dsl::*; + use diesel::prelude::*; + match diesel::delete(accounts.filter(email.eq(state.email.as_str()))) + .execute(connection) + { + Ok(_) => { + log::info!("deleted account: {}", state.email.as_str()); + } + Err(e) => { + log::error!("failed to delete account: {}\n{e}", state.email.as_str()) + } + } + }) + .await; + } + session.purge(); + let mut resp = HttpResponse::SeeOther().body(""); + resp.headers_mut() + .insert(LOCATION, "/subscription".parse().unwrap()); + resp +} + #[get("/subscription")] -pub async fn subscription(config: web::Data, session: Session) -> impl Responder { +pub async fn subscription( + config: web::Data, + session: Session, + db: web::Data, +) -> impl Responder { if let Some(SessionState { email }) = SessionState::get(&session) { - let mut template = SubscriptionLoggedInTemplate { email }; + get_or_insert_account(email.clone(), &session, db).await; + let template = SubscriptionLoggedInTemplate { email }; HttpResponse::Ok() .content_type(ContentType::html()) .body(template.render().expect("rendering can't fail")) @@ -248,7 +337,11 @@ pub async fn subscription(config: web::Data, session: Session) -> impl R } pub fn all_services(cfg: &mut ServiceConfig) { - cfg.service(subscription).service(login).service(callback); + cfg.service(subscription) + .service(login) + .service(callback) + .service(logout) + .service(unsubscribe); } #[cfg(test)] diff --git a/src/config.rs b/src/config.rs index 6107f11..85fbd61 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,9 +9,17 @@ use openidconnect::{ ClientId, ClientSecret, IssuerUrl, RedirectUrl, Scope, }; use serde::{Deserialize, Serialize}; +use std::num::NonZeroU16; + +pub fn default_db_thread_channel_capacity() -> NonZeroU16 { + NonZeroU16::new(10).unwrap() +} #[derive(Deserialize, Serialize, Debug, Clone)] pub struct Config { + pub sqlite_db: String, + #[serde(default = "default_db_thread_channel_capacity")] + pub db_thread_channel_capacity: NonZeroU16, pub oidc: IndexMap, } @@ -27,6 +35,7 @@ impl ValueParserFactory for Config { } impl Config { + #[allow(dead_code)] pub const EXAMPLE: &'static str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml.sample")); pub fn load_str(input: &str, path: impl ToString) -> eyre::Result { @@ -35,7 +44,7 @@ impl Config { pub fn load(input: CachedInput) -> eyre::Result { let s = std::str::from_utf8(input.get_data()).wrap_err_with(|| input.path().to_string())?; - toml::from_str(s).wrap_err_with(|| input.path().to_string()) + Self::load_str(s, input.path()) } pub async fn resolve(&mut self) -> eyre::Result<()> { diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..46c44c3 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,56 @@ +use tokio::sync::{mpsc, oneshot}; + +struct DbThreadInner { + op_sender: mpsc::Sender>, + join_handle: std::thread::JoinHandle<()>, +} + +pub struct DbThread(Option); + +impl DbThread { + pub fn new(mut connection: diesel::SqliteConnection, buffer: usize) -> Self { + let (op_sender, mut op_receiver) = + mpsc::channel::>(buffer); + let join_handle = std::thread::spawn(move || loop { + let Some(f) = op_receiver.blocking_recv() else { + break; + }; + f(&mut connection); + }); + Self(Some(DbThreadInner { + op_sender, + join_handle, + })) + } + pub async fn run_on< + F: FnOnce(&mut diesel::SqliteConnection) -> T + Send + 'static, + T: Send + 'static, + >( + &self, + f: F, + ) -> T { + let (result_sender, result_receiver) = oneshot::channel(); + self.0 + .as_ref() + .unwrap() + .op_sender + .send(Box::new(move |connection| { + // if send failed, the future must have been canceled, so ignore send failures + let _ = result_sender.send(f(connection)); + })) + .await + .expect("send to succeed"); + result_receiver.await.expect("result to be written") + } +} + +impl Drop for DbThread { + fn drop(&mut self) { + let DbThreadInner { + op_sender, + join_handle, + } = self.0.take().unwrap(); + drop(op_sender); + join_handle.join().unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7235f9e..ae458de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,30 @@ -use crate::cli::{Listen, ListenFdSocket, ListenFdSockets}; +use crate::{ + cli::{Listen, ListenFdSocket, ListenFdSockets}, + db::DbThread, +}; use actix_session::{storage::CookieSessionStore, SessionMiddleware}; use actix_web::{cookie::Key, web, App, HttpServer}; use clap::Parser; +use diesel::Connection; use listenfd::ListenFd; mod app; mod cli; mod client; mod config; +mod db; +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); @@ -26,6 +37,7 @@ pub async fn main() -> eyre::Result<()> { App::new() .wrap(cookie_session) .app_data(config.clone()) + .app_data(db.clone()) .configure(app::all_services) }); let Listen { diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..d0a643a --- /dev/null +++ b/src/models.rs @@ -0,0 +1,10 @@ +use chrono::NaiveDateTime; +use diesel::prelude::*; + +#[derive(Queryable, Selectable, Insertable)] +#[diesel(table_name = crate::schema::accounts)] +#[diesel(check_for_backend(diesel::sqlite::Sqlite))] +pub struct Account { + pub email: String, + pub date: NaiveDateTime, +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..65fc72d --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,8 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + accounts (email) { + email -> Text, + date -> Timestamp, + } +}