feat(web client): content pages
This commit is contained in:
parent
58f5722551
commit
6fd3499d7b
15 changed files with 312 additions and 11 deletions
39
Cargo.lock
generated
39
Cargo.lock
generated
|
|
@ -738,7 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -926,6 +926,15 @@ dependencies = [
|
||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -2654,6 +2663,25 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "qrcodegen"
|
name = "qrcodegen"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
|
@ -3918,6 +3946,7 @@ dependencies = [
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_i18n",
|
"leptos_i18n",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
|
"pulldown-cmark",
|
||||||
"qrcodegen",
|
"qrcodegen",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -4034,6 +4063,12 @@ version = "1.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|
@ -4358,7 +4393,7 @@ version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ futures = "0.3"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
gloo-storage = "0.3"
|
gloo-storage = "0.3"
|
||||||
qrcodegen = "1.8"
|
qrcodegen = "1.8"
|
||||||
|
pulldown-cmark = "0.13"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen = "0.2.118"
|
wasm-bindgen = "0.2.118"
|
||||||
|
|
|
||||||
|
|
@ -2093,3 +2093,91 @@ a:hover { text-decoration: underline; }
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: rgba(200,164,72,0.4);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
13
clients/web/pages/about/en.md
Normal file
13
clients/web/pages/about/en.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# About Trictrac
|
||||||
|
|
||||||
|
Trictrac was one of the most popular French board games from the 16th to the 19th centuries. It is played with the same basic equipment and moves as modern backgammon (more or less), but is much more complex. The goal of the game is not to move out all your pieces before your opponent, but to reach a certain number of points by navigating through various game situations.
|
||||||
|
|
||||||
|
## This Project
|
||||||
|
|
||||||
|
This application allows you to play trictrac against a friend online or locally against a bot. The game engine is written in Rust and compiled into WebAssembly, and runs entirely in your browser.
|
||||||
|
|
||||||
|
The source code is available at https://github.com/mmai/trictrac
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For any questions, bug reports, or feedback, please open an issue on the project repository.
|
||||||
13
clients/web/pages/about/fr.md
Normal file
13
clients/web/pages/about/fr.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# À propos du Trictrac
|
||||||
|
|
||||||
|
Le trictrac fut un des jeux de société français les plus populaires du XVIe au XIXe siècle. Il se joue avec le même matériel et mouvements de base que le backgammon moderne (à peu de choses près), mais est beaucoup plus complexe. Le but du jeu n'est pas de sortir toutes ses pièces avant l'adversaire, mais d'atteindre un certain nombre de points en réalisant travers de multiples situations de jeu.
|
||||||
|
|
||||||
|
## Ce projet
|
||||||
|
|
||||||
|
Cette application vous permet de jouer au trictrac contre un ami en ligne ou localement contre un bot. Le moteur de jeu est écrit en Rust et compilé en WebAssembly, et s'exécute entièrement dans votre navigateur.
|
||||||
|
|
||||||
|
Le code source est disponible sur https://github.com/mmai/trictrac
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Pour toute question, rapport de bogue ou retour d'expérience, veuillez ouvrir une issue sur le dépôt du projet.
|
||||||
1
clients/web/pages/readme.txt
Normal file
1
clients/web/pages/readme.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Sync this folder to the PAGES_DIR directory of the server running `relay-server`.
|
||||||
|
|
@ -64,6 +64,12 @@ pub struct GameDetail {
|
||||||
pub participants: Vec<Participant>,
|
pub participants: Vec<Participant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct PageContent {
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Request bodies ────────────────────────────────────────────────────────────
|
// ── Request bodies ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -242,6 +248,18 @@ pub async fn post_reset_password(token: &str, new_password: &str) -> Result<(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_page(slug: &str, lang: &str) -> Result<PageContent, String> {
|
||||||
|
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::<PageContent>().await.map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
Err(format!("status {}", resp.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.
|
/// Maps to the `Intl.DateTimeFormat` options object accepted by `Date.toLocaleString`.
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ use crate::game::trictrac::backend::TrictracBackend;
|
||||||
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
use crate::game::trictrac::types::{GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
use crate::portal::{
|
use crate::portal::{
|
||||||
account::AccountPage, forgot_password::ForgotPasswordPage, game_detail::GameDetailPage,
|
account::AccountPage, content_page::ContentPage, forgot_password::ForgotPasswordPage,
|
||||||
lobby::LobbyPage, profile::ProfilePage, reset_password::ResetPasswordPage,
|
game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage,
|
||||||
verify_email::VerifyEmailPage,
|
reset_password::ResetPasswordPage, verify_email::VerifyEmailPage,
|
||||||
};
|
};
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
|
||||||
|
|
@ -432,6 +432,7 @@ pub fn App() -> impl IntoView {
|
||||||
<Route path=path!("/verify-email") view=VerifyEmailPage />
|
<Route path=path!("/verify-email") view=VerifyEmailPage />
|
||||||
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
|
<Route path=path!("/forgot-password") view=ForgotPasswordPage />
|
||||||
<Route path=path!("/reset-password") view=ResetPasswordPage />
|
<Route path=path!("/reset-password") view=ResetPasswordPage />
|
||||||
|
<Route path=path!("/page/:slug") view=ContentPage />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
51
clients/web/src/portal/content_page.rs
Normal file
51
clients/web/src/portal/content_page.rs
Normal file
|
|
@ -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! {
|
||||||
|
<div class="portal-main">
|
||||||
|
{move || match page.get().map(|sw| sw.take()) {
|
||||||
|
None => view! {
|
||||||
|
<p class="portal-loading">{t!(i18n, loading)}</p>
|
||||||
|
}.into_any(),
|
||||||
|
Some(Err(_)) => view! {
|
||||||
|
<p class="portal-empty">"Page not found."</p>
|
||||||
|
}.into_any(),
|
||||||
|
Some(Ok(p)) => {
|
||||||
|
let html = md_to_html(&p.content);
|
||||||
|
view! {
|
||||||
|
<div class="portal-card content-page" inner_html=html />
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod account;
|
pub mod account;
|
||||||
|
pub mod content_page;
|
||||||
pub mod forgot_password;
|
pub mod forgot_password;
|
||||||
pub mod game_detail;
|
pub mod game_detail;
|
||||||
pub mod lobby;
|
pub mod lobby;
|
||||||
|
|
|
||||||
6
justfile
6
justfile
|
|
@ -14,6 +14,10 @@ bump version:
|
||||||
git flow release start {{version}}
|
git flow release start {{version}}
|
||||||
@echo "Done. Finish with: git flow release finish {{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:
|
doc:
|
||||||
cargo doc --no-deps
|
cargo doc --no-deps
|
||||||
shell:
|
shell:
|
||||||
|
|
@ -47,7 +51,7 @@ build:
|
||||||
|
|
||||||
[working-directory: 'deploy']
|
[working-directory: 'deploy']
|
||||||
run-relay:
|
run-relay:
|
||||||
./relay-server
|
PAGES_DIR=../clients/web/pages ./relay-server
|
||||||
|
|
||||||
build-relay:
|
build-relay:
|
||||||
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
|
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
|
||||||
|
|
|
||||||
13
module.nix
13
module.nix
|
|
@ -29,6 +29,12 @@ in
|
||||||
description = "Web server protocol.";
|
description = "Web server protocol.";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pages_dir = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/var/lib/trictrac/pages";
|
||||||
|
description = "Directory containing content pages.";
|
||||||
|
};
|
||||||
|
|
||||||
hostname = mkOption {
|
hostname = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "trictrac.localhost";
|
default = "trictrac.localhost";
|
||||||
|
|
@ -132,9 +138,9 @@ in
|
||||||
# Explicit listen so this vhost isn't shadowed by a default_server
|
# Explicit listen so this vhost isn't shadowed by a default_server
|
||||||
# created by other virtual hosts with forceSSL = true.
|
# created by other virtual hosts with forceSSL = true.
|
||||||
listen = [
|
listen = [
|
||||||
{ addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
|
{ addr = "0.0.0.0"; port = listenPort; ssl = withSSL; }
|
||||||
{ addr = "[::]"; port = listenPort; ssl = withSSL; }
|
{ addr = "[::]"; port = listenPort; ssl = withSSL; }
|
||||||
];
|
];
|
||||||
locations."/" = {
|
locations."/" = {
|
||||||
extraConfig = proxyConfig;
|
extraConfig = proxyConfig;
|
||||||
proxyPass = "http://trictrac-api/";
|
proxyPass = "http://trictrac-api/";
|
||||||
|
|
@ -195,6 +201,7 @@ in
|
||||||
environment = {
|
environment = {
|
||||||
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
|
DATABASE_URL = "postgresql://${cfg.user}@127.0.0.1/${cfg.user}";
|
||||||
APP_URL = "${cfg.protocol}://${cfg.hostname}";
|
APP_URL = "${cfg.protocol}://${cfg.hostname}";
|
||||||
|
PAGES_DIR = cfg.pages_dir;
|
||||||
SMTP_HOST = cfg.smtp.host;
|
SMTP_HOST = cfg.smtp.host;
|
||||||
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
|
SMTP_PORT = toString (if cfg.smtp.port != null then cfg.smtp.port
|
||||||
else if cfg.smtp.tls then 465 else 1025);
|
else if cfg.smtp.tls then 465 else 1025);
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ pub fn router() -> Router<Arc<AppState>> {
|
||||||
.route("/users/{username}/games", get(user_games))
|
.route("/users/{username}/games", get(user_games))
|
||||||
.route("/games/result", post(game_result))
|
.route("/games/result", post(game_result))
|
||||||
.route("/games/{id}", get(game_detail))
|
.route("/games/{id}", get(game_detail))
|
||||||
|
.route("/pages/{slug}", get(get_page))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Token generation ──────────────────────────────────────────────────────────
|
// ── Token generation ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -535,3 +536,66 @@ async fn game_result(
|
||||||
|
|
||||||
Ok(Json(GameResultResponse { game_record_id }))
|
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<String>,
|
||||||
|
Query(query): Query<LangQuery>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
// 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 }))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,15 +63,18 @@ pub struct AppState {
|
||||||
pub db: Pool,
|
pub db: Pool,
|
||||||
/// SMTP mailer for email verification and password reset.
|
/// SMTP mailer for email verification and password reset.
|
||||||
pub mailer: Mailer,
|
pub mailer: Mailer,
|
||||||
|
/// Directory containing static content pages as `{slug}/{lang}.md` files.
|
||||||
|
pub pages_dir: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(db: Pool, mailer: Mailer) -> Self {
|
pub fn new(db: Pool, mailer: Mailer, pages_dir: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
rooms: Mutex::new(HashMap::new()),
|
rooms: Mutex::new(HashMap::new()),
|
||||||
configs: RwLock::new(HashMap::new()),
|
configs: RwLock::new(HashMap::new()),
|
||||||
db,
|
db,
|
||||||
mailer,
|
mailer,
|
||||||
|
pages_dir,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@ async fn main() {
|
||||||
let auth_backend = AuthBackend::new(pool.clone());
|
let auth_backend = AuthBackend::new(pool.clone());
|
||||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
|
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();
|
let watchdog_state = app_state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1200)); // 20 Min
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue