feat(web client): content pages

This commit is contained in:
Henri Bourcereau 2026-05-25 16:14:25 +02:00
parent 58f5722551
commit 6fd3499d7b
15 changed files with 312 additions and 11 deletions

View file

@ -52,6 +52,7 @@ pub fn router() -> Router<Arc<AppState>> {
.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 ──────────────────────────────────────────────────────────
@ -535,3 +536,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<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 }))
}

View file

@ -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,
}
}
}

View file

@ -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