diff options
| author | srdusr <[email protected]> | 2025-12-14 09:24:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-12-14 09:24:00 +0200 |
| commit | 3dbebfbc9345d2603908f32c0dabebc0ff21feb3 (patch) | |
| tree | 61a4f60187046a087424845eb8e22239ccd7d6ee /crates/server | |
| parent | 8fc4440150ce84c9cc3f5ec13d50beb58a5b65bc (diff) | |
| download | typerpunk-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.sql | 28 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 2 | ||||
| -rw-r--r-- | crates/server/src/state.rs | 4 | ||||
| -rw-r--r-- | crates/server/src/texts.rs | 286 |
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 }))) +} |