srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/server
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-12-14 09:24:00 +0200
committersrdusr <[email protected]>2025-12-14 09:24:00 +0200
commit3dbebfbc9345d2603908f32c0dabebc0ff21feb3 (patch)
tree61a4f60187046a087424845eb8e22239ccd7d6ee /crates/server
parent8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc (diff)
downloadtyperpunk-3dbebfbc9345d2603908f32c0dabebc0ff21feb3.tar.gz
typerpunk-3dbebfbc9345d2603908f32c0dabebc0ff21feb3.zip
Add community text submissions, and make custom text usable for study
Submissions - POST /api/texts proposes a passage; nothing reaches players until a moderator approves it. GET /api/texts serves the approved set, which the client merges on top of its bundled packs at startup. - Validation the server enforces rather than trusts: category from a fixed list, 40 to 600 characters, no control characters (a newline makes a passage untypeable in a single-line input), attribution length, and a unique index on md5(lower(btrim(content))) so the same passage cannot be submitted twice under different whitespace or casing. - Moderation is a flag on users. The queue and the review endpoint both refuse a non-moderator, and reviewing an already-reviewed submission is a 404 rather than a silent second write. - Submissions are rate limited per user: enough for a real contributor, not enough to fill the queue from a script. - A Contribute screen carries the form, your own submissions with their status, and - for moderators only - the review queue. This is the half of TypeRacer's model the packs could not reach by authoring: their corpus is roughly twelve thousand passages, grown by submission. Custom text as a study tool - Imported documents are kept between visits, with how far through each one you are. Custom text lived only in memory, so importing a set of notes and reloading the page lost them - fine for pasting a paragraph to race, useless for working through a file over several sittings. - Position is recorded when a segment is finished, not when the next is started, so closing the tab after a segment does not lose it. - Markdown is chunked as markdown: fenced code blocks are kept whole and typed line by line, and the decoration - hashes, asterisks, backticks, link brackets, table pipes - is stripped so what you retype is the material rather than the punctuation around it. - Everything stays on the device. Notes are not uploaded anywhere. Fixed while doing it: a chunk could contain a newline, which cannot be typed in a single-line input at all. Any paragraph with a line break inside it -- ordinary in notes and in wrapped prose - produced an unfinishable segment. Whitespace inside a chunk is now flattened.
Diffstat (limited to 'crates/server')
-rw-r--r--crates/server/migrations/0010_text_submissions.sql28
-rw-r--r--crates/server/src/main.rs2
-rw-r--r--crates/server/src/state.rs4
-rw-r--r--crates/server/src/texts.rs286
4 files changed, 320 insertions, 0 deletions
diff --git a/crates/server/migrations/0010_text_submissions.sql b/crates/server/migrations/0010_text_submissions.sql
new file mode 100644
index 0000000..b735a8f
--- /dev/null
+++ b/crates/server/migrations/0010_text_submissions.sql
@@ -0,0 +1,28 @@
+- Community-submitted passages, the way a text corpus actually grows past
+- what fits in a repository. Nothing here reaches players until a moderator
+- approves it.
+ALTER TABLE users ADD COLUMN is_moderator BOOLEAN NOT NULL DEFAULT FALSE;
+
+CREATE TABLE text_submissions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ category TEXT NOT NULL,
+ content TEXT NOT NULL,
+ attribution TEXT,
+ - Syntax language for code passages; NULL for prose.
+ language TEXT,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
+ reject_reason TEXT,
+ reviewed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
+ reviewed_at TEXT,
+ created_at TEXT NOT NULL
+);
+
+CREATE INDEX idx_text_submissions_status ON text_submissions(status);
+CREATE INDEX idx_text_submissions_user ON text_submissions(user_id);
+
+- One submission per passage, regardless of whitespace or case. Built on
+- md5() rather than a stored hash column so the constraint cannot drift out
+- of step with the content it is derived from.
+CREATE UNIQUE INDEX idx_text_submissions_dedupe
+ ON text_submissions (md5(lower(btrim(content))));
diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs
index 0ce169d..3838316 100644
--- a/crates/server/src/main.rs
+++ b/crates/server/src/main.rs
@@ -10,6 +10,7 @@ mod rate_limit;
mod spotify;
mod state;
mod stats;
+mod texts;
use crate::state::RaceText;
use axum::http::{HeaderValue, Method};
@@ -89,6 +90,7 @@ fn build_app(app_state: Arc<AppState>) -> Router {
.merge(multiplayer::router())
.merge(spotify::router())
.merge(lyrics::router())
+ .merge(texts::router())
.with_state(app_state)
}
diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs
index fac6f29..ff583ac 100644
--- a/crates/server/src/state.rs
+++ b/crates/server/src/state.rs
@@ -39,6 +39,9 @@ pub struct AppState {
/// an open proxy unless it is bounded. Keyed by IP, since the endpoint is
/// reachable without an account.
pub lyrics_rate_limiter: RateLimiter,
+ /// Keyed by user: enough for someone contributing in a sitting, not
+ /// enough for a script to fill the moderation queue.
+ pub submission_rate_limiter: RateLimiter<String>,
/// Set from COOKIE_SECURE. Off for plain-HTTP local dev, must be on
/// behind TLS in production or browsers silently drop the cookie.
pub cookie_secure: bool,
@@ -68,6 +71,7 @@ impl AppState {
// rapid Words-10 sessions while still capping scripted spam.
stats_rate_limiter: RateLimiter::new(60, Duration::from_secs(5 * 60)),
lyrics_rate_limiter: RateLimiter::new(30, Duration::from_secs(60)),
+ submission_rate_limiter: RateLimiter::new(20, Duration::from_secs(60 * 60)),
cookie_secure,
rooms: new_registry(),
race_texts,
diff --git a/crates/server/src/texts.rs b/crates/server/src/texts.rs
new file mode 100644
index 0000000..46abe50
--- /dev/null
+++ b/crates/server/src/texts.rs
@@ -0,0 +1,286 @@
+//! Community text submissions.
+//!
+//! A bundled dataset can only get so large before it stops belonging in a
+//! repository. TypeRacer's corpus is roughly twelve thousand passages, and it
+//! got there through submissions rather than authoring. This is that path:
+//! anyone signed in can propose a passage, and nothing reaches players until
+//! a moderator approves it.
+
+use crate::auth::current_user_or_token;
+use crate::error::AppError;
+use crate::state::AppState;
+use axum::extract::{Path, Query, State};
+use axum::http::HeaderMap;
+use axum::response::IntoResponse;
+use axum::routing::{get, post};
+use axum::{Json, Router};
+use axum_extra::extract::CookieJar;
+use serde::{Deserialize, Serialize};
+use sqlx::Row;
+use std::sync::Arc;
+use time::OffsetDateTime;
+
+/// Long enough to be worth typing, short enough to stay a single race.
+const MIN_CONTENT: usize = 40;
+const MAX_CONTENT: usize = 600;
+const MAX_ATTRIBUTION: usize = 120;
+
+/// Where a submission may be filed. Kept in step with the bundled packs so a
+/// submission cannot invent a category the mode picker will never show.
+const CATEGORIES: &[&str] = &[
+ "anime", "business", "general", "hacking", "history", "literature",
+ "movies", "nature", "philosophy", "programming", "quotes", "science",
+ "shell", "sysadmin", "technology",
+];
+
+/// Syntax languages the client can highlight. Anything else is prose.
+const LANGUAGES: &[&str] = &["javascript", "python", "rust", "clike", "shell"];
+
+pub fn router() -> Router<Arc<AppState>> {
+ Router::new()
+ .route("/api/texts", post(submit).get(approved))
+ .route("/api/texts/mine", get(mine))
+ .route("/api/texts/queue", get(queue))
+ .route("/api/texts/:id/review", post(review))
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SubmitBody {
+ pub category: String,
+ pub content: String,
+ pub attribution: Option<String>,
+ pub language: Option<String>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct SubmissionView {
+ pub id: String,
+ pub category: String,
+ pub content: String,
+ pub attribution: Option<String>,
+ pub language: Option<String>,
+ pub status: String,
+ pub reject_reason: Option<String>,
+ pub created_at: String,
+ /// Only populated on the moderation queue.
+ pub submitted_by: Option<String>,
+}
+
+fn row_to_view(row: &sqlx::postgres::PgRow) -> SubmissionView {
+ SubmissionView {
+ id: row.try_get("id").unwrap_or_default(),
+ category: row.try_get("category").unwrap_or_default(),
+ content: row.try_get("content").unwrap_or_default(),
+ attribution: row.try_get("attribution").unwrap_or(None),
+ language: row.try_get("language").unwrap_or(None),
+ status: row.try_get("status").unwrap_or_default(),
+ reject_reason: row.try_get("reject_reason").unwrap_or(None),
+ created_at: row.try_get("created_at").unwrap_or_default(),
+ submitted_by: row.try_get("submitted_by").unwrap_or(None),
+ }
+}
+
+/// Rejects the things a passage must never contain, whatever else it says.
+/// Control characters break the per-character rendering, and a newline makes
+/// the passage untypeable in a single-line input.
+fn clean(content: &str) -> Result<String, AppError> {
+ let trimmed = content.trim();
+ if trimmed.chars().any(|c| c.is_control()) {
+ return Err(AppError::InvalidInput(
+ "a passage must be a single line with no control characters".into(),
+ ));
+ }
+ let count = trimmed.chars().count();
+ if count < MIN_CONTENT || count > MAX_CONTENT {
+ return Err(AppError::InvalidInput(format!(
+ "a passage must be between {MIN_CONTENT} and {MAX_CONTENT} characters"
+ )));
+ }
+ Ok(trimmed.to_string())
+}
+
+async fn submit(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+ Json(body): Json<SubmitBody>,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user_or_token(&state.db, &jar, &headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+
+ // Shares the stats limiter's shape: keyed by user, generous enough for a
+ // genuine contributor and mean enough to stop a script filling the queue.
+ if !state.submission_rate_limiter.check(user.id.clone()) {
+ return Err(AppError::RateLimited);
+ }
+
+ if !CATEGORIES.contains(&body.category.as_str()) {
+ return Err(AppError::InvalidInput("unknown category".into()));
+ }
+ let content = clean(&body.content)?;
+ let attribution = body.attribution.map(|a| a.trim().to_string()).filter(|a| !a.is_empty());
+ if attribution.as_ref().is_some_and(|a| a.chars().count() > MAX_ATTRIBUTION) {
+ return Err(AppError::InvalidInput("attribution is too long".into()));
+ }
+ let language = body.language.filter(|l| LANGUAGES.contains(&l.as_str()));
+
+ let result = sqlx::query(
+ "INSERT INTO text_submissions (id, user_id, category, content, attribution, language, status, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)",
+ )
+ .bind(uuid::Uuid::new_v4().to_string())
+ .bind(&user.id)
+ .bind(&body.category)
+ .bind(&content)
+ .bind(&attribution)
+ .bind(&language)
+ .bind(crate::auth::format_timestamp(OffsetDateTime::now_utc()))
+ .execute(&state.db)
+ .await;
+
+ match result {
+ Ok(_) => Ok(Json(serde_json::json!({ "status": "pending" }))),
+ // The unique index on the normalised content is the dedupe: someone
+ // has already proposed this passage, which is not the submitter's
+ // mistake and should not read like an error.
+ Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(AppError::InvalidInput(
+ "that passage has already been submitted".into(),
+ )),
+ Err(e) => Err(AppError::Internal(e.into())),
+ }
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ApprovedQuery {
+ pub since: Option<String>,
+}
+
+/// Everything approved, for the client to merge into its bundled pool. Public
+/// and unauthenticated: these are the passages the app types.
+async fn approved(
+ State(state): State<Arc<AppState>>,
+ Query(q): Query<ApprovedQuery>,
+) -> Result<impl IntoResponse, AppError> {
+ let rows = sqlx::query(
+ "SELECT id, category, content, attribution, language, status, reject_reason, created_at,
+ NULL::text as submitted_by
+ FROM text_submissions
+ WHERE status = 'approved' AND ($1::text IS NULL OR created_at > $1)
+ ORDER BY created_at DESC
+ LIMIT 5000",
+ )
+ .bind(&q.since)
+ .fetch_all(&state.db)
+ .await?;
+
+ let items: Vec<SubmissionView> = rows.iter().map(row_to_view).collect();
+ Ok(Json(items))
+}
+
+async fn mine(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+) -> Result<impl IntoResponse, AppError> {
+ let user = current_user_or_token(&state.db, &jar, &headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+ let rows = sqlx::query(
+ "SELECT id, category, content, attribution, language, status, reject_reason, created_at,
+ NULL::text as submitted_by
+ FROM text_submissions WHERE user_id = $1 ORDER BY created_at DESC LIMIT 200",
+ )
+ .bind(&user.id)
+ .fetch_all(&state.db)
+ .await?;
+ let items: Vec<SubmissionView> = rows.iter().map(row_to_view).collect();
+ Ok(Json(items))
+}
+
+async fn require_moderator(
+ state: &AppState,
+ jar: &CookieJar,
+ headers: &HeaderMap,
+) -> Result<(), AppError> {
+ let user = current_user_or_token(&state.db, jar, headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+ let is_moderator: bool = sqlx::query_scalar("SELECT is_moderator FROM users WHERE id = $1")
+ .bind(&user.id)
+ .fetch_optional(&state.db)
+ .await?
+ .unwrap_or(false);
+ if !is_moderator {
+ // Unauthorized rather than NotFound: the caller is signed in, they
+ // simply are not a moderator, and saying so is not a leak.
+ return Err(AppError::Unauthorized);
+ }
+ Ok(())
+}
+
+async fn queue(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+) -> Result<impl IntoResponse, AppError> {
+ require_moderator(&state, &jar, &headers).await?;
+ let rows = sqlx::query(
+ "SELECT s.id, s.category, s.content, s.attribution, s.language, s.status,
+ s.reject_reason, s.created_at, u.username as submitted_by
+ FROM text_submissions s JOIN users u ON u.id = s.user_id
+ WHERE s.status = 'pending'
+ ORDER BY s.created_at ASC
+ LIMIT 200",
+ )
+ .fetch_all(&state.db)
+ .await?;
+ let items: Vec<SubmissionView> = rows.iter().map(row_to_view).collect();
+ Ok(Json(items))
+}
+
+#[derive(Debug, Deserialize)]
+pub struct ReviewBody {
+ /// "approve" or "reject".
+ pub decision: String,
+ pub reason: Option<String>,
+}
+
+async fn review(
+ State(state): State<Arc<AppState>>,
+ jar: CookieJar,
+ headers: HeaderMap,
+ Path(id): Path<String>,
+ Json(body): Json<ReviewBody>,
+) -> Result<impl IntoResponse, AppError> {
+ require_moderator(&state, &jar, &headers).await?;
+ let reviewer = current_user_or_token(&state.db, &jar, &headers)
+ .await
+ .ok_or(AppError::Unauthorized)?;
+
+ let status = match body.decision.as_str() {
+ "approve" => "approved",
+ "reject" => "rejected",
+ _ => return Err(AppError::InvalidInput("decision must be approve or reject".into())),
+ };
+
+ let affected = sqlx::query(
+ "UPDATE text_submissions
+ SET status = $1, reject_reason = $2, reviewed_by = $3, reviewed_at = $4
+ WHERE id = $5 AND status = 'pending'",
+ )
+ .bind(status)
+ .bind(body.reason.as_deref().filter(|r| !r.trim().is_empty()))
+ .bind(&reviewer.id)
+ .bind(crate::auth::format_timestamp(OffsetDateTime::now_utc()))
+ .bind(&id)
+ .execute(&state.db)
+ .await?
+ .rows_affected();
+
+ if affected == 0 {
+ // Either it does not exist or someone else already reviewed it.
+ return Err(AppError::NotFound);
+ }
+ Ok(Json(serde_json::json!({ "status": status })))
+}