works!
This commit is contained in:
parent
8b76a51434
commit
d6ebd3a4a6
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
/target
|
||||
/.vscode
|
||||
/config.toml
|
||||
/config.toml
|
||||
*.db
|
45
Cargo.lock
generated
45
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 = "<TODO>"
|
||||
secret = "<TODO>"
|
||||
redirect_url = "https://my-site/subscription/callback/debian-salsa"
|
||||
scopes = ["email"]
|
||||
scopes = ["email", "openid"]
|
||||
|
|
9
diesel.toml
Normal file
9
diesel.toml
Normal file
|
@ -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"
|
0
migrations/.keep
Normal file
0
migrations/.keep
Normal file
1
migrations/2024-04-08-223926_create_accounts/down.sql
Normal file
1
migrations/2024-04-08-223926_create_accounts/down.sql
Normal file
|
@ -0,0 +1 @@
|
|||
DROP TABLE "accounts"
|
4
migrations/2024-04-08-223926_create_accounts/up.sql
Normal file
4
migrations/2024-04-08-223926_create_accounts/up.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
CREATE TABLE "accounts" (
|
||||
"email" TEXT NOT NULL PRIMARY KEY ASC,
|
||||
"date" TIMESTAMP NOT NULL
|
||||
) WITHOUT ROWID;
|
133
src/app.rs
133
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#"<head><title>Subscription</title></head>
|
||||
<body>
|
||||
<h1>Register or Sign In:</h1>
|
||||
As an anti-spam measure, you need to register using a 3rd-party account:
|
||||
<ul>
|
||||
{{- for provider in providers -}}
|
||||
<li><a href="{provider.url}">{provider.pretty_name}</a></li>
|
||||
|
@ -86,7 +85,11 @@ templates! {
|
|||
}
|
||||
#[text = r#"<head><title>Subscription</title></head>
|
||||
<body>
|
||||
<p>Logged in as {email}</p>
|
||||
<p>Signed in as {email}</p>
|
||||
<ul>
|
||||
<li><a href="/subscription/logout">Logout</a></li>
|
||||
<li><b><a href="/subscription/unsubscribe">Unsubscribe and Delete Account</a></b></li>
|
||||
</ul>
|
||||
</body>"#]
|
||||
#[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<DbThread>,
|
||||
) -> 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<AuthQuery>,
|
||||
db: web::Data<DbThread>,
|
||||
) -> 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::<CsrfToken>(CSRF_TOKEN)
|
||||
.context("invalid csrf token")?
|
||||
.ok_or_eyre("missing csrf token")?;
|
||||
.remove_as::<CsrfToken>(CSRF_TOKEN)
|
||||
.ok_or_eyre("missing csrf token")?
|
||||
.ok()
|
||||
.ok_or_eyre("invalid csrf token")?;
|
||||
let nonce = session
|
||||
.get::<Nonce>(NONCE)
|
||||
.context("invalid nonce")?
|
||||
.ok_or_eyre("missing nonce")?;
|
||||
session.remove(CSRF_TOKEN);
|
||||
session.remove(NONCE);
|
||||
.remove_as::<Nonce>(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<DbThread>) -> 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<Config>, session: Session) -> impl Responder {
|
||||
pub async fn subscription(
|
||||
config: web::Data<Config>,
|
||||
session: Session,
|
||||
db: web::Data<DbThread>,
|
||||
) -> 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<Config>, 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)]
|
||||
|
|
|
@ -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<String, OIDCProvider>,
|
||||
}
|
||||
|
||||
|
@ -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<Config> {
|
||||
|
@ -35,7 +44,7 @@ impl Config {
|
|||
|
||||
pub fn load(input: CachedInput) -> eyre::Result<Config> {
|
||||
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<()> {
|
||||
|
|
56
src/db.rs
Normal file
56
src/db.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
struct DbThreadInner {
|
||||
op_sender: mpsc::Sender<Box<dyn FnOnce(&mut diesel::SqliteConnection) + Send>>,
|
||||
join_handle: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub struct DbThread(Option<DbThreadInner>);
|
||||
|
||||
impl DbThread {
|
||||
pub fn new(mut connection: diesel::SqliteConnection, buffer: usize) -> Self {
|
||||
let (op_sender, mut op_receiver) =
|
||||
mpsc::channel::<Box<dyn FnOnce(&mut diesel::SqliteConnection) + Send>>(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();
|
||||
}
|
||||
}
|
14
src/lib.rs
14
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 {
|
||||
|
|
10
src/models.rs
Normal file
10
src/models.rs
Normal file
|
@ -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,
|
||||
}
|
8
src/schema.rs
Normal file
8
src/schema.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
// @generated automatically by Diesel CLI.
|
||||
|
||||
diesel::table! {
|
||||
accounts (email) {
|
||||
email -> Text,
|
||||
date -> Timestamp,
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue