🔥 Okibi

yuki / okibi

焚き火の仲間だけの自前Gitホスティング(このサイト自身)

git clone https://okibi.fly.dev/yuki/okibi.git

okibi / src / web.rs

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");