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:
parent
5f4539342a
commit
dc754fe996
11 changed files with 187 additions and 40 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1 +1,5 @@
|
|||
/target
|
||||
.env
|
||||
whaleprep.db*
|
||||
*.pid
|
||||
*.log
|
||||
|
|
|
|||
78
Cargo.lock
generated
78
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
13
deploy/env.example
Normal 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=
|
||||
|
|
@ -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=$!
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
24
src/auth.rs
24
src/auth.rs
|
|
@ -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 {
|
||||
|
|
|
|||
19
src/main.rs
19
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<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)
|
||||
}
|
||||
|
||||
|
|
|
|||
73
src/views.rs
73
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#"<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…</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…</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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue