feat: add rss feed and remove rev feature

This commit is contained in:
Jet 2026-03-26 18:15:43 -07:00
parent 3937d8fd75
commit 5356e2dbb4
No known key found for this signature in database
8 changed files with 268 additions and 13 deletions

180
api/Cargo.lock generated
View file

@ -20,6 +20,15 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@ -41,6 +50,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.8" version = "0.8.8"
@ -105,6 +120,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@ -127,6 +148,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]] [[package]]
name = "chumsky" name = "chumsky"
version = "0.9.3" version = "0.9.3"
@ -436,6 +468,30 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@ -568,6 +624,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64", "base64",
"chrono",
"lettre", "lettre",
"rusqlite", "rusqlite",
"serde", "serde",
@ -576,6 +633,16 @@ dependencies = [
"tower-http", "tower-http",
] ]
[[package]]
name = "js-sys"
version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@ -706,6 +773,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.37.3" version = "0.37.3"
@ -907,6 +983,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.23" version = "1.0.23"
@ -1287,6 +1369,51 @@ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [
"unicode-ident",
]
[[package]] [[package]]
name = "wasm-encoder" name = "wasm-encoder"
version = "0.244.0" version = "0.244.0"
@ -1321,12 +1448,65 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"

View file

@ -6,6 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
axum = "0.8" axum = "0.8"
base64 = "0.22" base64 = "0.22"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
lettre = "0.11" lettre = "0.11"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View file

@ -1,9 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use axum::extract::State; use axum::extract::State;
use axum::http::header::CONTENT_TYPE;
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::Json; use axum::Json;
use base64::Engine; use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@ -25,6 +28,22 @@ pub struct QuestionStats {
answered: i64, answered: i64,
} }
const SITE_URL: &str = "https://jetpham.com";
fn xml_escape(text: &str) -> String {
text.replace('&', "&")
.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( pub async fn get_questions(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Question>>, StatusCode> { ) -> Result<Json<Vec<Question>>, StatusCode> {
@ -80,6 +99,63 @@ pub async fn get_question_stats(
Ok(Json(QuestionStats { asked, answered })) 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)] #[derive(Deserialize)]
pub struct SubmitQuestion { pub struct SubmitQuestion {
question: String, question: String,

View file

@ -53,6 +53,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
get(handlers::get_questions).post(handlers::post_question), get(handlers::get_questions).post(handlers::post_question),
) )
.route("/api/questions/stats", get(handlers::get_question_stats)) .route("/api/questions/stats", get(handlers::get_question_stats))
.route("/qa/rss.xml", get(handlers::get_question_rss))
.route("/api/webhook", post(handlers::webhook)) .route("/api/webhook", post(handlers::webhook))
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(state); .with_state(state);

View file

@ -144,6 +144,10 @@ in
reverse_proxy 127.0.0.1:3003 reverse_proxy 127.0.0.1:3003
} }
handle /qa/rss.xml {
reverse_proxy 127.0.0.1:3003
}
handle { handle {
root * ${package} root * ${package}
try_files {path} /index.html try_files {path} /index.html
@ -161,6 +165,10 @@ in
reverse_proxy 127.0.0.1:3003 reverse_proxy 127.0.0.1:3003
} }
handle /qa/rss.xml {
reverse_proxy 127.0.0.1:3003
}
handle { handle {
root * ${package} root * ${package}
try_files {path} /index.html try_files {path} /index.html

2
src/global.d.ts vendored
View file

@ -1,7 +1,5 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __COMMIT_SHA__: string;
declare module "*.txt?raw" { declare module "*.txt?raw" {
const content: string; const content: string;
export default content; export default content;

View file

@ -1,7 +1,6 @@
const CLEARNET_HOST = "jetpham.com"; const CLEARNET_HOST = "jetpham.com";
const ONION_HOST = const ONION_HOST =
"jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion"; "jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion";
const COMMIT_BASE_URL = "https://git.extremist.software/jet/website/commit/";
const REPO_URL = "https://git.extremist.software/jet/website"; const REPO_URL = "https://git.extremist.software/jet/website";
function isOnionHost(hostname: string): boolean { function isOnionHost(hostname: string): boolean {
@ -26,17 +25,15 @@ export function renderFooter() {
const footer = document.getElementById("site-footer"); const footer = document.getElementById("site-footer");
if (!footer) return; if (!footer) return;
const commitSha = __COMMIT_SHA__;
const shortSha = commitSha.slice(0, 7);
const mirror = getMirrorLink(); const mirror = getMirrorLink();
footer.innerHTML = ` footer.innerHTML = `
<div class="site-panel px-[2ch] py-[1ch]"> <div class="site-panel px-[2ch] py-[1ch]">
<div class="site-footer-inner"> <div class="site-footer-inner">
<span>rev <a href="${COMMIT_BASE_URL}${commitSha}">${shortSha}</a></span>
<span aria-hidden="true">|</span>
<a href="${REPO_URL}">src</a> <a href="${REPO_URL}">src</a>
<span aria-hidden="true">|</span> <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> <a href="/pgp.txt" data-native-link>pgp</a>
<span aria-hidden="true">|</span> <span aria-hidden="true">|</span>
<a href="/ssh.txt" data-native-link>ssh</a> <a href="/ssh.txt" data-native-link>ssh</a>

View file

@ -1,5 +1,4 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { execSync } from "node:child_process";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import wasm from "vite-plugin-wasm"; import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await"; import topLevelAwait from "vite-plugin-top-level-await";
@ -7,12 +6,7 @@ import { viteSingleFile } from "vite-plugin-singlefile";
import ansi from "./vite-plugin-ansi"; import ansi from "./vite-plugin-ansi";
import markdown from "./vite-plugin-markdown"; import markdown from "./vite-plugin-markdown";
const commitSha = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
export default defineConfig({ export default defineConfig({
define: {
__COMMIT_SHA__: JSON.stringify(commitSha),
},
plugins: [ plugins: [
ansi(), ansi(),
markdown(), markdown(),