Compare commits
2 commits
99715f6105
...
7b842b3342
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b842b3342 | |||
|
|
f48390b15e |
35 changed files with 2813 additions and 66 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -29,3 +29,11 @@ yarn-error.log*
|
|||
|
||||
/.direnv
|
||||
/result
|
||||
|
||||
# rust
|
||||
/api/target
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
|
|
|||
1606
api/Cargo.lock
generated
Normal file
1606
api/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
13
api/Cargo.toml
Normal file
13
api/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "jetpham-qa-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
lettre = "0.11"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
61
api/src/email.rs
Normal file
61
api/src/email.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::client::Tls;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
|
||||
pub fn send_notification(
|
||||
id: i64,
|
||||
question: &str,
|
||||
notify_email: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let truncated = if question.len() > 50 {
|
||||
format!("{}...", &question[..50])
|
||||
} else {
|
||||
question.to_string()
|
||||
};
|
||||
|
||||
let from: Mailbox = "Q&A <qa@extremist.software>".parse()?;
|
||||
let reply_to: Mailbox = format!("qa+{id}@extremist.software").parse()?;
|
||||
let to: Mailbox = notify_email.parse()?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from)
|
||||
.reply_to(reply_to)
|
||||
.to(to)
|
||||
.subject(format!("Q&A #{id}: {truncated}"))
|
||||
.body(question.to_string())?;
|
||||
|
||||
let mailer = SmtpTransport::builder_dangerous("localhost")
|
||||
.tls(Tls::None)
|
||||
.build();
|
||||
mailer.send(&email)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn strip_quoted_text(body: &str) -> String {
|
||||
let mut result = Vec::new();
|
||||
for line in body.lines() {
|
||||
if line.starts_with('>') {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("On ") && line.ends_with("wrote:") {
|
||||
break;
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
result.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
pub fn extract_id_from_address(to: &str) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let addr = to.trim();
|
||||
let addr = if let Some(start) = addr.find('<') {
|
||||
&addr[start + 1..addr.find('>').unwrap_or(addr.len())]
|
||||
} else {
|
||||
addr
|
||||
};
|
||||
let local = addr.split('@').next().unwrap_or("");
|
||||
let id_str = local
|
||||
.strip_prefix("qa+")
|
||||
.ok_or("No qa+ prefix in address")?;
|
||||
Ok(id_str.parse()?)
|
||||
}
|
||||
184
api/src/handlers.rs
Normal file
184
api/src/handlers.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::email;
|
||||
use crate::serve::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Question {
|
||||
id: i64,
|
||||
question: String,
|
||||
answer: String,
|
||||
created_at: String,
|
||||
answered_at: String,
|
||||
}
|
||||
|
||||
pub async fn get_questions(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Vec<Question>>, StatusCode> {
|
||||
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut stmt = db
|
||||
.prepare(
|
||||
"SELECT id, question, answer, created_at, answered_at \
|
||||
FROM questions WHERE answer IS NOT NULL \
|
||||
ORDER BY answered_at DESC",
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let questions = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Question {
|
||||
id: row.get(0)?,
|
||||
question: row.get(1)?,
|
||||
answer: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
answered_at: row.get(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(questions))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SubmitQuestion {
|
||||
question: String,
|
||||
}
|
||||
|
||||
pub async fn post_question(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SubmitQuestion>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if body.question.is_empty() || body.question.len() > 200 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Question must be 1-200 characters".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if !state.rate_limiter.check(&ip) {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many questions. Try again later.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let id: i64 = {
|
||||
let db = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
|
||||
db.execute(
|
||||
"INSERT INTO questions (question) VALUES (?1)",
|
||||
rusqlite::params![body.question],
|
||||
)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "insert error".to_string()))?;
|
||||
db.last_insert_rowid()
|
||||
};
|
||||
|
||||
let notify_email = state.notify_email.clone();
|
||||
let question_text = body.question.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = email::send_notification(id, &question_text, ¬ify_email) {
|
||||
eprintln!("Failed to send notification: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
// --- MTA Hook webhook types ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MtaHookPayload {
|
||||
#[serde(default)]
|
||||
pub messages: Vec<MtaHookMessage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MtaHookMessage {
|
||||
#[serde(default)]
|
||||
pub envelope: Envelope,
|
||||
#[serde(default)]
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct Envelope {
|
||||
#[serde(default)]
|
||||
pub to: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MtaHookResponse {
|
||||
pub action: &'static str,
|
||||
}
|
||||
|
||||
pub async fn webhook(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<MtaHookPayload>,
|
||||
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
|
||||
// Verify webhook secret
|
||||
let secret = headers
|
||||
.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()));
|
||||
}
|
||||
|
||||
for message in &payload.messages {
|
||||
// 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
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
|
||||
db.execute(
|
||||
"UPDATE questions SET answer = ?1, answered_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
|
||||
WHERE id = ?2 AND answer IS NULL",
|
||||
rusqlite::params![body, id],
|
||||
)
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
|
||||
|
||||
return Ok(Json(MtaHookResponse { action: "discard" }));
|
||||
}
|
||||
|
||||
// No Q&A recipient matched — let Stalwart deliver normally
|
||||
Ok(Json(MtaHookResponse { action: "accept" }))
|
||||
}
|
||||
9
api/src/main.rs
Normal file
9
api/src/main.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
mod email;
|
||||
mod handlers;
|
||||
mod rate_limit;
|
||||
mod serve;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
serve::run().await.expect("server error");
|
||||
}
|
||||
35
api/src/rate_limit.rs
Normal file
35
api/src/rate_limit.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct RateLimiter {
|
||||
max_requests: u32,
|
||||
window_secs: u64,
|
||||
clients: Mutex<HashMap<String, (u32, Instant)>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(max_requests: u32, window_secs: u64) -> Self {
|
||||
Self {
|
||||
max_requests,
|
||||
window_secs,
|
||||
clients: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check(&self, ip: &str) -> bool {
|
||||
let mut clients = self.clients.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
|
||||
let entry = clients.entry(ip.to_string()).or_insert((0, now));
|
||||
if now.duration_since(entry.1).as_secs() >= self.window_secs {
|
||||
*entry = (1, now);
|
||||
return true;
|
||||
}
|
||||
if entry.0 >= self.max_requests {
|
||||
return false;
|
||||
}
|
||||
entry.0 += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
55
api/src/serve.rs
Normal file
55
api/src/serve.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use rusqlite::Connection;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::handlers;
|
||||
use crate::rate_limit::RateLimiter;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Mutex<Connection>,
|
||||
pub notify_email: String,
|
||||
pub rate_limiter: RateLimiter,
|
||||
pub webhook_secret: String,
|
||||
}
|
||||
|
||||
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 notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set");
|
||||
let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
answered_at TEXT
|
||||
);",
|
||||
)?;
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db: Mutex::new(conn),
|
||||
notify_email,
|
||||
rate_limiter: RateLimiter::new(5, 3600),
|
||||
webhook_secret,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/questions",
|
||||
get(handlers::get_questions).post(handlers::post_question),
|
||||
)
|
||||
.route("/api/webhook", post(handlers::webhook))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await?;
|
||||
println!("Listening on 127.0.0.1:3001");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
84
flake.lock
generated
84
flake.lock
generated
|
|
@ -1,8 +1,53 @@
|
|||
{
|
||||
"nodes": {
|
||||
"agenix": {
|
||||
"inputs": {
|
||||
"darwin": "darwin",
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770165109,
|
||||
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
|
||||
"owner": "ryantm",
|
||||
"repo": "agenix",
|
||||
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ryantm",
|
||||
"repo": "agenix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"darwin": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agenix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1744478979,
|
||||
"narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=",
|
||||
"owner": "lnl7",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "43975d782b418ebf4969e9ccba82466728c2851b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "lnl7",
|
||||
"ref": "master",
|
||||
"repo": "nix-darwin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
|
|
@ -18,6 +63,27 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agenix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1745494811,
|
||||
"narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772542754,
|
||||
|
|
@ -52,6 +118,7 @@
|
|||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"agenix": "agenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
|
|
@ -89,6 +156,21 @@
|
|||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
|
|
|||
14
flake.nix
14
flake.nix
|
|
@ -4,8 +4,10 @@
|
|||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
agenix.url = "github:ryantm/agenix";
|
||||
agenix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
|
||||
outputs = { self, nixpkgs, rust-overlay, flake-utils, agenix }:
|
||||
(flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
|
|
@ -77,10 +79,20 @@
|
|||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
qa-api = pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "jetpham-qa-api";
|
||||
version = "0.1.0";
|
||||
src = ./api;
|
||||
cargoHash = "sha256-/EgiCn5N3E1tCcBWI3Sm3NGQt2h8l8yPwi/ZjporiVs=";
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
default = website;
|
||||
cgol-wasm = cgol-wasm;
|
||||
inherit qa-api;
|
||||
};
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
|
|
|
|||
63
index.html
63
index.html
|
|
@ -71,8 +71,8 @@
|
|||
},
|
||||
"sameAs": [
|
||||
"https://github.com/jetpham",
|
||||
"https://x.com/jetpham5",
|
||||
"https://bsky.app/profile/jetpham.com",
|
||||
"https://x.com/exmistsoftware",
|
||||
"https://bsky.app/profile/extremist.software",
|
||||
"https://git.extremist.software"
|
||||
]
|
||||
}
|
||||
|
|
@ -80,62 +80,21 @@
|
|||
</head>
|
||||
<body style="background:#000">
|
||||
<canvas id="canvas" class="fixed top-0 left-0 -z-10 h-screen w-screen" aria-hidden="true"></canvas>
|
||||
<main>
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
<!-- FrostedBox -->
|
||||
<div class="relative px-[2ch] py-[2ch] my-[2ch] w-full max-w-[66.666667%] min-w-fit md:mt-[4ch]">
|
||||
<nav aria-label="Main navigation" class="flex justify-center px-4">
|
||||
<div class="relative px-[2ch] py-[1ch] mt-[2ch] w-full max-w-[66.666667%] min-w-fit">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 h-[200%]"
|
||||
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%); -webkit-mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%);"
|
||||
class="pointer-events-none absolute inset-0"
|
||||
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
|
||||
<!-- Content -->
|
||||
<div class="relative z-10">
|
||||
<div class="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
|
||||
<div class="order-1 flex flex-col items-center md:order-2">
|
||||
<h1 class="sr-only">Jet Pham</h1>
|
||||
<div id="ansi-art" aria-hidden="true"></div>
|
||||
<p class="mt-[2ch]">Software Extremist</p>
|
||||
</div>
|
||||
<div class="order-2 shrink-0 md:order-1">
|
||||
<img
|
||||
src="/jet.svg"
|
||||
alt="A picture of Jet wearing a beanie in purple and blue lighting"
|
||||
width="250"
|
||||
height="250"
|
||||
class="aspect-square w-full max-w-[250px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
|
||||
style="background-color: #a80055; color: transparent"
|
||||
/>
|
||||
<div class="relative z-10 flex justify-center gap-[2ch]">
|
||||
<a href="/">[HOME]</a>
|
||||
<a href="/qa">[Q&A]</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact -->
|
||||
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Contact</legend>
|
||||
<a href="mailto:jet@extremist.software">jet@extremist.software</a>
|
||||
</fieldset>
|
||||
<!-- Links -->
|
||||
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Links</legend>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/jetpham5" class="inline-flex items-center">X</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/jetpham.com" class="inline-flex items-center">Bluesky</a>
|
||||
</li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</nav>
|
||||
<div id="outlet"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
116
module.nix
116
module.nix
|
|
@ -4,6 +4,7 @@ self:
|
|||
let
|
||||
cfg = config.services.jetpham-website;
|
||||
package = self.packages.x86_64-linux.default;
|
||||
qaApi = self.packages.x86_64-linux.qa-api;
|
||||
in
|
||||
{
|
||||
options.services.jetpham-website = {
|
||||
|
|
@ -14,15 +15,130 @@ in
|
|||
default = "jetpham.com";
|
||||
description = "Domain to serve the website on.";
|
||||
};
|
||||
|
||||
tor.enable = lib.mkEnableOption "Tor hidden service for the website";
|
||||
|
||||
envFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Environment file containing QA_NOTIFY_EMAIL.";
|
||||
};
|
||||
|
||||
qaMailDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "extremist.software";
|
||||
description = "Mail domain for Q&A reply addresses.";
|
||||
};
|
||||
|
||||
webhookSecretFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing the WEBHOOK_SECRET for MTA Hook authentication.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
age.secrets.webhook-secret = {
|
||||
file = "${self}/secrets/webhook-secret.age";
|
||||
mode = "0400";
|
||||
};
|
||||
|
||||
age.secrets.tor-onion-secret-key = lib.mkIf cfg.tor.enable {
|
||||
file = "${self}/secrets/tor-onion-secret-key.age";
|
||||
owner = "tor";
|
||||
group = "tor";
|
||||
mode = "0400";
|
||||
};
|
||||
age.secrets.tor-onion-public-key = lib.mkIf cfg.tor.enable {
|
||||
file = "${self}/secrets/tor-onion-public-key.age";
|
||||
owner = "tor";
|
||||
group = "tor";
|
||||
mode = "0444";
|
||||
};
|
||||
age.secrets.tor-onion-hostname = lib.mkIf cfg.tor.enable {
|
||||
file = "${self}/secrets/tor-onion-hostname.age";
|
||||
owner = "tor";
|
||||
group = "tor";
|
||||
mode = "0444";
|
||||
};
|
||||
|
||||
services.tor = lib.mkIf cfg.tor.enable {
|
||||
enable = true;
|
||||
relay.onionServices.jetpham-website = {
|
||||
map = [{ port = 80; target = { addr = "127.0.0.1"; port = 8888; }; }];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable {
|
||||
description = "Copy Tor onion keys into place";
|
||||
after = [ "agenix.service" ];
|
||||
before = [ "tor.service" ];
|
||||
wantedBy = [ "tor.service" ];
|
||||
serviceConfig.Type = "oneshot";
|
||||
script = ''
|
||||
dir="/var/lib/tor/onion/jetpham-website"
|
||||
mkdir -p "$dir"
|
||||
cp ${config.age.secrets.tor-onion-secret-key.path} "$dir/hs_ed25519_secret_key"
|
||||
cp ${config.age.secrets.tor-onion-public-key.path} "$dir/hs_ed25519_public_key"
|
||||
cp ${config.age.secrets.tor-onion-hostname.path} "$dir/hostname"
|
||||
chown -R tor:tor "$dir"
|
||||
chmod 700 "$dir"
|
||||
chmod 400 "$dir/hs_ed25519_secret_key"
|
||||
chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname"
|
||||
'';
|
||||
};
|
||||
# Q&A API systemd service
|
||||
systemd.services.jetpham-qa-api = {
|
||||
description = "Jet Pham Q&A API";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
DynamicUser = true;
|
||||
StateDirectory = "jetpham-qa";
|
||||
Environment = [ "QA_DB_PATH=/var/lib/jetpham-qa/qa.db" ];
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
LoadCredential = "webhook-secret:${config.age.secrets.webhook-secret.path}";
|
||||
} // lib.optionalAttrs (cfg.envFile != null) {
|
||||
EnvironmentFile = cfg.envFile;
|
||||
};
|
||||
script = ''
|
||||
export WEBHOOK_SECRET="$(cat $CREDENTIALS_DIRECTORY/webhook-secret)"
|
||||
exec ${qaApi}/bin/jetpham-qa-api
|
||||
'';
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts.${cfg.domain} = {
|
||||
extraConfig = ''
|
||||
header Cross-Origin-Opener-Policy "same-origin"
|
||||
header Cross-Origin-Embedder-Policy "require-corp"
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:3001
|
||||
}
|
||||
|
||||
handle {
|
||||
root * ${package}
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."http://127.0.0.1:8888" = lib.mkIf cfg.tor.enable {
|
||||
extraConfig = ''
|
||||
header Cross-Origin-Opener-Policy "same-origin"
|
||||
header Cross-Origin-Embedder-Policy "require-corp"
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:3001
|
||||
}
|
||||
|
||||
handle {
|
||||
root * ${package}
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
133
package-lock.json
generated
133
package-lock.json
generated
|
|
@ -16,6 +16,8 @@
|
|||
"anser": "^2.3.5",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"eslint": "^10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^15.0.12",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
|
@ -1877,6 +1879,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -2150,6 +2162,20 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
|
||||
|
|
@ -2196,6 +2222,19 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
|
@ -2334,6 +2373,22 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -2354,6 +2409,16 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
|
@ -2404,6 +2469,20 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
|
|
@ -2435,6 +2514,16 @@
|
|||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
|
@ -2736,6 +2825,19 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "15.0.12",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
|
|
@ -3094,6 +3196,20 @@
|
|||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
|
|
@ -3140,6 +3256,23 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
"anser": "^2.3.5",
|
||||
"escape-carriage": "^1.3.1",
|
||||
"eslint": "^10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^15.0.12",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -5,4 +5,14 @@
|
|||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/projects</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://jetpham.com/qa</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
|
|||
10
secrets.nix
Normal file
10
secrets.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
let
|
||||
laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE40ISu3ydCqfdpb26JYD5cIN0Fu0id/FDS+xjB5zpqu";
|
||||
server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAING219cDKTDLaZefmqvOHfXvYloA/ErsCGE0pM022vlB";
|
||||
in
|
||||
{
|
||||
"secrets/tor-onion-secret-key.age".publicKeys = [ laptop server ];
|
||||
"secrets/tor-onion-public-key.age".publicKeys = [ laptop server ];
|
||||
"secrets/tor-onion-hostname.age".publicKeys = [ laptop server ];
|
||||
"secrets/webhook-secret.age".publicKeys = [ laptop server ];
|
||||
}
|
||||
BIN
secrets/tor-onion-hostname.age
Normal file
BIN
secrets/tor-onion-hostname.age
Normal file
Binary file not shown.
7
secrets/tor-onion-public-key.age
Normal file
7
secrets/tor-onion-public-key.age
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
age-encryption.org/v1
|
||||
-> ssh-ed25519 Ziw7aw 2u0CVE/rQWpJNSRW/1xeWBKpShWT4ckke+Ih4j3WbRk
|
||||
xeE2xTlSPEDPeC4BNkaSoOckwuOhyCQWqtXkwuhBiRo
|
||||
-> ssh-ed25519 uKftJg Tt1mbTWHyXRDjvGWFBqmyrMl/PtUs45N1032luY88x8
|
||||
A51wD3tiZ0lV1TSub+Pz7hZ+kndiEpnmBliP59qYzkY
|
||||
--- 7X+mgLxb3uYfiYebJnAUwl/4jhGJJSweaolMttmoEIQ
|
||||
3à‡’Í.Úu˼’ÞZ"Àä9Ö<39>'AëâªK~.@ŽÌ‰7¾lW„÷ pý{CôÁAŠ&@¸|ÕËšV%55˜Ot¼Šr9Çîºúñ<C3BA>h»L²‡@®G’õLä˜gû€
|
||||
8
secrets/tor-onion-secret-key.age
Normal file
8
secrets/tor-onion-secret-key.age
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
age-encryption.org/v1
|
||||
-> ssh-ed25519 Ziw7aw AZTbqWYmTaudHZ8PiTZlwpf7VzwaP921guVV1iQi8WM
|
||||
7CGUyEjZoAPCBX2pqNHLd2P1KLnj/Y5nnBVToWYCjWg
|
||||
-> ssh-ed25519 uKftJg RfAkte/jcx+/SvZiUNH07cnBJcvl0Sjt7zSxdSCsXE0
|
||||
TdMsQ2u5WXw3KAi7Tk4JOdbiFStT8F88xjDlRN8LH2Q
|
||||
--- pgQnGRRVjVK02tbMgDoh3SatJFxxFLazqy5ieHu96tk
|
||||
žÞxó}ûë¥T¡½<11>SÒP^©kH
׋/æôœõþ›Ý³U~ókÅ;Ö•äÕ†=•ÁºNå«\PZï|óO¨/YíV6Ìx‹=„¹¹Â<gR+2á³C5n
|
||||
©ãÃàÚ/J…‹k¤;5ÚNÈ—Ü#q’&Øxš‹|6²ƒŸ>H
|
||||
7
secrets/webhook-secret.age
Normal file
7
secrets/webhook-secret.age
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
age-encryption.org/v1
|
||||
-> ssh-ed25519 Ziw7aw zDI2q+wM70YbQFw7hEQbU58XrgkWnM8fura+ovG0bTw
|
||||
0Aznts9gOT8ADxFY0SIv/VT0ExEEq9wZLTnlC0j15JU
|
||||
-> ssh-ed25519 uKftJg dFQxnrMwQHJ2lVBMi6P4XugljREjAHPV9bEFmLhuiBc
|
||||
VWKLaM3peiELoJigapdZv0N4Z4lRZZ8eGMEFy+WtdxA
|
||||
--- zJghsuTUoVH8bKynqq29kzEuNDuEjvFf5v/s98jQdaA
|
||||
𡂝濮+<2B>i籤V2*Hf苳儑齪<E58491><E9BDAA> c栛
|
||||
14
src/components/frosted-box.ts
Normal file
14
src/components/frosted-box.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export function frostedBox(content: string, extraClasses?: string): string {
|
||||
return `
|
||||
<div class="relative px-[2ch] py-[2ch] my-[2ch] w-full max-w-[66.666667%] min-w-fit ${extraClasses ?? ""}">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 h-[200%]"
|
||||
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%); -webkit-mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%);"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
|
||||
<div class="relative z-10">
|
||||
${content}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
10
src/content/projects/cgol.md
Normal file
10
src/content/projects/cgol.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
title: Conway's Game of Life
|
||||
description: WebAssembly implementation of Conway's Game of Life, running as the background of this website.
|
||||
---
|
||||
|
||||
The background animation on this site is a WebAssembly implementation of
|
||||
Conway's Game of Life, written in Rust and compiled to WASM.
|
||||
|
||||
It runs directly in your browser using the HTML5 Canvas API, simulating
|
||||
cellular automata in real-time.
|
||||
20
src/global.d.ts
vendored
20
src/global.d.ts
vendored
|
|
@ -14,3 +14,23 @@ declare module "*.utf8ans?raw" {
|
|||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module "virtual:projects" {
|
||||
interface Project {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
html: string;
|
||||
}
|
||||
const projects: Project[];
|
||||
export default projects;
|
||||
}
|
||||
|
||||
declare module "gray-matter" {
|
||||
interface GrayMatterResult {
|
||||
data: Record<string, string>;
|
||||
content: string;
|
||||
}
|
||||
function matter(input: string): GrayMatterResult;
|
||||
export = matter;
|
||||
}
|
||||
|
|
|
|||
25
src/lib/api.ts
Normal file
25
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export interface Question {
|
||||
id: number;
|
||||
question: string;
|
||||
answer: string;
|
||||
created_at: string;
|
||||
answered_at: string;
|
||||
}
|
||||
|
||||
export async function getQuestions(): Promise<Question[]> {
|
||||
const res = await fetch("/api/questions");
|
||||
if (!res.ok) throw new Error("Failed to fetch questions");
|
||||
return res.json() as Promise<Question[]>;
|
||||
}
|
||||
|
||||
export async function submitQuestion(question: string): Promise<void> {
|
||||
const res = await fetch("/api/questions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 429) throw new Error("Too many questions. Please try again later.");
|
||||
throw new Error("Failed to submit question");
|
||||
}
|
||||
}
|
||||
11
src/main.ts
11
src/main.ts
|
|
@ -1,8 +1,13 @@
|
|||
import "~/styles/globals.css";
|
||||
import Jet from "~/assets/Jet.txt?ansi";
|
||||
import init, { start } from "cgol";
|
||||
import { route, initRouter } from "~/router";
|
||||
import { homePage } from "~/pages/home";
|
||||
import { qaPage } from "~/pages/qa";
|
||||
import { notFoundPage } from "~/pages/not-found";
|
||||
|
||||
document.getElementById("ansi-art")!.innerHTML = Jet;
|
||||
route("/", homePage);
|
||||
route("/qa", qaPage);
|
||||
route("*", notFoundPage);
|
||||
|
||||
try {
|
||||
await init();
|
||||
|
|
@ -10,3 +15,5 @@ try {
|
|||
} catch (e) {
|
||||
console.error("WASM init failed:", e);
|
||||
}
|
||||
|
||||
initRouter();
|
||||
|
|
|
|||
40
src/pages/home.ts
Normal file
40
src/pages/home.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import Jet from "~/assets/Jet.txt?ansi";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function homePage(outlet: HTMLElement) {
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<div class="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
|
||||
<div class="order-1 flex flex-col items-center md:order-2">
|
||||
<h1 class="sr-only">Jet Pham</h1>
|
||||
<div aria-hidden="true">${Jet}</div>
|
||||
<p class="mt-[2ch]">Software Extremist</p>
|
||||
</div>
|
||||
<div class="order-2 shrink-0 md:order-1">
|
||||
<img
|
||||
src="/jet.svg"
|
||||
alt="A picture of Jet wearing a beanie in purple and blue lighting"
|
||||
width="250"
|
||||
height="250"
|
||||
class="aspect-square w-full max-w-[250px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
|
||||
style="background-color: #a80055; color: transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Contact</legend>
|
||||
<a href="mailto:jet@extremist.software">jet@extremist.software</a>
|
||||
</fieldset>
|
||||
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Links</legend>
|
||||
<ol>
|
||||
<li><a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</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://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
|
||||
</ol>
|
||||
</fieldset>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
12
src/pages/not-found.ts
Normal file
12
src/pages/not-found.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function notFoundPage(outlet: HTMLElement) {
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--light-red);">404</h1>
|
||||
<p class="mt-[1ch]">Page not found.</p>
|
||||
<p class="mt-[1ch]"><a href="/">[BACK TO HOME]</a></p>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
24
src/pages/project.ts
Normal file
24
src/pages/project.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import projects from "virtual:projects";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function projectPage(outlet: HTMLElement, params: Record<string, string>) {
|
||||
const project = projects.find((p) => p.slug === params.slug);
|
||||
if (!project) {
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--light-red);">Project not found</h1>
|
||||
<p class="mt-[1ch]"><a href="/projects">[BACK TO PROJECTS]</a></p>
|
||||
`)}
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--yellow);">${project.title}</h1>
|
||||
<div class="project-content mt-[2ch]">${project.html}</div>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
22
src/pages/projects.ts
Normal file
22
src/pages/projects.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import projects from "virtual:projects";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
export function projectsPage(outlet: HTMLElement) {
|
||||
const list = projects
|
||||
.map(
|
||||
(p) => `
|
||||
<li class="mb-[1ch]">
|
||||
<a href="/projects/${p.slug}" style="color: var(--light-cyan);">${p.title}</a>
|
||||
<p style="color: var(--light-gray);">${p.description}</p>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<h1 style="color: var(--yellow);">Projects</h1>
|
||||
<ul class="mt-[2ch]">${list}</ul>
|
||||
`)}
|
||||
</div>`;
|
||||
}
|
||||
87
src/pages/qa.ts
Normal file
87
src/pages/qa.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { getQuestions, submitQuestion } from "~/lib/api";
|
||||
import { frostedBox } from "~/components/frosted-box";
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export async function qaPage(outlet: HTMLElement) {
|
||||
outlet.innerHTML = `
|
||||
<div class="flex flex-col items-center justify-start px-4">
|
||||
${frostedBox(`
|
||||
<form id="qa-form">
|
||||
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-[1ch]">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Ask a Question</legend>
|
||||
<textarea id="qa-input" maxlength="200" rows="3"
|
||||
class="qa-textarea"
|
||||
placeholder="Type your question..."></textarea>
|
||||
<div class="flex justify-between mt-[1ch]">
|
||||
<span id="char-count" style="color: var(--dark-gray);">0/200</span>
|
||||
<button type="submit" class="qa-button">[SUBMIT]</button>
|
||||
</div>
|
||||
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
|
||||
</fieldset>
|
||||
</form>
|
||||
<div id="qa-list" class="mt-[2ch]">Loading...</div>
|
||||
`)}
|
||||
</div>`;
|
||||
|
||||
const form = document.getElementById("qa-form") as HTMLFormElement;
|
||||
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
|
||||
const charCount = document.getElementById("char-count")!;
|
||||
const status = document.getElementById("qa-status")!;
|
||||
const list = document.getElementById("qa-list")!;
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
charCount.textContent = `${input.value.length}/200`;
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
|
||||
status.textContent = "Submitting...";
|
||||
status.style.color = "var(--light-gray)";
|
||||
|
||||
submitQuestion(question)
|
||||
.then(() => {
|
||||
input.value = "";
|
||||
charCount.textContent = "0/200";
|
||||
status.textContent = "Question submitted! It will appear here once answered.";
|
||||
status.style.color = "var(--light-green)";
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
status.textContent = err instanceof Error ? err.message : "Failed to submit question.";
|
||||
status.style.color = "var(--light-red)";
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const questions = await getQuestions();
|
||||
if (questions.length === 0) {
|
||||
list.textContent = "No questions answered yet.";
|
||||
list.style.color = "var(--dark-gray)";
|
||||
} else {
|
||||
list.innerHTML = questions
|
||||
.map(
|
||||
(q) => `
|
||||
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
|
||||
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
|
||||
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
|
||||
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
|
||||
<p class="mt-[0.5ch]" style="color: var(--dark-gray);">
|
||||
Asked ${q.created_at} · Answered ${q.answered_at}
|
||||
</p>
|
||||
</fieldset>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
} catch {
|
||||
list.textContent = "Failed to load questions.";
|
||||
list.style.color = "var(--light-red)";
|
||||
list.style.textAlign = "center";
|
||||
}
|
||||
}
|
||||
68
src/router.ts
Normal file
68
src/router.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
type PageHandler = (
|
||||
outlet: HTMLElement,
|
||||
params: Record<string, string>,
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface Route {
|
||||
pattern: RegExp;
|
||||
keys: string[];
|
||||
handler: PageHandler;
|
||||
}
|
||||
|
||||
const routes: Route[] = [];
|
||||
let notFoundHandler: PageHandler | null = null;
|
||||
|
||||
export function route(path: string, handler: PageHandler) {
|
||||
if (path === "*") {
|
||||
notFoundHandler = handler;
|
||||
return;
|
||||
}
|
||||
const keys: string[] = [];
|
||||
const pattern = path.replace(/:(\w+)/g, (_, key: string) => {
|
||||
keys.push(key);
|
||||
return "([^/]+)";
|
||||
});
|
||||
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler });
|
||||
}
|
||||
|
||||
export function navigate(path: string) {
|
||||
history.pushState(null, "", path);
|
||||
void render();
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const path = location.pathname;
|
||||
const outlet = document.getElementById("outlet")!;
|
||||
|
||||
for (const r of routes) {
|
||||
const match = path.match(r.pattern);
|
||||
if (match) {
|
||||
const params: Record<string, string> = {};
|
||||
r.keys.forEach((key, i) => {
|
||||
params[key] = match[i + 1]!;
|
||||
});
|
||||
outlet.innerHTML = "";
|
||||
await r.handler(outlet, params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
outlet.innerHTML = "";
|
||||
if (notFoundHandler) {
|
||||
await notFoundHandler(outlet, {});
|
||||
}
|
||||
}
|
||||
|
||||
export function initRouter() {
|
||||
window.addEventListener("popstate", () => void render());
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const anchor = (e.target as HTMLElement).closest("a");
|
||||
if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) {
|
||||
e.preventDefault();
|
||||
navigate(anchor.pathname);
|
||||
}
|
||||
});
|
||||
|
||||
void render();
|
||||
}
|
||||
|
|
@ -80,7 +80,73 @@
|
|||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--blue);
|
||||
background-color: var(--light-blue);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
/* Form inputs */
|
||||
.qa-textarea {
|
||||
width: 100%;
|
||||
background-color: var(--black);
|
||||
border: 2px solid var(--white);
|
||||
color: var(--light-gray);
|
||||
padding: 1ch;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.qa-button {
|
||||
border: none;
|
||||
padding: 0.25ch 1ch;
|
||||
color: var(--yellow);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-button:hover {
|
||||
background-color: var(--yellow);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
/* Project markdown content */
|
||||
.project-content h2 {
|
||||
color: var(--light-cyan);
|
||||
margin-top: 2ch;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content h3 {
|
||||
color: var(--light-green);
|
||||
margin-top: 1.5ch;
|
||||
margin-bottom: 0.5ch;
|
||||
}
|
||||
|
||||
.project-content p {
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content ul,
|
||||
.project-content ol {
|
||||
margin-left: 2ch;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content code {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.project-content pre {
|
||||
border: 2px solid var(--dark-gray);
|
||||
padding: 1ch;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1ch;
|
||||
}
|
||||
|
||||
.project-content a {
|
||||
color: var(--light-blue);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
"include": [
|
||||
"src",
|
||||
"vite.config.ts",
|
||||
"vite-plugin-ansi.ts"
|
||||
"vite-plugin-ansi.ts",
|
||||
"vite-plugin-markdown.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
|
|
|
|||
32
vite-plugin-markdown.ts
Normal file
32
vite-plugin-markdown.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import matter from "gray-matter";
|
||||
import { marked } from "marked";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
export default function markdownPlugin(): Plugin {
|
||||
const virtualModuleId = "virtual:projects";
|
||||
const resolvedId = "\0" + virtualModuleId;
|
||||
const projectsDir = path.resolve("src/content/projects");
|
||||
|
||||
return {
|
||||
name: "vite-plugin-markdown",
|
||||
resolveId(id) {
|
||||
if (id === virtualModuleId) return resolvedId;
|
||||
},
|
||||
load(id) {
|
||||
if (id !== resolvedId) return;
|
||||
|
||||
const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith(".md"));
|
||||
const projects = files.map((file) => {
|
||||
const raw = fs.readFileSync(path.join(projectsDir, file), "utf-8");
|
||||
const { data, content } = matter(raw);
|
||||
const html = marked(content);
|
||||
const slug = file.replace(".md", "");
|
||||
return { slug, ...data, html };
|
||||
});
|
||||
|
||||
return `export default ${JSON.stringify(projects)};`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4,10 +4,12 @@ import wasm from "vite-plugin-wasm";
|
|||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
import ansi from "./vite-plugin-ansi";
|
||||
import markdown from "./vite-plugin-markdown";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
ansi(),
|
||||
markdown(),
|
||||
tailwindcss(),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue