Compare commits

..

1 commit

Author SHA1 Message Date
Vercel
6fc13edfd4 Fix React Server Components CVE vulnerabilities
Updated dependencies to fix Next.js and React CVE vulnerabilities.

The fix-react2shell-next tool automatically updated the following packages to their secure versions:
- next
- react-server-dom-webpack
- react-server-dom-parcel  
- react-server-dom-turbopack

All package.json files have been scanned and vulnerable versions have been patched to the correct fixed versions based on the official React advisory.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-01-17 04:08:53 +00:00
76 changed files with 3258 additions and 9487 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# Since the ".env" file is gitignored, you can use the ".env.example" file to
# build a new ".env" file when you clone the repo. Keep this file up-to-date
# when you add new variables to `.env`.
# This file will be committed to version control, so make sure not to have any
# secrets in it. If you are cloning this repo, create a copy of this file named
# ".env" and populate it with your secrets.
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
# Prisma
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
DATABASE_URL="postgresql://postgres:password@localhost:5432/website"

35
.gitignore vendored
View file

@ -1,16 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules /node_modules
/.pnp
.pnp.js
# testing # testing
/coverage /coverage
# vite # database
/dist /prisma/db.sqlite
/prisma/db.sqlite-journal
db.sqlite
# next.js
/.next/
/out/
next-env.d.ts
# production
/build
# misc # misc
.DS_Store .DS_Store
*.pem *.pem
*.har
# debug # debug
npm-debug.log* npm-debug.log*
@ -18,10 +31,14 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files # local env files
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
.env .env
.env*.local .env*.local
# vercel
.vercel
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
@ -29,12 +46,4 @@ yarn-error.log*
.idea .idea
/.direnv /.direnv
result /generated
# rust
/api/target
# sqlite
*.db
*.db-wal
*.db-shm

119
README.md
View file

@ -1,94 +1,103 @@
# jetpham.com # jetpham.com
Personal site for Jet Pham. <div align="center">
<img src="src/app/icon0.svg" alt="jetpham.com icon" width="200" height="200">
</div>
The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a WebGL2 Conway's Game of Life background. Jet Pham's personal website. This website comes with a long story. The domain was originally registered in highschool by my teamate on my robotics team as a joke. The site was originally a filesystem full of memes and random files. Once I was in college, the domain expired and I registered it myself.
It also ships as a Nix flake with a reusable NixOS module for serving the static frontend and the Q+A API on a host. The site originally contained a blog. It was made in Next.js plainly with plain colors and no real style. I posted a few blogs about my life but eventually lost motivaiton and didn't like sharing it with other people after having been inspired by so many other cool websites.
## Features I started to become more obsessed with Rust and rewrote my website from being a blog into a static linktree site made in rust via WASM. It was in ASCII style using a modified fork of ratatui and had a fun implementation of Conways Game of Life in the background.
- ASCII/ANSI-inspired visual style with the IBM VGA font After leaving that website alone, I started to make more web based projects in Next.js. I realized I could properly make this website awesome and still keep the interesting style in the site while making it more performant, responsive, and accessible. This is the state that you see the website in now.
- Conway's Game of Life running in the background via WebGL2
- Q+A page backed by the site API
- Single-file oriented frontend build with Vite
- Fullscreen GPU blur/composite for the frosted panel effect
## Stack I have some awesome features packed in this site now that represent all the cool things I'm interested in:
- Vite - ANSI rendering of my name in CSS!
- TypeScript - Terminal style text, font, and colors just like BBS
- Tailwind CSS v4 - Rust WASM implementation of Conway's Game of Life running in the background
- WebGL2 - List of socials and contact info
- npm
Let me know if you have any feedback about the site!
## Tech Stack
- [Next.js 15](https://nextjs.org) with Turbo mode
- [Prisma](https://prisma.io) with PostgreSQL
- [Tailwind CSS v4](https://tailwindcss.com)
- [tRPC](https://trpc.io)
- [TypeScript](https://www.typescriptlang.org/)
- [React Query](https://tanstack.com/query)
- [React 19](https://react.dev/)
- Rust + WebAssembly (for Conway's Game of Life)
- [Bun](https://bun.sh) (package manager)
## Development ## Development
### Prerequisites ### Prerequisites
- Node.js + npm - Bun
- Docker (or Podman)
- Rust (for building the Conway's Game of Life WASM module)
- wasm-pack (install via `curl https://drager.github.io/wasm-pack/installer/init.sh -sSf | sh` or use the install script)
### Install ### Getting Started
1. Clone the repository
2. Build the Rust WASM module:
```bash ```bash
npm install bun run build:wasm
``` ```
### Start the dev server Or use the install script which will also install wasm-pack if needed:
```bash ```bash
npm run dev ./install.sh
``` ```
### Check the app 3. Install dependencies:
```bash ```bash
npm run check bun install
``` ```
### Build for production 4. Set up environment variables:
```bash ```bash
npm run build cp .env.example .env.local
# Edit .env.local with your configuration
``` ```
## Structure Adjust the database URL as needed for your setup.
```text 5. Start the database:
api/ Q+A backend
module.nix NixOS module ```bash
src/ frontend app ./start-database.sh
``` ```
## NixOS module This script will start a PostgreSQL database in a Docker or Podman container. Make sure Docker or Podman is installed and running.
Import the module from the flake and point it at the host-managed secret files you want to use. 6. Set up the database schema:
```nix ```bash
{ bun run db:push
inputs.website.url = "github:jetpham/website";
outputs = { self, nixpkgs, website, ... }: {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
website.nixosModules.default
({ config, ... }: {
services.jetpham-website = {
enable = true;
domain = "jetpham.com";
webhookSecretFile = config.age.secrets.webhook-secret.path;
};
})
];
};
};
}
``` ```
Optional Tor support is configured by setting `services.jetpham-website.tor.enable = true;` and providing the three onion key file paths. 7. Start the development server:
## Notes ```bash
bun run dev
```
- The homepage and Q+A page are the intended public pages. The site will be available at `http://localhost:3000`.
- The background renderer targets WebGL2 and falls back to the site shell background if WebGL2 is unavailable.
## Project Structure
```
src/ - Next.js app router pages
cgol/ - Rust WASM module for Conway's Game of Life
```

1787
api/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,26 +0,0 @@
[package]
name = "jetpham-qa-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
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"] }
[profile.dev]
debug = "line-tables-only"
split-debuginfo = "unpacked"
[profile.release]
codegen-units = 1
debug = 0
lto = "thin"
panic = "abort"
strip = "symbols"

View file

@ -1,106 +0,0 @@
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,
mail_domain: &str,
reply_domain: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let truncated = if question.len() > 50 {
format!("{}...", &question[..50])
} else {
question.to_string()
};
let from: Mailbox = format!("Q&A <qa@{mail_domain}>").parse()?;
let reply_to: Mailbox = format!("qa@{reply_domain}").parse()?;
let to: Mailbox = notify_email.parse()?;
let email = Message::builder()
.from(from)
.reply_to(reply_to)
.to(to)
.subject(format!("{id} - {truncated}"))
.body(question.to_string())?;
let mailer = SmtpTransport::builder_dangerous("localhost")
.tls(Tls::None)
.hello_name(lettre::transport::smtp::extension::ClientId::Domain(
mail_domain.to_string(),
))
.build();
mailer.send(&email)?;
Ok(())
}
pub fn extract_id_from_subject(subject: &str) -> Result<i64, Box<dyn std::error::Error>> {
let subject = subject.trim();
let start = subject
.char_indices()
.find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx))
.ok_or("Subject missing question id")?;
let digits: String = subject[start..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if digits.is_empty() {
return Err("Subject missing numeric question id".into());
}
let remainder = subject[start + digits.len()..].trim_start();
if !(remainder.starts_with('-') || remainder.starts_with(':')) {
return Err("Subject missing separator after question id".into());
}
Ok(digits.parse()?)
}
pub fn extract_plain_text_body(contents: &str) -> String {
let normalized = contents.replace("\r\n", "\n");
let body = if let Some((headers, body)) = normalized.split_once("\n\n") {
if headers.lines().any(|line| line.contains(':')) {
body
} else {
normalized.as_str()
}
} else {
normalized.as_str()
};
strip_quoted_text(body)
}
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()
}
#[cfg(test)]
mod tests {
use super::{extract_id_from_subject, extract_plain_text_body};
#[test]
fn extracts_id_from_subject() {
assert_eq!(extract_id_from_subject("Re: 42 - Hello").unwrap(), 42);
}
#[test]
fn extracts_plain_text_from_raw_email() {
let raw = "Subject: Q&A #42: Hello\r\nFrom: Jet <jet@example.com>\r\n\r\nThis is the answer.\r\n\r\nOn earlier mail wrote:\r\n> quoted";
assert_eq!(extract_plain_text_body(raw), "This is the answer.");
}
}

View file

@ -1,692 +0,0 @@
use std::sync::Arc;
use axum::extract::State;
use axum::http::header::CONTENT_TYPE;
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::email;
use crate::serve::AppState;
#[derive(Serialize)]
pub struct Question {
id: i64,
question: String,
answer: String,
created_at: String,
answered_at: String,
}
#[derive(Serialize)]
pub struct QuestionStats {
asked: i64,
answered: i64,
}
const SITE_URL: &str = "https://jetpham.com";
fn xml_escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn rss_pub_date(timestamp: &str) -> String {
DateTime::parse_from_rfc3339(timestamp)
.map(|dt| dt.to_rfc2822())
.unwrap_or_else(|_| Utc::now().to_rfc2822())
}
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))
}
pub async fn get_question_stats(
State(state): State<Arc<AppState>>,
) -> Result<Json<QuestionStats>, StatusCode> {
let db = state
.db
.lock()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let asked: i64 = db
.query_row("SELECT COUNT(*) FROM questions", [], |row| row.get(0))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let answered: i64 = db
.query_row(
"SELECT COUNT(*) FROM questions WHERE answer IS NOT NULL",
[],
|row| row.get(0),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(QuestionStats { asked, answered }))
}
pub async fn get_question_rss(
State(state): State<Arc<AppState>>,
) -> Result<impl IntoResponse, 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)?;
let items = questions
.into_iter()
.map(|question| {
let guid = format!("{SITE_URL}/qa#question-{}", question.id);
let description = format!(
"Question: {}\n\nAnswer: {}",
question.question, question.answer
);
format!(
"<item><title>{}</title><link>{}</link><guid>{}</guid><pubDate>{}</pubDate><description>{}</description></item>",
xml_escape(&question.question),
xml_escape(&guid),
xml_escape(&guid),
xml_escape(&rss_pub_date(&question.answered_at)),
xml_escape(&description),
)
})
.collect::<Vec<_>>()
.join("");
let xml = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel><title>Jet Pham Q+A</title><link>{SITE_URL}/qa</link><description>Answered questions from Jet Pham&apos;s site</description><language>en-us</language>{items}</channel></rss>"
);
Ok(([(CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
}
#[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 mail_domain = state.mail_domain.clone();
let qa_reply_domain = state.qa_reply_domain.clone();
let question_text = body.question.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = email::send_notification(
id,
&question_text,
&notify_email,
&mail_domain,
&qa_reply_domain,
) {
eprintln!("Failed to send notification: {e}");
}
});
Ok(StatusCode::CREATED)
}
// --- MTA Hook webhook types ---
#[derive(Deserialize)]
pub struct MtaHookPayload {
#[serde(default)]
pub messages: Vec<MtaHookMessage>,
#[serde(default)]
pub envelope: Envelope,
#[serde(default)]
pub message: MtaHookBody,
}
#[derive(Deserialize)]
pub struct MtaHookMessage {
#[serde(default)]
pub envelope: Envelope,
#[serde(default)]
pub message: MtaHookBody,
#[serde(default)]
pub contents: String,
}
#[derive(Deserialize, Default)]
pub struct Envelope {
#[serde(default)]
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 subject: Option<String>,
#[serde(default)]
pub headers: MessageHeaders,
#[serde(default)]
pub contents: String,
}
#[derive(Deserialize, Default)]
pub struct MessageHeaders {
#[serde(default)]
pub subject: Option<String>,
}
#[derive(Serialize)]
pub struct MtaHookResponse {
pub action: &'static str,
}
fn webhook_secret_matches(headers: &HeaderMap, expected_secret: &str) -> bool {
let expected_secret = expected_secret.trim();
let header_secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.trim();
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.trim() == expected_secret
}
fn webhook_secret_debug(headers: &HeaderMap) -> String {
let header_secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let auth = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let decoded = auth
.strip_prefix("Basic ")
.and_then(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.unwrap_or_default();
format!(
"x-webhook-secret={header_secret:?}; authorization={auth:?}; basic-decoded={decoded:?}"
)
}
fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> 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,
expected_domain,
message.message.subject.as_deref().or(message.message.headers.subject.as_deref()),
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,
expected_domain,
payload
.message
.subject
.as_deref()
.or(payload.message.headers.subject.as_deref()),
&payload.message.contents,
)
}
fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
Some(current)
}
fn string_from_value(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Object(map) => map
.get("address")
.or_else(|| map.get("email"))
.or_else(|| map.get("value"))
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
_ => None,
}
}
fn recipients_from_value(value: Option<&Value>) -> Vec<Recipient> {
let Some(value) = value else {
return Vec::new();
};
match value {
Value::Array(values) => values
.iter()
.filter_map(|v| string_from_value(v).map(Recipient::Address))
.collect(),
_ => string_from_value(value)
.map(Recipient::Address)
.into_iter()
.collect(),
}
}
fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option<String> {
paths.iter().find_map(|path| {
value_at_path(value, path)
.and_then(|v| v.as_str())
.map(ToOwned::to_owned)
})
}
fn subject_from_headers_value(value: Option<&Value>) -> Option<String> {
let headers = value?.as_array()?;
headers.iter().find_map(|header| {
let parts = header.as_array()?;
let name = parts.first()?.as_str()?.trim();
if !name.eq_ignore_ascii_case("Subject") {
return None;
}
parts
.get(1)?
.as_str()
.map(|s| s.trim().to_string())
})
}
fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> {
if let Some(messages) = payload.get("messages").and_then(Value::as_array) {
for message in messages {
if let Some(reply) = extract_qa_reply_from_message(
&recipients_from_value(value_at_path(message, &["envelope", "to"])),
expected_domain,
string_at_paths(
message,
&[
&["message", "subject"],
&["message", "headers", "subject"],
&["headers", "subject"],
],
)
.or_else(|| subject_from_headers_value(value_at_path(message, &["message", "headers"])))
.as_deref(),
&string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
) {
return Some(reply);
}
}
}
extract_qa_reply_from_message(
&recipients_from_value(value_at_path(payload, &["envelope", "to"])),
expected_domain,
string_at_paths(
payload,
&[
&["message", "subject"],
&["message", "headers", "subject"],
&["headers", "subject"],
],
)
.or_else(|| subject_from_headers_value(value_at_path(payload, &["message", "headers"])))
.as_deref(),
&string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]])
.unwrap_or_default(),
)
}
fn extract_qa_reply_from_message(
recipients: &[Recipient],
expected_domain: &str,
subject: Option<&str>,
contents: &str,
) -> Option<(i64, String)> {
let _qa_recipient = recipients.iter().find(|recipient| {
let address = recipient.address();
let Some((local, domain)) = address.rsplit_once('@') else {
return false;
};
local.eq_ignore_ascii_case("qa") && domain.eq_ignore_ascii_case(expected_domain)
})?;
let subject = subject.map(ToOwned::to_owned).or_else(|| {
contents
.replace("\r\n", "\n")
.lines()
.find_map(|line| line.strip_prefix("Subject: ").map(ToOwned::to_owned))
})?;
let id = email::extract_id_from_subject(&subject).ok()?;
let body = email::extract_plain_text_body(contents);
if body.is_empty() {
return None;
}
Some((id, body))
}
pub async fn webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: String,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
if !webhook_secret_matches(&headers, &state.webhook_secret) {
eprintln!(
"Rejected webhook: invalid secret; expected_len={}; {}",
state.webhook_secret.len(),
webhook_secret_debug(&headers)
);
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
}
let payload_value: Value = match serde_json::from_str(&body) {
Ok(payload) => payload,
Err(err) => {
eprintln!("Rejected webhook: invalid JSON payload: {err}; body={body}");
return Ok(Json(MtaHookResponse { action: "accept" }));
}
};
let parsed_reply = serde_json::from_value::<MtaHookPayload>(payload_value.clone())
.ok()
.and_then(|payload| extract_qa_reply(&payload, &state.qa_reply_domain))
.or_else(|| extract_qa_reply_from_value(&payload_value, &state.qa_reply_domain));
if let Some((id, body)) = parsed_reply {
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()))?;
eprintln!("Stored Q&A reply for question #{id}");
return Ok(Json(MtaHookResponse { action: "discard" }));
}
eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}");
Ok(Json(MtaHookResponse { action: "accept" }))
}
#[cfg(test)]
mod tests {
use axum::http::HeaderMap;
use serde_json::Value;
use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload};
#[test]
fn extracts_reply_from_current_stalwart_payload() {
let payload: MtaHookPayload = serde_json::from_str(
r#"{
"envelope": {
"to": [
{
"address": "qa@extremist.software"
}
]
},
"message": {
"subject": "Re: 42 - hello",
"contents": "This is the answer.\n\nOn earlier mail wrote:\n> quoted"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply(&payload, "extremist.software"),
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@extremist.software"]
},
"message": {
"subject": "Re: 7 - legacy"
},
"contents": "Legacy answer"
}
]
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply(&payload, "extremist.software"),
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"));
}
#[test]
fn extracts_reply_from_value_with_string_recipient() {
let payload: Value = serde_json::from_str(
r#"{
"envelope": {
"to": "qa@extremist.software"
},
"message": {
"headers": {
"subject": "Re: 9 - hi"
},
"contents": "Answer body"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply_from_value(&payload, "extremist.software"),
Some((9, "Answer body".to_string()))
);
}
#[test]
fn extracts_reply_from_value_with_header_pairs() {
let payload: Value = serde_json::from_str(
r#"{
"envelope": {
"to": [{"address":"qa@extremist.software"}]
},
"message": {
"headers": [
["From", " jet@extremist.software\r\n"],
["Subject", " Re: 11 - hi\r\n"]
],
"contents": "Answer from header pairs"
}
}"#,
)
.unwrap();
assert_eq!(
extract_qa_reply_from_value(&payload, "extremist.software"),
Some((11, "Answer from header pairs".to_string()))
);
}
}

View file

@ -1,9 +0,0 @@
mod email;
mod handlers;
mod rate_limit;
mod serve;
#[tokio::main]
async fn main() {
serve::run().await.expect("server error");
}

View file

@ -1,35 +0,0 @@
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
}
}

View file

@ -1,69 +0,0 @@
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 mail_domain: String,
pub qa_reply_domain: 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 mail_domain =
std::env::var("QA_MAIL_DOMAIN").unwrap_or_else(|_| "extremist.software".to_string());
let qa_reply_domain =
std::env::var("QA_REPLY_DOMAIN").unwrap_or_else(|_| mail_domain.clone());
let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
let listen_address =
std::env::var("QA_LISTEN_ADDRESS").unwrap_or_else(|_| "127.0.0.1".to_string());
let listen_port = std::env::var("QA_LISTEN_PORT").unwrap_or_else(|_| "3003".to_string());
let listen_target = format!("{listen_address}:{listen_port}");
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,
mail_domain,
qa_reply_domain,
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/questions/stats", get(handlers::get_question_stats))
.route("/qa/rss.xml", get(handlers::get_question_rss))
.route("/api/webhook", post(handlers::webhook))
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind(&listen_target).await?;
println!("Listening on {listen_target}");
axum::serve(listener, app).await?;
Ok(())
}

1178
bun.lock Normal file

File diff suppressed because it is too large Load diff

2
cgol/.cargo/config.toml Normal file
View file

@ -0,0 +1,2 @@
[build]
target = "wasm32-unknown-unknown"

7
cgol/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/target
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log
/pkg

3
cgol/.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"rust-analyzer.cargo.target": "wasm32-unknown-unknown",
}

45
cgol/Cargo.toml Normal file
View file

@ -0,0 +1,45 @@
[package]
name = "cgol"
version = "0.1.0"
authors = ["jet"]
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["console_error_panic_hook"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"console",
"CanvasRenderingContext2d",
"Document",
"HtmlCanvasElement",
"Window",
"MouseEvent",
"Element",
"EventTarget",
"Performance",
"DomRect",
"TouchEvent",
"Touch",
"TouchList",
"ImageData",
] }
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1", optional = true }
[dev-dependencies]
wasm-bindgen-test = "0.3"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1

84
cgol/README.md Normal file
View file

@ -0,0 +1,84 @@
<div align="center">
<h1><code>wasm-pack-template</code></h1>
<strong>A template for kick starting a Rust and WebAssembly project using <a href="https://github.com/rustwasm/wasm-pack">wasm-pack</a>.</strong>
<p>
<a href="https://travis-ci.org/rustwasm/wasm-pack-template"><img src="https://img.shields.io/travis/rustwasm/wasm-pack-template.svg?style=flat-square" alt="Build Status" /></a>
</p>
<h3>
<a href="https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html">Tutorial</a>
<span> | </span>
<a href="https://discordapp.com/channels/442252698964721669/443151097398296587">Chat</a>
</h3>
<sub>Built with 🦀🕸 by <a href="https://rustwasm.github.io/">The Rust and WebAssembly Working Group</a></sub>
</div>
## About
[**📚 Read this template tutorial! 📚**][template-docs]
This template is designed for compiling Rust libraries into WebAssembly and
publishing the resulting package to NPM.
Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other
templates and usages of `wasm-pack`.
[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html
[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html
## 🚴 Usage
### 🐑 Use `cargo generate` to Clone this Template
[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate)
```
cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project
cd my-project
```
### 🛠️ Build with `wasm-pack build`
```
wasm-pack build
```
### 🔬 Test in Headless Browsers with `wasm-pack test`
```
wasm-pack test --headless --firefox
```
### 🎁 Publish to NPM with `wasm-pack publish`
```
wasm-pack publish
```
## 🔋 Batteries Included
* [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating
between WebAssembly and JavaScript.
* [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook)
for logging panic messages to the developer console.
* `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you
## License
Licensed under either of
* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
at your option.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual licensed as above, without any additional terms or
conditions.

463
cgol/src/lib.rs Normal file
View file

@ -0,0 +1,463 @@
mod utils;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::Clamped;
use wasm_bindgen::JsCast;
use js_sys::Math;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window};
const CELL_SIZE: u32 = 20;
const SIMULATION_FPS: f64 = 60.0;
const SIMULATION_FRAME_MS: f64 = 1000.0 / SIMULATION_FPS;
const HUE_ROTATION_PERIOD_MS: f64 = 3000.0;
fn random_hue() -> u8 {
(Math::random() * 256.0) as u8
}
fn mix_colors(hues: &[u8]) -> u8 {
if hues.is_empty() {
return 0;
}
let (sum_sin, sum_cos) = hues
.iter()
.map(|&hue| (hue as f64 * 360.0 / 255.0).to_radians())
.fold((0.0, 0.0), |acc, h_rad| {
(acc.0 + h_rad.sin(), acc.1 + h_rad.cos())
});
let avg_hue_degrees = sum_sin.atan2(sum_cos).to_degrees().rem_euclid(360.0);
(avg_hue_degrees * 255.0 / 360.0) as u8
}
/// Convert HSL hue (0-255) to RGB
/// Using full saturation (100%) and lightness (50%)
fn hue_to_rgb(hue: u8) -> (u8, u8, u8) {
let h = hue as f64 / 255.0 * 6.0;
let x = 1.0 - (h % 2.0 - 1.0).abs();
let (r, g, b) = match h as u32 {
0 => (1.0, x, 0.0),
1 => (x, 1.0, 0.0),
2 => (0.0, 1.0, x),
3 => (0.0, x, 1.0),
4 => (x, 0.0, 1.0),
_ => (1.0, 0.0, x),
};
((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
}
#[derive(Clone, Copy, PartialEq)]
enum Cell {
Dead,
Alive { hue: u8 },
}
struct Universe {
width: u32,
height: u32,
cells: Vec<Cell>,
next_cells: Vec<Cell>,
neighbor_hues_buffer: Vec<u8>,
}
impl Universe {
fn new(width: u32, height: u32) -> Self {
let size = (width * height) as usize;
let mut u = Self {
width,
height,
cells: vec![Cell::Dead; size],
next_cells: vec![Cell::Dead; size],
neighbor_hues_buffer: Vec::with_capacity(8),
};
u.randomize();
u
}
fn randomize(&mut self) {
for c in &mut self.cells {
*c = if Math::random() < 0.5 {
Cell::Alive { hue: random_hue() }
} else {
Cell::Dead
};
}
}
#[inline(always)]
fn index(&self, row: u32, col: u32) -> usize {
(row * self.width + col) as usize
}
fn count_neighbors_and_get_hues(&mut self, row: u32, col: u32) -> u8 {
self.neighbor_hues_buffer.clear();
let north = if row == 0 { self.height - 1 } else { row - 1 };
let south = if row == self.height - 1 { 0 } else { row + 1 };
let west = if col == 0 { self.width - 1 } else { col - 1 };
let east = if col == self.width - 1 { 0 } else { col + 1 };
let neighbors = [
(north, west),
(north, col),
(north, east),
(row, west),
(row, east),
(south, west),
(south, col),
(south, east),
];
for (nr, nc) in neighbors {
let idx = self.index(nr, nc);
if let Cell::Alive { hue } = self.cells[idx] {
self.neighbor_hues_buffer.push(hue);
}
}
self.neighbor_hues_buffer.len() as u8
}
fn tick(&mut self) {
for row in 0..self.height {
for col in 0..self.width {
let idx = self.index(row, col);
let cell = self.cells[idx];
let neighbor_count = self.count_neighbors_and_get_hues(row, col);
self.next_cells[idx] = match (cell, neighbor_count) {
(Cell::Alive { .. }, x) if x < 2 => Cell::Dead,
(Cell::Alive { hue }, 2) | (Cell::Alive { hue }, 3) => Cell::Alive { hue },
(Cell::Alive { .. }, x) if x > 3 => Cell::Dead,
(Cell::Dead, 3) => {
let mixed_hue = mix_colors(&self.neighbor_hues_buffer);
Cell::Alive { hue: mixed_hue }
}
(otherwise, _) => otherwise,
};
}
}
std::mem::swap(&mut self.cells, &mut self.next_cells);
}
fn set_alive_block(&mut self, center_row: i32, center_col: i32, half: i32, hue: u8) {
let h = self.height as i32;
let w = self.width as i32;
for dr in -half..=half {
for dc in -half..=half {
let r = (center_row + dr).rem_euclid(h) as u32;
let c = (center_col + dc).rem_euclid(w) as u32;
let idx = self.index(r, c);
self.cells[idx] = Cell::Alive { hue };
}
}
}
}
struct AppState {
window: Window,
canvas: HtmlCanvasElement,
ctx: CanvasRenderingContext2d,
universe: Universe,
cursor_row: i32,
cursor_col: i32,
last_frame_time: f64,
cursor_active: bool,
// Reusable pixel buffer for ImageData (avoids allocation each frame)
pixel_buffer: Vec<u8>,
canvas_width: u32,
canvas_height: u32,
}
impl AppState {
fn new() -> Result<Self, JsValue> {
utils::set_panic_hook();
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
let document = window
.document()
.ok_or_else(|| JsValue::from_str("no document"))?;
let canvas = document
.get_element_by_id("canvas")
.and_then(|e| e.dyn_into::<HtmlCanvasElement>().ok())
.ok_or_else(|| JsValue::from_str("canvas element not found"))?;
let ctx = canvas
.get_context("2d")?
.ok_or_else(|| JsValue::from_str("no 2d context"))?
.dyn_into::<CanvasRenderingContext2d>()?;
// Disable image smoothing for sharp pixel scaling
ctx.set_image_smoothing_enabled(false);
let mut state = Self {
window,
canvas,
ctx,
universe: Universe::new(1, 1),
cursor_row: 0,
cursor_col: 0,
last_frame_time: 0.0,
cursor_active: false,
pixel_buffer: Vec::new(),
canvas_width: 0,
canvas_height: 0,
};
state.resize_canvas_and_universe();
Ok(state)
}
fn get_current_time(&self) -> f64 {
self.window.performance().unwrap().now()
}
fn resize_canvas_and_universe(&mut self) {
let width = self.window.inner_width().unwrap().as_f64().unwrap();
let height = self.window.inner_height().unwrap().as_f64().unwrap();
let dpr = self.window.device_pixel_ratio();
let css_w = width;
let css_h = height;
let element = self.canvas.dyn_ref::<web_sys::Element>().unwrap();
element
.set_attribute(
"style",
&format!(
"position:fixed;inset:0;width:{}px;height:{}px;image-rendering:pixelated",
css_w, css_h
),
)
.ok();
self.canvas_width = (css_w * dpr) as u32;
self.canvas_height = (css_h * dpr) as u32;
self.canvas.set_width(self.canvas_width);
self.canvas.set_height(self.canvas_height);
// Disable image smoothing after resize
self.ctx.set_image_smoothing_enabled(false);
// Clear canvas
self.ctx.set_fill_style_str("black");
self.ctx.fill_rect(0.0, 0.0, self.canvas_width as f64, self.canvas_height as f64);
let cols = (self.canvas_width / CELL_SIZE).max(1);
let rows = (self.canvas_height / CELL_SIZE).max(1);
self.universe = Universe::new(cols, rows);
// Allocate pixel buffer for the universe size (1 pixel per cell)
// We'll draw at universe resolution and let CSS scale it up
let buffer_size = (cols * rows * 4) as usize;
self.pixel_buffer = vec![0u8; buffer_size];
}
fn draw_scaled(&mut self) {
let grid_width = self.universe.width;
let grid_height = self.universe.height;
let cell_w = CELL_SIZE;
let cell_h = CELL_SIZE;
// Fill pixel buffer at full canvas resolution
let canvas_w = self.canvas_width as usize;
let canvas_h = self.canvas_height as usize;
// Resize buffer if needed
let needed_size = canvas_w * canvas_h * 4;
if self.pixel_buffer.len() != needed_size {
self.pixel_buffer.resize(needed_size, 0);
}
// Fill with black first (for dead cells)
for chunk in self.pixel_buffer.chunks_exact_mut(4) {
chunk[0] = 0;
chunk[1] = 0;
chunk[2] = 0;
chunk[3] = 255;
}
// Draw each cell as a CELL_SIZE × CELL_SIZE block
for row in 0..grid_height {
for col in 0..grid_width {
let cell_idx = self.universe.index(row, col);
if let Cell::Alive { hue } = self.universe.cells[cell_idx] {
let (r, g, b) = hue_to_rgb(hue);
let start_x = (col * cell_w) as usize;
let start_y = (row * cell_h) as usize;
for py in 0..cell_h as usize {
let y = start_y + py;
if y >= canvas_h {
break;
}
for px in 0..cell_w as usize {
let x = start_x + px;
if x >= canvas_w {
break;
}
let pixel_idx = (y * canvas_w + x) * 4;
self.pixel_buffer[pixel_idx] = r;
self.pixel_buffer[pixel_idx + 1] = g;
self.pixel_buffer[pixel_idx + 2] = b;
self.pixel_buffer[pixel_idx + 3] = 255;
}
}
}
}
}
// Single putImageData call with full canvas
if let Ok(image_data) = ImageData::new_with_u8_clamped_array_and_sh(
Clamped(&self.pixel_buffer),
self.canvas_width,
self.canvas_height,
) {
self.ctx.put_image_data(&image_data, 0.0, 0.0).ok();
}
}
}
#[wasm_bindgen]
pub fn start() -> Result<(), JsValue> {
let state = AppState::new()?;
let state_rc = Rc::new(RefCell::new(state));
let canvas = state_rc.borrow().canvas.clone();
let window = state_rc.borrow().window.clone();
let document = window.document().unwrap();
// Mouse move handler
let state_for_mouse = state_rc.clone();
let canvas_for_mouse = canvas.clone();
let window_for_mouse = window.clone();
let mouse_closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
let rect = canvas_for_mouse.get_bounding_client_rect();
let dpr = window_for_mouse.device_pixel_ratio();
let x = (event.client_x() as f64 - rect.left()) * dpr;
let y = (event.client_y() as f64 - rect.top()) * dpr;
let mut s = state_for_mouse.borrow_mut();
s.cursor_col = (x / CELL_SIZE as f64) as i32;
s.cursor_row = (y / CELL_SIZE as f64) as i32;
s.cursor_active = true;
}) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("mousemove", mouse_closure.as_ref().unchecked_ref())?;
mouse_closure.forget();
// Touch move handler
let state_for_touch_move = state_rc.clone();
let canvas_for_touch_move = canvas.clone();
let window_for_touch_move = window.clone();
let touch_move_closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
event.prevent_default();
if let Some(touch) = event.touches().get(0) {
let rect = canvas_for_touch_move.get_bounding_client_rect();
let dpr = window_for_touch_move.device_pixel_ratio();
let x = (touch.client_x() as f64 - rect.left()) * dpr;
let y = (touch.client_y() as f64 - rect.top()) * dpr;
let mut s = state_for_touch_move.borrow_mut();
s.cursor_col = (x / CELL_SIZE as f64) as i32;
s.cursor_row = (y / CELL_SIZE as f64) as i32;
s.cursor_active = true;
}
}) as Box<dyn FnMut(_)>);
document
.add_event_listener_with_callback("touchmove", touch_move_closure.as_ref().unchecked_ref())?;
touch_move_closure.forget();
// Touch start handler
let state_for_touch_start = state_rc.clone();
let canvas_for_touch_start = canvas.clone();
let window_for_touch_start = window.clone();
let touch_start_closure = Closure::wrap(Box::new(move |event: web_sys::TouchEvent| {
event.prevent_default();
if let Some(touch) = event.touches().get(0) {
let rect = canvas_for_touch_start.get_bounding_client_rect();
let dpr = window_for_touch_start.device_pixel_ratio();
let x = (touch.client_x() as f64 - rect.left()) * dpr;
let y = (touch.client_y() as f64 - rect.top()) * dpr;
let mut s = state_for_touch_start.borrow_mut();
s.cursor_col = (x / CELL_SIZE as f64) as i32;
s.cursor_row = (y / CELL_SIZE as f64) as i32;
s.cursor_active = true;
}
}) as Box<dyn FnMut(_)>);
document
.add_event_listener_with_callback("touchstart", touch_start_closure.as_ref().unchecked_ref())?;
touch_start_closure.forget();
// Touch end handler
let state_for_touch_end = state_rc.clone();
let touch_end_closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| {
let mut s = state_for_touch_end.borrow_mut();
s.cursor_active = false;
}) as Box<dyn FnMut(_)>);
document
.add_event_listener_with_callback("touchend", touch_end_closure.as_ref().unchecked_ref())?;
touch_end_closure.forget();
// Resize handler
let state_for_resize = state_rc.clone();
let resize_closure = Closure::wrap(Box::new(move || {
let mut s = state_for_resize.borrow_mut();
s.resize_canvas_and_universe();
}) as Box<dyn FnMut()>);
window.add_event_listener_with_callback("resize", resize_closure.as_ref().unchecked_ref())?;
resize_closure.forget();
// Animation loop
let f = Rc::new(RefCell::new(None::<Closure<dyn FnMut()>>));
let g = f.clone();
let state_for_anim = state_rc.clone();
let window_for_anim = window.clone();
*g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
let current_time = state_for_anim.borrow().get_current_time();
{
let mut s = state_for_anim.borrow_mut();
// Run simulation FIRST (throttled to 60 FPS max)
// This way cursor-placed cells won't be immediately killed
if current_time - s.last_frame_time >= SIMULATION_FRAME_MS {
s.last_frame_time = current_time;
s.universe.tick();
}
// Process cursor input AFTER tick for responsiveness
// Cells placed here survive until the next tick
if s.cursor_active {
let cursor_row = s.cursor_row;
let cursor_col = s.cursor_col;
let hue = ((current_time % HUE_ROTATION_PERIOD_MS) / HUE_ROTATION_PERIOD_MS * 256.0) as u8;
s.universe.set_alive_block(cursor_row, cursor_col, 2, hue);
}
// Draw every frame
s.draw_scaled();
}
window_for_anim
.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref())
.ok();
}) as Box<dyn FnMut()>));
window.request_animation_frame(g.borrow().as_ref().unwrap().as_ref().unchecked_ref())?;
Ok(())
}

10
cgol/src/utils.rs Normal file
View file

@ -0,0 +1,10 @@
pub fn set_panic_hook() {
// When the `console_error_panic_hook` feature is enabled, we can call the
// `set_panic_hook` function at least once during initialization, and then
// we will get better error messages if our code ever panics.
//
// For more details see
// https://github.com/rustwasm/console_error_panic_hook#readme
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}

View file

@ -1,15 +1,21 @@
import tseslint from "typescript-eslint"; import { FlatCompat } from "@eslint/eslintrc";
import tseslint from 'typescript-eslint';
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
});
export default tseslint.config( export default tseslint.config(
{ {
ignores: ["dist"], ignores: ['.next']
}, },
...compat.extends("next/core-web-vitals"),
{ {
files: ["**/*.ts"], files: ['**/*.ts', '**/*.tsx'],
extends: [ extends: [
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked, ...tseslint.configs.stylisticTypeChecked
], ],
rules: { rules: {
"@typescript-eslint/array-type": "off", "@typescript-eslint/array-type": "off",
@ -18,10 +24,7 @@ export default tseslint.config(
"warn", "warn",
{ prefer: "type-imports", fixStyle: "inline-type-imports" }, { prefer: "type-imports", fixStyle: "inline-type-imports" },
], ],
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
"warn",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/require-await": "off", "@typescript-eslint/require-await": "off",
"@typescript-eslint/no-misused-promises": [ "@typescript-eslint/no-misused-promises": [
"error", "error",
@ -31,12 +34,12 @@ export default tseslint.config(
}, },
{ {
linterOptions: { linterOptions: {
reportUnusedDisableDirectives: true, reportUnusedDisableDirectives: true
}, },
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
projectService: true, projectService: true
}, }
}, }
}, }
); )

43
flake.lock generated
View file

@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1774386573, "lastModified": 1763283776,
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=", "narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9", "rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -34,10 +34,45 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1763433504,
"narHash": "sha256-cVid5UNpk88sPYHkLAA5aZEHOFQXSB/2L1vl18Aq7IM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "42ce16c6d8318a654d53f047c9400b7d902d6e61",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
} }
}, },
"systems": { "systems": {

105
flake.nix
View file

@ -1,78 +1,63 @@
{ {
description = "Jet Pham's personal website"; description = "CTF Jet development environment (Bun)";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs =
{ outputs = { self, nixpkgs, rust-overlay, flake-utils }:
self, flake-utils.lib.eachDefaultSystem (system:
nixpkgs,
flake-utils,
}:
(flake-utils.lib.eachDefaultSystem (
system:
let let
pkgs = import nixpkgs { inherit system; }; overlays = [ (import rust-overlay) ];
lib = pkgs.lib; pkgs = import nixpkgs {
websiteSrc = lib.fileset.toSource { inherit system overlays;
root = ./.;
fileset = lib.fileset.unions [
./index.html
./package-lock.json
./package.json
./public
./src
./tsconfig.json
./vite-plugin-ansi.ts
./vite.config.ts
];
}; };
website = pkgs.buildNpmPackage { bun = pkgs.bun;
pname = "jet-website";
version = "0.1.0";
src = websiteSrc;
npmDepsHash = "sha256-UDz4tXNvEa8uiDDGg16K9JbNeQZR3BsVNKtuOgcyurQ=";
installPhase = '' # Prisma engines for NixOS
runHook preInstall prismaEngines = pkgs.prisma-engines;
mkdir -p $out
cp -r dist/* $out/
runHook postInstall
'';
};
qa-api = pkgs.rustPlatform.buildRustPackage {
pname = "jetpham-qa-api";
version = "0.1.0";
src = ./api;
cargoHash = "sha256-mAQUSA5S3uCCo4RbWVgzMMPDjVoSTcJCFdFOx9ZaxQo=";
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl ];
};
in devTools = with pkgs; [
{
packages = {
default = website;
inherit qa-api;
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
git git
postgresql
curl curl
openssl wget
typescript-language-server typescript-language-server
rust-analyzer
rustc
cargo
pkg-config pkg-config
wasm-pack
binaryen
(rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
extensions = [ "rust-src" ];
targets = [ "wasm32-unknown-unknown" ];
}))
]; ];
in {
devShells.default = pkgs.mkShell {
buildInputs = [
bun
prismaEngines
] ++ devTools;
NIXPKGS_ALLOW_UNFREE = "1";
PRISMA_QUERY_ENGINE_BINARY = "${prismaEngines}/bin/query-engine";
PRISMA_SCHEMA_ENGINE_BINARY = "${prismaEngines}/bin/schema-engine";
PRISMA_INTROSPECTION_ENGINE_BINARY = "${prismaEngines}/bin/introspection-engine";
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING = "1";
};
packages = {
inherit bun prismaEngines;
default = pkgs.symlinkJoin {
name = "ctfjet-dev-bun";
paths = [ bun prismaEngines ];
};
}; };
} }
)) );
// {
nixosModules.default = import ./module.nix self;
};
} }

View file

@ -1,137 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-title" content="Jet Pham" />
<title>Jet Pham - Software Extremist</title>
<meta
name="description"
content="Jet Pham's personal website. Software extremist."
/>
<link rel="canonical" href="https://jetpham.com/" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-icon.png" />
<link
rel="preload"
href="/Web437_IBM_VGA_8x16.woff"
as="font"
type="font/woff"
crossorigin
/>
<meta property="og:title" content="Jet Pham - Software Extremist" />
<meta
property="og:description"
content="Jet Pham's personal website. Software extremist."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://jetpham.com/" />
<meta property="og:site_name" content="Jet Pham" />
<meta
property="og:image"
content="https://jetpham.com/web-app-manifest-512x512.png"
/>
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Jet Pham" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Jet Pham - Software Extremist" />
<meta
name="twitter:description"
content="Jet Pham's personal website. Software extremist."
/>
<meta
name="twitter:image"
content="https://jetpham.com/web-app-manifest-512x512.png"
/>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Jet Pham",
"givenName": "Jet",
"familyName": "Pham",
"description": "Software extremist.",
"url": "https://jetpham.com",
"jobTitle": "Software Extremist",
"hasOccupation": {
"@type": "Occupation",
"name": "Hacker"
},
"email": "jet@extremist.software",
"image": "https://jetpham.com/jet.svg",
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "University of San Francisco",
"url": "https://www.usfca.edu"
},
"hasCredential": {
"@type": "EducationalOccupationalCredential",
"credentialCategory": "degree",
"name": "Bachelor of Science in Computer Science"
},
"homeLocation": {
"@type": "City",
"name": "San Francisco, CA"
},
"workLocation": {
"@type": "City",
"name": "San Francisco, CA"
},
"memberOf": {
"@type": "Organization",
"name": "Noisebridge",
"url": "https://www.noisebridge.net"
},
"affiliation": {
"@type": "Organization",
"name": "Noisebridge",
"url": "https://www.noisebridge.net"
},
"sameAs": [
"https://github.com/jetpham",
"https://x.com/exmistsoftware",
"https://bsky.app/profile/extremist.software",
"https://git.extremist.software"
]
}
</script>
</head>
<body style="background: #000">
<a class="skip-link" href="#outlet">Skip to content</a>
<canvas
id="canvas"
class="pointer-events-none fixed inset-0 z-0 h-screen w-screen"
aria-hidden="true"
></canvas>
<div class="page-frame relative z-10">
<nav aria-label="Main navigation" class="site-region">
<div class="site-shell site-panel-frame px-[2ch] py-[1ch]">
<div class="site-panel-frost" aria-hidden="true"></div>
<div class="site-panel-border" aria-hidden="true"></div>
<div
class="site-panel-content site-nav-links flex justify-center gap-[2ch]"
>
<a href="/" data-nav-link class="site-nav-link"
><span class="site-nav-marker" aria-hidden="true">&gt;</span
><span>Home</span
><span class="site-nav-marker" aria-hidden="true">&lt;</span></a
>
<a href="/qa" data-nav-link class="site-nav-link"
><span class="site-nav-marker" aria-hidden="true">&gt;</span
><span>Q&amp;A</span
><span class="site-nav-marker" aria-hidden="true">&lt;</span></a
>
</div>
</div>
</nav>
<main id="outlet" class="site-region" tabindex="-1"></main>
<footer class="site-region site-footer">
<div id="site-footer" class="site-shell"></div>
</footer>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

36
install.sh Executable file
View file

@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
# fix home issues
export HOME=/root
# Install Rustup
if ! command -v rustup
then
echo "Installing Rustup..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -t wasm32-unknown-unknown --profile minimal
source "$HOME/.cargo/env"
else
echo "Rustup already installed."
fi
# Install wasm-pack
if ! command -v wasm-pack
then
echo "Installing wasm-pack..."
curl https://drager.github.io/wasm-pack/installer/init.sh -sSf | sh
echo "wasm-pack installation complete."
else
echo "wasm-pack already installed."
fi
# Build cgol WASM package
echo "Building cgol WASM package..."
cd cgol
wasm-pack build --release --target web
cd ..
# Install Next.js dependencies with bun
echo "Installing Next.js dependencies with bun..."
bun install

View file

@ -1,292 +0,0 @@
self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.jetpham-website;
package = cfg.package;
qaApi = cfg.apiPackage;
apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}";
usingDefaultWebhookSecret = cfg.webhookSecretFile == null;
webhookSecretPath =
if usingDefaultWebhookSecret then config.age.secrets.webhook-secret.path else cfg.webhookSecretFile;
usingDefaultTorSecretKey = cfg.tor.onionSecretKeyFile == null;
usingDefaultTorPublicKey = cfg.tor.onionPublicKeyFile == null;
usingDefaultTorHostname = cfg.tor.onionHostnameFile == null;
torOnionSecretKeyPath =
if usingDefaultTorSecretKey then
config.age.secrets.tor-onion-secret-key.path
else
cfg.tor.onionSecretKeyFile;
torOnionPublicKeyPath =
if usingDefaultTorPublicKey then
config.age.secrets.tor-onion-public-key.path
else
cfg.tor.onionPublicKeyFile;
torOnionHostnamePath =
if usingDefaultTorHostname then
config.age.secrets.tor-onion-hostname.path
else
cfg.tor.onionHostnameFile;
caddyCommonConfig = ''
header Cross-Origin-Opener-Policy "same-origin"
header Cross-Origin-Embedder-Policy "require-corp"
handle /api/* {
reverse_proxy ${apiListen}
}
handle /qa/rss.xml {
reverse_proxy ${apiListen}
}
handle {
root * ${package}
try_files {path} /index.html
file_server
}
${cfg.caddy.extraConfig}
'';
in
{
options.services.jetpham-website = {
enable = lib.mkEnableOption "Jet Pham's personal website";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.default;
defaultText = lib.literalExpression "self.packages.${pkgs.system}.default";
description = "Static site package served by Caddy.";
};
apiPackage = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.qa-api;
defaultText = lib.literalExpression "self.packages.${pkgs.system}.qa-api";
description = "Q&A API package run by systemd.";
};
domain = lib.mkOption {
type = lib.types.str;
default = "jetpham.com";
description = "Domain to serve the website on.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Open HTTP and HTTPS ports when Caddy is enabled.";
};
apiListenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address for the local Q&A API listener.";
};
apiListenPort = lib.mkOption {
type = lib.types.port;
default = 3003;
description = "Port for the local Q&A API listener.";
};
caddy.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Serve the static site and reverse proxy the API through Caddy.";
};
caddy.extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra Caddy directives appended inside the virtual host block.";
};
tor = {
enable = lib.mkEnableOption "Tor hidden service for the website";
port = lib.mkOption {
type = lib.types.port;
default = 8888;
description = "Local Caddy port exposed through the onion service.";
};
onionSecretKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the Tor hidden service secret key file.";
};
onionPublicKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the Tor hidden service public key file.";
};
onionHostnameFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the Tor hidden service hostname file.";
};
};
qaNotifyEmail = lib.mkOption {
type = lib.types.str;
default = "jet@extremist.software";
description = "Email address to receive Q&A notifications.";
};
qaMailDomain = lib.mkOption {
type = lib.types.str;
default = "extremist.software";
description = "Mail domain for Q&A reply addresses.";
};
qaReplyDomain = lib.mkOption {
type = lib.types.str;
default = "extremist.software";
description = "Domain used in the static Q&A Reply-To address (`qa@...`). Use a dedicated subdomain and route only that mail into the webhook to avoid impacting your main inbox if the Q&A API fails.";
};
webhookSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing the WEBHOOK_SECRET for MTA Hook authentication. Defaults to the module-managed agenix secret when left unset.";
};
};
config = lib.mkIf cfg.enable {
age.secrets.webhook-secret = lib.mkIf usingDefaultWebhookSecret {
file = "${self}/secrets/webhook-secret.age";
mode = "0400";
};
age.secrets.tor-onion-secret-key = lib.mkIf (cfg.tor.enable && usingDefaultTorSecretKey) {
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 && usingDefaultTorPublicKey) {
file = "${self}/secrets/tor-onion-public-key.age";
owner = "tor";
group = "tor";
mode = "0444";
};
age.secrets.tor-onion-hostname = lib.mkIf (cfg.tor.enable && usingDefaultTorHostname) {
file = "${self}/secrets/tor-onion-hostname.age";
owner = "tor";
group = "tor";
mode = "0444";
};
assertions = [
{
assertion =
!cfg.tor.enable
|| (torOnionSecretKeyPath != null && torOnionPublicKeyPath != null && torOnionHostnamePath != null);
message = "services.jetpham-website.tor requires onionSecretKeyFile, onionPublicKeyFile, and onionHostnameFile.";
}
];
networking.firewall.allowedTCPPorts = lib.mkIf (cfg.caddy.enable && cfg.openFirewall) [
80
443
];
services.caddy.enable = cfg.caddy.enable;
services.tor = lib.mkIf cfg.tor.enable {
enable = true;
relay.onionServices.jetpham-website = {
map = [
{
port = 80;
target = {
addr = "127.0.0.1";
port = cfg.tor.port;
};
}
];
};
};
systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable {
description = "Copy Tor onion keys into place";
after = lib.optional (
usingDefaultTorSecretKey || usingDefaultTorPublicKey || usingDefaultTorHostname
) "agenix.service";
before = [ "tor.service" ];
wantedBy = [ "tor.service" ];
serviceConfig.Type = "oneshot";
script = ''
dir="/var/lib/tor/onion/jetpham-website"
mkdir -p "$dir"
cp ${torOnionSecretKeyPath} "$dir/hs_ed25519_secret_key"
cp ${torOnionPublicKeyPath} "$dir/hs_ed25519_public_key"
cp ${torOnionHostnamePath} "$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"
'';
};
systemd.services.jetpham-qa-api = {
description = "Jet Pham Q&A API";
after = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service";
wants = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
DynamicUser = true;
StateDirectory = "jetpham-qa";
WorkingDirectory = "/var/lib/jetpham-qa";
Environment = [
"QA_DB_PATH=/var/lib/jetpham-qa/qa.db"
"QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}"
"QA_MAIL_DOMAIN=${cfg.qaMailDomain}"
"QA_REPLY_DOMAIN=${cfg.qaReplyDomain}"
"QA_LISTEN_ADDRESS=${cfg.apiListenAddress}"
"QA_LISTEN_PORT=${toString cfg.apiListenPort}"
];
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/jetpham-qa" ];
Restart = "on-failure";
RestartSec = 5;
LoadCredential = "webhook-secret:${webhookSecretPath}";
};
script = ''
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
'';
};
services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable {
extraConfig = caddyCommonConfig;
};
services.caddy.virtualHosts."http://:${toString cfg.tor.port}" =
lib.mkIf (cfg.caddy.enable && cfg.tor.enable)
{
extraConfig = ''
bind 127.0.0.1
${caddyCommonConfig}
'';
};
};
}

100
next.config.js Normal file
View file

@ -0,0 +1,100 @@
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
*/
import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
webpack: (config, { isServer }) => {
config.module.rules.push({
test: /\.txt$/,
type: "asset/source",
});
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
};
config.module.rules.push({
test: /\.wasm$/,
type: "asset/resource",
generator: {
filename: "static/wasm/[name].[hash][ext]",
},
});
// Ensure WASM files are properly handled
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
};
// Ensure WASM files are properly served
config.output.webassemblyModuleFilename = "static/wasm/[modulehash].wasm";
return config;
},
turbopack: {
rules: {
"*.txt": {
loaders: ["raw-loader"],
as: "*.js",
},
},
},
productionBrowserSourceMaps: false,
// Redirect /_not-found to /
async redirects() {
return [
{
source: '/_not-found',
destination: '/',
permanent: false,
},
];
},
// Ensure static files are properly served
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Resource-Policy',
value: 'cross-origin',
},
],
},
{
source: '/_next/static/:path*',
headers: [
{
key: 'Cross-Origin-Resource-Policy',
value: 'cross-origin',
},
],
},
{
source: '/_next/static/wasm/:path*',
headers: [
{
key: 'Cross-Origin-Resource-Policy',
value: 'cross-origin',
},
{
key: 'Content-Type',
value: 'application/wasm',
},
],
},
];
},
};
export default config;

3144
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,29 +4,58 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "build": "next build",
"check": "npm run lint && tsc --noEmit", "build:wasm": "cd cgol && wasm-pack build --release --target web",
"dev": "vite", "check": "next lint && tsc --noEmit",
"format:check": "prettier --check \"**/*.{ts,js,jsx,mdx}\" --cache", "db:generate": "prisma migrate dev",
"format:write": "prettier --write \"**/*.{ts,js,jsx,mdx}\" --cache", "db:migrate": "prisma migrate deploy",
"lint": "eslint .", "db:push": "prisma db push",
"lint:fix": "eslint . --fix", "db:studio": "prisma studio",
"preview": "vite preview", "dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"postinstall": "prisma generate",
"lint": "next lint",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "dependencies": {
"@tailwindcss/vite": "^4.2.1", "@prisma/client": "^6.19.0",
"@types/node": "^25.3.3", "@t3-oss/env-nextjs": "^0.12.0",
"anser": "^2.3.5", "@tanstack/react-query": "^5.90.11",
"@trpc/client": "^11.7.2",
"@trpc/react-query": "^11.7.2",
"@trpc/server": "^11.7.2",
"anser": "^2.3.3",
"cgol": "file:./cgol/pkg",
"escape-carriage": "^1.3.1", "escape-carriage": "^1.3.1",
"eslint": "^10", "next": "15.5.9",
"prettier": "^3.8.1", "react": "^19.2.0",
"prettier-plugin-tailwindcss": "^0.7.2", "react-dom": "^19.2.0",
"tailwindcss": "^4.2.1", "server-only": "^0.0.1",
"typescript": "^5.9.3", "superjson": "^2.2.5",
"typescript-eslint": "^8.56.1", "zod": "^3.25.76"
"vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.0"
}, },
"knip": {} "devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^20.19.25",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-config-next": "^15.5.6",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"prisma": "^6.19.0",
"raw-loader": "^4.0.2",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0"
},
"ct3aMetadata": {
"initVersion": "7.40.0"
}
} }

5
postcss.config.js Normal file
View file

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

21
prisma/schema.prisma Normal file
View file

@ -0,0 +1,21 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}

View file

View file

@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://jetpham.com/sitemap.xml

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://jetpham.com/</loc>
<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>

View file

@ -1,2 +0,0 @@
# Jet Pham SSH fingerprints
ssh-ed25519 SHA256:Ziw7a2bUA1ew4AFQLB8rk9G3l9I4/eRClf9OJMLMLUA

View file

@ -1,10 +0,0 @@
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 ];
}

Binary file not shown.

View file

@ -1,7 +0,0 @@
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û€

View file

@ -1,8 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw AZTbqWYmTaudHZ8PiTZlwpf7VzwaP921guVV1iQi8WM
7CGUyEjZoAPCBX2pqNHLd2P1KLnj/Y5nnBVToWYCjWg
-> ssh-ed25519 uKftJg RfAkte/jcx+/SvZiUNH07cnBJcvl0Sjt7zSxdSCsXE0
TdMsQ2u5WXw3KAi7Tk4JOdbiFStT8F88xjDlRN8LH2Q
--- pgQnGRRVjVK02tbMgDoh3SatJFxxFLazqy5ieHu96tk
žÞxó}ûë¥T¡½<11>P^©kH ×/æôœõþݳU~ókÅ;Ö•äÕ†=•ÁºNå«\PZï|óO¨/Y íV6Ìx=„ ¹¹Â<gR+2á³C5n
©ãÃàÚ/J…k¤;5ÚNÈ—Ü#q&Øxš|­6²ƒŸ>H

View file

@ -1,7 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw Wef4V3554wa7yF3ztMeKxqxgI4sb8MYF7x7GFj+XhFc
GcqWkuplBIelOaP1cvOqwyK6igK5MAKPUqvpxCaV/Yk
-> ssh-ed25519 uKftJg Tnhs9FR2j2713OO4qDwWb4ERNivqmKI8tN45Av1hzTU
bWXnkDFehYUr3AghaUV4wYKfQEOqJsZC/SL2DUcq3DM
--- eBpn66oOUDpku3NMzF+30j2uC/iyzO3Oy3lftTQM8MY
¡öî™ÙI®í£Q'ã1\óùËÄRç BP®Åºnµ_pøn„>¬Œƒ“·²—k&Xå<58> Ë‚=RL:d6A ¨}ÜÒäÎWóK

View file

@ -0,0 +1,114 @@
import Anser, { type AnserJsonEntry } from "anser";
import { escapeCarriageReturn } from "escape-carriage";
import React, { memo, useMemo } from "react";
const colorMap: Record<string, string> = {
"ansi-black": "text-[var(--black)]",
"ansi-red": "text-[var(--red)]",
"ansi-green": "text-[var(--green)]",
"ansi-yellow": "text-[var(--brown)]",
"ansi-blue": "text-[var(--blue)]",
"ansi-magenta": "text-[var(--magenta)]",
"ansi-cyan": "text-[var(--cyan)]",
"ansi-white": "text-[var(--light-gray)]",
"ansi-bright-black": "text-[var(--dark-gray)]",
"ansi-bright-red": "text-[var(--light-red)]",
"ansi-bright-green": "text-[var(--light-green)]",
"ansi-bright-yellow": "text-[var(--yellow)]",
"ansi-bright-blue": "text-[var(--light-blue)]",
"ansi-bright-magenta": "text-[var(--light-magenta)]",
"ansi-bright-cyan": "text-[var(--light-cyan)]",
"ansi-bright-white": "text-[var(--white)]",
} as const;
const bgColorMap: Record<string, string> = {
"ansi-black": "bg-transparent",
"ansi-red": "bg-[var(--red)]",
"ansi-green": "bg-[var(--green)]",
"ansi-yellow": "bg-[var(--brown)]",
"ansi-blue": "bg-[var(--blue)]",
"ansi-magenta": "bg-[var(--magenta)]",
"ansi-cyan": "bg-[var(--cyan)]",
"ansi-white": "bg-[var(--light-gray)]",
"ansi-bright-black": "bg-[var(--dark-gray)]",
"ansi-bright-red": "bg-[var(--light-red)]",
"ansi-bright-green": "bg-[var(--light-green)]",
"ansi-bright-yellow": "bg-[var(--yellow)]",
"ansi-bright-blue": "bg-[var(--light-blue)]",
"ansi-bright-magenta": "bg-[var(--light-magenta)]",
"ansi-bright-cyan": "bg-[var(--light-cyan)]",
"ansi-bright-white": "bg-[var(--white)]",
} as const;
const decorationMap: Record<string, string> = {
bold: "font-bold",
dim: "opacity-50",
italic: "italic",
hidden: "invisible",
strikethrough: "line-through",
underline: "underline",
blink: "animate-pulse",
} as const;
function fixBackspace(txt: string): string {
let tmp = txt;
do {
txt = tmp;
tmp = txt.replace(/[^\n]\x08/gm, "");
} while (tmp.length < txt.length);
return txt;
}
function createClass(bundle: AnserJsonEntry): string | null {
const classes: string[] = [];
if (bundle.bg && bgColorMap[bundle.bg]) {
classes.push(bgColorMap[bundle.bg]!);
}
if (bundle.fg && colorMap[bundle.fg]) {
classes.push(colorMap[bundle.fg]!);
}
if (bundle.decoration && decorationMap[bundle.decoration]) {
classes.push(decorationMap[bundle.decoration]!);
}
return classes.length ? classes.join(" ") : null;
}
interface Props {
children?: string;
className?: string;
}
const Ansi = memo(function Ansi({ className, children = "" }: Props) {
const bundles = useMemo(() => {
const input = escapeCarriageReturn(fixBackspace(children));
return Anser.ansiToJson(input, {
json: true,
remove_empty: true,
use_classes: true,
});
}, [children]);
const renderedContent = useMemo(
() =>
bundles.map((bundle, key) => {
const bundleClassName = createClass(bundle);
return (
<span key={key} className={bundleClassName ?? undefined}>
{bundle.content}
</span>
);
}),
[bundles],
);
return (
<div className="flex justify-center">
<pre className={className ?? ""} style={{ textAlign: "left" }}>
<code>{renderedContent}</code>
</pre>
</div>
);
});
export default Ansi;

View file

@ -0,0 +1,28 @@
import { type ReactNode } from "react";
interface BorderedBoxProps {
label?: string;
children: ReactNode;
className?: string;
}
export function BorderedBox({
label,
children,
className = "",
}: BorderedBoxProps) {
return (
<fieldset
className={`mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 ${className}`}
>
{label && (
<legend className="-mx-[0.5ch] px-[0.5ch] text-white">
{label}
</legend>
)}
{children}
</fieldset>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
export function CgolCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const initializedRef = useRef(false);
const cleanupRef = useRef<(() => void) | null>(null);
const initializeWasm = useCallback(async () => {
if (typeof window === "undefined") return;
try {
const canvas = canvasRef.current;
if (!canvas || initializedRef.current) return;
const cgolModule = await import("cgol");
// Initialize WASM module
const initFunction = cgolModule.default;
if (initFunction && typeof initFunction === "function") {
await initFunction();
}
// Start CGOL
if (typeof cgolModule.start === "function") {
cgolModule.start();
initializedRef.current = true;
const cleanupFn = (cgolModule as { cleanup?: () => void }).cleanup;
if (typeof cleanupFn === "function") {
cleanupRef.current = cleanupFn;
}
}
} catch (error: unknown) {
console.error("Failed to initialize CGOL WebAssembly module:", error);
}
}, []);
useEffect(() => {
// Initialize immediately without delay
void initializeWasm();
return () => {
// Call cleanup if available from WASM module
if (cleanupRef.current) {
cleanupRef.current();
}
// Reset initialization state on unmount
initializedRef.current = false;
};
}, [initializeWasm]);
return (
<canvas
ref={canvasRef}
id="canvas"
className="fixed top-0 left-0 -z-10 h-screen w-screen"
aria-hidden="true"
/>
);
}

View file

@ -0,0 +1,47 @@
import { type ReactNode } from "react";
interface FrostedBoxProps {
label?: string;
children: ReactNode;
className?: string;
}
export function FrostedBox({
label,
children,
className = "",
}: FrostedBoxProps) {
return (
<div
className={`relative my-[calc(2ch-2px)] px-[2ch] py-[2ch] ${className}`}
>
{/* Extended frosted glass backdrop with mask */}
<div
className="pointer-events-none absolute inset-0 h-[200%]"
style={{
backgroundColor: "rgba(0, 0, 0, 0.75)",
backdropFilter: "blur(16px)",
WebkitBackdropFilter: "blur(16px)",
maskImage:
"linear-gradient(to bottom, black 0% 50%, transparent 50% 100%)",
WebkitMaskImage:
"linear-gradient(to bottom, black 0% 50%, transparent 50% 100%)",
}}
/>
{/* Border */}
<div className="absolute inset-0 border-2 border-white" />
{/* Content */}
<div className="relative z-10">
{label && (
<span className="absolute -top-[1ch] left-2 bg-transparent text-white">
{label}
</span>
)}
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,16 @@
import React from "react";
import Ansi from "./ansi";
interface HeaderProps {
content: string;
className?: string;
}
export default function Header({ content, className }: HeaderProps) {
return (
<div className={className}>
<Ansi>{content}</Ansi>
</div>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { api } from "~/trpc/react";
export function LatestPost() {
const [latestPost] = api.post.getLatest.useSuspenseQuery();
const utils = api.useUtils();
const [name, setName] = useState("");
const createPost = api.post.create.useMutation({
onSuccess: async () => {
await utils.post.invalidate();
setName("");
},
});
return (
<div className="w-full max-w-xs">
{latestPost ? (
<p className="truncate">Your most recent post: {latestPost.name}</p>
) : (
<p>You have no posts yet.</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({ name });
}}
className="flex flex-col gap-2"
>
<input
type="text"
placeholder="Title"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-full bg-white/10 px-4 py-2 text-white"
/>
<button
type="submit"
className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
disabled={createPost.isPending}
>
{createPost.isPending ? "Submitting..." : "Submit"}
</button>
</form>
</div>
);
}

View file

@ -0,0 +1,34 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g. when you make requests from Client Components).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };

View file

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 979 KiB

After

Width:  |  Height:  |  Size: 979 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

34
src/app/layout.tsx Normal file
View file

@ -0,0 +1,34 @@
import "~/styles/globals.css";
import { type Metadata, type Viewport } from "next";
// import { TRPCReactProvider } from "~/trpc/react";
export const metadata: Metadata = {
title: "Jet Pham",
description: "Jet Pham's personal website",
icons: [{ rel: "icon", url: "/favicon.ico" }],
appleWebApp: {
title: "Jet Pham",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#000000",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
{/* <TRPCReactProvider> */}
{children}
{/* </TRPCReactProvider> */}
</body>
</html>
);
}

View file

@ -16,7 +16,7 @@
} }
], ],
"theme_color": "#a80055", "theme_color": "#a80055",
"background_color": "#000000", "background_color": "#a80055",
"display": "standalone" "display": "standalone"
} }

79
src/app/page.tsx Normal file
View file

@ -0,0 +1,79 @@
import Link from "next/link";
import Image from "next/image";
// import { HydrateClient } from "~/trpc/server";
import { BorderedBox } from "./_components/bordered-box";
import { FrostedBox } from "./_components/frosted-box";
import Header from "./_components/header";
import { CgolCanvas } from "./_components/cgol-canvas";
import FirstName from "~/assets/Jet.txt";
export default async function Home() {
return (
// <HydrateClient>
<>
<CgolCanvas />
<main>
<div className="flex flex-col items-center justify-start px-4">
<FrostedBox className="my-[2ch] w-full max-w-[66.666667%] min-w-fit md:mt-[4ch]">
<div className="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
<div className="order-1 flex flex-col items-center md:order-2">
<Header content={FirstName} />
<div className="mt-[2ch]">Software Extremist</div>
</div>
<div className="order-2 shrink-0 md:order-1">
<Image
src="/jet.svg"
alt="Jet"
width={250}
height={250}
className="aspect-square w-full max-w-[250px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
priority
/>
</div>
</div>
<BorderedBox label="Skills" className="mt-[2ch]">
<div>Making crazy stuff</div>
</BorderedBox>
<BorderedBox label="Links">
<ol>
<li>
<Link
href="https://github.com/jetpham"
className="inline-flex items-center"
>
GitHub
</Link>
</li>
<li>
<Link
href="https://linkedin.com/in/jetpham"
className="inline-flex items-center"
>
LinkedIn
</Link>
</li>
<li>
<Link
href="https://bsky.app/profile/jetpham.com"
className="inline-flex items-center"
>
Bluesky
</Link>
</li>
<li>
<Link
href="https://x.com/jetpham5"
className="inline-flex items-center"
>
X (Twitter)
</Link>
</li>
</ol>
</BorderedBox>
</FrostedBox>
</div>
</main>
</>
// </HydrateClient>
);
}

View file

@ -1,10 +0,0 @@
export function frostedBox(content: string, extraClasses?: string): string {
return `
<div class="site-shell site-panel-frame relative my-[2ch] overflow-hidden px-[2ch] py-[2ch] ${extraClasses ?? ""}">
<div class="site-panel-frost pointer-events-none" aria-hidden="true"></div>
<div class="site-panel-border" aria-hidden="true"></div>
<div class="site-panel-content h-full min-h-0">
${content}
</div>
</div>`;
}

44
src/env.js Normal file
View file

@ -0,0 +1,44 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Specify your server-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars.
*/
server: {
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
},
/**
* Specify your client-side environment variables schema here. This way you can ensure the app
* isn't built with invalid env vars. To expose them to the client, prefix them with
* `NEXT_PUBLIC_`.
*/
client: {
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
/**
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
* useful for Docker builds.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
/**
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
* `SOME_VAR=''` will throw an error.
*/
emptyStringAsUndefined: true,
});

10
src/global.d.ts vendored
View file

@ -1,16 +1,10 @@
/// <reference types="vite/client" /> declare module "*.txt" {
declare module "*.txt?raw" {
const content: string; const content: string;
export default content; export default content;
} }
declare module "*.txt?ansi" { declare module "*.utf8ans" {
const content: string; const content: string;
export default content; export default content;
} }
declare module "*.utf8ans?raw" {
const content: string;
export default content;
}

View file

@ -1,132 +0,0 @@
export interface Question {
id: number;
question: string;
answer: string;
created_at: string;
answered_at: string;
}
export interface QuestionStats {
asked: number;
answered: number;
}
const DEV_QUESTIONS: Question[] = [
{
id: 1,
question: "What is a fact about octopuses?",
answer: "An octopus has three hearts and blue blood.",
created_at: "2026-03-23T18:10:00.000Z",
answered_at: "2026-03-23T19:00:00.000Z",
},
{
id: 2,
question: "What is a fact about axolotls?",
answer:
"An axolotl can regrow limbs, parts of its heart, and even parts of its brain.",
created_at: "2026-03-24T02:15:00.000Z",
answered_at: "2026-03-24T05:45:00.000Z",
},
{
id: 3,
question: "What is a fact about crows?",
answer: "Crows can recognize human faces and remember them for years.",
created_at: "2026-03-25T08:30:00.000Z",
answered_at: "2026-03-25T09:05:00.000Z",
},
{
id: 4,
question: "What is a fact about wombats?",
answer: "Wombats produce cube-shaped poop.",
created_at: "2026-03-25T11:10:00.000Z",
answered_at: "2026-03-25T11:40:00.000Z",
},
{
id: 5,
question: "What is a fact about mantis shrimp?",
answer:
"A mantis shrimp can punch so fast it creates tiny cavitation bubbles in water.",
created_at: "2026-03-25T13:00:00.000Z",
answered_at: "2026-03-25T13:18:00.000Z",
},
{
id: 6,
question: "What is a fact about sloths?",
answer:
"Some sloths can hold their breath longer than dolphins by slowing their heart rate.",
created_at: "2026-03-25T14:25:00.000Z",
answered_at: "2026-03-25T15:00:00.000Z",
},
{
id: 7,
question: "What is a fact about owls?",
answer:
"An owl cannot rotate its eyes, so it turns its whole head instead.",
created_at: "2026-03-25T16:05:00.000Z",
answered_at: "2026-03-25T16:21:00.000Z",
},
{
id: 8,
question: "What is a fact about capybaras?",
answer:
"Capybaras are the largest rodents in the world and are excellent swimmers.",
created_at: "2026-03-25T18:45:00.000Z",
answered_at: "2026-03-25T19:07:00.000Z",
},
{
id: 9,
question: "What is a fact about penguins?",
answer: "Penguins have solid bones, which help them dive instead of float.",
created_at: "2026-03-25T21:20:00.000Z",
answered_at: "2026-03-25T21:55:00.000Z",
},
{
id: 10,
question: "What is a fact about bats?",
answer: "Bats are the only mammals capable of sustained powered flight.",
created_at: "2026-03-26T00:10:00.000Z",
answered_at: "2026-03-26T00:32:00.000Z",
},
];
const DEV_QUESTION_STATS: QuestionStats = {
asked: 16,
answered: DEV_QUESTIONS.length,
};
export async function getQuestions(): Promise<Question[]> {
if (import.meta.env.DEV) return DEV_QUESTIONS;
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 getQuestionStats(): Promise<QuestionStats> {
if (import.meta.env.DEV) return DEV_QUESTION_STATS;
try {
const res = await fetch("/api/questions/stats");
if (!res.ok) throw new Error("Failed to fetch question stats");
return res.json() as Promise<QuestionStats>;
} catch {
const questions = await getQuestions();
return {
asked: questions.length,
answered: questions.length,
};
}
}
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");
}
}

View file

@ -1,88 +0,0 @@
const DRAFT_KEY = "qa-draft";
const LAST_PLACEHOLDER_KEY = "qa-last-placeholder";
const relativeTime = new Intl.RelativeTimeFormat(undefined, {
numeric: "auto",
});
function getValidDate(dateString: string): Date | null {
const date = new Date(dateString);
return Number.isNaN(date.getTime()) ? null : date;
}
export function readQuestionDraft(): string {
try {
return localStorage.getItem(DRAFT_KEY) ?? "";
} catch {
return "";
}
}
export function writeQuestionDraft(value: string) {
try {
localStorage.setItem(DRAFT_KEY, value);
} catch {
// Ignore storage failures.
}
}
export function clearQuestionDraft() {
try {
localStorage.removeItem(DRAFT_KEY);
} catch {
// Ignore storage failures.
}
}
export function formatDateOnly(dateString: string): string {
const date = getValidDate(dateString);
if (!date) return dateString;
return date.toISOString().slice(0, 10);
}
export function formatFriendlyDate(dateString: string): string {
const date = getValidDate(dateString);
if (!date) return dateString;
const now = Date.now();
const diffMs = date.getTime() - now;
const diffMinutes = Math.round(diffMs / 60000);
const diffHours = Math.round(diffMs / 3600000);
const diffDays = Math.round(diffMs / 86400000);
if (Math.abs(diffMinutes) < 60)
return relativeTime.format(diffMinutes, "minute");
if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour");
if (Math.abs(diffDays) < 14) return relativeTime.format(diffDays, "day");
return formatDateOnly(dateString);
}
export function pickPlaceholder<T>(items: readonly T[]): T {
if (items.length === 1) return items[0]!;
let lastIndex = -1;
try {
lastIndex = Number.parseInt(
sessionStorage.getItem(LAST_PLACEHOLDER_KEY) ?? "",
10,
);
} catch {
lastIndex = -1;
}
let nextIndex = Math.floor(Math.random() * items.length);
if (nextIndex === lastIndex) {
nextIndex =
(nextIndex + 1 + Math.floor(Math.random() * (items.length - 1))) %
items.length;
}
try {
sessionStorage.setItem(LAST_PLACEHOLDER_KEY, String(nextIndex));
} catch {
// Ignore storage failures.
}
return items[nextIndex]!;
}

View file

@ -1,46 +0,0 @@
const CLEARNET_HOST = "jetpham.com";
const ONION_HOST =
"jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion";
const REPO_URL = "https://git.extremist.software/jet/website";
function isOnionHost(hostname: string): boolean {
return hostname.endsWith(".onion");
}
function getMirrorLink() {
if (isOnionHost(location.hostname)) {
return {
href: `https://${CLEARNET_HOST}`,
label: "clearnet",
};
}
return {
href: `http://${ONION_HOST}`,
label: ".onion",
};
}
export function renderFooter() {
const footer = document.getElementById("site-footer");
if (!footer) return;
const mirror = getMirrorLink();
footer.innerHTML = `
<div class="site-panel-frame px-[2ch] py-[1ch]">
<div class="site-panel-frost" aria-hidden="true"></div>
<div class="site-panel-border" aria-hidden="true"></div>
<div class="site-panel-content site-footer-inner">
<a href="${REPO_URL}">Src</a>
<span aria-hidden="true">|</span>
<a href="/qa/rss.xml" data-native-link>RSS</a>
<span aria-hidden="true">|</span>
<a href="/pgp.txt" data-native-link>PGP</a>
<span aria-hidden="true">|</span>
<a href="/ssh.txt" data-native-link>SSH</a>
<span aria-hidden="true">|</span>
<a href="${mirror.href}">${mirror.label}</a>
</div>
</div>`;
}

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
import "~/styles/globals.css";
import { route, initRouter } from "~/router";
import { initWebGLBackground } from "~/lib/webgl-background";
import { renderFooter } from "~/lib/site";
import { homePage } from "~/pages/home";
import { qaPage } from "~/pages/qa";
import { notFoundPage } from "~/pages/not-found";
route("/", "Jet Pham - Home", homePage);
route("/qa", "Jet Pham - Q+A", qaPage);
route("*", "404 - Jet Pham", notFoundPage);
renderFooter();
initWebGLBackground();
initRouter();

View file

@ -1,68 +0,0 @@
import Jet from "~/assets/Jet.txt?ansi";
import { frostedBox } from "~/components/frosted-box";
export function homePage(outlet: HTMLElement) {
outlet.classList.remove("qa-outlet");
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start">
${frostedBox(`
<div class="flex flex-col items-center justify-center gap-[1.25ch] md: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" data-emitter-ansi>${Jet}</div>
<p class="mt-[2ch]">Software Extremist</p>
</div>
<div class="order-2 shrink-0 md:order-1">
<img
data-emitter-image
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-[220px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
style="background-color: #a80055; color: transparent"
/>
</div>
</div>
<fieldset class="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" style="border-color: var(--white)">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--white)">Contact</legend>
<button type="button" id="copy-email" class="qa-inline-action">jet@extremist.software</button>
<span id="copy-email-status" class="qa-meta ml-[1ch]" aria-live="polite"></span>
</fieldset>
<fieldset class="section-block mt-[2ch] border-2 px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" style="border-color: var(--white)">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--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>`;
const copyButton = document.getElementById("copy-email") as HTMLButtonElement;
const copyStatus = document.getElementById(
"copy-email-status",
) as HTMLSpanElement;
let resetTimer: number | null = null;
copyButton.addEventListener("click", () => {
void (async () => {
try {
await navigator.clipboard.writeText("jet@extremist.software");
copyStatus.textContent = "copied";
copyStatus.style.color = "var(--light-green)";
} catch {
copyStatus.textContent = "copy failed";
copyStatus.style.color = "var(--light-red)";
}
if (resetTimer !== null) window.clearTimeout(resetTimer);
resetTimer = window.setTimeout(() => {
copyStatus.textContent = "";
resetTimer = null;
}, 1400);
})();
});
}

View file

@ -1,13 +0,0 @@
import { frostedBox } from "~/components/frosted-box";
export function notFoundPage(outlet: HTMLElement) {
outlet.classList.remove("qa-outlet");
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start">
${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>`;
}

View file

@ -1,283 +0,0 @@
import {
getQuestions,
getQuestionStats,
submitQuestion,
type Question,
type QuestionStats,
} from "~/lib/api";
import {
clearQuestionDraft,
formatDateOnly,
pickPlaceholder,
readQuestionDraft,
writeQuestionDraft,
} from "~/lib/qa";
import { frostedBox } from "~/components/frosted-box";
const PLACEHOLDER_QUESTIONS = [
"Why call yourself a software extremist?",
"What are you building at Noisebridge?",
"Why Forgejo over GitHub?",
"What is the weirdest thing in your nix-config?",
"Why did you write HolyC?",
"What do you like about San Francisco hacker culture?",
"What is your favorite project you've seen at TIAT?",
"What is your favorite project you've seen at Noisebridge?",
"What is your favorite hacker conference?",
"What is your cat's name?",
"What are your favorite programming languages and tools?",
"Who are your biggest inspirations?",
] as const;
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function formatQuestionTooltip(question: Question): string {
const askedExact = formatDateOnly(question.created_at);
const answeredExact = formatDateOnly(question.answered_at);
return `
<p>Asked ${escapeHtml(askedExact)}</p>
<p>Answered ${escapeHtml(answeredExact)}</p>`;
}
function formatRatio(stats: QuestionStats): string {
if (stats.asked === 0) return "0%";
return `${Math.round((stats.answered / stats.asked) * 100)}%`;
}
function renderQuestions(list: HTMLElement, questions: Question[]) {
if (questions.length === 0) {
list.innerHTML = `
<section class="qa-item qa-list-item px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<p class="qa-list-label">No answers yet</p>
<p>...</p>
</section>`;
return;
}
list.innerHTML = questions
.map(
(q) => `
<section class="qa-item qa-list-item mb-[2ch] px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0" tabindex="0">
<div class="qa-item-meta" role="note">
${formatQuestionTooltip(q)}
</div>
<p style="color: var(--light-gray);">${escapeHtml(q.question)}</p>
<p class="mt-[1ch]" style="color: var(--light-blue);">${escapeHtml(q.answer)}</p>
</section>`,
)
.join("");
}
export async function qaPage(outlet: HTMLElement) {
outlet.classList.add("qa-outlet");
const draft = readQuestionDraft();
const placeholderQuestion = pickPlaceholder(PLACEHOLDER_QUESTIONS);
outlet.innerHTML = `
<div class="qa-page flex h-full flex-col items-center justify-start">
${frostedBox(
`
<div class="flex h-full flex-col">
<form id="qa-form" novalidate>
<section class="section-block">
<label class="sr-only" for="qa-input">Question</label>
<div class="qa-input-wrap">
<textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea"
aria-describedby="qa-status char-count"
placeholder="${escapeHtml(placeholderQuestion)}">${escapeHtml(draft)}</textarea>
<div class="qa-input-bar">
<span id="char-count" class="qa-bar-text">${draft.length}/200</span>
<button id="qa-submit" type="submit" class="qa-button">[SUBMIT]</button>
</div>
</div>
<p id="qa-status" class="qa-meta" aria-live="polite"></p>
<div id="qa-stats" class="qa-stats mt-[1ch]" aria-live="polite">Asked ... | Answered ... | Ratio ...</div>
</section>
</form>
<div id="qa-list" class="qa-list-scroll mt-[2ch] min-h-0 flex-1 overflow-y-auto pr-[1ch]" aria-live="polite">Loading answered questions...</div>
</div>
`,
"my-0 flex h-full min-h-0 flex-col",
)}
</div>`;
const form = document.getElementById("qa-form") as HTMLFormElement;
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
const submitButton = document.getElementById(
"qa-submit",
) as HTMLButtonElement;
const charCount = document.getElementById("char-count") as HTMLSpanElement;
const status = document.getElementById("qa-status") as HTMLParagraphElement;
const stats = document.getElementById("qa-stats") as HTMLDivElement;
const list = document.getElementById("qa-list") as HTMLDivElement;
let isSubmitting = false;
let hasInteracted = draft.trim().length > 0;
let buttonResetTimer: number | null = null;
const defaultButtonText = "[SUBMIT]";
function setStatus(message: string, color: string) {
status.textContent = message;
status.style.color = color;
}
function setStatsDisplay(nextStats: QuestionStats) {
stats.textContent = `Asked ${nextStats.asked} | Answered ${nextStats.answered} | Ratio ${formatRatio(nextStats)}`;
}
function showButtonMessage(message: string, color: string, duration = 1600) {
if (buttonResetTimer !== null) window.clearTimeout(buttonResetTimer);
submitButton.textContent = message;
submitButton.style.color = color;
submitButton.classList.add("qa-button-message");
buttonResetTimer = window.setTimeout(() => {
buttonResetTimer = null;
submitButton.textContent = defaultButtonText;
submitButton.style.color = "";
submitButton.classList.remove("qa-button-message");
if (!input.matches(":focus") && input.value.trim().length === 0) {
input.removeAttribute("aria-invalid");
}
}, duration);
}
function updateValidation() {
const trimmed = input.value.trim();
const remaining = 200 - input.value.length;
charCount.textContent = `${input.value.length}/200`;
if (trimmed.length === 0 && hasInteracted) {
input.setAttribute("aria-invalid", "true");
return false;
}
input.removeAttribute("aria-invalid");
if (remaining <= 20) {
charCount.style.color =
remaining === 0
? "var(--light-red)"
: remaining <= 5
? "var(--yellow)"
: "var(--dark-gray)";
return true;
}
charCount.style.color = "var(--dark-gray)";
return true;
}
async function loadQuestions() {
list.textContent = "Loading answered questions...";
list.style.color = "var(--light-gray)";
list.style.textAlign = "left";
try {
const questions = await getQuestions();
renderQuestions(list, questions);
} catch {
showButtonMessage("[LOAD FAILED]", "var(--light-red)");
list.innerHTML = `
<section class="qa-item qa-list-item px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<p class="qa-list-label" style="color: var(--light-red);">Load failed</p>
<p>Failed to load answered questions.</p>
<p class="qa-meta">
<button type="button" id="qa-retry" class="qa-inline-action">Retry loading questions</button>
</p>
</section>`;
list.style.color = "var(--light-red)";
const retryButton = document.getElementById(
"qa-retry",
) as HTMLButtonElement | null;
retryButton?.addEventListener("click", () => {
void loadQuestions();
});
}
}
async function loadStats() {
try {
const nextStats = await getQuestionStats();
setStatsDisplay(nextStats);
} catch {
stats.textContent = "Asked ? | Answered ? | Ratio ?";
}
}
input.addEventListener("input", () => {
hasInteracted = true;
writeQuestionDraft(input.value);
updateValidation();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
form.addEventListener("submit", (e) => {
e.preventDefault();
if (isSubmitting) return;
const question = input.value.trim();
hasInteracted = true;
if (!updateValidation() || !question) {
setStatus("", "var(--dark-gray)");
showButtonMessage("[EMPTY]", "var(--light-red)");
input.focus();
return;
}
isSubmitting = true;
submitButton.disabled = true;
submitButton.textContent = "[SENDING]";
setStatus("Submitting...", "var(--light-gray)");
submitQuestion(question)
.then(() => {
input.value = "";
clearQuestionDraft();
hasInteracted = false;
submitButton.textContent = defaultButtonText;
submitButton.style.color = "";
updateValidation();
setStatus(
"Question submitted! It will appear here once answered.",
"var(--light-green)",
);
input.focus();
})
.catch((err: unknown) => {
const message =
err instanceof Error ? err.message : "Failed to submit question.";
if (message.includes("Too many questions")) {
showButtonMessage("[RATE LIMIT]", "var(--light-red)");
setStatus(message, "var(--light-red)");
} else {
showButtonMessage("[FAILED]", "var(--light-red)");
setStatus("", "var(--dark-gray)");
}
})
.finally(() => {
isSubmitting = false;
submitButton.disabled = false;
if (buttonResetTimer === null) {
submitButton.textContent = defaultButtonText;
submitButton.style.color = "";
}
});
});
updateValidation();
await loadStats();
await loadQuestions();
}

View file

@ -1,95 +0,0 @@
type PageHandler = (
outlet: HTMLElement,
params: Record<string, string>,
) => void | Promise<void>;
interface Route {
pattern: RegExp;
keys: string[];
handler: PageHandler;
title: string;
}
const routes: Route[] = [];
let notFoundHandler: PageHandler | null = null;
let notFoundTitle = "404 - Jet Pham";
export function route(path: string, title: string, handler: PageHandler) {
if (path === "*") {
notFoundHandler = handler;
notFoundTitle = title;
return;
}
const keys: string[] = [];
const pattern = path.replace(/:(\w+)/g, (_, key: string) => {
keys.push(key);
return "([^/]+)";
});
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler, title });
}
export function navigate(path: string) {
history.pushState(null, "", path);
window.scrollTo({ top: 0, behavior: "auto" });
void render();
}
function updateNavState(path: string) {
const navLinks =
document.querySelectorAll<HTMLAnchorElement>("[data-nav-link]");
navLinks.forEach((link) => {
if (link.pathname === path) {
link.setAttribute("aria-current", "page");
} else {
link.removeAttribute("aria-current");
}
});
}
async function render() {
const path = location.pathname;
const outlet = document.getElementById("outlet")!;
updateNavState(path);
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);
document.title = r.title;
document.dispatchEvent(new Event("site:render"));
return;
}
}
outlet.innerHTML = "";
if (notFoundHandler) {
await notFoundHandler(outlet, {});
}
document.title = notFoundTitle;
document.dispatchEvent(new Event("site:render"));
}
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.hash &&
!anchor.hasAttribute("data-native-link") &&
!anchor.hasAttribute("download") &&
!anchor.hasAttribute("target")
) {
e.preventDefault();
navigate(anchor.pathname);
}
});
void render();
}

23
src/server/api/root.ts Normal file
View file

@ -0,0 +1,23 @@
import { postRouter } from "~/server/api/routers/post";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
/**
* This is the primary router for your server.
*
* All routers added in /api/routers should be manually added here.
*/
export const appRouter = createTRPCRouter({
post: postRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
/**
* Create a server-side caller for the tRPC API.
* @example
* const trpc = createCaller(createContext);
* const res = await trpc.post.all();
* ^? Post[]
*/
export const createCaller = createCallerFactory(appRouter);

View file

@ -0,0 +1,31 @@
import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
export const postRouter = createTRPCRouter({
hello: publicProcedure
.input(z.object({ text: z.string() }))
.query(({ input }) => {
return {
greeting: `Hello ${input.text}`,
};
}),
create: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
name: input.name,
},
});
}),
getLatest: publicProcedure.query(async ({ ctx }) => {
const post = await ctx.db.post.findFirst({
orderBy: { createdAt: "desc" },
});
return post ?? null;
}),
});

106
src/server/api/trpc.ts Normal file
View file

@ -0,0 +1,106 @@
/**
* YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS:
* 1. You want to modify request context (see Part 1).
* 2. You want to create a new middleware or type of procedure (see Part 3).
*
* TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will
* need to use are documented accordingly near the end.
*/
import { initTRPC } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { db } from "~/server/db";
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API.
*
* These allow you to access things when processing a request, like the database, the session, etc.
*
* This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
* wrap this and provides the required context.
*
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
return {
db,
...opts,
};
};
/**
* 2. INITIALIZATION
*
* This is where the tRPC API is initialized, connecting the context and transformer. We also parse
* ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
* errors on the backend.
*/
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
/**
* Create a server-side caller.
*
* @see https://trpc.io/docs/server/server-side-calls
*/
export const createCallerFactory = t.createCallerFactory;
/**
* 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
*
* These are the pieces you use to build your tRPC API. You should import these a lot in the
* "/src/server/api/routers" directory.
*/
/**
* This is how you create new routers and sub-routers in your tRPC API.
*
* @see https://trpc.io/docs/router
*/
export const createTRPCRouter = t.router;
/**
* Middleware for timing procedure execution and adding an artificial delay in development.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
// artificial delay in dev
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
/**
* Public (unauthenticated) procedure
*
* This is the base piece you use to build new queries and mutations on your tRPC API. It does not
* guarantee that a user querying is authorized, but you can still access user session data if they
* are logged in.
*/
export const publicProcedure = t.procedure.use(timingMiddleware);

16
src/server/db.ts Normal file
View file

@ -0,0 +1,16 @@
import { env } from "~/env";
import { PrismaClient } from "../../generated/prisma";
const createPrismaClient = () =>
new PrismaClient({
log:
env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
const globalForPrisma = globalThis as unknown as {
prisma: ReturnType<typeof createPrismaClient> | undefined;
};
export const db = globalForPrisma.prisma ?? createPrismaClient();
if (env.NODE_ENV !== "production") globalForPrisma.prisma = db;

View file

@ -9,182 +9,57 @@
} }
:root { :root {
--font-sans: --font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", --font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace;
"Source Code Pro", monospace;
--font-mono:
"IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono",
"Source Code Pro", monospace;
/* 16-color palette */ /* 16-color palette */
--black: #000000; --black: #000000;
--blue: #0000aa; --blue: #0000AA;
--green: #00aa00; --green: #00AA00;
--cyan: #00aaaa; --cyan: #00AAAA;
--red: #aa0000; --red: #AA0000;
--magenta: #aa00aa; --magenta: #AA00AA;
--brown: #aa5500; --brown: #AA5500;
--light-gray: #aaaaaa; --light-gray: #AAAAAA;
--dark-gray: #555555; --dark-gray: #555555;
--light-blue: #5555ff; --light-blue: #5555FF;
--light-green: #55ff55; --light-green: #55FF55;
--light-cyan: #55ffff; --light-cyan: #55FFFF;
--light-red: #ff5555; --light-red: #FF5555;
--light-magenta: #ff55ff; --light-magenta: #FF55FF;
--yellow: #ffff55; --yellow: #FFFF55;
--white: #ffffff; --white: #FFFFFF;
--panel-bg-alpha: 0.2;
--panel-gloss-alpha: 0.05;
--panel-border-inset: 0.6rem;
--panel-bg: rgba(0, 0, 0, var(--panel-bg-alpha));
--panel-blur: 10px;
--fallback-glow-a: rgba(85, 255, 255, 0.18);
--fallback-glow-b: rgba(85, 85, 255, 0.16);
--fallback-glow-c: rgba(255, 85, 85, 0.14);
} }
/* Global BBS-style 80-character width constraint - responsive */
html { html {
min-height: 100%; height: 100%;
box-sizing: border-box; width: min(80ch, 100vw); /* 80 characters wide on desktop, full width on mobile */
} padding: 1rem;
margin: 0 auto;
body {
min-height: 100vh;
min-height: 100dvh;
position: relative;
isolation: isolate;
margin: 0;
overflow: hidden;
background-color: var(--black);
color: var(--white);
font-family: "IBM VGA", monospace; font-family: "IBM VGA", monospace;
font-size: 1.25rem; font-size: 1.25rem; /* Smaller font size for mobile */
white-space: normal; white-space: pre;
line-height: 1; line-height: 1;
} box-sizing: border-box;
overflow: hidden; /* Disable scrolling */
body::before {
content: "";
position: fixed;
inset: -20vmax;
z-index: -30;
background: var(--black);
opacity: 0;
transform: translate3d(0, 0, 0) scale(1);
pointer-events: none;
}
body[data-background-mode="failed"]::before {
opacity: 1;
animation: fallback-drift 18s linear infinite alternate;
}
@media (prefers-reduced-motion: reduce) {
body[data-background-mode="failed"]::before {
animation: none;
}
}
@keyframes fallback-drift {
from {
transform: translate3d(-3%, -2%, 0) scale(1);
}
to {
transform: translate3d(3%, 2%, 0) scale(1.12);
}
}
::selection {
background-color: var(--light-blue);
color: var(--black);
}
*,
*::before,
*::after {
box-sizing: inherit;
} }
/* Desktop font size */ /* Desktop font size */
@media (min-width: 768px) { @media (min-width: 768px) {
body { html {
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
.page-frame { /* Apply CGA theme to body */
height: 100vh; body {
height: 100dvh; height: 100%;
width: min(80ch, 100vw); background-color: var(--black);
margin: 0 auto; color: var(--white);
padding: 1rem; margin: 0;
display: grid; padding: 0;
grid-template-rows: auto 1fr auto; overflow: hidden; /* Disable scrolling */
gap: 2ch;
}
#outlet {
display: block;
min-height: 0;
overflow: hidden;
user-select: none;
}
#outlet.qa-outlet {
overflow: hidden;
}
#outlet:focus {
outline: none;
}
.site-shell {
width: 100%;
box-sizing: border-box;
margin: 0 auto;
user-select: text;
}
@media (min-width: 768px) {
.site-shell {
width: min(100%, 60%);
}
}
.site-panel-frame {
position: relative;
overflow: hidden;
}
.site-panel-frost {
position: absolute;
inset: var(--panel-border-inset);
pointer-events: none;
overflow: hidden;
background:
linear-gradient(
to bottom,
rgba(255, 255, 255, var(--panel-gloss-alpha)),
transparent 24%
),
var(--panel-bg);
}
.site-panel-border {
position: absolute;
inset: var(--panel-border-inset);
border: 2px solid var(--white);
pointer-events: none;
}
.site-panel-content {
position: relative;
z-index: 1;
}
.site-region {
width: 100%;
} }
/* Global focus ring styles for all tabbable elements */ /* Global focus ring styles for all tabbable elements */
@ -204,265 +79,10 @@ select:focus,
a { a {
color: var(--light-blue); color: var(--light-blue);
text-decoration: none; text-decoration: none;
text-underline-offset: 0.2ch;
} }
a:hover, a:hover {
a:focus-visible, color: var(--blue);
a[aria-current="page"] {
background-color: var(--light-blue);
color: var(--black);
text-decoration: underline;
} }
a[aria-current="page"] {
color: var(--yellow);
background-color: transparent;
text-decoration: underline;
}
.site-nav-link {
display: inline-flex;
align-items: center;
color: var(--light-blue);
text-decoration: none;
}
.site-nav-link:hover,
.site-nav-link:focus-visible {
background-color: transparent;
color: var(--light-blue);
text-decoration: none;
}
.site-nav-link[aria-current="page"] {
color: var(--yellow);
text-decoration: none;
}
.site-nav-marker {
display: inline-block;
width: 1ch;
color: currentColor;
visibility: hidden;
}
.site-nav-link[aria-current="page"] .site-nav-marker {
visibility: visible;
}
.skip-link {
position: absolute;
left: 1rem;
top: 0;
transform: translateY(-150%);
padding: 0.5ch 1ch;
background: var(--yellow);
color: var(--black);
z-index: 20;
}
.skip-link:focus {
transform: translateY(1rem);
}
/* Form inputs */
.qa-input-wrap {
position: relative;
padding: 1ch;
background-color: rgba(0, 0, 0, 0.18);
border: 2px solid var(--white);
display: flex;
flex-direction: column;
gap: 1ch;
}
.qa-page {
min-height: 0;
width: 100%;
}
.qa-list-scroll {
padding-top: 1.5ch;
padding-bottom: 1.5ch;
scrollbar-gutter: stable;
overscroll-behavior: contain;
padding-right: 0.5ch;
}
.qa-list-scroll::-webkit-scrollbar {
width: 1ch;
}
.qa-list-scroll::-webkit-scrollbar-track {
background: transparent;
}
.qa-list-scroll::-webkit-scrollbar-thumb {
background-color: var(--dark-gray);
border-left: 1px solid transparent;
border-right: 1px solid transparent;
}
.qa-list-scroll::-webkit-scrollbar-thumb:hover {
background-color: var(--light-gray);
}
.qa-stats {
color: var(--dark-gray);
text-align: center;
}
.qa-textarea {
width: 100%;
min-height: 2lh;
background-color: transparent;
border: none;
caret-color: var(--white);
caret-shape: block;
color: var(--light-gray);
padding: 0;
overflow-y: auto;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.qa-textarea::placeholder {
color: var(--dark-gray);
}
.qa-input-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1ch;
pointer-events: auto;
}
.qa-bar-text {
color: var(--dark-gray);
}
.qa-button {
border: none;
padding: 0;
color: var(--yellow);
background: transparent;
font-family: inherit;
font-size: inherit;
line-height: inherit;
cursor: pointer;
pointer-events: auto;
}
.qa-button:hover {
background-color: var(--yellow);
color: var(--black);
}
.qa-button-message:hover {
background: transparent;
}
.qa-button:disabled {
color: var(--dark-gray);
cursor: wait;
}
.qa-button:disabled:hover {
background: transparent;
color: var(--dark-gray);
}
.qa-meta {
margin-top: 0.5ch;
color: var(--dark-gray);
}
.qa-item {
position: relative;
}
.qa-list-item {
border-top: none;
}
.qa-list-item + .qa-list-item {
margin-top: 1.5ch;
padding-top: 1.5ch;
border-top: 2px solid var(--white);
}
.qa-list-label {
margin-bottom: 1ch;
color: var(--white);
}
.qa-item-meta {
position: absolute;
top: 0;
right: 0;
display: none;
padding: 0.75ch 1ch;
background-color: rgba(0, 0, 0, 0.9);
box-shadow: inset 0 0 0 1px var(--dark-gray);
color: var(--light-gray);
z-index: 5;
}
.qa-item:hover .qa-item-meta,
.qa-item:focus-within .qa-item-meta,
.qa-item:focus .qa-item-meta {
display: block;
}
.qa-inline-action {
border: none;
background: transparent;
color: var(--light-blue);
padding: 0;
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2ch;
}
.qa-inline-action:hover,
.qa-inline-action:focus-visible {
background-color: var(--light-blue);
color: var(--black);
}
.qa-textarea::selection {
background-color: var(--light-blue);
color: var(--black);
}
.qa-textarea:focus {
outline: none;
}
.site-footer-inner {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 1ch;
color: var(--dark-gray);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Project markdown content */

25
src/trpc/query-client.ts Normal file
View file

@ -0,0 +1,25 @@
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});

78
src/trpc/react.tsx Normal file
View file

@ -0,0 +1,78 @@
"use client";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { httpBatchStreamLink, loggerLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { type AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
// Server: always make a new query client
return createQueryClient();
}
// Browser: use singleton pattern to keep the same query client
clientQueryClientSingleton ??= createQueryClient();
return clientQueryClientSingleton;
};
export const api = createTRPCReact<AppRouter>();
/**
* Inference helper for inputs.
*
* @example type HelloInput = RouterInputs['example']['hello']
*/
export type RouterInputs = inferRouterInputs<AppRouter>;
/**
* Inference helper for outputs.
*
* @example type HelloOutput = RouterOutputs['example']['hello']
*/
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}

30
src/trpc/server.ts Normal file
View file

@ -0,0 +1,30 @@
import "server-only";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { headers } from "next/headers";
import { cache } from "react";
import { createCaller, type AppRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";
import { createQueryClient } from "./query-client";
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
});
});
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient
);

88
start-database.sh Executable file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Use this script to start a docker container for a local development database
# TO RUN ON WINDOWS:
# 1. Install WSL (Windows Subsystem for Linux) - https://learn.microsoft.com/en-us/windows/wsl/install
# 2. Install Docker Desktop or Podman Deskop
# - Docker Desktop for Windows - https://docs.docker.com/docker-for-windows/install/
# - Podman Desktop - https://podman.io/getting-started/installation
# 3. Open WSL - `wsl`
# 4. Run this script - `./start-database.sh`
# On Linux and macOS you can run this script directly - `./start-database.sh`
# import env variables from .env
set -a
source .env
DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}')
DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'\/' '{print $1}')
DB_NAME=$(echo "$DATABASE_URL" | awk -F'/' '{print $4}')
DB_CONTAINER_NAME="$DB_NAME-postgres"
if ! [ -x "$(command -v docker)" ] && ! [ -x "$(command -v podman)" ]; then
echo -e "Docker or Podman is not installed. Please install docker or podman and try again.\nDocker install guide: https://docs.docker.com/engine/install/\nPodman install guide: https://podman.io/getting-started/installation"
exit 1
fi
# determine which docker command to use
if [ -x "$(command -v docker)" ]; then
DOCKER_CMD="docker"
elif [ -x "$(command -v podman)" ]; then
DOCKER_CMD="podman"
fi
if ! $DOCKER_CMD info > /dev/null 2>&1; then
echo "$DOCKER_CMD daemon is not running. Please start $DOCKER_CMD and try again."
exit 1
fi
if command -v nc >/dev/null 2>&1; then
if nc -z localhost "$DB_PORT" 2>/dev/null; then
echo "Port $DB_PORT is already in use."
exit 1
fi
else
echo "Warning: Unable to check if port $DB_PORT is already in use (netcat not installed)"
read -p "Do you want to continue anyway? [y/N]: " -r REPLY
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Aborting."
exit 1
fi
fi
if [ "$($DOCKER_CMD ps -q -f name=$DB_CONTAINER_NAME)" ]; then
echo "Database container '$DB_CONTAINER_NAME' already running"
exit 0
fi
if [ "$($DOCKER_CMD ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
$DOCKER_CMD start "$DB_CONTAINER_NAME"
echo "Existing database container '$DB_CONTAINER_NAME' started"
exit 0
fi
if [ "$DB_PASSWORD" = "password" ]; then
echo "You are using the default database password"
read -p "Should we generate a random password for you? [y/N]: " -r REPLY
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Please change the default password in the .env file and try again"
exit 1
fi
# Generate a random URL-safe password
DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
if [[ "$(uname)" == "Darwin" ]]; then
# macOS requires an empty string to be passed with the `i` flag
sed -i '' "s#:password@#:$DB_PASSWORD@#" .env
else
sed -i "s#:password@#:$DB_PASSWORD@#" .env
fi
fi
$DOCKER_CMD run -d \
--name $DB_CONTAINER_NAME \
-e POSTGRES_USER="postgres" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB="$DB_NAME" \
-p "$DB_PORT":5432 \
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"

View file

@ -1,17 +1,42 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", /* Base Options: */
"module": "ESNext", "esModuleInterop": true,
"moduleResolution": "bundler",
"strict": true,
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true, "skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
/* Strictness */
"strict": true,
"noUncheckedIndexedAccess": true,
"checkJs": true,
/* Bundled projects */
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"plugins": [{ "name": "next" }],
"incremental": true,
/* Path Aliases */
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"]
} }
}, },
"include": ["src", "vite.config.ts", "vite-plugin-ansi.ts"], "include": [
"exclude": ["node_modules", "dist"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules", "generated", "cgol/pkg"]
} }

View file

@ -1,132 +0,0 @@
import Anser, { type AnserJsonEntry } from "anser";
import { escapeCarriageReturn } from "escape-carriage";
import fs from "node:fs";
import type { Plugin } from "vite";
const colorMap: Record<string, string> = {
"ansi-black": "color: var(--black)",
"ansi-red": "color: var(--red)",
"ansi-green": "color: var(--green)",
"ansi-yellow": "color: var(--brown)",
"ansi-blue": "color: var(--blue)",
"ansi-magenta": "color: var(--magenta)",
"ansi-cyan": "color: var(--cyan)",
"ansi-white": "color: var(--light-gray)",
"ansi-bright-black": "color: var(--dark-gray)",
"ansi-bright-red": "color: var(--light-red)",
"ansi-bright-green": "color: var(--light-green)",
"ansi-bright-yellow": "color: var(--yellow)",
"ansi-bright-blue": "color: var(--light-blue)",
"ansi-bright-magenta": "color: var(--light-magenta)",
"ansi-bright-cyan": "color: var(--light-cyan)",
"ansi-bright-white": "color: var(--white)",
};
const bgColorMap: Record<string, string> = {
"ansi-black": "background-color: transparent",
"ansi-red": "background-color: var(--red)",
"ansi-green": "background-color: var(--green)",
"ansi-yellow": "background-color: var(--brown)",
"ansi-blue": "background-color: var(--blue)",
"ansi-magenta": "background-color: var(--magenta)",
"ansi-cyan": "background-color: var(--cyan)",
"ansi-white": "background-color: var(--light-gray)",
"ansi-bright-black": "background-color: var(--dark-gray)",
"ansi-bright-red": "background-color: var(--light-red)",
"ansi-bright-green": "background-color: var(--light-green)",
"ansi-bright-yellow": "background-color: var(--yellow)",
"ansi-bright-blue": "background-color: var(--light-blue)",
"ansi-bright-magenta": "background-color: var(--light-magenta)",
"ansi-bright-cyan": "background-color: var(--light-cyan)",
"ansi-bright-white": "background-color: var(--white)",
};
const decorationMap: Record<string, string> = {
bold: "font-weight: 700",
dim: "opacity: 0.5",
italic: "font-style: italic",
hidden: "visibility: hidden",
strikethrough: "text-decoration: line-through",
underline: "text-decoration: underline",
blink: "animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
};
function fixBackspace(txt: string): string {
let tmp = txt;
do {
txt = tmp;
tmp = txt.replace(/[^\n]\x08/gm, "");
} while (tmp.length < txt.length);
return txt;
}
function createStyle(bundle: AnserJsonEntry): string | null {
const declarations: string[] = [];
if (bundle.bg && bgColorMap[bundle.bg]) {
declarations.push(bgColorMap[bundle.bg]!);
}
if (bundle.fg && colorMap[bundle.fg]) {
declarations.push(colorMap[bundle.fg]!);
}
if (bundle.decoration && decorationMap[bundle.decoration]) {
declarations.push(decorationMap[bundle.decoration]!);
}
return declarations.length ? declarations.join("; ") : null;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderAnsiToHtml(raw: string): string {
const input = escapeCarriageReturn(fixBackspace(raw));
const bundles = Anser.ansiToJson(input, {
json: true,
remove_empty: true,
use_classes: true,
});
const spans = bundles
.map((bundle) => {
const style = createStyle(bundle);
const content = escapeHtml(bundle.content);
if (style) {
return `<span style="${style}">${content}</span>`;
}
return `<span>${content}</span>`;
})
.join("");
return `<div class="flex justify-center"><pre style="text-align:left"><code>${spans}</code></pre></div>`;
}
const ANSI_SUFFIX = "?ansi";
export default function ansiPlugin(): Plugin {
return {
name: "vite-plugin-ansi",
enforce: "pre",
async resolveId(source, importer, options) {
if (!source.endsWith(ANSI_SUFFIX)) return;
const bare = source.slice(0, -ANSI_SUFFIX.length);
const resolved = await this.resolve(bare, importer, {
...options,
skipSelf: true,
});
if (resolved) {
return resolved.id + ANSI_SUFFIX;
}
},
load(id) {
if (!id.endsWith(ANSI_SUFFIX)) return;
const filePath = id.slice(0, -ANSI_SUFFIX.length);
const raw = fs.readFileSync(filePath, "utf-8");
const html = renderAnsiToHtml(raw);
return `export default ${JSON.stringify(html)};`;
},
};
}

View file

@ -1,37 +0,0 @@
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import { viteSingleFile } from "vite-plugin-singlefile";
import ansi from "./vite-plugin-ansi";
export default defineConfig({
plugins: [
ansi(),
tailwindcss(),
viteSingleFile({ useRecommendedBuildConfig: false }),
],
resolve: {
alias: {
"~": "/src",
},
},
server: {
headers: {
"Cache-Control": "no-store",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
preview: {
headers: {
"Cache-Control": "no-store",
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
},
},
build: {
target: "esnext",
assetsInlineLimit: 0,
cssCodeSplit: false,
cssMinify: false,
},
});