feat: update to match stalwart, and add onion
This commit is contained in:
parent
55a862fabb
commit
ede986080a
9 changed files with 277 additions and 56 deletions
1
api/Cargo.lock
generated
1
api/Cargo.lock
generated
|
|
@ -567,6 +567,7 @@ name = "jetpham-qa-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"lettre",
|
"lettre",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
base64 = "0.22"
|
||||||
lettre = "0.11"
|
lettre = "0.11"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ pub fn send_notification(
|
||||||
id: i64,
|
id: i64,
|
||||||
question: &str,
|
question: &str,
|
||||||
notify_email: &str,
|
notify_email: &str,
|
||||||
|
mail_domain: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let truncated = if question.len() > 50 {
|
let truncated = if question.len() > 50 {
|
||||||
format!("{}...", &question[..50])
|
format!("{}...", &question[..50])
|
||||||
|
|
@ -13,8 +14,8 @@ pub fn send_notification(
|
||||||
question.to_string()
|
question.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let from: Mailbox = "Q&A <qa@extremist.software>".parse()?;
|
let from: Mailbox = format!("Q&A <qa@{mail_domain}>").parse()?;
|
||||||
let reply_to: Mailbox = format!("qa+{id}@extremist.software").parse()?;
|
let reply_to: Mailbox = format!("qa+{id}@{mail_domain}").parse()?;
|
||||||
let to: Mailbox = notify_email.parse()?;
|
let to: Mailbox = notify_email.parse()?;
|
||||||
|
|
||||||
let email = Message::builder()
|
let email = Message::builder()
|
||||||
|
|
@ -26,7 +27,9 @@ pub fn send_notification(
|
||||||
|
|
||||||
let mailer = SmtpTransport::builder_dangerous("localhost")
|
let mailer = SmtpTransport::builder_dangerous("localhost")
|
||||||
.tls(Tls::None)
|
.tls(Tls::None)
|
||||||
.hello_name(lettre::transport::smtp::extension::ClientId::Domain("extremist.software".to_string()))
|
.hello_name(lettre::transport::smtp::extension::ClientId::Domain(
|
||||||
|
mail_domain.to_string(),
|
||||||
|
))
|
||||||
.build();
|
.build();
|
||||||
mailer.send(&email)?;
|
mailer.send(&email)?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::email;
|
use crate::email;
|
||||||
|
|
@ -20,7 +21,10 @@ pub struct Question {
|
||||||
pub async fn get_questions(
|
pub async fn get_questions(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Result<Json<Vec<Question>>, StatusCode> {
|
) -> Result<Json<Vec<Question>>, StatusCode> {
|
||||||
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
let db = state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
let mut stmt = db
|
let mut stmt = db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, question, answer, created_at, answered_at \
|
"SELECT id, question, answer, created_at, answered_at \
|
||||||
|
|
@ -86,14 +90,20 @@ pub async fn post_question(
|
||||||
"INSERT INTO questions (question) VALUES (?1)",
|
"INSERT INTO questions (question) VALUES (?1)",
|
||||||
rusqlite::params![body.question],
|
rusqlite::params![body.question],
|
||||||
)
|
)
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "insert error".to_string()))?;
|
.map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"insert error".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
db.last_insert_rowid()
|
db.last_insert_rowid()
|
||||||
};
|
};
|
||||||
|
|
||||||
let notify_email = state.notify_email.clone();
|
let notify_email = state.notify_email.clone();
|
||||||
|
let mail_domain = state.mail_domain.clone();
|
||||||
let question_text = body.question.clone();
|
let question_text = body.question.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
if let Err(e) = email::send_notification(id, &question_text, ¬ify_email) {
|
if let Err(e) = email::send_notification(id, &question_text, ¬ify_email, &mail_domain) {
|
||||||
eprintln!("Failed to send notification: {e}");
|
eprintln!("Failed to send notification: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -107,6 +117,10 @@ pub async fn post_question(
|
||||||
pub struct MtaHookPayload {
|
pub struct MtaHookPayload {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub messages: Vec<MtaHookMessage>,
|
pub messages: Vec<MtaHookMessage>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub envelope: Envelope,
|
||||||
|
#[serde(default)]
|
||||||
|
pub message: MtaHookBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -114,13 +128,43 @@ pub struct MtaHookMessage {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub envelope: Envelope,
|
pub envelope: Envelope,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub message: MtaHookBody,
|
||||||
|
#[serde(default)]
|
||||||
pub contents: String,
|
pub contents: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
#[derive(Deserialize, Default)]
|
||||||
pub struct Envelope {
|
pub struct Envelope {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub to: Vec<String>,
|
pub to: Vec<Recipient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Recipient {
|
||||||
|
Address(String),
|
||||||
|
WithAddress { address: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Recipient {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Address(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recipient {
|
||||||
|
fn address(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Address(address) => address,
|
||||||
|
Self::WithAddress { address } => address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct MtaHookBody {
|
||||||
|
#[serde(default)]
|
||||||
|
pub contents: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -128,43 +172,92 @@ pub struct MtaHookResponse {
|
||||||
pub action: &'static str,
|
pub action: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
|
||||||
|
let header_secret = headers
|
||||||
|
.get("X-Webhook-Secret")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if header_secret == expected_secret {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_header = match headers.get(axum::http::header::AUTHORIZATION) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let auth_header = match auth_header.to_str() {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let encoded = match auth_header.strip_prefix("Basic ") {
|
||||||
|
Some(value) => value,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let credentials = match std::str::from_utf8(&decoded) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let (_, password) = match credentials.split_once(':') {
|
||||||
|
Some(parts) => parts,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
password == expected_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_qa_reply(payload: &MtaHookPayload) -> Option<(i64, String)> {
|
||||||
|
if !payload.messages.is_empty() {
|
||||||
|
for message in &payload.messages {
|
||||||
|
if let Some(reply) = extract_qa_reply_from_message(
|
||||||
|
&message.envelope.to,
|
||||||
|
if message.message.contents.is_empty() {
|
||||||
|
&message.contents
|
||||||
|
} else {
|
||||||
|
&message.message.contents
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return Some(reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_qa_reply_from_message(&payload.envelope.to, &payload.message.contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_qa_reply_from_message(
|
||||||
|
recipients: &[Recipient],
|
||||||
|
contents: &str,
|
||||||
|
) -> Option<(i64, String)> {
|
||||||
|
let qa_recipient = recipients.iter().find(|recipient| {
|
||||||
|
let local = recipient.address().split('@').next().unwrap_or("");
|
||||||
|
local.starts_with("qa+")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let id = email::extract_id_from_address(qa_recipient.address()).ok()?;
|
||||||
|
let body = email::strip_quoted_text(contents);
|
||||||
|
if body.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((id, body))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn webhook(
|
pub async fn webhook(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(payload): Json<MtaHookPayload>,
|
Json(payload): Json<MtaHookPayload>,
|
||||||
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
||||||
// Verify webhook secret
|
if !webhook_secret_matches(&headers, &state.webhook_secret) {
|
||||||
let secret = headers
|
eprintln!("Rejected webhook: invalid secret");
|
||||||
.get("X-Webhook-Secret")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.unwrap_or("");
|
|
||||||
|
|
||||||
if secret != state.webhook_secret {
|
|
||||||
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
|
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
for message in &payload.messages {
|
if let Some((id, body)) = extract_qa_reply(&payload) {
|
||||||
// Find a qa+<id> recipient
|
|
||||||
let qa_recipient = message.envelope.to.iter().find(|addr| {
|
|
||||||
let local = addr.split('@').next().unwrap_or("");
|
|
||||||
local.starts_with("qa+")
|
|
||||||
});
|
|
||||||
|
|
||||||
let recipient = match qa_recipient {
|
|
||||||
Some(r) => r,
|
|
||||||
None => continue, // not a Q&A reply, skip
|
|
||||||
};
|
|
||||||
|
|
||||||
let id = match email::extract_id_from_address(recipient) {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = email::strip_quoted_text(&message.contents);
|
|
||||||
if body.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = state
|
let db = state
|
||||||
.db
|
.db
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -176,9 +269,82 @@ pub async fn webhook(
|
||||||
)
|
)
|
||||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
|
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
|
||||||
|
|
||||||
|
eprintln!("Stored Q&A reply for question #{id}");
|
||||||
return Ok(Json(MtaHookResponse { action: "discard" }));
|
return Ok(Json(MtaHookResponse { action: "discard" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No Q&A recipient matched — let Stalwart deliver normally
|
// No Q&A recipient matched — let Stalwart deliver normally
|
||||||
Ok(Json(MtaHookResponse { action: "accept" }))
|
Ok(Json(MtaHookResponse { action: "accept" }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
|
||||||
|
use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_reply_from_current_stalwart_payload() {
|
||||||
|
let payload: MtaHookPayload = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"envelope": {
|
||||||
|
"to": [
|
||||||
|
{
|
||||||
|
"address": "qa+42@extremist.software"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted"
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
extract_qa_reply(&payload),
|
||||||
|
Some((42, "This is the answer.".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_reply_from_legacy_batch_payload() {
|
||||||
|
let payload: MtaHookPayload = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"envelope": {
|
||||||
|
"to": ["qa+7@extremist.software"]
|
||||||
|
},
|
||||||
|
"contents": "Legacy answer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
extract_qa_reply(&payload),
|
||||||
|
Some((7, "Legacy answer".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_header_secret() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("X-Webhook-Secret", "topsecret".parse().unwrap());
|
||||||
|
|
||||||
|
assert!(webhook_secret_matches(&headers, "topsecret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_basic_auth_password() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
"Basic dXNlcjp0b3BzZWNyZXQ=".parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(webhook_secret_matches(&headers, "topsecret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use crate::rate_limit::RateLimiter;
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Mutex<Connection>,
|
pub db: Mutex<Connection>,
|
||||||
pub notify_email: String,
|
pub notify_email: String,
|
||||||
|
pub mail_domain: String,
|
||||||
pub rate_limiter: RateLimiter,
|
pub rate_limiter: RateLimiter,
|
||||||
pub webhook_secret: String,
|
pub webhook_secret: String,
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +19,8 @@ pub struct AppState {
|
||||||
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let db_path = std::env::var("QA_DB_PATH").unwrap_or_else(|_| "qa.db".to_string());
|
let db_path = std::env::var("QA_DB_PATH").unwrap_or_else(|_| "qa.db".to_string());
|
||||||
let notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set");
|
let notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set");
|
||||||
|
let mail_domain =
|
||||||
|
std::env::var("QA_MAIL_DOMAIN").unwrap_or_else(|_| "extremist.software".to_string());
|
||||||
let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
|
let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
|
||||||
|
|
||||||
let conn = Connection::open(&db_path)?;
|
let conn = Connection::open(&db_path)?;
|
||||||
|
|
@ -35,6 +38,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let state = Arc::new(AppState {
|
let state = Arc::new(AppState {
|
||||||
db: Mutex::new(conn),
|
db: Mutex::new(conn),
|
||||||
notify_email,
|
notify_email,
|
||||||
|
mail_domain,
|
||||||
rate_limiter: RateLimiter::new(5, 3600),
|
rate_limiter: RateLimiter::new(5, 3600),
|
||||||
webhook_secret,
|
webhook_secret,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
46
flake.nix
46
flake.nix
|
|
@ -7,16 +7,27 @@
|
||||||
agenix.url = "github:ryantm/agenix";
|
agenix.url = "github:ryantm/agenix";
|
||||||
agenix.inputs.nixpkgs.follows = "nixpkgs";
|
agenix.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
outputs = { self, nixpkgs, rust-overlay, flake-utils, agenix }:
|
outputs =
|
||||||
(flake-utils.lib.eachDefaultSystem (system:
|
{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
rust-overlay,
|
||||||
|
flake-utils,
|
||||||
|
agenix,
|
||||||
|
}:
|
||||||
|
(flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
let
|
let
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [ (import rust-overlay) ];
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
rustToolchain = pkgs.rust-bin.selectLatestNightlyWith (toolchain:
|
agenixPkg = agenix.packages.${system}.default;
|
||||||
|
rustToolchain = pkgs.rust-bin.selectLatestNightlyWith (
|
||||||
|
toolchain:
|
||||||
toolchain.default.override {
|
toolchain.default.override {
|
||||||
extensions = [ "rust-src" ];
|
extensions = [ "rust-src" ];
|
||||||
targets = [ "wasm32-unknown-unknown" ];
|
targets = [ "wasm32-unknown-unknown" ];
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
# Stage 1: Build the WASM crate (FOD — network allowed, output content-addressed)
|
# Stage 1: Build the WASM crate (FOD — network allowed, output content-addressed)
|
||||||
cgol-wasm = pkgs.stdenv.mkDerivation {
|
cgol-wasm = pkgs.stdenv.mkDerivation {
|
||||||
|
|
@ -24,7 +35,12 @@
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./cgol;
|
src = ./cgol;
|
||||||
|
|
||||||
nativeBuildInputs = [ rustToolchain pkgs.wasm-pack pkgs.binaryen pkgs.cacert ];
|
nativeBuildInputs = [
|
||||||
|
rustToolchain
|
||||||
|
pkgs.wasm-pack
|
||||||
|
pkgs.binaryen
|
||||||
|
pkgs.cacert
|
||||||
|
];
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
export HOME=$TMPDIR
|
export HOME=$TMPDIR
|
||||||
|
|
@ -83,12 +99,13 @@
|
||||||
pname = "jetpham-qa-api";
|
pname = "jetpham-qa-api";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./api;
|
src = ./api;
|
||||||
cargoHash = "sha256-/EgiCn5N3E1tCcBWI3Sm3NGQt2h8l8yPwi/ZjporiVs=";
|
cargoHash = "sha256-PL5D3NtPFZcDIxf8f2EOT7fahKVgt/+7obJIdR17AUY=";
|
||||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||||
buildInputs = [ pkgs.openssl ];
|
buildInputs = [ pkgs.openssl ];
|
||||||
};
|
};
|
||||||
|
|
||||||
in {
|
in
|
||||||
|
{
|
||||||
packages = {
|
packages = {
|
||||||
default = website;
|
default = website;
|
||||||
cgol-wasm = cgol-wasm;
|
cgol-wasm = cgol-wasm;
|
||||||
|
|
@ -96,12 +113,21 @@
|
||||||
};
|
};
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
nodejs git curl typescript-language-server
|
nodejs
|
||||||
pkg-config wasm-pack binaryen rustToolchain
|
git
|
||||||
|
curl
|
||||||
|
openssl
|
||||||
|
agenixPkg
|
||||||
|
typescript-language-server
|
||||||
|
pkg-config
|
||||||
|
wasm-pack
|
||||||
|
binaryen
|
||||||
|
rustToolchain
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)) // {
|
))
|
||||||
|
// {
|
||||||
nixosModules.default = import ./module.nix self;
|
nixosModules.default = import ./module.nix self;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
module.nix
27
module.nix
|
|
@ -5,6 +5,11 @@ let
|
||||||
cfg = config.services.jetpham-website;
|
cfg = config.services.jetpham-website;
|
||||||
package = self.packages.x86_64-linux.default;
|
package = self.packages.x86_64-linux.default;
|
||||||
qaApi = self.packages.x86_64-linux.qa-api;
|
qaApi = self.packages.x86_64-linux.qa-api;
|
||||||
|
webhookSecretPath =
|
||||||
|
if cfg.webhookSecretFile != null then
|
||||||
|
cfg.webhookSecretFile
|
||||||
|
else
|
||||||
|
config.age.secrets.webhook-secret.path;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.jetpham-website = {
|
options.services.jetpham-website = {
|
||||||
|
|
@ -38,7 +43,7 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
age.secrets.webhook-secret = {
|
age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) {
|
||||||
file = "${self}/secrets/webhook-secret.age";
|
file = "${self}/secrets/webhook-secret.age";
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
};
|
};
|
||||||
|
|
@ -65,7 +70,15 @@ in
|
||||||
services.tor = lib.mkIf cfg.tor.enable {
|
services.tor = lib.mkIf cfg.tor.enable {
|
||||||
enable = true;
|
enable = true;
|
||||||
relay.onionServices.jetpham-website = {
|
relay.onionServices.jetpham-website = {
|
||||||
map = [{ port = 80; target = { addr = "127.0.0.1"; port = 8888; }; }];
|
map = [
|
||||||
|
{
|
||||||
|
port = 80;
|
||||||
|
target = {
|
||||||
|
addr = "127.0.0.1";
|
||||||
|
port = 8888;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -98,13 +111,19 @@ in
|
||||||
Environment = [
|
Environment = [
|
||||||
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
|
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
|
||||||
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
|
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
|
||||||
|
"QA_MAIL_DOMAIN=${cfg.qaMailDomain}"
|
||||||
];
|
];
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = 5;
|
RestartSec = 5;
|
||||||
LoadCredential = "webhook-secret:${config.age.secrets.webhook-secret.path}";
|
LoadCredential = "webhook-secret:${webhookSecretPath}";
|
||||||
};
|
};
|
||||||
script = ''
|
script = ''
|
||||||
export WEBHOOK_SECRET="$(cat $CREDENTIALS_DIRECTORY/webhook-secret)"
|
if [ ! -s "$CREDENTIALS_DIRECTORY/webhook-secret" ]; then
|
||||||
|
echo "WEBHOOK_SECRET credential is empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export WEBHOOK_SECRET="$(cat "$CREDENTIALS_DIRECTORY/webhook-secret")"
|
||||||
exec ${qaApi}/bin/jetpham-qa-api
|
exec ${qaApi}/bin/jetpham-qa-api
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
age-encryption.org/v1
|
age-encryption.org/v1
|
||||||
-> ssh-ed25519 Ziw7aw zDI2q+wM70YbQFw7hEQbU58XrgkWnM8fura+ovG0bTw
|
-> ssh-ed25519 Ziw7aw Wef4V3554wa7yF3ztMeKxqxgI4sb8MYF7x7GFj+XhFc
|
||||||
0Aznts9gOT8ADxFY0SIv/VT0ExEEq9wZLTnlC0j15JU
|
GcqWkuplBIelOaP1cvOqwyK6igK5MAKPUqvpxCaV/Yk
|
||||||
-> ssh-ed25519 uKftJg dFQxnrMwQHJ2lVBMi6P4XugljREjAHPV9bEFmLhuiBc
|
-> ssh-ed25519 uKftJg Tnhs9FR2j2713OO4qDwWb4ERNivqmKI8tN45Av1hzTU
|
||||||
VWKLaM3peiELoJigapdZv0N4Z4lRZZ8eGMEFy+WtdxA
|
bWXnkDFehYUr3AghaUV4wYKfQEOqJsZC/SL2DUcq3DM
|
||||||
--- zJghsuTUoVH8bKynqq29kzEuNDuEjvFf5v/s98jQdaA
|
--- eBpn66oOUDpku3NMzF+30j2uC/iyzO3Oy3lftTQM8MY
|
||||||
𡂝濮+<2B>i籤V2*Hf苳儑齪<E58491><E9BDAA> c栛
|
¡öî™ÙI®í£Q'ã1\óùËÄRç BP®Åº¶nµ_‘pøn„>¬Œƒ“·²‹—k&Xå<58> Ë‚=RL:d6A¨}ÜÒäÎWóK
|
||||||
|
|
@ -33,6 +33,7 @@ export function homePage(outlet: HTMLElement) {
|
||||||
<li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li>
|
<li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li>
|
||||||
<li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li>
|
<li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li>
|
||||||
<li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
|
<li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
|
||||||
|
<li><a href="http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion" class="inline-flex items-center">.onion</a></li>
|
||||||
</ol>
|
</ol>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
`)}
|
`)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue