whaleprep/src/auth.rs
dev dc754fe996 Student sign-in UX, gzip, landing stats cache
Sign-in page rewritten for non-technical students: Google-first when
configured, plain-language email path with honest 30-day scope, recovery
note, placeholders, full-width CTA. deploy/termux-start.sh sources .env
for GOOGLE_* credentials; deploy/env.example documents the 5-minute
Cloud Console setup; .gitignore covers secrets, DBs, pid/log files.
bench scripts send a browser UA so Cloudflare bot checks stop 403ing
local load tests. Landing bank counts cached 60s (five aggregate
subselects no longer run per hit). gzip compression on all responses.
2026-10-12 23:33:33 +05:30

203 lines
No EOL
7.3 KiB
Rust

use axum::extract::{Form, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse};
use chrono::Utc;
use serde::Deserialize;
use sqlx::SqlitePool;
use std::sync::Arc;
use crate::google::Google;
use crate::views;
pub const COOKIE: &str = "pyq_session";
pub struct Ctx {
pub pool: SqlitePool,
pub base: String,
pub google_client_id: String,
pub google: Option<Arc<Google>>,
/// Landing-page bank counts, refreshed at most once a minute. They only
/// change on import, so there is no reason to run five aggregate
/// subselects on every homepage hit.
pub stats_cache: std::sync::Mutex<(std::time::Instant, (i64, i64, i64, i64, i64))>,
}
impl Ctx {
pub async fn bank_stats(&self) -> (i64, i64, i64, i64, i64) {
{
let guard = self.stats_cache.lock().unwrap();
if guard.0.elapsed() < std::time::Duration::from_secs(60) {
return guard.1;
}
}
let fresh: (i64, i64, i64, i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM questions),(SELECT COUNT(DISTINCT subject||'-'||year||'-'||shift) FROM questions),(SELECT COUNT(*) FROM subtopics),(SELECT COUNT(DISTINCT subject) FROM questions),(SELECT COUNT(*) FROM users)",
)
.fetch_one(&self.pool)
.await
.unwrap_or((0, 0, 0, 0, 0));
*self.stats_cache.lock().unwrap() =
(std::time::Instant::now(), fresh);
fresh
}
}
impl Ctx {
/// Returns the signed-in user id and display name, or a redirect to sign-in.
pub async fn require_user(&self, h: &HeaderMap) -> Result<(String, String), axum::response::Response> {
match user_id(&self.pool, h).await {
Some(id) => {
let name: String = sqlx::query_scalar("SELECT name FROM users WHERE id=?")
.bind(&id)
.fetch_one(&self.pool)
.await
.unwrap_or_else(|_| "Student".into());
Ok((id, name))
}
None => Err(redirect(&format!("{}/student", self.base))),
}
}
}
pub async fn user_id(pool: &SqlitePool, h: &HeaderMap) -> Option<String> {
let raw = h.get(COOKIE).and_then(|v| v.to_str().ok()).or_else(|| {
h.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
})?;
let mut token: Option<&str> = None;
for part in raw.split(';') {
if let Some((k, v)) = part.trim().split_once('=')
&& k == COOKIE
{
token = Some(v);
}
}
let token = token?;
tracing::debug!("session token={token}");
match sqlx::query_scalar::<_, String>("SELECT user_id FROM sessions WHERE token=?")
.bind(token)
.fetch_optional(pool)
.await
{
Ok(found) => {
tracing::debug!("session lookup -> {:?}", found);
found
}
Err(e) => {
tracing::error!("session lookup failed: {e}");
None
}
}
}
pub fn redirect(to: &str) -> axum::response::Response {
axum::response::Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, to)
.body(axum::body::Body::empty())
.expect("static redirect response")
}
pub fn html_page(body: String, code: StatusCode) -> axum::response::Response {
(code, Html(body)).into_response()
}
pub async fn student_post(State(ctx): State<Arc<Ctx>>, Form(f): Form<StudentForm>) -> axum::response::Response {
let name = f.name.trim();
let email = f.email.trim().to_lowercase();
let next = f
.next
.filter(|n| n.starts_with('/') && !n.contains(".."))
.unwrap_or_else(|| "/build".into());
if name.is_empty() || name.chars().count() > 80 {
return html_page(views::student(&ctx, &next, Some("Enter the name your account shows."), None), StatusCode::BAD_REQUEST);
}
if !email.contains('@') || email.len() > 120 {
return html_page(views::student(&ctx, &next, Some("That email address does not look right."), None), StatusCode::BAD_REQUEST);
}
let candidate = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO users(id,name,email,created_at) VALUES(?,?,?,?) ON CONFLICT(email) DO UPDATE SET name=excluded.name")
.bind(&candidate)
.bind(name)
.bind(&email)
.bind(Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
.ok();
let uid: String = sqlx::query_scalar("SELECT id FROM users WHERE email=?")
.bind(&email)
.fetch_one(&ctx.pool)
.await
.unwrap_or(candidate);
let token = uuid::Uuid::new_v4().simple().to_string();
sqlx::query("INSERT INTO sessions(token,user_id,created_at) VALUES(?,?,?)")
.bind(&token)
.bind(&uid)
.bind(Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
.ok();
let cookie = format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000");
axum::response::Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, format!("{}{}", ctx.base, next))
.header(axum::http::header::SET_COOKIE, cookie)
.body(axum::body::Body::empty())
.expect("static redirect response")
}
/// Creates a session for a verified identity and returns the redirect that carries it.
pub async fn start_session(ctx: &Ctx, uid: &str, name: &str, email: &str, next: Option<String>) -> axum::response::Response {
let _ = sqlx::query("UPDATE users SET name=?, email=? WHERE id=?").bind(name).bind(email).bind(uid).execute(&ctx.pool).await;
let token = uuid::Uuid::new_v4().simple().to_string();
if let Err(e) = sqlx::query("INSERT INTO sessions(token,user_id,created_at) VALUES(?,?,?)")
.bind(&token)
.bind(uid)
.bind(Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
{
tracing::error!("could not start session: {e}");
}
tracing::info!("sign in: {email}");
let to = next.filter(|n| n.starts_with('/') && !n.contains("..")).unwrap_or_else(|| "/build".into());
axum::response::Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, format!("{}{}", ctx.base, to))
.header(
axum::http::header::SET_COOKIE,
format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000"),
)
.body(axum::body::Body::empty())
.expect("static redirect response")
}
#[derive(Deserialize)]
pub struct StudentForm {
pub name: String,
pub email: String,
#[serde(default)]
pub next: Option<String>,
}
pub async fn logout(State(ctx): State<Arc<Ctx>>, h: HeaderMap) -> axum::response::Response {
if let Some(uid) = user_id(&ctx.pool, &h).await {
tracing::info!("sign out for {uid}");
}
if let Some(raw) = h.get(COOKIE).and_then(|c| c.to_str().ok())
&& let Some((_, v)) = raw.split_once('=')
{
let token = v.split(';').next().unwrap_or_default();
sqlx::query("DELETE FROM sessions WHERE token=?").bind(token).execute(&ctx.pool).await.ok();
}
let mut r = redirect(&format!("{}/", ctx.base));
r.headers_mut().insert(
axum::http::header::SET_COOKIE,
format!("{COOKIE}=; Path=/; HttpOnly; Max-Age=0").parse().expect("valid cookie"),
);
r
}