diff --git a/.gitignore b/.gitignore index ea8c4bf..9512e44 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /target +.env +whaleprep.db* +*.pid +*.log diff --git a/Cargo.lock b/Cargo.lock index f3520cc..52fb601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -32,6 +38,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "atoi" version = "2.0.0" @@ -271,6 +289,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + [[package]] name = "const-oid" version = "0.9.6" @@ -359,6 +394,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -645,6 +689,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "flume" version = "0.12.0" @@ -1381,6 +1436,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -2175,6 +2240,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2706,6 +2777,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -3323,6 +3395,12 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index ae10cfc..ace488a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ sha2 = "0.11.0" sqlx = { version = "0.9.0", features = ["sqlite", "runtime-tokio", "macros", "chrono", "uuid"] } tokio = { version = "1.53.2", features = ["full"] } tower-cookies = "0.11.0" -tower-http = { version = "0.7.1", features = ["fs"] } +tower-http = { version = "0.7.1", features = ["fs", "compression-gzip"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } uuid = { version = "1.28.0", features = ["v4", "serde"] } diff --git a/deploy/env.example b/deploy/env.example new file mode 100644 index 0000000..41bb0f6 --- /dev/null +++ b/deploy/env.example @@ -0,0 +1,13 @@ +# Copy to .env next to the binary (repo root on the host) and fill in. +# The start script sources this file; it is git-ignored, never committed. +# +# How to get these (5 minutes, needs a Google account): +# 1. Open https://console.cloud.google.com/apis/credentials +# 2. Create Credentials -> OAuth client ID -> Web application. +# 3. Under "Authorised JavaScript origins" add exactly: +# https://whaleprep.oiupoyt.space +# Under "Authorised redirect URIs" add exactly: +# https://whaleprep.oiupoyt.space/student/google/callback +# 4. Paste the client ID and secret below, restart the service. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/deploy/termux-start.sh b/deploy/termux-start.sh index 61fa873..456dffb 100755 --- a/deploy/termux-start.sh +++ b/deploy/termux-start.sh @@ -16,6 +16,12 @@ if [ -f "$PID_FILE" ]; then fi fi [ -x ./target/release/whaleprep ] || { echo "missing ./target/release/whaleprep — run: cargo build --release"; exit 1; } +# Optional secrets: GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET, one per line. +# Without them the app runs with the name+email form only. +[ -f ./.env ] && set -a && . ./.env && set +a || true +if [ -z "${GOOGLE_CLIENT_ID:-}" ]; then + echo "note: GOOGLE_CLIENT_ID unset — Google button stays hidden, email form only" +fi echo "Starting WhalePrep on :$PORT (db $DB_FILE)..." PYQ_DB="$DB_FILE" PYQ_ADDR="127.0.0.1:$PORT" RUST_LOG=info nohup ./target/release/whaleprep > "$LOG_FILE" 2>&1 & NEW_PID=$! diff --git a/scripts/bench.py b/scripts/bench.py index 8977f21..41ac269 100755 --- a/scripts/bench.py +++ b/scripts/bench.py @@ -18,6 +18,7 @@ CONC = int(sys.argv[3]) if len(sys.argv) > 3 else 20 jar = http.cookiejar.CookieJar() opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) +opener.addheaders = [("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) WhalePrep-bench/1.0")] def sign_in(): diff --git a/scripts/bench_write.py b/scripts/bench_write.py index 85fe0cd..d906ead 100755 --- a/scripts/bench_write.py +++ b/scripts/bench_write.py @@ -22,6 +22,7 @@ DB = sys.argv[4] if len(sys.argv) > 4 else "/tmp/bench.db" def student_session(i): jar = http.cookiejar.CookieJar() opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) +opener.addheaders = [("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) WhalePrep-bench/1.0")] body = urllib.parse.urlencode( {"name": f"Load{i}", "email": f"load{i}@example.com", "next": "/build"} ).encode() diff --git a/src/auth.rs b/src/auth.rs index 08b8390..b942f35 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -16,6 +16,30 @@ pub struct Ctx { pub base: String, pub google_client_id: String, pub google: Option>, + /// 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 { diff --git a/src/main.rs b/src/main.rs index f72a183..993b540 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,7 +59,16 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Google sign-in enabled for {}", &client_id[..client_id.len().min(16)]); Some(Arc::new(google::Google::new(&client_id, &client_secret))) }; - let ctx = Arc::new(Ctx { pool, base: base.clone(), google_client_id: client_id, google }); + let ctx = Arc::new(Ctx { + pool, + base: base.clone(), + google_client_id: client_id, + google, + stats_cache: std::sync::Mutex::new(( + std::time::Instant::now() - std::time::Duration::from_secs(61), + (0, 0, 0, 0, 0), + )), + }); let static_dir = format!("{}/static", std::env::current_dir()?.display()); let app = Router::new() @@ -83,6 +92,7 @@ async fn main() -> anyhow::Result<()> { .route("/api/attempt/{attempt}/state", get(attempt_state)) .route("/api/attempt/{attempt}/submit", post(submit_api)) .nest_service("/static", get_service(ServeDir::new(static_dir))) + .layer(tower_http::compression::CompressionLayer::new()) .with_state(ctx); let listener = tokio::net::TcpListener::bind(&addr).await?; @@ -92,12 +102,7 @@ async fn main() -> anyhow::Result<()> { } async fn home(State(ctx): State) -> impl IntoResponse { - let c: (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(&ctx.pool) - .await - .unwrap_or((0, 0, 0, 0, 0)); + let c = ctx.bank_stats().await; hx(views::landing(&ctx, c.0, c.1, c.2, c.3, c.4, false), StatusCode::OK) } diff --git a/src/views.rs b/src/views.rs index 4a36ea8..bde91a8 100644 --- a/src/views.rs +++ b/src/views.rs @@ -195,48 +195,57 @@ pub type Fallback = Option<(String, String, String)>; pub fn student(ctx: &Ctx, next: &str, err: Option<&str>, fallback: Fallback) -> String { let e = err.map(|m| format!(r#""#)).unwrap_or_default(); - let (intro, google_block, action) = match (&fallback, ctx.google.is_some()) { - (Some(_), true) => ( - "Sign in with Google and every paper you build, submit and analyse is attached to that account, so a score is still there on the next device.", - format!( - r#"
- -

loading the Google button…

"#, - b = ctx.base, - next = esc(next) - ), - "sign in with an email instead", - ), - (None, true) => ( - "Google sign-in is available but its button did not get its parameters. Refresh the page, or use the form below.", - String::new(), - "sign in", - ), - _ => ( - "Google sign-in is switched off on this server because no client id was configured. The form below keys your papers to an email so the whole flow still works.", - String::new(), - "sign in", - ), + let google_on = ctx.google.is_some() && fallback.is_some(); + // Students are non-technical: one primary action, plain words, no jargon. + // Google first when configured (scores follow the account across devices); + // otherwise the name+email form, honestly labeled as this-device-only. + let google_block = if google_on { + format!( + r#""#, + b = ctx.base, + next = esc(next) + ) + } else { + String::new() + }; + let divider = if google_on { + "
or
".to_string() + } else { + String::new() + }; + let email_head = if google_on { "use just a name and email" } else { "sign in" }; + let email_note = if google_on { + "No Google account? Type any name and email. Your papers stay on this browser only — use the same email on another device and the scores won't follow." + } else { + "Type any name and email to start practising. Your papers stay on this browser for 30 days — use the same email here and everything is still waiting." }; let body = format!( r#"

Sign in

-

{intro}

+

One tap and your scores, mistakes and weak topics are saved.

{google_block} -

{action}

+{divider} +

{email_head}

+

{email_note}

- - + + {e} -
+
+

Lost access on a new phone? Sign in with the same Google account and everything returns. With the email form, scores live in this browser only — there is no password to forget because there is no password.

{gsi_script}"#, - intro = intro, google_block = google_block, - action = action, + divider = divider, + email_head = email_head, + email_note = email_note, b = ctx.base, next = esc(next), e = e, diff --git a/static/style.css b/static/style.css index c0ba3c2..86a601a 100644 --- a/static/style.css +++ b/static/style.css @@ -232,3 +232,9 @@ input[type=text],input[type=email],input[type=number],select{min-height:44px;pad /* ===== mistake tagging ===== */ .mistake-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:1px solid var(--border)} .btn.sm{min-height:36px;padding:7px 12px;font-size:.72rem} + +/* ===== sign-in ===== */ +.signin-primary{max-width:420px} +.signin-or{display:flex;align-items:center;gap:12px;max-width:420px;margin:18px 0 4px;color:var(--dim);font-size:.75rem} +.signin-or::before,.signin-or::after{content:"";flex:1;height:1px;background:var(--border)} +.signin-or span{padding:0 2px}