diff --git a/.env.example b/.env.example
index bfddad82..5c2e55a2 100644
--- a/.env.example
+++ b/.env.example
@@ -2,3 +2,8 @@
GITHUB_TOKEN=
# How many logins may be fetched for the first time each hour (default 600).
# NEW_LOGINS_PER_HOUR=600
+# The admin pages (/admin/status, /admin/health) behind Cloudflare Access. Without all three they open only
+# for a local run. The team domain and the Access app's audience tag are shown in Zero Trust, under the app.
+# ACCESS_TEAM_DOMAIN=yourteam.cloudflareaccess.com
+# ACCESS_AUD=
+# ADMIN_EMAILS=you@example.com
diff --git a/Cargo.lock b/Cargo.lock
index e5ced151..1a9f8898 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,6 +14,12 @@ 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 = "aws-lc-rs"
version = "1.18.1"
@@ -89,18 +95,45 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+[[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"
@@ -164,8 +197,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
- "cpufeatures",
- "rand_core",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
]
[[package]]
@@ -187,6 +220,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -219,6 +258,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -237,6 +285,78 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -254,6 +374,65 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -293,6 +472,22 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -379,6 +574,17 @@ dependencies = [
"slab",
]
+[[package]]
+name = "generic-array"
+version = "0.14.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -402,7 +608,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi",
- "rand_core",
+ "rand_core 0.10.1",
"wasm-bindgen",
]
@@ -412,6 +618,17 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "h2"
version = "0.4.20"
@@ -437,6 +654,24 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "http"
version = "1.5.0"
@@ -525,7 +760,7 @@ version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
- "base64",
+ "base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
@@ -741,12 +976,49 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonwebtoken"
+version = "11.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
+dependencies = [
+ "base64 0.22.1",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "hmac",
+ "js-sys",
+ "p256",
+ "p384",
+ "rand 0.8.8",
+ "rsa",
+ "serde",
+ "serde_json",
+ "sha2",
+ "signature",
+ "zeroize",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+dependencies = [
+ "spin",
+]
+
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
[[package]]
name = "libwebp-sys"
version = "0.9.6"
@@ -830,6 +1102,51 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.8",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -842,6 +1159,39 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -854,6 +1204,27 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
[[package]]
name = "pkg-config"
version = "0.3.34"
@@ -882,6 +1253,24 @@ 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 = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -896,6 +1285,7 @@ name = "questlog"
version = "1.0.0"
dependencies = [
"axum",
+ "jsonwebtoken",
"png",
"reqwest",
"serde",
@@ -935,7 +1325,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
- "rand",
+ "rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
@@ -977,6 +1367,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "rand"
version = "0.10.3"
@@ -985,7 +1386,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
- "rand_core",
+ "rand_core 0.10.1",
+]
+
+[[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_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
]
[[package]]
@@ -1000,7 +1420,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
- "rand_core",
+ "rand_core 0.10.1",
]
[[package]]
@@ -1021,7 +1441,7 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [
- "base64",
+ "base64 0.23.1",
"bytes",
"encoding_rs",
"futures-core",
@@ -1055,6 +1475,16 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
[[package]]
name = "ring"
version = "0.17.14"
@@ -1069,6 +1499,26 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1195,6 +1645,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -1290,6 +1754,17 @@ dependencies = [
"serde",
]
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -1306,6 +1781,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -1360,6 +1845,22 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1594,6 +2095,12 @@ 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 = "unicode-ident"
version = "1.0.26"
@@ -1624,6 +2131,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -1898,6 +2411,26 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "zerofrom"
version = "0.1.8"
@@ -1924,6 +2457,20 @@ name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
[[package]]
name = "zerotrie"
diff --git a/Cargo.toml b/Cargo.toml
index 31576283..5e51f1aa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,7 @@ publish = false
[dependencies]
axum = "0.8.9"
+jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
png = "0.18.1"
reqwest = { version = "0.13.5", features = ["json"] }
serde = { version = "1.0.229", features = ["derive"] }
diff --git a/README.md b/README.md
index 659f1510..fd1a25da 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,8 @@ docker run -d -p 8080:8080 --env-file .env -v questlog-data:/data questlog
`.env` holds `GITHUB_TOKEN=` with a fine-grained token that can read public repositories (see `.env.example`). `NEW_LOGINS_PER_HOUR` (600 by default) caps how many logins are fetched for the first time each hour; raise it for a busy day, GitHub's own limit still pauses fetching when it runs low. The server listens on `PORT` (8080) and keeps its cache in `DATA_DIR` (`/data`). With your own token, your own instance can count your private contributions too.
+**Watching it.** The server writes one plain line per request and per background event to stderr (`docker logs questlog`): what was asked, whether the card came from the memory cache, the disk cache or was drawn (and how long that took), each GitHub fetch with the quota left, queue decisions, and a status line every 15 minutes. Client IPs are never written; the Cloudflare ray id links a line to Cloudflare's own logs. `/status` and `/health` show the public state (up, whether GitHub fetching runs, whether new users get in, with times in UTC). `/admin/status` and `/admin/health` show every count and capacity, the last hour's traffic and the latest log lines. Put them behind a Cloudflare Access app and set `ACCESS_TEAM_DOMAIN`, `ACCESS_AUD` and `ADMIN_EMAILS` (see `.env.example`): the server then checks the login token Access signs on every request, so the pages stay closed even if someone reaches the server another way. Without those settings they open only for a local run.
+
## Development
The pages at `/`, `/builder` and `/wiki` come from `site/`, a Next.js static export that the binary embeds. Build it before `cargo build`: `cd site && npm ci && npm run build` (it writes `static/`).
diff --git a/docker-compose.vps.yml b/docker-compose.vps.yml
index 3ec09fcb..b580095b 100644
--- a/docker-compose.vps.yml
+++ b/docker-compose.vps.yml
@@ -12,6 +12,9 @@ services:
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
env_file: .env
+ logging: # one readable line per request and event; capped so the log can't fill the disk
+ driver: json-file
+ options: { max-size: "20m", max-file: "5" }
volumes:
- questlog-data:/data
diff --git a/site/app/admin/status/page.tsx b/site/app/admin/status/page.tsx
new file mode 100644
index 00000000..2b9f52dd
--- /dev/null
+++ b/site/app/admin/status/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import Nav from "@/components/Nav";
+import Health from "@/components/Health";
+
+// Behind Cloudflare Access: every number, for Baran. Not indexed, not linked from the site.
+export const metadata: Metadata = {
+ title: "questlog health, every number",
+ robots: { index: false, follow: false },
+};
+
+export default function AdminStatusPage() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/site/app/status/page.tsx b/site/app/status/page.tsx
new file mode 100644
index 00000000..9dead9f4
--- /dev/null
+++ b/site/app/status/page.tsx
@@ -0,0 +1,31 @@
+import type { Metadata } from "next";
+import Nav from "@/components/Nav";
+import Footer from "@/components/Footer";
+import Health from "@/components/Health";
+
+export const metadata: Metadata = {
+ title: "questlog health: is the service up, and how busy is it",
+ description: "Live health of questlog: whether cards are served, whether stats arrive from GitHub, and whether new users get in right now.",
+ alternates: { canonical: "/status" },
+ openGraph: { url: "/status", title: "questlog health", description: "Live health of the questlog card service." },
+};
+
+export default function StatusPage() {
+ return (
+ <>
+
+ Skip to content
+
+
+
+
+
+
+ This page needs JavaScript. The same is at /health .
+
+
+
+
+ >
+ );
+}
diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx
index d6bc8a35..5f1cece0 100644
--- a/site/components/Footer.tsx
+++ b/site/components/Footer.tsx
@@ -12,6 +12,7 @@ export default function Footer() {
Make my cards
Scenes
Wiki
+ Health
GitHub
diff --git a/site/components/Health.module.css b/site/components/Health.module.css
new file mode 100644
index 00000000..ed2565bf
--- /dev/null
+++ b/site/components/Health.module.css
@@ -0,0 +1,406 @@
+/* The health page: a header with the overall state and a Refresh button, then one glass panel per part of the
+ service, each with a tone chip and a pixel meter built from 20 blocks like the cards' own bars. */
+.page {
+ --good: var(--moss);
+ --watch: var(--ember);
+ --bad: #d0543f;
+ width: min(100% - 2 * var(--gutter), var(--wide));
+ margin: 0 auto;
+ padding: clamp(8rem, 14vw, 11rem) 0 var(--section);
+}
+
+.header {
+ display: grid;
+ gap: 1rem;
+ margin-bottom: clamp(2.5rem, 5vw, 4rem);
+}
+
+.eyebrow {
+ font-family: var(--tiny);
+ font-size: 0.85rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--gold);
+}
+
+.titleRow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem 2rem;
+}
+
+.h1 {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+ font-family: var(--display);
+ font-size: clamp(2.4rem, 5vw, 4.5rem);
+ letter-spacing: -0.02em;
+}
+
+/* a square pixel lamp: steady when fine, slow pulse while waiting */
+.dot {
+ flex: none;
+ width: 0.42em;
+ height: 0.42em;
+ background: var(--good);
+ box-shadow: 0 0 0.5em var(--good);
+}
+
+.dot[data-tone="bad"] {
+ background: var(--bad);
+ box-shadow: 0 0 0.5em var(--bad);
+}
+
+.dot[data-tone="wait"] {
+ background: var(--gold);
+ box-shadow: none;
+ animation: pulse 1.4s steps(2) infinite;
+}
+
+@keyframes pulse {
+ 50% {
+ opacity: 0.3;
+ }
+}
+
+.refresh {
+ min-height: 2.75rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.refresh:disabled {
+ opacity: 0.6;
+ cursor: default;
+ transform: none;
+}
+
+.spin {
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.updated {
+ max-width: 46rem;
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.failed {
+ color: #f0a08f;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
+ gap: 1.25rem;
+}
+
+.panel {
+ display: grid;
+ align-content: start;
+ gap: 0.9rem;
+ padding: 1.5rem;
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(8, 7, 12, 0.72);
+}
+
+.panelHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.panelHead h2 {
+ font-size: 1.15rem;
+ letter-spacing: -0.01em;
+ color: var(--gold);
+}
+
+.chip {
+ flex: none;
+ padding: 0.2rem 0.6rem;
+ border-radius: 999px;
+ border: 1px solid currentColor;
+ font-family: var(--tiny);
+ font-size: 0.75rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: #7fc58f;
+}
+
+.chip[data-tone="watch"] {
+ color: #e7a564;
+}
+
+.chip[data-tone="bad"] {
+ color: #f0a08f;
+}
+
+.value {
+ font-size: clamp(2rem, 3vw, 2.6rem);
+ font-weight: 600;
+ line-height: 1.1;
+ letter-spacing: -0.03em;
+ font-variant-numeric: tabular-nums;
+}
+
+.of {
+ font-size: 1rem;
+ font-weight: 400;
+ letter-spacing: 0;
+ color: var(--muted);
+}
+
+.meter {
+ display: grid;
+ grid-template-columns: repeat(20, 1fr);
+ gap: 3px;
+ height: 14px;
+}
+
+.meter span {
+ background: rgba(245, 222, 160, 0.1);
+}
+
+.meter .on {
+ background: var(--good);
+}
+
+.meter[data-tone="watch"] .on {
+ background: var(--watch);
+}
+
+.meter[data-tone="bad"] .on {
+ background: var(--bad);
+}
+
+.notes {
+ display: grid;
+ gap: 0.5rem;
+ font-size: 0.95rem;
+ color: var(--muted);
+}
+
+.notes strong {
+ color: var(--parchment);
+ font-variant-numeric: tabular-nums;
+}
+
+.warn {
+ color: #f0a08f;
+}
+
+.raw {
+ margin-top: 2.5rem;
+ color: var(--faint);
+ font-size: 0.95rem;
+}
+
+.raw a {
+ color: var(--gold);
+ text-underline-offset: 3px;
+}
+
+/* loading: panels in their real shape with a soft light passing over */
+.shimmer {
+ min-height: 14rem;
+ background:
+ linear-gradient(100deg, transparent 30%, rgba(245, 222, 160, 0.06) 50%, transparent 70%) 0 0 / 300% 100%,
+ rgba(8, 7, 12, 0.72);
+ animation: shimmer 1.6s linear infinite;
+}
+
+@keyframes shimmer {
+ from {
+ background-position: 100% 0, 0 0;
+ }
+ to {
+ background-position: -100% 0, 0 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .dot[data-tone="wait"],
+ .spin,
+ .shimmer {
+ animation: none;
+ }
+}
+
+.dot[data-tone="watch"] {
+ background: var(--watch);
+ box-shadow: 0 0 0.5em var(--watch);
+}
+
+/* admin: full-width panels for the traffic chart and the log */
+.wide {
+ margin-top: 1.25rem;
+}
+
+.peak {
+ color: var(--faint);
+ font-size: 0.9rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.chart {
+ display: grid;
+ grid-template-columns: repeat(60, 1fr);
+ align-items: end;
+ gap: 2px;
+ height: 10rem;
+ padding-bottom: 2px;
+ border-bottom: 1px solid var(--line);
+}
+
+.col {
+ display: flex;
+ flex-direction: column-reverse;
+ height: 100%;
+}
+
+.col span {
+ display: block;
+ min-height: 2px;
+}
+
+.col [data-kind="cache"],
+.legend [data-kind="cache"] {
+ background: var(--good);
+}
+
+.col [data-kind="drawn"],
+.legend [data-kind="drawn"] {
+ background: var(--gold);
+}
+
+.col [data-kind="busy"],
+.legend [data-kind="busy"] {
+ background: var(--watch);
+}
+
+.col [data-kind="error"],
+.legend [data-kind="error"] {
+ background: var(--bad);
+}
+
+.col [data-kind="other"],
+.legend [data-kind="other"] {
+ background: rgba(245, 222, 160, 0.28);
+}
+
+.axis {
+ display: flex;
+ justify-content: space-between;
+ color: var(--faint);
+ font-size: 0.85rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem 1.5rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ color: var(--muted);
+ font-size: 0.95rem;
+}
+
+.legend li {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.legend li span {
+ width: 0.7rem;
+ height: 0.7rem;
+}
+
+.legend strong {
+ color: var(--parchment);
+ font-variant-numeric: tabular-nums;
+}
+
+.tools {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+}
+
+.toggle {
+ min-height: 2.5rem;
+ padding: 0 1rem;
+ border: 1px solid rgba(242, 230, 201, 0.3);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--parchment);
+ font: inherit;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.toggle[aria-pressed="true"] {
+ border-color: var(--gold);
+ background: rgba(245, 222, 160, 0.14);
+ color: var(--gold);
+}
+
+.search input {
+ min-height: 2.5rem;
+ width: min(16rem, 100%);
+ padding: 0 1rem;
+ border: 1px solid rgba(242, 230, 201, 0.3);
+ border-radius: 999px;
+ background: rgba(8, 7, 12, 0.6);
+ color: var(--parchment);
+ font: inherit;
+ font-size: 0.9rem;
+}
+
+.search input::placeholder {
+ color: var(--faint);
+}
+
+.log {
+ max-height: 30rem;
+ margin: 0;
+ padding: 0.75rem 1rem;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: rgba(4, 3, 7, 0.75);
+ list-style: none;
+ font-family: var(--mono);
+ font-size: 0.8rem;
+ line-height: 1.6;
+ white-space: pre;
+ color: var(--muted);
+}
+
+.log li[data-level="warn"] {
+ color: #e7a564;
+}
+
+.log li[data-level="error"] {
+ color: #f0a08f;
+}
+
+@media (max-width: 640px) {
+ .panelHead:has(.tools) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
diff --git a/site/components/Health.tsx b/site/components/Health.tsx
new file mode 100644
index 00000000..bf80f5ba
--- /dev/null
+++ b/site/components/Health.tsx
@@ -0,0 +1,515 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { RefreshCw } from "lucide-react";
+import s from "./Health.module.css";
+
+/** What the public /health answers: no counts or capacities, only states and times. */
+type PublicData = {
+ ok: boolean;
+ now: number;
+ started_at: number;
+ fetching: { paused: boolean; back_in_secs: number | null };
+ new_users: { open: boolean; next_opening_secs: number | null };
+};
+
+/** What /admin/health answers (the keys this page reads). */
+type HealthData = {
+ ok: boolean;
+ version: string;
+ now: number;
+ started_at: number;
+ queue: number;
+ cache: { entries: number; bytes: number; budget: number; hits: number; misses: number; disk_bytes: number | null; disk_budget: number };
+ renders: { busy: number; slots: number };
+ held: number;
+ users: { saved: number | null; in_memory: number; unknown: number };
+ memory: { used: number | null; limit: number | null };
+ traffic: Minute[];
+ recent: string[];
+ scenes: string[];
+ github: { remaining: number | null; limit: number; resets_at: number | null; paused_secs: number };
+ new_users: { this_hour: number; per_hour: number; oldest_frees_in_secs: number | null };
+ waiting: { new: number; new_capacity: number; refresh: number; refresh_capacity: number };
+};
+
+type Minute = { minute: number; cache: number; drawn: number; busy: number; error: number; other: number };
+
+const EVERY = 30; // seconds between automatic checks
+const REST = 10; // seconds the Refresh button rests after a press
+
+const utc = (secs: number) => new Date(secs * 1000).toISOString().slice(11, 16) + " UTC";
+const clockUtc = (d: Date) => d.toISOString().slice(11, 19) + " UTC";
+const mins = (secs: number) => {
+ const m = Math.max(1, Math.ceil(secs / 60));
+ return m === 1 ? "1 minute" : `${m} minutes`;
+};
+const num = (n: number) => n.toLocaleString("en-US");
+const mb = (b: number) =>
+ b >= 1073741824
+ ? `${+(b / 1073741824).toFixed(1)} GB`
+ : b < 1048576 ? `${Math.max(1, Math.round(b / 1024))} KB` : `${(b / 1048576).toFixed(b < 10485760 ? 1 : 0)} MB`;
+
+function since(secs: number) {
+ const d = Math.floor(secs / 86400);
+ const h = Math.floor((secs % 86400) / 3600);
+ const m = Math.floor((secs % 3600) / 60);
+ if (d > 0) return `${d} day${d === 1 ? "" : "s"} ${h} h`;
+ if (h > 0) return `${h} h ${m} min`;
+ return `${Math.max(1, m)} min`;
+}
+
+type Tone = "good" | "watch" | "bad";
+
+/** A pixel meter: 20 blocks, filled by share, coloured by tone. */
+function Meter({ share, tone, label }: { share: number; tone: Tone; label: string }) {
+ const filled = Math.round(Math.min(1, Math.max(0, share)) * 20);
+ return (
+
+ {Array.from({ length: 20 }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+function Panel({ title, tone, value, of, meter, children }: {
+ title: string;
+ tone: Tone;
+ value: string;
+ of?: string;
+ meter?: { share: number; label: string };
+ children: React.ReactNode;
+}) {
+ const word = { good: "Fine", watch: "Busy", bad: "Needs attention" }[tone];
+ return (
+
+
+
+ {value}
+ {of && {of} }
+
+ {meter && }
+ {children}
+
+ );
+}
+
+/** Used share to tone: under 70% fine, under 90% busy, then needs attention. */
+const toneOfUsed = (used: number): Tone => (used < 0.7 ? "good" : used < 0.9 ? "watch" : "bad");
+
+/** `full`: the admin view with every number, from /admin/health (behind a login). */
+export default function Health({ full = false }: { full?: boolean }) {
+ const url = full ? "/admin/health" : "/health";
+ const [data, setData] = useState(null);
+ const [at, setAt] = useState(null);
+ const [failedAt, setFailedAt] = useState(null);
+ const [asking, setAsking] = useState(false);
+ const [rest, setRest] = useState(0);
+ const [, tick] = useState(0);
+ const busy = useRef(false);
+
+ const load = useCallback(async () => {
+ if (busy.current) return;
+ busy.current = true;
+ setAsking(true);
+ try {
+ const r = await fetch(url, { cache: "no-store" });
+ if (!r.ok) throw new Error(String(r.status));
+ setData(await r.json());
+ setAt(new Date());
+ setFailedAt(null);
+ } catch {
+ setFailedAt(new Date()); // the last good numbers stay on screen
+ } finally {
+ busy.current = false;
+ setAsking(false);
+ }
+ }, [url]);
+
+ // checks every EVERY seconds while the tab is visible, and at once when it comes back
+ useEffect(() => {
+ load();
+ const id = setInterval(() => document.visibilityState === "visible" && load(), EVERY * 1000);
+ const back = () => document.visibilityState === "visible" && load();
+ document.addEventListener("visibilitychange", back);
+ return () => {
+ clearInterval(id);
+ document.removeEventListener("visibilitychange", back);
+ };
+ }, [load]);
+
+ // a one second clock for the countdowns and the Refresh rest
+ useEffect(() => {
+ const id = setInterval(() => {
+ tick((n) => n + 1);
+ setRest((r) => Math.max(0, r - 1));
+ }, 1000);
+ return () => clearInterval(id);
+ }, []);
+
+ const refresh = () => {
+ if (rest > 0 || asking) return;
+ setRest(REST);
+ load();
+ };
+
+ // server time now, moved on by the seconds since the last answer, so countdowns run between checks
+ const drift = at ? Math.floor((Date.now() - at.getTime()) / 1000) : 0;
+ const now = data ? data.now + drift : 0;
+
+ const down = !data && failedAt;
+ const calm = data && "fetching" in data ? !data.fetching.paused && data.new_users.open : true;
+ const headline = !data
+ ? down
+ ? "The service is not answering"
+ : "Checking the service"
+ : failedAt
+ ? "The service stopped answering"
+ : !data.ok
+ ? "Something is wrong"
+ : calm
+ ? "All is well"
+ : "Running, but busy";
+
+ return (
+
+
+ Service health
+
+
+
+ {headline}
+
+ 0 || asking}>
+
+ {asking ? "Updating" : rest > 0 ? `Refresh in ${rest} s` : "Refresh"}
+
+
+
+ {at ? `Updated at ${clockUtc(at)}. Checks again every ${EVERY} seconds by itself.` : "Asking the server for its numbers."}
+ {failedAt && (
+
+ {" "}
+ The check at {clockUtc(failedAt)} got no answer{data ? ", so these are the last numbers that came in" : ""}. The page
+ keeps trying.
+
+ )}
+
+
+
+ {!data ? (
+
+ {Array.from({ length: full ? 6 : 3 }, (_, i) => (
+
+ ))}
+
+ ) : "fetching" in data ? (
+
+ ) : (
+
+ )}
+
+
+ The same as JSON{full ? "" : ", for scripts and uptime monitors"}: {url} . Times are in UTC.
+
+
+ );
+}
+
+function PublicPanels({ d, now }: { d: PublicData; now: number }) {
+ const back = d.fetching.back_in_secs;
+ const next = d.new_users.next_opening_secs;
+ return (
+
+
+ Cards are being drawn and served. Cards already on a profile keep showing even while the service restarts.
+
+
+
+ {d.fetching.paused && back ? (
+
+ GitHub asks us to slow down. Reading starts again in {mins(back)} , at {utc(now + back)} .
+ Until then cards show the last stats they had.
+
+ ) : (
+ When a card gets old, its stats are read from GitHub again, so every card stays close to current.
+ )}
+
+
+
+ {!d.new_users.open && next ? (
+
+ Many people are trying questlog right now. A new user sees the busy card and gets in at the next opening, in{" "}
+ {mins(next)} , at {utc(now + next)} .
+
+ ) : (
+ A new user's first card shows a short wait message, then the real card a few seconds later.
+ )}
+
+
+ );
+}
+
+function Panels({ d, now }: { d: HealthData; now: number }) {
+ const g = d.github;
+ const left = g.remaining;
+ const resetIn = g.resets_at ? g.resets_at - now : null;
+ const quotaTone: Tone = left === null ? "good" : g.paused_secs > 0 ? "bad" : toneOfUsed(1 - left / g.limit);
+
+ const nu = d.new_users;
+ const nuTone = toneOfUsed(nu.this_hour / nu.per_hour);
+ const freeIn = nu.oldest_frees_in_secs;
+
+ const w = d.waiting;
+ const lineTone = toneOfUsed(Math.max(w.new / w.new_capacity, w.refresh / w.refresh_capacity));
+
+ const r = d.renders;
+ const renderTone: Tone = r.busy < r.slots ? "good" : "watch";
+
+ const c = d.cache;
+ const asks = c.hits + c.misses;
+ const rate = asks ? Math.round((c.hits * 100) / asks) : 0;
+
+ return (
+ <>
+
+
+ {resetIn !== null && resetIn > 0 ? (
+
+ Refills in {mins(resetIn)} , at {utc(g.resets_at!)} .
+
+ ) : (
+ GitHub reports the quota with each fetch; nothing has been fetched since the last start.
+ )}
+ {g.paused_secs > 0 && (
+
+ Fetching is paused for {mins(g.paused_secs)} to save the quota. New users see a wait message until then.
+
+ )}
+ Each new or refreshed user costs a few points. Cards already drawn cost nothing.
+
+
+
+
+ {num(Math.max(0, nu.per_hour - nu.this_hour))} more first-time users can come in this hour.
+
+ {freeIn !== null && freeIn > 0 && (
+
+ The hour slides: the next place frees in {mins(freeIn)} , at {utc(now + freeIn)} .
+
+ )}
+ Past the cap a new user sees the busy card and gets in a little later. People already on the list are never held back.
+
+
+
+
+ The line for new users holds {num(w.new_capacity)}. Refreshes of known users wait in their own line:{" "}
+ {num(w.refresh)} of {num(w.refresh_capacity)}.
+
+ While a refresh waits, its card keeps showing the last stats.
+
+
+
+
+ A card that is not in the cache takes a slot for a moment. When both are taken, a request waits up to 2 seconds, then
+ gets the busy card for a minute.
+
+
+
+
+
+ {mb(c.bytes)} of {mb(c.budget)} used. Since the last start {rate}% of cards came from the cache (
+ {num(c.hits)} found, {num(c.misses)} drawn).
+
+ Every drawn card is also saved on disk, so it is drawn once per version.
+
+
+
+
+ Version {d.version} , started at {utc(d.started_at)} on{" "}
+ {new Date(d.started_at * 1000).toISOString().slice(0, 10)}, with {d.scenes.length} scenes.
+
+ A start clears the in-memory cache; cards saved on disk come back at once.
+
+
+
0 ? "watch" : "good"}
+ value={d.users.saved === null ? num(d.users.in_memory) : num(d.users.saved)}
+ of={d.users.saved === null ? "users in memory" : "users saved"}
+ >
+
+ {num(d.users.in_memory)} of them asked for since the last start. {num(d.users.unknown)} {" "}
+ {d.users.unknown === 1 ? "name was" : "names were"} not on GitHub in the last hour.
+
+
+ {d.held > 0
+ ? `${num(d.held)} held after failed GitHub fetches; their cards say the road is closed for 10 minutes.`
+ : "No user is held after failed fetches."}
+
+
+
+
+
+ Saved cards on disk: {d.cache.disk_bytes === null ? "none" : mb(d.cache.disk_bytes)} of{" "}
+ {mb(d.cache.disk_budget)}. Past that, the hourly trim removes the least recently used.
+
+
+
+
+
+
+ >
+ );
+}
+
+const memTone = (m: { used: number | null; limit: number | null }): Tone =>
+ m.used && m.limit ? toneOfUsed(m.used / m.limit) : "good";
+
+const KINDS = [
+ { key: "cache", label: "From the cache" },
+ { key: "drawn", label: "Drawn" },
+ { key: "busy", label: "Busy" },
+ { key: "error", label: "Errors" },
+ { key: "other", label: "Pages and files" },
+] as const;
+
+/** Requests per minute for the last hour, stacked by what they were answered with. */
+function Traffic({ minutes }: { minutes: Minute[] }) {
+ const total = (m: Minute) => KINDS.reduce((n, k) => n + m[k.key], 0);
+ const peak = Math.max(1, ...minutes.map(total));
+ const sums = KINDS.map((k) => minutes.reduce((n, m) => n + m[k.key], 0));
+ const all = sums.reduce((a, b) => a + b, 0);
+ const summary = `${num(all)} requests in the last hour: ` + KINDS.map((k, i) => `${num(sums[i])} ${k.label.toLowerCase()}`).join(", ");
+ return (
+
+
+ Traffic, last hour
+ busiest minute: {num(peak === 1 && all === 0 ? 0 : peak)}
+
+
+ {minutes.map((m) => (
+
+ {KINDS.map((k) =>
+ m[k.key] ? : null,
+ )}
+
+ ))}
+
+
+ {minutes[0] ? utc(minutes[0].minute) : ""}
+ now
+
+
+ {KINDS.map((k, i) => (
+
+
+ {k.label} {num(sums[i])}
+
+ ))}
+
+ Requests Cloudflare answers from its own cache never reach the server, so they are not counted here.
+
+ );
+}
+
+/** The latest log lines, newest first, with a filter for warnings and errors and a text search. */
+function Log({ lines }: { lines: string[] }) {
+ const [only, setOnly] = useState(false);
+ const [find, setFind] = useState("");
+ const shown = lines
+ .slice()
+ .reverse()
+ .filter((l) => (!only || / (WARN|ERROR) /.test(l)) && (!find || l.toLowerCase().includes(find.toLowerCase())));
+ return (
+
+
+
+
+ The last {num(lines.length)} lines since the start, newest first. The full log is in Portainer or docker logs questlog.
+
+
+ {shown.length ? (
+
+ {shown.map((l, i) => {
+ const level = / ERROR /.test(l) ? "error" : / WARN /.test(l) ? "warn" : "info";
+ return (
+
+ {l}
+
+ );
+ })}
+
+ ) : (
+ {lines.length ? "No line matches. Clear the search or the filter." : "Nothing logged yet."}
+ )}
+
+ );
+}
diff --git a/src/cache.rs b/src/cache.rs
index bede6f9d..8e2e49ec 100644
--- a/src/cache.rs
+++ b/src/cache.rs
@@ -64,6 +64,11 @@ impl RenderCache {
}
pub fn get(&self, key: u64) -> Option>> {
+ self.lookup(key).map(|(bytes, _)| bytes)
+ }
+
+ /// Like get, and says where the card was found: "memory" or "disk".
+ pub fn lookup(&self, key: u64) -> Option<(Arc>, &'static str)> {
{
let mut l = self.inner.lock().unwrap();
l.tick += 1;
@@ -72,7 +77,7 @@ impl RenderCache {
*used = tick;
let bytes = bytes.clone();
l.hits += 1;
- return Some(bytes);
+ return Some((bytes, "memory"));
}
}
// not in memory: a card saved on disk is read without holding the lock, then kept in memory
@@ -81,7 +86,7 @@ impl RenderCache {
let bytes = Arc::new(bytes);
self.remember(key, bytes.clone());
self.inner.lock().unwrap().disk_hits += 1;
- Some(bytes)
+ Some((bytes, "disk"))
}
None => {
self.inner.lock().unwrap().misses += 1;
@@ -102,6 +107,12 @@ impl RenderCache {
self.remember(key, bytes);
}
+ /// Bytes of saved cards on disk, for the admin page.
+ pub fn disk_bytes(&self) -> Option {
+ let dir = std::fs::read_dir(self.disk.as_ref()?).ok()?;
+ Some(dir.flatten().filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum())
+ }
+
/// Deletes the least recently used saved cards (by modification time) until the disk holds at most `budget`
/// bytes. Returns how many it deleted.
pub fn trim_disk(&self, budget: u64) -> usize {
diff --git a/src/github.rs b/src/github.rs
index 6fdfe1aa..78517074 100644
--- a/src/github.rs
+++ b/src/github.rs
@@ -27,7 +27,7 @@ pub trait Transport: Send + Sync + 'static {
}
const PROFILE: &str = "query($login: String!, $after: String) {
- rateLimit { remaining resetAt }
+ rateLimit { limit remaining resetAt }
user(login: $login) {
login createdAt
followers { totalCount } pullRequests { totalCount } issues { totalCount }
@@ -52,7 +52,7 @@ fn years_query(first: i64, last: i64) -> String {
)
})
.collect();
- format!("query($login: String!) {{ rateLimit {{ remaining resetAt }} user(login: $login) {{\n{parts}}} }}")
+ format!("query($login: String!) {{ rateLimit {{ limit remaining resetAt }} user(login: $login) {{\n{parts}}} }}")
}
pub struct GitHub {
@@ -206,7 +206,11 @@ fn year(c: &Value) -> RawYear {
fn quota(body: &Value) -> Option {
let r = &body["data"]["rateLimit"];
- Some(Quota { remaining: r["remaining"].as_u64()?, reset_at: parse_utc(r["resetAt"].as_str()?)? })
+ Some(Quota {
+ limit: r["limit"].as_u64().unwrap_or(5000),
+ remaining: r["remaining"].as_u64()?,
+ reset_at: parse_utc(r["resetAt"].as_str()?)?,
+ })
}
/// The real transport. The token goes only into the Authorization header and is never logged.
diff --git a/src/lib.rs b/src/lib.rs
index 4bdd8887..b70128eb 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,6 +8,7 @@ pub mod decorations;
pub mod encode;
pub mod fmt;
pub mod github;
+pub mod log;
pub mod queue;
pub mod raster;
pub mod scene;
diff --git a/src/log.rs b/src/log.rs
new file mode 100644
index 00000000..a95c5516
--- /dev/null
+++ b/src/log.rs
@@ -0,0 +1,103 @@
+//! Plain readable log lines on stderr, which Docker keeps:
+//!
+//! ```text
+//! 2026-10-20 14:32:01 INFO http GET /octocat/stats -> 200 card, drawn now | 41 ms | 18.2 KB | TR | GitHub image proxy | ray 8d1f2a3b4c5d-FRA
+//! ```
+//!
+//! Columns: UTC time, level (INFO, WARN, ERROR), area (start, http, queue, github, cache, status), then the event
+//! in words. Client IPs are never written; Cloudflare's ray id finds the same request in its dashboard.
+use crate::dates::civil_from_days;
+use std::collections::VecDeque;
+use std::io::Write;
+use std::sync::Mutex;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// The latest lines, newest last, for the admin page.
+static RECENT: Mutex> = Mutex::new(VecDeque::new());
+const KEEP: usize = 400;
+/// Characters of one message; the rest is cut, so a huge header can't make a huge line.
+const MAX_CHARS: usize = 600;
+
+pub fn line(level: &str, area: &str, msg: std::fmt::Arguments) {
+ let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
+ let line = format!("{} {level:<5} {area:<7} {}", stamp(secs), clean(&msg.to_string()));
+ let _ = writeln!(std::io::stderr(), "{line}"); // a closed log pipe must not take the server down
+ let mut r = RECENT.lock().unwrap();
+ if r.len() >= KEEP {
+ r.pop_front();
+ }
+ r.push_back(line);
+}
+
+/// One line of plain text: control characters (newlines, terminal escapes) and bidi overrides become `?`,
+/// and the text is cut at MAX_CHARS.
+pub fn clean(s: &str) -> String {
+ let bidi = |c: char| matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}');
+ let mut out: String = s.chars().take(MAX_CHARS).map(|c| if c.is_control() || bidi(c) { '?' } else { c }).collect();
+ if s.chars().nth(MAX_CHARS).is_some() {
+ out.push_str(" ...(cut)");
+ }
+ out
+}
+
+pub fn recent() -> Vec {
+ RECENT.lock().unwrap().iter().cloned().collect()
+}
+
+/// `2026-10-20 14:32:01` for a Unix time in seconds (UTC).
+pub fn stamp(secs: u64) -> String {
+ let (y, m, d) = civil_from_days((secs / 86400) as i64);
+ let s = secs % 86400;
+ format!("{y}-{m:02}-{d:02} {:02}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
+}
+
+/// `14:32 UTC` for a Unix time, for "paused until" lines.
+pub fn clock(secs: u64) -> String {
+ let s = secs % 86400;
+ format!("{:02}:{:02} UTC", s / 3600, s / 60 % 60)
+}
+
+/// `18.2 KB`, `1.4 MB`.
+pub fn size(bytes: u64) -> String {
+ match bytes {
+ b if b < 1024 => format!("{b} B"),
+ b if b < 1 << 20 => format!("{:.1} KB", b as f64 / 1024.0),
+ b => format!("{:.1} MB", b as f64 / (1 << 20) as f64),
+ }
+}
+
+#[macro_export]
+macro_rules! info {
+ ($area:expr, $($arg:tt)*) => { $crate::log::line("INFO", $area, format_args!($($arg)*)) };
+}
+
+#[macro_export]
+macro_rules! warn {
+ ($area:expr, $($arg:tt)*) => { $crate::log::line("WARN", $area, format_args!($($arg)*)) };
+}
+
+#[macro_export]
+macro_rules! error {
+ ($area:expr, $($arg:tt)*) => { $crate::log::line("ERROR", $area, format_args!($($arg)*)) };
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn formats() {
+ assert_eq!(stamp(1_792_506_721), "2026-10-20 14:32:01");
+ assert_eq!(clock(1_792_506_721), "14:32 UTC");
+ assert_eq!(size(512), "512 B");
+ assert_eq!(size(18_637), "18.2 KB");
+ assert_eq!(size(1_468_006), "1.4 MB");
+ }
+
+ #[test]
+ fn lines_stay_one_safe_line() {
+ assert_eq!(clean("a\nb\r\u{1b}[31mc\u{202E}d\u{9b}"), "a?b??[31mc?d?");
+ let long = clean(&"x".repeat(5000));
+ assert!(long.ends_with(" ...(cut)") && long.chars().count() == MAX_CHARS + 9);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index f76f86b5..d6cbd524 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,6 +22,7 @@ async fn main() -> Result<(), questlog::BoxError> {
if let Some(n) = std::env::var("NEW_LOGINS_PER_HOUR").ok().and_then(|v| v.parse().ok()) {
cfg.new_per_hour = n;
}
+ let new_per_hour = cfg.new_per_hour;
let queue = Queue::new(github, store.clone(), cfg);
queue.start();
let sweeper = store.clone();
@@ -31,15 +32,17 @@ async fn main() -> Result<(), questlog::BoxError> {
tick.tick().await;
match sweeper.sweep(now_secs()) {
Ok(0) => {}
- Ok(n) => eprintln!("sweep: removed {n} users"),
- Err(e) => eprintln!("sweep: {e}"),
+ Ok(n) => questlog::info!("cache", "hourly sweep removed {n} users nobody asked for in a long time"),
+ Err(e) => questlog::error!("cache", "hourly sweep of saved users failed: {e}"),
}
}
});
let cache = questlog::cache::RenderCache::new(questlog::server::CACHE_BYTES)
.with_disk(std::path::Path::new(&data).join("renders"), env!("RENDER_VERSION"));
- let app = Arc::new(App::with_cache(Arc::new(Assets::embedded()?), store, queue, cache));
+ let access = questlog::server::Access::from_env();
+ let admin = if access.is_some() { "admin pages open to ADMIN_EMAILS through Cloudflare Access" } else { "admin pages local only" };
+ let app = Arc::new(App::with_cache(Arc::new(Assets::embedded()?), store, queue, cache).with_access(access));
let trim = app.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600));
@@ -48,15 +51,30 @@ async fn main() -> Result<(), questlog::BoxError> {
let t = trim.clone();
let n = tokio::task::spawn_blocking(move || t.cache.trim_disk(questlog::server::DISK_BYTES)).await.unwrap_or(0);
if n > 0 {
- eprintln!("renders: trimmed {n} saved cards");
+ questlog::info!("cache", "disk over its budget: removed the {n} least recently used saved cards");
}
}
});
+ let status = app.clone();
+ tokio::spawn(async move {
+ let mut tick = tokio::time::interval(Duration::from_secs(900));
+ tick.tick().await; // the first tick is immediate; the start line already says the state
+ loop {
+ tick.tick().await;
+ questlog::server::log_status(&status);
+ }
+ });
// HOST=127.0.0.1 keeps a local run off the network; the container needs the default.
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into());
let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?;
- eprintln!("questlog listening on {host}:{port}");
- axum::serve(listener, router(app)).with_graceful_shutdown(shutdown()).await?;
+ questlog::info!(
+ "start",
+ "questlog {} (render version {}) listening on {host}:{port} | data in {data} | up to {new_per_hour} new users an hour | {admin}",
+ env!("CARGO_PKG_VERSION"),
+ env!("RENDER_VERSION")
+ );
+ let service = router(app).into_make_service_with_connect_info::();
+ axum::serve(listener, service).with_graceful_shutdown(shutdown()).await?;
Ok(())
}
diff --git a/src/queue.rs b/src/queue.rs
index 2ad602c1..0345aa24 100644
--- a/src/queue.rs
+++ b/src/queue.rs
@@ -13,6 +13,7 @@ use tokio::sync::Notify;
use tokio::time::Instant;
pub struct Quota {
+ pub limit: u64, // points an hour for this token
pub remaining: u64,
pub reset_at: u64, // unix seconds
}
@@ -86,6 +87,7 @@ struct State {
paused_until: Option,
next_sweep: Instant,
quota: Option,
+ quota_limit: u64,
quota_reset: u64, // unix seconds when the quota last seen refills
refresh: HashMap, // the refresh lane: login -> requests while it waited
}
@@ -121,6 +123,7 @@ impl Queue {
paused_until: None,
next_sweep: Instant::now(),
quota: None,
+ quota_limit: 5000,
quota_reset: 0,
refresh: HashMap::new(),
};
@@ -155,7 +158,9 @@ impl Queue {
}
if s.refresh.len() < self.inner.cfg.refresh_capacity {
s.refresh.insert(login.to_string(), 1);
+ let waiting = s.refresh.len();
drop(s);
+ crate::info!("queue", "{login} has stale stats: refresh queued ({waiting} refreshes waiting)");
self.inner.notify.notify_one();
}
}
@@ -170,12 +175,41 @@ impl Queue {
self.inner.state.lock().unwrap().quota
}
+ /// The queue's side of /health: GitHub's quota and when it refills, new logins this hour against the cap,
+ /// and how full each lane is.
+ pub fn health(&self) -> serde_json::Value {
+ let now = Instant::now();
+ let cfg = &self.inner.cfg;
+ let mut s = self.inner.state.lock().unwrap();
+ while s.new_log.front().is_some_and(|&t| now.duration_since(t) >= HOUR) {
+ s.new_log.pop_front();
+ }
+ let next_new = s.new_log.front().map(|&t| (HOUR - now.duration_since(t)).as_secs());
+ let paused_secs = s.paused_until.map_or(0, |p| p.saturating_duration_since(now).as_secs());
+ serde_json::json!({
+ "github": {
+ "remaining": s.quota,
+ "limit": s.quota_limit,
+ "resets_at": (s.quota_reset > 0).then_some(s.quota_reset),
+ "paused_secs": paused_secs,
+ },
+ "new_users": {
+ "this_hour": s.new_log.len(),
+ "per_hour": cfg.new_per_hour,
+ "oldest_frees_in_secs": next_new,
+ },
+ "held": s.failed.values().filter(|&&until| until > now).count(),
+ "waiting": {"new": s.pending.len(), "new_capacity": cfg.capacity, "refresh": s.refresh.len(), "refresh_capacity": cfg.refresh_capacity},
+ })
+ }
+
pub fn enqueue(&self, login: &str, ip: Option<&str>) -> Enqueued {
let now = Instant::now();
let cfg = &self.inner.cfg;
let mut s = self.inner.state.lock().unwrap();
if let Some(&until) = s.failed.get(login) {
if now < until {
+ crate::info!("queue", "{login} not queued: its last GitHub fetches failed, held for {} s more", (until - now).as_secs());
return Enqueued::Failed;
}
s.failed.remove(login);
@@ -194,12 +228,18 @@ impl Queue {
return Enqueued::Waiting { eta_secs: eta(&s, cfg, pos, now), paused: paused(&s, now) };
}
if s.pending.len() >= cfg.capacity {
+ crate::warn!("queue", "{login} turned away with the busy card: the queue is full ({} new users waiting)", cfg.capacity);
return Enqueued::Full;
}
while s.new_log.front().is_some_and(|&t| now.duration_since(t) >= HOUR) {
s.new_log.pop_front();
}
if s.new_log.len() >= cfg.new_per_hour {
+ crate::warn!(
+ "queue",
+ "{login} turned away with the busy card: {} new users were already queued this hour (NEW_LOGINS_PER_HOUR)",
+ cfg.new_per_hour
+ );
return Enqueued::Full;
}
if let Some(ip) = ip {
@@ -208,6 +248,7 @@ impl Queue {
log.pop_front();
}
if log.len() >= cfg.per_ip_per_hour {
+ crate::warn!("queue", "{login} turned away with the busy card: this visitor asked for {} new users this hour", cfg.per_ip_per_hour);
return Enqueued::IpLimited;
}
log.push_back(now);
@@ -217,7 +258,13 @@ impl Queue {
s.active.insert(login.to_string());
let wait = eta(&s, cfg, s.pending.len() - 1, now);
let is_paused = paused(&s, now);
+ let ahead = s.pending.len() - 1;
drop(s);
+ crate::info!(
+ "queue",
+ "{login} is new: queued for its first GitHub fetch, {ahead} ahead, ready in about {wait} s{}",
+ if is_paused { " (fetching is paused for GitHub's quota)" } else { "" }
+ );
self.inner.notify.notify_one();
Enqueued::Waiting { eta_secs: wait, paused: is_paused }
}
@@ -284,11 +331,12 @@ impl Queue {
Ok(mut f) => {
f.stats.login = login.to_string(); // keyed by the requested login, or a renamed account refetches forever
if let Err(e) = self.inner.store.put(f.stats.clone()) {
- eprintln!("store {login}: {e}");
+ crate::error!("github", "fetched {login} but saving the stats failed: {e}");
}
Ok(f)
}
Err(FetchError::NotFound) => {
+ crate::info!("github", "{login} is not a GitHub user: remembered as unknown, its cards say so");
self.inner.store.forget(login);
self.inner.store.mark_unknown(login, now_secs());
Err(FetchError::NotFound)
@@ -299,10 +347,21 @@ impl Queue {
match result {
Ok(f) => {
s.avg_secs = 0.8 * s.avg_secs + 0.2 * took.as_secs_f64();
+ let quota = f.quota.as_ref().map_or(String::new(), |q| {
+ format!(" | quota {} left, refills at {}", q.remaining, crate::log::clock(q.reset_at))
+ });
+ crate::info!("github", "fetched {login} in {:.1} s{quota}", took.as_secs_f64());
if let Some(q) = f.quota {
s.quota = Some(q.remaining);
+ s.quota_limit = q.limit;
s.quota_reset = q.reset_at;
if q.remaining < cfg.min_quota {
+ crate::warn!(
+ "github",
+ "quota below {}: fetching paused until it refills at {}, new users see a wait message",
+ cfg.min_quota,
+ crate::log::clock(q.reset_at)
+ );
pause(&mut s, now + Duration::from_secs(q.reset_at.saturating_sub(now_secs())));
}
}
@@ -314,24 +373,35 @@ impl Queue {
s.attempts.remove(login);
}
Err(FetchError::RateLimited { reset_at }) => {
+ crate::warn!(
+ "github",
+ "GitHub rate limited the fetch of {login}: fetching paused until {}, {login} goes back to the front",
+ crate::log::clock(reset_at)
+ );
pause(&mut s, now + Duration::from_secs(reset_at.saturating_sub(now_secs()).max(1)));
s.pending.push_front(login.to_string());
drop(s);
self.inner.notify.notify_one();
}
Err(FetchError::Transient(e)) => {
- eprintln!("fetch {login}: {e}");
let tries = {
let a = s.attempts.entry(login.to_string()).or_default();
*a += 1;
*a
};
if tries > cfg.backoff.len() {
+ crate::error!(
+ "github",
+ "fetching {login} failed {tries} times, giving up for {} min (its cards say the road is closed): {e}",
+ cfg.fail_hold.as_secs() / 60
+ );
s.active.remove(login);
s.attempts.remove(login);
s.failed.insert(login.to_string(), now + cfg.fail_hold);
} else {
- let (q, login, wait) = (self.clone(), login.to_string(), cfg.backoff[tries - 1]);
+ let wait = cfg.backoff[tries - 1];
+ crate::warn!("github", "fetching {login} failed (try {tries}), trying again in {} s: {e}", wait.as_secs());
+ let (q, login) = (self.clone(), login.to_string());
tokio::spawn(async move {
tokio::time::sleep(wait).await;
q.inner.state.lock().unwrap().pending.push_back(login);
@@ -448,7 +518,7 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn refreshes_keep_a_quota_reserve_for_new_logins() {
- let low = Some(Quota { remaining: 1000, reset_at: now_secs() + 3600 });
+ let low = Some(Quota { limit: 5000, remaining: 1000, reset_at: now_secs() + 3600 });
let (q, mock, _) = setup("q-reserve", QueueConfig::default(), vec![("a", ok("a", low)), ("b", ok("b", None))]);
q.enqueue("a", Some("ip"));
q.start();
@@ -540,7 +610,7 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn low_quota_pauses_then_resumes() {
- let low = Quota { remaining: 10, reset_at: now_secs() + 100 };
+ let low = Quota { limit: 5000, remaining: 10, reset_at: now_secs() + 100 };
let (q, mock, _) = setup("q-quota", QueueConfig::default(), vec![("a", ok("a", Some(low))), ("b", ok("b", None))]);
q.start();
q.enqueue("a", None);
@@ -557,7 +627,7 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn waiting_says_whether_the_queue_is_paused() {
- let low = Quota { remaining: 10, reset_at: now_secs() + 100 };
+ let low = Quota { limit: 5000, remaining: 10, reset_at: now_secs() + 100 };
let (q, _, _) = setup("q-paused-flag", QueueConfig::default(), vec![("a", ok("a", Some(low))), ("b", ok("b", None))]);
q.start();
let Enqueued::Waiting { paused, .. } = q.enqueue("a", None) else { panic!("a should queue") };
@@ -595,8 +665,8 @@ mod tests {
#[tokio::test(start_paused = true)]
async fn a_pause_is_never_shortened() {
- let long = Quota { remaining: 1, reset_at: now_secs() + 500 };
- let short = Quota { remaining: 1, reset_at: now_secs() + 50 };
+ let long = Quota { limit: 5000, remaining: 1, reset_at: now_secs() + 500 };
+ let short = Quota { limit: 5000, remaining: 1, reset_at: now_secs() + 50 };
let (q, mock, _) = setup("q-pause", QueueConfig::default(), vec![
("a", ok("a", Some(long))), ("b", ok("b", Some(short))), ("c", ok("c", None)),
]);
diff --git a/src/server/admin.rs b/src/server/admin.rs
new file mode 100644
index 00000000..220b82b6
--- /dev/null
+++ b/src/server/admin.rs
@@ -0,0 +1,304 @@
+//! The admin pages, /admin/status and /admin/health, for the service's owner. Two locks: Cloudflare Access asks
+//! for the login at the edge, and every request here must also carry the token Access signs
+//! (`Cf-Access-Jwt-Assertion`), checked against Access's public keys, this app's audience tag and the allowed
+//! emails. Without the ACCESS_* settings only a local run can open them, never a request through Cloudflare.
+use super::*;
+use crate::BoxError;
+use axum::extract::ConnectInfo;
+use jsonwebtoken::jwk::JwkSet;
+use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
+use std::collections::VecDeque;
+use std::net::SocketAddr;
+use std::sync::Mutex;
+use std::time::Instant;
+use tokio::sync::RwLock;
+
+/// Never kept by a browser or an edge: it is behind a login.
+const ADMIN: &str = "private, no-store";
+/// Access's signing keys are kept this long, and fetched again at most this often for an unknown key id.
+const KEYS_FOR: Duration = Duration::from_secs(3600);
+const REFETCH_AFTER: Duration = Duration::from_secs(60);
+
+pub struct Access {
+ certs_url: String,
+ validation: Validation,
+ emails: Vec,
+ keys: RwLock,
+ http: reqwest::Client,
+}
+
+#[derive(Default)]
+struct Keys {
+ by_kid: HashMap,
+ fetched: Option,
+}
+
+#[derive(serde::Deserialize)]
+struct Claims {
+ email: Option,
+}
+
+impl Access {
+ /// From ACCESS_TEAM_DOMAIN (like `team.cloudflareaccess.com`), ACCESS_AUD (the Access app's audience tag) and
+ /// ADMIN_EMAILS (comma separated). None when any is missing: then nothing through Cloudflare gets in.
+ pub fn from_env() -> Option {
+ let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
+ let (team, aud) = (var("ACCESS_TEAM_DOMAIN")?, var("ACCESS_AUD")?);
+ let emails: Vec = var("ADMIN_EMAILS")?.split(',').map(|e| e.trim().to_ascii_lowercase()).filter(|e| !e.is_empty()).collect();
+ (!emails.is_empty()).then(|| Access::new(&team, &aud, emails, Algorithm::RS256))
+ }
+
+ fn new(team: &str, aud: &str, emails: Vec, alg: Algorithm) -> Access {
+ let mut validation = Validation::new(alg);
+ validation.set_audience(&[aud]);
+ validation.set_issuer(&[format!("https://{team}")]);
+ validation.set_required_spec_claims(&["exp", "iss", "aud"]);
+ Access {
+ certs_url: format!("https://{team}/cdn-cgi/access/certs"),
+ validation,
+ emails,
+ keys: RwLock::default(),
+ http: reqwest::Client::builder().timeout(Duration::from_secs(10)).build().unwrap_or_default(),
+ }
+ }
+
+ /// The signed-in email, when the token is Access's, for this app, unexpired, and the email is allowed.
+ pub async fn check(&self, token: &str) -> Result {
+ let header = decode_header(token).map_err(|_| "unreadable login token".to_string())?;
+ if !self.validation.algorithms.contains(&header.alg) {
+ return Err(format!("login token uses {:?}, not Access's algorithm", header.alg));
+ }
+ let kid = header.kid.ok_or("login token has no key id")?;
+ let short: String = kid.chars().take(64).collect();
+ let key = self.key(&kid).await.ok_or_else(|| format!("login token signed with an unknown key ({short:?})"))?;
+ let data = decode::(token, &key, &self.validation).map_err(|e| format!("login token refused ({e})"))?;
+ let email = data.claims.email.unwrap_or_default().to_ascii_lowercase();
+ if self.emails.contains(&email) { Ok(email) } else { Err(format!("{email} is signed in but not an admin")) }
+ }
+
+ async fn key(&self, kid: &str) -> Option {
+ {
+ let k = self.keys.read().await;
+ let age = k.fetched.map(|t| t.elapsed());
+ if let Some(key) = k.by_kid.get(kid).filter(|_| age.is_some_and(|a| a < KEYS_FOR)) {
+ return Some(key.clone());
+ }
+ if age.is_some_and(|a| a < REFETCH_AFTER) {
+ return None;
+ }
+ }
+ let mut k = self.keys.write().await;
+ // another request may have fetched while this one waited for the lock
+ if k.fetched.is_some_and(|t| t.elapsed() < REFETCH_AFTER) {
+ return k.by_kid.get(kid).cloned();
+ }
+ match self.fetch().await {
+ Ok(by_kid) => {
+ crate::info!("admin", "loaded {} Cloudflare Access signing keys", by_kid.len());
+ k.by_kid = by_kid;
+ }
+ Err(e) => crate::error!("admin", "could not load Cloudflare Access signing keys from {}: {e}", self.certs_url),
+ }
+ k.fetched = Some(Instant::now());
+ k.by_kid.get(kid).cloned()
+ }
+
+ async fn fetch(&self) -> Result, BoxError> {
+ let set: JwkSet = self.http.get(&self.certs_url).send().await?.error_for_status()?.json().await?;
+ Ok(set
+ .keys
+ .iter()
+ .filter_map(|jwk| Some((jwk.common.key_id.clone()?, DecodingKey::try_from(jwk).ok()?)))
+ .collect())
+ }
+}
+
+/// Whether this request may open the admin pages. A local run (a loopback client, no Cloudflare) may; anything
+/// else needs a valid Access login. Every refusal is logged with its reason.
+async fn allowed(app: &App, req: axum::extract::Request) -> bool {
+ let loopback = req.extensions().get::>().is_some_and(|c| c.0.ip().is_loopback());
+ let (uri, h) = (req.uri().to_string().chars().take(300).collect::(), req.headers().clone());
+ drop(req); // a request is not Sync, so it can't be held across the key fetch below
+ if loopback && h.get("cf-ray").is_none() {
+ return true;
+ }
+ let Some(access) = &app.access else {
+ crate::warn!("admin", "refused {uri}: the admin login is not set up (ACCESS_TEAM_DOMAIN, ACCESS_AUD, ADMIN_EMAILS)");
+ return false;
+ };
+ let cookie = h
+ .get_all(header::COOKIE)
+ .iter()
+ .filter_map(|v| v.to_str().ok())
+ .flat_map(|v| v.split(';'))
+ .find_map(|c| c.trim().strip_prefix("CF_Authorization="));
+ let Some(token) = h.get("cf-access-jwt-assertion").and_then(|v| v.to_str().ok()).or(cookie) else {
+ crate::warn!("admin", "refused {uri}: no Cloudflare Access login came with the request");
+ return false;
+ };
+ match access.check(token).await {
+ Ok(_) => true,
+ Err(why) => {
+ crate::warn!("admin", "refused {uri}: {why}");
+ false
+ }
+ }
+}
+
+pub(super) async fn admin_status(State(app): State>>, req: axum::extract::Request) -> Response {
+ if !allowed(&app, req).await {
+ return private((StatusCode::NOT_FOUND, "not found").into_response());
+ }
+ private(static_file("admin/status.html", ADMIN))
+}
+
+pub(super) async fn admin_health(State(app): State>>, req: axum::extract::Request) -> Response {
+ if !allowed(&app, req).await {
+ return private((StatusCode::NOT_FOUND, "not found").into_response());
+ }
+ let a = app.clone();
+ let disk = tokio::task::spawn_blocking(move || (a.cache.disk_bytes(), a.store.saved_count())).await.unwrap_or((None, None));
+ private(full_health(&app, disk).into_response())
+}
+
+fn private(mut r: Response) -> Response {
+ r.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static(ADMIN));
+ r
+}
+
+/// Everything, for /admin/status: counts, capacities, quota, cache, renderer, users, memory, the last hour's
+/// traffic by minute and the latest log lines.
+fn full_health(app: &App, (disk_bytes, saved): (Option, Option)) -> Json {
+ let now = now_secs();
+ let (entries, bytes, hits, misses) = app.cache.stats();
+ let (in_memory, unknown) = app.store.counts(now);
+ let mut v = serde_json::json!({
+ "ok": true,
+ "version": env!("CARGO_PKG_VERSION"),
+ "now": now,
+ "started_at": app.started_at,
+ "queue": app.queue.len(),
+ "refresh": app.queue.refresh_len(),
+ "cache": {"entries": entries, "bytes": bytes, "budget": CACHE_BYTES, "hits": hits, "misses": misses,
+ "disk_bytes": disk_bytes, "disk_budget": DISK_BYTES},
+ "renders": {"busy": RENDER_SLOTS - app.renders.available_permits(), "slots": RENDER_SLOTS},
+ "users": {"saved": saved, "in_memory": in_memory, "unknown": unknown},
+ "memory": memory(),
+ "quota": app.queue.quota(),
+ "scenes": app.assets.scene_names(),
+ "traffic": traffic(now),
+ "recent": crate::log::recent(),
+ });
+ if let (Some(v), serde_json::Value::Object(q)) = (v.as_object_mut(), app.queue.health()) {
+ v.extend(q);
+ }
+ Json(v)
+}
+
+/// The container's memory use and limit from its cgroup, or the process's resident size where there is none.
+fn memory() -> serde_json::Value {
+ let read = |p: &str| std::fs::read_to_string(p).ok().and_then(|s| s.trim().parse::().ok());
+ if let Some(used) = read("/sys/fs/cgroup/memory.current") {
+ return serde_json::json!({"used": used, "limit": read("/sys/fs/cgroup/memory.max")});
+ }
+ let rss = std::fs::read_to_string("/proc/self/status").ok().and_then(|s| {
+ let line = s.lines().find(|l| l.starts_with("VmRSS:"))?.to_string();
+ line.split_whitespace().nth(1)?.parse::().ok().map(|kb| kb * 1024)
+ });
+ serde_json::json!({"used": rss, "limit": null})
+}
+
+/// What a request was answered with, for the per-minute traffic counts.
+#[derive(Clone, Copy)]
+pub(super) enum Outcome {
+ Cache,
+ Drawn,
+ Busy,
+ Error,
+ Other,
+}
+
+/// Requests per minute for the last hour: (minute, [cache, drawn, busy, error, other]).
+static TRAFFIC: Mutex> = Mutex::new(VecDeque::new());
+
+pub(super) fn count(o: Outcome) {
+ let minute = now_secs() / 60;
+ let mut t = TRAFFIC.lock().unwrap();
+ if t.back().is_none_or(|(m, _)| *m != minute) {
+ t.push_back((minute, [0; 5]));
+ }
+ while t.front().is_some_and(|(m, _)| *m + 60 <= minute) {
+ t.pop_front();
+ }
+ if let Some((_, n)) = t.back_mut() {
+ n[o as usize] += 1;
+ }
+}
+
+fn traffic(now: u64) -> Vec {
+ let t = TRAFFIC.lock().unwrap();
+ let last = now / 60;
+ (last.saturating_sub(59)..=last)
+ .map(|m| {
+ let n = t.iter().find(|(x, _)| *x == m).map_or([0; 5], |(_, n)| *n);
+ serde_json::json!({"minute": m * 60, "cache": n[0], "drawn": n[1], "busy": n[2], "error": n[3], "other": n[4]})
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use jsonwebtoken::{EncodingKey, Header, encode};
+
+ const TEAM: &str = "team.cloudflareaccess.com";
+
+ /// An Access that knows one HS256 key "k1", so tokens can be made in the test; Cloudflare's are RS256.
+ async fn access() -> Access {
+ let a = Access::new(TEAM, "aud1", vec!["owner@example.com".into()], Algorithm::HS256);
+ let mut k = a.keys.write().await;
+ k.by_kid.insert("k1".into(), DecodingKey::from_secret(b"secret"));
+ k.fetched = Some(Instant::now());
+ drop(k);
+ a
+ }
+
+ fn token(kid: &str, aud: &str, email: &str, exp_in: i64) -> String {
+ let mut h = Header::new(Algorithm::HS256);
+ h.kid = Some(kid.into());
+ let exp = now_secs() as i64 + exp_in;
+ let claims = serde_json::json!({"aud": [aud], "iss": format!("https://{TEAM}"), "email": email, "exp": exp});
+ encode(&h, &claims, &EncodingKey::from_secret(b"secret")).unwrap()
+ }
+
+ #[tokio::test]
+ async fn only_the_owner_with_a_valid_login_gets_in() {
+ let a = access().await;
+ assert_eq!(a.check(&token("k1", "aud1", "Owner@Example.com", 600)).await, Ok("owner@example.com".into()));
+ for (why, t) in [
+ ("another person", token("k1", "aud1", "someone@example.com", 600)),
+ ("another Access app", token("k1", "aud2", "owner@example.com", 600)),
+ ("expired", token("k1", "aud1", "owner@example.com", -600)),
+ ("unknown key, no refetch within a minute", token("k9", "aud1", "owner@example.com", 600)),
+ ("another algorithm", {
+ let mut h = Header::new(Algorithm::HS384);
+ h.kid = Some("k1".into());
+ let claims = serde_json::json!({"aud": ["aud1"], "iss": format!("https://{TEAM}"), "email": "owner@example.com", "exp": now_secs() + 600});
+ encode(&h, &claims, &EncodingKey::from_secret(b"secret")).unwrap()
+ }),
+ ("not a token", "abc.def.ghi".to_string()),
+ ] {
+ assert!(a.check(&t).await.is_err(), "{why}");
+ }
+ }
+
+ #[test]
+ fn traffic_counts_by_minute() {
+ count(Outcome::Drawn);
+ count(Outcome::Cache);
+ let t = traffic(now_secs());
+ assert_eq!(t.len(), 60);
+ let last = &t[59];
+ assert!(last["drawn"].as_u64().unwrap() >= 1 && last["cache"].as_u64().unwrap() >= 1, "{last}");
+ }
+}
diff --git a/src/server/cards.rs b/src/server/cards.rs
index cd6dcdb2..546d3c54 100644
--- a/src/server/cards.rs
+++ b/src/server/cards.rs
@@ -158,7 +158,16 @@ pub(super) fn failed() -> Response {
img.px.fill([0x1C, 0x12, 0x0A, 0xFF]);
encode(&[img], None, 5).unwrap_or_default()
});
- image(bytes.clone(), NO_CACHE, "error")
+ served(image(bytes.clone(), NO_CACHE, "error"), "drawing failed, plain fallback panel".into())
+}
+
+/// How a card response was made, read by the request log.
+#[derive(Clone)]
+pub(crate) struct Served(pub String);
+
+pub(super) fn served(mut r: Response, how: String) -> Response {
+ r.extensions_mut().insert(Served(how));
+ r
}
/// A look as a cache key part: its maps sorted, so equal looks give equal keys.
@@ -196,28 +205,37 @@ pub(super) async fn render_image(
kind: &'static str,
job: impl FnOnce() -> Option> + Send + 'static,
) -> Response {
- if let Some(bytes) = app.cache.get(key) {
- return image((*bytes).clone(), cache, kind);
+ if let Some((bytes, place)) = app.cache.lookup(key) {
+ let how = if place == "memory" { "from the memory cache" } else { "from the disk cache (now kept in memory too)" };
+ return served(image((*bytes).clone(), cache, kind), how.into());
}
+ let started = std::time::Instant::now();
if ip.is_some_and(|ip| !app.ip_renders.take(ip)) {
- return shed(app, w, NO_CACHE);
+ return served(shed(app, w, NO_CACHE), "busy, this visitor used its renders for the minute".into());
}
let permit = match tokio::time::timeout(SHED_AFTER, app.renders.clone().acquire_owned()).await {
Ok(Ok(permit)) => permit,
Ok(Err(_)) => return failed(),
- Err(_) => return shed(app, w, SHED),
+ Err(_) => return served(shed(app, w, SHED), "busy, both render slots stayed full for 2 s".into()),
};
+ let waited = started.elapsed().as_millis();
let out = tokio::task::spawn_blocking(move || {
let _permit = permit;
- job()
+ let t = std::time::Instant::now();
+ job().map(|b| (b, t.elapsed().as_millis()))
});
match out.await.ok().flatten() {
- Some(bytes) => {
+ Some((bytes, drew)) => {
let bytes = Arc::new(bytes);
app.cache.put(key, bytes.clone());
- image((*bytes).clone(), cache, kind)
+ let wait = if waited > 0 { format!(" after {waited} ms waiting for a render slot") } else { String::new() };
+ let how = format!("not in cache, drawn in {drew} ms{wait}, saved to memory and disk");
+ served(image((*bytes).clone(), cache, kind), how)
+ }
+ None => {
+ crate::error!("cache", "drawing a {} card failed, answered the plain fallback panel", w.key());
+ failed()
}
- None => failed(),
}
}
diff --git a/src/server/mod.rs b/src/server/mod.rs
index dec86402..990e83bd 100644
--- a/src/server/mod.rs
+++ b/src/server/mod.rs
@@ -1,8 +1,12 @@
//! HTTP: the card routes, the preview page and /health. Every card response is a 200 image, because a non-200
//! shows as a broken image on GitHub. The X-Questlog header says which kind it is.
+mod admin;
mod cards;
mod pages;
+pub use admin::Access;
+use admin::{Outcome, admin_health, admin_status};
+
use cards::*;
use pages::*;
use crate::cache::{self, IpBudget, RenderCache};
@@ -61,9 +65,15 @@ pub struct App {
pub renders: Arc,
pub cache: RenderCache,
pub ip_renders: IpBudget,
+ pub started_at: u64,
+ /// The admin login check; None lets only local runs open the admin pages.
+ pub access: Option,
shed: HashMap>,
}
+/// Cards drawn at the same time; the rest wait up to SHED_AFTER.
+const RENDER_SLOTS: usize = 2;
+
impl App {
/// Two render slots; the busy cards for overload are drawn once here, so shedding costs no render.
pub fn new(assets: Arc, store: Arc, queue: Queue) -> App {
@@ -83,12 +93,19 @@ impl App {
assets,
store,
queue,
- renders: Arc::new(Semaphore::new(2)),
+ renders: Arc::new(Semaphore::new(RENDER_SLOTS)),
cache,
ip_renders: IpBudget::new(RENDERS_PER_IP_MINUTE),
+ started_at: now_secs(),
+ access: None,
shed,
}
}
+
+ pub fn with_access(mut self, access: Option) -> App {
+ self.access = access;
+ self
+ }
}
pub fn router(app: Arc>) -> Router {
@@ -96,7 +113,16 @@ pub fn router(app: Arc>) -> Router {
.route("/", get(|| async { static_file("index.html", PAGE) }))
.route("/wiki", get(|| async { static_file("wiki.html", PAGE) }))
.route("/builder", get(|| async { static_file("builder.html", PAGE) }))
- .route("/static/{*path}", get(|Path(p): Path| async move { static_file(&p, ASSET) }))
+ .route("/status", get(|| async { static_file("status.html", PAGE) }))
+ .route("/admin/status", get(admin_status::))
+ .route("/admin/health", get(admin_health::))
+ // the admin page's shell is only served through its guarded address
+ .route(
+ "/static/{*path}",
+ get(|Path(p): Path| async move {
+ if p.starts_with("admin/") { (StatusCode::NOT_FOUND, "not found").into_response() } else { static_file(&p, ASSET) }
+ }),
+ )
.route("/art/{scene}/{file}", get(art::))
.route("/demo/message", get(demo_message::))
.route("/decorations/{name}", get(decoration::))
@@ -108,16 +134,122 @@ pub fn router(app: Arc>) -> Router {
.route("/{user}/{widget}/{scene}", get(card_at::))
.route("/{user}/{widget}/{scene}/{time}", get(card_at::))
.with_state(app)
+ .layer(axum::middleware::from_fn(log_request))
}
-async fn health(State(app): State>>) -> Json {
+/// One line per request: what was asked, what was answered and how, how long it took, and who asked (country,
+/// kind of client, Cloudflare ray id). Busy and failed answers are WARN, server errors ERROR.
+async fn log_request(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
+ let start = std::time::Instant::now();
+ let (method, uri) = (req.method().clone(), req.uri().to_string());
+ let h = req.headers();
+ let hv = |k: &str| h.get(k).and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
+ let (country, ray, client) = (hv("cf-ipcountry"), hv("cf-ray"), client_kind(&hv("user-agent")));
+ let res = next.run(req).await;
+ let ms = start.elapsed().as_millis();
+ let status = res.status();
+ let kind = res.headers().get("x-questlog").and_then(|v| v.to_str().ok()).unwrap_or("");
+ let answer = match (kind, res.extensions().get::()) {
+ ("", _) if status == StatusCode::NOT_FOUND => " no such address".to_string(),
+ ("", _) if uri.starts_with("/health") || uri.starts_with("/admin/health") => " health report".to_string(),
+ ("", _) if status.is_success() => " page or file built into the server".to_string(),
+ ("", _) => String::new(),
+ (k, Some(Served(how))) => format!(" {}, {how}", describe(k)),
+ (k, None) => format!(" {}", describe(k)),
+ };
+ let bytes = axum::body::HttpBody::size_hint(res.body()).exact().map(|b| format!(" | {}", crate::log::size(b)));
+ let mut who = vec![client];
+ if !country.is_empty() {
+ who.push(country);
+ }
+ if !ray.is_empty() {
+ who.push(format!("ray {ray}"));
+ }
+ let uri: String = uri.chars().take(300).collect();
+ let line = format!("{method} {uri} -> {}{answer} | {ms} ms{} | {}", status.as_u16(), bytes.unwrap_or_default(), who.join(" | "));
+ admin::count(if status.is_server_error() || answer.contains("drawing failed") || kind == "failed" {
+ Outcome::Error
+ } else if kind == "busy" {
+ Outcome::Busy
+ } else if answer.contains("drawn in") {
+ Outcome::Drawn
+ } else if answer.contains(" cache") {
+ Outcome::Cache
+ } else {
+ Outcome::Other
+ });
+ if status.is_server_error() || answer.contains("drawing failed") {
+ crate::error!("http", "{line}");
+ } else if matches!(kind, "busy" | "failed") {
+ crate::warn!("http", "{line}");
+ } else {
+ crate::info!("http", "{line}");
+ }
+ res
+}
+
+/// What the X-Questlog kind means, in words.
+fn describe(kind: &str) -> &str {
+ match kind {
+ "card" => "card",
+ "placeholder" => "placeholder (user waits for the first GitHub fetch)",
+ "unknown" => "\"no adventurer by that name\" (not a GitHub user)",
+ "busy" => "busy card",
+ "failed" => "\"road is closed\" (GitHub fetches for this user keep failing)",
+ "error" => "error card (bad options in the address)",
+ k => k,
+ }
+}
+
+/// Who asked, from the user agent: GitHub's image proxy, a browser, a known tool or bot, or the start of it.
+fn client_kind(ua: &str) -> String {
+ let low = ua.to_ascii_lowercase();
+ if ua.is_empty() {
+ "no user agent".into()
+ } else if ua.starts_with("github-camo") {
+ "GitHub image proxy".into()
+ } else if let Some(bot) = ua.split([' ', ';', '(', ')']).find(|w| { let w = w.to_ascii_lowercase(); w.contains("bot") || w.contains("spider") || w.contains("crawl") }) {
+ bot.trim_end_matches(',').chars().take(40).collect()
+ } else if low.starts_with("curl") || low.starts_with("wget") || low.starts_with("python") || low.starts_with("go-http") {
+ ua.split(' ').next().unwrap_or(ua).chars().take(40).collect()
+ } else if low.contains("mozilla") {
+ let os = ["Android", "iPhone", "iPad", "Windows", "Mac OS", "Linux"].into_iter().find(|o| ua.contains(o));
+ format!("browser{}", os.map(|o| format!(" on {o}")).unwrap_or_default())
+ } else {
+ ua.chars().take(40).collect()
+ }
+}
+
+/// A status line every 15 minutes, so the log shows the service's state without opening /health.
+pub fn log_status(app: &App) {
let (entries, bytes, hits, misses) = app.cache.stats();
+ let rate = if hits + misses > 0 { hits * 100 / (hits + misses) } else { 0 };
+ let quota = app.queue.quota().map_or("not known yet (no fetch since start)".into(), |q| format!("{q} points left this hour"));
+ crate::info!(
+ "status",
+ "{} new users waiting, {} refreshes waiting | cache {entries} cards, {}, {rate}% of asks found ({hits} found, {misses} drawn) | GitHub quota {quota}",
+ app.queue.len(),
+ app.queue.refresh_len(),
+ crate::log::size(bytes as u64)
+ );
+}
+
+/// The public health: up or not, whether GitHub fetching runs, and whether new users get in now or when they
+/// will. No capacities or counts, so it tells nobody how much load it takes to fill the service.
+async fn health(State(app): State>>) -> Json {
+ let q = app.queue.health();
+ let paused = q["github"]["paused_secs"].as_u64().unwrap_or(0);
+ let (n, w) = (&q["new_users"], &q["waiting"]);
+ let capped = n["this_hour"].as_u64() >= n["per_hour"].as_u64();
+ let open = !capped && w["new"].as_u64() < w["new_capacity"].as_u64();
+ // whole minutes: enough for people, and no second-exact timing for anyone flooding new logins
+ let minutes = |s: Option| s.map(|s| s.div_ceil(60) * 60);
Json(serde_json::json!({
"ok": true,
- "queue": app.queue.len(),
- "refresh": app.queue.refresh_len(),
- "cache": {"entries": entries, "bytes": bytes, "hits": hits, "misses": misses},
- "quota": app.queue.quota(),
+ "now": now_secs(),
+ "started_at": app.started_at,
+ "fetching": {"paused": paused > 0, "back_in_secs": minutes((paused > 0).then_some(paused))},
+ "new_users": {"open": open, "next_opening_secs": if capped { minutes(n["oldest_frees_in_secs"].as_u64()) } else { None }},
"scenes": app.assets.scene_names(),
}))
}
@@ -160,7 +292,7 @@ mod tests {
let app = Arc::new(App::new(Arc::new(Assets::embedded().unwrap()), store, queue));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
- let r = router(app.clone());
+ let r = router(app.clone()).into_make_service_with_connect_info::();
tokio::spawn(async move { axum::serve(listener, r).await.unwrap() });
(base, app)
}
@@ -369,6 +501,16 @@ mod tests {
let (base, _) = serve(QueueConfig::default()).await;
let v: serde_json::Value = reqwest::get(format!("{base}/health")).await.unwrap().json().await.unwrap();
assert_eq!(v["ok"], true);
+ assert_eq!(v["new_users"]["open"], true);
+ assert!(v.get("github").is_none() && v.get("quota").is_none(), "no capacities in public: {v}");
+ let full: serde_json::Value = reqwest::get(format!("{base}/admin/health")).await.unwrap().json().await.unwrap();
+ assert_eq!(full["new_users"]["per_hour"], 600);
+ assert!(full["traffic"].as_array().is_some_and(|t| t.len() == 60) && full["recent"].is_array(), "{full}");
+ let client = reqwest::Client::new();
+ for path in ["/admin/health", "/admin/status", "/static/admin/status.html"] {
+ let r = client.get(format!("{base}{path}")).header("cf-ray", "x").send().await.unwrap();
+ assert_eq!(r.status(), 404, "{path} through Cloudflare without a login");
+ }
let scenes = v["scenes"].as_array().unwrap();
assert!(scenes.contains(&serde_json::json!("forest")), "{scenes:?}");
assert!(!scenes.contains(&serde_json::json!("_shared")), "shared art is not a scene");
diff --git a/src/store.rs b/src/store.rs
index b4c7fbd6..afcaa5dd 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -95,6 +95,17 @@ impl Store {
m.touched.remove(login);
}
+ /// (users in memory, logins remembered as not on GitHub), for the admin page.
+ pub fn counts(&self, now: u64) -> (usize, usize) {
+ let m = self.mem.lock().unwrap();
+ (m.users.len(), m.unknown.values().filter(|&&t| now.saturating_sub(t) < UNKNOWN_SECS).count())
+ }
+
+ /// Users saved on disk, for the admin page (lists the folder, so call it off the request threads).
+ pub fn saved_count(&self) -> Option {
+ Some(std::fs::read_dir(self.dir.join("users")).ok()?.count())
+ }
+
pub fn mark_unknown(&self, login: &str, now: u64) {
self.mem.lock().unwrap().unknown.insert(login.to_string(), now);
}