split accounts from sessions and hash tokens at rest

This commit is contained in:
2026-08-23 17:51:47 +03:00
parent 4dc4d615e5
commit a9abf582f5
5 changed files with 230 additions and 118 deletions
+26 -11
View File
@@ -252,10 +252,12 @@ pub async fn admin_setup(
State(state): State<Arc<AppState>>,
Json(req): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
let dbi = state.db.lock().await;
if db::has_admin_account(&dbi.db) {
return Err((StatusCode::FORBIDDEN, "admin already configured").into_response());
// Optional first-run gate: if ADMIN_SETUP_KEY is set, a matching key must
// be supplied so a random visitor cannot claim the instance first.
if let Ok(key) = std::env::var("ADMIN_SETUP_KEY") {
if req.get("key").and_then(|v| v.as_str()) != Some(key.as_str()) {
return Err((StatusCode::UNAUTHORIZED, "invalid setup key").into_response());
}
}
let username = req.get("username").and_then(|v| v.as_str()).unwrap_or("");
@@ -281,7 +283,20 @@ pub async fn admin_setup(
}
};
db::create_admin_account(&dbi.db, username, &stored);
// Atomic check-and-create inside a transaction so two concurrent setup
// requests cannot both pass the "no admin yet" check and create two admins.
let dbi = state.db.lock().await;
let tx = match dbi.db.unchecked_transaction() {
Ok(tx) => tx,
Err(_) => return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response()),
};
if db::has_admin_account(&tx) {
return Err((StatusCode::FORBIDDEN, "admin already configured").into_response());
}
db::create_admin_account(&tx, username, &stored);
if tx.commit().is_err() {
return Err((StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response());
}
db::log_admin_action(&dbi.db, "admin_setup", Some(username), None);
Ok(Json(serde_json::json!({ "ok": true })))
@@ -388,7 +403,7 @@ pub async fn stats(
if let Ok(mut stmt) = conn.prepare(
"SELECT cc.submitted_by, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*), SUM(CASE WHEN cc.status = 'approved' THEN 1 ELSE 0 END)
FROM community_clues cc
LEFT JOIN sessions s ON cc.submitted_by = s.token
LEFT JOIN sessions s ON cc.submitted_by = s.account_id
LEFT JOIN settings st ON cc.submitted_by = st.session_token
GROUP BY cc.submitted_by ORDER BY COUNT(*) DESC LIMIT 10"
) {
@@ -468,9 +483,9 @@ pub async fn list_sessions(
check_admin(&headers).await?;
let dbi = state.db.lock().await;
let mut stmt = dbi.db.prepare(
"SELECT s.token, s.display_name, s.created_at, s.last_seen,
(SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.token AND sr.is_complete = 1),
(SELECT current_streak FROM streaks st WHERE st.session_token = s.token),
"SELECT s.account_id, s.display_name, s.created_at, s.last_seen,
(SELECT COUNT(*) FROM solve_records sr WHERE sr.session_token = s.account_id AND sr.is_complete = 1),
(SELECT current_streak FROM streaks st WHERE st.session_token = s.account_id),
COALESCE(s.banned, 0)
FROM sessions s ORDER BY s.last_seen DESC LIMIT 100"
).unwrap();
@@ -495,7 +510,7 @@ pub async fn delete_session(
) -> Result<Json<serde_json::Value>, Response> {
check_admin(&headers).await?;
let dbi = state.db.lock().await;
dbi.db.execute("DELETE FROM sessions WHERE token = ?1", [&token]).ok();
dbi.db.execute("DELETE FROM sessions WHERE account_id = ?1", [&token]).ok();
dbi.db.execute("DELETE FROM settings WHERE session_token = ?1", [&token]).ok();
db::log_admin_action(&dbi.db, "delete_session", Some(&token), None);
invalidate_stats_cache();
@@ -1395,7 +1410,7 @@ pub async fn list_community_puzzles(
let dbi = state.db.lock().await;
let mut stmt = dbi.db.prepare(
"SELECT up.id, up.title, s.display_name, up.width, up.height, up.created_at, up.updated_at
FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.token
FROM user_puzzles up LEFT JOIN sessions s ON up.session_token = s.account_id
WHERE up.status = 'published' ORDER BY up.updated_at DESC LIMIT 200"
).unwrap();
let puzzles: Vec<serde_json::Value> = stmt.query_map([], |row| {
+2 -2
View File
@@ -751,7 +751,7 @@ pub async fn v1_create_pack(
let id = Uuid::new_v4().to_string();
let dbi = state.db.lock().await;
let (_, author_name) = db::get_or_create_session(&dbi.db, &auth.session_token);
let author_name = db::account_display_name(&dbi.db, &auth.session_token);
let ok = db::create_pack(&dbi.db, &id,
&crate::security::sanitize_html(name),
desc.map(|d| crate::security::sanitize_html(d)).as_deref(),
@@ -774,7 +774,7 @@ pub async fn v1_me(
let dbi = state.db.lock().await;
let (current, best, last_date) = db::get_streak(&dbi.db, &auth.session_token);
let has_passkey = db::has_passkey(&dbi.db, &auth.session_token);
let (_, name) = db::get_or_create_session(&dbi.db, &auth.session_token);
let name = db::account_display_name(&dbi.db, &auth.session_token);
api_ok(serde_json::json!({
"display_name": name,
+161 -80
View File
@@ -490,22 +490,73 @@ pub fn init_db(path: &Path) -> Connection {
db.execute("ALTER TABLE solve_records ADD COLUMN cell_results TEXT", [])
.ok();
// additive identity-refactor scaffolding (safe, behavior-unchanged):
// stable accounts alongside session credentials, so existing installs
// upgrade automatically on startup.
db.execute_batch(
"CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY
);",
)
.unwrap();
let has_account_col: bool = db
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'account_id'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if !has_account_col {
db.execute("ALTER TABLE sessions ADD COLUMN account_id TEXT", []).ok();
}
db.execute_batch(
"UPDATE sessions SET account_id = token WHERE account_id IS NULL;",
)
.unwrap();
db.execute_batch(
"INSERT OR IGNORE INTO accounts (id) SELECT DISTINCT account_id FROM sessions WHERE account_id IS NOT NULL;",
)
.unwrap();
// one-time: hash legacy plaintext session tokens at rest. A session whose
// token still equals its account_id has not been hashed yet (after hashing
// they diverge), so this is idempotent across restarts and installs.
let legacy: Vec<String> = {
let mut stmt = db
.prepare("SELECT token FROM sessions WHERE token = account_id")
.unwrap();
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
rows.filter_map(Result::ok).collect()
};
if !legacy.is_empty() {
if let Ok(tx) = db.unchecked_transaction() {
for t in legacy {
let _ = tx.execute(
"UPDATE sessions SET token = ?1 WHERE token = ?2",
params![token_hash(&t), &t],
);
}
let _ = tx.commit();
}
}
db
}
pub fn ban_session(db: &Connection, token: &str) {
db.execute("UPDATE sessions SET banned = 1 WHERE token = ?1", [token])
db.execute("UPDATE sessions SET banned = 1 WHERE account_id = ?1", [token])
.ok();
}
pub fn unban_session(db: &Connection, token: &str) {
db.execute("UPDATE sessions SET banned = 0 WHERE token = ?1", [token])
db.execute("UPDATE sessions SET banned = 0 WHERE account_id = ?1", [token])
.ok();
}
pub fn is_banned(db: &Connection, token: &str) -> bool {
db.query_row(
"SELECT banned FROM sessions WHERE token = ?1",
"SELECT COUNT(*) FROM sessions WHERE account_id = ?1 AND banned = 1",
[token],
|r| r.get::<_, i64>(0),
)
@@ -557,7 +608,7 @@ pub fn cleanup_user_content(db: &Connection, token: &str) {
.ok();
// clear display name so deleted/banned users can't be looked up
db.execute(
"UPDATE sessions SET display_name = NULL WHERE token = ?1",
"UPDATE sessions SET display_name = NULL WHERE account_id = ?1",
[token],
)
.ok();
@@ -845,30 +896,79 @@ pub fn get_puzzle_daily_info(db: &Connection, puzzle_id: &str) -> Option<(String
}
// sessions
pub fn token_hash(token: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(token.as_bytes()))
}
fn new_account_id() -> String {
use rand::Rng;
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
// Resolve a bearer token to its account id, creating a fresh account+session
// when unknown. Returns (account id, display name).
pub fn get_or_create_session(db: &Connection, token: &str) -> (String, Option<String>) {
let now = chrono::Utc::now().to_rfc3339();
let key = token_hash(token);
let existing = db.query_row(
"SELECT token, display_name FROM sessions WHERE token = ?1",
[token],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
"SELECT account_id, display_name FROM sessions WHERE token = ?1",
[&key],
|row| Ok((row.get::<_, Option<String>>(0)?, row.get::<_, Option<String>>(1)?)),
);
if let Ok((t, name)) = existing {
if let Ok((account_id, name)) = existing {
let account_id = account_id.unwrap_or_else(|| key.clone());
db.execute(
"UPDATE sessions SET last_seen = ?1 WHERE token = ?2",
params![now, token],
params![now, &key],
)
.ok();
return (t, name);
return (account_id, name);
}
let account_id = new_account_id();
db.execute("INSERT OR IGNORE INTO accounts (id) VALUES (?1)", [&account_id]).ok();
db.execute(
"INSERT INTO sessions (token, created_at, last_seen) VALUES (?1, ?2, ?2)",
params![token, now],
"INSERT INTO sessions (token, account_id, created_at, last_seen) VALUES (?1, ?2, ?3, ?3)",
params![&key, &account_id, now],
)
.unwrap();
(token.to_string(), None)
(account_id, None)
}
// Mint a fresh session (new bearer) attached to an existing account, used by
// passkey login to resume an account on a new device.
pub fn create_session_for_account(db: &Connection, token: &str, account_id: &str) -> (String, Option<String>) {
let now = chrono::Utc::now().to_rfc3339();
let key = token_hash(token);
let name: Option<String> = db
.query_row(
"SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1",
[account_id],
|r| r.get(0),
)
.ok()
.flatten();
db.execute(
"INSERT INTO sessions (token, account_id, display_name, created_at, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)",
params![&key, account_id, name, now],
)
.unwrap();
(account_id.to_string(), name)
}
pub fn account_display_name(db: &Connection, account_id: &str) -> Option<String> {
db.query_row(
"SELECT display_name FROM sessions WHERE account_id = ?1 ORDER BY last_seen DESC LIMIT 1",
[account_id],
|r| r.get(0),
)
.ok()
.flatten()
}
pub fn get_user_agent_hash(db: &Connection, token: &str) -> Option<String> {
@@ -891,7 +991,7 @@ pub fn set_user_agent_hash(db: &Connection, token: &str, hash: &str) {
pub fn set_display_name(db: &Connection, token: &str, name: &str) {
db.execute(
"UPDATE sessions SET display_name = ?1 WHERE token = ?2",
"UPDATE sessions SET display_name = ?1 WHERE account_id = ?2",
params![name, token],
)
.ok();
@@ -1123,8 +1223,13 @@ pub fn get_community_clues(
}
pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool {
let tx = match db.unchecked_transaction() {
Ok(tx) => tx,
Err(_) => return false,
};
// check if already voted
let existing: Option<i32> = db
let existing: Option<i32> = tx
.query_row(
"SELECT vote FROM clue_votes WHERE session_token = ?1 AND clue_id = ?2",
params![token, clue_id],
@@ -1134,65 +1239,57 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool
if let Some(old_vote) = existing {
if old_vote == vote {
return false;
} // same vote, no change
return false; // same vote, no change; tx rolls back on drop
}
// undo old vote
if old_vote > 0 {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET upvotes = upvotes - 1 WHERE id = ?1",
[clue_id],
)
.ok();
);
} else {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET downvotes = downvotes - 1 WHERE id = ?1",
[clue_id],
)
.ok();
);
}
// apply new vote
if vote > 0 {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
[clue_id],
)
.ok();
);
} else {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
[clue_id],
)
.ok();
);
}
db.execute(
let _ = tx.execute(
"UPDATE clue_votes SET vote = ?1 WHERE session_token = ?2 AND clue_id = ?3",
params![vote, token, clue_id],
)
.ok();
);
} else {
// new vote
if vote > 0 {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET upvotes = upvotes + 1 WHERE id = ?1",
[clue_id],
)
.ok();
);
} else {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET downvotes = downvotes + 1 WHERE id = ?1",
[clue_id],
)
.ok();
);
}
db.execute(
let _ = tx.execute(
"INSERT INTO clue_votes (session_token, clue_id, vote) VALUES (?1, ?2, ?3)",
params![token, clue_id, vote],
)
.ok();
);
}
// auto-approve clues with net +3 votes, auto-reject at net -3
let (up, down): (i64, i64) = db
let (up, down): (i64, i64) = tx
.query_row(
"SELECT upvotes, downvotes FROM community_clues WHERE id = ?1",
[clue_id],
@@ -1201,20 +1298,18 @@ pub fn vote_clue(db: &Connection, token: &str, clue_id: i64, vote: i32) -> bool
.unwrap_or((0, 0));
if up - down >= 3 {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET status = 'approved' WHERE id = ?1 AND status = 'pending'",
[clue_id],
)
.ok();
);
} else if down - up >= 3 {
db.execute(
let _ = tx.execute(
"UPDATE community_clues SET status = 'rejected' WHERE id = ?1 AND status = 'pending'",
[clue_id],
)
.ok();
);
}
true
tx.commit().is_ok()
}
pub fn get_user_votes(db: &Connection, token: &str, clue_ids: &[i64]) -> Vec<(i64, i32)> {
@@ -1538,7 +1633,7 @@ pub fn list_published_puzzles(
let mut stmt = db.prepare(
"SELECT up.id, up.title, CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, up.width, up.height, up.updated_at, up.origin_domain
FROM user_puzzles up
LEFT JOIN sessions s ON up.session_token = s.token
LEFT JOIN sessions s ON up.session_token = s.account_id
LEFT JOIN settings st ON up.session_token = st.session_token
WHERE up.status = 'published' AND up.clues_json IS NOT NULL
ORDER BY up.updated_at DESC LIMIT ?1 OFFSET ?2"
@@ -2025,23 +2120,9 @@ pub fn import_user_data(
imported += 1;
}
// streak (only if better than current)
if let Some(streak) = data.get("streak") {
let imp_best = streak.get("best").and_then(|v| v.as_i64()).unwrap_or(0);
let imp_current = streak.get("current").and_then(|v| v.as_i64()).unwrap_or(0);
let imp_last = streak
.get("last_date")
.and_then(|v| v.as_str())
.unwrap_or("");
let (cur_current, cur_best, _) = get_streak(db, token);
if imp_best > cur_best as i64 || imp_current > cur_current as i64 {
db.execute(
"INSERT OR REPLACE INTO streaks (session_token, current_streak, best_streak, last_solve_date) VALUES (?1, ?2, ?3, ?4)",
params![token, std::cmp::max(imp_current, cur_current as i64), std::cmp::max(imp_best, cur_best as i64), imp_last],
).ok();
imported += 1;
}
}
// Ignore imported streak values: a client-supplied streak is untrusted and
// would let anyone inflate the streak leaderboard. Streaks are re-derived
// from actual solves by update_streak.
// achievements (merge - only add ones not already unlocked)
if let Some(achievements) = data.get("achievements").and_then(|v| v.as_array()) {
@@ -2199,7 +2280,7 @@ pub fn delete_account(db: &Connection, token: &str) {
[token],
)
.ok();
db.execute("DELETE FROM sessions WHERE token = ?1", [token])
db.execute("DELETE FROM sessions WHERE account_id = ?1", [token])
.ok();
}
@@ -2207,7 +2288,7 @@ pub fn delete_account(db: &Connection, token: &str) {
pub fn export_user_data(db: &Connection, token: &str) -> serde_json::Value {
let session: serde_json::Value = db
.query_row(
"SELECT display_name, created_at, last_seen FROM sessions WHERE token = ?1",
"SELECT display_name, created_at, last_seen FROM sessions WHERE account_id = ?1",
[token],
|row| {
Ok(serde_json::json!({
@@ -2295,7 +2376,7 @@ pub fn get_leaderboard_filtered(
let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE sr.puzzle_id = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
@@ -2325,7 +2406,7 @@ pub fn get_daily_leaderboard_filtered(
let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -2405,8 +2486,8 @@ pub fn cleanup_ephemeral_sessions(db: &Connection) -> i64 {
let sessions: Vec<(String, String, String)> = {
let mut stmt = db
.prepare(
"SELECT s.token, st.settings_json, s.last_seen FROM sessions s
JOIN settings st ON st.session_token = s.token",
"SELECT s.account_id, st.settings_json, s.last_seen FROM sessions s
JOIN settings st ON st.session_token = s.account_id",
)
.unwrap();
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
@@ -2738,7 +2819,7 @@ pub fn get_alltime_leaderboard(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used,
p.daily_date
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -2771,7 +2852,7 @@ pub fn get_weekly_leaderboard(
let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, MIN(sr.solve_time_secs) as best_time, sr.hints_used
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.preset = ?1 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -2800,7 +2881,7 @@ pub fn get_streak_leaderboard(db: &Connection, limit: usize) -> Vec<(String, i64
let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, st2.current_streak, st2.best_streak
FROM streaks st2
LEFT JOIN sessions s ON st2.session_token = s.token
LEFT JOIN sessions s ON st2.session_token = s.account_id
LEFT JOIN settings st ON st2.session_token = st.session_token
WHERE st2.current_streak > 0
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
@@ -2826,7 +2907,7 @@ pub fn get_completionist_leaderboard(db: &Connection, limit: usize) -> Vec<(Stri
let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, COUNT(*) as total
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE sr.is_complete = 1 AND sr.flagged = 0
AND (st.settings_json IS NULL OR json_extract(st.settings_json, '$.hideFromLeaderboards') IS NOT 1)
@@ -2857,7 +2938,7 @@ pub fn get_clean_leaderboard(
let mut stmt = db.prepare(
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2
@@ -3505,7 +3586,7 @@ pub fn get_daily_leaderboard_by_date(
let sql = if clean_only {
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
@@ -3516,7 +3597,7 @@ pub fn get_daily_leaderboard_by_date(
} else {
"SELECT CASE WHEN st.settings_json IS NOT NULL AND json_extract(st.settings_json, '$.privateProfile') IS 1 THEN NULL ELSE s.display_name END, sr.solve_time_secs, sr.hints_used
FROM solve_records sr
LEFT JOIN sessions s ON sr.session_token = s.token
LEFT JOIN sessions s ON sr.session_token = s.account_id
LEFT JOIN puzzles p ON sr.puzzle_id = p.id
LEFT JOIN settings st ON sr.session_token = st.session_token
WHERE p.daily_date = ?1 AND p.preset = ?2 AND sr.is_complete = 1 AND sr.flagged = 0
+11 -6
View File
@@ -2701,17 +2701,22 @@ pub async fn passkey_auth_finish(
}
}
// create a session with the original token
// mint a fresh session attached to the account this passkey belongs to
let dbi = state.db.lock().await;
let (token, name) = db::get_or_create_session(&dbi.db, &session_token);
let settings_json = db::get_settings(&dbi.db, &token);
let new_plain = crate::session::generate_token();
let (account_id, name) = db::create_session_for_account(&dbi.db, &new_plain, &session_token);
let settings_json = db::get_settings(&dbi.db, &account_id);
let cookie_max_age = serde_json::from_str::<serde_json::Value>(&settings_json)
.ok()
.and_then(|v| v.get("cookieLifetime")?.as_u64())
.unwrap_or(2_592_000);
let session = Session {
token, display_name: name, is_new: false, cookie_max_age,
token: account_id,
cookie_token: new_plain,
display_name: name,
is_new: true,
cookie_max_age,
};
Ok(with_session_cookie(&session, Json(serde_json::json!({
@@ -2740,7 +2745,7 @@ pub async fn get_session_info(
) -> Result<Response, AppError> {
let dbi = state.db.lock().await;
let info = dbi.db.query_row(
"SELECT created_at, last_seen FROM sessions WHERE token = ?1",
"SELECT created_at, last_seen FROM sessions WHERE account_id = ?1",
[&session.token],
|row| Ok(serde_json::json!({
"created_at": row.get::<_, String>(0)?,
@@ -3446,7 +3451,7 @@ pub async fn create_pack(
let id = Uuid::new_v4().to_string();
let dbi = state.db.lock().await;
let (_, author_name) = db::get_or_create_session(&dbi.db, &session.token);
let author_name = db::account_display_name(&dbi.db, &session.token);
let success = db::create_pack(
&dbi.db, &id,
&crate::security::sanitize_html(name),
+30 -19
View File
@@ -9,6 +9,7 @@ use crate::state::AppState;
pub struct Session {
pub token: String,
pub cookie_token: String,
pub display_name: Option<String>,
pub is_new: bool,
pub cookie_max_age: u64,
@@ -16,16 +17,16 @@ pub struct Session {
impl Session {
pub fn cookie_header(&self) -> String {
let secure = std::env::var("SECURE_COOKIES").as_deref() == Ok("true");
let secure = std::env::var("SECURE_COOKIES").as_deref() != Ok("false");
let same_site = if secure { "Strict" } else { "Lax" };
let secure_flag = if secure { "; Secure" } else { "" };
if self.cookie_max_age == 0 {
format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.token, same_site, secure_flag)
format!("cruciverb_session={}; Path=/; HttpOnly; SameSite={}{}", self.cookie_token, same_site, secure_flag)
} else {
format!(
"cruciverb_session={}; Path=/; HttpOnly; SameSite={}; Max-Age={}{}",
self.token, same_site, self.cookie_max_age, secure_flag
self.cookie_token, same_site, self.cookie_max_age, secure_flag
)
}
}
@@ -38,7 +39,7 @@ impl FromRequestParts<Arc<AppState>> for Session {
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let token = parts
let cookie_token = parts
.headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
@@ -57,15 +58,15 @@ impl FromRequestParts<Arc<AppState>> for Session {
.unwrap_or_default();
let dbi = state.db.lock().await;
let (mut token, mut name, mut is_new) = match token {
Some(t) if !t.is_empty() => {
let (token, name) = crate::db::get_or_create_session(&dbi.db, &t);
(token, name, false)
let (mut cookie_token, mut token, mut name, mut is_new) = match cookie_token {
Some(t) if is_valid_token(&t) => {
let (acct, name) = crate::db::get_or_create_session(&dbi.db, &t);
(t, acct, name, false)
}
_ => {
let new_token = generate_token();
let (token, name) = crate::db::get_or_create_session(&dbi.db, &new_token);
(token, name, true)
let (acct, name) = crate::db::get_or_create_session(&dbi.db, &new_token);
(new_token, acct, name, true)
}
};
@@ -82,14 +83,20 @@ impl FromRequestParts<Arc<AppState>> for Session {
.unwrap_or(false)
&& crate::db::has_passkey(&dbi.db, &token);
if binding_enabled && !user_agent_hash.is_empty() {
let stored = crate::db::get_user_agent_hash(&dbi.db, &token);
let key = crate::db::token_hash(&cookie_token);
let stored = crate::db::get_user_agent_hash(&dbi.db, &key);
match stored {
None => crate::db::set_user_agent_hash(&dbi.db, &token, &user_agent_hash),
None => crate::db::set_user_agent_hash(&dbi.db, &key, &user_agent_hash),
Some(s) if s != user_agent_hash => {
let new_token = generate_token();
let (t2, n2) = crate::db::get_or_create_session(&dbi.db, &new_token);
crate::db::set_user_agent_hash(&dbi.db, &t2, &user_agent_hash);
token = t2;
let new_plain = generate_token();
let (a2, n2) = crate::db::get_or_create_session(&dbi.db, &new_plain);
crate::db::set_user_agent_hash(
&dbi.db,
&crate::db::token_hash(&new_plain),
&user_agent_hash,
);
token = a2;
cookie_token = new_plain;
name = n2;
is_new = true;
settings_json = crate::db::get_settings(&dbi.db, &token);
@@ -105,11 +112,15 @@ impl FromRequestParts<Arc<AppState>> for Session {
.and_then(|v| v.get("cookieLifetime")?.as_u64())
.unwrap_or(2_592_000);
Ok(Session { token, display_name: name, is_new, cookie_max_age })
Ok(Session { token, cookie_token, display_name: name, is_new, cookie_max_age })
}
}
fn generate_token() -> String {
fn is_valid_token(t: &str) -> bool {
t.len() == 64 && t.chars().all(|c| c.is_ascii_hexdigit())
}
pub(crate) fn generate_token() -> String {
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
bytes.iter().map(|b| format!("{:02x}", b)).collect()
@@ -118,4 +129,4 @@ fn generate_token() -> String {
fn ua_hash(ua: &str) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(ua.as_bytes()))
}
}