yuki / okibi
焚き火の仲間だけの自前Gitホスティング(このサイト自身)
10650e1cb6335f476111d88e6e3b6f73ad047713
yuki
1781032995
Okibi v0.1 — 焚き火(atsm)会員だけの自前 Git ホスティング
---
.github/workflows/deploy.yml | 15 +
.gitignore | 6 +
Cargo.lock | 2199 ++++++++++++++++++++++++++++++++++++++++++
Cargo.toml | 26 +
Dockerfile | 19 +
README.md | 51 +
fly.toml | 36 +
scripts/e2e.sh | 70 ++
src/auth.rs | 286 ++++++
src/ci.rs | 150 +++
src/config.rs | 62 ++
src/db.rs | 105 ++
src/email.rs | 38 +
src/git_http.rs | 294 ++++++
src/main.rs | 68 ++
src/repos.rs | 191 ++++
src/style.css | 64 ++
src/util.rs | 59 ++
src/web.rs | 583 +++++++++++
tasks/todo.md | 36 +
20 files changed, 4358 insertions(+)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..dd2f223
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,15 @@
+name: deploy
+on:
+ push:
+ branches: [main]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ concurrency: deploy-okibi
+ steps:
+ - uses: actions/checkout@v4
+ - uses: superfly/flyctl-actions/setup-flyctl@master
+ - run: flyctl deploy --remote-only
+ env:
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2f2d4bb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target
+/data
+*.db
+*.db-*
+/tmp
+.DS_Store
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..294cd4b
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2199 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "axum-macros",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-macros"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "maud"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
+dependencies = [
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "okibi"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "axum",
+ "bytes",
+ "hex",
+ "maud",
+ "rand 0.8.6",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "serde",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e5361301a1d9e5dd94c524eb99365fbaed5b237e831d7f45e2ddea11ffe8627"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "422033a2245cb4b6ff8def11b2dfaf184a2ab2573f5af28082a163a68889af0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..d5b849d
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "okibi"
+version = "0.1.0"
+edition = "2021"
+description = "Okibi — 焚き火(atsm)会員だけの自前 Git ホスティング"
+
+[dependencies]
+axum = { version = "0.8", features = ["macros"] }
+tokio = { version = "1", features = ["full"] }
+tower = "0.5"
+tower-http = { version = "0.6", features = ["trace", "fs"] }
+sqlx = { version = "0.8", default-features = false, features = ["sqlite", "runtime-tokio", "macros"] }
+maud = "0.27"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+rand = "0.8"
+sha2 = "0.10"
+hex = "0.4"
+anyhow = "1"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+bytes = "1"
+
+[profile.release]
+opt-level = 2
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8062413
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+# ---- build ----
+FROM rust:1.95-slim-bookworm AS build
+WORKDIR /app
+COPY Cargo.toml ./
+COPY Cargo.lock ./
+COPY src ./src
+RUN cargo build --release
+
+# ---- runtime ----
+FROM debian:bookworm-slim
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ca-certificates bash \
+ && rm -rf /var/lib/apt/lists/*
+COPY --from=build /app/target/release/okibi /usr/local/bin/okibi
+ENV OKIBI_DATA=/data \
+ OKIBI_BIND=0.0.0.0:8787 \
+ OKIBI_GIT_HTTP_BACKEND=/usr/lib/git-core/git-http-backend
+EXPOSE 8787
+CMD ["okibi"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e0c792e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# 🔥 Okibi — 焚き火(atsm)の仲間だけの Git
+
+> おき火=燃え続ける熾火。コードが、残る場所。
+
+atsm の焚き火コミュニティのメンバーだけが安全に push できる、自前のモダンな Git ホスティング。
+GitHub のような体験を、招待制で。
+
+## なにができる
+- メンバーだけがログイン(自前 magic-link / 管理者の招待リンク)
+- リポジトリ作成・公開/非公開
+- `git push` / `git pull`(HTTPS + Personal Access Token)
+- Web でコードを閲覧(ファイルツリー / blob)
+- push 時に `.okibi/ci.sh`(または `.okibi/ci.yml` の `script:`)を実行する簡易 CI
+
+## 技術
+- Rust + axum + SQLite(sqlx)+ maud
+- git smart-HTTP は実 `git-http-backend`(CGI) に委譲(pack は自前実装しない=安全)
+- 認証: セッション(Cookie) / git は handle + PAT の Basic 認証
+- シークレットはハッシュのみ保存(セッション・PAT・magic-link)
+
+## ローカル起動
+```bash
+cargo run # http://localhost:8787
+# 開発時、メール未設定なら magic-link は標準出力に出ます
+```
+
+主な環境変数:
+| var | 説明 | 既定 |
+|---|---|---|
+| `OKIBI_BASE_URL` | 公開URL(リンク生成に使用) | `http://localhost:8787` |
+| `OKIBI_DATA` | データ/リポジトリ置き場 | `./data` |
+| `OKIBI_SEED_MEMBERS` | 初期メンバー(=admin)メール | yuki の2件 |
+| `OKIBI_ADMIN_BOOTSTRAP` | メール無しで admin ログインする秘密トークン | 無効 |
+| `RESEND_API_KEY` | あれば magic-link をメール送信 | 無ければ stdout |
+| `OKIBI_CI_ENABLE` | `1` で簡易CIを有効化(pushコード実行注意) | 無効 |
+
+## CI の使い方
+リポジトリに `.okibi/ci.sh` を置くと push 時に実行されます(`OKIBI_CI_ENABLE=1` のとき)。
+```sh
+# .okibi/ci.sh
+cargo test
+```
+
+## セキュリティ note(v1)
+- 簡易CIは pushコードをホスト上でそのまま実行します。既定で無効。
+ 本番有効化時は ephemeral なサンドボックス(Fly machine/コンテナ)に隔離するのが TODO。
+- 認証は atsm allowlist で会員ゲート。atsm.wtf に SSO/OIDC が無いため、当面は
+ 自前 magic-link + 招待リンクで運用(atsm が SSO を出したら差し替え)。
+
+## デプロイ
+`git push` → GitHub Actions → Fly.io(`fly deploy` 直叩きは禁止)。
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..fa8cbf4
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,36 @@
+app = "okibi"
+primary_region = "nrt"
+
+[build]
+
+[env]
+ OKIBI_DATA = "/data"
+ OKIBI_BIND = "0.0.0.0:8787"
+ OKIBI_BASE_URL = "https://okibi.fly.dev"
+ OKIBI_GIT_HTTP_BACKEND = "/usr/lib/git-core/git-http-backend"
+
+[[mounts]]
+ source = "okibi_data"
+ destination = "/data"
+
+[http_service]
+ internal_port = 8787
+ force_https = true
+ auto_stop_machines = false
+ auto_start_machines = true
+ min_machines_running = 1
+
+[checks]
+ [checks.health]
+ port = 8787
+ type = "http"
+ interval = "15s"
+ timeout = "2s"
+ grace_period = "5s"
+ method = "get"
+ path = "/healthz"
+
+[[vm]]
+ cpu_kind = "shared"
+ cpus = 1
+ memory_mb = 512
diff --git a/scripts/e2e.sh b/scripts/e2e.sh
new file mode 100644
index 0000000..65ec586
--- /dev/null
+++ b/scripts/e2e.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# End-to-end: magic-link login -> create repo -> issue PAT -> git push/pull.
+set -uo pipefail
+BASE="http://localhost:8799"
+JAR=$(mktemp)
+WORK=$(mktemp -d)
+LOG="${OKIBI_LOG:-/tmp/okibi.log}"
+EMAIL="yuki@hamada.tokyo"
+HANDLE="yuki"
+REPO="hello"
+ok(){ echo "✅ $*"; }
+die(){ echo "❌ $*"; exit 1; }
+
+echo "== 1. health =="
+curl -fsS "$BASE/healthz" >/dev/null && ok "health ok" || die "no server"
+
+echo "== 2. non-member is rejected =="
+curl -fsS -d "email=stranger@example.com" "$BASE/login" | grep -q "登録されていません" \
+ && ok "stranger rejected" || die "stranger NOT rejected (membership gate broken!)"
+
+echo "== 3. request magic link (member) =="
+: > "$LOG.marker" 2>/dev/null || true
+curl -fsS -c "$JAR" -d "email=$EMAIL" "$BASE/login" | grep -q "ログインリンク" || die "login submit failed"
+sleep 0.5
+LINK=$(grep -oE "$BASE/auth/verify\?token=[a-f0-9]+" "$LOG" | tail -1)
+[ -n "$LINK" ] && ok "magic link issued" || die "no magic link in log"
+
+echo "== 4. verify -> session =="
+curl -fsS -c "$JAR" -b "$JAR" "$LINK" -o /dev/null && ok "verified, session set" || die "verify failed"
+grep -q okibi_session "$JAR" || die "no session cookie"
+
+echo "== 5. create repo =="
+curl -fsS -c "$JAR" -b "$JAR" -d "name=$REPO&public=1" "$BASE/new" -o /dev/null && ok "repo created" || die "create failed"
+
+echo "== 6. issue PAT =="
+PAT=$(curl -fsS -c "$JAR" -b "$JAR" -d "name=e2e" "$BASE/settings/tokens" \
+ | grep -oE "okibi_[a-f0-9]+" | head -1)
+[ -n "$PAT" ] && ok "PAT issued (${PAT:0:12}...)" || die "no PAT"
+
+echo "== 7. git push =="
+cd "$WORK"
+git init -q -b main proj && cd proj
+git config user.email t@t.io; git config user.name t
+mkdir -p .okibi
+printf 'echo "hello from CI"\nls -la\n' > .okibi/ci.sh
+echo "# Hello Okibi" > README.md
+git add -A && git commit -qm "first fire"
+REMOTE="http://$HANDLE:$PAT@localhost:8799/$HANDLE/$REPO.git"
+git push -q "$REMOTE" main 2>&1 && ok "push ok" || die "push failed"
+
+echo "== 8. git clone back =="
+cd "$WORK"
+git clone -q "$REMOTE" cloned 2>&1 && [ -f cloned/README.md ] \
+ && ok "clone ok, README present" || die "clone failed"
+grep -q "Hello Okibi" cloned/README.md && ok "content matches" || die "content mismatch"
+
+echo "== 9. unauthorized push is blocked =="
+BADREMOTE="http://$HANDLE:okibi_deadbeef@localhost:8799/$HANDLE/$REPO.git"
+if git -C "$WORK/proj" push -q "$BADREMOTE" main 2>/dev/null; then
+ die "bad PAT push SUCCEEDED (auth broken!)"
+else
+ ok "bad PAT push rejected"
+fi
+
+echo "== 10. web tree shows file =="
+curl -fsS "$BASE/$HANDLE/$REPO" | grep -q "README.md" && ok "README visible in web tree" || die "web tree missing file"
+
+echo
+echo "🔥 ALL E2E CHECKS PASSED"
+rm -rf "$WORK" "$JAR"
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..e056410
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,286 @@
+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)
+}
+
+/// 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)
+}
diff --git a/src/ci.rs b/src/ci.rs
new file mode 100644
index 0000000..0313d6b
--- /dev/null
+++ b/src/ci.rs
@@ -0,0 +1,150 @@
+use crate::repos::{self, Repo};
+use crate::util::now;
+use crate::AppState;
+use std::path::PathBuf;
+
+/// Fire CI for a repo after a push. Non-blocking: spawns a background task.
+///
+/// SAFETY: the runner executes code from the pushed commit on the host with no
+/// sandbox. It is OFF unless `OKIBI_CI_ENABLE=1`. v1 trusts atsm members only;
+/// real isolation (ephemeral Fly machine / container per run) is a follow-up.
+pub fn on_push(state: AppState, owner_handle: String, repo: Repo) {
+ if std::env::var("OKIBI_CI_ENABLE").ok().as_deref() != Some("1") {
+ tracing::info!("CI skipped (OKIBI_CI_ENABLE != 1) for {owner_handle}/{}", repo.name);
+ return;
+ }
+ tokio::spawn(async move {
+ if let Err(e) = run(state, owner_handle, repo).await {
+ tracing::error!("CI run failed: {e}");
+ }
+ });
+}
+
+async fn run(state: AppState, owner_handle: String, repo: Repo) -> anyhow::Result<()> {
+ let cfg = state.cfg.clone();
+ let (sha, _subject, _t) = match repos::head_commit(&cfg, &owner_handle, &repo.name, &repo.default_branch) {
+ Some(c) => c,
+ None => return Ok(()), // empty push / branch delete
+ };
+
+ let run_id: i64 = sqlx::query_scalar(
+ "INSERT INTO ci_runs (repo_id, sha, ref_name, status, created_at)
+ VALUES (?, ?, ?, 'running', ?) RETURNING id",
+ )
+ .bind(repo.id)
+ .bind(&sha)
+ .bind(&repo.default_branch)
+ .bind(now())
+ .fetch_one(&state.db)
+ .await?;
+
+ let bare = repos::repo_path(&cfg, &owner_handle, &repo.name);
+ let workdir = std::env::temp_dir().join(format!("okibi-ci-{run_id}"));
+ let _ = std::fs::remove_dir_all(&workdir);
+
+ let (status, log) = execute(&cfg.git_bin, &bare, &workdir, &sha).await;
+ let _ = std::fs::remove_dir_all(&workdir);
+
+ sqlx::query("UPDATE ci_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
+ .bind(&status)
+ .bind(&log)
+ .bind(now())
+ .bind(run_id)
+ .execute(&state.db)
+ .await?;
+ tracing::info!("CI {owner_handle}/{} #{run_id} -> {status}", repo.name);
+ Ok(())
+}
+
+/// Checkout `sha` into a workdir and run the project's CI script.
+async fn execute(git_bin: &str, bare: &PathBuf, workdir: &PathBuf, sha: &str) -> (String, String) {
+ let mut log = String::new();
+
+ // Clone the bare repo at the pushed sha.
+ let clone = tokio::process::Command::new(git_bin)
+ .args(["clone", "--quiet"])
+ .arg(bare)
+ .arg(workdir)
+ .output()
+ .await;
+ if let Err(e) = clone {
+ return ("error".into(), format!("clone failed: {e}"));
+ }
+ let _ = tokio::process::Command::new(git_bin)
+ .current_dir(workdir)
+ .args(["checkout", "--quiet", sha])
+ .output()
+ .await;
+
+ let script = ci_script(workdir);
+ let script = match script {
+ Some(s) => s,
+ None => return ("skipped".into(), "no .okibi/ci.sh or script: in .okibi/ci.yml\n".into()),
+ };
+ log.push_str(&format!("$ running .okibi CI ({} bytes)\n", script.len()));
+
+ let fut = tokio::process::Command::new("bash")
+ .current_dir(workdir)
+ .arg("-eo")
+ .arg("pipefail")
+ .arg("-c")
+ .arg(&script)
+ .env("CI", "true")
+ .env("OKIBI_SHA", sha)
+ .output();
+
+ match tokio::time::timeout(std::time::Duration::from_secs(600), fut).await {
+ Ok(Ok(out)) => {
+ log.push_str(&String::from_utf8_lossy(&out.stdout));
+ log.push_str(&String::from_utf8_lossy(&out.stderr));
+ let status = if out.status.success() { "success" } else { "failed" };
+ (status.into(), log)
+ }
+ Ok(Err(e)) => ("error".into(), format!("{log}\nspawn error: {e}")),
+ Err(_) => ("timeout".into(), format!("{log}\nexceeded 600s")),
+ }
+}
+
+/// Resolve the CI script: prefer `.okibi/ci.sh`, else extract a `script:` list
+/// from `.okibi/ci.yml` (tiny line-based parser, no YAML dep).
+fn ci_script(workdir: &PathBuf) -> Option<String> {
+ let sh = workdir.join(".okibi/ci.sh");
+ if let Ok(s) = std::fs::read_to_string(&sh) {
+ if !s.trim().is_empty() {
+ return Some(s);
+ }
+ }
+ let yml = workdir.join(".okibi/ci.yml");
+ let text = std::fs::read_to_string(&yml).ok()?;
+ let mut lines = Vec::new();
+ let mut in_script = false;
+ for raw in text.lines() {
+ let trimmed = raw.trim_start();
+ if trimmed.starts_with("script:") {
+ in_script = true;
+ // inline form: `script: echo hi`
+ let rest = trimmed.trim_start_matches("script:").trim();
+ if !rest.is_empty() && rest != "|" {
+ lines.push(rest.trim_matches(|c| c == '"' || c == '\'').to_string());
+ in_script = false;
+ }
+ continue;
+ }
+ if in_script {
+ if let Some(item) = trimmed.strip_prefix("- ") {
+ lines.push(item.trim_matches(|c| c == '"' || c == '\'').to_string());
+ } else if trimmed.is_empty() {
+ continue;
+ } else if !raw.starts_with(' ') {
+ in_script = false; // dedent → end of block
+ } else {
+ lines.push(trimmed.to_string());
+ }
+ }
+ }
+ if lines.is_empty() {
+ None
+ } else {
+ Some(lines.join("\n"))
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..bc2952b
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,62 @@
+use std::path::PathBuf;
+
+/// Runtime configuration, sourced from env with sane local defaults.
+#[derive(Clone)]
+pub struct Config {
+ /// Public base URL (used in magic links). e.g. https://okibi.fly.dev
+ pub base_url: String,
+ /// Directory holding bare git repos: `<data>/repos/<owner>/<name>.git`
+ pub repos_dir: PathBuf,
+ /// SQLite file path.
+ pub db_path: PathBuf,
+ /// Path to the `git` binary.
+ pub git_bin: String,
+ /// Path to `git-http-backend` CGI.
+ pub git_http_backend: String,
+ /// Resend API key (optional; if absent, magic links are logged to stdout).
+ pub resend_key: Option<String>,
+ /// From address for outgoing mail.
+ pub mail_from: String,
+ /// Comma-separated seed member emails (always allowed + admin).
+ pub seed_members: Vec<String>,
+ /// Secret token enabling no-email admin bootstrap login (optional).
+ pub admin_bootstrap: Option<String>,
+ pub bind: String,
+}
+
+fn env(key: &str) -> Option<String> {
+ std::env::var(key).ok().filter(|v| !v.is_empty())
+}
+
+impl Config {
+ pub fn from_env() -> Self {
+ let data = env("OKIBI_DATA").unwrap_or_else(|| "./data".into());
+ let data = PathBuf::from(data);
+ let default_backend = if std::path::Path::new(
+ "/opt/homebrew/opt/git/libexec/git-core/git-http-backend",
+ )
+ .exists()
+ {
+ "/opt/homebrew/opt/git/libexec/git-core/git-http-backend".to_string()
+ } else {
+ "/usr/lib/git-core/git-http-backend".to_string()
+ };
+ Config {
+ base_url: env("OKIBI_BASE_URL").unwrap_or_else(|| "http://localhost:8787".into()),
+ repos_dir: data.join("repos"),
+ db_path: data.join("okibi.db"),
+ git_bin: env("OKIBI_GIT_BIN").unwrap_or_else(|| "git".into()),
+ git_http_backend: env("OKIBI_GIT_HTTP_BACKEND").unwrap_or(default_backend),
+ resend_key: env("RESEND_API_KEY"),
+ mail_from: env("OKIBI_MAIL_FROM").unwrap_or_else(|| "Okibi <info@enablerdao.com>".into()),
+ seed_members: env("OKIBI_SEED_MEMBERS")
+ .unwrap_or_else(|| "yuki@hamada.tokyo,mail@yukihamada.jp".into())
+ .split(',')
+ .map(|s| s.trim().to_lowercase())
+ .filter(|s| !s.is_empty())
+ .collect(),
+ admin_bootstrap: env("OKIBI_ADMIN_BOOTSTRAP"),
+ bind: env("OKIBI_BIND").unwrap_or_else(|| "0.0.0.0:8787".into()),
+ }
+ }
+}
diff --git a/src/db.rs b/src/db.rs
new file mode 100644
index 0000000..490ce50
--- /dev/null
+++ b/src/db.rs
@@ -0,0 +1,105 @@
+use crate::util::now;
+use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
+use sqlx::ConnectOptions;
+use std::str::FromStr;
+
+pub type Db = SqlitePool;
+
+const SCHEMA: &str = r#"
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ handle TEXT UNIQUE NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ display_name TEXT NOT NULL DEFAULT '',
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL
+);
+
+-- atsm member allowlist: only these emails may sign in.
+CREATE TABLE IF NOT EXISTS members (
+ email TEXT PRIMARY KEY,
+ display_name TEXT NOT NULL DEFAULT '',
+ is_admin INTEGER NOT NULL DEFAULT 0,
+ added_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+ token_hash TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS magic_links (
+ token_hash TEXT PRIMARY KEY,
+ email TEXT NOT NULL,
+ expires_at INTEGER NOT NULL,
+ used INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS pats (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ token_hash TEXT UNIQUE NOT NULL,
+ created_at INTEGER NOT NULL,
+ last_used INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS repos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ owner_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ visibility TEXT NOT NULL DEFAULT 'private',
+ default_branch TEXT NOT NULL DEFAULT 'main',
+ description TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ UNIQUE(owner_id, name)
+);
+
+CREATE TABLE IF NOT EXISTS ci_runs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ repo_id INTEGER NOT NULL,
+ sha TEXT NOT NULL,
+ ref_name TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'queued',
+ log TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ finished_at INTEGER
+);
+"#;
+
+pub async fn init(db_path: &std::path::Path, seed_members: &[String]) -> anyhow::Result<Db> {
+ if let Some(parent) = db_path.parent() {
+ std::fs::create_dir_all(parent).ok();
+ }
+ let url = format!("sqlite://{}", db_path.display());
+ let opts = sqlx::sqlite::SqliteConnectOptions::from_str(&url)?
+ .create_if_missing(true)
+ .log_statements(tracing::log::LevelFilter::Debug);
+ let pool = SqlitePoolOptions::new()
+ .max_connections(5)
+ .connect_with(opts)
+ .await?;
+
+ for stmt in SCHEMA.split(';') {
+ let s = stmt.trim();
+ if !s.is_empty() {
+ sqlx::query(s).execute(&pool).await?;
+ }
+ }
+
+ // Seed allowlist members (admins).
+ for email in seed_members {
+ sqlx::query(
+ "INSERT INTO members (email, display_name, is_admin, added_at)
+ VALUES (?, '', 1, ?)
+ ON CONFLICT(email) DO UPDATE SET is_admin = 1",
+ )
+ .bind(email)
+ .bind(now())
+ .execute(&pool)
+ .await?;
+ }
+
+ Ok(pool)
+}
diff --git a/src/email.rs b/src/email.rs
new file mode 100644
index 0000000..6c51189
--- /dev/null
+++ b/src/email.rs
@@ -0,0 +1,38 @@
+use crate::config::Config;
+
+/// Send a magic-link email via Resend. If no key is configured, log the link
+/// to stdout (dev mode) so local flows still work.
+pub async fn send_magic_link(cfg: &Config, to: &str, link: &str) {
+ if cfg.resend_key.is_none() {
+ tracing::warn!("[dev] magic link for {to}: {link}");
+ println!("\n🔥 [dev] Okibi magic link for {to}:\n {link}\n");
+ return;
+ }
+ let key = cfg.resend_key.as_ref().unwrap();
+ let html = format!(
+ r#"<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto">
+ <h2>🔥 Okibi にログイン</h2>
+ <p>焚き火の仲間だけの Git。下のボタンでログインします(15分有効)。</p>
+ <p><a href="{link}" style="display:inline-block;background:#e25822;color:#fff;
+ padding:12px 20px;border-radius:8px;text-decoration:none">ログイン</a></p>
+ <p style="color:#888;font-size:12px">心当たりがなければ無視してください。</p></div>"#
+ );
+ let body = serde_json::json!({
+ "from": cfg.mail_from,
+ "to": [to],
+ "subject": "🔥 Okibi ログインリンク",
+ "html": html,
+ });
+ let client = reqwest::Client::new();
+ match client
+ .post("https://api.resend.com/emails")
+ .bearer_auth(key)
+ .json(&body)
+ .send()
+ .await
+ {
+ Ok(r) if r.status().is_success() => tracing::info!("magic link emailed to {to}"),
+ Ok(r) => tracing::error!("resend failed {}: {}", r.status(), r.text().await.unwrap_or_default()),
+ Err(e) => tracing::error!("resend error: {e}"),
+ }
+}
diff --git a/src/git_http.rs b/src/git_http.rs
new file mode 100644
index 0000000..7f246dc
--- /dev/null
+++ b/src/git_http.rs
@@ -0,0 +1,294 @@
+use crate::auth::{self, User};
+use crate::repos::{self, Repo};
+use crate::{ci, AppState};
+use axum::body::Bytes;
+use axum::extract::{Path, RawQuery, State};
+use axum::http::{header, HeaderMap, StatusCode};
+use axum::response::{IntoResponse, Response};
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+const REALM: &str = "Okibi — atsm members only";
+
+fn need_auth() -> Response {
+ (
+ StatusCode::UNAUTHORIZED,
+ [(header::WWW_AUTHENTICATE, format!("Basic realm=\"{REALM}\""))],
+ "authentication required\n",
+ )
+ .into_response()
+}
+
+/// Resolve (owner_handle, repo_name) → repo + owner, or an error response.
+async fn resolve(
+ state: &AppState,
+ owner: &str,
+ repo_seg: &str,
+) -> Result<(User, Repo), Response> {
+ let name = repo_seg.strip_suffix(".git").unwrap_or(repo_seg);
+ let owner_user = auth::user_by_handle(&state.db, owner)
+ .await
+ .ok_or_else(|| (StatusCode::NOT_FOUND, "no such owner\n").into_response())?;
+ let repo = repos::get_repo(&state.db, owner_user.id, name)
+ .await
+ .ok_or_else(|| (StatusCode::NOT_FOUND, "no such repo\n").into_response())?;
+ Ok((owner_user, repo))
+}
+
+/// Authorize a git op. `write` = push (receive-pack). Returns the actor on success.
+async fn authorize(
+ state: &AppState,
+ headers: &HeaderMap,
+ owner: &User,
+ repo: &Repo,
+ write: bool,
+) -> Result<Option<User>, Response> {
+ // Try PAT basic auth if present.
+ let actor = if let Some((u, p)) = auth::basic_auth(headers) {
+ auth::verify_pat(&state.db, &u, &p).await
+ } else {
+ None
+ };
+
+ if write {
+ // Push: must be authenticated and own the repo.
+ match &actor {
+ Some(a) if a.id == owner.id => Ok(Some(actor.unwrap())),
+ Some(_) => Err((StatusCode::FORBIDDEN, "not your repo\n").into_response()),
+ None => Err(need_auth()),
+ }
+ } else {
+ // Pull: public repos are open; private requires the owner.
+ if repo.is_public() {
+ Ok(actor)
+ } else {
+ match &actor {
+ Some(a) if a.id == owner.id => Ok(Some(actor.unwrap())),
+ Some(_) => Err((StatusCode::FORBIDDEN, "private repo\n").into_response()),
+ None => Err(need_auth()),
+ }
+ }
+ }
+}
+
+fn service_is_write(service: &str) -> bool {
+ service == "git-receive-pack"
+}
+
+// GET /{owner}/{repo}/info/refs?service=git-(upload|receive)-pack
+pub async fn info_refs(
+ State(state): State<AppState>,
+ Path((owner, repo)): Path<(String, String)>,
+ RawQuery(query): RawQuery,
+ headers: HeaderMap,
+) -> Response {
+ let query = query.unwrap_or_default();
+ let service = query
+ .split('&')
+ .find_map(|kv| kv.strip_prefix("service="))
+ .unwrap_or("")
+ .to_string();
+ if !service.starts_with("git-") {
+ return (StatusCode::BAD_REQUEST, "dumb http not supported\n").into_response();
+ }
+ let (owner_user, repo_meta) = match resolve(&state, &owner, &repo).await {
+ Ok(v) => v,
+ Err(r) => return r,
+ };
+ if let Err(r) = authorize(&state, &headers, &owner_user, &repo_meta, service_is_write(&service)).await {
+ return r;
+ }
+ cgi(
+ &state,
+ &owner_user.handle,
+ &repo,
+ "info/refs",
+ "GET",
+ &query,
+ None,
+ &headers,
+ Bytes::new(),
+ None,
+ )
+ .await
+}
+
+// POST /{owner}/{repo}/git-upload-pack
+pub async fn upload_pack(
+ State(state): State<AppState>,
+ Path((owner, repo)): Path<(String, String)>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ service(&state, owner, repo, "git-upload-pack", false, headers, body).await
+}
+
+// POST /{owner}/{repo}/git-receive-pack
+pub async fn receive_pack(
+ State(state): State<AppState>,
+ Path((owner, repo)): Path<(String, String)>,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ service(&state, owner, repo, "git-receive-pack", true, headers, body).await
+}
+
+async fn service(
+ state: &AppState,
+ owner: String,
+ repo: String,
+ svc: &str,
+ write: bool,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Response {
+ let (owner_user, repo_meta) = match resolve(state, &owner, &repo).await {
+ Ok(v) => v,
+ Err(r) => return r,
+ };
+ let actor = match authorize(state, &headers, &owner_user, &repo_meta, write).await {
+ Ok(a) => a,
+ Err(r) => return r,
+ };
+ let content_type = headers
+ .get(header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_string());
+ let resp = cgi(
+ state,
+ &owner_user.handle,
+ &repo,
+ svc,
+ "POST",
+ "",
+ content_type.as_deref(),
+ &headers,
+ body,
+ actor.as_ref().map(|u| u.handle.clone()),
+ )
+ .await;
+
+ // After a successful push, kick off CI for the repo's default branch.
+ if write && resp.status().is_success() {
+ ci::on_push(state.clone(), owner_user.handle.clone(), repo_meta.clone());
+ }
+ resp
+}
+
+/// Bridge a request into the `git-http-backend` CGI.
+#[allow(clippy::too_many_arguments)]
+async fn cgi(
+ state: &AppState,
+ owner_handle: &str,
+ repo_seg: &str,
+ subpath: &str,
+ method: &str,
+ query: &str,
+ content_type: Option<&str>,
+ headers: &HeaderMap,
+ body: Bytes,
+ remote_user: Option<String>,
+) -> Response {
+ let cfg = state.cfg.clone();
+ let project_root = cfg.repos_dir.join(owner_handle);
+ let path_info = format!("/{repo_seg}/{subpath}");
+ let git_protocol = headers
+ .get("git-protocol")
+ .and_then(|v| v.to_str().ok())
+ .map(|s| s.to_string());
+ let backend = cfg.git_http_backend.clone();
+ let method = method.to_string();
+ let query = query.to_string();
+ let content_type = content_type.map(|s| s.to_string());
+
+ let out = tokio::task::spawn_blocking(move || {
+ let mut cmd = Command::new(&backend);
+ cmd.env("GIT_PROJECT_ROOT", &project_root)
+ .env("GIT_HTTP_EXPORT_ALL", "1")
+ .env("PATH_INFO", &path_info)
+ .env("REQUEST_METHOD", &method)
+ .env("QUERY_STRING", &query)
+ .env("CONTENT_LENGTH", body.len().to_string())
+ .env("REMOTE_ADDR", "127.0.0.1")
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ if let Some(ct) = &content_type {
+ cmd.env("CONTENT_TYPE", ct);
+ }
+ if let Some(gp) = &git_protocol {
+ cmd.env("GIT_PROTOCOL", gp);
+ }
+ if let Some(ru) = &remote_user {
+ cmd.env("REMOTE_USER", ru);
+ }
+ let mut child = cmd.spawn()?;
+ if let Some(mut stdin) = child.stdin.take() {
+ stdin.write_all(&body)?;
+ }
+ let output = child.wait_with_output()?;
+ Ok::<_, std::io::Error>(output)
+ })
+ .await;
+
+ let output = match out {
+ Ok(Ok(o)) => o,
+ Ok(Err(e)) => {
+ tracing::error!("git-http-backend io error: {e}");
+ return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error\n").into_response();
+ }
+ Err(e) => {
+ tracing::error!("git-http-backend join error: {e}");
+ return (StatusCode::INTERNAL_SERVER_ERROR, "git backend panicked\n").into_response();
+ }
+ };
+ if !output.status.success() && output.stdout.is_empty() {
+ tracing::error!(
+ "git-http-backend failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ return (StatusCode::INTERNAL_SERVER_ERROR, "git backend failed\n").into_response();
+ }
+
+ parse_cgi(output.stdout)
+}
+
+/// Split CGI output into headers + body and build an axum Response.
+fn parse_cgi(raw: Vec<u8>) -> Response {
+ let sep_crlf = find(&raw, b"\r\n\r\n");
+ let sep_lf = find(&raw, b"\n\n");
+ let (head_end, body_start) = match (sep_crlf, sep_lf) {
+ (Some(a), Some(b)) if a <= b => (a, a + 4),
+ (_, Some(b)) => (b, b + 2),
+ (Some(a), None) => (a, a + 4),
+ (None, None) => (0, 0),
+ };
+ let header_block = String::from_utf8_lossy(&raw[..head_end]).to_string();
+ let body = raw[body_start..].to_vec();
+
+ let mut status = StatusCode::OK;
+ let mut builder = Response::builder();
+ for line in header_block.lines() {
+ if let Some((k, v)) = line.split_once(':') {
+ let k = k.trim();
+ let v = v.trim();
+ if k.eq_ignore_ascii_case("Status") {
+ if let Some(code) = v.split_whitespace().next() {
+ if let Ok(c) = code.parse::<u16>() {
+ status = StatusCode::from_u16(c).unwrap_or(StatusCode::OK);
+ }
+ }
+ } else {
+ builder = builder.header(k, v);
+ }
+ }
+ }
+ builder
+ .status(status)
+ .body(axum::body::Body::from(body))
+ .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build error\n").into_response())
+}
+
+fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
+ haystack.windows(needle.len()).position(|w| w == needle)
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..a2e2d29
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,68 @@
+mod auth;
+mod ci;
+mod config;
+mod db;
+mod email;
+mod git_http;
+mod repos;
+mod util;
+mod web;
+
+use axum::routing::{get, post};
+use axum::Router;
+use config::Config;
+use db::Db;
+
+/// Shared application state.
+#[derive(Clone)]
+pub struct AppState {
+ pub db: Db,
+ pub cfg: Config,
+}
+
+async fn healthz() -> &'static str {
+ "ok\n"
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| "okibi=info,tower_http=warn".into()),
+ )
+ .init();
+
+ let cfg = Config::from_env();
+ std::fs::create_dir_all(&cfg.repos_dir).ok();
+ let db = db::init(&cfg.db_path, &cfg.seed_members).await?;
+ tracing::info!("members seeded: {:?}", cfg.seed_members);
+
+ let state = AppState { db, cfg: cfg.clone() };
+
+ let app = Router::new()
+ .route("/", get(web::index))
+ .route("/healthz", get(healthz))
+ .route("/login", get(web::login_form).post(web::login_submit))
+ .route("/auth/verify", get(web::verify))
+ .route("/auth/bootstrap", get(web::bootstrap))
+ .route("/members", get(web::members_page))
+ .route("/members/invite", post(web::invite_submit))
+ .route("/logout", post(web::logout))
+ .route("/new", get(web::new_repo_form).post(web::new_repo_submit))
+ .route("/settings", get(web::settings))
+ .route("/settings/tokens", post(web::new_token))
+ // git smart-HTTP (must come before the generic repo view)
+ .route("/{owner}/{repo}/info/refs", get(git_http::info_refs))
+ .route("/{owner}/{repo}/git-upload-pack", post(git_http::upload_pack))
+ .route("/{owner}/{repo}/git-receive-pack", post(git_http::receive_pack))
+ // web views
+ .route("/{owner}", get(web::user_view))
+ .route("/{owner}/{repo}", get(web::repo_view))
+ .with_state(state);
+
+ let listener = tokio::net::TcpListener::bind(&cfg.bind).await?;
+ tracing::info!("🔥 Okibi listening on {} (base {})", cfg.bind, cfg.base_url);
+ axum::serve(listener, app).await?;
+ Ok(())
+}
diff --git a/src/repos.rs b/src/repos.rs
new file mode 100644
index 0000000..4cea6a3
--- /dev/null
+++ b/src/repos.rs
@@ -0,0 +1,191 @@
+use crate::auth::User;
+use crate::config::Config;
+use crate::db::Db;
+use crate::util::now;
+use std::path::PathBuf;
+use std::process::Command;
+
+#[derive(Clone, Debug, sqlx::FromRow)]
+pub struct Repo {
+ pub id: i64,
+ pub owner_id: i64,
+ pub name: String,
+ pub visibility: String,
+ pub default_branch: String,
+ pub description: String,
+}
+
+impl Repo {
+ pub fn is_public(&self) -> bool {
+ self.visibility == "public"
+ }
+}
+
+/// On-disk path of a repo's bare git dir.
+pub fn repo_path(cfg: &Config, owner_handle: &str, name: &str) -> PathBuf {
+ cfg.repos_dir.join(owner_handle).join(format!("{name}.git"))
+}
+
+/// Create repo metadata + initialise a bare git repo on disk.
+pub async fn create_repo(
+ db: &Db,
+ cfg: &Config,
+ owner: &User,
+ name: &str,
+ visibility: &str,
+ description: &str,
+) -> anyhow::Result<Repo> {
+ let visibility = if visibility == "public" { "public" } else { "private" };
+ sqlx::query(
+ "INSERT INTO repos (owner_id, name, visibility, default_branch, description, created_at)
+ VALUES (?, ?, ?, 'main', ?, ?)",
+ )
+ .bind(owner.id)
+ .bind(name)
+ .bind(visibility)
+ .bind(description)
+ .bind(now())
+ .execute(db)
+ .await?;
+
+ let path = repo_path(cfg, &owner.handle, name);
+ std::fs::create_dir_all(&path)?;
+ let path_str = path.to_string_lossy().to_string();
+ run_git(cfg, &["init", "--bare", "--initial-branch=main", &path_str])?;
+ // Allow smart-HTTP push/pull for this repo.
+ run_git_in(cfg, &path, &["config", "http.receivepack", "true"])?;
+ run_git_in(cfg, &path, &["config", "http.uploadpack", "true"])?;
+
+ get_repo(db, owner.id, name)
+ .await
+ .ok_or_else(|| anyhow::anyhow!("repo vanished after insert"))
+}
+
+pub async fn get_repo(db: &Db, owner_id: i64, name: &str) -> Option<Repo> {
+ sqlx::query_as::<_, Repo>(
+ "SELECT id, owner_id, name, visibility, default_branch, description
+ FROM repos WHERE owner_id = ? AND name = ?",
+ )
+ .bind(owner_id)
+ .bind(name)
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten()
+}
+
+pub async fn list_repos_for_owner(db: &Db, owner_id: i64) -> Vec<Repo> {
+ sqlx::query_as::<_, Repo>(
+ "SELECT id, owner_id, name, visibility, default_branch, description
+ FROM repos WHERE owner_id = ? ORDER BY name",
+ )
+ .bind(owner_id)
+ .fetch_all(db)
+ .await
+ .unwrap_or_default()
+}
+
+/// Repos visible to `viewer` (public ones + viewer's own). Newest first.
+pub async fn list_visible(db: &Db, viewer: Option<&User>) -> Vec<(Repo, String)> {
+ let rows = sqlx::query_as::<_, (i64, i64, String, String, String, String, String)>(
+ "SELECT r.id, r.owner_id, r.name, r.visibility, r.default_branch, r.description, u.handle
+ FROM repos r JOIN users u ON u.id = r.owner_id
+ ORDER BY r.created_at DESC",
+ )
+ .fetch_all(db)
+ .await
+ .unwrap_or_default();
+ rows.into_iter()
+ .map(|(id, owner_id, name, visibility, default_branch, description, handle)| {
+ (
+ Repo { id, owner_id, name, visibility, default_branch, description },
+ handle,
+ )
+ })
+ .filter(|(r, _)| {
+ r.is_public() || viewer.map(|v| v.id == r.owner_id).unwrap_or(false)
+ })
+ .collect()
+}
+
+/// Does the repo have any commits yet?
+pub fn is_empty(cfg: &Config, owner_handle: &str, name: &str) -> bool {
+ let path = repo_path(cfg, owner_handle, name);
+ run_git_in(cfg, &path, &["rev-parse", "--verify", "HEAD"]).is_err()
+}
+
+/// `git ls-tree` a path at HEAD (or a ref). Returns (mode, type, name) rows.
+pub fn ls_tree(cfg: &Config, owner_handle: &str, name: &str, rev: &str, subpath: &str) -> Vec<(String, String, String)> {
+ let path = repo_path(cfg, owner_handle, name);
+ let spec = if subpath.is_empty() {
+ rev.to_string()
+ } else {
+ format!("{rev}:{subpath}")
+ };
+ let out = match run_git_in(cfg, &path, &["ls-tree", "--long", &spec]) {
+ Ok(o) => o,
+ Err(_) => return vec![],
+ };
+ let mut entries = vec![];
+ for line in out.lines() {
+ // <mode> <type> <object> <size>\t<name>
+ if let Some((meta, fname)) = line.split_once('\t') {
+ let cols: Vec<&str> = meta.split_whitespace().collect();
+ if cols.len() >= 2 {
+ entries.push((cols[0].to_string(), cols[1].to_string(), fname.to_string()));
+ }
+ }
+ }
+ // dirs first, then files, alpha
+ entries.sort_by(|a, b| {
+ let ad = a.1 == "tree";
+ let bd = b.1 == "tree";
+ bd.cmp(&ad).then(a.2.cmp(&b.2))
+ });
+ entries
+}
+
+/// Read a blob's bytes at `rev:path`.
+pub fn read_blob(cfg: &Config, owner_handle: &str, name: &str, rev: &str, file: &str) -> Option<Vec<u8>> {
+ let path = repo_path(cfg, owner_handle, name);
+ let spec = format!("{rev}:{file}");
+ let output = Command::new(&cfg.git_bin)
+ .arg("-C")
+ .arg(&path)
+ .args(["cat-file", "blob", &spec])
+ .output()
+ .ok()?;
+ if output.status.success() {
+ Some(output.stdout)
+ } else {
+ None
+ }
+}
+
+/// Latest commit (sha, subject, unix time) for a ref.
+pub fn head_commit(cfg: &Config, owner_handle: &str, name: &str, rev: &str) -> Option<(String, String, i64)> {
+ let path = repo_path(cfg, owner_handle, name);
+ let out = run_git_in(cfg, &path, &["log", "-1", "--format=%H%x00%s%x00%ct", rev]).ok()?;
+ let parts: Vec<&str> = out.trim().split('\u{0}').collect();
+ if parts.len() == 3 {
+ Some((parts[0].to_string(), parts[1].to_string(), parts[2].parse().unwrap_or(0)))
+ } else {
+ None
+ }
+}
+
+fn run_git(cfg: &Config, args: &[&str]) -> anyhow::Result<String> {
+ let out = Command::new(&cfg.git_bin).args(args).output()?;
+ if !out.status.success() {
+ anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
+ }
+ Ok(String::from_utf8_lossy(&out.stdout).to_string())
+}
+
+fn run_git_in(cfg: &Config, dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
+ let out = Command::new(&cfg.git_bin).arg("-C").arg(dir).args(args).output()?;
+ if !out.status.success() {
+ anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
+ }
+ Ok(String::from_utf8_lossy(&out.stdout).to_string())
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..791d56f
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,64 @@
+:root {
+ --bg: #0f0d0c; --panel: #1a1614; --ink: #f3ece6; --muted: #9a8f86;
+ --fire: #e25822; --fire2: #f59e0b; --line: #2a2320; --link: #f0a868;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0; background: var(--bg); color: var(--ink);
+ font: 15px/1.6 system-ui, -apple-system, "Hiragino Kaku Gothic ProN", sans-serif;
+}
+a { color: var(--link); text-decoration: none; }
+a:hover { text-decoration: underline; }
+.top {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 12px 20px; border-bottom: 1px solid var(--line);
+ background: linear-gradient(180deg, #1a1411, #0f0d0c);
+}
+.brand { font-size: 18px; font-weight: 700; color: var(--ink); }
+.top nav { display: flex; gap: 16px; align-items: center; }
+main { max-width: 880px; margin: 0 auto; padding: 28px 20px 80px; }
+h1 { font-size: 22px; margin: 0 0 16px; }
+h2 { font-size: 17px; }
+.muted { color: var(--muted); }
+.badge {
+ font-size: 11px; background: var(--line); color: var(--muted);
+ padding: 2px 8px; border-radius: 999px; margin-left: 8px; vertical-align: middle;
+}
+.card {
+ background: var(--panel); border: 1px solid var(--line);
+ border-radius: 12px; padding: 18px; margin: 16px 0;
+}
+input[type=text], input[type=email] {
+ width: 100%; padding: 10px 12px; margin: 6px 0 14px;
+ background: #0c0a09; border: 1px solid var(--line); border-radius: 8px; color: var(--ink);
+}
+label { display: block; font-size: 13px; color: var(--muted); }
+label.row, .row { display: flex; gap: 10px; align-items: center; }
+button {
+ background: var(--fire); color: #fff; border: 0; border-radius: 8px;
+ padding: 10px 18px; font-size: 14px; font-weight: 600; cursor: pointer;
+}
+button:hover { background: var(--fire2); }
+button.link { background: none; color: var(--link); padding: 0; font-weight: 400; }
+button.link:hover { text-decoration: underline; }
+.repolist { list-style: none; padding: 0; }
+.repolist li { padding: 14px 0; border-bottom: 1px solid var(--line); }
+.repolist .repo { font-size: 16px; }
+code, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
+pre {
+ background: #0c0a09; border: 1px solid var(--line); border-radius: 10px;
+ padding: 14px; overflow: auto; font-size: 13px;
+}
+pre.code { white-space: pre; }
+pre.token { color: var(--fire2); font-size: 15px; }
+.clonebar code {
+ display: inline-block; background: #0c0a09; border: 1px solid var(--line);
+ border-radius: 8px; padding: 8px 12px; margin: 8px 0; font-size: 13px;
+}
+table.tree { width: 100%; border-collapse: collapse; }
+table.tree td { padding: 8px 4px; border-bottom: 1px solid var(--line); }
+.crumb { font-size: 14px; }
+footer {
+ border-top: 1px solid var(--line); color: var(--muted); font-size: 12px;
+ text-align: center; padding: 24px;
+}
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 0000000..cce0eca
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,59 @@
+use rand::RngCore;
+use sha2::{Digest, Sha256};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// Current unix time in seconds.
+pub fn now() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_secs() as i64
+}
+
+/// 32 random bytes as lowercase hex (64 chars).
+pub fn random_token() -> String {
+ let mut buf = [0u8; 32];
+ rand::thread_rng().fill_bytes(&mut buf);
+ hex::encode(buf)
+}
+
+/// SHA-256 hex digest — we store only hashes of secrets (sessions, PATs, magic links).
+pub fn sha256_hex(input: &str) -> String {
+ let mut h = Sha256::new();
+ h.update(input.as_bytes());
+ hex::encode(h.finalize())
+}
+
+/// Normalize an email for comparison (trim + lowercase).
+pub fn norm_email(s: &str) -> String {
+ s.trim().to_lowercase()
+}
+
+/// Derive a default handle from an email local-part, kept to [a-z0-9-].
+pub fn handle_from_email(email: &str) -> String {
+ let local = email.split('@').next().unwrap_or("user");
+ let mut out = String::new();
+ for c in local.chars() {
+ if c.is_ascii_alphanumeric() {
+ out.push(c.to_ascii_lowercase());
+ } else if c == '.' || c == '_' || c == '-' || c == '+' {
+ out.push('-');
+ }
+ }
+ let trimmed = out.trim_matches('-').to_string();
+ if trimmed.is_empty() {
+ "user".into()
+ } else {
+ trimmed
+ }
+}
+
+/// Validate a repo/handle name: lowercase alnum, dash, underscore, dot. 1..=64 chars.
+pub fn valid_name(s: &str) -> bool {
+ !s.is_empty()
+ && s.len() <= 64
+ && s.chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
+ && s != "."
+ && s != ".."
+}
diff --git a/src/web.rs b/src/web.rs
new file mode 100644
index 0000000..f7a5929
--- /dev/null
+++ b/src/web.rs
@@ -0,0 +1,583 @@
+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 {
+ @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
+}
+
+// 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! {
+ h1 { "リポジトリ" }
+ @if repos.is_empty() {
+ p.muted { "まだ何もない。" a href="/new" { "最初の一本を建てる" } "。" }
+ }
+ 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 /{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 { "現在のメンバー" }
+ 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()
+}
+
+// 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) } }
+ }
+ }
+ }
+ }
+ };
+ 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");
diff --git a/tasks/todo.md b/tasks/todo.md
new file mode 100644
index 0000000..36a2f46
--- /dev/null
+++ b/tasks/todo.md
@@ -0,0 +1,36 @@
+# Okibi — 焚き火(atsm)会員だけの自前 Git ホスティング
+
+> 言葉が、残る。焚き火の仲間だけが安全に push できる、モダンな自作 GitHub。
+> Stack: Rust + axum + sqlx(SQLite) + maud。git smart-HTTP は git-http-backend(CGI) に委譲。
+
+## 決定事項(2026-06-10)
+- 本体: 完全自作(Rust/axum)
+- 認証: 自前 magic-link(メール)+ **atsm 会員 allowlist** ゲート
+ - 理由: atsm.wtf に OIDC 無し / members API は表示名のみ → 純正 SSO は kenny 側依存。allowlist で疎結合に。
+ - upgrade path: atsm.wtf が /api/me or OIDC を出したら差し替え
+- git: smart-HTTP を `git-http-backend` に委譲(pack 自前実装しない)
+- CI: v1 は `.okibi/ci.yml` を push 時に実行(最小ランナー)。隔離は後追い。
+- ホスト: Fly.io + Volume(`/data`)
+
+## フェーズ
+- [x] P0 scaffold: Cargo + axum + health + SQLite 初期化
+- [ ] P1 認証: magic-link 発行/検証 + セッション + atsm allowlist + PAT 発行
+- [ ] P2 リポ: 作成/一覧 + ディスクに bare repo init
+- [ ] P3 **git smart-HTTP push/pull**(CGI bridge + 認証ゲート)★コア
+- [ ] P4 Web UI: リポ一覧 / ファイルツリー / blob 表示(モダン最小)
+- [ ] P5 CI: post-receive → ci.yml 実行 → ログ保存/表示
+- [ ] P6 Fly デプロイ(git push → Actions、Volume、Resend secret)
+
+## v1 完了の定義(検証)
+1. allowlist のメールで magic-link ログインできる / 非会員は弾かれる
+2. ブラウザでリポ作成 → PAT 発行
+3. `git clone https://<handle>:<PAT>@okibi/<handle>/<repo>.git` → commit → push → pull が通る
+4. push した内容がブラウザのファイルツリーで見える
+5. `.okibi/ci.yml` の script が走りログが残る
+
+## ローカル検証コマンド
+```
+cargo build
+cargo run # :8787
+# 別シェルで git push/pull E2E(scripts/e2e.sh)
+```