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.
This commit is contained in:
dev 2026-10-12 23:33:33 +05:30
parent 5f4539342a
commit dc754fe996
11 changed files with 187 additions and 40 deletions

4
.gitignore vendored
View file

@ -1 +1,5 @@
/target
.env
whaleprep.db*
*.pid
*.log

78
Cargo.lock generated
View file

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

View file

@ -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"] }

13
deploy/env.example Normal file
View file

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

View file

@ -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 </dev/null >> "$LOG_FILE" 2>&1 &
NEW_PID=$!

View file

@ -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():

View file

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

View file

@ -16,6 +16,30 @@ pub struct Ctx {
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 {

View file

@ -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<CtxArc>) -> 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)
}

View file

@ -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#"<p class="bad-t" role="alert">{m}</p>"#)).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#"<div id="google-btn" style="display:flex;justify-content:center;margin:18px 0;min-height:44px"></div>
<form method="post" action="{b}/student/google" id="google-form" hidden>
<input type="hidden" name="credential" value="">
<input type="hidden" name="next" value="{next}">
</form>
<p class="dim" id="gsi-status" role="status">loading the Google button&hellip;</p>"#,
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#"<div class="signin-primary">
<div id="google-btn" style="display:flex;justify-content:center;margin:6px 0 4px;min-height:44px"></div>
<form method="post" action="{b}/student/google" id="google-form" hidden>
<input type="hidden" name="credential" value="">
<input type="hidden" name="next" value="{next}">
</form>
<p class="dim" id="gsi-status" role="status">loading the Google button&hellip;</p>
</div>"#,
b = ctx.base,
next = esc(next)
)
} else {
String::new()
};
let divider = if google_on {
"<div class=\"signin-or\" aria-hidden=\"true\"><span>or</span></div>".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 <b>this browser only</b> — 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 <b>this browser for 30 days</b> — use the same email here and everything is still waiting."
};
let body = format!(
r#"<h1>Sign in</h1>
<p class="meta">{intro}</p>
<p class="meta">One tap and your scores, mistakes and weak topics are saved.</p>
{google_block}
<h2>{action}</h2>
{divider}
<h2>{email_head}</h2>
<p class="meta">{email_note}</p>
<form method="post" action="{b}/student" style="max-width:420px;margin-top:8px">
<input type="hidden" name="next" value="{next}">
<label for="name">name</label><input id="name" name="name" type="text" autocomplete="name" required>
<label for="email">email</label><input id="email" name="email" type="email" autocomplete="email" required>
<label for="name">your name</label><input id="name" name="name" type="text" autocomplete="name" placeholder="e.g. Aarav Sharma" required>
<label for="email">email</label><input id="email" name="email" type="email" autocomplete="email" placeholder="you@example.com" required>
{e}
<div style="margin-top:14px"><button class="btn primary" type="submit">{action}</button></div>
<div style="margin-top:14px"><button class="btn primary block" type="submit">start practising</button></div>
</form>
<p class="dim" style="margin-top:14px;max-width:60ch">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.</p>
{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,

View file

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