From 67b3a770b84feb69e1b75e5a4df1d9044b48b05c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:42:00 +0000 Subject: [PATCH] Rewrite frontend in Rust + ratatui + WASM (Ratzilla) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing page is now a terminal UI rendered in the browser: a Rust crate in frontend/ draws the whole page with ratatui through Ratzilla's DOM backend, compiled to wasm32-unknown-unknown. Same behaviour as the old index.html — pre-open countdown to registration, countdown to the event once open, the /api/count seats gauge with waitlist/full states, the how-to steps and the info cells — and memory safe. The built artifacts (frontend/dist: index.html shell + wasm-bindgen output) are committed and embedded by the Go binary, so go build, CI and the Docker image need no Rust toolchain. Rebuild with 'make frontend' (scripts/build-frontend.sh). Server: the two asset paths are whitelisted by exact name (serveStatic still takes no path input, so STATIC_DIR cannot leak), and the CSP gains 'wasm-unsafe-eval' so the browser may compile the module. CI gains a frontend job (fmt + clippy + wasm build). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T3hKpyUVosNCRUZVJnB883 --- .dockerignore | 1 + .github/workflows/ci.yml | 17 + .gitignore | 6 + Makefile | 7 +- README.md | 20 +- docs/screenshot-closed.png | Bin 0 -> 174403 bytes docs/screenshot-open.png | Bin 0 -> 216671 bytes frontend/Cargo.lock | 848 +++++++++++++++++++++++++++++++++ frontend/Cargo.toml | 28 ++ frontend/dist/dday_web.js | 812 +++++++++++++++++++++++++++++++ frontend/dist/dday_web_bg.wasm | Bin 0 -> 392080 bytes frontend/dist/index.html | 39 ++ frontend/index.html | 39 ++ frontend/src/lib.rs | 595 +++++++++++++++++++++++ index.html | 345 -------------- main.go | 14 +- main_test.go | 2 +- scripts/build-frontend.sh | 35 ++ server.go | 28 +- 19 files changed, 2474 insertions(+), 362 deletions(-) create mode 100644 docs/screenshot-closed.png create mode 100644 docs/screenshot-open.png create mode 100644 frontend/Cargo.lock create mode 100644 frontend/Cargo.toml create mode 100644 frontend/dist/dday_web.js create mode 100644 frontend/dist/dday_web_bg.wasm create mode 100644 frontend/dist/index.html create mode 100644 frontend/index.html create mode 100644 frontend/src/lib.rs delete mode 100644 index.html create mode 100755 scripts/build-frontend.sh diff --git a/.dockerignore b/.dockerignore index 08bd5ee..9bd9084 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,3 +14,4 @@ config/*_ed25519.pub dday *.out *.db +frontend/target diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6640174..10a9a8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,23 @@ jobs: - name: test (race) run: go test -race ./... + # The committed frontend/dist artifacts are what ships; this job only proves + # the Rust frontend still compiles (fmt + clippy + wasm build). + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: rustup target add wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + workspaces: frontend + - name: cargo fmt + run: cargo fmt --manifest-path frontend/Cargo.toml --check + - name: cargo clippy + run: cargo clippy --manifest-path frontend/Cargo.toml --target wasm32-unknown-unknown -- -D warnings + - name: build (wasm32) + run: cargo build --manifest-path frontend/Cargo.toml --release --target wasm32-unknown-unknown + docker: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index d55b433..41521a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,14 @@ /dday *.out +# Rust frontend build tree (frontend/dist IS committed — the Go binary embeds it) +/frontend/target/ + # Secrets — never commit matrix.env .env /config/*_ed25519 /config/*_ed25519.pub + +# Local SQLite DB from dev runs +/dday.db* diff --git a/Makefile b/Makefile index 7634efe..98f7f52 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ IMAGE ?= dday:latest PORT ?= 3329 MATRIX_ENV ?= matrix.env -.PHONY: help run dev build docker up down logs fmt vet tidy test keys \ +.PHONY: help run dev build frontend docker up down logs fmt vet tidy test keys \ check check-keys matrix-hello matrix-send bot help: ## Show this help @@ -12,9 +12,12 @@ help: ## Show this help run: ## Run the web server locally on $(PORT) PORT=$(PORT) go run . -dev: ## Run the web server serving index.html from disk (live edits) +dev: ## Run the web server serving frontend/dist from disk (rebuild via `make frontend`) PORT=$(PORT) STATIC_DIR=. go run . +frontend: ## Rebuild the Rust+ratatui WASM frontend into frontend/dist (committed) + ./scripts/build-frontend.sh + build: ## Build the web server binary CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o dday . diff --git a/README.md b/README.md index f411329..ec585f4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # D-Day -Statyczny landing page unconference **D-Day**. +Landing page unconference **D-Day** — terminal w przeglądarce: Rust + [ratatui](https://ratatui.rs) + WebAssembly ([Ratzilla](https://github.com/ratatui/ratzilla)). Memory safe ✨ - **Kiedy:** sobota 8 sierpnia 2026, 14:00–22:00 - **Gdzie:** Hakierspejs, Zielona 30 LU3, Łódź @@ -9,9 +9,23 @@ Statyczny landing page unconference **D-Day**. ## Rozwój -Pojedynczy plik `index.html`, bez zależności. Otwórz lokalnie w przeglądarce albo hostuj przez GitHub Pages. +Frontend mieszka w `frontend/` — crate Rust renderujący cały interfejs jako TUI +(ratatui) do DOM przez Ratzillę. Zbudowane artefakty (`frontend/dist/`: +`index.html`, `dday_web.js`, `dday_web_bg.wasm`) **są commitowane** — binarka Go +je embeduje, więc `go build` i obraz Dockera nie wymagają toolchaina Rusta. -Link do formularza zapisów podmień w `index.html` (szukaj `TODO` w sekcji ` + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..783ebdb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,39 @@ + + + + + +D-Day · Unconference · Hakierspejs Łódź + + + + + + + + + + + diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs new file mode 100644 index 0000000..abb59e4 --- /dev/null +++ b/frontend/src/lib.rs @@ -0,0 +1,595 @@ +//! D-Day landing page as a terminal UI in the browser. +//! +//! Rust + ratatui rendered to the DOM via Ratzilla, compiled to WebAssembly — +//! the whole frontend is memory safe. It mirrors the behaviour of the old +//! index.html: a pre-open countdown to registration, then a countdown to the +//! event, the registered-seats gauge fed by GET /api/count, the how-to-register +//! steps and the info cells. + +use std::cell::RefCell; +use std::rc::Rc; + +// Link the std implementation of the critical-section API that ratatui-core +// declares; without it the wasm module keeps unresolved `env` imports. +use critical_section as _; + +use ratzilla::ratatui::{ + layout::{Alignment, Constraint, Flex, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Gauge, Paragraph}, + Frame, Terminal, +}; +use ratzilla::widgets::Hyperlink; +use ratzilla::{DomBackend, WebRenderer}; +use serde::Deserialize; +use wasm_bindgen::prelude::*; + +// Two countdown targets. Both mirror internal/regwindow (OpenMoment) and the +// event date shown in the info cells — keep them in sync when either changes. +// REG_OPEN_MS — registration opens: niedziela 26 lipca 2026, 15:00 Europe/Warsaw +// EVENT_MS — D-Day starts: sobota 8 sierpnia 2026, 14:00 Europe/Warsaw +// Both moments fall in CEST (UTC+2), so the UTC instants are 2h earlier. +const REG_OPEN_MS: f64 = 1785070800000.0; // 2026-07-26 13:00:00 UTC +const EVENT_MS: f64 = 1786190400000.0; // 2026-08-08 12:00:00 UTC + +// Palette — same values as the old CSS custom properties. +const FG: Color = Color::Rgb(0xe6, 0xed, 0xf3); +const MUTED: Color = Color::Rgb(0x8b, 0x98, 0xa5); +const ACCENT: Color = Color::Rgb(0x39, 0xd3, 0x53); +const ACCENT2: Color = Color::Rgb(0x2d, 0xd4, 0xbf); +const BORDER: Color = Color::Rgb(0x2a, 0x33, 0x40); +const FULL_RED: Color = Color::Rgb(0xf8, 0x51, 0x49); + +const BOT_URL: &str = "https://matrix.to/#/@ddaybot:matrix.org"; +const ROOM_URL: &str = "https://matrix.to/#/#hs-ldz-dday:matrix.org"; + +/// GET /api/count response. Fields absent in older API versions stay None and +/// are derived from `count`, exactly like the old JS did. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct CountResponse { + count: i64, + limit: i64, + waitlist: i64, + confirmed: Option, + #[serde(rename = "waitlistCount")] + waitlist_count: Option, + full: Option, + open: Option, +} + +/// Shared app state: filled in by the /api/count fetch, read every frame. +#[derive(Default)] +struct App { + /// None until /api/count answers (or on static hosting, forever); then the + /// authoritative open flag. The clock alone decides while it is None. + api_open: Option, + /// Seat counts from /api/count; None while loading or when the API is + /// absent (static hosting) — the seats section then shows placeholders. + seats: Option, +} + +/// Normalized seat counts, derived once from the API response. +struct Seats { + confirmed: i64, + limit: i64, + waitlist_count: i64, + waitlist_limit: i64, + full: bool, +} + +impl Seats { + fn from_response(j: &CountResponse) -> Self { + let lim = if j.limit > 0 { j.limit } else { 20 }; + let wl = if j.waitlist > 0 { j.waitlist } else { 20 }; + Seats { + confirmed: j.confirmed.unwrap_or_else(|| j.count.min(lim)), + limit: lim, + waitlist_count: j.waitlist_count.unwrap_or_else(|| (j.count - lim).max(0)), + waitlist_limit: wl, + full: j.full.unwrap_or(j.count >= lim + wl), + } + } +} + +#[wasm_bindgen(start)] +pub fn start() -> Result<(), JsValue> { + console_error_panic_hook::set_once(); + + let app = Rc::new(RefCell::new(App::default())); + + // Registered-seats counter — pulls same-origin /api/count (allowed by CSP + // connect-src 'self'). On static hosting the endpoint is absent: the seats + // section degrades to a "—" placeholder, never an error in the UI. + { + let app = app.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(j) = fetch_count().await { + let mut a = app.borrow_mut(); + a.api_open = j.open; + a.seats = Some(Seats::from_response(&j)); + } + }); + } + + let backend = DomBackend::new().map_err(|e| JsValue::from_str(&e.to_string()))?; + let terminal = Terminal::new(backend).map_err(|e| JsValue::from_str(&e.to_string()))?; + + terminal.draw_web(move |frame| { + let app = app.borrow(); + draw(frame, &app, js_sys::Date::now()); + }); + + Ok(()) +} + +async fn fetch_count() -> Result { + gloo_net::http::Request::get("/api/count") + .header("Accept", "application/json") + .send() + .await? + .json::() + .await +} + +/// One frame. Sections are laid out top-down into whatever height the browser +/// window gives us; sections that no longer fit are dropped from the bottom. +fn draw(frame: &mut Frame<'_>, app: &App, now_ms: f64) { + let area = frame.area(); + // Prefer the API's open flag; fall back to the clock when it's absent. + let open = app.api_open == Some(true) || now_ms >= REG_OPEN_MS; + + // Center a max-90-column page, like the old .wrap{max-width:900px}. + let width = area.width.min(90); + let [page] = Layout::horizontal([Constraint::Length(width)]) + .flex(Flex::Center) + .areas(area); + + let mut cursor = page; + // Allocates the next h rows; a section that only partially fits gets the + // remaining rows and renders clipped rather than disappearing entirely. + let mut take = |h: u16| -> Option { + if cursor.height == 0 { + return None; + } + let h = h.min(cursor.height); + let r = Rect { + height: h, + ..cursor + }; + cursor = Rect { + y: cursor.y + h, + height: cursor.height - h, + ..cursor + }; + Some(r) + }; + + if let Some(r) = take(HEADER_HEIGHT) { + draw_header(frame, r); + } + if let Some(r) = take(COUNTDOWN_HEIGHT) { + draw_countdown(frame, r, open, now_ms); + } + // No signups yet before opening — the seats section appears once open, + // and the "how to register" steps only make sense then, too. + if open { + if let Some(r) = take(SEATS_HEIGHT) { + draw_seats(frame, r, app); + } + if let Some(r) = take(HOWTO_HEIGHT) { + draw_howto(frame, r); + } + } + if let Some(r) = take(META_HEIGHT) { + draw_meta(frame, r, open); + } + if let Some(r) = take(FOOT_HEIGHT) { + draw_foot(frame, r); + } +} + +const HEADER_HEIGHT: u16 = 9; +const COUNTDOWN_HEIGHT: u16 = 11; +const SEATS_HEIGHT: u16 = 9; +const HOWTO_HEIGHT: u16 = 7; +const META_HEIGHT: u16 = 4; +const FOOT_HEIGHT: u16 = 5; + +/// ANSI-shadow "D-DAY" banner, 39 columns wide. +const BANNER: [&str; 6] = [ + "██████╗ ██████╗ █████╗ ██╗ ██╗", + "██╔══██╗ ██╔══██╗██╔══██╗╚██╗ ██╔╝", + "██║ ██║█████╗██║ ██║███████║ ╚████╔╝ ", + "██║ ██║╚════╝██║ ██║██╔══██║ ╚██╔╝ ", + "██████╔╝ ██████╔╝██║ ██║ ██║ ", + "╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ", +]; + +fn draw_header(frame: &mut Frame<'_>, area: Rect) { + let mut lines: Vec = vec![Line::default()]; // top margin row + lines.extend( + BANNER + .iter() + .map(|row| Line::from(Span::styled(*row, Style::new().fg(ACCENT)))), + ); + lines.push(Line::default()); + lines.push(Line::from(vec![ + Span::styled( + "UNCONFERENCE · HAKIERSPEJS ŁÓDŹ", + Style::new().fg(MUTED).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("[ 8 SIERPNIA 2026 ]", Style::new().fg(ACCENT)), + ])); + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + +/// Seven-segment style digit font, 3 rows high, 3 columns wide. +fn seg(c: char) -> [&'static str; 3] { + match c { + '0' => [" _ ", "| |", "|_|"], + '1' => [" ", " |", " |"], + '2' => [" _ ", " _|", "|_ "], + '3' => [" _ ", " _|", " _|"], + '4' => [" ", "|_|", " |"], + '5' => [" _ ", "|_ ", " _|"], + '6' => [" _ ", "|_ ", "|_|"], + '7' => [" _ ", " |", " |"], + '8' => [" _ ", "|_|", "|_|"], + '9' => [" _ ", "|_|", " _|"], + _ => [" ", " ", " "], + } +} + +/// Renders a digit string in the 3-row segment font, one string per row. +fn big_digits(s: &str) -> [String; 3] { + let mut rows = [String::new(), String::new(), String::new()]; + for (i, c) in s.chars().enumerate() { + let glyph = seg(c); + for (row, part) in rows.iter_mut().zip(glyph) { + if i > 0 { + row.push(' '); + } + row.push_str(part); + } + } + rows +} + +fn draw_countdown(frame: &mut Frame<'_>, area: Rect, open: bool, now_ms: f64) { + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(BORDER)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let (label_spans, target): (Vec, Option) = if !open { + // Before registration opens — count down to REG_OPEN. + ( + vec![Span::styled("ZAPISY STARTUJĄ ZA", Style::new().fg(MUTED))], + Some(REG_OPEN_MS), + ) + } else if now_ms < EVENT_MS { + // Registration is open — count down to the start of the event. + ( + vec![ + Span::styled("DO STARTU ", Style::new().fg(MUTED)), + Span::styled( + "D-DAY", + Style::new().fg(ACCENT).add_modifier(Modifier::BOLD), + ), + ], + Some(EVENT_MS), + ) + } else { + // Event under way — drop the countdown digits. + ( + vec![Span::styled( + "D-DAY TRWA!", + Style::new().fg(ACCENT).add_modifier(Modifier::BOLD), + )], + None, + ) + }; + + let mut lines = vec![Line::from(label_spans), Line::default()]; + + if let Some(t) = target { + let diff = (((t - now_ms) / 1000.0).floor() as i64).max(0); + let (d, h, m, s) = ( + diff / 86400, + diff % 86400 / 3600, + diff % 3600 / 60, + diff % 60, + ); + let groups = [ + (format!("{d:02}"), "DNI"), + (format!("{h:02}"), "GODZ"), + (format!("{m:02}"), "MIN"), + (format!("{s:02}"), "SEK"), + ]; + let rendered: Vec<[String; 3]> = groups.iter().map(|(n, _)| big_digits(n)).collect(); + for row in 0..3 { + let mut spans = Vec::new(); + for (i, g) in rendered.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" · ", Style::new().fg(BORDER))); + } + spans.push(Span::styled( + g[row].clone(), + Style::new().fg(FG).add_modifier(Modifier::BOLD), + )); + } + lines.push(Line::from(spans)); + } + let mut label_line = Vec::new(); + for (i, (_, l)) in groups.iter().enumerate() { + if i > 0 { + label_line.push(Span::raw(" ")); + } + label_line.push(Span::styled(format!("{l:^7}"), Style::new().fg(MUTED))); + } + lines.push(Line::from(label_line)); + lines.push(Line::default()); + } + + // CTA: disabled placeholder before opening, live Matrix link once open. + if open { + lines.push(Line::from(Span::styled( + "ZAPISZ SIĘ PRZEZ MATRIX ↓", + Style::new().fg(ACCENT).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::default()); // last inner row: the hyperlink below + } else { + lines.push(Line::from(Span::styled( + "[ ZAPISY WKRÓTCE ]", + Style::new().fg(MUTED), + ))); + } + + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner); + + // The clickable link sits on the last inner row, centered by hand because + // Hyperlink renders the raw anchor text. + if open { + link_centered( + frame, + inner, + inner.height.saturating_sub(1), + BOT_URL, + ACCENT2, + ); + } +} + +/// Renders a Hyperlink centered on the given row of `area`. +fn link_centered(frame: &mut Frame<'_>, area: Rect, row: u16, url: &str, color: Color) { + let w = url.chars().count() as u16; + if row >= area.height || w > area.width { + return; + } + let rect = Rect { + x: area.x + (area.width - w) / 2, + y: area.y + row, + width: w, + height: 1, + }; + frame.render_widget( + Hyperlink::new(Span::styled(url, Style::new().fg(color))), + rect, + ); +} + +fn draw_seats(frame: &mut Frame<'_>, area: Rect, app: &App) { + let full = app.seats.as_ref().is_some_and(|s| s.full); + let accent = if full { FULL_RED } else { ACCENT }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(BORDER)) + .title(Line::from(Span::styled( + " ZAPISANYCH NA D-DAY ", + Style::new().fg(MUTED), + ))) + .title_alignment(Alignment::Center); + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + match &app.seats { + Some(s) if s.full => { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "█ K O M P L E T █", + Style::new().fg(FULL_RED).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + format!( + "Brak miejsc — {} uczestników + {} rezerwowych wyczerpane", + s.limit, s.waitlist_limit + ), + Style::new().fg(FULL_RED), + ))); + } + Some(s) => { + for row in big_digits(&s.confirmed.to_string()) { + lines.push(Line::from(Span::styled( + row, + Style::new().fg(accent).add_modifier(Modifier::BOLD), + ))); + } + lines.push(Line::from(vec![ + Span::styled( + format!("{}", s.confirmed), + Style::new().fg(accent).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" / {} miejsc", s.limit), Style::new().fg(FG)), + ])); + if s.waitlist_count > 0 { + lines.push(Line::from(Span::styled( + format!( + "Lista rezerwowa: {} / {}", + s.waitlist_count, s.waitlist_limit + ), + Style::new().fg(ACCENT2).add_modifier(Modifier::BOLD), + ))); + } + let note = if s.confirmed >= s.limit { + "Miejsca podstawowe zajęte — trwają zapisy na listę rezerwową".to_string() + } else { + format!("{} miejsc + {} rezerwowych", s.limit, s.waitlist_limit) + }; + lines.push(Line::from(Span::styled(note, Style::new().fg(MUTED)))); + } + None => { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "—", + Style::new().fg(MUTED).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + "— / 20 miejsc", + Style::new().fg(FG), + ))); + lines.push(Line::from(Span::styled( + "20 miejsc + 20 rezerwowych", + Style::new().fg(MUTED), + ))); + } + } + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner); + + // Progress bar on the last inner row, like the old .bar element. + if let Some(s) = &app.seats { + let ratio = if s.full { + 1.0 + } else if s.limit > 0 { + (s.confirmed as f64 / s.limit as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + let bar_w = inner.width.min(46); + let bar = Rect { + x: inner.x + (inner.width - bar_w) / 2, + y: inner.y + inner.height.saturating_sub(1), + width: bar_w, + height: 1, + }; + frame.render_widget( + Gauge::default() + .ratio(ratio) + .label("") + .gauge_style(Style::new().fg(accent).bg(Color::Rgb(0x0d, 0x14, 0x20))), + bar, + ); + } +} + +fn draw_howto(frame: &mut Frame<'_>, area: Rect) { + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(BORDER)) + .title(Line::from(Span::styled( + " JAK SIĘ ZAPISAĆ ", + Style::new().fg(ACCENT), + ))); + let inner = block.inner(area); + frame.render_widget(block, area); + + let step = |n: &str, spans: Vec>| { + let mut v = vec![Span::styled( + format!(" {n} "), + Style::new().fg(Color::Rgb(0x04, 0x14, 0x0a)).bg(ACCENT), + )]; + v.push(Span::raw(" ")); + v.extend(spans); + Line::from(v) + }; + let lines = + vec![ + step( + "1", + vec![ + Span::styled("Napisz ", Style::new().fg(FG)), + Span::styled("!register", Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)), + Span::styled(" do bota ", Style::new().fg(FG)), + Span::styled("@ddaybot:matrix.org", Style::new().fg(ACCENT2)), + Span::styled(" (albo na czacie ", Style::new().fg(FG)), + Span::styled("#hs-ldz-dday", Style::new().fg(ACCENT2)), + Span::styled(")", Style::new().fg(FG)), + ], + ), + step( + "2", + vec![Span::styled( + "Bot odeśle Ci prywatny link do formularza (miejscowość + e-mail)", + Style::new().fg(FG), + )], + ), + step( + "3", + vec![Span::styled( + "Wypełnij formularz — dostaniesz swój numer uczestnika", + Style::new().fg(FG), + )], + ), + Line::from(Span::styled( + "20 miejsc podstawowych + 20 na liście rezerwowej. Start: nd 26 lipca, 15:00 czasu PL.", + Style::new().fg(MUTED), + )), + ]; + frame.render_widget(Paragraph::new(lines), inner); +} + +fn draw_meta(frame: &mut Frame<'_>, area: Rect, open: bool) { + // Once registration is open the "Zapisy od" cell is redundant — drop it. + let all: [(&str, &str, &str); 4] = [ + ("KIEDY", "8 sierpnia", "sob, 14:00 – 22:00"), + ("GDZIE", "Hakierspejs", "Zielona 30 LU3, Łódź"), + ("WSTĘP", "Darmowy", "zapisy obowiązkowe"), + ("ZAPISY OD", "26 lipca", "nd, 15:00 czasu PL"), + ]; + let cells = if open { &all[..3] } else { &all[..] }; + let constraints = vec![Constraint::Ratio(1, cells.len() as u32); cells.len()]; + let rects = Layout::horizontal(constraints).split(area); + for ((k, v, sub), r) in cells.iter().zip(rects.iter()) { + let lines = vec![ + Line::from(vec![ + Span::styled(*k, Style::new().fg(MUTED)), + Span::raw(" "), + Span::styled(*v, Style::new().fg(FG).add_modifier(Modifier::BOLD)), + ]), + Line::from(Span::styled(*sub, Style::new().fg(MUTED))), + ]; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(BORDER)); + let inner = block.inner(*r); + frame.render_widget(block, *r); + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner); + } +} + +fn draw_foot(frame: &mut Frame<'_>, area: Rect) { + let lines = vec![ + Line::from(Span::styled( + "Nieformalny format — agenda tworzona na miejscu przez uczestników.", + Style::new().fg(MUTED), + )), + Line::from(Span::styled( + "Przyjdź z tematem albo po prostu posłuchaj.", + Style::new().fg(MUTED), + )), + Line::default(), // row 2: Matrix chat hyperlink, rendered below + Line::default(), // row 3: privacy hyperlink, rendered below + Line::from(Span::styled( + "#dday · #unconference · #hakierspejs", + Style::new().fg(MUTED).add_modifier(Modifier::DIM), + )), + ]; + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); + link_centered(frame, area, 2, ROOM_URL, ACCENT2); + link_centered(frame, area, 3, "/privacy", ACCENT2); +} diff --git a/index.html b/index.html deleted file mode 100644 index 85f8ecd..0000000 --- a/index.html +++ /dev/null @@ -1,345 +0,0 @@ - - - - - -D-Day · Unconference · Hakierspejs Łódź - - - - - - - -
-
-
-

D-Day

- Unconference · Hakierspejs Łódź -
- 8 sierpnia 2026 -
- - -
-

Zapisy startują za

-
-
--
dni
-
--
godz
-
--
min
-
--
sek
-
- -
- -
-
Zapisanych na D-Day
-
-
/ 20 miejsc
-
- -
20 miejsc + 20 rezerwowych
-
- -
-
Jak się zapisać
-
    -
  1. Napisz !register do bota @ddaybot:matrix.org (albo na czacie #hs-ldz-dday).
  2. -
  3. Bot odeśle Ci prywatny link do formularza (miejscowość + e-mail).
  4. -
  5. Wypełnij formularz — dostaniesz swój numer uczestnika.
  6. -
-

20 miejsc podstawowych + 20 na liście rezerwowej. Zapisy startują w niedzielę 26 lipca, 15:00 czasu PL.

-
- -
-
Kiedy
Sob, 8 sierpnia14:00 – 22:00
-
Gdzie
HakierspejsZielona 30 LU3, Łódź
-
Wstęp
Darmowyzapisy obowiązkowe
-
Zapisy od
Nd 26 lipca15:00 czasu PL
-
- -
-
Nieformalny format — agenda tworzona na miejscu przez uczestników. Przyjdź z tematem albo po prostu posłuchaj.
- 💬 Czat na Matrix -
- -

#dday · #unconference · #hakierspejs

- -
- - - - diff --git a/main.go b/main.go index 15beb57..7856a67 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,10 @@ // Command dday serves the D-Day landing page and the event registration flow. // -// It embeds index.html and privacy.html into the binary, so the container is -// fully self-contained. Set STATIC_DIR to serve static files from the -// filesystem instead (useful for live-editing during development). +// The landing page is a Rust+ratatui terminal UI compiled to WebAssembly +// (Ratzilla) — its built artifacts in frontend/dist plus privacy.html are +// embedded into the binary, so the container is fully self-contained. Set +// STATIC_DIR to serve static files from the filesystem instead (useful for +// live-editing during development; run scripts/build-frontend.sh to rebuild). // // Registration state is persisted in a pure-Go SQLite database (CGO off). package main @@ -30,7 +32,7 @@ import ( "filippo.io/age" ) -//go:embed index.html privacy.html +//go:embed privacy.html frontend/dist var embedded embed.FS // Capacity model: the first seatLimit registrations (numbers 1..seatLimit) are @@ -152,7 +154,9 @@ func secure(next http.Handler) http.Handler { h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "no-referrer") h.Set("Content-Security-Policy", - "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'") + // 'wasm-unsafe-eval' lets the browser compile the embedded + // WebAssembly frontend; everything else stays same-origin. + "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'") next.ServeHTTP(w, r) }) } diff --git a/main_test.go b/main_test.go index f5001c9..9eac29b 100644 --- a/main_test.go +++ b/main_test.go @@ -683,7 +683,7 @@ func TestApiRegistered(t *testing.T) { } // TestStaticDirDoesNotLeak simulates STATIC_DIR=. (repo root): the root handler -// must serve ONLY index.html for "/", and never expose secrets like matrix.env +// must serve ONLY the landing page for "/", and never expose secrets like matrix.env // or the age key via an arbitrary path (http.FileServer would have leaked them). func TestStaticDirDoesNotLeak(t *testing.T) { h := newMux(deps{ diff --git a/scripts/build-frontend.sh b/scripts/build-frontend.sh new file mode 100755 index 0000000..cf217f8 --- /dev/null +++ b/scripts/build-frontend.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Builds the Rust+ratatui (Ratzilla) WASM frontend into frontend/dist/, which +# the Go binary embeds and serves. Requires: +# rustup target add wasm32-unknown-unknown +# cargo install wasm-bindgen-cli --version +# +# The generated file names are fixed (dday_web.js / dday_web_bg.wasm) — the Go +# server whitelists exactly these paths, so keep them in sync with server.go. +set -eu + +cd "$(dirname "$0")/.." + +WB_VERSION="$(grep -A1 'name = "wasm-bindgen"' frontend/Cargo.lock | sed -n 's/^version = "\(.*\)"/\1/p')" +if command -v wasm-bindgen >/dev/null 2>&1; then + have="$(wasm-bindgen --version | awk '{print $2}')" + if [ "$have" != "$WB_VERSION" ]; then + echo "wasm-bindgen $have installed but $WB_VERSION required:" >&2 + echo " cargo install wasm-bindgen-cli --version $WB_VERSION --locked" >&2 + exit 1 + fi +else + echo "wasm-bindgen-cli missing:" >&2 + echo " cargo install wasm-bindgen-cli --version $WB_VERSION --locked" >&2 + exit 1 +fi + +cargo build --manifest-path frontend/Cargo.toml --release --target wasm32-unknown-unknown + +rm -rf frontend/dist +wasm-bindgen --target web --no-typescript \ + --out-dir frontend/dist --out-name dday_web \ + frontend/target/wasm32-unknown-unknown/release/dday_web.wasm +cp frontend/index.html frontend/dist/index.html + +ls -lh frontend/dist/ diff --git a/server.go b/server.go index 1c321ae..3953a11 100644 --- a/server.go +++ b/server.go @@ -21,7 +21,7 @@ import ( // openStart is the human-readable moment registration opens, shown on the // "closed"/"expired" pages. Sourced from regwindow so the web server, the bot -// and index.html all state the same date. Matches the countdown in index.html. +// and the WASM frontend (frontend/src/lib.rs) all state the same date. const openStart = regwindow.OpenStartText // tokenTTL bounds how long a registration link stays valid after it was issued. @@ -91,6 +91,11 @@ func newMux(d deps) http.Handler { _, _ = w.Write([]byte("ok\n")) }) + // The WASM frontend's two build artifacts, whitelisted by exact path — + // serveStatic still never takes path input, so STATIC_DIR cannot leak. + mux.HandleFunc("/dday_web.js", d.handleAsset("frontend/dist/dday_web.js", "text/javascript; charset=utf-8")) + mux.HandleFunc("/dday_web_bg.wasm", d.handleAsset("frontend/dist/dday_web_bg.wasm", "application/wasm")) + mux.HandleFunc("/register", d.handleRegister) mux.HandleFunc("/api/count", d.handleCount) mux.HandleFunc("/api/registered", d.handleRegistered) @@ -113,20 +118,31 @@ func (d deps) handleRoot(w http.ResponseWriter, r *http.Request) { methodNotAllowed(w) return } - d.serveStatic(w, "index.html") + d.serveStatic(w, "frontend/dist/index.html", "text/html; charset=utf-8") } -// serveStatic writes one of the fixed, known HTML files from d.files. Only the +// handleAsset serves one fixed frontend build artifact with its content type. +func (d deps) handleAsset(name, contentType string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w) + return + } + d.serveStatic(w, name, contentType) + } +} + +// serveStatic writes one of the fixed, known files from d.files. Only the // names the handlers reference can be served — there is no path input, so a // STATIC_DIR pointing at a directory with secrets cannot expose them. -func (d deps) serveStatic(w http.ResponseWriter, name string) { +func (d deps) serveStatic(w http.ResponseWriter, name, contentType string) { f, err := d.files.Open(name) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } defer f.Close() - w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Type", contentType) if _, err := io.Copy(w, f); err != nil { log.Printf("serve %s: %v", name, err) } @@ -369,7 +385,7 @@ func (d deps) handlePrivacy(w http.ResponseWriter, r *http.Request) { methodNotAllowed(w) return } - d.serveStatic(w, "privacy.html") + d.serveStatic(w, "privacy.html", "text/html; charset=utf-8") } // decode validates a token; on failure it writes a 400 page and returns ok=false.