yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
use crate::db::Db;
use crate::util::{handle_from_email, norm_email, now, random_token, sha256_hex};
use axum::http::HeaderMap;
pub const SESSION_COOKIE: &str = "okibi_session";
const SESSION_TTL: i64 = 60 * 60 * 24 * 30; // 30 days
const MAGIC_TTL: i64 = 60 * 15; // 15 min
#[derive(Clone, Debug, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub handle: String,
pub email: String,
pub display_name: String,
pub is_admin: i64,
}
/// Is this email on the atsm allowlist? Returns (display_name, is_admin).
pub async fn member(db: &Db, email: &str) -> Option<(String, bool)> {
let email = norm_email(email);
sqlx::query_as::<_, (String, i64)>(
"SELECT display_name, is_admin FROM members WHERE email = ?",
)
.bind(&email)
.fetch_optional(db)
.await
.ok()
.flatten()
.map(|(n, a)| (n, a != 0))
}
/// Add (or keep) an email on the allowlist. Used by admin invites.
pub async fn add_member(db: &Db, email: &str, is_admin: bool) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO members (email, display_name, is_admin, added_at)
VALUES (?, '', ?, ?)
ON CONFLICT(email) DO UPDATE SET is_admin = MAX(is_admin, excluded.is_admin)",
)
.bind(norm_email(email))
.bind(is_admin as i64)
.bind(now())
.execute(db)
.await?;
Ok(())
}
/// All allowlisted emails (for the admin members page).
pub async fn list_members(db: &Db) -> Vec<(String, bool)> {
sqlx::query_as::<_, (String, i64)>(
"SELECT email, is_admin FROM members ORDER BY added_at",
)
.fetch_all(db)
.await
.unwrap_or_default()
.into_iter()
.map(|(e, a)| (e, a != 0))
.collect()
}
/// Create a single-use magic link token for an allowlisted email.
pub async fn create_magic_link(db: &Db, email: &str) -> anyhow::Result<String> {
let token = random_token();
sqlx::query(
"INSERT INTO magic_links (token_hash, email, expires_at, used) VALUES (?, ?, ?, 0)",
)
.bind(sha256_hex(&token))
.bind(norm_email(email))
.bind(now() + MAGIC_TTL)
.execute(db)
.await?;
Ok(token)
}
/// Consume a magic link, returning the email if valid & unused & unexpired.
pub async fn consume_magic_link(db: &Db, token: &str) -> Option<String> {
let h = sha256_hex(token);
let row = sqlx::query_as::<_, (String, i64, i64)>(
"SELECT email, expires_at, used FROM magic_links WHERE token_hash = ?",
)
.bind(&h)
.fetch_optional(db)
.await
.ok()
.flatten()?;
let (email, expires_at, used) = row;
if used != 0 || expires_at < now() {
return None;
}
sqlx::query("UPDATE magic_links SET used = 1 WHERE token_hash = ?")
.bind(&h)
.execute(db)
.await
.ok()?;
Some(email)
}
/// Find-or-create the user for an allowlisted email.
pub async fn upsert_user(db: &Db, email: &str) -> anyhow::Result<User> {
let email = norm_email(email);
if let Some(u) = user_by_email(db, &email).await {
return Ok(u);
}
let (display_name, is_admin) = member(db, &email)
.await
.unwrap_or_else(|| (String::new(), false));
// Pick a unique handle.
let base = handle_from_email(&email);
let mut handle = base.clone();
let mut n = 1;
while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE handle = ?")
.bind(&handle)
.fetch_one(db)
.await?
> 0
{
n += 1;
handle = format!("{base}{n}");
}
sqlx::query(
"INSERT INTO users (handle, email, display_name, is_admin, created_at)
VALUES (?, ?, ?, ?, ?)",
)
.bind(&handle)
.bind(&email)
.bind(&display_name)
.bind(is_admin as i64)
.bind(now())
.execute(db)
.await?;
user_by_email(db, &email)
.await
.ok_or_else(|| anyhow::anyhow!("user vanished after insert"))
}
pub async fn user_by_email(db: &Db, email: &str) -> Option<User> {
sqlx::query_as::<_, User>(
"SELECT id, handle, email, display_name, is_admin FROM users WHERE email = ?",
)
.bind(norm_email(email))
.fetch_optional(db)
.await
.ok()
.flatten()
}
pub async fn user_by_handle(db: &Db, handle: &str) -> Option<User> {
sqlx::query_as::<_, User>(
"SELECT id, handle, email, display_name, is_admin FROM users WHERE handle = ?",
)
.bind(handle)
.fetch_optional(db)
.await
.ok()
.flatten()
}
/// Create a web session, return the raw cookie value.
pub async fn create_session(db: &Db, user_id: i64) -> anyhow::Result<String> {
let token = random_token();
sqlx::query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)")
.bind(sha256_hex(&token))
.bind(user_id)
.bind(now() + SESSION_TTL)
.execute(db)
.await?;
Ok(token)
}
pub async fn destroy_session(db: &Db, token: &str) {
let _ = sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
.bind(sha256_hex(token))
.execute(db)
.await;
}
/// Resolve the logged-in user from request cookies.
pub async fn current_user(db: &Db, headers: &HeaderMap) -> Option<User> {
let token = cookie(headers, SESSION_COOKIE)?;
let row = sqlx::query_as::<_, (i64, i64)>(
"SELECT user_id, expires_at FROM sessions WHERE token_hash = ?",
)
.bind(sha256_hex(&token))
.fetch_optional(db)
.await
.ok()
.flatten()?;
if row.1 < now() {
return None;
}
sqlx::query_as::<_, User>(
"SELECT id, handle, email, display_name, is_admin FROM users WHERE id = ?",
)
.bind(row.0)
.fetch_optional(db)
.await
.ok()
.flatten()
}
/// Create a personal access token (for git over HTTPS). Returns raw token.
pub async fn create_pat(db: &Db, user_id: i64, name: &str) -> anyhow::Result<String> {
let token = format!("okibi_{}", random_token());
sqlx::query(
"INSERT INTO pats (user_id, name, token_hash, created_at) VALUES (?, ?, ?, ?)",
)
.bind(user_id)
.bind(name)
.bind(sha256_hex(&token))
.bind(now())
.execute(db)
.await?;
Ok(token)
}
/// Resolve a user from a bare PAT (used by the MCP Bearer auth).
pub async fn user_by_pat(db: &Db, token: &str) -> Option<User> {
let h = sha256_hex(token);
let uid = sqlx::query_scalar::<_, i64>("SELECT user_id FROM pats WHERE token_hash = ?")
.bind(&h)
.fetch_optional(db)
.await
.ok()
.flatten()?;
let _ = sqlx::query("UPDATE pats SET last_used = ? WHERE token_hash = ?")
.bind(now())
.bind(&h)
.execute(db)
.await;
sqlx::query_as::<_, User>(
"SELECT id, handle, email, display_name, is_admin FROM users WHERE id = ?",
)
.bind(uid)
.fetch_optional(db)
.await
.ok()
.flatten()
}
/// Extract a Bearer token from the Authorization header.
pub fn bearer(headers: &HeaderMap) -> Option<String> {
let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
raw.strip_prefix("Bearer ").map(|s| s.trim().to_string())
}
/// Verify HTTP basic auth (handle + PAT) for git operations.
pub async fn verify_pat(db: &Db, handle: &str, token: &str) -> Option<User> {
let user = user_by_handle(db, handle).await?;
let h = sha256_hex(token);
let ok = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM pats WHERE user_id = ? AND token_hash = ?",
)
.bind(user.id)
.bind(&h)
.fetch_one(db)
.await
.ok()?
> 0;
if ok {
let _ = sqlx::query("UPDATE pats SET last_used = ? WHERE token_hash = ?")
.bind(now())
.bind(&h)
.execute(db)
.await;
Some(user)
} else {
None
}
}
/// Extract a cookie value from request headers.
pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
let part = part.trim();
if let Some(rest) = part.strip_prefix(&format!("{name}=")) {
return Some(rest.to_string());
}
}
None
}
/// Parse HTTP Basic auth header into (user, pass).
pub fn basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
let b64 = raw.strip_prefix("Basic ")?;
let decoded = base64_decode(b64)?;
let s = String::from_utf8(decoded).ok()?;
let (u, p) = s.split_once(':')?;
Some((u.to_string(), p.to_string()))
}
/// Minimal base64 decoder (avoids pulling a crate just for basic auth).
fn base64_decode(input: &str) -> Option<Vec<u8>> {
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut lut = [255u8; 256];
for (i, &c) in TABLE.iter().enumerate() {
lut[c as usize] = i as u8;
}
let clean: Vec<u8> = input.bytes().filter(|&b| b != b'=' && !b.is_ascii_whitespace()).collect();
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
let mut buf = 0u32;
let mut bits = 0u32;
for &c in &clean {
let v = lut[c as usize];
if v == 255 {
return None;
}
buf = (buf << 6) | v as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
Some(out)
}