split the project in 2

This commit is contained in:
2026-05-29 18:55:56 +01:00
parent 8aadda2d72
commit aef2113198
523 changed files with 2929 additions and 10 deletions
+32 -4
View File
@@ -1,6 +1,12 @@
# tssbot.web # tssbot.web
React + Vite + Tailwind v4 web shell for Toothless' TSS Bot. Toothless' TSS Bot web stack.
The repo is split into:
- `frontend/` - React + Vite + Tailwind v4 web shell
- `backend/` - backend API service scaffold, ready for database-backed routes
- root process files - production frontend server, deploy webhook, PM2 config, and shared repo scripts
Routes: Routes:
@@ -17,7 +23,7 @@ npm install
npm run dev npm run dev
``` ```
The development server runs on <http://localhost:3001>. The frontend development server runs on <http://localhost:3001>.
Set `comingsoon=TRUE` in `.env` to serve a temporary coming soon page instead Set `comingsoon=TRUE` in `.env` to serve a temporary coming soon page instead
of the app. Omit it, or set any other value, to serve the regular site. of the app. Omit it, or set any other value, to serve the regular site.
@@ -29,11 +35,31 @@ that with `VITE_API_TARGET`:
VITE_API_TARGET=http://localhost:8080 npm run dev VITE_API_TARGET=http://localhost:8080 npm run dev
``` ```
Run the Rust backend separately:
```sh
npm run dev:backend
```
The backend listens on <http://localhost:6000> by default and reads the SQLite
databases configured by `TSS_BATTLES_DB` and `TSS_TEAMS_DB`.
## Production with PM2 ## Production with PM2
On a fresh headless Ubuntu server, install the native build tools Rust crates
need before the first backend build:
```sh
sudo apt update
sudo apt install -y build-essential pkg-config curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
. "$HOME/.cargo/env"
```
```sh ```sh
npm install npm install
npm run build npm run build
npm run build:backend
pm2 start ecosystem.config.cjs pm2 start ecosystem.config.cjs
``` ```
@@ -191,13 +217,15 @@ The default deploy flow is:
```sh ```sh
git pull --ff-only git pull --ff-only
npm ci --include=dev --include=optional npm ci --include=dev --include=optional
npm run build -- --outDir dist-next npm run build -- --outDir ../dist-next
cargo build --manifest-path backend/Cargo.toml --release
# the webhook promotes dist-next to dist after carrying over old hashed assets # the webhook promotes dist-next to dist after carrying over old hashed assets
pm2 reload tssbot-web --update-env pm2 reload tssbot-web --update-env
pm2 reload tssbot-backend --update-env
``` ```
Only processes listed in `PM2_RESTART_TARGETS` are reloaded. The default is Only processes listed in `PM2_RESTART_TARGETS` are reloaded. The default is
`tssbot-web`, so unrelated PM2 processes are left alone. The web server handles `tssbot-web,tssbot-backend`, so unrelated PM2 processes are left alone. The web server handles
`SIGINT` and `SIGTERM` by closing its listener and SQLite handles before exit, `SIGINT` and `SIGTERM` by closing its listener and SQLite handles before exit,
which lets PM2 finish reloads without dropping active requests. The webhook which lets PM2 finish reloads without dropping active requests. The webhook
exits after 24 hours so PM2 restarts it cleanly. exits after 24 hours so PM2 restarts it cleanly.
+770
View File
@@ -0,0 +1,770 @@
# 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 = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"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 = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[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 = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[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",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[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-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown",
]
[[package]]
name = "http"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
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 = "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.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[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.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "log"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[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 = "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 = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[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 = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[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.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rusqlite"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[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 = "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",
]
[[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"
[[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 = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[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 = "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",
"http",
"http-body",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[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 = "tssbot-backend"
version = "0.1.0"
dependencies = [
"axum",
"rusqlite",
"serde",
"serde_json",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[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 = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[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.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "tssbot-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
urlencoding = "2"
+34
View File
@@ -0,0 +1,34 @@
# tssbot backend
Rust backend API service for Toothless' TSS Bot.
It reads two SQLite databases:
- `TSS_BATTLES_DB` for `tss_battles.db`
- `TSS_TEAMS_DB` for `tss_teams.db`
Both paths can be absolute or relative to the repo root when run through the root scripts/PM2.
It currently exposes:
- `GET /health`
- `GET /api/tss/leaderboard/teams?limit=100`
- `GET /api/tss/teams/resolve?name=...`
- `GET /api/tss/teams/search?q=...&limit=10`
- `GET /api/tss/teams/:team`
- `GET /api/tss/teams/:team/history`
- `GET /api/tss/teams/:team/games`
## Local development
```sh
npm run dev:backend
```
The backend listens on <http://localhost:6000> by default. Override with `BACKEND_PORT`.
## Production build
```sh
npm run build:backend
```
+958
View File
@@ -0,0 +1,958 @@
use axum::{
extract::{Path, Query, State},
http::{header, Method, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
collections::BTreeMap,
env, fs,
net::SocketAddr,
path::{Path as FsPath, PathBuf},
sync::Arc,
};
use tokio::net::TcpListener;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
const MAX_TEAM_NAME_LENGTH: usize = 80;
#[derive(Clone)]
struct AppState {
battles_db: PathBuf,
teams_db: PathBuf,
}
#[derive(Debug)]
struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.into(),
}
}
fn internal(message: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.into(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
type ApiResult<T> = Result<Json<T>, ApiError>;
#[derive(Deserialize)]
struct LimitQuery {
limit: Option<u32>,
}
#[derive(Deserialize)]
struct ResolveQuery {
name: String,
}
#[derive(Deserialize)]
struct SearchQuery {
q: Option<String>,
name: Option<String>,
limit: Option<u32>,
}
#[derive(Serialize)]
struct HealthResponse {
ok: bool,
service: &'static str,
battles_db: String,
teams_db: String,
databases: BTreeMap<&'static str, bool>,
}
#[derive(Serialize)]
struct LeaderboardResponse {
teams: Vec<TeamLeaderboardRow>,
}
#[derive(Serialize)]
struct SearchResponse {
teams: Vec<TeamSearchRow>,
}
#[derive(Serialize)]
struct ResolveResponse {
team_id: i64,
long_name: String,
tag_name: Option<String>,
name: String,
}
#[derive(Serialize)]
struct TeamSearchRow {
team_id: i64,
long_name: String,
tag_name: Option<String>,
members: i64,
clanrating: Option<i64>,
}
#[derive(Serialize)]
struct TeamLeaderboardRow {
team_id: i64,
clan_id: i64,
long_name: String,
tag_name: Option<String>,
short_name: Option<String>,
player_count: i64,
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
total_kills: i64,
points: Points,
}
#[derive(Serialize)]
struct TeamDetail {
team_id: i64,
clan_id: i64,
long_name: String,
tag_name: Option<String>,
short_name: Option<String>,
description: Option<String>,
region: Option<String>,
members: i64,
captain_uid: Option<String>,
guild_id: Option<String>,
created_unix: Option<i64>,
updated_unix: Option<i64>,
clanrating: Option<i64>,
data_set: &'static str,
team_summary: TeamSummary,
players: Vec<PlayerSummary>,
}
#[derive(Serialize)]
struct TeamSummary {
player_count: i64,
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
kdr: f64,
total_kills: i64,
points: Points,
total_points: i64,
}
#[derive(Serialize)]
struct Points {
total_points: i64,
}
#[derive(Serialize)]
struct PlayerSummary {
uid: String,
nick: Option<String>,
role: String,
joined_unix: Option<i64>,
points: i64,
sqb_points: i64,
total_battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
total_kills: i64,
ground_kills: i64,
air_kills: i64,
assists: i64,
deaths: i64,
kdr: f64,
}
#[derive(Serialize)]
struct HistoryResponse {
team_id: i64,
long_name: String,
tag_name: Option<String>,
history: Vec<PeriodHistory>,
rating_hourly: Vec<RatingPoint>,
}
#[derive(Serialize)]
struct PeriodHistory {
period: String,
battles: i64,
wins: i64,
losses: i64,
win_rate: f64,
}
#[derive(Serialize)]
struct RatingPoint {
timestamp: i64,
rating: i64,
}
#[derive(Serialize)]
struct GamesResponse {
team_id: i64,
long_name: String,
tag_name: Option<String>,
games: Vec<GameRow>,
}
#[derive(Serialize)]
struct GameRow {
session_id: String,
timestamp: i64,
endtime_unix: i64,
map_name: Option<String>,
mission_mode: Option<String>,
result: String,
player_count: i64,
winning_team: Option<String>,
losing_team: Option<String>,
stats: GameStats,
}
#[derive(Serialize)]
struct GameStats {
ground_kills: i64,
air_kills: i64,
assists: i64,
captures: i64,
deaths: i64,
score: i64,
missile_evades: i64,
shell_interceptions: i64,
team_kills_stat: i64,
}
struct TeamRecord {
team_id: i64,
long_name: String,
tag_name: Option<String>,
description: Option<String>,
region: Option<String>,
members: i64,
captain_uid: Option<String>,
guild_id: Option<String>,
created_unix: Option<i64>,
updated_unix: Option<i64>,
clanrating: Option<i64>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
load_root_env();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let port = env_u16("BACKEND_PORT")
.or_else(|| env_u16("PORT"))
.unwrap_or(6000);
let state = Arc::new(AppState {
battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"),
});
let app = Router::new()
.route("/health", get(health))
.route("/api/tss/leaderboard/teams", get(leaderboard))
.route("/api/tss/teams/resolve", get(resolve_team))
.route("/api/tss/teams/search", get(search_teams))
.route("/api/tss/teams/{team}", get(team_detail))
.route("/api/tss/teams/{team}/history", get(team_history))
.route("/api/tss/teams/{team}/games", get(team_games))
.layer(
CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(Any)
.allow_headers([header::ACCEPT, header::CONTENT_TYPE]),
)
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = TcpListener::bind(addr).await?;
tracing::info!("tssbot backend listening on http://{}", addr);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
let mut databases = BTreeMap::new();
databases.insert(
"battles",
Connection::open_with_flags(
&state.battles_db,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.is_ok(),
);
databases.insert(
"teams",
Connection::open_with_flags(&state.teams_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.is_ok(),
);
Json(HealthResponse {
ok: databases.values().all(|ok| *ok),
service: "tssbot-backend",
battles_db: state.battles_db.display().to_string(),
teams_db: state.teams_db.display().to_string(),
databases,
})
}
async fn leaderboard(
State(state): State<Arc<AppState>>,
Query(query): Query<LimitQuery>,
) -> ApiResult<LeaderboardResponse> {
let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100));
let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?;
let mut stmt = teams_conn
.prepare(
"SELECT team_id, long_name, tag_name, description, region, members, captain_uid, guild_id,
created_unix, updated_unix, clanrating
FROM teams_data
ORDER BY COALESCE(clanrating, 0) DESC, members DESC, long_name COLLATE NOCASE ASC
LIMIT ?1",
)
.map_err(db_error)?;
let teams = stmt
.query_map(params![limit], |row| read_team_record(row))
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
let mut rows = Vec::with_capacity(teams.len());
for team in teams {
let summary = team_summary_for(&battles_conn, team.team_id)?;
rows.push(TeamLeaderboardRow {
team_id: team.team_id,
clan_id: team.team_id,
long_name: team.long_name.clone(),
tag_name: team.tag_name.clone(),
short_name: team.tag_name.clone(),
player_count: team.members,
total_battles: summary.total_battles,
wins: summary.wins,
losses: summary.losses,
win_rate: summary.win_rate,
total_kills: summary.total_kills,
points: Points {
total_points: team.clanrating.unwrap_or(summary.total_points),
},
});
}
Ok(Json(LeaderboardResponse { teams: rows }))
}
async fn resolve_team(
State(state): State<Arc<AppState>>,
Query(query): Query<ResolveQuery>,
) -> ApiResult<ResolveResponse> {
let name = validate_team_name(&query.name)?;
let conn = open_db(&state.teams_db)?;
let team = find_team(&conn, name)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
Ok(Json(ResolveResponse {
team_id: team.team_id,
long_name: team.long_name.clone(),
tag_name: team.tag_name.clone(),
name: team.tag_name.clone().unwrap_or(team.long_name),
}))
}
async fn search_teams(
State(state): State<Arc<AppState>>,
Query(query): Query<SearchQuery>,
) -> ApiResult<SearchResponse> {
let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or("");
let name = validate_team_name(raw)?;
let limit = i64::from(query.limit.unwrap_or(10).clamp(1, 20));
let like = format!("%{}%", escape_like(name));
let conn = open_db(&state.teams_db)?;
let mut stmt = conn
.prepare(
"SELECT team_id, long_name, tag_name, members, clanrating
FROM teams_data
WHERE long_name LIKE ?1 ESCAPE '\\' OR tag_name LIKE ?1 ESCAPE '\\'
ORDER BY
CASE
WHEN tag_name = ?2 COLLATE NOCASE THEN 0
WHEN long_name = ?2 COLLATE NOCASE THEN 1
ELSE 2
END,
COALESCE(clanrating, 0) DESC,
long_name COLLATE NOCASE ASC
LIMIT ?3",
)
.map_err(db_error)?;
let teams = stmt
.query_map(params![like, name, limit], |row| {
Ok(TeamSearchRow {
team_id: row.get(0)?,
long_name: row.get(1)?,
tag_name: row.get(2)?,
members: row.get(3)?,
clanrating: row.get(4)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
Ok(Json(SearchResponse { teams }))
}
async fn team_detail(
State(state): State<Arc<AppState>>,
Path(team_name): Path<String>,
) -> ApiResult<TeamDetail> {
let decoded = decode_path_team(&team_name)?;
let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?;
let team =
find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
let summary = team_summary_for(&battles_conn, team.team_id)?;
let players = player_summaries_for(&teams_conn, &battles_conn, team.team_id)?;
Ok(Json(TeamDetail {
team_id: team.team_id,
clan_id: team.team_id,
long_name: team.long_name,
tag_name: team.tag_name.clone(),
short_name: team.tag_name,
description: team.description,
region: team.region,
members: team.members,
captain_uid: team.captain_uid,
guild_id: team.guild_id,
created_unix: team.created_unix,
updated_unix: team.updated_unix,
clanrating: team.clanrating,
data_set: "tss",
team_summary: summary,
players,
}))
}
async fn team_history(
State(state): State<Arc<AppState>>,
Path(team_name): Path<String>,
) -> ApiResult<HistoryResponse> {
let decoded = decode_path_team(&team_name)?;
let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?;
let team =
find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
let history = period_history_for(&battles_conn, team.team_id)?;
let rating_hourly = rating_history_for(&teams_conn, team.team_id)?;
Ok(Json(HistoryResponse {
team_id: team.team_id,
long_name: team.long_name,
tag_name: team.tag_name,
history,
rating_hourly,
}))
}
async fn team_games(
State(state): State<Arc<AppState>>,
Path(team_name): Path<String>,
) -> ApiResult<GamesResponse> {
let decoded = decode_path_team(&team_name)?;
let teams_conn = open_db(&state.teams_db)?;
let battles_conn = open_db(&state.battles_db)?;
let team =
find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?;
let games = games_for(&battles_conn, team.team_id)?;
Ok(Json(GamesResponse {
team_id: team.team_id,
long_name: team.long_name,
tag_name: team.tag_name,
games,
}))
}
fn open_db(path: &FsPath) -> Result<Connection, ApiError> {
Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| {
ApiError::internal(format!("Could not open {}: {}", path.display(), error))
})
}
fn db_error(error: rusqlite::Error) -> ApiError {
ApiError::internal(format!("Database query failed: {}", error))
}
fn read_team_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<TeamRecord> {
Ok(TeamRecord {
team_id: row.get(0)?,
long_name: row.get(1)?,
tag_name: row.get(2)?,
description: row.get(3)?,
region: row.get(4)?,
members: row.get(5)?,
captain_uid: row.get(6)?,
guild_id: row.get(7)?,
created_unix: row.get(8)?,
updated_unix: row.get(9)?,
clanrating: row.get(10)?,
})
}
fn find_team(conn: &Connection, name: &str) -> Result<Option<TeamRecord>, ApiError> {
conn.query_row(
"SELECT team_id, long_name, tag_name, description, region, members, captain_uid, guild_id,
created_unix, updated_unix, clanrating
FROM teams_data
WHERE team_id = ?1
OR long_name = ?2 COLLATE NOCASE
OR tag_name = ?2 COLLATE NOCASE
ORDER BY
CASE
WHEN tag_name = ?2 COLLATE NOCASE THEN 0
WHEN long_name = ?2 COLLATE NOCASE THEN 1
ELSE 2
END
LIMIT 1",
params![name.parse::<i64>().ok(), name],
read_team_record,
)
.optional()
.map_err(db_error)
}
fn team_summary_for(conn: &Connection, team_id: i64) -> Result<TeamSummary, ApiError> {
conn.query_row(
"SELECT
COUNT(DISTINCT session_id),
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
COALESCE(SUM(ground_kills), 0),
COALESCE(SUM(air_kills), 0),
COALESCE(SUM(assists), 0),
COALESCE(SUM(deaths), 0),
COALESCE(SUM(score), 0),
COUNT(DISTINCT UID)
FROM player_games_hist
WHERE team_id = ?1",
params![team_id],
|row| {
let battles: i64 = row.get(0)?;
let wins: i64 = row.get(1)?;
let losses: i64 = row.get(2)?;
let ground: i64 = row.get(3)?;
let air: i64 = row.get(4)?;
let assists: i64 = row.get(5)?;
let deaths: i64 = row.get(6)?;
let score: i64 = row.get(7)?;
let player_count: i64 = row.get(8)?;
let total_kills = ground + air;
Ok(TeamSummary {
player_count,
total_battles: battles,
wins,
losses,
win_rate: percent(wins, battles),
kdr: ratio(total_kills, deaths),
total_kills,
points: Points {
total_points: score + assists + total_kills,
},
total_points: score + assists + total_kills,
})
},
)
.map_err(db_error)
}
fn player_summaries_for(
teams_conn: &Connection,
battles_conn: &Connection,
team_id: i64,
) -> Result<Vec<PlayerSummary>, ApiError> {
let mut stmt = teams_conn
.prepare(
"SELECT uid, nick, role, joined_unix, points
FROM team_members
WHERE team_id = ?1
ORDER BY points DESC, nick COLLATE NOCASE ASC",
)
.map_err(db_error)?;
let members = stmt
.query_map(params![team_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<i64>>(3)?,
row.get::<_, i64>(4)?,
))
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)?;
let mut out = Vec::with_capacity(members.len());
let mut stats_stmt = battles_conn
.prepare(
"SELECT
COUNT(DISTINCT session_id),
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END),
COALESCE(SUM(ground_kills), 0),
COALESCE(SUM(air_kills), 0),
COALESCE(SUM(assists), 0),
COALESCE(SUM(deaths), 0),
COALESCE(SUM(score), 0)
FROM player_games_hist
WHERE team_id = ?1 AND UID = ?2",
)
.map_err(db_error)?;
for (uid, nick, role, joined_unix, points) in members {
let summary = stats_stmt
.query_row(params![team_id, uid], |row| {
let battles: i64 = row.get(0)?;
let wins: i64 = row.get(1)?;
let losses: i64 = row.get(2)?;
let ground: i64 = row.get(3)?;
let air: i64 = row.get(4)?;
let assists: i64 = row.get(5)?;
let deaths: i64 = row.get(6)?;
let score: i64 = row.get(7)?;
let total_kills = ground + air;
Ok(PlayerSummary {
uid: uid.clone(),
nick: nick.clone(),
role: role.clone(),
joined_unix,
points,
sqb_points: if points == 0 {
score + assists + total_kills
} else {
points
},
total_battles: battles,
wins,
losses,
win_rate: percent(wins, battles),
total_kills,
ground_kills: ground,
air_kills: air,
assists,
deaths,
kdr: ratio(total_kills, deaths),
})
})
.map_err(db_error)?;
out.push(summary);
}
Ok(out)
}
fn period_history_for(conn: &Connection, team_id: i64) -> Result<Vec<PeriodHistory>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT
strftime('%Y-%m', endtime_unix, 'unixepoch') AS period,
COUNT(DISTINCT session_id),
SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END),
SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END)
FROM player_games_hist
WHERE team_id = ?1 AND endtime_unix > 0
GROUP BY period
ORDER BY period ASC",
)
.map_err(db_error)?;
stmt.query_map(params![team_id], |row| {
let period: String = row.get(0)?;
let battles: i64 = row.get(1)?;
let wins: i64 = row.get(2)?;
let losses: i64 = row.get(3)?;
Ok(PeriodHistory {
period,
battles,
wins,
losses,
win_rate: percent(wins, battles),
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)
}
fn rating_history_for(conn: &Connection, team_id: i64) -> Result<Vec<RatingPoint>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT unix_time, COALESCE(total_score, 0)
FROM teams_points
WHERE team_id = ?1
ORDER BY unix_time ASC",
)
.map_err(db_error)?;
stmt.query_map(params![team_id], |row| {
Ok(RatingPoint {
timestamp: row.get(0)?,
rating: row.get(1)?,
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)
}
fn games_for(conn: &Connection, team_id: i64) -> Result<Vec<GameRow>, ApiError> {
let mut stmt = conn
.prepare(
"SELECT
p.session_id,
COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp,
m.mission_mode,
CASE
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win'
ELSE 'Loss'
END AS result,
COUNT(DISTINCT p.UID),
COALESCE(SUM(p.ground_kills), 0),
COALESCE(SUM(p.air_kills), 0),
COALESCE(SUM(p.assists), 0),
COALESCE(SUM(p.captures), 0),
COALESCE(SUM(p.deaths), 0),
COALESCE(SUM(p.score), 0),
COALESCE(SUM(p.missile_evades), 0),
COALESCE(SUM(p.shell_interceptions), 0),
COALESCE(SUM(p.team_kills_stat), 0),
m.winning_team,
m.losing_team
FROM player_games_hist p
LEFT JOIN match_summary m ON m.session_id = p.session_id
WHERE p.team_id = ?1
GROUP BY p.session_id
ORDER BY timestamp DESC
LIMIT 100",
)
.map_err(db_error)?;
stmt.query_map(params![team_id], |row| {
let session_id: String = row.get(0)?;
let timestamp: i64 = row.get(1)?;
let mission_mode: Option<String> = row.get(2)?;
Ok(GameRow {
session_id,
timestamp,
endtime_unix: timestamp,
map_name: mission_mode.clone(),
mission_mode,
result: row.get(3)?,
player_count: row.get(4)?,
winning_team: row.get(14)?,
losing_team: row.get(15)?,
stats: GameStats {
ground_kills: row.get(5)?,
air_kills: row.get(6)?,
assists: row.get(7)?,
captures: row.get(8)?,
deaths: row.get(9)?,
score: row.get(10)?,
missile_evades: row.get(11)?,
shell_interceptions: row.get(12)?,
team_kills_stat: row.get(13)?,
},
})
})
.map_err(db_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(db_error)
}
fn validate_team_name(name: &str) -> Result<&str, ApiError> {
let trimmed = name.trim();
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
return Err(ApiError::bad_request(
"Team name must be 2 to 80 characters",
));
}
Ok(trimmed)
}
fn decode_path_team(value: &str) -> Result<String, ApiError> {
let decoded = urlencoding::decode(value)
.map_err(|_| ApiError::bad_request("Invalid team name"))?
.into_owned();
validate_team_name(&decoded)?;
Ok(decoded)
}
fn escape_like(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
fn percent(part: i64, total: i64) -> f64 {
if total <= 0 {
0.0
} else {
(part as f64 / total as f64) * 100.0
}
}
fn ratio(top: i64, bottom: i64) -> f64 {
if bottom <= 0 {
top as f64
} else {
top as f64 / bottom as f64
}
}
fn env_u16(key: &str) -> Option<u16> {
env::var(key).ok()?.parse().ok()
}
fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf {
let raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string());
let expanded = expand_home(&raw);
let path = PathBuf::from(expanded);
if path.is_absolute() {
path
} else {
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
fn expand_home(value: &str) -> String {
if value == "~" {
return home_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|| value.to_string());
}
if let Some(rest) = value
.strip_prefix("~/")
.or_else(|| value.strip_prefix("~\\"))
{
if let Some(home) = home_dir() {
return home.join(rest).display().to_string();
}
}
value.to_string()
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn load_root_env() {
let candidates = [
env::current_dir().ok().map(|path| path.join(".env")),
env::current_dir()
.ok()
.and_then(|path| path.parent().map(|parent| parent.join(".env"))),
Some(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join(".env"),
),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
load_env_file(&candidate);
return;
}
}
}
fn load_env_file(path: &FsPath) {
let Ok(contents) = fs::read_to_string(path) else {
return;
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() || env::var_os(key).is_some() {
continue;
}
let value = value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
env::set_var(key, value);
}
}
+1
View File
@@ -0,0 +1 @@
{"rustc_fingerprint":13465357961897998566,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___.exe\nlib___.rlib\n___.dll\n___.dll\n___.lib\n___.dll\nC:\\Program Files\\Rust stable MSVC 1.94\npacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"msvc\"\ntarget_family=\"windows\"\ntarget_feature=\"cmpxchg16b\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_feature=\"sse3\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"windows\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"pc\"\nwindows\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.0 (4a4ef493e 2026-03-02)\nbinary: rustc\ncommit-hash: 4a4ef493e3a1488c6e321570238084b38948f6db\ncommit-date: 2026-03-02\nhost: x86_64-pc-windows-msvc\nrelease: 1.94.0\nLLVM version: 21.1.8\n","stderr":""}},"successes":{}}
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by cargo.
# For information about cache directory tags see https://bford.info/cachedir/
View File
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
dd6ca575ec4431f0
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":15657897354478470176,"path":6717365521836368625,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\atomic-waker-da9a01f7dc236e82\\dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
78c7cc385d4285de
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"portable-atomic\"]","target":14411119108718288063,"profile":2241668132362809309,"path":6717365521836368625,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\atomic-waker-f1278d2645921f3c\\dep-lib-atomic_waker","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
040c93e36e855d88
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"arbitrary\", \"bytemuck\", \"example_generated\", \"serde\", \"serde_core\", \"std\"]","target":7691312148208718491,"profile":2241668132362809309,"path":8434845941539571929,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\bitflags-a8ec033f306034c3\\dep-lib-bitflags","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
2b1cfe2bdcc2f913
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":13827760451848848284,"path":6232424548232413856,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\bytes-a1899f05397b67f9\\dep-lib-bytes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
72bd6b8bd42622b0
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"extra-platforms\", \"serde\", \"std\"]","target":11402411492164584411,"profile":5585765287293540646,"path":6232424548232413856,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\bytes-cc0a20647e53726a\\dep-lib-bytes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
645c0c4ba84f291f
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":4333757155065362140,"path":5893973166268932723,"deps":[[9159843920629750842,"find_msvc_tools",false,13263039337708535718],[12678166843757613889,"shlex",false,9241398564909348994]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cc-b8e5d38724e4a1e6\\dep-lib-cc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
dd5891f43e03cafc
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":2241668132362809309,"path":425904950434454382,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\cfg-if-3dcb42ca3a02dcb5\\dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\", \"default\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":15245709686714427328,"profile":2241668132362809309,"path":4661389752810606431,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\fallible-iterator-2b1f1fdbcc3af240\\dep-lib-fallible_iterator","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"std\"]","target":16001337131876932863,"profile":2241668132362809309,"path":6866245534065373636,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\fallible-streaming-iterator-773a55ab8825561a\\dep-lib-fallible_streaming_iterator","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
a63f26ce01c80fb8
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[]","target":10620166500288925791,"profile":4333757155065362140,"path":14910102133086743437,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\find-msvc-tools-526db89c667947b5\\dep-lib-find_msvc_tools","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
af3a210f94c85f68
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"default\", \"std\"]","target":18077926938045032029,"profile":15657897354478470176,"path":12626290509863766455,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\foldhash-860ea31daecb2567\\dep-lib-foldhash","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
e83e84de7a3ab2a5
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[\"default\", \"std\"]","target":18077926938045032029,"profile":2241668132362809309,"path":12626290509863766455,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\foldhash-9dc18cd827dd0134\\dep-lib-foldhash","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
86b80bf85ce58506
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":6496257856677244489,"profile":15657897354478470176,"path":2780369495299393720,"deps":[[6803352382179706244,"percent_encoding",false,203382068760923564]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\form_urlencoded-f95ea697b2465d1a\\dep-lib-form_urlencoded","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
1fb5ca1a3b1a9b25
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"cfg-target-has-atomic\", \"default\", \"futures-sink\", \"sink\", \"std\", \"unstable\"]","target":13634065851578929263,"profile":17467636112133979524,"path":16899676779384462205,"deps":[[302948626015856208,"futures_core",false,1643743490218323212]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\futures-channel-d31c1762bab1da24\\dep-lib-futures_channel","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
a6246b8f05ae4be0
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"cfg-target-has-atomic\", \"default\", \"futures-sink\", \"sink\", \"std\", \"unstable\"]","target":13634065851578929263,"profile":13318305459243126790,"path":16899676779384462205,"deps":[[302948626015856208,"futures_core",false,13301172465390714160]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\futures-channel-eff736dee8ee9cfc\\dep-lib-futures_channel","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
30fdd177e24197b8
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"cfg-target-has-atomic\", \"default\", \"portable-atomic\", \"std\", \"unstable\"]","target":9453135960607436725,"profile":13318305459243126790,"path":17521873979997666290,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\futures-core-85d3f87fb4b487db\\dep-lib-futures_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
0ced52d4febfcf16
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"cfg-target-has-atomic\", \"default\", \"portable-atomic\", \"std\", \"unstable\"]","target":9453135960607436725,"profile":17467636112133979524,"path":17521873979997666290,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\futures-core-cd18f53292d99fc1\\dep-lib-futures_core","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
b82ec0acee527533
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"cfg-target-has-atomic\", \"default\", \"std\", \"unstable\"]","target":13518091470260541623,"profile":17467636112133979524,"path":3102762548533649612,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\futures-task-0723fba4205513af\\dep-lib-futures_task","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
ce660c091a8c68d3
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\"]","declared_features":"[\"alloc\", \"cfg-target-has-atomic\", \"default\", \"std\", \"unstable\"]","target":13518091470260541623,"profile":13318305459243126790,"path":3102762548533649612,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\futures-task-12597ee60dfe15d1\\dep-lib-futures_task","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
22b07e2271a86017
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[\"alloc\", \"slab\"]","declared_features":"[\"alloc\", \"async-await\", \"async-await-macro\", \"bilock\", \"cfg-target-has-atomic\", \"channel\", \"compat\", \"default\", \"futures-channel\", \"futures-io\", \"futures-macro\", \"futures-sink\", \"futures_01\", \"io\", \"io-compat\", \"libc\", \"memchr\", \"portable-atomic\", \"sink\", \"slab\", \"spin\", \"std\", \"tokio-io\", \"unstable\", \"write-all-vectored\"]","target":1788798584831431502,"profile":13318305459243126790,"path":9137121785660462178,"deps":[[302948626015856208,"futures_core",false,13301172465390714160],[2251399859588827949,"pin_project_lite",false,4614563224716250225],[12256881686772805731,"futures_task",false,15233579783029548750],[14895711841936801505,"slab",false,10607738326241059162]],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\futures-util-c4c26eb39c4b6d81\\dep-lib-futures_util","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1,5 @@
{"$message_type":"diagnostic","message":"linker `link.exe` not found","code":null,"level":"error","spans":[],"children":[{"message":"program not found","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: linker `link.exe` not found\u001b[0m\n \u001b[1m\u001b[96m|\u001b[0m\n \u001b[1m\u001b[96m= \u001b[0m\u001b[1m\u001b[97mnote\u001b[0m: program not found\n\n"}
{"$message_type":"diagnostic","message":"the msvc targets depend on the msvc linker but `link.exe` was not found","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: the msvc targets depend on the msvc linker but `link.exe` was not found\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"VS Code is a different product, and is not sufficient.","code":null,"level":"note","spans":[],"children":[],"rendered":"\u001b[1m\u001b[92mnote\u001b[0m\u001b[1m\u001b[97m: VS Code is a different product, and is not sufficient.\u001b[0m\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m\u001b[97m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
41385b921dbbb8d2
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[]","target":12509520342503990962,"profile":2241668132362809309,"path":152072485705541812,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\httpdate-23a892f8f0a14996\\dep-lib-httpdate","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.
@@ -0,0 +1 @@
10fc3430d7934e80
@@ -0,0 +1 @@
{"rustc":3674164131150989441,"features":"[]","declared_features":"[]","target":12509520342503990962,"profile":15657897354478470176,"path":152072485705541812,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug\\.fingerprint\\httpdate-974d134952b1c9dd\\dep-lib-httpdate","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
@@ -0,0 +1 @@
This file has an mtime of when this was started.

Some files were not shown because too many files have changed in this diff Show More