diff --git a/Cargo.lock b/Cargo.lock index de6765c..c1257d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -189,7 +189,7 @@ dependencies = [ [[package]] name = "backbone-lib" -version = "0.2.11" +version = "0.2.12" dependencies = [ "bytes", "ewebsock", @@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -926,6 +926,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2649,11 +2658,30 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.11" +version = "0.2.12" dependencies = [ "serde", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "qrcodegen" version = "1.8.0" @@ -2883,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relay-server" -version = "0.2.11" +version = "0.2.12" dependencies = [ "argon2", "axum", @@ -3893,7 +3921,7 @@ dependencies = [ [[package]] name = "trictrac-store" -version = "0.2.11" +version = "0.2.12" dependencies = [ "anyhow", "base64 0.21.7", @@ -3906,7 +3934,7 @@ dependencies = [ [[package]] name = "trictrac-web" -version = "0.2.11" +version = "0.2.12" dependencies = [ "backbone-lib", "futures", @@ -3918,6 +3946,7 @@ dependencies = [ "leptos", "leptos_i18n", "leptos_router", + "pulldown-cmark", "qrcodegen", "rand 0.9.4", "serde", @@ -4034,6 +4063,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4358,7 +4393,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d722f4f..a468cd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.2.12" +version = "0.2.13" [workspace] resolver = "2" diff --git a/README.md b/README.md index ca4c0de..f9485c7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ This is a game of [Trictrac](https://en.wikipedia.org/wiki/Trictrac) rust implementation. -The project is still on its early stages. - ## Usage Install [devenv](https://devenv.sh/getting-started/), start a devenv shell `devenv shell`, and run the following commands. @@ -17,118 +15,18 @@ just run-relay # listens on :8080 just dev ``` -Open two browser windows at `http://127.0.0.1:9091`. In one, create a room; in the other, join with the same room name. - -Playing with the cli against the 'random' bot: `cargo run --bin=client_cli -- --bot random` - -## Roadmap - -- [x] rules -- [x] command line interface -- [x] basic bot (random play) -- [ ] web client (in progress) -- [ ] network game (in progress) -- [ ] AI bot +Open a browser window at `http://127.0.0.1:9091`. You can play against a very basic bot, or invite an other player to connect at the same address. ## Code structure - game rules and game state are implemented in the _store/_ folder. +- a server for the network game is implemented in _server/relay-server_, which uses _server/protocol_ +- the web client is in _clients/web_, it connects to the server using the _clients/backbone-lib_ library - the command-line application is implemented in _clients/cli/_; it allows you to play against a bot, or to have two bots play against each other -- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. +- the bots algorithms and the training of their models are implemented in the _bot/_ and _spiel_bot_ folders. This is a work in progress, they are not performant at all. -### _store_ package +## Inspirations -The game state is defined by the `GameState` struct in _store/src/game.rs_. The `to_string_id()` method allows this state to be encoded compactly in a string (without the played moves history). For a more readable textual representation, the `fmt::Display` trait is implemented. +The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_, _server/relay-server_ is a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/). -### _clients/cli_ package - -`clients/cli/src/game_runner.rs` contains the logic to make two bots play against each other. - -### _bot_ package - -- `bot/src/strategy/default.rs` contains the code for a basic bot strategy: it determines the list of valid moves (using the `get_possible_moves_sequences` method of `store::MoveRules`) and simply executes the first move in the list. -- `bot/src/strategy/dqnburn.rs` is another bot strategy that uses a reinforcement learning trained model with the DQN algorithm via the burn library (). -- `bot/scripts/trains.sh` allows you to train agents using different algorithms (DQN, PPO, SAC). - -### multiplayer game - -Packages "clients/backbone-lib", "clients/web/game", "server/protocol", "server/relay-server" are a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/). - -The system consists of: - -- A **relay server** (Axum/Tokio) that routes messages between players and manages rooms, without knowing anything about game rules. -- A **backbone library** that handles WebSocket connection, handshake, and message routing, exposing an async API to the game frontend. -- Game-specific **backend logic** implementing the `BackEndArchitecture` trait, which runs only on the hosting client. -- A **Leptos frontend** that connects to a session and reacts to state updates. - -There is no dedicated game server. One of the players acts as the host: their browser runs the game backend locally. The relay server only forwards messages — it never touches game state. - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Host Client │ -│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐ │ -│ │ Leptos UI │◄──►│ GameSession │◄──►│ Backend │ │ -│ └─────────────┘ └────────┬─────────┘ └────────────┘ │ -└───────────────────────────── │ ────────────────────────────┘ - │ WebSocket - ┌──────▼──────┐ - │ Relay Server│ - └──────┬──────┘ - │ WebSocket -┌───────────────────────────────│────────────────────────────┐ -│ ┌─────────────┐ ┌─────────▼────────┐ │ -│ │ Leptos UI │◄──►│ GameSession │ (no backend) │ -│ └─────────────┘ └──────────────────┘ │ -│ Remote Client │ -└─────────────────────────────────────────────────────────────┘ -``` - -#### Data flow - -- **Actions** (e.g. "place stone at B3") flow from the UI to the host backend via `GameSession::send_action()`. -- **State updates** flow back as `ViewStateUpdate::Full` (full snapshot, on join or reset) or `ViewStateUpdate::Incremental` (delta, for animations). -- **Timers** are managed by the host's background task (wall-clock, no polling required from the game). - -#### backbone-lib session API - -The key design choice: `backbone-lib` owns a background async task per session. The Leptos app never drives a loop — it just awaits on events. - -#### Workspace - -**server/protocol** - -Shared message-type constants and the `JoinRequest` struct used during the WebSocket handshake. - -**server/relay-server** - -Listens on port 8080. Loads `GameConfig.json` on startup to know which games exist and their player limits: - -```json -[{ "name": "trictrac", "max_players": 10 }] -``` - -Games can be added at runtime via the `/reload` endpoint. `/enlist` lists active rooms. A watchdog cleans up inactive rooms every 20 minutes. - -For production, put it behind a reverse proxy with SSL (the browser requires `wss://` on HTTPS pages). Example Caddy config: - -``` -your-domain.com { - handle_path /api/* { - reverse_proxy localhost:8080 - } - file_server -} -``` - -**clients/backbone-lib** - -Modules: - -| Module | Purpose | -| ---------- | ---------------------------------------------------------------------------------------------------------- | -| `session` | `GameSession`, `connect()`, `SessionEvent`, `RoomConfig` | -| `host` | Background async task for the hosting client (drives `BackEndArchitecture`, manages timers) | -| `client` | Background async task for non-hosting clients | -| `protocol` | Wire encoding/decoding helpers (postcard + message-type bytes) | -| `platform` | `spawn_task` / `sleep_ms` abstractions (WASM: `spawn_local` + gloo-timers; native: thread + thread::sleep) | -| `traits` | `BackEndArchitecture`, `BackendCommand`, `ViewStateUpdate`, `SerializationCap` | +The web client UX/UI is inspired by https://playtiao.com. diff --git a/clients/web/Cargo.toml b/clients/web/Cargo.toml index 1edb9eb..4b82427 100644 --- a/clients/web/Cargo.toml +++ b/clients/web/Cargo.toml @@ -19,6 +19,7 @@ futures = "0.3" rand = "0.9" gloo-storage = "0.3" qrcodegen = "1.8" +pulldown-cmark = "0.13" [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = "0.2.118" diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 09b21e9..1d4cc77 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -305,6 +305,62 @@ a:hover { text-decoration: underline; } .portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; } .portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; } +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + .portal-link { color: var(--ui-gold); text-decoration: none; @@ -2045,6 +2101,7 @@ a:hover { text-decoration: underline; } text-decoration: none; opacity: 0.8; transition: opacity 0.15s; + cursor: pointer; } .game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } @@ -2078,13 +2135,27 @@ a:hover { text-decoration: underline; } } /* Push the version wrapper to the bottom of the sidebar flex column */ -.game-sidebar > div:has(.site-nav-version) { +.sidebar-footer { margin-top: auto; - padding: 0.75rem 1rem; border-top: 1px solid rgba(200,164,72,0.12); } +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + .site-nav-version { + margin: 2em 0 1em; display: block; text-align: center; font-family: var(--font-ui); @@ -2092,3 +2163,91 @@ a:hover { text-decoration: underline; } letter-spacing: 0.06em; color: rgba(200,164,72,0.4); } + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 03ba37c..1e5fbc2 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -140,5 +140,14 @@ "nickname_modal_sign_in": "Sign in", "nickname_modal_register": "Create account", "new_game": "New game", - "language": "Language" + "language": "Language", + "delete_account_title": "Danger zone", + "delete_account_btn": "Delete my account", + "delete_account_warning": "This action is irreversible. Your account will be permanently deleted.", + "delete_account_confirm_label": "Type your username to confirm:", + "delete_account_confirm_btn": "Delete permanently", + "delete_account_mismatch": "Username does not match.", + "account_deleted": "Your account has been permanently deleted.", + "about": "About", + "legal": "Legal notices" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 569d66b..28ae43c 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -130,15 +130,22 @@ "copy_link": "Copier le lien", "link_copied": "Copié !", "scan_qr": "ou scannez le QR code", - "join_code_label": "Rejoindre avec un code", - "join_code_placeholder": "Code de la salle", "share_btn": "Partager", "nickname_modal_title": "Choisissez votre pseudo", "nickname_modal_hint": "Vous jouerez sous le nom de :", "nickname_modal_play": "Jouer", "nickname_modal_or": "ou", - "nickname_modal_sign_in": "Se connecter", + "nickname_modal_sign_in": "connectez-vous", "nickname_modal_register": "Créer un compte", "new_game": "Nouvelle partie", - "language": "Langue" + "language": "Langue", + "delete_account_title": "Zone de danger", + "delete_account_btn": "Supprimer mon compte", + "delete_account_warning": "Cette action est irréversible. Votre compte sera définitivement supprimé.", + "delete_account_confirm_label": "Tapez votre nom d'utilisateur pour confirmer :", + "delete_account_confirm_btn": "Supprimer définitivement", + "delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.", + "account_deleted": "Votre compte a été définitivement supprimé.", + "about": "À propos", + "legal": "Mentions légales" } diff --git a/clients/web/pages/about/en.md b/clients/web/pages/about/en.md new file mode 100644 index 0000000..27a382e --- /dev/null +++ b/clients/web/pages/about/en.md @@ -0,0 +1,12 @@ +# About + +This application allows you to play [trictrac](https://en.wikipedia.org/wiki/Trictrac) against a friend online or locally against a bot. + +The source code is available at [github.com/mmai/trictrac](https://github.com/mmai/trictrac) +The application is self-hosted and runs on a simple Raspberry Pi. + +## Contact & bug Report + +For any questions, bug reports, or feedback, you can contact me at rhumbs@rhumbs.fr. + +If you encounter an issue during gameplay, you can copy the context of a game by clicking _Take snapshot_ then paste the resulting code into your message, specifying the expected behavior and the incorrect behavior you observed. diff --git a/clients/web/pages/about/fr.md b/clients/web/pages/about/fr.md new file mode 100644 index 0000000..1c3ec74 --- /dev/null +++ b/clients/web/pages/about/fr.md @@ -0,0 +1,12 @@ +# À propos + +Cette application vous permet de jouer au [trictrac](https://fr.wikipedia.org/wiki/Trictrac) contre un ami en ligne ou localement contre un bot. + +Le code source est disponible sur [github.com/mmai/trictrac](https://github.com/mmai/trictrac). +L'application est auto hébergée et tourne sur un simple Raspberry Pi. + +## Contact et rapport de bogue + +Pour toute question, rapport de bogue ou retour d'expérience, vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr. + +Si vous constatez une anomalie en cours de jeu, vous pouvez copier le contexte d'une partie en cliquant sur _Prendre un instantané_, puis coller le code obtenu dans le message, en précisant le comportement auquel vous vous attendiez, et le comportement erroné constaté. diff --git a/clients/web/pages/legal/en.md b/clients/web/pages/legal/en.md new file mode 100644 index 0000000..ff72761 --- /dev/null +++ b/clients/web/pages/legal/en.md @@ -0,0 +1,26 @@ +# Legal Notices + +## Data and Privacy + +This site does not use third-party analytics or advertising trackers. + +If you create an account, your username, email address, and argon2-hashed password are stored in a database on our server. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties. + +Game records (room codes, move history, outcomes) may be stored to display game history on your profile page. + +## Cookies and Sessions + +A session cookie is stored in your browser when you sign in. It is used solely to keep you authenticated and expires after 30 days of inactivity. + +## Contact + +The website is created by + +Henri Bourcereau\ +7 rue Lugeol\ +33000 Bordeaux\ +France + +It is hosted at the same address. + +You can contact me at rhumbs@rhumbs.fr diff --git a/clients/web/pages/legal/fr.md b/clients/web/pages/legal/fr.md new file mode 100644 index 0000000..43f85d5 --- /dev/null +++ b/clients/web/pages/legal/fr.md @@ -0,0 +1,28 @@ +# Mentions légales + +## Données et vie privée + +Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires. + +Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés dans une base de données sur notre serveur. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers. + +Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil. + +Vous pouvez supprimer votre compte et la totalité des données associées depuis votre page de profil. + +## Cookies et sessions + +Un cookie de session est stocké dans votre navigateur lorsque vous vous connectez. Il sert uniquement à maintenir votre authentification et expire après 30 jours d'inactivité. + +## Contact + +Le site est réalisé par + +Henri Bourcereau\ +7 rue Lugeol\ +33000 Bordeaux\ +France + +Il est hébergé à la même adresse. + +Vous pouvez me contacter à l'adresse rhumbs@rhumbs.fr diff --git a/clients/web/pages/readme.txt b/clients/web/pages/readme.txt new file mode 100644 index 0000000..ea3df35 --- /dev/null +++ b/clients/web/pages/readme.txt @@ -0,0 +1 @@ +Sync this folder to the PAGES_DIR directory of the server running `relay-server`. diff --git a/clients/web/src/api.rs b/clients/web/src/api.rs index d826165..2452b67 100644 --- a/clients/web/src/api.rs +++ b/clients/web/src/api.rs @@ -64,6 +64,12 @@ pub struct GameDetail { pub participants: Vec, } +#[derive(Clone, Debug, Deserialize)] +pub struct PageContent { + pub title: String, + pub content: String, +} + // ── Request bodies ──────────────────────────────────────────────────────────── #[derive(Serialize)] @@ -141,6 +147,19 @@ pub async fn post_logout() -> Result<(), String> { } } +pub async fn delete_account() -> Result<(), String> { + let resp = gloo_net::http::Request::delete(&url("/auth/account")) + .credentials(web_sys::RequestCredentials::Include) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 204 { + Ok(()) + } else { + Err(format!("status {}", resp.status())) + } +} + pub async fn get_user_profile(username: &str) -> Result { let resp = gloo_net::http::Request::get(&url(&format!("/users/{username}"))) .credentials(web_sys::RequestCredentials::Include) @@ -242,6 +261,18 @@ pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(), } } +pub async fn get_page(slug: &str, lang: &str) -> Result { + let resp = gloo_net::http::Request::get(&url(&format!("/pages/{slug}?lang={lang}"))) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status() == 200 { + resp.json::().await.map_err(|e| e.to_string()) + } else { + Err(format!("status {}", resp.status())) + } +} + // ── Utilities ───────────────────────────────────────────────────────────────── /// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`. diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 5c38d33..ba90a54 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -21,15 +21,37 @@ use crate::game::trictrac::backend::TrictracBackend; use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState}; use crate::i18n::*; use crate::portal::{ - account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage, - lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage, - verify_email::VerifyEmailPage, + account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage, + game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage, + reset_password::ResetPasswordPage, verify_email::VerifyEmailPage, }; use trictrac_store::CheckerMove; use std::collections::VecDeque; -const RELAY_URL: &str = "ws://localhost:8080/ws"; +/// Newtype wrappers so context lookup can distinguish signals of the same inner type. +#[derive(Clone, Copy)] +pub(crate) struct AnonNickname(pub RwSignal>); +#[derive(Clone, Copy)] +pub(crate) struct AuthEmailVerified(pub RwSignal); +/// One-shot message shown as a top banner and auto-dismissed after a few seconds. +#[derive(Clone, Copy)] +pub(crate) struct FlashMessage(pub RwSignal>); + +fn relay_url() -> String { + #[cfg(debug_assertions)] + { + "ws://localhost:8080/ws".to_string() + } + #[cfg(not(debug_assertions))] + { + let location = web_sys::window().and_then(|w| Some(w.location())).unwrap(); + let protocol = location.protocol().unwrap_or_default(); + let host = location.host().unwrap_or_default(); + let ws_protocol = if protocol == "https:" { "wss" } else { "ws" }; + format!("{ws_protocol}://{host}/ws") + } +} const GAME_ID: &str = "trictrac"; const STORAGE_KEY: &str = "trictrac_session"; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -155,14 +177,16 @@ pub fn App() -> impl IntoView { let auth_username: RwSignal> = RwSignal::new(None); let auth_email_verified: RwSignal = RwSignal::new(false); provide_context(auth_username); - provide_context(auth_email_verified); + provide_context(AuthEmailVerified(auth_email_verified)); // Set to true once get_me resolves (success or failure) so lobby can // decide immediately whether to show the nickname modal. let auth_loaded: RwSignal = RwSignal::new(false); provide_context(auth_loaded); // Nickname chosen by an anonymous player; used instead of "Anonymous". let anon_nickname: RwSignal> = RwSignal::new(None); - provide_context(anon_nickname); + provide_context(AnonNickname(anon_nickname)); + let flash: RwSignal> = RwSignal::new(None); + provide_context(FlashMessage(flash)); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); @@ -205,7 +229,7 @@ pub fn App() -> impl IntoView { Some(NetCommand::CreateRoom { room }) => { break Some(( RoomConfig { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, @@ -219,7 +243,7 @@ pub fn App() -> impl IntoView { Some(NetCommand::JoinRoom { room }) => { break Some(( RoomConfig { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room, rule_variation: 0, @@ -304,7 +328,7 @@ pub fn App() -> impl IntoView { if !session.is_host { save_session(&StoredSession { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: session.reconnect_token, @@ -358,7 +382,7 @@ pub fn App() -> impl IntoView { if is_host { save_session(&StoredSession { - relay_url: RELAY_URL.to_string(), + relay_url: relay_url(), game_id: GAME_ID.to_string(), room_id: room_id_for_storage.clone(), token: reconnect_token, @@ -404,6 +428,7 @@ pub fn App() -> impl IntoView { view! { +
"Page not found."

}> @@ -413,6 +438,7 @@ pub fn App() -> impl IntoView { +
@@ -421,6 +447,34 @@ pub fn App() -> impl IntoView { } } +/// Fixed top banner that shows a flash message and auto-dismisses after 5 seconds. +#[component] +fn FlashBanner() -> impl IntoView { + let flash = use_context::() + .expect("FlashMessage context not found") + .0; + + Effect::new(move |_| { + if flash.get().is_some() { + spawn_local(async move { + gloo_timers::future::TimeoutFuture::new(5_000).await; + flash.set(None); + }); + } + }); + + move || { + flash.get().map(|msg| { + view! { +
+ { msg } + +
+ } + }) + } +} + /// Renders the full-screen game overlay, but only when the current route is "/". /// This lets the user navigate to profile/account pages while a game is running. #[component] @@ -544,47 +598,60 @@ fn SiteHamburger() -> impl IntoView { // Auth -
- - - - {move || match auth_username.get() { Some(u) => { let href = format!("/profile/{u}"); view! { + }.into_any() }, None => view! { + }.into_any(), }} -
+ +
"v" {VERSION}
+ // ── Replay snapshot modal ───────────────────────────────────────────── diff --git a/clients/web/src/portal/account.rs b/clients/web/src/portal/account.rs index 842338d..c986f38 100644 --- a/clients/web/src/portal/account.rs +++ b/clients/web/src/portal/account.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos_router::hooks::use_navigate; use crate::api; +use crate::app::AuthEmailVerified; use crate::i18n::*; #[component] @@ -9,8 +10,8 @@ pub fn AccountPage() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let navigate = use_navigate(); // Only redirect to profile when the email is actually verified. @@ -107,8 +108,8 @@ fn LoginForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let navigate = use_navigate(); let login = RwSignal::new(String::new()); @@ -177,8 +178,8 @@ fn RegisterForm() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let username = RwSignal::new(String::new()); let email = RwSignal::new(String::new()); diff --git a/clients/web/src/portal/content_page.rs b/clients/web/src/portal/content_page.rs new file mode 100644 index 0000000..f44e3c0 --- /dev/null +++ b/clients/web/src/portal/content_page.rs @@ -0,0 +1,51 @@ +use leptos::prelude::*; +use leptos_router::hooks::use_params_map; +use pulldown_cmark::{Options, Parser, html}; + +use crate::api; +use crate::i18n::*; + +#[component] +pub fn ContentPage() -> impl IntoView { + let params = use_params_map(); + let slug = move || params.read().get("slug").unwrap_or_default(); + let i18n = use_i18n(); + + let page = LocalResource::new(move || { + let s = slug(); + let lang = match i18n.get_locale() { + Locale::en => "en", + Locale::fr => "fr", + }; + async move { api::get_page(&s, lang).await } + }); + + view! { +
+ {move || match page.get().map(|sw| sw.take()) { + None => view! { +

{t!(i18n, loading)}

+ }.into_any(), + Some(Err(_)) => view! { +

"Page not found."

+ }.into_any(), + Some(Ok(p)) => { + let html = md_to_html(&p.content); + view! { +
+ }.into_any() + } + }} +
+ } +} + +fn md_to_html(md: &str) -> String { + let mut opts = Options::empty(); + opts.insert(Options::ENABLE_TABLES); + opts.insert(Options::ENABLE_STRIKETHROUGH); + let parser = Parser::new_ext(md, opts); + let mut output = String::new(); + html::push_html(&mut output, parser); + output +} diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index c3dbf24..3dcde7e 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -3,7 +3,7 @@ use leptos::prelude::*; use leptos_router::components::A; use leptos_router::hooks::use_query_map; -use crate::app::{NetCommand, Screen}; +use crate::app::{AnonNickname, NetCommand, Screen}; use crate::i18n::*; // ── Room/nickname generation ────────────────────────────────────────────────── @@ -103,7 +103,7 @@ pub fn LobbyPage() -> impl IntoView { let cmd_tx = use_context::>().expect("NetCommand sender"); let auth_username = use_context::>>().expect("auth_username context"); let auth_loaded = use_context::>().expect("auth_loaded context"); - let anon_nickname = use_context::>>().expect("anon_nickname context"); + let anon_nickname = use_context::().expect("anon_nickname context").0; let query = use_query_map(); let view_state: RwSignal = RwSignal::new(LobbyView::Idle); @@ -195,12 +195,9 @@ fn IdleCard( pending_action: RwSignal>, ) -> impl IntoView { let i18n = use_i18n(); - let join_open = RwSignal::new(false); - let join_code = RwSignal::new(String::new()); let cmd_bot = cmd_tx.clone(); let cmd_create = cmd_tx.clone(); - let cmd_join = cmd_tx; let on_create = move |_: leptos::ev::MouseEvent| { let code = generate_room_code(); @@ -232,48 +229,6 @@ fn IdleCard( {t!(i18n, create_room)}
- - // Hidden "join by code" fallback -
- - {move || { - let cmd = cmd_join.clone(); - join_open.get().then(|| view! { -
- - -
- }) - }} -
} } @@ -338,8 +293,6 @@ fn NicknameModal( {t!(i18n, nickname_modal_or)} " " {t!(i18n, nickname_modal_sign_in)} - " · " - {t!(i18n, nickname_modal_register)}

diff --git a/clients/web/src/portal/mod.rs b/clients/web/src/portal/mod.rs index a270b5f..54a84d1 100644 --- a/clients/web/src/portal/mod.rs +++ b/clients/web/src/portal/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod content_page; pub mod forgot_password; pub mod game_detail; pub mod lobby; diff --git a/clients/web/src/portal/profile.rs b/clients/web/src/portal/profile.rs index c727bbd..ac11bd6 100644 --- a/clients/web/src/portal/profile.rs +++ b/clients/web/src/portal/profile.rs @@ -1,7 +1,8 @@ use leptos::prelude::*; -use leptos_router::{components::A, hooks::use_params_map}; +use leptos_router::{components::A, hooks::use_navigate, hooks::use_params_map}; use crate::api::{self, GameSummary, UserProfile}; +use crate::app::{AuthEmailVerified, FlashMessage}; use crate::i18n::*; #[component] @@ -30,6 +31,8 @@ pub fn ProfilePage() -> impl IntoView { #[component] fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView { let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username context not found"); let page = RwSignal::new(0i64); let games = LocalResource::new(move || { let u = username.clone(); @@ -46,7 +49,9 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView { time_style: None, }; let joined = api::format_ts(profile.created_at, locale_tag, &date_format); - // let joined = api::format_ts(profile.created_at, locale_tag, &api::DateFormatOptions::date_only()); + + let profile_username = profile.username.clone(); + let is_own_profile = move || auth_username.get().as_deref() == Some(&profile_username); view! {
@@ -83,6 +88,106 @@ fn ProfileContent(profile: UserProfile, username: String) -> impl IntoView { } }}
+ + {move || if is_own_profile() { + let uname = profile.username.clone(); + view! { }.into_any() + } else { + view! { }.into_any() + }} + } +} + +#[component] +fn DeleteAccountSection(username: String) -> impl IntoView { + let i18n = use_i18n(); + let auth_username = + use_context::>>().expect("auth_username context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; + let flash = use_context::().expect("FlashMessage context not found").0; + let navigate = use_navigate(); + + let confirming = RwSignal::new(false); + let confirm_input = RwSignal::new(String::new()); + let error = RwSignal::new(String::new()); + let pending = RwSignal::new(false); + + view! { +
+

{t!(i18n, delete_account_title)}

+ {move || if !confirming.get() { + view! { +
+

+ {t!(i18n, delete_account_warning)} +

+ +
+ }.into_any() + } else { + // Define submit fresh each reactive call so the closure is FnMut-compatible. + let expected = username.clone(); + let nav = navigate.clone(); + let submit = move |ev: leptos::ev::SubmitEvent| { + ev.prevent_default(); + if pending.get() { return; } + error.set(String::new()); + + if confirm_input.get() != expected { + error.set(t_string!(i18n, delete_account_mismatch).to_string()); + return; + } + + pending.set(true); + let nav = nav.clone(); + wasm_bindgen_futures::spawn_local(async move { + match api::delete_account().await { + Ok(()) => { + auth_username.set(None); + auth_email_verified.set(false); + flash.set(Some(t_string!(i18n, account_deleted).to_string())); + nav("/", Default::default()); + } + Err(e) => { + error.set(e); + pending.set(false); + } + } + }); + }; + view! { +
+

+ {t!(i18n, delete_account_warning)} +

+ + + {move || if !error.get().is_empty() { + view! {

{ error.get() }

}.into_any() + } else { + view! { }.into_any() + }} +
+ + +
+ + }.into_any() + }} +
} } diff --git a/clients/web/src/portal/verify_email.rs b/clients/web/src/portal/verify_email.rs index 0ce0cae..03736e2 100644 --- a/clients/web/src/portal/verify_email.rs +++ b/clients/web/src/portal/verify_email.rs @@ -2,6 +2,7 @@ use leptos::prelude::*; use leptos_router::hooks::use_query_map; use crate::api; +use crate::app::AuthEmailVerified; use crate::i18n::*; #[derive(Clone, PartialEq)] @@ -16,8 +17,8 @@ pub fn VerifyEmailPage() -> impl IntoView { let i18n = use_i18n(); let auth_username = use_context::>>().expect("auth_username context not found"); - let auth_email_verified = - use_context::>().expect("auth_email_verified context not found"); + let auth_email_verified = use_context::() + .expect("auth_email_verified context not found").0; let query = use_query_map(); let token = query.with(|m| m.get("token").map(|s| s.to_string()).unwrap_or_default()); diff --git a/container/flake.lock b/container/flake.lock index 073ffc3..d81bd13 100644 --- a/container/flake.lock +++ b/container/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1778430510, - "narHash": "sha256-Ti+ZBvW6yrWWAg2szExVTwCd4qOJ3KlVr1tFHfyfi8Q=", + "lastModified": 1779467186, + "narHash": "sha256-nOesoDCiXcUftqbRBMz9tt4blI5PvljMWbm3kuCA+0s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8fd9daa3db09ced9700431c5b7ad0e8ba199b575", + "rev": "b77b3de8775677f84492abe84635f87b0e153f0f", "type": "github" }, "original": { diff --git a/devenv.lock b/devenv.lock index 991fcf7..e6e8ef6 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,11 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1776863933, + "lastModified": 1779486363, + "narHash": "sha256-6rNmvwTngbmz/j3DGsEYkyakrHHY8N5wgRD9k35HyuM=", "owner": "cachix", "repo": "devenv", - "rev": "863b4204725efaeeb73811e376f928232b720646", + "rev": "90692720b2ad7a7811204155900bf6bea3a3b420", "type": "github" }, "original": { @@ -18,10 +19,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1776734388, + "lastModified": 1779102034, + "narHash": "sha256-vZJZjLo513IeI8hjzHFc6TDezUd4uCE2Eq4SNO3DNNg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "10e7ad5bbcb421fe07e3a4ad53a634b0cd57ffac", + "rev": "687f05a9184cad4eaf905c48b63649e3a86f5433", "type": "github" }, "original": { @@ -34,6 +36,7 @@ "nixpkgs-cmake3": { "locked": { "lastModified": 1758213207, + "narHash": "sha256-rqoqF0LEi+6ZT59tr+hTQlxVwrzQsET01U4uUdmqRtM=", "owner": "NixOS", "repo": "nixpkgs", "rev": "f4b140d5b253f5e2a1ff4e5506edbf8267724bde", diff --git a/flake.nix b/flake.nix index 62b1eac..93b33d8 100644 --- a/flake.nix +++ b/flake.nix @@ -56,7 +56,7 @@ frontendCargoDeps = rustPlatform.fetchCargoVendor { src = ./.; name = "trictrac-frontend-vendor"; - hash = "sha256-neJh0ZQGa5LNY8vBu3kYkM+ARkXOW/EHx8sPBOsWsgE="; + hash = "sha256-E3MJbEehbXni7B8fQPS8fSri4f2b0A33r2djiK81E2Y="; }; in final.stdenv.mkDerivation { @@ -103,7 +103,7 @@ trictrac = with final; rustPlatform.buildRustPackage { pname = "trictrac"; - version = "0.2.12"; # trictrac-version + version = "0.2.13"; # trictrac-version src = ./.; nativeBuildInputs = [ pkg-config ]; diff --git a/justfile b/justfile index 598a757..3ae77d1 100644 --- a/justfile +++ b/justfile @@ -13,6 +13,10 @@ bump version: git commit -m "chore: bump version to {{version}}" @echo "Done. Finish with: git flow release finish {{version}}" +# Sync pages content to production server +pages-deploy: + rsync -av clients/web/pages/ raspberry:/var/lib/trictrac/pages/ + doc: cargo doc --no-deps shell: @@ -46,7 +50,7 @@ build: [working-directory: 'deploy'] run-relay: - ./relay-server + PAGES_DIR=../clients/web/pages ./relay-server build-relay: CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release @@ -54,6 +58,10 @@ build-relay: cp target/release/relay-server deploy cp -u server/relay-server/GameConfig.json deploy/ +# generate web stats report from the current nginx logs +stats: + ssh -t raspberry sudo goaccess /var/log/nginx/trictrac_access.log --log-format=COMBINED -o html > var/stats/report.html + # start a trictrac container with nixos-container # `boot.enableContainers = true` must be set on local nixos system local: diff --git a/module.nix b/module.nix index 53f77c6..28bec85 100644 --- a/module.nix +++ b/module.nix @@ -29,6 +29,12 @@ in description = "Web server protocol."; }; + pages_dir = mkOption { + type = types.str; + default = "/var/lib/trictrac/pages"; + description = "Directory containing content pages."; + }; + hostname = mkOption { type = types.str; default = "trictrac.localhost"; @@ -132,9 +138,9 @@ in # Explicit listen so this vhost isn't shadowed by a default_server # created by other virtual hosts with forceSSL = true. listen = [ - { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; } - { addr = "[::]"; port = listenPort; ssl = withSSL; } - ]; + { addr = "0.0.0.0"; port = listenPort; ssl = withSSL; } + { addr = "[::]"; port = listenPort; ssl = withSSL; } + ]; locations."/" = { extraConfig = proxyConfig; proxyPass = "http://trictrac-api/"; @@ -195,6 +201,7 @@ in environment = { DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}"; APP_URL = "${cfg.protocol}://${cfg.hostname}"; + PAGES_DIR = cfg.pages_dir; SMTP_HOST = cfg.smtp.host; SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port else if cfg.smtp.tls then 465 else 1025); diff --git a/server/relay-server/src/db.rs b/server/relay-server/src/db.rs index 83b9f25..0b9c878 100644 --- a/server/relay-server/src/db.rs +++ b/server/relay-server/src/db.rs @@ -188,6 +188,25 @@ pub async fn update_password_hash(pool: &Pool, user_id: i64, hash: &str) -> Resu Ok(()) } +/// Permanently deletes a user and their auth data. +/// Game history rows are kept but de-associated (user_id set to NULL). +pub async fn delete_user(pool: &Pool, user_id: i64) -> Result<(), DbError> { + let client = pool.get().await?; + client + .execute( + "UPDATE game_participants SET user_id = NULL WHERE user_id = $1", + &[&user_id], + ) + .await?; + client + .execute("DELETE FROM email_tokens WHERE user_id = $1", &[&user_id]) + .await?; + client + .execute("DELETE FROM users WHERE id = $1", &[&user_id]) + .await?; + Ok(()) +} + // ── Email tokens ────────────────────────────────────────────────────────────── pub async fn create_email_token( diff --git a/server/relay-server/src/http.rs b/server/relay-server/src/http.rs index 9c6071f..0104c76 100644 --- a/server/relay-server/src/http.rs +++ b/server/relay-server/src/http.rs @@ -19,7 +19,7 @@ use axum::{ extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, - routing::{get, post}, + routing::{delete, get, post}, }; use axum_login::AuthSession; use rand::distributions::Alphanumeric; @@ -48,10 +48,12 @@ pub fn router() -> Router> { .route("/auth/resend-verification", post(resend_verification)) .route("/auth/forgot-password", post(forgot_password)) .route("/auth/reset-password", post(reset_password)) + .route("/auth/account", delete(delete_account)) .route("/users/{username}", get(user_profile)) .route("/users/{username}/games", get(user_games)) .route("/games/result", post(game_result)) .route("/games/{id}", get(game_detail)) + .route("/pages/{slug}", get(get_page)) } // ── Token generation ────────────────────────────────────────────────────────── @@ -245,6 +247,7 @@ async fn register( async fn login( mut auth_session: AuthSession, + State(state): State>, Json(body): Json, ) -> Result { let creds = Credentials { @@ -260,6 +263,18 @@ async fn login( auth_session.login(&user).await.map_err(|_| AppError::Internal)?; + if !user.email_verified { + let _ = db::delete_email_tokens(&state.db, user.id, "verify").await; + let token = generate_token(); + let expires_at = now_unix() + VERIFY_TOKEN_EXPIRY; + if db::create_email_token(&state.db, user.id, &token, "verify", expires_at) + .await + .is_ok() + { + state.mailer.send_verification(&user.email, &token).await; + } + } + Ok(Json(MeResponse { id: user.id, username: user.username, @@ -272,6 +287,16 @@ async fn logout(mut auth_session: AuthSession) -> Result, + State(state): State>, +) -> Result { + let user = auth_session.user.clone().ok_or(AppError::Unauthorized)?; + auth_session.logout().await.map_err(|_| AppError::Internal)?; + db::delete_user(&state.db, user.id).await?; + Ok(StatusCode::NO_CONTENT) +} + async fn me(auth_session: AuthSession) -> Result { match auth_session.user { Some(user) => Ok(Json(MeResponse { @@ -522,3 +547,66 @@ async fn game_result( Ok(Json(GameResultResponse { game_record_id })) } + +// ── Static content pages ────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct LangQuery { + #[serde(default = "default_lang")] + lang: String, +} + +fn default_lang() -> String { + "en".to_string() +} + +#[derive(Serialize)] +struct PageResponse { + title: String, + content: String, +} + +async fn get_page( + Path(slug): Path, + Query(query): Query, + State(state): State>, +) -> Result { + // Reject slugs with path-traversal characters or unusual lengths. + if slug.is_empty() + || slug.len() > 64 + || !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(AppError::NotFound); + } + // Normalise lang to a safe identifier. + let lang = if !query.lang.is_empty() + && query.lang.len() <= 5 + && query.lang.chars().all(|c| c.is_ascii_alphabetic()) + { + query.lang.to_ascii_lowercase() + } else { + "en".to_string() + }; + + let base = std::path::Path::new(&state.pages_dir); + let primary = base.join(&slug).join(format!("{lang}.md")); + + let content = match tokio::fs::read_to_string(&primary).await { + Ok(c) => c, + Err(_) if lang != "en" => { + let fallback = base.join(&slug).join("en.md"); + tokio::fs::read_to_string(&fallback) + .await + .map_err(|_| AppError::NotFound)? + } + Err(_) => return Err(AppError::NotFound), + }; + + let title = content + .lines() + .find(|l| l.starts_with("# ")) + .map(|l| l[2..].trim().to_string()) + .unwrap_or_default(); + + Ok(Json(PageResponse { title, content })) +} diff --git a/server/relay-server/src/lobby.rs b/server/relay-server/src/lobby.rs index db1c4f8..db8f57c 100644 --- a/server/relay-server/src/lobby.rs +++ b/server/relay-server/src/lobby.rs @@ -63,15 +63,18 @@ pub struct AppState { pub db: Pool, /// SMTP mailer for email verification and password reset. pub mailer: Mailer, + /// Directory containing static content pages as `{slug}/{lang}.md` files. + pub pages_dir: String, } impl AppState { - pub fn new(db: Pool, mailer: Mailer) -> Self { + pub fn new(db: Pool, mailer: Mailer, pages_dir: String) -> Self { Self { rooms: Mutex::new(HashMap::new()), configs: RwLock::new(HashMap::new()), db, mailer, + pages_dir, } } } diff --git a/server/relay-server/src/main.rs b/server/relay-server/src/main.rs index 32baf70..367ef98 100644 --- a/server/relay-server/src/main.rs +++ b/server/relay-server/src/main.rs @@ -66,7 +66,8 @@ async fn main() { let auth_backend = AuthBackend::new(pool.clone()); let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build(); - let app_state = Arc::new(AppState::new(pool, mailer)); + let pages_dir = std::env::var("PAGES_DIR").unwrap_or_else(|_| "pages".to_string()); + let app_state = Arc::new(AppState::new(pool, mailer, pages_dir)); let watchdog_state = app_state.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min @@ -86,7 +87,7 @@ async fn main() { .allow_origin(AllowOrigin::list([ "http://localhost:9091".parse().unwrap(), // unified web dev server ])) - .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_headers([ HeaderName::from_static("content-type"), HeaderName::from_static("cookie"),