yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
use crate::auth::{self, User, SESSION_COOKIE};
use crate::repos::{self, Repo};
use crate::util::valid_name;
use crate::AppState;
use axum::extract::{Form, Path, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
use maud::{html, Markup, PreEscaped, DOCTYPE};
use serde::Deserialize;
fn layout(title: &str, user: &Option<User>, body: Markup) -> Markup {
html! {
(DOCTYPE)
html lang="ja" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
title { (title) " · Okibi" }
style { (PreEscaped(CSS)) }
}
body {
header.top {
a.brand href="/" { "🔥 Okibi" }
nav {
a href="/about" { "機能" }
@if let Some(u) = user {
a href="/new" { "+ 新規リポジトリ" }
a href={"/" (u.handle)} { "@" (u.handle) }
@if u.is_admin != 0 { a href="/members" { "メンバー" } }
a href="/settings" { "設定" }
form method="post" action="/logout" style="display:inline" {
button.link type="submit" { "ログアウト" }
}
} @else {
a href="/login" { "ログイン" }
}
}
}
main { (body) }
footer { "焚き火(atsm)の仲間だけの Git · " a href="https://atsm.wtf" { "atsm.wtf" } }
}
}
}
}
async fn current(state: &AppState, headers: &HeaderMap) -> Option<User> {
auth::current_user(&state.db, headers).await
}
/// Feature list — single source of truth for the about page & home hero.
fn features() -> [(&'static str, &'static str, &'static str); 9] {
[
("🔥", "焚き火だけ", "atsm の仲間だけがログイン。magic-link と管理者の招待リンクで、メールサーバ無しでも入れる。"),
("📦", "リポジトリ", "公開/非公開を選んで作成。git push / pull は HTTPS + アクセストークンで。"),
("👀", "コード閲覧", "Web でファイルツリーを辿り、ファイルの中身まで読める。ログイン不要で公開リポを覗ける。"),
("📥", "取り込み", "GitHub 等の URL を渡すと、Okibi マシンが直接 mirror clone。巨大リポも自宅回線を通さず取り込む。"),
("⚙️", "簡易CI", "リポジトリに .okibi/ci.sh を置けば push 時に実行。テストやビルドを自動で。"),
("🔑", "トークン", "用途ごとに PAT を発行。パスワード代わりに push、いつでも作り直せる。"),
("👥", "メンバー", "管理者が allowlist を管理し、招待リンクを発行。焚き火の輪を広げる。"),
("🛡", "安全より", "秘密はハッシュで保存。git 処理は実 git に委譲し、自前で pack を再実装しない。"),
("🤖", "MCP", "Claude などの AI から /mcp に PAT で接続。repo 一覧・作成・ファイル取得をツールで直接。"),
]
}
fn feature_grid() -> Markup {
html! {
div.features {
@for (icon, title, desc) in features() {
div.feature {
div.ficon { (icon) }
h3 { (title) }
p.muted { (desc) }
}
}
}
}
}
fn hero() -> Markup {
html! {
section.hero {
div.flame { "🔥" }
h1.htitle { "Okibi" }
p.htag { "焚き火の仲間だけの、自前 Git ホスティング。" br;
"おき火=燃え続ける熾火。コードが、残る場所。" }
div.hcta {
a.btn href="/about" { "機能を見る" }
a.btn.ghost href="/login" { "ログイン" }
}
}
}
}
// GET /
pub async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = current(&state, &headers).await;
let repos = repos::list_visible(&state.db, user.as_ref()).await;
let body = html! {
@if user.is_none() {
(hero())
(feature_grid())
h2 { "公開リポジトリ" }
} @else {
h1 { "リポジトリ" }
}
@if repos.is_empty() {
p.muted {
@if user.is_some() { "まだ何もない。" a href="/new" { "最初の一本を建てる" } "。" }
@else { "まだ公開リポジトリはありません。" }
}
}
ul.repolist {
@for (r, owner) in &repos {
li {
a.repo href={"/" (owner) "/" (r.name)} { (owner) " / " strong { (r.name) } }
@if !r.is_public() { span.badge { "private" } }
@if !r.description.is_empty() { p.muted { (r.description) } }
}
}
}
};
Html(layout("home", &user, body).into_string()).into_response()
}
// GET /about
pub async fn about(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = current(&state, &headers).await;
let body = html! {
(hero())
h2 { "できること" }
(feature_grid())
section.card {
h2 { "使いはじめる" }
ol {
li { "焚き火のメンバーに招待リンクをもらう(または管理者に依頼)" }
li { "ログインしたら " code { "設定" } " で PAT を発行" }
li { code { "git clone https://<handle>:<PAT>@okibi.fly.dev/<handle>/<repo>.git" } }
li { "push すれば " code { ".okibi/ci.sh" } " が回る(CI 有効時)" }
}
}
section.card {
h2 { "技術" }
p.muted { "Rust + axum + SQLite + maud。git smart-HTTP は実 git-http-backend に委譲。" }
}
};
Html(layout("機能", &user, body).into_string()).into_response()
}
// GET /{owner}
pub async fn user_view(
State(state): State<AppState>,
Path(owner): Path<String>,
headers: HeaderMap,
) -> Response {
let viewer = current(&state, &headers).await;
let owner_user = match auth::user_by_handle(&state.db, &owner).await {
Some(u) => u,
None => return error_page(&viewer, "ユーザーが見つかりません。"),
};
let is_self = viewer.as_ref().map(|v| v.id == owner_user.id).unwrap_or(false);
let repos = repos::list_repos_for_owner(&state.db, owner_user.id).await;
let body = html! {
h1 { "@" (owner_user.handle)
@if !owner_user.display_name.is_empty() { " " span.muted { (owner_user.display_name) } } }
ul.repolist {
@for r in &repos {
@if r.is_public() || is_self {
li {
a.repo href={"/" (owner) "/" (r.name)} { strong { (r.name) } }
@if !r.is_public() { span.badge { "private" } }
@if !r.description.is_empty() { p.muted { (r.description) } }
}
}
}
}
};
Html(layout(&format!("@{owner}"), &viewer, body).into_string()).into_response()
}
// GET /login
pub async fn login_form(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = current(&state, &headers).await;
if user.is_some() {
return Redirect::to("/").into_response();
}
let body = html! {
h1 { "ログイン" }
p.muted { "atsm の焚き火に参加しているメールアドレスへ、ログインリンクを送ります。" }
form method="post" action="/login" class="card" {
label { "メールアドレス" }
input type="email" name="email" required placeholder="you@example.com";
button type="submit" { "ログインリンクを送る" }
}
};
Html(layout("ログイン", &None, body).into_string()).into_response()
}
#[derive(Deserialize)]
pub struct LoginInput {
email: String,
}
// POST /login
pub async fn login_submit(
State(state): State<AppState>,
Form(input): Form<LoginInput>,
) -> Response {
let email = input.email.trim().to_lowercase();
let is_member = auth::member(&state.db, &email).await.is_some();
let msg = if is_member {
match auth::create_magic_link(&state.db, &email).await {
Ok(token) => {
let link = format!("{}/auth/verify?token={}", state.cfg.base_url, token);
crate::email::send_magic_link(&state.cfg, &email, &link).await;
"メールを確認してください。ログインリンクを送りました(15分有効)。"
}
Err(_) => "エラーが発生しました。もう一度お試しください。",
}
} else {
"このメールアドレスは焚き火のメンバーに登録されていません。"
};
let body = html! {
h1 { "ログイン" }
p.card { (msg) }
p { a href="/login" { "戻る" } }
};
Html(layout("ログイン", &None, body).into_string()).into_response()
}
#[derive(Deserialize)]
pub struct VerifyQuery {
token: String,
}
// GET /auth/verify?token=...
pub async fn verify(
State(state): State<AppState>,
Query(q): Query<VerifyQuery>,
) -> Response {
let email = match auth::consume_magic_link(&state.db, &q.token).await {
Some(e) => e,
None => {
let body = html! { h1 { "リンク無効" } p { "期限切れか使用済みです。" a href="/login" { "再送" } } };
return Html(layout("無効", &None, body).into_string()).into_response();
}
};
let user = match auth::upsert_user(&state.db, &email).await {
Ok(u) => u,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "user error").into_response(),
};
let token = match auth::create_session(&state.db, user.id).await {
Ok(t) => t,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "session error").into_response(),
};
redirect_with_cookie("/", &set_cookie(&state, &token, false))
}
#[derive(Deserialize)]
pub struct BootstrapQuery {
token: String,
}
// GET /auth/bootstrap?token=... — no-email admin login (env-gated).
pub async fn bootstrap(
State(state): State<AppState>,
Query(q): Query<BootstrapQuery>,
) -> Response {
match &state.cfg.admin_bootstrap {
Some(t) if !t.is_empty() && t == &q.token => {}
_ => return (StatusCode::NOT_FOUND, "not found\n").into_response(),
}
let email = match state.cfg.seed_members.first() {
Some(e) => e.clone(),
None => return (StatusCode::INTERNAL_SERVER_ERROR, "no seed admin\n").into_response(),
};
let _ = auth::add_member(&state.db, &email, true).await;
let user = match auth::upsert_user(&state.db, &email).await {
Ok(u) => u,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "user error\n").into_response(),
};
let token = match auth::create_session(&state.db, user.id).await {
Ok(t) => t,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "session error\n").into_response(),
};
redirect_with_cookie("/", &set_cookie(&state, &token, false))
}
// GET /members — admin: allowlist + invite link generator.
pub async fn members_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = match current(&state, &headers).await {
Some(u) if u.is_admin != 0 => u,
Some(u) => return error_page(&Some(u), "管理者のみ。"),
None => return Redirect::to("/login").into_response(),
};
let members = auth::list_members(&state.db).await;
let body = html! {
h1 { "メンバー(焚き火の allowlist)" }
section.card {
h2 { "招待リンクを発行" }
p.muted { "メールアドレスを allowlist に追加し、ログイン用リンクを発行します。焚き火やDMで本人に渡してください(15分有効)。" }
form method="post" action="/members/invite" class="row" {
input type="email" name="email" placeholder="member@example.com" required;
label.row { input type="checkbox" name="admin" value="1"; " admin" }
button type="submit" { "発行" }
}
}
section.card {
h2 { "プロジェクトを取り込む(サーバー側 mirror clone)" }
p.muted { "GitHub 等の URL を Okibi マシンが直接クローンします。private は URL に token を埋めてください(保存後に scrub されます)。" }
form method="post" action="/admin/import" class="row" {
input type="text" name="name" placeholder="repo名" required pattern="[A-Za-z0-9_.-]+";
input type="text" name="git_url" placeholder="https://token@github.com/owner/repo.git" required;
label.row { input type="checkbox" name="public" value="1"; " public" }
button type="submit" { "取り込み" }
}
}
section.card {
h2 { "現在のメンバー" }
ul {
@for (email, is_admin) in &members {
li { code { (email) } @if *is_admin { span.badge { "admin" } } }
}
}
}
};
Html(layout("メンバー", &Some(user), body).into_string()).into_response()
}
#[derive(Deserialize)]
pub struct InviteInput {
email: String,
#[serde(default)]
admin: Option<String>,
}
// POST /members/invite — admin only.
pub async fn invite_submit(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<InviteInput>,
) -> Response {
let user = match current(&state, &headers).await {
Some(u) if u.is_admin != 0 => u,
Some(u) => return error_page(&Some(u), "管理者のみ。"),
None => return Redirect::to("/login").into_response(),
};
let email = input.email.trim().to_lowercase();
if email.is_empty() || !email.contains('@') {
return error_page(&Some(user), "メールアドレスが不正です。");
}
if auth::add_member(&state.db, &email, input.admin.is_some())
.await
.is_err()
{
return error_page(&Some(user), "追加に失敗しました。");
}
let token = match auth::create_magic_link(&state.db, &email).await {
Ok(t) => t,
Err(_) => return error_page(&Some(user), "リンク発行に失敗しました。"),
};
let link = format!("{}/auth/verify?token={}", state.cfg.base_url, token);
let body = html! {
h1 { "招待リンクを発行しました" }
div.card {
p { code { (email) } " を allowlist に追加しました。下のリンクを本人に渡してください(15分有効・1回限り)。" }
pre.token { (link) }
}
p { a href="/members" { "メンバーへ戻る" } }
};
Html(layout("招待", &Some(user), body).into_string()).into_response()
}
#[derive(Deserialize)]
pub struct ImportInput {
name: String,
git_url: String,
#[serde(default)]
public: Option<String>,
}
// POST /admin/import — admin only. Server-side mirror-clone of an external repo.
pub async fn admin_import(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<ImportInput>,
) -> Response {
let user = match current(&state, &headers).await {
Some(u) if u.is_admin != 0 => u,
Some(_) => return (StatusCode::FORBIDDEN, "admin only\n").into_response(),
None => return (StatusCode::UNAUTHORIZED, "login required\n").into_response(),
};
let name = input.name.trim().to_string();
if !valid_name(&name) {
return (StatusCode::BAD_REQUEST, "bad name\n").into_response();
}
if repos::get_repo(&state.db, user.id, &name).await.is_some() {
return (StatusCode::CONFLICT, "repo already exists\n").into_response();
}
let git_url = input.git_url.trim().to_string();
if !git_url.starts_with("http://") && !git_url.starts_with("https://") {
return (StatusCode::BAD_REQUEST, "only http(s) urls\n").into_response();
}
let vis = if input.public.is_some() { "public" } else { "private" };
// Insert metadata now; clone fills the bare path in the background.
if let Err(e) = repos::insert_repo_meta(&state.db, user.id, &name, vis, "imported", "main").await {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}\n")).into_response();
}
let st = state.clone();
let handle = user.handle.clone();
let nm = name.clone();
tokio::task::spawn_blocking(move || {
match repos::import_clone(&st.cfg, &handle, &nm, &git_url) {
Ok(()) => {
// fix default_branch to the imported HEAD
if let Some(br) = repos::detect_head(&st.cfg, &handle, &nm) {
let db = st.db.clone();
let nm2 = nm.clone();
tokio::spawn(async move {
let _ = sqlx::query("UPDATE repos SET default_branch = ? WHERE name = ?")
.bind(br)
.bind(nm2)
.execute(&db)
.await;
});
}
tracing::info!("imported {handle}/{nm}");
}
Err(e) => tracing::error!("import {handle}/{nm} failed: {e}"),
}
});
(StatusCode::ACCEPTED, format!("import started: {name}\n")).into_response()
}
// POST /logout
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(tok) = auth::cookie(&headers, SESSION_COOKIE) {
auth::destroy_session(&state.db, &tok).await;
}
redirect_with_cookie("/", &set_cookie(&state, "", true))
}
// GET /new
pub async fn new_repo_form(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = match current(&state, &headers).await {
Some(u) => u,
None => return Redirect::to("/login").into_response(),
};
let body = html! {
h1 { "新規リポジトリ" }
form method="post" action="/new" class="card" {
label { "名前" }
input type="text" name="name" required placeholder="my-project"
pattern="[A-Za-z0-9_.-]+";
label { "説明(任意)" }
input type="text" name="description" placeholder="一言で";
label.row { input type="checkbox" name="public" value="1"; " 公開(チェックなし=private)" }
button type="submit" { "作成" }
}
p.muted { "作成後、" code { "@" (user.handle) } " 直下に置かれます。" }
};
Html(layout("新規", &Some(user), body).into_string()).into_response()
}
#[derive(Deserialize)]
pub struct NewRepoInput {
name: String,
#[serde(default)]
description: String,
#[serde(default)]
public: Option<String>,
}
// POST /new
pub async fn new_repo_submit(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<NewRepoInput>,
) -> Response {
let user = match current(&state, &headers).await {
Some(u) => u,
None => return Redirect::to("/login").into_response(),
};
let name = input.name.trim();
if !valid_name(name) {
return error_page(&Some(user), "名前が不正です(英数字 . _ - のみ)。");
}
if repos::get_repo(&state.db, user.id, name).await.is_some() {
return error_page(&Some(user), "同名のリポジトリが既にあります。");
}
let vis = if input.public.is_some() { "public" } else { "private" };
match repos::create_repo(&state.db, &state.cfg, &user, name, vis, input.description.trim()).await {
Ok(_) => Redirect::to(&format!("/{}/{}", user.handle, name)).into_response(),
Err(e) => error_page(&Some(user), &format!("作成に失敗: {e}")),
}
}
// GET /settings
pub async fn settings(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = match current(&state, &headers).await {
Some(u) => u,
None => return Redirect::to("/login").into_response(),
};
let pats = sqlx::query_as::<_, (i64, String, i64)>(
"SELECT id, name, created_at FROM pats WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user.id)
.fetch_all(&state.db)
.await
.unwrap_or_default();
let body = html! {
h1 { "設定" }
section.card {
h2 { "アクセストークン (PAT)" }
p.muted { "git の push/pull で、パスワード代わりに使います。ユーザー名は " code { (user.handle) } "。" }
form method="post" action="/settings/tokens" class="row" {
input type="text" name="name" placeholder="トークン名(例: laptop)" required;
button type="submit" { "発行" }
}
@if !pats.is_empty() {
ul {
@for (_, name, _) in &pats {
li { code { (name) } }
}
}
}
}
section.card {
h2 { "🤖 MCP(AIから使う)" }
p.muted { "Claude Code などから Okibi を直接操作。PAT を発行して下のコマンドの " code { "<PAT>" } " に貼り付け。" }
pre { (format!("claude mcp add --transport http okibi {}/mcp \\\n --header \"Authorization: Bearer <PAT>\"", state.cfg.base_url)) }
p.muted { "tools: okibi_whoami / okibi_list_repos / okibi_create_repo / okibi_list_tree / okibi_get_file" }
}
section.card {
h2 { "git で使う" }
pre { (format!("git clone {}/{}/REPO.git\nUsername: {}\nPassword: <PAT>", state.cfg.base_url, user.handle, user.handle)) }
}
};
Html(layout("設定", &Some(user), body).into_string()).into_response()
}
#[derive(Deserialize)]
pub struct NewTokenInput {
name: String,
}
// POST /settings/tokens
pub async fn new_token(
State(state): State<AppState>,
headers: HeaderMap,
Form(input): Form<NewTokenInput>,
) -> Response {
let user = match current(&state, &headers).await {
Some(u) => u,
None => return Redirect::to("/login").into_response(),
};
let token = match auth::create_pat(&state.db, user.id, input.name.trim()).await {
Ok(t) => t,
Err(_) => return error_page(&Some(user), "発行に失敗しました。"),
};
let body = html! {
h1 { "トークンを発行しました" }
div.card {
p { "この値は二度と表示されません。今すぐコピーしてください。" }
pre.token { (token) }
p.muted { "使い方:" }
pre { (format!("git clone {}/{}/REPO.git", state.cfg.base_url, user.handle)) "\n"
"Username: " (user.handle) "\n"
"Password: <このトークン>" }
}
p { a href="/settings" { "設定へ戻る" } }
};
Html(layout("トークン", &Some(user), body).into_string()).into_response()
}
#[derive(Deserialize)]
pub struct BrowseQuery {
#[serde(default)]
p: String,
}
// GET /{owner}/{repo}
pub async fn repo_view(
State(state): State<AppState>,
Path((owner, repo)): Path<(String, String)>,
Query(q): Query<BrowseQuery>,
headers: HeaderMap,
) -> Response {
let viewer = current(&state, &headers).await;
let owner_user = match auth::user_by_handle(&state.db, &owner).await {
Some(u) => u,
None => return error_page(&viewer, "ユーザーが見つかりません。"),
};
let r = match repos::get_repo(&state.db, owner_user.id, &repo).await {
Some(r) => r,
None => return error_page(&viewer, "リポジトリが見つかりません。"),
};
// visibility gate
if !r.is_public() && viewer.as_ref().map(|v| v.id != owner_user.id).unwrap_or(true) {
return error_page(&viewer, "このリポジトリは非公開です。");
}
let clone_url = format!("{}/{}/{}.git", state.cfg.base_url, owner, repo);
let empty = repos::is_empty(&state.cfg, &owner, &repo);
let body = if empty {
empty_repo_view(&owner, &r, &clone_url, viewer.as_ref().map(|u| u.handle.as_str()).unwrap_or(&owner))
} else {
browse_view(&state, &owner, &r, &q.p, &clone_url).await
};
Html(layout(&format!("{owner}/{}", r.name), &viewer, body).into_string()).into_response()
}
fn repo_header(owner: &str, r: &Repo, clone_url: &str) -> Markup {
html! {
h1 { a href={"/" (owner)} { (owner) } " / " (r.name)
@if !r.is_public() { span.badge { "private" } } }
@if !r.description.is_empty() { p.muted { (r.description) } }
div.clonebar { code { (format!("git clone {clone_url}")) } }
}
}
fn empty_repo_view(owner: &str, r: &Repo, clone_url: &str, handle: &str) -> Markup {
html! {
(repo_header(owner, r, clone_url))
div.card {
h2 { "最初の push" }
pre {
(format!(
"git init
git add .
git commit -m \"first fire\"
git branch -M main
git remote add origin {clone_url}
git push -u origin main
# Username: {handle}
# Password: <設定で発行した PAT>"))
}
}
}
}
async fn browse_view(state: &AppState, owner: &str, r: &Repo, path: &str, clone_url: &str) -> Markup {
let rev = &r.default_branch;
// file?
if !path.is_empty() {
if let Some(bytes) = repos::read_blob(&state.cfg, owner, &r.name, rev, path) {
let is_text = std::str::from_utf8(&bytes).is_ok() && bytes.len() < 512 * 1024;
return html! {
(repo_header(owner, r, clone_url))
(breadcrumb(owner, &r.name, path))
@if is_text {
pre.code { (String::from_utf8_lossy(&bytes)) }
} @else {
p.muted { "バイナリまたは大きすぎるファイル(" (bytes.len()) " bytes)。" }
}
};
}
}
let entries = repos::ls_tree(&state.cfg, owner, &r.name, rev, path);
let head = repos::head_commit(&state.cfg, owner, &r.name, rev);
html! {
(repo_header(owner, r, clone_url))
@if let Some((sha, subject, _)) = &head {
p.muted { "最新: " code { (&sha[..sha.len().min(8)]) } " " (subject) }
}
(breadcrumb(owner, &r.name, path))
table.tree {
@for (_mode, ty, name) in &entries {
tr {
td {
@let child = if path.is_empty() { name.clone() } else { format!("{path}/{name}") };
@if ty == "tree" {
a href={"/" (owner) "/" (r.name) "?p=" (urlencode(&child))} { "📁 " (name) }
} @else {
a href={"/" (owner) "/" (r.name) "?p=" (urlencode(&child))} { "📄 " (name) }
}
}
}
}
}
}
}
fn breadcrumb(owner: &str, repo: &str, path: &str) -> Markup {
let segs: Vec<&str> = if path.is_empty() {
vec![]
} else {
path.split('/').collect()
};
html! {
p.crumb {
a href={"/" (owner) "/" (repo)} { (repo) }
@for (i, seg) in segs.iter().enumerate() {
" / "
@let acc = segs[..=i].join("/");
a href={"/" (owner) "/" (repo) "?p=" (urlencode(&acc))} { (seg) }
}
}
}
}
fn error_page(user: &Option<User>, msg: &str) -> Response {
let body = html! { h1 { "エラー" } p.card { (msg) } p { a href="/" { "ホームへ" } } };
(StatusCode::NOT_FOUND, Html(layout("エラー", user, body).into_string())).into_response()
}
fn urlencode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
// ---- cookie helpers ----
fn set_cookie(state: &AppState, value: &str, clear: bool) -> String {
let secure = state.cfg.base_url.starts_with("https");
let max_age = if clear { 0 } else { 60 * 60 * 24 * 30 };
let mut c = format!(
"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
);
if secure {
c.push_str("; Secure");
}
c
}
fn redirect_with_cookie(to: &str, cookie: &str) -> Response {
(
StatusCode::SEE_OTHER,
[(header::LOCATION, to.to_string()), (header::SET_COOKIE, cookie.to_string())],
"",
)
.into_response()
}
const CSS: &str = include_str!("style.css");