slack status without the slack status.zzstoatzz.io/
quickslice

remove rust implementation artifacts

quickslice rewrite replaces the custom rust backend.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+22 -14441
scripts
src
static
emojis
templates
-11
.dockerignore
··· 1 - target/ 2 - .git/ 3 - .gitignore 4 - *.db 5 - .env 6 - .cargo/ 7 - Dockerfile 8 - .dockerignore 9 - fly.toml 10 - README.md 11 - .github/
-13
.env.template
··· 1 - # Environment Configuration 2 - PORT="8080" # The port your server will listen on 3 - HOST="127.0.0.1" # Hostname for the server 4 - PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id. 5 - # DB_PATH="./statusphere.sqlite3" # The SQLite database path. Leave commented out to use a temporary in-memory database. 6 - 7 - # Dev Mode Configuration 8 - DEV_MODE="false" # Enable dev mode for testing with dummy data. Access via ?dev=true query parameter when enabled. 9 - 10 - # Custom Emojis 11 - # Directory to read/write custom emoji image files at runtime. 12 - # For local dev, keep under the repo: 13 - EMOJI_DIR="static/emojis"
-36
.pre-commit-config.yaml
··· 1 - repos: 2 - - repo: local 3 - hooks: 4 - - id: cargo-check 5 - name: Cargo check 6 - entry: cargo check 7 - language: system 8 - types: [rust] 9 - pass_filenames: false 10 - - id: cargo-fmt-check 11 - name: Cargo fmt check 12 - entry: cargo fmt -- --check 13 - language: system 14 - types: [rust] 15 - pass_filenames: false 16 - - id: cargo-clippy 17 - name: Cargo clippy 18 - entry: cargo clippy -- -D warnings 19 - language: system 20 - types: [rust] 21 - pass_filenames: false 22 - - id: check-html-syntax 23 - name: Check HTML syntax 24 - entry: bash -c 'for file in "$@"; do if ! xmllint --html --noout "$file" 2>/dev/null; then echo "HTML syntax error in $file"; exit 1; fi; done' -- 25 - language: system 26 - files: \.html$ 27 - - id: check-js-syntax 28 - name: Check JavaScript syntax 29 - entry: bash -c 'for file in "$@"; do if ! node -c "$file" 2>/dev/null; then echo "JavaScript syntax error in $file"; exit 1; fi; done' -- 30 - language: system 31 - files: \.js$ 32 - - id: check-json 33 - name: Check JSON 34 - entry: bash -c 'for file in "$@"; do if ! python3 -m json.tool "$file" > /dev/null 2>&1; then echo "JSON syntax error in $file"; exit 1; fi; done' -- 35 - language: system 36 - files: \.json$
-1
CLAUDE.md
··· 1 - - fly logs is a blocking command, you need to run it in the background
-4338
Cargo.lock
··· 1 - # This file is automatically @generated by Cargo. 2 - # It is not intended for manual editing. 3 - version = 4 4 - 5 - [[package]] 6 - name = "actix-codec" 7 - version = "0.5.2" 8 - source = "registry+https://github.com/rust-lang/crates.io-index" 9 - checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" 10 - dependencies = [ 11 - "bitflags", 12 - "bytes", 13 - "futures-core", 14 - "futures-sink", 15 - "memchr", 16 - "pin-project-lite", 17 - "tokio", 18 - "tokio-util", 19 - "tracing", 20 - ] 21 - 22 - [[package]] 23 - name = "actix-files" 24 - version = "0.6.6" 25 - source = "registry+https://github.com/rust-lang/crates.io-index" 26 - checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be" 27 - dependencies = [ 28 - "actix-http", 29 - "actix-service", 30 - "actix-utils", 31 - "actix-web", 32 - "bitflags", 33 - "bytes", 34 - "derive_more 0.99.19", 35 - "futures-core", 36 - "http-range", 37 - "log", 38 - "mime", 39 - "mime_guess", 40 - "percent-encoding", 41 - "pin-project-lite", 42 - "v_htmlescape", 43 - ] 44 - 45 - [[package]] 46 - name = "actix-http" 47 - version = "3.10.0" 48 - source = "registry+https://github.com/rust-lang/crates.io-index" 49 - checksum = "0fa882656b67966045e4152c634051e70346939fced7117d5f0b52146a7c74c9" 50 - dependencies = [ 51 - "actix-codec", 52 - "actix-rt", 53 - "actix-service", 54 - "actix-utils", 55 - "base64 0.22.1", 56 - "bitflags", 57 - "brotli", 58 - "bytes", 59 - "bytestring", 60 - "derive_more 2.0.1", 61 - "encoding_rs", 62 - "flate2", 63 - "foldhash", 64 - "futures-core", 65 - "h2 0.3.26", 66 - "http 0.2.12", 67 - "httparse", 68 - "httpdate", 69 - "itoa", 70 - "language-tags", 71 - "local-channel", 72 - "mime", 73 - "percent-encoding", 74 - "pin-project-lite", 75 - "rand 0.9.0", 76 - "sha1", 77 - "smallvec", 78 - "tokio", 79 - "tokio-util", 80 - "tracing", 81 - "zstd", 82 - ] 83 - 84 - [[package]] 85 - name = "actix-macros" 86 - version = "0.2.4" 87 - source = "registry+https://github.com/rust-lang/crates.io-index" 88 - checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" 89 - dependencies = [ 90 - "quote", 91 - "syn", 92 - ] 93 - 94 - [[package]] 95 - name = "actix-multipart" 96 - version = "0.6.2" 97 - source = "registry+https://github.com/rust-lang/crates.io-index" 98 - checksum = "d974dd6c4f78d102d057c672dcf6faa618fafa9df91d44f9c466688fc1275a3a" 99 - dependencies = [ 100 - "actix-multipart-derive", 101 - "actix-utils", 102 - "actix-web", 103 - "bytes", 104 - "derive_more 0.99.19", 105 - "futures-core", 106 - "futures-util", 107 - "httparse", 108 - "local-waker", 109 - "log", 110 - "memchr", 111 - "mime", 112 - "rand 0.8.5", 113 - "serde", 114 - "serde_json", 115 - "serde_plain", 116 - "tempfile", 117 - "tokio", 118 - ] 119 - 120 - [[package]] 121 - name = "actix-multipart-derive" 122 - version = "0.6.1" 123 - source = "registry+https://github.com/rust-lang/crates.io-index" 124 - checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d" 125 - dependencies = [ 126 - "darling", 127 - "parse-size", 128 - "proc-macro2", 129 - "quote", 130 - "syn", 131 - ] 132 - 133 - [[package]] 134 - name = "actix-router" 135 - version = "0.5.3" 136 - source = "registry+https://github.com/rust-lang/crates.io-index" 137 - checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" 138 - dependencies = [ 139 - "bytestring", 140 - "cfg-if", 141 - "http 0.2.12", 142 - "regex", 143 - "regex-lite", 144 - "serde", 145 - "tracing", 146 - ] 147 - 148 - [[package]] 149 - name = "actix-rt" 150 - version = "2.10.0" 151 - source = "registry+https://github.com/rust-lang/crates.io-index" 152 - checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" 153 - dependencies = [ 154 - "futures-core", 155 - "tokio", 156 - ] 157 - 158 - [[package]] 159 - name = "actix-server" 160 - version = "2.5.0" 161 - source = "registry+https://github.com/rust-lang/crates.io-index" 162 - checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894" 163 - dependencies = [ 164 - "actix-rt", 165 - "actix-service", 166 - "actix-utils", 167 - "futures-core", 168 - "futures-util", 169 - "mio", 170 - "socket2", 171 - "tokio", 172 - "tracing", 173 - ] 174 - 175 - [[package]] 176 - name = "actix-service" 177 - version = "2.0.2" 178 - source = "registry+https://github.com/rust-lang/crates.io-index" 179 - checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" 180 - dependencies = [ 181 - "futures-core", 182 - "paste", 183 - "pin-project-lite", 184 - ] 185 - 186 - [[package]] 187 - name = "actix-session" 188 - version = "0.10.1" 189 - source = "registry+https://github.com/rust-lang/crates.io-index" 190 - checksum = "efe6976a74f34f1b6d07a6c05aadc0ed0359304a7781c367fa5b4029418db08f" 191 - dependencies = [ 192 - "actix-service", 193 - "actix-utils", 194 - "actix-web", 195 - "anyhow", 196 - "derive_more 1.0.0", 197 - "rand 0.8.5", 198 - "serde", 199 - "serde_json", 200 - "tracing", 201 - ] 202 - 203 - [[package]] 204 - name = "actix-utils" 205 - version = "3.0.1" 206 - source = "registry+https://github.com/rust-lang/crates.io-index" 207 - checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" 208 - dependencies = [ 209 - "local-waker", 210 - "pin-project-lite", 211 - ] 212 - 213 - [[package]] 214 - name = "actix-web" 215 - version = "4.10.2" 216 - source = "registry+https://github.com/rust-lang/crates.io-index" 217 - checksum = "f2e3b15b3dc6c6ed996e4032389e9849d4ab002b1e92fbfe85b5f307d1479b4d" 218 - dependencies = [ 219 - "actix-codec", 220 - "actix-http", 221 - "actix-macros", 222 - "actix-router", 223 - "actix-rt", 224 - "actix-server", 225 - "actix-service", 226 - "actix-utils", 227 - "actix-web-codegen", 228 - "bytes", 229 - "bytestring", 230 - "cfg-if", 231 - "cookie", 232 - "derive_more 2.0.1", 233 - "encoding_rs", 234 - "foldhash", 235 - "futures-core", 236 - "futures-util", 237 - "impl-more", 238 - "itoa", 239 - "language-tags", 240 - "log", 241 - "mime", 242 - "once_cell", 243 - "pin-project-lite", 244 - "regex", 245 - "regex-lite", 246 - "serde", 247 - "serde_json", 248 - "serde_urlencoded", 249 - "smallvec", 250 - "socket2", 251 - "time", 252 - "tracing", 253 - "url", 254 - ] 255 - 256 - [[package]] 257 - name = "actix-web-codegen" 258 - version = "4.3.0" 259 - source = "registry+https://github.com/rust-lang/crates.io-index" 260 - checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" 261 - dependencies = [ 262 - "actix-router", 263 - "proc-macro2", 264 - "quote", 265 - "syn", 266 - ] 267 - 268 - [[package]] 269 - name = "addr2line" 270 - version = "0.24.2" 271 - source = "registry+https://github.com/rust-lang/crates.io-index" 272 - checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 273 - dependencies = [ 274 - "gimli", 275 - ] 276 - 277 - [[package]] 278 - name = "adler2" 279 - version = "2.0.0" 280 - source = "registry+https://github.com/rust-lang/crates.io-index" 281 - checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 282 - 283 - [[package]] 284 - name = "aead" 285 - version = "0.5.2" 286 - source = "registry+https://github.com/rust-lang/crates.io-index" 287 - checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" 288 - dependencies = [ 289 - "crypto-common", 290 - "generic-array", 291 - ] 292 - 293 - [[package]] 294 - name = "aes" 295 - version = "0.8.4" 296 - source = "registry+https://github.com/rust-lang/crates.io-index" 297 - checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" 298 - dependencies = [ 299 - "cfg-if", 300 - "cipher", 301 - "cpufeatures", 302 - ] 303 - 304 - [[package]] 305 - name = "aes-gcm" 306 - version = "0.10.3" 307 - source = "registry+https://github.com/rust-lang/crates.io-index" 308 - checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" 309 - dependencies = [ 310 - "aead", 311 - "aes", 312 - "cipher", 313 - "ctr", 314 - "ghash", 315 - "subtle", 316 - ] 317 - 318 - [[package]] 319 - name = "ahash" 320 - version = "0.8.11" 321 - source = "registry+https://github.com/rust-lang/crates.io-index" 322 - checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" 323 - dependencies = [ 324 - "cfg-if", 325 - "once_cell", 326 - "version_check", 327 - "zerocopy 0.7.35", 328 - ] 329 - 330 - [[package]] 331 - name = "aho-corasick" 332 - version = "1.1.3" 333 - source = "registry+https://github.com/rust-lang/crates.io-index" 334 - checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 335 - dependencies = [ 336 - "memchr", 337 - ] 338 - 339 - [[package]] 340 - name = "alloc-no-stdlib" 341 - version = "2.0.4" 342 - source = "registry+https://github.com/rust-lang/crates.io-index" 343 - checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 344 - 345 - [[package]] 346 - name = "alloc-stdlib" 347 - version = "0.2.2" 348 - source = "registry+https://github.com/rust-lang/crates.io-index" 349 - checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 350 - dependencies = [ 351 - "alloc-no-stdlib", 352 - ] 353 - 354 - [[package]] 355 - name = "allocator-api2" 356 - version = "0.2.21" 357 - source = "registry+https://github.com/rust-lang/crates.io-index" 358 - checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 359 - 360 - [[package]] 361 - name = "android-tzdata" 362 - version = "0.1.1" 363 - source = "registry+https://github.com/rust-lang/crates.io-index" 364 - checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 365 - 366 - [[package]] 367 - name = "android_system_properties" 368 - version = "0.1.5" 369 - source = "registry+https://github.com/rust-lang/crates.io-index" 370 - checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 371 - dependencies = [ 372 - "libc", 373 - ] 374 - 375 - [[package]] 376 - name = "anstream" 377 - version = "0.6.18" 378 - source = "registry+https://github.com/rust-lang/crates.io-index" 379 - checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 380 - dependencies = [ 381 - "anstyle", 382 - "anstyle-parse", 383 - "anstyle-query", 384 - "anstyle-wincon", 385 - "colorchoice", 386 - "is_terminal_polyfill", 387 - "utf8parse", 388 - ] 389 - 390 - [[package]] 391 - name = "anstyle" 392 - version = "1.0.10" 393 - source = "registry+https://github.com/rust-lang/crates.io-index" 394 - checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 395 - 396 - [[package]] 397 - name = "anstyle-parse" 398 - version = "0.2.6" 399 - source = "registry+https://github.com/rust-lang/crates.io-index" 400 - checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 401 - dependencies = [ 402 - "utf8parse", 403 - ] 404 - 405 - [[package]] 406 - name = "anstyle-query" 407 - version = "1.1.2" 408 - source = "registry+https://github.com/rust-lang/crates.io-index" 409 - checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 410 - dependencies = [ 411 - "windows-sys 0.59.0", 412 - ] 413 - 414 - [[package]] 415 - name = "anstyle-wincon" 416 - version = "3.0.7" 417 - source = "registry+https://github.com/rust-lang/crates.io-index" 418 - checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 419 - dependencies = [ 420 - "anstyle", 421 - "once_cell", 422 - "windows-sys 0.59.0", 423 - ] 424 - 425 - [[package]] 426 - name = "anyhow" 427 - version = "1.0.97" 428 - source = "registry+https://github.com/rust-lang/crates.io-index" 429 - checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" 430 - 431 - [[package]] 432 - name = "askama" 433 - version = "0.13.0" 434 - source = "registry+https://github.com/rust-lang/crates.io-index" 435 - checksum = "9a4e46abb203e00ef226442d452769233142bbfdd79c3941e84c8e61c4112543" 436 - dependencies = [ 437 - "askama_derive", 438 - "itoa", 439 - "percent-encoding", 440 - "serde", 441 - "serde_json", 442 - ] 443 - 444 - [[package]] 445 - name = "askama_derive" 446 - version = "0.13.0" 447 - source = "registry+https://github.com/rust-lang/crates.io-index" 448 - checksum = "54398906821fd32c728135f7b351f0c7494ab95ae421d41b6f5a020e158f28a6" 449 - dependencies = [ 450 - "askama_parser", 451 - "basic-toml", 452 - "memchr", 453 - "proc-macro2", 454 - "quote", 455 - "rustc-hash", 456 - "serde", 457 - "serde_derive", 458 - "syn", 459 - ] 460 - 461 - [[package]] 462 - name = "askama_parser" 463 - version = "0.13.0" 464 - source = "registry+https://github.com/rust-lang/crates.io-index" 465 - checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" 466 - dependencies = [ 467 - "memchr", 468 - "serde", 469 - "serde_derive", 470 - "winnow", 471 - ] 472 - 473 - [[package]] 474 - name = "async-compression" 475 - version = "0.4.20" 476 - source = "registry+https://github.com/rust-lang/crates.io-index" 477 - checksum = "310c9bcae737a48ef5cdee3174184e6d548b292739ede61a1f955ef76a738861" 478 - dependencies = [ 479 - "flate2", 480 - "futures-core", 481 - "memchr", 482 - "pin-project-lite", 483 - "tokio", 484 - ] 485 - 486 - [[package]] 487 - name = "async-lock" 488 - version = "3.4.0" 489 - source = "registry+https://github.com/rust-lang/crates.io-index" 490 - checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" 491 - dependencies = [ 492 - "event-listener", 493 - "event-listener-strategy", 494 - "pin-project-lite", 495 - ] 496 - 497 - [[package]] 498 - name = "async-sqlite" 499 - version = "0.5.0" 500 - source = "registry+https://github.com/rust-lang/crates.io-index" 501 - checksum = "60659f08ccb3a20c15af150ae736cde366fa0657246be9d194affb0149be188f" 502 - dependencies = [ 503 - "crossbeam-channel", 504 - "futures-channel", 505 - "futures-util", 506 - "rusqlite", 507 - ] 508 - 509 - [[package]] 510 - name = "async-trait" 511 - version = "0.1.88" 512 - source = "registry+https://github.com/rust-lang/crates.io-index" 513 - checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" 514 - dependencies = [ 515 - "proc-macro2", 516 - "quote", 517 - "syn", 518 - ] 519 - 520 - [[package]] 521 - name = "atomic-waker" 522 - version = "1.1.2" 523 - source = "registry+https://github.com/rust-lang/crates.io-index" 524 - checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 525 - 526 - [[package]] 527 - name = "atrium-api" 528 - version = "0.25.2" 529 - source = "registry+https://github.com/rust-lang/crates.io-index" 530 - checksum = "0d4eb9b4787aba546015c8ccda1d3924c157cee13d67848997fba74ac8144a07" 531 - dependencies = [ 532 - "atrium-common", 533 - "atrium-xrpc", 534 - "chrono", 535 - "http 1.2.0", 536 - "ipld-core", 537 - "langtag", 538 - "regex", 539 - "serde", 540 - "serde_bytes", 541 - "serde_json", 542 - "thiserror", 543 - "tokio", 544 - "trait-variant", 545 - ] 546 - 547 - [[package]] 548 - name = "atrium-common" 549 - version = "0.1.1" 550 - source = "registry+https://github.com/rust-lang/crates.io-index" 551 - checksum = "ba30d2f9e1a8b3db8fc97d0a5f91ee5a28f8acdddb771ad74c1b08eda357ca3d" 552 - dependencies = [ 553 - "dashmap", 554 - "lru", 555 - "moka", 556 - "thiserror", 557 - "tokio", 558 - "trait-variant", 559 - "web-time", 560 - ] 561 - 562 - [[package]] 563 - name = "atrium-identity" 564 - version = "0.1.3" 565 - source = "registry+https://github.com/rust-lang/crates.io-index" 566 - checksum = "007c7fdb0e026c7d01697b78263b2d85742b5113fbc5263f8885280cacceca05" 567 - dependencies = [ 568 - "atrium-api", 569 - "atrium-common", 570 - "atrium-xrpc", 571 - "serde", 572 - "serde_html_form", 573 - "serde_json", 574 - "thiserror", 575 - "trait-variant", 576 - ] 577 - 578 - [[package]] 579 - name = "atrium-oauth" 580 - version = "0.1.1" 581 - source = "registry+https://github.com/rust-lang/crates.io-index" 582 - checksum = "24e59e30ae1aa9bbb99ebf2fa5ca40a8ca6665b6b7e4d1de322d99544045e91e" 583 - dependencies = [ 584 - "atrium-api", 585 - "atrium-common", 586 - "atrium-identity", 587 - "atrium-xrpc", 588 - "base64 0.22.1", 589 - "chrono", 590 - "dashmap", 591 - "ecdsa", 592 - "elliptic-curve", 593 - "jose-jwa", 594 - "jose-jwk", 595 - "p256", 596 - "rand 0.8.5", 597 - "reqwest", 598 - "serde", 599 - "serde_html_form", 600 - "serde_json", 601 - "sha2", 602 - "thiserror", 603 - "tokio", 604 - "trait-variant", 605 - ] 606 - 607 - [[package]] 608 - name = "atrium-xrpc" 609 - version = "0.12.2" 610 - source = "registry+https://github.com/rust-lang/crates.io-index" 611 - checksum = "18a9e526cb2ed3e0a2ca78c3ce2a943d9041a68e067dadf42923b523771e07df" 612 - dependencies = [ 613 - "http 1.2.0", 614 - "serde", 615 - "serde_html_form", 616 - "serde_json", 617 - "thiserror", 618 - "trait-variant", 619 - ] 620 - 621 - [[package]] 622 - name = "autocfg" 623 - version = "1.4.0" 624 - source = "registry+https://github.com/rust-lang/crates.io-index" 625 - checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 626 - 627 - [[package]] 628 - name = "backtrace" 629 - version = "0.3.74" 630 - source = "registry+https://github.com/rust-lang/crates.io-index" 631 - checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 632 - dependencies = [ 633 - "addr2line", 634 - "cfg-if", 635 - "libc", 636 - "miniz_oxide", 637 - "object", 638 - "rustc-demangle", 639 - "windows-targets 0.52.6", 640 - ] 641 - 642 - [[package]] 643 - name = "base-x" 644 - version = "0.2.11" 645 - source = "registry+https://github.com/rust-lang/crates.io-index" 646 - checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 647 - 648 - [[package]] 649 - name = "base16ct" 650 - version = "0.2.0" 651 - source = "registry+https://github.com/rust-lang/crates.io-index" 652 - checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 653 - 654 - [[package]] 655 - name = "base64" 656 - version = "0.20.0" 657 - source = "registry+https://github.com/rust-lang/crates.io-index" 658 - checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" 659 - 660 - [[package]] 661 - name = "base64" 662 - version = "0.21.7" 663 - source = "registry+https://github.com/rust-lang/crates.io-index" 664 - checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 665 - 666 - [[package]] 667 - name = "base64" 668 - version = "0.22.1" 669 - source = "registry+https://github.com/rust-lang/crates.io-index" 670 - checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 671 - 672 - [[package]] 673 - name = "base64ct" 674 - version = "1.7.3" 675 - source = "registry+https://github.com/rust-lang/crates.io-index" 676 - checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" 677 - 678 - [[package]] 679 - name = "basic-toml" 680 - version = "0.1.10" 681 - source = "registry+https://github.com/rust-lang/crates.io-index" 682 - checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" 683 - dependencies = [ 684 - "serde", 685 - ] 686 - 687 - [[package]] 688 - name = "bitflags" 689 - version = "2.9.0" 690 - source = "registry+https://github.com/rust-lang/crates.io-index" 691 - checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 692 - 693 - [[package]] 694 - name = "block-buffer" 695 - version = "0.10.4" 696 - source = "registry+https://github.com/rust-lang/crates.io-index" 697 - checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 698 - dependencies = [ 699 - "generic-array", 700 - ] 701 - 702 - [[package]] 703 - name = "bon" 704 - version = "3.5.1" 705 - source = "registry+https://github.com/rust-lang/crates.io-index" 706 - checksum = "65268237be94042665b92034f979c42d431d2fd998b49809543afe3e66abad1c" 707 - dependencies = [ 708 - "bon-macros", 709 - "rustversion", 710 - ] 711 - 712 - [[package]] 713 - name = "bon-macros" 714 - version = "3.5.1" 715 - source = "registry+https://github.com/rust-lang/crates.io-index" 716 - checksum = "803c95b2ecf650eb10b5f87dda6b9f6a1b758cee53245e2b7b825c9b3803a443" 717 - dependencies = [ 718 - "darling", 719 - "ident_case", 720 - "prettyplease", 721 - "proc-macro2", 722 - "quote", 723 - "rustversion", 724 - "syn", 725 - ] 726 - 727 - [[package]] 728 - name = "brotli" 729 - version = "7.0.0" 730 - source = "registry+https://github.com/rust-lang/crates.io-index" 731 - checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" 732 - dependencies = [ 733 - "alloc-no-stdlib", 734 - "alloc-stdlib", 735 - "brotli-decompressor", 736 - ] 737 - 738 - [[package]] 739 - name = "brotli-decompressor" 740 - version = "4.0.2" 741 - source = "registry+https://github.com/rust-lang/crates.io-index" 742 - checksum = "74fa05ad7d803d413eb8380983b092cbbaf9a85f151b871360e7b00cd7060b37" 743 - dependencies = [ 744 - "alloc-no-stdlib", 745 - "alloc-stdlib", 746 - ] 747 - 748 - [[package]] 749 - name = "bumpalo" 750 - version = "3.17.0" 751 - source = "registry+https://github.com/rust-lang/crates.io-index" 752 - checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 753 - 754 - [[package]] 755 - name = "byteorder" 756 - version = "1.5.0" 757 - source = "registry+https://github.com/rust-lang/crates.io-index" 758 - checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 759 - 760 - [[package]] 761 - name = "bytes" 762 - version = "1.10.1" 763 - source = "registry+https://github.com/rust-lang/crates.io-index" 764 - checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 765 - 766 - [[package]] 767 - name = "bytestring" 768 - version = "1.4.0" 769 - source = "registry+https://github.com/rust-lang/crates.io-index" 770 - checksum = "e465647ae23b2823b0753f50decb2d5a86d2bb2cac04788fafd1f80e45378e5f" 771 - dependencies = [ 772 - "bytes", 773 - ] 774 - 775 - [[package]] 776 - name = "cc" 777 - version = "1.2.16" 778 - source = "registry+https://github.com/rust-lang/crates.io-index" 779 - checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" 780 - dependencies = [ 781 - "jobserver", 782 - "libc", 783 - "shlex", 784 - ] 785 - 786 - [[package]] 787 - name = "cfg-if" 788 - version = "1.0.0" 789 - source = "registry+https://github.com/rust-lang/crates.io-index" 790 - checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 791 - 792 - [[package]] 793 - name = "chrono" 794 - version = "0.4.40" 795 - source = "registry+https://github.com/rust-lang/crates.io-index" 796 - checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" 797 - dependencies = [ 798 - "android-tzdata", 799 - "iana-time-zone", 800 - "js-sys", 801 - "num-traits", 802 - "serde", 803 - "wasm-bindgen", 804 - "windows-link", 805 - ] 806 - 807 - [[package]] 808 - name = "cid" 809 - version = "0.11.1" 810 - source = "registry+https://github.com/rust-lang/crates.io-index" 811 - checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" 812 - dependencies = [ 813 - "core2", 814 - "multibase", 815 - "multihash", 816 - "serde", 817 - "serde_bytes", 818 - "unsigned-varint", 819 - ] 820 - 821 - [[package]] 822 - name = "cipher" 823 - version = "0.4.4" 824 - source = "registry+https://github.com/rust-lang/crates.io-index" 825 - checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" 826 - dependencies = [ 827 - "crypto-common", 828 - "inout", 829 - ] 830 - 831 - [[package]] 832 - name = "colorchoice" 833 - version = "1.0.3" 834 - source = "registry+https://github.com/rust-lang/crates.io-index" 835 - checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 836 - 837 - [[package]] 838 - name = "concurrent-queue" 839 - version = "2.5.0" 840 - source = "registry+https://github.com/rust-lang/crates.io-index" 841 - checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 842 - dependencies = [ 843 - "crossbeam-utils", 844 - ] 845 - 846 - [[package]] 847 - name = "const-oid" 848 - version = "0.9.6" 849 - source = "registry+https://github.com/rust-lang/crates.io-index" 850 - checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 851 - 852 - [[package]] 853 - name = "convert_case" 854 - version = "0.4.0" 855 - source = "registry+https://github.com/rust-lang/crates.io-index" 856 - checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 857 - 858 - [[package]] 859 - name = "cookie" 860 - version = "0.16.2" 861 - source = "registry+https://github.com/rust-lang/crates.io-index" 862 - checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" 863 - dependencies = [ 864 - "aes-gcm", 865 - "base64 0.20.0", 866 - "hkdf", 867 - "hmac", 868 - "percent-encoding", 869 - "rand 0.8.5", 870 - "sha2", 871 - "subtle", 872 - "time", 873 - "version_check", 874 - ] 875 - 876 - [[package]] 877 - name = "core-foundation" 878 - version = "0.9.4" 879 - source = "registry+https://github.com/rust-lang/crates.io-index" 880 - checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 881 - dependencies = [ 882 - "core-foundation-sys", 883 - "libc", 884 - ] 885 - 886 - [[package]] 887 - name = "core-foundation-sys" 888 - version = "0.8.7" 889 - source = "registry+https://github.com/rust-lang/crates.io-index" 890 - checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 891 - 892 - [[package]] 893 - name = "core2" 894 - version = "0.4.0" 895 - source = "registry+https://github.com/rust-lang/crates.io-index" 896 - checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 897 - dependencies = [ 898 - "memchr", 899 - ] 900 - 901 - [[package]] 902 - name = "cpufeatures" 903 - version = "0.2.17" 904 - source = "registry+https://github.com/rust-lang/crates.io-index" 905 - checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 906 - dependencies = [ 907 - "libc", 908 - ] 909 - 910 - [[package]] 911 - name = "crc32fast" 912 - version = "1.4.2" 913 - source = "registry+https://github.com/rust-lang/crates.io-index" 914 - checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" 915 - dependencies = [ 916 - "cfg-if", 917 - ] 918 - 919 - [[package]] 920 - name = "crossbeam-channel" 921 - version = "0.5.14" 922 - source = "registry+https://github.com/rust-lang/crates.io-index" 923 - checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" 924 - dependencies = [ 925 - "crossbeam-utils", 926 - ] 927 - 928 - [[package]] 929 - name = "crossbeam-epoch" 930 - version = "0.9.18" 931 - source = "registry+https://github.com/rust-lang/crates.io-index" 932 - checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 933 - dependencies = [ 934 - "crossbeam-utils", 935 - ] 936 - 937 - [[package]] 938 - name = "crossbeam-utils" 939 - version = "0.8.21" 940 - source = "registry+https://github.com/rust-lang/crates.io-index" 941 - checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 942 - 943 - [[package]] 944 - name = "crypto-bigint" 945 - version = "0.5.5" 946 - source = "registry+https://github.com/rust-lang/crates.io-index" 947 - checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 948 - dependencies = [ 949 - "generic-array", 950 - "rand_core 0.6.4", 951 - "subtle", 952 - "zeroize", 953 - ] 954 - 955 - [[package]] 956 - name = "crypto-common" 957 - version = "0.1.6" 958 - source = "registry+https://github.com/rust-lang/crates.io-index" 959 - checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 960 - dependencies = [ 961 - "generic-array", 962 - "rand_core 0.6.4", 963 - "typenum", 964 - ] 965 - 966 - [[package]] 967 - name = "ctr" 968 - version = "0.9.2" 969 - source = "registry+https://github.com/rust-lang/crates.io-index" 970 - checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" 971 - dependencies = [ 972 - "cipher", 973 - ] 974 - 975 - [[package]] 976 - name = "darling" 977 - version = "0.20.11" 978 - source = "registry+https://github.com/rust-lang/crates.io-index" 979 - checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 980 - dependencies = [ 981 - "darling_core", 982 - "darling_macro", 983 - ] 984 - 985 - [[package]] 986 - name = "darling_core" 987 - version = "0.20.11" 988 - source = "registry+https://github.com/rust-lang/crates.io-index" 989 - checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 990 - dependencies = [ 991 - "fnv", 992 - "ident_case", 993 - "proc-macro2", 994 - "quote", 995 - "strsim", 996 - "syn", 997 - ] 998 - 999 - [[package]] 1000 - name = "darling_macro" 1001 - version = "0.20.11" 1002 - source = "registry+https://github.com/rust-lang/crates.io-index" 1003 - checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 1004 - dependencies = [ 1005 - "darling_core", 1006 - "quote", 1007 - "syn", 1008 - ] 1009 - 1010 - [[package]] 1011 - name = "dashmap" 1012 - version = "6.1.0" 1013 - source = "registry+https://github.com/rust-lang/crates.io-index" 1014 - checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 1015 - dependencies = [ 1016 - "cfg-if", 1017 - "crossbeam-utils", 1018 - "hashbrown 0.14.5", 1019 - "lock_api", 1020 - "once_cell", 1021 - "parking_lot_core", 1022 - ] 1023 - 1024 - [[package]] 1025 - name = "data-encoding" 1026 - version = "2.8.0" 1027 - source = "registry+https://github.com/rust-lang/crates.io-index" 1028 - checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010" 1029 - 1030 - [[package]] 1031 - name = "data-encoding-macro" 1032 - version = "0.1.17" 1033 - source = "registry+https://github.com/rust-lang/crates.io-index" 1034 - checksum = "9f9724adfcf41f45bf652b3995837669d73c4d49a1b5ac1ff82905ac7d9b5558" 1035 - dependencies = [ 1036 - "data-encoding", 1037 - "data-encoding-macro-internal", 1038 - ] 1039 - 1040 - [[package]] 1041 - name = "data-encoding-macro-internal" 1042 - version = "0.1.15" 1043 - source = "registry+https://github.com/rust-lang/crates.io-index" 1044 - checksum = "18e4fdb82bd54a12e42fb58a800dcae6b9e13982238ce2296dc3570b92148e1f" 1045 - dependencies = [ 1046 - "data-encoding", 1047 - "syn", 1048 - ] 1049 - 1050 - [[package]] 1051 - name = "der" 1052 - version = "0.7.9" 1053 - source = "registry+https://github.com/rust-lang/crates.io-index" 1054 - checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" 1055 - dependencies = [ 1056 - "const-oid", 1057 - "zeroize", 1058 - ] 1059 - 1060 - [[package]] 1061 - name = "deranged" 1062 - version = "0.3.11" 1063 - source = "registry+https://github.com/rust-lang/crates.io-index" 1064 - checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 1065 - dependencies = [ 1066 - "powerfmt", 1067 - ] 1068 - 1069 - [[package]] 1070 - name = "derive_builder" 1071 - version = "0.20.2" 1072 - source = "registry+https://github.com/rust-lang/crates.io-index" 1073 - checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" 1074 - dependencies = [ 1075 - "derive_builder_macro", 1076 - ] 1077 - 1078 - [[package]] 1079 - name = "derive_builder_core" 1080 - version = "0.20.2" 1081 - source = "registry+https://github.com/rust-lang/crates.io-index" 1082 - checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" 1083 - dependencies = [ 1084 - "darling", 1085 - "proc-macro2", 1086 - "quote", 1087 - "syn", 1088 - ] 1089 - 1090 - [[package]] 1091 - name = "derive_builder_macro" 1092 - version = "0.20.2" 1093 - source = "registry+https://github.com/rust-lang/crates.io-index" 1094 - checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" 1095 - dependencies = [ 1096 - "derive_builder_core", 1097 - "syn", 1098 - ] 1099 - 1100 - [[package]] 1101 - name = "derive_more" 1102 - version = "0.99.19" 1103 - source = "registry+https://github.com/rust-lang/crates.io-index" 1104 - checksum = "3da29a38df43d6f156149c9b43ded5e018ddff2a855cf2cfd62e8cd7d079c69f" 1105 - dependencies = [ 1106 - "convert_case", 1107 - "proc-macro2", 1108 - "quote", 1109 - "rustc_version", 1110 - "syn", 1111 - ] 1112 - 1113 - [[package]] 1114 - name = "derive_more" 1115 - version = "1.0.0" 1116 - source = "registry+https://github.com/rust-lang/crates.io-index" 1117 - checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" 1118 - dependencies = [ 1119 - "derive_more-impl 1.0.0", 1120 - ] 1121 - 1122 - [[package]] 1123 - name = "derive_more" 1124 - version = "2.0.1" 1125 - source = "registry+https://github.com/rust-lang/crates.io-index" 1126 - checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" 1127 - dependencies = [ 1128 - "derive_more-impl 2.0.1", 1129 - ] 1130 - 1131 - [[package]] 1132 - name = "derive_more-impl" 1133 - version = "1.0.0" 1134 - source = "registry+https://github.com/rust-lang/crates.io-index" 1135 - checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" 1136 - dependencies = [ 1137 - "proc-macro2", 1138 - "quote", 1139 - "syn", 1140 - "unicode-xid", 1141 - ] 1142 - 1143 - [[package]] 1144 - name = "derive_more-impl" 1145 - version = "2.0.1" 1146 - source = "registry+https://github.com/rust-lang/crates.io-index" 1147 - checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" 1148 - dependencies = [ 1149 - "proc-macro2", 1150 - "quote", 1151 - "syn", 1152 - "unicode-xid", 1153 - ] 1154 - 1155 - [[package]] 1156 - name = "digest" 1157 - version = "0.10.7" 1158 - source = "registry+https://github.com/rust-lang/crates.io-index" 1159 - checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 1160 - dependencies = [ 1161 - "block-buffer", 1162 - "const-oid", 1163 - "crypto-common", 1164 - "subtle", 1165 - ] 1166 - 1167 - [[package]] 1168 - name = "displaydoc" 1169 - version = "0.2.5" 1170 - source = "registry+https://github.com/rust-lang/crates.io-index" 1171 - checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 1172 - dependencies = [ 1173 - "proc-macro2", 1174 - "quote", 1175 - "syn", 1176 - ] 1177 - 1178 - [[package]] 1179 - name = "dotenv" 1180 - version = "0.15.0" 1181 - source = "registry+https://github.com/rust-lang/crates.io-index" 1182 - checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" 1183 - 1184 - [[package]] 1185 - name = "ecdsa" 1186 - version = "0.16.9" 1187 - source = "registry+https://github.com/rust-lang/crates.io-index" 1188 - checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 1189 - dependencies = [ 1190 - "der", 1191 - "digest", 1192 - "elliptic-curve", 1193 - "rfc6979", 1194 - "signature", 1195 - ] 1196 - 1197 - [[package]] 1198 - name = "elliptic-curve" 1199 - version = "0.13.8" 1200 - source = "registry+https://github.com/rust-lang/crates.io-index" 1201 - checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 1202 - dependencies = [ 1203 - "base16ct", 1204 - "crypto-bigint", 1205 - "digest", 1206 - "ff", 1207 - "generic-array", 1208 - "group", 1209 - "rand_core 0.6.4", 1210 - "sec1", 1211 - "subtle", 1212 - "zeroize", 1213 - ] 1214 - 1215 - [[package]] 1216 - name = "encoding_rs" 1217 - version = "0.8.35" 1218 - source = "registry+https://github.com/rust-lang/crates.io-index" 1219 - checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 1220 - dependencies = [ 1221 - "cfg-if", 1222 - ] 1223 - 1224 - [[package]] 1225 - name = "enum-as-inner" 1226 - version = "0.6.1" 1227 - source = "registry+https://github.com/rust-lang/crates.io-index" 1228 - checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 1229 - dependencies = [ 1230 - "heck", 1231 - "proc-macro2", 1232 - "quote", 1233 - "syn", 1234 - ] 1235 - 1236 - [[package]] 1237 - name = "env_filter" 1238 - version = "0.1.3" 1239 - source = "registry+https://github.com/rust-lang/crates.io-index" 1240 - checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" 1241 - dependencies = [ 1242 - "log", 1243 - "regex", 1244 - ] 1245 - 1246 - [[package]] 1247 - name = "env_logger" 1248 - version = "0.11.7" 1249 - source = "registry+https://github.com/rust-lang/crates.io-index" 1250 - checksum = "c3716d7a920fb4fac5d84e9d4bce8ceb321e9414b4409da61b07b75c1e3d0697" 1251 - dependencies = [ 1252 - "anstream", 1253 - "anstyle", 1254 - "env_filter", 1255 - "jiff", 1256 - "log", 1257 - ] 1258 - 1259 - [[package]] 1260 - name = "equivalent" 1261 - version = "1.0.2" 1262 - source = "registry+https://github.com/rust-lang/crates.io-index" 1263 - checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 1264 - 1265 - [[package]] 1266 - name = "errno" 1267 - version = "0.3.10" 1268 - source = "registry+https://github.com/rust-lang/crates.io-index" 1269 - checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 1270 - dependencies = [ 1271 - "libc", 1272 - "windows-sys 0.59.0", 1273 - ] 1274 - 1275 - [[package]] 1276 - name = "event-listener" 1277 - version = "5.4.0" 1278 - source = "registry+https://github.com/rust-lang/crates.io-index" 1279 - checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" 1280 - dependencies = [ 1281 - "concurrent-queue", 1282 - "parking", 1283 - "pin-project-lite", 1284 - ] 1285 - 1286 - [[package]] 1287 - name = "event-listener-strategy" 1288 - version = "0.5.3" 1289 - source = "registry+https://github.com/rust-lang/crates.io-index" 1290 - checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" 1291 - dependencies = [ 1292 - "event-listener", 1293 - "pin-project-lite", 1294 - ] 1295 - 1296 - [[package]] 1297 - name = "fallible-iterator" 1298 - version = "0.3.0" 1299 - source = "registry+https://github.com/rust-lang/crates.io-index" 1300 - checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" 1301 - 1302 - [[package]] 1303 - name = "fallible-streaming-iterator" 1304 - version = "0.1.9" 1305 - source = "registry+https://github.com/rust-lang/crates.io-index" 1306 - checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 1307 - 1308 - [[package]] 1309 - name = "fastrand" 1310 - version = "2.3.0" 1311 - source = "registry+https://github.com/rust-lang/crates.io-index" 1312 - checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 1313 - 1314 - [[package]] 1315 - name = "ff" 1316 - version = "0.13.1" 1317 - source = "registry+https://github.com/rust-lang/crates.io-index" 1318 - checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 1319 - dependencies = [ 1320 - "rand_core 0.6.4", 1321 - "subtle", 1322 - ] 1323 - 1324 - [[package]] 1325 - name = "flate2" 1326 - version = "1.1.0" 1327 - source = "registry+https://github.com/rust-lang/crates.io-index" 1328 - checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" 1329 - dependencies = [ 1330 - "crc32fast", 1331 - "miniz_oxide", 1332 - ] 1333 - 1334 - [[package]] 1335 - name = "flume" 1336 - version = "0.11.1" 1337 - source = "registry+https://github.com/rust-lang/crates.io-index" 1338 - checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" 1339 - dependencies = [ 1340 - "futures-core", 1341 - "futures-sink", 1342 - "nanorand", 1343 - "spin", 1344 - ] 1345 - 1346 - [[package]] 1347 - name = "fnv" 1348 - version = "1.0.7" 1349 - source = "registry+https://github.com/rust-lang/crates.io-index" 1350 - checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 1351 - 1352 - [[package]] 1353 - name = "foldhash" 1354 - version = "0.1.4" 1355 - source = "registry+https://github.com/rust-lang/crates.io-index" 1356 - checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" 1357 - 1358 - [[package]] 1359 - name = "foreign-types" 1360 - version = "0.3.2" 1361 - source = "registry+https://github.com/rust-lang/crates.io-index" 1362 - checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" 1363 - dependencies = [ 1364 - "foreign-types-shared", 1365 - ] 1366 - 1367 - [[package]] 1368 - name = "foreign-types-shared" 1369 - version = "0.1.1" 1370 - source = "registry+https://github.com/rust-lang/crates.io-index" 1371 - checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" 1372 - 1373 - [[package]] 1374 - name = "form_urlencoded" 1375 - version = "1.2.1" 1376 - source = "registry+https://github.com/rust-lang/crates.io-index" 1377 - checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 1378 - dependencies = [ 1379 - "percent-encoding", 1380 - ] 1381 - 1382 - [[package]] 1383 - name = "futures-channel" 1384 - version = "0.3.31" 1385 - source = "registry+https://github.com/rust-lang/crates.io-index" 1386 - checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 1387 - dependencies = [ 1388 - "futures-core", 1389 - ] 1390 - 1391 - [[package]] 1392 - name = "futures-core" 1393 - version = "0.3.31" 1394 - source = "registry+https://github.com/rust-lang/crates.io-index" 1395 - checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 1396 - 1397 - [[package]] 1398 - name = "futures-io" 1399 - version = "0.3.31" 1400 - source = "registry+https://github.com/rust-lang/crates.io-index" 1401 - checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 1402 - 1403 - [[package]] 1404 - name = "futures-macro" 1405 - version = "0.3.31" 1406 - source = "registry+https://github.com/rust-lang/crates.io-index" 1407 - checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 1408 - dependencies = [ 1409 - "proc-macro2", 1410 - "quote", 1411 - "syn", 1412 - ] 1413 - 1414 - [[package]] 1415 - name = "futures-sink" 1416 - version = "0.3.31" 1417 - source = "registry+https://github.com/rust-lang/crates.io-index" 1418 - checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 1419 - 1420 - [[package]] 1421 - name = "futures-task" 1422 - version = "0.3.31" 1423 - source = "registry+https://github.com/rust-lang/crates.io-index" 1424 - checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 1425 - 1426 - [[package]] 1427 - name = "futures-util" 1428 - version = "0.3.31" 1429 - source = "registry+https://github.com/rust-lang/crates.io-index" 1430 - checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 1431 - dependencies = [ 1432 - "futures-core", 1433 - "futures-macro", 1434 - "futures-sink", 1435 - "futures-task", 1436 - "pin-project-lite", 1437 - "pin-utils", 1438 - "slab", 1439 - ] 1440 - 1441 - [[package]] 1442 - name = "generator" 1443 - version = "0.8.4" 1444 - source = "registry+https://github.com/rust-lang/crates.io-index" 1445 - checksum = "cc6bd114ceda131d3b1d665eba35788690ad37f5916457286b32ab6fd3c438dd" 1446 - dependencies = [ 1447 - "cfg-if", 1448 - "libc", 1449 - "log", 1450 - "rustversion", 1451 - "windows 0.58.0", 1452 - ] 1453 - 1454 - [[package]] 1455 - name = "generic-array" 1456 - version = "0.14.7" 1457 - source = "registry+https://github.com/rust-lang/crates.io-index" 1458 - checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 1459 - dependencies = [ 1460 - "typenum", 1461 - "version_check", 1462 - "zeroize", 1463 - ] 1464 - 1465 - [[package]] 1466 - name = "getrandom" 1467 - version = "0.2.15" 1468 - source = "registry+https://github.com/rust-lang/crates.io-index" 1469 - checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 1470 - dependencies = [ 1471 - "cfg-if", 1472 - "js-sys", 1473 - "libc", 1474 - "wasi 0.11.0+wasi-snapshot-preview1", 1475 - "wasm-bindgen", 1476 - ] 1477 - 1478 - [[package]] 1479 - name = "getrandom" 1480 - version = "0.3.1" 1481 - source = "registry+https://github.com/rust-lang/crates.io-index" 1482 - checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" 1483 - dependencies = [ 1484 - "cfg-if", 1485 - "libc", 1486 - "wasi 0.13.3+wasi-0.2.2", 1487 - "windows-targets 0.52.6", 1488 - ] 1489 - 1490 - [[package]] 1491 - name = "ghash" 1492 - version = "0.5.1" 1493 - source = "registry+https://github.com/rust-lang/crates.io-index" 1494 - checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" 1495 - dependencies = [ 1496 - "opaque-debug", 1497 - "polyval", 1498 - ] 1499 - 1500 - [[package]] 1501 - name = "gimli" 1502 - version = "0.31.1" 1503 - source = "registry+https://github.com/rust-lang/crates.io-index" 1504 - checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 1505 - 1506 - [[package]] 1507 - name = "group" 1508 - version = "0.13.0" 1509 - source = "registry+https://github.com/rust-lang/crates.io-index" 1510 - checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 1511 - dependencies = [ 1512 - "ff", 1513 - "rand_core 0.6.4", 1514 - "subtle", 1515 - ] 1516 - 1517 - [[package]] 1518 - name = "h2" 1519 - version = "0.3.26" 1520 - source = "registry+https://github.com/rust-lang/crates.io-index" 1521 - checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" 1522 - dependencies = [ 1523 - "bytes", 1524 - "fnv", 1525 - "futures-core", 1526 - "futures-sink", 1527 - "futures-util", 1528 - "http 0.2.12", 1529 - "indexmap", 1530 - "slab", 1531 - "tokio", 1532 - "tokio-util", 1533 - "tracing", 1534 - ] 1535 - 1536 - [[package]] 1537 - name = "h2" 1538 - version = "0.4.12" 1539 - source = "registry+https://github.com/rust-lang/crates.io-index" 1540 - checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" 1541 - dependencies = [ 1542 - "atomic-waker", 1543 - "bytes", 1544 - "fnv", 1545 - "futures-core", 1546 - "futures-sink", 1547 - "http 1.2.0", 1548 - "indexmap", 1549 - "slab", 1550 - "tokio", 1551 - "tokio-util", 1552 - "tracing", 1553 - ] 1554 - 1555 - [[package]] 1556 - name = "hashbrown" 1557 - version = "0.14.5" 1558 - source = "registry+https://github.com/rust-lang/crates.io-index" 1559 - checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1560 - 1561 - [[package]] 1562 - name = "hashbrown" 1563 - version = "0.15.2" 1564 - source = "registry+https://github.com/rust-lang/crates.io-index" 1565 - checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 1566 - dependencies = [ 1567 - "allocator-api2", 1568 - "equivalent", 1569 - "foldhash", 1570 - ] 1571 - 1572 - [[package]] 1573 - name = "hashlink" 1574 - version = "0.10.0" 1575 - source = "registry+https://github.com/rust-lang/crates.io-index" 1576 - checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" 1577 - dependencies = [ 1578 - "hashbrown 0.15.2", 1579 - ] 1580 - 1581 - [[package]] 1582 - name = "heck" 1583 - version = "0.5.0" 1584 - source = "registry+https://github.com/rust-lang/crates.io-index" 1585 - checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1586 - 1587 - [[package]] 1588 - name = "hex" 1589 - version = "0.4.3" 1590 - source = "registry+https://github.com/rust-lang/crates.io-index" 1591 - checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 1592 - 1593 - [[package]] 1594 - name = "hickory-proto" 1595 - version = "0.24.4" 1596 - source = "registry+https://github.com/rust-lang/crates.io-index" 1597 - checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" 1598 - dependencies = [ 1599 - "async-trait", 1600 - "cfg-if", 1601 - "data-encoding", 1602 - "enum-as-inner", 1603 - "futures-channel", 1604 - "futures-io", 1605 - "futures-util", 1606 - "idna", 1607 - "ipnet", 1608 - "once_cell", 1609 - "rand 0.8.5", 1610 - "thiserror", 1611 - "tinyvec", 1612 - "tokio", 1613 - "tracing", 1614 - "url", 1615 - ] 1616 - 1617 - [[package]] 1618 - name = "hickory-resolver" 1619 - version = "0.24.4" 1620 - source = "registry+https://github.com/rust-lang/crates.io-index" 1621 - checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" 1622 - dependencies = [ 1623 - "cfg-if", 1624 - "futures-util", 1625 - "hickory-proto", 1626 - "ipconfig", 1627 - "lru-cache", 1628 - "once_cell", 1629 - "parking_lot", 1630 - "rand 0.8.5", 1631 - "resolv-conf", 1632 - "smallvec", 1633 - "thiserror", 1634 - "tokio", 1635 - "tracing", 1636 - ] 1637 - 1638 - [[package]] 1639 - name = "hkdf" 1640 - version = "0.12.4" 1641 - source = "registry+https://github.com/rust-lang/crates.io-index" 1642 - checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 1643 - dependencies = [ 1644 - "hmac", 1645 - ] 1646 - 1647 - [[package]] 1648 - name = "hmac" 1649 - version = "0.12.1" 1650 - source = "registry+https://github.com/rust-lang/crates.io-index" 1651 - checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1652 - dependencies = [ 1653 - "digest", 1654 - ] 1655 - 1656 - [[package]] 1657 - name = "hostname" 1658 - version = "0.4.0" 1659 - source = "registry+https://github.com/rust-lang/crates.io-index" 1660 - checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" 1661 - dependencies = [ 1662 - "cfg-if", 1663 - "libc", 1664 - "windows 0.52.0", 1665 - ] 1666 - 1667 - [[package]] 1668 - name = "http" 1669 - version = "0.2.12" 1670 - source = "registry+https://github.com/rust-lang/crates.io-index" 1671 - checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" 1672 - dependencies = [ 1673 - "bytes", 1674 - "fnv", 1675 - "itoa", 1676 - ] 1677 - 1678 - [[package]] 1679 - name = "http" 1680 - version = "1.2.0" 1681 - source = "registry+https://github.com/rust-lang/crates.io-index" 1682 - checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" 1683 - dependencies = [ 1684 - "bytes", 1685 - "fnv", 1686 - "itoa", 1687 - ] 1688 - 1689 - [[package]] 1690 - name = "http-body" 1691 - version = "1.0.1" 1692 - source = "registry+https://github.com/rust-lang/crates.io-index" 1693 - checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 1694 - dependencies = [ 1695 - "bytes", 1696 - "http 1.2.0", 1697 - ] 1698 - 1699 - [[package]] 1700 - name = "http-body-util" 1701 - version = "0.1.2" 1702 - source = "registry+https://github.com/rust-lang/crates.io-index" 1703 - checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" 1704 - dependencies = [ 1705 - "bytes", 1706 - "futures-util", 1707 - "http 1.2.0", 1708 - "http-body", 1709 - "pin-project-lite", 1710 - ] 1711 - 1712 - [[package]] 1713 - name = "http-range" 1714 - version = "0.1.5" 1715 - source = "registry+https://github.com/rust-lang/crates.io-index" 1716 - checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" 1717 - 1718 - [[package]] 1719 - name = "httparse" 1720 - version = "1.10.1" 1721 - source = "registry+https://github.com/rust-lang/crates.io-index" 1722 - checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1723 - 1724 - [[package]] 1725 - name = "httpdate" 1726 - version = "1.0.3" 1727 - source = "registry+https://github.com/rust-lang/crates.io-index" 1728 - checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1729 - 1730 - [[package]] 1731 - name = "hyper" 1732 - version = "1.6.0" 1733 - source = "registry+https://github.com/rust-lang/crates.io-index" 1734 - checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 1735 - dependencies = [ 1736 - "bytes", 1737 - "futures-channel", 1738 - "futures-util", 1739 - "h2 0.4.12", 1740 - "http 1.2.0", 1741 - "http-body", 1742 - "httparse", 1743 - "itoa", 1744 - "pin-project-lite", 1745 - "smallvec", 1746 - "tokio", 1747 - "want", 1748 - ] 1749 - 1750 - [[package]] 1751 - name = "hyper-rustls" 1752 - version = "0.27.7" 1753 - source = "registry+https://github.com/rust-lang/crates.io-index" 1754 - checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1755 - dependencies = [ 1756 - "http 1.2.0", 1757 - "hyper", 1758 - "hyper-util", 1759 - "rustls 0.23.28", 1760 - "rustls-pki-types", 1761 - "tokio", 1762 - "tokio-rustls 0.26.2", 1763 - "tower-service", 1764 - ] 1765 - 1766 - [[package]] 1767 - name = "hyper-tls" 1768 - version = "0.6.0" 1769 - source = "registry+https://github.com/rust-lang/crates.io-index" 1770 - checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" 1771 - dependencies = [ 1772 - "bytes", 1773 - "http-body-util", 1774 - "hyper", 1775 - "hyper-util", 1776 - "native-tls", 1777 - "tokio", 1778 - "tokio-native-tls", 1779 - "tower-service", 1780 - ] 1781 - 1782 - [[package]] 1783 - name = "hyper-util" 1784 - version = "0.1.10" 1785 - source = "registry+https://github.com/rust-lang/crates.io-index" 1786 - checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" 1787 - dependencies = [ 1788 - "bytes", 1789 - "futures-channel", 1790 - "futures-util", 1791 - "http 1.2.0", 1792 - "http-body", 1793 - "hyper", 1794 - "pin-project-lite", 1795 - "socket2", 1796 - "tokio", 1797 - "tower-service", 1798 - "tracing", 1799 - ] 1800 - 1801 - [[package]] 1802 - name = "iana-time-zone" 1803 - version = "0.1.61" 1804 - source = "registry+https://github.com/rust-lang/crates.io-index" 1805 - checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 1806 - dependencies = [ 1807 - "android_system_properties", 1808 - "core-foundation-sys", 1809 - "iana-time-zone-haiku", 1810 - "js-sys", 1811 - "wasm-bindgen", 1812 - "windows-core 0.52.0", 1813 - ] 1814 - 1815 - [[package]] 1816 - name = "iana-time-zone-haiku" 1817 - version = "0.1.2" 1818 - source = "registry+https://github.com/rust-lang/crates.io-index" 1819 - checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 1820 - dependencies = [ 1821 - "cc", 1822 - ] 1823 - 1824 - [[package]] 1825 - name = "icu_collections" 1826 - version = "1.5.0" 1827 - source = "registry+https://github.com/rust-lang/crates.io-index" 1828 - checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 1829 - dependencies = [ 1830 - "displaydoc", 1831 - "yoke", 1832 - "zerofrom", 1833 - "zerovec", 1834 - ] 1835 - 1836 - [[package]] 1837 - name = "icu_locid" 1838 - version = "1.5.0" 1839 - source = "registry+https://github.com/rust-lang/crates.io-index" 1840 - checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 1841 - dependencies = [ 1842 - "displaydoc", 1843 - "litemap", 1844 - "tinystr", 1845 - "writeable", 1846 - "zerovec", 1847 - ] 1848 - 1849 - [[package]] 1850 - name = "icu_locid_transform" 1851 - version = "1.5.0" 1852 - source = "registry+https://github.com/rust-lang/crates.io-index" 1853 - checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 1854 - dependencies = [ 1855 - "displaydoc", 1856 - "icu_locid", 1857 - "icu_locid_transform_data", 1858 - "icu_provider", 1859 - "tinystr", 1860 - "zerovec", 1861 - ] 1862 - 1863 - [[package]] 1864 - name = "icu_locid_transform_data" 1865 - version = "1.5.0" 1866 - source = "registry+https://github.com/rust-lang/crates.io-index" 1867 - checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" 1868 - 1869 - [[package]] 1870 - name = "icu_normalizer" 1871 - version = "1.5.0" 1872 - source = "registry+https://github.com/rust-lang/crates.io-index" 1873 - checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 1874 - dependencies = [ 1875 - "displaydoc", 1876 - "icu_collections", 1877 - "icu_normalizer_data", 1878 - "icu_properties", 1879 - "icu_provider", 1880 - "smallvec", 1881 - "utf16_iter", 1882 - "utf8_iter", 1883 - "write16", 1884 - "zerovec", 1885 - ] 1886 - 1887 - [[package]] 1888 - name = "icu_normalizer_data" 1889 - version = "1.5.0" 1890 - source = "registry+https://github.com/rust-lang/crates.io-index" 1891 - checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" 1892 - 1893 - [[package]] 1894 - name = "icu_properties" 1895 - version = "1.5.1" 1896 - source = "registry+https://github.com/rust-lang/crates.io-index" 1897 - checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 1898 - dependencies = [ 1899 - "displaydoc", 1900 - "icu_collections", 1901 - "icu_locid_transform", 1902 - "icu_properties_data", 1903 - "icu_provider", 1904 - "tinystr", 1905 - "zerovec", 1906 - ] 1907 - 1908 - [[package]] 1909 - name = "icu_properties_data" 1910 - version = "1.5.0" 1911 - source = "registry+https://github.com/rust-lang/crates.io-index" 1912 - checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" 1913 - 1914 - [[package]] 1915 - name = "icu_provider" 1916 - version = "1.5.0" 1917 - source = "registry+https://github.com/rust-lang/crates.io-index" 1918 - checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 1919 - dependencies = [ 1920 - "displaydoc", 1921 - "icu_locid", 1922 - "icu_provider_macros", 1923 - "stable_deref_trait", 1924 - "tinystr", 1925 - "writeable", 1926 - "yoke", 1927 - "zerofrom", 1928 - "zerovec", 1929 - ] 1930 - 1931 - [[package]] 1932 - name = "icu_provider_macros" 1933 - version = "1.5.0" 1934 - source = "registry+https://github.com/rust-lang/crates.io-index" 1935 - checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 1936 - dependencies = [ 1937 - "proc-macro2", 1938 - "quote", 1939 - "syn", 1940 - ] 1941 - 1942 - [[package]] 1943 - name = "ident_case" 1944 - version = "1.0.1" 1945 - source = "registry+https://github.com/rust-lang/crates.io-index" 1946 - checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1947 - 1948 - [[package]] 1949 - name = "idna" 1950 - version = "1.0.3" 1951 - source = "registry+https://github.com/rust-lang/crates.io-index" 1952 - checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 1953 - dependencies = [ 1954 - "idna_adapter", 1955 - "smallvec", 1956 - "utf8_iter", 1957 - ] 1958 - 1959 - [[package]] 1960 - name = "idna_adapter" 1961 - version = "1.2.0" 1962 - source = "registry+https://github.com/rust-lang/crates.io-index" 1963 - checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 1964 - dependencies = [ 1965 - "icu_normalizer", 1966 - "icu_properties", 1967 - ] 1968 - 1969 - [[package]] 1970 - name = "impl-more" 1971 - version = "0.1.9" 1972 - source = "registry+https://github.com/rust-lang/crates.io-index" 1973 - checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" 1974 - 1975 - [[package]] 1976 - name = "indexmap" 1977 - version = "2.7.1" 1978 - source = "registry+https://github.com/rust-lang/crates.io-index" 1979 - checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" 1980 - dependencies = [ 1981 - "equivalent", 1982 - "hashbrown 0.15.2", 1983 - ] 1984 - 1985 - [[package]] 1986 - name = "inout" 1987 - version = "0.1.4" 1988 - source = "registry+https://github.com/rust-lang/crates.io-index" 1989 - checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" 1990 - dependencies = [ 1991 - "generic-array", 1992 - ] 1993 - 1994 - [[package]] 1995 - name = "ipconfig" 1996 - version = "0.3.2" 1997 - source = "registry+https://github.com/rust-lang/crates.io-index" 1998 - checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 1999 - dependencies = [ 2000 - "socket2", 2001 - "widestring", 2002 - "windows-sys 0.48.0", 2003 - "winreg", 2004 - ] 2005 - 2006 - [[package]] 2007 - name = "ipld-core" 2008 - version = "0.4.2" 2009 - source = "registry+https://github.com/rust-lang/crates.io-index" 2010 - checksum = "104718b1cc124d92a6d01ca9c9258a7df311405debb3408c445a36452f9bf8db" 2011 - dependencies = [ 2012 - "cid", 2013 - "serde", 2014 - "serde_bytes", 2015 - ] 2016 - 2017 - [[package]] 2018 - name = "ipnet" 2019 - version = "2.11.0" 2020 - source = "registry+https://github.com/rust-lang/crates.io-index" 2021 - checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 2022 - 2023 - [[package]] 2024 - name = "is_terminal_polyfill" 2025 - version = "1.70.1" 2026 - source = "registry+https://github.com/rust-lang/crates.io-index" 2027 - checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 2028 - 2029 - [[package]] 2030 - name = "itoa" 2031 - version = "1.0.15" 2032 - source = "registry+https://github.com/rust-lang/crates.io-index" 2033 - checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 2034 - 2035 - [[package]] 2036 - name = "jiff" 2037 - version = "0.2.5" 2038 - source = "registry+https://github.com/rust-lang/crates.io-index" 2039 - checksum = "c102670231191d07d37a35af3eb77f1f0dbf7a71be51a962dcd57ea607be7260" 2040 - dependencies = [ 2041 - "jiff-static", 2042 - "log", 2043 - "portable-atomic", 2044 - "portable-atomic-util", 2045 - "serde", 2046 - ] 2047 - 2048 - [[package]] 2049 - name = "jiff-static" 2050 - version = "0.2.5" 2051 - source = "registry+https://github.com/rust-lang/crates.io-index" 2052 - checksum = "4cdde31a9d349f1b1f51a0b3714a5940ac022976f4b49485fc04be052b183b4c" 2053 - dependencies = [ 2054 - "proc-macro2", 2055 - "quote", 2056 - "syn", 2057 - ] 2058 - 2059 - [[package]] 2060 - name = "jobserver" 2061 - version = "0.1.32" 2062 - source = "registry+https://github.com/rust-lang/crates.io-index" 2063 - checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" 2064 - dependencies = [ 2065 - "libc", 2066 - ] 2067 - 2068 - [[package]] 2069 - name = "jose-b64" 2070 - version = "0.1.2" 2071 - source = "registry+https://github.com/rust-lang/crates.io-index" 2072 - checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 2073 - dependencies = [ 2074 - "base64ct", 2075 - "serde", 2076 - "subtle", 2077 - "zeroize", 2078 - ] 2079 - 2080 - [[package]] 2081 - name = "jose-jwa" 2082 - version = "0.1.2" 2083 - source = "registry+https://github.com/rust-lang/crates.io-index" 2084 - checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 2085 - dependencies = [ 2086 - "serde", 2087 - ] 2088 - 2089 - [[package]] 2090 - name = "jose-jwk" 2091 - version = "0.1.2" 2092 - source = "registry+https://github.com/rust-lang/crates.io-index" 2093 - checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 2094 - dependencies = [ 2095 - "jose-b64", 2096 - "jose-jwa", 2097 - "p256", 2098 - "serde", 2099 - "zeroize", 2100 - ] 2101 - 2102 - [[package]] 2103 - name = "js-sys" 2104 - version = "0.3.77" 2105 - source = "registry+https://github.com/rust-lang/crates.io-index" 2106 - checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 2107 - dependencies = [ 2108 - "once_cell", 2109 - "wasm-bindgen", 2110 - ] 2111 - 2112 - [[package]] 2113 - name = "langtag" 2114 - version = "0.3.4" 2115 - source = "registry+https://github.com/rust-lang/crates.io-index" 2116 - checksum = "ed60c85f254d6ae8450cec15eedd921efbc4d1bdf6fcf6202b9a58b403f6f805" 2117 - dependencies = [ 2118 - "serde", 2119 - ] 2120 - 2121 - [[package]] 2122 - name = "language-tags" 2123 - version = "0.3.2" 2124 - source = "registry+https://github.com/rust-lang/crates.io-index" 2125 - checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" 2126 - 2127 - [[package]] 2128 - name = "lazy_static" 2129 - version = "1.5.0" 2130 - source = "registry+https://github.com/rust-lang/crates.io-index" 2131 - checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2132 - 2133 - [[package]] 2134 - name = "libc" 2135 - version = "0.2.170" 2136 - source = "registry+https://github.com/rust-lang/crates.io-index" 2137 - checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" 2138 - 2139 - [[package]] 2140 - name = "libsqlite3-sys" 2141 - version = "0.31.0" 2142 - source = "registry+https://github.com/rust-lang/crates.io-index" 2143 - checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" 2144 - dependencies = [ 2145 - "cc", 2146 - "pkg-config", 2147 - "vcpkg", 2148 - ] 2149 - 2150 - [[package]] 2151 - name = "linked-hash-map" 2152 - version = "0.5.6" 2153 - source = "registry+https://github.com/rust-lang/crates.io-index" 2154 - checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 2155 - 2156 - [[package]] 2157 - name = "linux-raw-sys" 2158 - version = "0.9.2" 2159 - source = "registry+https://github.com/rust-lang/crates.io-index" 2160 - checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" 2161 - 2162 - [[package]] 2163 - name = "litemap" 2164 - version = "0.7.5" 2165 - source = "registry+https://github.com/rust-lang/crates.io-index" 2166 - checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 2167 - 2168 - [[package]] 2169 - name = "local-channel" 2170 - version = "0.1.5" 2171 - source = "registry+https://github.com/rust-lang/crates.io-index" 2172 - checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" 2173 - dependencies = [ 2174 - "futures-core", 2175 - "futures-sink", 2176 - "local-waker", 2177 - ] 2178 - 2179 - [[package]] 2180 - name = "local-waker" 2181 - version = "0.1.4" 2182 - source = "registry+https://github.com/rust-lang/crates.io-index" 2183 - checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" 2184 - 2185 - [[package]] 2186 - name = "lock_api" 2187 - version = "0.4.12" 2188 - source = "registry+https://github.com/rust-lang/crates.io-index" 2189 - checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 2190 - dependencies = [ 2191 - "autocfg", 2192 - "scopeguard", 2193 - ] 2194 - 2195 - [[package]] 2196 - name = "log" 2197 - version = "0.4.27" 2198 - source = "registry+https://github.com/rust-lang/crates.io-index" 2199 - checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 2200 - 2201 - [[package]] 2202 - name = "loom" 2203 - version = "0.7.2" 2204 - source = "registry+https://github.com/rust-lang/crates.io-index" 2205 - checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" 2206 - dependencies = [ 2207 - "cfg-if", 2208 - "generator", 2209 - "scoped-tls", 2210 - "tracing", 2211 - "tracing-subscriber", 2212 - ] 2213 - 2214 - [[package]] 2215 - name = "lru" 2216 - version = "0.12.5" 2217 - source = "registry+https://github.com/rust-lang/crates.io-index" 2218 - checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" 2219 - dependencies = [ 2220 - "hashbrown 0.15.2", 2221 - ] 2222 - 2223 - [[package]] 2224 - name = "lru-cache" 2225 - version = "0.1.2" 2226 - source = "registry+https://github.com/rust-lang/crates.io-index" 2227 - checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" 2228 - dependencies = [ 2229 - "linked-hash-map", 2230 - ] 2231 - 2232 - [[package]] 2233 - name = "matchers" 2234 - version = "0.1.0" 2235 - source = "registry+https://github.com/rust-lang/crates.io-index" 2236 - checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 2237 - dependencies = [ 2238 - "regex-automata 0.1.10", 2239 - ] 2240 - 2241 - [[package]] 2242 - name = "memchr" 2243 - version = "2.7.4" 2244 - source = "registry+https://github.com/rust-lang/crates.io-index" 2245 - checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 2246 - 2247 - [[package]] 2248 - name = "metrics" 2249 - version = "0.24.1" 2250 - source = "registry+https://github.com/rust-lang/crates.io-index" 2251 - checksum = "7a7deb012b3b2767169ff203fadb4c6b0b82b947512e5eb9e0b78c2e186ad9e3" 2252 - dependencies = [ 2253 - "ahash", 2254 - "portable-atomic", 2255 - ] 2256 - 2257 - [[package]] 2258 - name = "mime" 2259 - version = "0.3.17" 2260 - source = "registry+https://github.com/rust-lang/crates.io-index" 2261 - checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 2262 - 2263 - [[package]] 2264 - name = "mime_guess" 2265 - version = "2.0.5" 2266 - source = "registry+https://github.com/rust-lang/crates.io-index" 2267 - checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 2268 - dependencies = [ 2269 - "mime", 2270 - "unicase", 2271 - ] 2272 - 2273 - [[package]] 2274 - name = "miniz_oxide" 2275 - version = "0.8.5" 2276 - source = "registry+https://github.com/rust-lang/crates.io-index" 2277 - checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" 2278 - dependencies = [ 2279 - "adler2", 2280 - ] 2281 - 2282 - [[package]] 2283 - name = "mio" 2284 - version = "1.0.3" 2285 - source = "registry+https://github.com/rust-lang/crates.io-index" 2286 - checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 2287 - dependencies = [ 2288 - "libc", 2289 - "log", 2290 - "wasi 0.11.0+wasi-snapshot-preview1", 2291 - "windows-sys 0.52.0", 2292 - ] 2293 - 2294 - [[package]] 2295 - name = "moka" 2296 - version = "0.12.10" 2297 - source = "registry+https://github.com/rust-lang/crates.io-index" 2298 - checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" 2299 - dependencies = [ 2300 - "async-lock", 2301 - "crossbeam-channel", 2302 - "crossbeam-epoch", 2303 - "crossbeam-utils", 2304 - "event-listener", 2305 - "futures-util", 2306 - "loom", 2307 - "parking_lot", 2308 - "portable-atomic", 2309 - "rustc_version", 2310 - "smallvec", 2311 - "tagptr", 2312 - "thiserror", 2313 - "uuid", 2314 - ] 2315 - 2316 - [[package]] 2317 - name = "multibase" 2318 - version = "0.9.1" 2319 - source = "registry+https://github.com/rust-lang/crates.io-index" 2320 - checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404" 2321 - dependencies = [ 2322 - "base-x", 2323 - "data-encoding", 2324 - "data-encoding-macro", 2325 - ] 2326 - 2327 - [[package]] 2328 - name = "multihash" 2329 - version = "0.19.3" 2330 - source = "registry+https://github.com/rust-lang/crates.io-index" 2331 - checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" 2332 - dependencies = [ 2333 - "core2", 2334 - "serde", 2335 - "unsigned-varint", 2336 - ] 2337 - 2338 - [[package]] 2339 - name = "nanorand" 2340 - version = "0.7.0" 2341 - source = "registry+https://github.com/rust-lang/crates.io-index" 2342 - checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" 2343 - dependencies = [ 2344 - "getrandom 0.2.15", 2345 - ] 2346 - 2347 - [[package]] 2348 - name = "nate-status" 2349 - version = "0.1.0" 2350 - dependencies = [ 2351 - "actix-files", 2352 - "actix-multipart", 2353 - "actix-session", 2354 - "actix-web", 2355 - "anyhow", 2356 - "askama", 2357 - "async-sqlite", 2358 - "async-trait", 2359 - "atrium-api", 2360 - "atrium-common", 2361 - "atrium-identity", 2362 - "atrium-oauth", 2363 - "chrono", 2364 - "dotenv", 2365 - "env_logger", 2366 - "futures-util", 2367 - "hex", 2368 - "hickory-resolver", 2369 - "hmac", 2370 - "log", 2371 - "once_cell", 2372 - "rand 0.8.5", 2373 - "reqwest", 2374 - "rocketman", 2375 - "serde", 2376 - "serde_json", 2377 - "sha2", 2378 - "thiserror", 2379 - "tokio", 2380 - "url", 2381 - ] 2382 - 2383 - [[package]] 2384 - name = "native-tls" 2385 - version = "0.2.14" 2386 - source = "registry+https://github.com/rust-lang/crates.io-index" 2387 - checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" 2388 - dependencies = [ 2389 - "libc", 2390 - "log", 2391 - "openssl", 2392 - "openssl-probe", 2393 - "openssl-sys", 2394 - "schannel", 2395 - "security-framework", 2396 - "security-framework-sys", 2397 - "tempfile", 2398 - ] 2399 - 2400 - [[package]] 2401 - name = "nu-ansi-term" 2402 - version = "0.46.0" 2403 - source = "registry+https://github.com/rust-lang/crates.io-index" 2404 - checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 2405 - dependencies = [ 2406 - "overload", 2407 - "winapi", 2408 - ] 2409 - 2410 - [[package]] 2411 - name = "num-conv" 2412 - version = "0.1.0" 2413 - source = "registry+https://github.com/rust-lang/crates.io-index" 2414 - checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 2415 - 2416 - [[package]] 2417 - name = "num-traits" 2418 - version = "0.2.19" 2419 - source = "registry+https://github.com/rust-lang/crates.io-index" 2420 - checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 2421 - dependencies = [ 2422 - "autocfg", 2423 - ] 2424 - 2425 - [[package]] 2426 - name = "object" 2427 - version = "0.36.7" 2428 - source = "registry+https://github.com/rust-lang/crates.io-index" 2429 - checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 2430 - dependencies = [ 2431 - "memchr", 2432 - ] 2433 - 2434 - [[package]] 2435 - name = "once_cell" 2436 - version = "1.20.3" 2437 - source = "registry+https://github.com/rust-lang/crates.io-index" 2438 - checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" 2439 - 2440 - [[package]] 2441 - name = "opaque-debug" 2442 - version = "0.3.1" 2443 - source = "registry+https://github.com/rust-lang/crates.io-index" 2444 - checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 2445 - 2446 - [[package]] 2447 - name = "openssl" 2448 - version = "0.10.71" 2449 - source = "registry+https://github.com/rust-lang/crates.io-index" 2450 - checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" 2451 - dependencies = [ 2452 - "bitflags", 2453 - "cfg-if", 2454 - "foreign-types", 2455 - "libc", 2456 - "once_cell", 2457 - "openssl-macros", 2458 - "openssl-sys", 2459 - ] 2460 - 2461 - [[package]] 2462 - name = "openssl-macros" 2463 - version = "0.1.1" 2464 - source = "registry+https://github.com/rust-lang/crates.io-index" 2465 - checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" 2466 - dependencies = [ 2467 - "proc-macro2", 2468 - "quote", 2469 - "syn", 2470 - ] 2471 - 2472 - [[package]] 2473 - name = "openssl-probe" 2474 - version = "0.1.6" 2475 - source = "registry+https://github.com/rust-lang/crates.io-index" 2476 - checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 2477 - 2478 - [[package]] 2479 - name = "openssl-sys" 2480 - version = "0.9.106" 2481 - source = "registry+https://github.com/rust-lang/crates.io-index" 2482 - checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" 2483 - dependencies = [ 2484 - "cc", 2485 - "libc", 2486 - "pkg-config", 2487 - "vcpkg", 2488 - ] 2489 - 2490 - [[package]] 2491 - name = "overload" 2492 - version = "0.1.1" 2493 - source = "registry+https://github.com/rust-lang/crates.io-index" 2494 - checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 2495 - 2496 - [[package]] 2497 - name = "p256" 2498 - version = "0.13.2" 2499 - source = "registry+https://github.com/rust-lang/crates.io-index" 2500 - checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 2501 - dependencies = [ 2502 - "ecdsa", 2503 - "elliptic-curve", 2504 - "primeorder", 2505 - "sha2", 2506 - ] 2507 - 2508 - [[package]] 2509 - name = "parking" 2510 - version = "2.2.1" 2511 - source = "registry+https://github.com/rust-lang/crates.io-index" 2512 - checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 2513 - 2514 - [[package]] 2515 - name = "parking_lot" 2516 - version = "0.12.3" 2517 - source = "registry+https://github.com/rust-lang/crates.io-index" 2518 - checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 2519 - dependencies = [ 2520 - "lock_api", 2521 - "parking_lot_core", 2522 - ] 2523 - 2524 - [[package]] 2525 - name = "parking_lot_core" 2526 - version = "0.9.10" 2527 - source = "registry+https://github.com/rust-lang/crates.io-index" 2528 - checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 2529 - dependencies = [ 2530 - "cfg-if", 2531 - "libc", 2532 - "redox_syscall", 2533 - "smallvec", 2534 - "windows-targets 0.52.6", 2535 - ] 2536 - 2537 - [[package]] 2538 - name = "parse-size" 2539 - version = "1.1.0" 2540 - source = "registry+https://github.com/rust-lang/crates.io-index" 2541 - checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" 2542 - 2543 - [[package]] 2544 - name = "paste" 2545 - version = "1.0.15" 2546 - source = "registry+https://github.com/rust-lang/crates.io-index" 2547 - checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 2548 - 2549 - [[package]] 2550 - name = "percent-encoding" 2551 - version = "2.3.1" 2552 - source = "registry+https://github.com/rust-lang/crates.io-index" 2553 - checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 2554 - 2555 - [[package]] 2556 - name = "pin-project-lite" 2557 - version = "0.2.16" 2558 - source = "registry+https://github.com/rust-lang/crates.io-index" 2559 - checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 2560 - 2561 - [[package]] 2562 - name = "pin-utils" 2563 - version = "0.1.0" 2564 - source = "registry+https://github.com/rust-lang/crates.io-index" 2565 - checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 2566 - 2567 - [[package]] 2568 - name = "pkg-config" 2569 - version = "0.3.32" 2570 - source = "registry+https://github.com/rust-lang/crates.io-index" 2571 - checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 2572 - 2573 - [[package]] 2574 - name = "polyval" 2575 - version = "0.6.2" 2576 - source = "registry+https://github.com/rust-lang/crates.io-index" 2577 - checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" 2578 - dependencies = [ 2579 - "cfg-if", 2580 - "cpufeatures", 2581 - "opaque-debug", 2582 - "universal-hash", 2583 - ] 2584 - 2585 - [[package]] 2586 - name = "portable-atomic" 2587 - version = "1.11.0" 2588 - source = "registry+https://github.com/rust-lang/crates.io-index" 2589 - checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" 2590 - 2591 - [[package]] 2592 - name = "portable-atomic-util" 2593 - version = "0.2.4" 2594 - source = "registry+https://github.com/rust-lang/crates.io-index" 2595 - checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" 2596 - dependencies = [ 2597 - "portable-atomic", 2598 - ] 2599 - 2600 - [[package]] 2601 - name = "powerfmt" 2602 - version = "0.2.0" 2603 - source = "registry+https://github.com/rust-lang/crates.io-index" 2604 - checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 2605 - 2606 - [[package]] 2607 - name = "ppv-lite86" 2608 - version = "0.2.20" 2609 - source = "registry+https://github.com/rust-lang/crates.io-index" 2610 - checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" 2611 - dependencies = [ 2612 - "zerocopy 0.7.35", 2613 - ] 2614 - 2615 - [[package]] 2616 - name = "prettyplease" 2617 - version = "0.2.31" 2618 - source = "registry+https://github.com/rust-lang/crates.io-index" 2619 - checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb" 2620 - dependencies = [ 2621 - "proc-macro2", 2622 - "syn", 2623 - ] 2624 - 2625 - [[package]] 2626 - name = "primeorder" 2627 - version = "0.13.6" 2628 - source = "registry+https://github.com/rust-lang/crates.io-index" 2629 - checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" 2630 - dependencies = [ 2631 - "elliptic-curve", 2632 - ] 2633 - 2634 - [[package]] 2635 - name = "proc-macro2" 2636 - version = "1.0.94" 2637 - source = "registry+https://github.com/rust-lang/crates.io-index" 2638 - checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" 2639 - dependencies = [ 2640 - "unicode-ident", 2641 - ] 2642 - 2643 - [[package]] 2644 - name = "quote" 2645 - version = "1.0.39" 2646 - source = "registry+https://github.com/rust-lang/crates.io-index" 2647 - checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" 2648 - dependencies = [ 2649 - "proc-macro2", 2650 - ] 2651 - 2652 - [[package]] 2653 - name = "rand" 2654 - version = "0.8.5" 2655 - source = "registry+https://github.com/rust-lang/crates.io-index" 2656 - checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 2657 - dependencies = [ 2658 - "libc", 2659 - "rand_chacha 0.3.1", 2660 - "rand_core 0.6.4", 2661 - ] 2662 - 2663 - [[package]] 2664 - name = "rand" 2665 - version = "0.9.0" 2666 - source = "registry+https://github.com/rust-lang/crates.io-index" 2667 - checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" 2668 - dependencies = [ 2669 - "rand_chacha 0.9.0", 2670 - "rand_core 0.9.3", 2671 - "zerocopy 0.8.24", 2672 - ] 2673 - 2674 - [[package]] 2675 - name = "rand_chacha" 2676 - version = "0.3.1" 2677 - source = "registry+https://github.com/rust-lang/crates.io-index" 2678 - checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 2679 - dependencies = [ 2680 - "ppv-lite86", 2681 - "rand_core 0.6.4", 2682 - ] 2683 - 2684 - [[package]] 2685 - name = "rand_chacha" 2686 - version = "0.9.0" 2687 - source = "registry+https://github.com/rust-lang/crates.io-index" 2688 - checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 2689 - dependencies = [ 2690 - "ppv-lite86", 2691 - "rand_core 0.9.3", 2692 - ] 2693 - 2694 - [[package]] 2695 - name = "rand_core" 2696 - version = "0.6.4" 2697 - source = "registry+https://github.com/rust-lang/crates.io-index" 2698 - checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 2699 - dependencies = [ 2700 - "getrandom 0.2.15", 2701 - ] 2702 - 2703 - [[package]] 2704 - name = "rand_core" 2705 - version = "0.9.3" 2706 - source = "registry+https://github.com/rust-lang/crates.io-index" 2707 - checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 2708 - dependencies = [ 2709 - "getrandom 0.3.1", 2710 - ] 2711 - 2712 - [[package]] 2713 - name = "redox_syscall" 2714 - version = "0.5.10" 2715 - source = "registry+https://github.com/rust-lang/crates.io-index" 2716 - checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" 2717 - dependencies = [ 2718 - "bitflags", 2719 - ] 2720 - 2721 - [[package]] 2722 - name = "regex" 2723 - version = "1.11.1" 2724 - source = "registry+https://github.com/rust-lang/crates.io-index" 2725 - checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 2726 - dependencies = [ 2727 - "aho-corasick", 2728 - "memchr", 2729 - "regex-automata 0.4.9", 2730 - "regex-syntax 0.8.5", 2731 - ] 2732 - 2733 - [[package]] 2734 - name = "regex-automata" 2735 - version = "0.1.10" 2736 - source = "registry+https://github.com/rust-lang/crates.io-index" 2737 - checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 2738 - dependencies = [ 2739 - "regex-syntax 0.6.29", 2740 - ] 2741 - 2742 - [[package]] 2743 - name = "regex-automata" 2744 - version = "0.4.9" 2745 - source = "registry+https://github.com/rust-lang/crates.io-index" 2746 - checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 2747 - dependencies = [ 2748 - "aho-corasick", 2749 - "memchr", 2750 - "regex-syntax 0.8.5", 2751 - ] 2752 - 2753 - [[package]] 2754 - name = "regex-lite" 2755 - version = "0.1.6" 2756 - source = "registry+https://github.com/rust-lang/crates.io-index" 2757 - checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" 2758 - 2759 - [[package]] 2760 - name = "regex-syntax" 2761 - version = "0.6.29" 2762 - source = "registry+https://github.com/rust-lang/crates.io-index" 2763 - checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 2764 - 2765 - [[package]] 2766 - name = "regex-syntax" 2767 - version = "0.8.5" 2768 - source = "registry+https://github.com/rust-lang/crates.io-index" 2769 - checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 2770 - 2771 - [[package]] 2772 - name = "reqwest" 2773 - version = "0.12.12" 2774 - source = "registry+https://github.com/rust-lang/crates.io-index" 2775 - checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" 2776 - dependencies = [ 2777 - "async-compression", 2778 - "base64 0.22.1", 2779 - "bytes", 2780 - "encoding_rs", 2781 - "futures-core", 2782 - "futures-util", 2783 - "h2 0.4.12", 2784 - "http 1.2.0", 2785 - "http-body", 2786 - "http-body-util", 2787 - "hyper", 2788 - "hyper-rustls", 2789 - "hyper-tls", 2790 - "hyper-util", 2791 - "ipnet", 2792 - "js-sys", 2793 - "log", 2794 - "mime", 2795 - "native-tls", 2796 - "once_cell", 2797 - "percent-encoding", 2798 - "pin-project-lite", 2799 - "rustls-pemfile 2.2.0", 2800 - "serde", 2801 - "serde_json", 2802 - "serde_urlencoded", 2803 - "sync_wrapper", 2804 - "system-configuration", 2805 - "tokio", 2806 - "tokio-native-tls", 2807 - "tokio-util", 2808 - "tower", 2809 - "tower-service", 2810 - "url", 2811 - "wasm-bindgen", 2812 - "wasm-bindgen-futures", 2813 - "web-sys", 2814 - "windows-registry", 2815 - ] 2816 - 2817 - [[package]] 2818 - name = "resolv-conf" 2819 - version = "0.7.1" 2820 - source = "registry+https://github.com/rust-lang/crates.io-index" 2821 - checksum = "48375394603e3dd4b2d64371f7148fd8c7baa2680e28741f2cb8d23b59e3d4c4" 2822 - dependencies = [ 2823 - "hostname", 2824 - ] 2825 - 2826 - [[package]] 2827 - name = "rfc6979" 2828 - version = "0.4.0" 2829 - source = "registry+https://github.com/rust-lang/crates.io-index" 2830 - checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 2831 - dependencies = [ 2832 - "hmac", 2833 - "subtle", 2834 - ] 2835 - 2836 - [[package]] 2837 - name = "ring" 2838 - version = "0.17.14" 2839 - source = "registry+https://github.com/rust-lang/crates.io-index" 2840 - checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 2841 - dependencies = [ 2842 - "cc", 2843 - "cfg-if", 2844 - "getrandom 0.2.15", 2845 - "libc", 2846 - "untrusted", 2847 - "windows-sys 0.52.0", 2848 - ] 2849 - 2850 - [[package]] 2851 - name = "rocketman" 2852 - version = "0.2.0" 2853 - source = "registry+https://github.com/rust-lang/crates.io-index" 2854 - checksum = "4a3aae946adbfdcf80cad8793e02d8eb94be06c925331aa56aeb446795893361" 2855 - dependencies = [ 2856 - "anyhow", 2857 - "async-trait", 2858 - "bon", 2859 - "derive_builder", 2860 - "flume", 2861 - "futures-util", 2862 - "metrics", 2863 - "rand 0.8.5", 2864 - "serde", 2865 - "serde_json", 2866 - "tokio", 2867 - "tokio-tungstenite", 2868 - "tracing", 2869 - "tracing-subscriber", 2870 - "url", 2871 - "zstd", 2872 - ] 2873 - 2874 - [[package]] 2875 - name = "rusqlite" 2876 - version = "0.33.0" 2877 - source = "registry+https://github.com/rust-lang/crates.io-index" 2878 - checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" 2879 - dependencies = [ 2880 - "bitflags", 2881 - "fallible-iterator", 2882 - "fallible-streaming-iterator", 2883 - "hashlink", 2884 - "libsqlite3-sys", 2885 - "smallvec", 2886 - ] 2887 - 2888 - [[package]] 2889 - name = "rustc-demangle" 2890 - version = "0.1.24" 2891 - source = "registry+https://github.com/rust-lang/crates.io-index" 2892 - checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 2893 - 2894 - [[package]] 2895 - name = "rustc-hash" 2896 - version = "2.1.1" 2897 - source = "registry+https://github.com/rust-lang/crates.io-index" 2898 - checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 2899 - 2900 - [[package]] 2901 - name = "rustc_version" 2902 - version = "0.4.1" 2903 - source = "registry+https://github.com/rust-lang/crates.io-index" 2904 - checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 2905 - dependencies = [ 2906 - "semver", 2907 - ] 2908 - 2909 - [[package]] 2910 - name = "rustix" 2911 - version = "1.0.1" 2912 - source = "registry+https://github.com/rust-lang/crates.io-index" 2913 - checksum = "dade4812df5c384711475be5fcd8c162555352945401aed22a35bffeab61f657" 2914 - dependencies = [ 2915 - "bitflags", 2916 - "errno", 2917 - "libc", 2918 - "linux-raw-sys", 2919 - "windows-sys 0.59.0", 2920 - ] 2921 - 2922 - [[package]] 2923 - name = "rustls" 2924 - version = "0.21.12" 2925 - source = "registry+https://github.com/rust-lang/crates.io-index" 2926 - checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" 2927 - dependencies = [ 2928 - "log", 2929 - "ring", 2930 - "rustls-webpki 0.101.7", 2931 - "sct", 2932 - ] 2933 - 2934 - [[package]] 2935 - name = "rustls" 2936 - version = "0.23.28" 2937 - source = "registry+https://github.com/rust-lang/crates.io-index" 2938 - checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" 2939 - dependencies = [ 2940 - "once_cell", 2941 - "rustls-pki-types", 2942 - "rustls-webpki 0.103.3", 2943 - "subtle", 2944 - "zeroize", 2945 - ] 2946 - 2947 - [[package]] 2948 - name = "rustls-native-certs" 2949 - version = "0.6.3" 2950 - source = "registry+https://github.com/rust-lang/crates.io-index" 2951 - checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" 2952 - dependencies = [ 2953 - "openssl-probe", 2954 - "rustls-pemfile 1.0.4", 2955 - "schannel", 2956 - "security-framework", 2957 - ] 2958 - 2959 - [[package]] 2960 - name = "rustls-pemfile" 2961 - version = "1.0.4" 2962 - source = "registry+https://github.com/rust-lang/crates.io-index" 2963 - checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" 2964 - dependencies = [ 2965 - "base64 0.21.7", 2966 - ] 2967 - 2968 - [[package]] 2969 - name = "rustls-pemfile" 2970 - version = "2.2.0" 2971 - source = "registry+https://github.com/rust-lang/crates.io-index" 2972 - checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" 2973 - dependencies = [ 2974 - "rustls-pki-types", 2975 - ] 2976 - 2977 - [[package]] 2978 - name = "rustls-pki-types" 2979 - version = "1.11.0" 2980 - source = "registry+https://github.com/rust-lang/crates.io-index" 2981 - checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" 2982 - 2983 - [[package]] 2984 - name = "rustls-webpki" 2985 - version = "0.101.7" 2986 - source = "registry+https://github.com/rust-lang/crates.io-index" 2987 - checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" 2988 - dependencies = [ 2989 - "ring", 2990 - "untrusted", 2991 - ] 2992 - 2993 - [[package]] 2994 - name = "rustls-webpki" 2995 - version = "0.103.3" 2996 - source = "registry+https://github.com/rust-lang/crates.io-index" 2997 - checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" 2998 - dependencies = [ 2999 - "ring", 3000 - "rustls-pki-types", 3001 - "untrusted", 3002 - ] 3003 - 3004 - [[package]] 3005 - name = "rustversion" 3006 - version = "1.0.20" 3007 - source = "registry+https://github.com/rust-lang/crates.io-index" 3008 - checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 3009 - 3010 - [[package]] 3011 - name = "ryu" 3012 - version = "1.0.20" 3013 - source = "registry+https://github.com/rust-lang/crates.io-index" 3014 - checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 3015 - 3016 - [[package]] 3017 - name = "schannel" 3018 - version = "0.1.27" 3019 - source = "registry+https://github.com/rust-lang/crates.io-index" 3020 - checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 3021 - dependencies = [ 3022 - "windows-sys 0.59.0", 3023 - ] 3024 - 3025 - [[package]] 3026 - name = "scoped-tls" 3027 - version = "1.0.1" 3028 - source = "registry+https://github.com/rust-lang/crates.io-index" 3029 - checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 3030 - 3031 - [[package]] 3032 - name = "scopeguard" 3033 - version = "1.2.0" 3034 - source = "registry+https://github.com/rust-lang/crates.io-index" 3035 - checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 3036 - 3037 - [[package]] 3038 - name = "sct" 3039 - version = "0.7.1" 3040 - source = "registry+https://github.com/rust-lang/crates.io-index" 3041 - checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" 3042 - dependencies = [ 3043 - "ring", 3044 - "untrusted", 3045 - ] 3046 - 3047 - [[package]] 3048 - name = "sec1" 3049 - version = "0.7.3" 3050 - source = "registry+https://github.com/rust-lang/crates.io-index" 3051 - checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" 3052 - dependencies = [ 3053 - "base16ct", 3054 - "der", 3055 - "generic-array", 3056 - "subtle", 3057 - "zeroize", 3058 - ] 3059 - 3060 - [[package]] 3061 - name = "security-framework" 3062 - version = "2.11.1" 3063 - source = "registry+https://github.com/rust-lang/crates.io-index" 3064 - checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" 3065 - dependencies = [ 3066 - "bitflags", 3067 - "core-foundation", 3068 - "core-foundation-sys", 3069 - "libc", 3070 - "security-framework-sys", 3071 - ] 3072 - 3073 - [[package]] 3074 - name = "security-framework-sys" 3075 - version = "2.14.0" 3076 - source = "registry+https://github.com/rust-lang/crates.io-index" 3077 - checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 3078 - dependencies = [ 3079 - "core-foundation-sys", 3080 - "libc", 3081 - ] 3082 - 3083 - [[package]] 3084 - name = "semver" 3085 - version = "1.0.26" 3086 - source = "registry+https://github.com/rust-lang/crates.io-index" 3087 - checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" 3088 - 3089 - [[package]] 3090 - name = "serde" 3091 - version = "1.0.219" 3092 - source = "registry+https://github.com/rust-lang/crates.io-index" 3093 - checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 3094 - dependencies = [ 3095 - "serde_derive", 3096 - ] 3097 - 3098 - [[package]] 3099 - name = "serde_bytes" 3100 - version = "0.11.16" 3101 - source = "registry+https://github.com/rust-lang/crates.io-index" 3102 - checksum = "364fec0df39c49a083c9a8a18a23a6bcfd9af130fe9fe321d18520a0d113e09e" 3103 - dependencies = [ 3104 - "serde", 3105 - ] 3106 - 3107 - [[package]] 3108 - name = "serde_derive" 3109 - version = "1.0.219" 3110 - source = "registry+https://github.com/rust-lang/crates.io-index" 3111 - checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 3112 - dependencies = [ 3113 - "proc-macro2", 3114 - "quote", 3115 - "syn", 3116 - ] 3117 - 3118 - [[package]] 3119 - name = "serde_html_form" 3120 - version = "0.2.7" 3121 - source = "registry+https://github.com/rust-lang/crates.io-index" 3122 - checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" 3123 - dependencies = [ 3124 - "form_urlencoded", 3125 - "indexmap", 3126 - "itoa", 3127 - "ryu", 3128 - "serde", 3129 - ] 3130 - 3131 - [[package]] 3132 - name = "serde_json" 3133 - version = "1.0.140" 3134 - source = "registry+https://github.com/rust-lang/crates.io-index" 3135 - checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 3136 - dependencies = [ 3137 - "itoa", 3138 - "memchr", 3139 - "ryu", 3140 - "serde", 3141 - ] 3142 - 3143 - [[package]] 3144 - name = "serde_plain" 3145 - version = "1.0.2" 3146 - source = "registry+https://github.com/rust-lang/crates.io-index" 3147 - checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" 3148 - dependencies = [ 3149 - "serde", 3150 - ] 3151 - 3152 - [[package]] 3153 - name = "serde_urlencoded" 3154 - version = "0.7.1" 3155 - source = "registry+https://github.com/rust-lang/crates.io-index" 3156 - checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 3157 - dependencies = [ 3158 - "form_urlencoded", 3159 - "itoa", 3160 - "ryu", 3161 - "serde", 3162 - ] 3163 - 3164 - [[package]] 3165 - name = "sha1" 3166 - version = "0.10.6" 3167 - source = "registry+https://github.com/rust-lang/crates.io-index" 3168 - checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 3169 - dependencies = [ 3170 - "cfg-if", 3171 - "cpufeatures", 3172 - "digest", 3173 - ] 3174 - 3175 - [[package]] 3176 - name = "sha2" 3177 - version = "0.10.8" 3178 - source = "registry+https://github.com/rust-lang/crates.io-index" 3179 - checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" 3180 - dependencies = [ 3181 - "cfg-if", 3182 - "cpufeatures", 3183 - "digest", 3184 - ] 3185 - 3186 - [[package]] 3187 - name = "sharded-slab" 3188 - version = "0.1.7" 3189 - source = "registry+https://github.com/rust-lang/crates.io-index" 3190 - checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3191 - dependencies = [ 3192 - "lazy_static", 3193 - ] 3194 - 3195 - [[package]] 3196 - name = "shlex" 3197 - version = "1.3.0" 3198 - source = "registry+https://github.com/rust-lang/crates.io-index" 3199 - checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 3200 - 3201 - [[package]] 3202 - name = "signal-hook-registry" 3203 - version = "1.4.2" 3204 - source = "registry+https://github.com/rust-lang/crates.io-index" 3205 - checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 3206 - dependencies = [ 3207 - "libc", 3208 - ] 3209 - 3210 - [[package]] 3211 - name = "signature" 3212 - version = "2.2.0" 3213 - source = "registry+https://github.com/rust-lang/crates.io-index" 3214 - checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 3215 - dependencies = [ 3216 - "digest", 3217 - "rand_core 0.6.4", 3218 - ] 3219 - 3220 - [[package]] 3221 - name = "slab" 3222 - version = "0.4.9" 3223 - source = "registry+https://github.com/rust-lang/crates.io-index" 3224 - checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 3225 - dependencies = [ 3226 - "autocfg", 3227 - ] 3228 - 3229 - [[package]] 3230 - name = "smallvec" 3231 - version = "1.14.0" 3232 - source = "registry+https://github.com/rust-lang/crates.io-index" 3233 - checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 3234 - 3235 - [[package]] 3236 - name = "socket2" 3237 - version = "0.5.8" 3238 - source = "registry+https://github.com/rust-lang/crates.io-index" 3239 - checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" 3240 - dependencies = [ 3241 - "libc", 3242 - "windows-sys 0.52.0", 3243 - ] 3244 - 3245 - [[package]] 3246 - name = "spin" 3247 - version = "0.9.8" 3248 - source = "registry+https://github.com/rust-lang/crates.io-index" 3249 - checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3250 - dependencies = [ 3251 - "lock_api", 3252 - ] 3253 - 3254 - [[package]] 3255 - name = "stable_deref_trait" 3256 - version = "1.2.0" 3257 - source = "registry+https://github.com/rust-lang/crates.io-index" 3258 - checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 3259 - 3260 - [[package]] 3261 - name = "strsim" 3262 - version = "0.11.1" 3263 - source = "registry+https://github.com/rust-lang/crates.io-index" 3264 - checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 3265 - 3266 - [[package]] 3267 - name = "subtle" 3268 - version = "2.6.1" 3269 - source = "registry+https://github.com/rust-lang/crates.io-index" 3270 - checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 3271 - 3272 - [[package]] 3273 - name = "syn" 3274 - version = "2.0.99" 3275 - source = "registry+https://github.com/rust-lang/crates.io-index" 3276 - checksum = "e02e925281e18ffd9d640e234264753c43edc62d64b2d4cf898f1bc5e75f3fc2" 3277 - dependencies = [ 3278 - "proc-macro2", 3279 - "quote", 3280 - "unicode-ident", 3281 - ] 3282 - 3283 - [[package]] 3284 - name = "sync_wrapper" 3285 - version = "1.0.2" 3286 - source = "registry+https://github.com/rust-lang/crates.io-index" 3287 - checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 3288 - dependencies = [ 3289 - "futures-core", 3290 - ] 3291 - 3292 - [[package]] 3293 - name = "synstructure" 3294 - version = "0.13.1" 3295 - source = "registry+https://github.com/rust-lang/crates.io-index" 3296 - checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 3297 - dependencies = [ 3298 - "proc-macro2", 3299 - "quote", 3300 - "syn", 3301 - ] 3302 - 3303 - [[package]] 3304 - name = "system-configuration" 3305 - version = "0.6.1" 3306 - source = "registry+https://github.com/rust-lang/crates.io-index" 3307 - checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 3308 - dependencies = [ 3309 - "bitflags", 3310 - "core-foundation", 3311 - "system-configuration-sys", 3312 - ] 3313 - 3314 - [[package]] 3315 - name = "system-configuration-sys" 3316 - version = "0.6.0" 3317 - source = "registry+https://github.com/rust-lang/crates.io-index" 3318 - checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 3319 - dependencies = [ 3320 - "core-foundation-sys", 3321 - "libc", 3322 - ] 3323 - 3324 - [[package]] 3325 - name = "tagptr" 3326 - version = "0.2.0" 3327 - source = "registry+https://github.com/rust-lang/crates.io-index" 3328 - checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" 3329 - 3330 - [[package]] 3331 - name = "tempfile" 3332 - version = "3.18.0" 3333 - source = "registry+https://github.com/rust-lang/crates.io-index" 3334 - checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" 3335 - dependencies = [ 3336 - "cfg-if", 3337 - "fastrand", 3338 - "getrandom 0.3.1", 3339 - "once_cell", 3340 - "rustix", 3341 - "windows-sys 0.59.0", 3342 - ] 3343 - 3344 - [[package]] 3345 - name = "thiserror" 3346 - version = "1.0.69" 3347 - source = "registry+https://github.com/rust-lang/crates.io-index" 3348 - checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 3349 - dependencies = [ 3350 - "thiserror-impl", 3351 - ] 3352 - 3353 - [[package]] 3354 - name = "thiserror-impl" 3355 - version = "1.0.69" 3356 - source = "registry+https://github.com/rust-lang/crates.io-index" 3357 - checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 3358 - dependencies = [ 3359 - "proc-macro2", 3360 - "quote", 3361 - "syn", 3362 - ] 3363 - 3364 - [[package]] 3365 - name = "thread_local" 3366 - version = "1.1.8" 3367 - source = "registry+https://github.com/rust-lang/crates.io-index" 3368 - checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 3369 - dependencies = [ 3370 - "cfg-if", 3371 - "once_cell", 3372 - ] 3373 - 3374 - [[package]] 3375 - name = "time" 3376 - version = "0.3.39" 3377 - source = "registry+https://github.com/rust-lang/crates.io-index" 3378 - checksum = "dad298b01a40a23aac4580b67e3dbedb7cc8402f3592d7f49469de2ea4aecdd8" 3379 - dependencies = [ 3380 - "deranged", 3381 - "itoa", 3382 - "num-conv", 3383 - "powerfmt", 3384 - "serde", 3385 - "time-core", 3386 - "time-macros", 3387 - ] 3388 - 3389 - [[package]] 3390 - name = "time-core" 3391 - version = "0.1.3" 3392 - source = "registry+https://github.com/rust-lang/crates.io-index" 3393 - checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" 3394 - 3395 - [[package]] 3396 - name = "time-macros" 3397 - version = "0.2.20" 3398 - source = "registry+https://github.com/rust-lang/crates.io-index" 3399 - checksum = "e8093bc3e81c3bc5f7879de09619d06c9a5a5e45ca44dfeeb7225bae38005c5c" 3400 - dependencies = [ 3401 - "num-conv", 3402 - "time-core", 3403 - ] 3404 - 3405 - [[package]] 3406 - name = "tinystr" 3407 - version = "0.7.6" 3408 - source = "registry+https://github.com/rust-lang/crates.io-index" 3409 - checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 3410 - dependencies = [ 3411 - "displaydoc", 3412 - "zerovec", 3413 - ] 3414 - 3415 - [[package]] 3416 - name = "tinyvec" 3417 - version = "1.9.0" 3418 - source = "registry+https://github.com/rust-lang/crates.io-index" 3419 - checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" 3420 - dependencies = [ 3421 - "tinyvec_macros", 3422 - ] 3423 - 3424 - [[package]] 3425 - name = "tinyvec_macros" 3426 - version = "0.1.1" 3427 - source = "registry+https://github.com/rust-lang/crates.io-index" 3428 - checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 3429 - 3430 - [[package]] 3431 - name = "tokio" 3432 - version = "1.44.1" 3433 - source = "registry+https://github.com/rust-lang/crates.io-index" 3434 - checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" 3435 - dependencies = [ 3436 - "backtrace", 3437 - "bytes", 3438 - "libc", 3439 - "mio", 3440 - "parking_lot", 3441 - "pin-project-lite", 3442 - "signal-hook-registry", 3443 - "socket2", 3444 - "tokio-macros", 3445 - "windows-sys 0.52.0", 3446 - ] 3447 - 3448 - [[package]] 3449 - name = "tokio-macros" 3450 - version = "2.5.0" 3451 - source = "registry+https://github.com/rust-lang/crates.io-index" 3452 - checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 3453 - dependencies = [ 3454 - "proc-macro2", 3455 - "quote", 3456 - "syn", 3457 - ] 3458 - 3459 - [[package]] 3460 - name = "tokio-native-tls" 3461 - version = "0.3.1" 3462 - source = "registry+https://github.com/rust-lang/crates.io-index" 3463 - checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" 3464 - dependencies = [ 3465 - "native-tls", 3466 - "tokio", 3467 - ] 3468 - 3469 - [[package]] 3470 - name = "tokio-rustls" 3471 - version = "0.24.1" 3472 - source = "registry+https://github.com/rust-lang/crates.io-index" 3473 - checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" 3474 - dependencies = [ 3475 - "rustls 0.21.12", 3476 - "tokio", 3477 - ] 3478 - 3479 - [[package]] 3480 - name = "tokio-rustls" 3481 - version = "0.26.2" 3482 - source = "registry+https://github.com/rust-lang/crates.io-index" 3483 - checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 3484 - dependencies = [ 3485 - "rustls 0.23.28", 3486 - "tokio", 3487 - ] 3488 - 3489 - [[package]] 3490 - name = "tokio-tungstenite" 3491 - version = "0.20.1" 3492 - source = "registry+https://github.com/rust-lang/crates.io-index" 3493 - checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" 3494 - dependencies = [ 3495 - "futures-util", 3496 - "log", 3497 - "rustls 0.21.12", 3498 - "rustls-native-certs", 3499 - "tokio", 3500 - "tokio-rustls 0.24.1", 3501 - "tungstenite", 3502 - "webpki-roots", 3503 - ] 3504 - 3505 - [[package]] 3506 - name = "tokio-util" 3507 - version = "0.7.14" 3508 - source = "registry+https://github.com/rust-lang/crates.io-index" 3509 - checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" 3510 - dependencies = [ 3511 - "bytes", 3512 - "futures-core", 3513 - "futures-sink", 3514 - "pin-project-lite", 3515 - "tokio", 3516 - ] 3517 - 3518 - [[package]] 3519 - name = "tower" 3520 - version = "0.5.2" 3521 - source = "registry+https://github.com/rust-lang/crates.io-index" 3522 - checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 3523 - dependencies = [ 3524 - "futures-core", 3525 - "futures-util", 3526 - "pin-project-lite", 3527 - "sync_wrapper", 3528 - "tokio", 3529 - "tower-layer", 3530 - "tower-service", 3531 - ] 3532 - 3533 - [[package]] 3534 - name = "tower-layer" 3535 - version = "0.3.3" 3536 - source = "registry+https://github.com/rust-lang/crates.io-index" 3537 - checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 3538 - 3539 - [[package]] 3540 - name = "tower-service" 3541 - version = "0.3.3" 3542 - source = "registry+https://github.com/rust-lang/crates.io-index" 3543 - checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 3544 - 3545 - [[package]] 3546 - name = "tracing" 3547 - version = "0.1.41" 3548 - source = "registry+https://github.com/rust-lang/crates.io-index" 3549 - checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 3550 - dependencies = [ 3551 - "log", 3552 - "pin-project-lite", 3553 - "tracing-attributes", 3554 - "tracing-core", 3555 - ] 3556 - 3557 - [[package]] 3558 - name = "tracing-attributes" 3559 - version = "0.1.28" 3560 - source = "registry+https://github.com/rust-lang/crates.io-index" 3561 - checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" 3562 - dependencies = [ 3563 - "proc-macro2", 3564 - "quote", 3565 - "syn", 3566 - ] 3567 - 3568 - [[package]] 3569 - name = "tracing-core" 3570 - version = "0.1.33" 3571 - source = "registry+https://github.com/rust-lang/crates.io-index" 3572 - checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 3573 - dependencies = [ 3574 - "once_cell", 3575 - "valuable", 3576 - ] 3577 - 3578 - [[package]] 3579 - name = "tracing-log" 3580 - version = "0.2.0" 3581 - source = "registry+https://github.com/rust-lang/crates.io-index" 3582 - checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 3583 - dependencies = [ 3584 - "log", 3585 - "once_cell", 3586 - "tracing-core", 3587 - ] 3588 - 3589 - [[package]] 3590 - name = "tracing-subscriber" 3591 - version = "0.3.19" 3592 - source = "registry+https://github.com/rust-lang/crates.io-index" 3593 - checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 3594 - dependencies = [ 3595 - "matchers", 3596 - "nu-ansi-term", 3597 - "once_cell", 3598 - "regex", 3599 - "sharded-slab", 3600 - "smallvec", 3601 - "thread_local", 3602 - "tracing", 3603 - "tracing-core", 3604 - "tracing-log", 3605 - ] 3606 - 3607 - [[package]] 3608 - name = "trait-variant" 3609 - version = "0.1.2" 3610 - source = "registry+https://github.com/rust-lang/crates.io-index" 3611 - checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" 3612 - dependencies = [ 3613 - "proc-macro2", 3614 - "quote", 3615 - "syn", 3616 - ] 3617 - 3618 - [[package]] 3619 - name = "try-lock" 3620 - version = "0.2.5" 3621 - source = "registry+https://github.com/rust-lang/crates.io-index" 3622 - checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3623 - 3624 - [[package]] 3625 - name = "tungstenite" 3626 - version = "0.20.1" 3627 - source = "registry+https://github.com/rust-lang/crates.io-index" 3628 - checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" 3629 - dependencies = [ 3630 - "byteorder", 3631 - "bytes", 3632 - "data-encoding", 3633 - "http 0.2.12", 3634 - "httparse", 3635 - "log", 3636 - "rand 0.8.5", 3637 - "rustls 0.21.12", 3638 - "sha1", 3639 - "thiserror", 3640 - "url", 3641 - "utf-8", 3642 - ] 3643 - 3644 - [[package]] 3645 - name = "typenum" 3646 - version = "1.18.0" 3647 - source = "registry+https://github.com/rust-lang/crates.io-index" 3648 - checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 3649 - 3650 - [[package]] 3651 - name = "unicase" 3652 - version = "2.8.1" 3653 - source = "registry+https://github.com/rust-lang/crates.io-index" 3654 - checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" 3655 - 3656 - [[package]] 3657 - name = "unicode-ident" 3658 - version = "1.0.18" 3659 - source = "registry+https://github.com/rust-lang/crates.io-index" 3660 - checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 3661 - 3662 - [[package]] 3663 - name = "unicode-xid" 3664 - version = "0.2.6" 3665 - source = "registry+https://github.com/rust-lang/crates.io-index" 3666 - checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 3667 - 3668 - [[package]] 3669 - name = "universal-hash" 3670 - version = "0.5.1" 3671 - source = "registry+https://github.com/rust-lang/crates.io-index" 3672 - checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" 3673 - dependencies = [ 3674 - "crypto-common", 3675 - "subtle", 3676 - ] 3677 - 3678 - [[package]] 3679 - name = "unsigned-varint" 3680 - version = "0.8.0" 3681 - source = "registry+https://github.com/rust-lang/crates.io-index" 3682 - checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 3683 - 3684 - [[package]] 3685 - name = "untrusted" 3686 - version = "0.9.0" 3687 - source = "registry+https://github.com/rust-lang/crates.io-index" 3688 - checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 3689 - 3690 - [[package]] 3691 - name = "url" 3692 - version = "2.5.4" 3693 - source = "registry+https://github.com/rust-lang/crates.io-index" 3694 - checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 3695 - dependencies = [ 3696 - "form_urlencoded", 3697 - "idna", 3698 - "percent-encoding", 3699 - ] 3700 - 3701 - [[package]] 3702 - name = "utf-8" 3703 - version = "0.7.6" 3704 - source = "registry+https://github.com/rust-lang/crates.io-index" 3705 - checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 3706 - 3707 - [[package]] 3708 - name = "utf16_iter" 3709 - version = "1.0.5" 3710 - source = "registry+https://github.com/rust-lang/crates.io-index" 3711 - checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 3712 - 3713 - [[package]] 3714 - name = "utf8_iter" 3715 - version = "1.0.4" 3716 - source = "registry+https://github.com/rust-lang/crates.io-index" 3717 - checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 3718 - 3719 - [[package]] 3720 - name = "utf8parse" 3721 - version = "0.2.2" 3722 - source = "registry+https://github.com/rust-lang/crates.io-index" 3723 - checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3724 - 3725 - [[package]] 3726 - name = "uuid" 3727 - version = "1.15.1" 3728 - source = "registry+https://github.com/rust-lang/crates.io-index" 3729 - checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587" 3730 - dependencies = [ 3731 - "getrandom 0.3.1", 3732 - ] 3733 - 3734 - [[package]] 3735 - name = "v_htmlescape" 3736 - version = "0.15.8" 3737 - source = "registry+https://github.com/rust-lang/crates.io-index" 3738 - checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" 3739 - 3740 - [[package]] 3741 - name = "valuable" 3742 - version = "0.1.1" 3743 - source = "registry+https://github.com/rust-lang/crates.io-index" 3744 - checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 3745 - 3746 - [[package]] 3747 - name = "vcpkg" 3748 - version = "0.2.15" 3749 - source = "registry+https://github.com/rust-lang/crates.io-index" 3750 - checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 3751 - 3752 - [[package]] 3753 - name = "version_check" 3754 - version = "0.9.5" 3755 - source = "registry+https://github.com/rust-lang/crates.io-index" 3756 - checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 3757 - 3758 - [[package]] 3759 - name = "want" 3760 - version = "0.3.1" 3761 - source = "registry+https://github.com/rust-lang/crates.io-index" 3762 - checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 3763 - dependencies = [ 3764 - "try-lock", 3765 - ] 3766 - 3767 - [[package]] 3768 - name = "wasi" 3769 - version = "0.11.0+wasi-snapshot-preview1" 3770 - source = "registry+https://github.com/rust-lang/crates.io-index" 3771 - checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 3772 - 3773 - [[package]] 3774 - name = "wasi" 3775 - version = "0.13.3+wasi-0.2.2" 3776 - source = "registry+https://github.com/rust-lang/crates.io-index" 3777 - checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" 3778 - dependencies = [ 3779 - "wit-bindgen-rt", 3780 - ] 3781 - 3782 - [[package]] 3783 - name = "wasm-bindgen" 3784 - version = "0.2.100" 3785 - source = "registry+https://github.com/rust-lang/crates.io-index" 3786 - checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 3787 - dependencies = [ 3788 - "cfg-if", 3789 - "once_cell", 3790 - "rustversion", 3791 - "wasm-bindgen-macro", 3792 - ] 3793 - 3794 - [[package]] 3795 - name = "wasm-bindgen-backend" 3796 - version = "0.2.100" 3797 - source = "registry+https://github.com/rust-lang/crates.io-index" 3798 - checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 3799 - dependencies = [ 3800 - "bumpalo", 3801 - "log", 3802 - "proc-macro2", 3803 - "quote", 3804 - "syn", 3805 - "wasm-bindgen-shared", 3806 - ] 3807 - 3808 - [[package]] 3809 - name = "wasm-bindgen-futures" 3810 - version = "0.4.50" 3811 - source = "registry+https://github.com/rust-lang/crates.io-index" 3812 - checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" 3813 - dependencies = [ 3814 - "cfg-if", 3815 - "js-sys", 3816 - "once_cell", 3817 - "wasm-bindgen", 3818 - "web-sys", 3819 - ] 3820 - 3821 - [[package]] 3822 - name = "wasm-bindgen-macro" 3823 - version = "0.2.100" 3824 - source = "registry+https://github.com/rust-lang/crates.io-index" 3825 - checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 3826 - dependencies = [ 3827 - "quote", 3828 - "wasm-bindgen-macro-support", 3829 - ] 3830 - 3831 - [[package]] 3832 - name = "wasm-bindgen-macro-support" 3833 - version = "0.2.100" 3834 - source = "registry+https://github.com/rust-lang/crates.io-index" 3835 - checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 3836 - dependencies = [ 3837 - "proc-macro2", 3838 - "quote", 3839 - "syn", 3840 - "wasm-bindgen-backend", 3841 - "wasm-bindgen-shared", 3842 - ] 3843 - 3844 - [[package]] 3845 - name = "wasm-bindgen-shared" 3846 - version = "0.2.100" 3847 - source = "registry+https://github.com/rust-lang/crates.io-index" 3848 - checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 3849 - dependencies = [ 3850 - "unicode-ident", 3851 - ] 3852 - 3853 - [[package]] 3854 - name = "web-sys" 3855 - version = "0.3.77" 3856 - source = "registry+https://github.com/rust-lang/crates.io-index" 3857 - checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 3858 - dependencies = [ 3859 - "js-sys", 3860 - "wasm-bindgen", 3861 - ] 3862 - 3863 - [[package]] 3864 - name = "web-time" 3865 - version = "1.1.0" 3866 - source = "registry+https://github.com/rust-lang/crates.io-index" 3867 - checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 3868 - dependencies = [ 3869 - "js-sys", 3870 - "wasm-bindgen", 3871 - ] 3872 - 3873 - [[package]] 3874 - name = "webpki-roots" 3875 - version = "0.25.4" 3876 - source = "registry+https://github.com/rust-lang/crates.io-index" 3877 - checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" 3878 - 3879 - [[package]] 3880 - name = "widestring" 3881 - version = "1.2.0" 3882 - source = "registry+https://github.com/rust-lang/crates.io-index" 3883 - checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" 3884 - 3885 - [[package]] 3886 - name = "winapi" 3887 - version = "0.3.9" 3888 - source = "registry+https://github.com/rust-lang/crates.io-index" 3889 - checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 3890 - dependencies = [ 3891 - "winapi-i686-pc-windows-gnu", 3892 - "winapi-x86_64-pc-windows-gnu", 3893 - ] 3894 - 3895 - [[package]] 3896 - name = "winapi-i686-pc-windows-gnu" 3897 - version = "0.4.0" 3898 - source = "registry+https://github.com/rust-lang/crates.io-index" 3899 - checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 3900 - 3901 - [[package]] 3902 - name = "winapi-x86_64-pc-windows-gnu" 3903 - version = "0.4.0" 3904 - source = "registry+https://github.com/rust-lang/crates.io-index" 3905 - checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 3906 - 3907 - [[package]] 3908 - name = "windows" 3909 - version = "0.52.0" 3910 - source = "registry+https://github.com/rust-lang/crates.io-index" 3911 - checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" 3912 - dependencies = [ 3913 - "windows-core 0.52.0", 3914 - "windows-targets 0.52.6", 3915 - ] 3916 - 3917 - [[package]] 3918 - name = "windows" 3919 - version = "0.58.0" 3920 - source = "registry+https://github.com/rust-lang/crates.io-index" 3921 - checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" 3922 - dependencies = [ 3923 - "windows-core 0.58.0", 3924 - "windows-targets 0.52.6", 3925 - ] 3926 - 3927 - [[package]] 3928 - name = "windows-core" 3929 - version = "0.52.0" 3930 - source = "registry+https://github.com/rust-lang/crates.io-index" 3931 - checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 3932 - dependencies = [ 3933 - "windows-targets 0.52.6", 3934 - ] 3935 - 3936 - [[package]] 3937 - name = "windows-core" 3938 - version = "0.58.0" 3939 - source = "registry+https://github.com/rust-lang/crates.io-index" 3940 - checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" 3941 - dependencies = [ 3942 - "windows-implement", 3943 - "windows-interface", 3944 - "windows-result", 3945 - "windows-strings", 3946 - "windows-targets 0.52.6", 3947 - ] 3948 - 3949 - [[package]] 3950 - name = "windows-implement" 3951 - version = "0.58.0" 3952 - source = "registry+https://github.com/rust-lang/crates.io-index" 3953 - checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" 3954 - dependencies = [ 3955 - "proc-macro2", 3956 - "quote", 3957 - "syn", 3958 - ] 3959 - 3960 - [[package]] 3961 - name = "windows-interface" 3962 - version = "0.58.0" 3963 - source = "registry+https://github.com/rust-lang/crates.io-index" 3964 - checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" 3965 - dependencies = [ 3966 - "proc-macro2", 3967 - "quote", 3968 - "syn", 3969 - ] 3970 - 3971 - [[package]] 3972 - name = "windows-link" 3973 - version = "0.1.0" 3974 - source = "registry+https://github.com/rust-lang/crates.io-index" 3975 - checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" 3976 - 3977 - [[package]] 3978 - name = "windows-registry" 3979 - version = "0.2.0" 3980 - source = "registry+https://github.com/rust-lang/crates.io-index" 3981 - checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" 3982 - dependencies = [ 3983 - "windows-result", 3984 - "windows-strings", 3985 - "windows-targets 0.52.6", 3986 - ] 3987 - 3988 - [[package]] 3989 - name = "windows-result" 3990 - version = "0.2.0" 3991 - source = "registry+https://github.com/rust-lang/crates.io-index" 3992 - checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" 3993 - dependencies = [ 3994 - "windows-targets 0.52.6", 3995 - ] 3996 - 3997 - [[package]] 3998 - name = "windows-strings" 3999 - version = "0.1.0" 4000 - source = "registry+https://github.com/rust-lang/crates.io-index" 4001 - checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" 4002 - dependencies = [ 4003 - "windows-result", 4004 - "windows-targets 0.52.6", 4005 - ] 4006 - 4007 - [[package]] 4008 - name = "windows-sys" 4009 - version = "0.48.0" 4010 - source = "registry+https://github.com/rust-lang/crates.io-index" 4011 - checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 4012 - dependencies = [ 4013 - "windows-targets 0.48.5", 4014 - ] 4015 - 4016 - [[package]] 4017 - name = "windows-sys" 4018 - version = "0.52.0" 4019 - source = "registry+https://github.com/rust-lang/crates.io-index" 4020 - checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 4021 - dependencies = [ 4022 - "windows-targets 0.52.6", 4023 - ] 4024 - 4025 - [[package]] 4026 - name = "windows-sys" 4027 - version = "0.59.0" 4028 - source = "registry+https://github.com/rust-lang/crates.io-index" 4029 - checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 4030 - dependencies = [ 4031 - "windows-targets 0.52.6", 4032 - ] 4033 - 4034 - [[package]] 4035 - name = "windows-targets" 4036 - version = "0.48.5" 4037 - source = "registry+https://github.com/rust-lang/crates.io-index" 4038 - checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 4039 - dependencies = [ 4040 - "windows_aarch64_gnullvm 0.48.5", 4041 - "windows_aarch64_msvc 0.48.5", 4042 - "windows_i686_gnu 0.48.5", 4043 - "windows_i686_msvc 0.48.5", 4044 - "windows_x86_64_gnu 0.48.5", 4045 - "windows_x86_64_gnullvm 0.48.5", 4046 - "windows_x86_64_msvc 0.48.5", 4047 - ] 4048 - 4049 - [[package]] 4050 - name = "windows-targets" 4051 - version = "0.52.6" 4052 - source = "registry+https://github.com/rust-lang/crates.io-index" 4053 - checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 4054 - dependencies = [ 4055 - "windows_aarch64_gnullvm 0.52.6", 4056 - "windows_aarch64_msvc 0.52.6", 4057 - "windows_i686_gnu 0.52.6", 4058 - "windows_i686_gnullvm", 4059 - "windows_i686_msvc 0.52.6", 4060 - "windows_x86_64_gnu 0.52.6", 4061 - "windows_x86_64_gnullvm 0.52.6", 4062 - "windows_x86_64_msvc 0.52.6", 4063 - ] 4064 - 4065 - [[package]] 4066 - name = "windows_aarch64_gnullvm" 4067 - version = "0.48.5" 4068 - source = "registry+https://github.com/rust-lang/crates.io-index" 4069 - checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 4070 - 4071 - [[package]] 4072 - name = "windows_aarch64_gnullvm" 4073 - version = "0.52.6" 4074 - source = "registry+https://github.com/rust-lang/crates.io-index" 4075 - checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 4076 - 4077 - [[package]] 4078 - name = "windows_aarch64_msvc" 4079 - version = "0.48.5" 4080 - source = "registry+https://github.com/rust-lang/crates.io-index" 4081 - checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 4082 - 4083 - [[package]] 4084 - name = "windows_aarch64_msvc" 4085 - version = "0.52.6" 4086 - source = "registry+https://github.com/rust-lang/crates.io-index" 4087 - checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 4088 - 4089 - [[package]] 4090 - name = "windows_i686_gnu" 4091 - version = "0.48.5" 4092 - source = "registry+https://github.com/rust-lang/crates.io-index" 4093 - checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 4094 - 4095 - [[package]] 4096 - name = "windows_i686_gnu" 4097 - version = "0.52.6" 4098 - source = "registry+https://github.com/rust-lang/crates.io-index" 4099 - checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 4100 - 4101 - [[package]] 4102 - name = "windows_i686_gnullvm" 4103 - version = "0.52.6" 4104 - source = "registry+https://github.com/rust-lang/crates.io-index" 4105 - checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 4106 - 4107 - [[package]] 4108 - name = "windows_i686_msvc" 4109 - version = "0.48.5" 4110 - source = "registry+https://github.com/rust-lang/crates.io-index" 4111 - checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 4112 - 4113 - [[package]] 4114 - name = "windows_i686_msvc" 4115 - version = "0.52.6" 4116 - source = "registry+https://github.com/rust-lang/crates.io-index" 4117 - checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 4118 - 4119 - [[package]] 4120 - name = "windows_x86_64_gnu" 4121 - version = "0.48.5" 4122 - source = "registry+https://github.com/rust-lang/crates.io-index" 4123 - checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 4124 - 4125 - [[package]] 4126 - name = "windows_x86_64_gnu" 4127 - version = "0.52.6" 4128 - source = "registry+https://github.com/rust-lang/crates.io-index" 4129 - checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 4130 - 4131 - [[package]] 4132 - name = "windows_x86_64_gnullvm" 4133 - version = "0.48.5" 4134 - source = "registry+https://github.com/rust-lang/crates.io-index" 4135 - checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 4136 - 4137 - [[package]] 4138 - name = "windows_x86_64_gnullvm" 4139 - version = "0.52.6" 4140 - source = "registry+https://github.com/rust-lang/crates.io-index" 4141 - checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 4142 - 4143 - [[package]] 4144 - name = "windows_x86_64_msvc" 4145 - version = "0.48.5" 4146 - source = "registry+https://github.com/rust-lang/crates.io-index" 4147 - checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 4148 - 4149 - [[package]] 4150 - name = "windows_x86_64_msvc" 4151 - version = "0.52.6" 4152 - source = "registry+https://github.com/rust-lang/crates.io-index" 4153 - checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 4154 - 4155 - [[package]] 4156 - name = "winnow" 4157 - version = "0.7.4" 4158 - source = "registry+https://github.com/rust-lang/crates.io-index" 4159 - checksum = "0e97b544156e9bebe1a0ffbc03484fc1ffe3100cbce3ffb17eac35f7cdd7ab36" 4160 - dependencies = [ 4161 - "memchr", 4162 - ] 4163 - 4164 - [[package]] 4165 - name = "winreg" 4166 - version = "0.50.0" 4167 - source = "registry+https://github.com/rust-lang/crates.io-index" 4168 - checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 4169 - dependencies = [ 4170 - "cfg-if", 4171 - "windows-sys 0.48.0", 4172 - ] 4173 - 4174 - [[package]] 4175 - name = "wit-bindgen-rt" 4176 - version = "0.33.0" 4177 - source = "registry+https://github.com/rust-lang/crates.io-index" 4178 - checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" 4179 - dependencies = [ 4180 - "bitflags", 4181 - ] 4182 - 4183 - [[package]] 4184 - name = "write16" 4185 - version = "1.0.0" 4186 - source = "registry+https://github.com/rust-lang/crates.io-index" 4187 - checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 4188 - 4189 - [[package]] 4190 - name = "writeable" 4191 - version = "0.5.5" 4192 - source = "registry+https://github.com/rust-lang/crates.io-index" 4193 - checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 4194 - 4195 - [[package]] 4196 - name = "yoke" 4197 - version = "0.7.5" 4198 - source = "registry+https://github.com/rust-lang/crates.io-index" 4199 - checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" 4200 - dependencies = [ 4201 - "serde", 4202 - "stable_deref_trait", 4203 - "yoke-derive", 4204 - "zerofrom", 4205 - ] 4206 - 4207 - [[package]] 4208 - name = "yoke-derive" 4209 - version = "0.7.5" 4210 - source = "registry+https://github.com/rust-lang/crates.io-index" 4211 - checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" 4212 - dependencies = [ 4213 - "proc-macro2", 4214 - "quote", 4215 - "syn", 4216 - "synstructure", 4217 - ] 4218 - 4219 - [[package]] 4220 - name = "zerocopy" 4221 - version = "0.7.35" 4222 - source = "registry+https://github.com/rust-lang/crates.io-index" 4223 - checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 4224 - dependencies = [ 4225 - "byteorder", 4226 - "zerocopy-derive 0.7.35", 4227 - ] 4228 - 4229 - [[package]] 4230 - name = "zerocopy" 4231 - version = "0.8.24" 4232 - source = "registry+https://github.com/rust-lang/crates.io-index" 4233 - checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" 4234 - dependencies = [ 4235 - "zerocopy-derive 0.8.24", 4236 - ] 4237 - 4238 - [[package]] 4239 - name = "zerocopy-derive" 4240 - version = "0.7.35" 4241 - source = "registry+https://github.com/rust-lang/crates.io-index" 4242 - checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 4243 - dependencies = [ 4244 - "proc-macro2", 4245 - "quote", 4246 - "syn", 4247 - ] 4248 - 4249 - [[package]] 4250 - name = "zerocopy-derive" 4251 - version = "0.8.24" 4252 - source = "registry+https://github.com/rust-lang/crates.io-index" 4253 - checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" 4254 - dependencies = [ 4255 - "proc-macro2", 4256 - "quote", 4257 - "syn", 4258 - ] 4259 - 4260 - [[package]] 4261 - name = "zerofrom" 4262 - version = "0.1.6" 4263 - source = "registry+https://github.com/rust-lang/crates.io-index" 4264 - checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 4265 - dependencies = [ 4266 - "zerofrom-derive", 4267 - ] 4268 - 4269 - [[package]] 4270 - name = "zerofrom-derive" 4271 - version = "0.1.6" 4272 - source = "registry+https://github.com/rust-lang/crates.io-index" 4273 - checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 4274 - dependencies = [ 4275 - "proc-macro2", 4276 - "quote", 4277 - "syn", 4278 - "synstructure", 4279 - ] 4280 - 4281 - [[package]] 4282 - name = "zeroize" 4283 - version = "1.8.1" 4284 - source = "registry+https://github.com/rust-lang/crates.io-index" 4285 - checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 4286 - dependencies = [ 4287 - "serde", 4288 - ] 4289 - 4290 - [[package]] 4291 - name = "zerovec" 4292 - version = "0.10.4" 4293 - source = "registry+https://github.com/rust-lang/crates.io-index" 4294 - checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 4295 - dependencies = [ 4296 - "yoke", 4297 - "zerofrom", 4298 - "zerovec-derive", 4299 - ] 4300 - 4301 - [[package]] 4302 - name = "zerovec-derive" 4303 - version = "0.10.3" 4304 - source = "registry+https://github.com/rust-lang/crates.io-index" 4305 - checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 4306 - dependencies = [ 4307 - "proc-macro2", 4308 - "quote", 4309 - "syn", 4310 - ] 4311 - 4312 - [[package]] 4313 - name = "zstd" 4314 - version = "0.13.3" 4315 - source = "registry+https://github.com/rust-lang/crates.io-index" 4316 - checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" 4317 - dependencies = [ 4318 - "zstd-safe", 4319 - ] 4320 - 4321 - [[package]] 4322 - name = "zstd-safe" 4323 - version = "7.2.3" 4324 - source = "registry+https://github.com/rust-lang/crates.io-index" 4325 - checksum = "f3051792fbdc2e1e143244dc28c60f73d8470e93f3f9cbd0ead44da5ed802722" 4326 - dependencies = [ 4327 - "zstd-sys", 4328 - ] 4329 - 4330 - [[package]] 4331 - name = "zstd-sys" 4332 - version = "2.0.14+zstd.1.5.7" 4333 - source = "registry+https://github.com/rust-lang/crates.io-index" 4334 - checksum = "8fb060d4926e4ac3a3ad15d864e99ceb5f343c6b34f5bd6d81ae6ed417311be5" 4335 - dependencies = [ 4336 - "cc", 4337 - "pkg-config", 4338 - ]
-45
Cargo.toml
··· 1 - [package] 2 - name = "nate-status" 3 - version = "0.1.0" 4 - edition = "2024" 5 - # Based on Bailey Townsend's Rusty Statusphere example app 6 - # https://github.com/fatfingers23/rusty_statusphere_example_app 7 - 8 - [dependencies] 9 - actix-files = "0.6.6" 10 - actix-session = { version = "0.10", features = ["cookie-session"] } 11 - actix-web = "4.10.2" 12 - actix-multipart = "0.6" 13 - anyhow = "1.0.97" 14 - askama = "0.13" 15 - atrium-common = "0.1.1" 16 - atrium-api = "0.25.0" 17 - atrium-identity = "0.1.3" 18 - atrium-oauth = "0.1.0" 19 - chrono = "0.4.40" 20 - env_logger = "0.11.7" 21 - hickory-resolver = "0.24.1" 22 - log = "0.4.27" 23 - serde = { version = "1.0.219", features = ["derive"] } 24 - serde_json = "1.0.140" 25 - rocketman = "0.2.0" 26 - tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 27 - futures-util = "0.3" 28 - dotenv = "0.15.0" 29 - thiserror = "1.0.69" 30 - async-sqlite = "0.5.0" 31 - async-trait = "0.1.88" 32 - rand = "0.8" 33 - reqwest = { version = "0.12", features = ["json"] } 34 - once_cell = "1.19" 35 - hmac = "0.12" 36 - sha2 = "0.10" 37 - hex = "0.4" 38 - url = "2.5" 39 - 40 - [build-dependencies] 41 - askama = "0.13" 42 - 43 - 44 - [profile.dev.package.askama_derive] 45 - opt-level = 3
+22 -22
README.md
··· 1 - # quickslice-status 1 + # status 2 2 3 3 a status app for bluesky, built with [quickslice](https://github.com/bigmoves/quickslice). 4 4 5 - **live:** https://quickslice-status.pages.dev 5 + **live:** https://status.zzstoatzz.io 6 6 7 7 ## architecture 8 8 ··· 36 36 37 37 register an oauth client in the quickslice admin ui at `https://zzstoatzz-quickslice-status.fly.dev/` 38 38 39 - redirect uri: `https://quickslice-status.pages.dev/callback` 39 + redirect uri: `https://status.zzstoatzz.io/callback` 40 + 41 + ## lexicons 40 42 41 - ## lexicon 43 + ### io.zzstoatzz.status.record 42 44 43 - uses `io.zzstoatzz.status` lexicon for user statuses. 45 + user status records with emoji, optional text, and optional expiration. 44 46 45 47 ```json 46 48 { 47 - "lexicon": 1, 48 - "id": "io.zzstoatzz.status", 49 - "defs": { 50 - "main": { 51 - "type": "record", 52 - "key": "self", 53 - "record": { 54 - "type": "object", 55 - "required": ["status", "createdAt"], 56 - "properties": { 57 - "status": { "type": "string", "maxLength": 128 }, 58 - "createdAt": { "type": "string", "format": "datetime" } 59 - } 60 - } 61 - } 62 - } 49 + "emoji": "🔥", 50 + "text": "shipping code", 51 + "createdAt": "2025-12-13T12:00:00Z" 52 + } 53 + ``` 54 + 55 + ### io.zzstoatzz.status.preferences 56 + 57 + user preferences for display settings. 58 + 59 + ```json 60 + { 61 + "accentColor": "#4a9eff", 62 + "theme": "dark" 63 63 } 64 64 ``` 65 65 ··· 71 71 python -m http.server 8000 72 72 ``` 73 73 74 - for oauth to work locally, you'd need to register a separate oauth client with `http://localhost:8000/callback` as the redirect uri and update `CONFIG.clientId` in `app.js`. 74 + for oauth to work locally, register a separate oauth client with `http://localhost:8000/callback` as the redirect uri and update `CONFIG.clientId` in `app.js`.
-34
fly.review.toml
··· 1 - app = "zzstoatzz-status" 2 - primary_region = "ewr" 3 - 4 - [build] 5 - dockerfile = "Dockerfile" 6 - 7 - [env] 8 - SERVER_PORT = "8080" 9 - SERVER_HOST = "0.0.0.0" 10 - DATABASE_URL = "sqlite:///data/status.db" 11 - ENABLE_FIREHOSE = "true" 12 - DEV_MODE = "true" 13 - # OAUTH_REDIRECT_BASE will be set dynamically by the workflow 14 - 15 - [http_service] 16 - internal_port = 8080 17 - force_https = true 18 - auto_stop_machines = true 19 - auto_start_machines = true 20 - min_machines_running = 1 21 - 22 - [http_service.concurrency] 23 - type = "requests" 24 - hard_limit = 250 25 - soft_limit = 200 26 - 27 - [[mounts]] 28 - source = "status_data" 29 - destination = "/data" 30 - 31 - [[vm]] 32 - cpu_kind = "shared" 33 - cpus = 1 34 - memory_mb = 256
-21
justfile
··· 1 - watch: 2 - cargo watch -x run -w src -w templates 3 - 4 - dev: 5 - SERVER_PORT=3000 cargo watch -x run -w src -w templates 6 - 7 - deploy: 8 - fly deploy 9 - 10 - lint: 11 - cargo clippy -- -D warnings 12 - 13 - fmt: 14 - cargo fmt 15 - 16 - clean: 17 - cargo clean 18 - rm -f status.db 19 - 20 - test: 21 - cargo test
-152
progress.md
··· 1 - # Status App Progress 2 - 3 - ## Completed ✅ 4 - 5 - ### Core Functionality 6 - - Forked from Bailey Townsend's Rusty Statusphere 7 - - Multi-user support with BlueSky OAuth authentication 8 - - Custom lexicon: `io.zzstoatzz.status.record` with emoji + optional text 9 - - Status expiration times (30min to 1 week) 10 - - Real-time updates via Jetstream firehose 11 - - Public profiles at status.zzstoatzz.io/@handle 12 - - Global feed showing all statuses 13 - - Database persistence on Fly.io 14 - 15 - ### OAuth Implementation 16 - - Fixed OAuth callback error handling (missing 'code' field) 17 - - Reverted to working state with `transition:generic` scope 18 - - Research complete: No granular permissions available yet in AT Protocol 19 - - Must use broad permissions until Auth Scopes feature ships 20 - 21 - ### UI/UX 22 - - One-time walkthrough for new users (stored in localStorage) 23 - - Fixed double @ symbol in feed username links 24 - - Fixed feed ordering (newest first by startedAt) 25 - - Emoji picker with visual selection 26 - - Status expiration display with relative times 27 - 28 - ## Current State 🚧 29 - - App deployed and functional at status.zzstoatzz.io 30 - - OAuth works but requires broad permissions (AT Protocol limitation) 31 - - All core features operational 32 - 33 - ## Today's Progress (Sept 1, 2025) 34 - - Forked from Bailey's emoji-only statusphere 35 - - Created custom lexicon with text + expiration support 36 - - Added multi-user OAuth authentication 37 - - Implemented emoji picker with keyword search 38 - - Fixed mobile responsiveness 39 - - Added status expiration (30min to 1 week) 40 - - Set up CI/CD with GitHub Actions 41 - - Renamed repo to "status" 42 - - Improved delete UX (removed confusing clear button) 43 - - Made feed handles visually distinct 44 - - Updated link previews to be lowercase and include actual status 45 - - Cleaned up dead code from original fork 46 - - Posted thread about the launch 47 - 48 - ## Progress Update (Sept 2, 2025) 49 - 50 - ### Major Features Added 51 - - **Custom Emoji Support**: Integrated 1600+ animated emojis from bufo.zone 52 - - Scraped and stored in `/static/emojis/` 53 - - Searchable in emoji picker 54 - - Supports GIF animation 55 - - No database needed - served directly from filesystem 56 - - **Infinite Scrolling**: Global feed now loads forever 57 - - Added `/api/feed` endpoint with pagination 58 - - Smooth loading with "beginning of time" indicator 59 - - Handles large datasets efficiently 60 - - **Theme Consistency**: Added theme toggle indicator across all pages 61 - - **Performance Optimization**: Added database indexes on critical columns 62 - - `idx_status_startedAt` for feed queries 63 - - `idx_status_authorDid_startedAt` for user queries 64 - 65 - ### Bug Fixes 66 - - Fixed favicon not loading in production 67 - - Fixed custom emoji layout issues in picker 68 - - Fixed theme toggle icons being invisible 69 - - Removed unused CSS file and public directory 70 - - Suppressed dead_code warning for auto-generated lexicons 71 - 72 - ### Code Quality Improvements 73 - - Created 5 GitHub issues for technical debt: 74 - - ✅ #1: Database indexes (COMPLETED) 75 - - #2: Excessive unwrap() usage (57 instances) 76 - - #3: Duplicated handle resolution code 77 - - #4: Hardcoded configuration values 78 - - #5: No rate limiting on API endpoints 79 - - Cleaned up unused `public/css` directory 80 - - Removed hardcoded OWNER_DID references 81 - 82 - ## Next Steps 📋 83 - 84 - ### Immediate 85 - 1. **Persistent Session Storage**: Users currently must re-login each visit 86 - 2. **UI Polish**: Small visual improvements needed 87 - 88 - ### Location Feature (Proposed) 89 - - Add optional location to statuses 90 - - Browser geolocation API integration 91 - - Privacy controls (location blurring) 92 - - Future: Integrate with SmokeSignal's location standards 93 - - Vision: Global map of statuses 94 - 95 - ### Future Considerations 96 - - Migrate to granular OAuth scopes when available 97 - - H3 hexagon location support 98 - - SmokeSignal event integration 99 - - Location-based discovery 100 - 101 - ## Progress Update (Sept 2, 2025 - Evening) 102 - 103 - ### Testing Infrastructure & Resilience 104 - - **Test Framework Setup**: Established comprehensive testing with `just test` command 105 - - 9 tests covering rate limiting, error handling, and API endpoints 106 - - All tests passing 107 - - **Rate Limiting**: Implemented token bucket algorithm 108 - - 30 requests per minute per IP address on `/status` endpoint 109 - - Prevents spam and abuse 110 - - Closes GitHub issue #5 111 - - **Error Handling**: Centralized error handling with `AppError` enum 112 - - Consistent error responses across the application 113 - - Better debugging and user feedback 114 - 115 - ### Admin Moderation System 116 - - **Soft Hide Capability**: Added ability to hide inappropriate content 117 - - Posts remain in database but excluded from global feed 118 - - Admin DID hardcoded: `did:plc:xbtmt2zjwlrfegqvch7fboei` (zzstoatzz.io) 119 - - `/admin/hide-status` endpoint for toggling visibility 120 - - Hide button in UI visible only to admin 121 - - Confirmation dialog before hiding 122 - 123 - ### UI Improvements 124 - - **Fixed Emoji Alignment**: Resolved custom emoji sizing issues in status history 125 - - Standardized container dimensions (1.5rem x 1.5rem for history items) 126 - - Consistent layout regardless of emoji type 127 - 128 - ### DevOps & CI/CD 129 - - **Review Apps**: Set up automatic preview deployments for PRs 130 - - Uses GitHub Actions with `superfly/fly-pr-review-apps@1.2.1` 131 - - Deploys to `pr-<number>-zzstoatzz-status.fly.dev` 132 - - Smaller resources for review apps (256MB RAM) 133 - - Updated FLY_API_TOKEN to org-level token for app creation 134 - 135 - ### Code Quality 136 - - **Refactoring**: Cleaned up parameter passing 137 - - Replaced verbose `&dyn rusqlite::ToSql` with `rusqlite::params!` macro 138 - - More idiomatic Rust code 139 - 140 - ## Technical Debt 141 - - ✅ ~~No rate limiting on API endpoints~~ (RESOLVED with issue #5) 142 - - OAuth scopes too broad (waiting on AT Protocol) 143 - - Session persistence needed 144 - - Location feature architecture planned but not implemented 145 - - #2: Excessive unwrap() usage (57 instances) 146 - - #3: Duplicated handle resolution code 147 - - #4: Hardcoded configuration values 148 - 149 - ## Resources 150 - - OAuth research: `/tmp/atproto-oauth-research/` 151 - - Location proposal: `/tmp/atproto-oauth-research/location_integration_proposal.md` 152 - - PR #7: Testing, rate limiting, and moderation features
-3
rust-toolchain.toml
··· 1 - [toolchain] 2 - channel = "stable" 3 - version = "1.85.1"
-55
scripts/add_custom_emoji.py
··· 1 - #!/usr/bin/env python3 2 - """ 3 - Script to add custom emojis to the database 4 - """ 5 - import sqlite3 6 - import sys 7 - import time 8 - from pathlib import Path 9 - 10 - def add_custom_emoji(db_path, name, filename, alt_text=None, category="custom"): 11 - """Add a custom emoji to the database""" 12 - conn = sqlite3.connect(db_path) 13 - cursor = conn.cursor() 14 - 15 - # Check if emoji already exists 16 - cursor.execute("SELECT COUNT(*) FROM custom_emojis WHERE name = ?", (name,)) 17 - if cursor.fetchone()[0] > 0: 18 - print(f"Emoji '{name}' already exists, skipping...") 19 - conn.close() 20 - return False 21 - 22 - # Add the emoji 23 - added_at = int(time.time()) 24 - cursor.execute( 25 - "INSERT INTO custom_emojis (name, filename, alt_text, category, addedAt) VALUES (?, ?, ?, ?, ?)", 26 - (name, filename, alt_text, category, added_at) 27 - ) 28 - 29 - conn.commit() 30 - conn.close() 31 - print(f"Added emoji '{name}' -> {filename}") 32 - return True 33 - 34 - def main(): 35 - # Default database path 36 - db_path = "status.db" 37 - 38 - # Example custom emojis to add 39 - emojis = [ 40 - ("partyparrot", "partyparrot.gif", "Party Parrot", "custom"), 41 - ("shipit", "shipit.png", "Ship It Squirrel", "custom"), 42 - ("blobheart", "blobheart.png", "Blob Heart", "custom"), 43 - ("rustacean", "rustacean.png", "Rust Crab", "custom"), 44 - ("dumpsterfire", "dumpsterfire.gif", "Dumpster Fire", "custom"), 45 - ] 46 - 47 - print(f"Adding custom emojis to {db_path}...") 48 - 49 - for name, filename, alt_text, category in emojis: 50 - add_custom_emoji(db_path, name, filename, alt_text, category) 51 - 52 - print("Done!") 53 - 54 - if __name__ == "__main__": 55 - main()
-90
scripts/register_emojis.py
··· 1 - #!/usr/bin/env python3 2 - # /// script 3 - # requires-python = ">=3.11" 4 - # dependencies = [] 5 - # /// 6 - """ 7 - Register all downloaded emoji images in the database 8 - """ 9 - 10 - import sqlite3 11 - import time 12 - from pathlib import Path 13 - 14 - 15 - def main(): 16 - # Setup paths 17 - script_dir = Path(__file__).parent 18 - project_root = script_dir.parent 19 - emojis_dir = project_root / "static" / "emojis" 20 - db_path = project_root / "statusphere.sqlite3" 21 - 22 - if not db_path.exists(): 23 - print(f"Error: Database not found at {db_path}") 24 - return 25 - 26 - # Get all image files 27 - image_files = [] 28 - for ext in ['*.png', '*.gif', '*.jpg', '*.jpeg', '*.webp']: 29 - image_files.extend(emojis_dir.glob(ext)) 30 - 31 - print(f"Found {len(image_files)} image files") 32 - 33 - # Connect to database 34 - conn = sqlite3.connect(db_path) 35 - cursor = conn.cursor() 36 - 37 - # Check what already exists 38 - cursor.execute("SELECT name FROM custom_emojis") 39 - existing = {row[0] for row in cursor.fetchall()} 40 - print(f"Already registered: {len(existing)} emojis") 41 - 42 - # Register new emojis 43 - added = 0 44 - skipped = 0 45 - timestamp = int(time.time()) 46 - 47 - for image_path in image_files: 48 - filename = image_path.name 49 - # Create a short name from filename 50 - name = filename.rsplit('.', 1)[0] 51 - # Truncate super long names 52 - if len(name) > 50: 53 - name = name[:47] + "..." 54 - 55 - if name in existing: 56 - skipped += 1 57 - continue 58 - 59 - # Determine mime type 60 - ext = filename.rsplit('.', 1)[-1].lower() 61 - mime_map = { 62 - 'png': 'image/png', 63 - 'gif': 'image/gif', 64 - 'jpg': 'image/jpeg', 65 - 'jpeg': 'image/jpeg', 66 - 'webp': 'image/webp' 67 - } 68 - mime_type = mime_map.get(ext, 'image/png') 69 - 70 - # Create alt text from name 71 - alt_text = name.replace('-', ' ').replace('_', ' ') 72 - 73 - cursor.execute( 74 - "INSERT INTO custom_emojis (name, filename, alt_text, category, addedAt) VALUES (?, ?, ?, ?, ?)", 75 - (name, filename, alt_text, 'bufo', timestamp) 76 - ) 77 - added += 1 78 - 79 - conn.commit() 80 - conn.close() 81 - 82 - print(f"✓ Added {added} new emojis") 83 - if skipped: 84 - print(f" Skipped {skipped} existing emojis") 85 - 86 - print(f"\nTotal emojis in database now: {len(existing) + added}") 87 - 88 - 89 - if __name__ == "__main__": 90 - main()
-137
scripts/scrape_bufo_emojis.py
··· 1 - #!/usr/bin/env python3 2 - # /// script 3 - # requires-python = ">=3.11" 4 - # dependencies = [ 5 - # "httpx", 6 - # "beautifulsoup4", 7 - # "rich", 8 - # ] 9 - # /// 10 - """ 11 - Scrape all custom emoji images from bufo.zone and download them to static/emojis. 12 - """ 13 - 14 - import asyncio 15 - import re 16 - from pathlib import Path 17 - 18 - import httpx 19 - from bs4 import BeautifulSoup 20 - from rich.console import Console 21 - from rich.progress import Progress, SpinnerColumn, TextColumn 22 - 23 - console = Console() 24 - 25 - 26 - async def fetch_emoji_urls() -> set[str]: 27 - """Fetch all unique emoji URLs from bufo.zone""" 28 - console.print("[cyan]Fetching emoji list from bufo.zone...[/cyan]") 29 - 30 - async with httpx.AsyncClient() as client: 31 - response = await client.get("https://bufo.zone") 32 - response.raise_for_status() 33 - 34 - # Parse HTML 35 - soup = BeautifulSoup(response.text, 'html.parser') 36 - 37 - # Find all image URLs from all-the.bufo.zone 38 - urls = set() 39 - for img in soup.find_all('img'): 40 - src = img.get('src', '') 41 - if 'all-the.bufo.zone' in src: 42 - urls.add(src) 43 - 44 - # Also find URLs in inline styles or other attributes 45 - pattern = re.compile(r'https://all-the\.bufo\.zone/[^"\'>\s]+\.(png|gif|jpg|jpeg|webp)') 46 - for match in pattern.finditer(response.text): 47 - urls.add(match.group(0)) 48 - 49 - console.print(f"[green]Found {len(urls)} unique emoji images[/green]") 50 - return urls 51 - 52 - 53 - async def download_emoji(client: httpx.AsyncClient, url: str, output_dir: Path) -> str: 54 - """Download a single emoji and return filename""" 55 - filename = url.split('/')[-1] 56 - output_path = output_dir / filename 57 - 58 - if output_path.exists(): 59 - return filename 60 - 61 - response = await client.get(url) 62 - response.raise_for_status() 63 - 64 - output_path.write_bytes(response.content) 65 - return filename 66 - 67 - 68 - async def download_all_emojis(urls: set[str], output_dir: Path) -> int: 69 - """Download all emojis concurrently with rate limiting""" 70 - output_dir.mkdir(parents=True, exist_ok=True) 71 - 72 - downloaded = 0 73 - skipped = 0 74 - 75 - async with httpx.AsyncClient(timeout=30.0) as client: 76 - with Progress( 77 - SpinnerColumn(), 78 - TextColumn("[progress.description]{task.description}"), 79 - console=console, 80 - ) as progress: 81 - task = progress.add_task(f"[cyan]Downloading {len(urls)} emojis...", total=len(urls)) 82 - 83 - # Download in batches to avoid overwhelming the server 84 - batch_size = 10 85 - urls_list = list(urls) 86 - 87 - for i in range(0, len(urls_list), batch_size): 88 - batch = urls_list[i:i+batch_size] 89 - tasks = [download_emoji(client, url, output_dir) for url in batch] 90 - results = await asyncio.gather(*tasks, return_exceptions=True) 91 - 92 - for url, result in zip(batch, results): 93 - if isinstance(result, Exception): 94 - console.print(f"[red]Error downloading {url}: {result}[/red]") 95 - else: 96 - if (output_dir / result).stat().st_size > 0: 97 - downloaded += 1 98 - else: 99 - skipped += 1 100 - 101 - progress.update(task, advance=len(batch)) 102 - 103 - # Small delay between batches 104 - if i + batch_size < len(urls_list): 105 - await asyncio.sleep(0.5) 106 - 107 - return downloaded 108 - 109 - 110 - async def main(): 111 - """Main function""" 112 - console.print("[bold cyan]Bufo Emoji Scraper[/bold cyan]\n") 113 - 114 - # Setup paths 115 - script_dir = Path(__file__).parent 116 - project_root = script_dir.parent 117 - output_dir = project_root / "static" / "emojis" 118 - 119 - # Fetch emoji URLs 120 - urls = await fetch_emoji_urls() 121 - 122 - if not urls: 123 - console.print("[red]No emojis found![/red]") 124 - return 125 - 126 - # Download emojis 127 - downloaded = await download_all_emojis(urls, output_dir) 128 - 129 - console.print(f"\n[bold green]✨ Done! Downloaded {downloaded} images to {output_dir}[/bold green]") 130 - 131 - # List what we got 132 - files = list(output_dir.glob("*")) 133 - console.print(f"[cyan]Total files in directory: {len(files)}[/cyan]") 134 - 135 - 136 - if __name__ == "__main__": 137 - asyncio.run(main())
-217
src/api/auth.rs
··· 1 - use crate::resolver::HickoryDnsTxtResolver; 2 - use crate::{ 3 - config, 4 - storage::{SqliteSessionStore, SqliteStateStore}, 5 - templates::{ErrorTemplate, LoginTemplate}, 6 - }; 7 - use actix_session::Session; 8 - use actix_web::{ 9 - HttpRequest, HttpResponse, Responder, Result, get, post, 10 - web::{self, Redirect}, 11 - }; 12 - use askama::Template; 13 - use atrium_api::agent::Agent; 14 - use atrium_identity::{did::CommonDidResolver, handle::AtprotoHandleResolver}; 15 - use atrium_oauth::{ 16 - AuthorizeOptions, CallbackParams, DefaultHttpClient, KnownScope, OAuthClient, Scope, 17 - }; 18 - use serde::{Deserialize, Serialize}; 19 - use std::sync::Arc; 20 - 21 - #[derive(Deserialize)] 22 - pub struct OAuthCallbackParams { 23 - pub state: Option<String>, 24 - pub iss: Option<String>, 25 - pub code: Option<String>, 26 - pub error: Option<String>, 27 - pub error_description: Option<String>, 28 - } 29 - 30 - pub type OAuthClientType = Arc< 31 - OAuthClient< 32 - SqliteStateStore, 33 - SqliteSessionStore, 34 - CommonDidResolver<DefaultHttpClient>, 35 - AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>, 36 - >, 37 - >; 38 - 39 - /// OAuth client metadata endpoint for production 40 - #[get("/oauth-client-metadata.json")] 41 - pub async fn client_metadata(config: web::Data<config::Config>) -> Result<HttpResponse> { 42 - let public_url = config.oauth_redirect_base.clone(); 43 - 44 - let metadata = serde_json::json!({ 45 - "client_id": format!("{}/oauth-client-metadata.json", public_url), 46 - "client_name": "Status Sphere", 47 - "client_uri": public_url.clone(), 48 - "redirect_uris": [format!("{}/oauth/callback", public_url)], 49 - "scope": "atproto repo:io.zzstoatzz.status.record rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app", 50 - "grant_types": ["authorization_code", "refresh_token"], 51 - "response_types": ["code"], 52 - "token_endpoint_auth_method": "none", 53 - "dpop_bound_access_tokens": true 54 - }); 55 - 56 - Ok(HttpResponse::Ok() 57 - .content_type("application/json") 58 - .body(metadata.to_string())) 59 - } 60 - 61 - /// OAuth callback endpoint to complete session creation 62 - #[get("/oauth/callback")] 63 - pub async fn oauth_callback( 64 - request: HttpRequest, 65 - params: web::Query<OAuthCallbackParams>, 66 - oauth_client: web::Data<OAuthClientType>, 67 - session: Session, 68 - ) -> HttpResponse { 69 - // Check if there's an OAuth error from BlueSky 70 - if let Some(error) = &params.error { 71 - let error_msg = params 72 - .error_description 73 - .as_deref() 74 - .unwrap_or("An error occurred during authentication"); 75 - log::error!("OAuth error from BlueSky: {} - {}", error, error_msg); 76 - 77 - let html = ErrorTemplate { 78 - title: "Authentication Error", 79 - error: error_msg, 80 - }; 81 - return HttpResponse::BadRequest().body(html.render().expect("template should be valid")); 82 - } 83 - 84 - // Check if we have the required code field for a successful callback 85 - let code = match &params.code { 86 - Some(code) => code.clone(), 87 - None => { 88 - log::error!("OAuth callback missing required code parameter"); 89 - let html = ErrorTemplate { 90 - title: "Error", 91 - error: "Missing required OAuth code. Please try logging in again.", 92 - }; 93 - return HttpResponse::BadRequest() 94 - .body(html.render().expect("template should be valid")); 95 - } 96 - }; 97 - 98 - // Create CallbackParams for the OAuth client 99 - let callback_params = CallbackParams { 100 - code, 101 - state: params.state.clone(), 102 - iss: params.iss.clone(), 103 - }; 104 - 105 - //Processes the call back and parses out a session if found and valid 106 - match oauth_client.callback(callback_params).await { 107 - Ok((bsky_session, _)) => { 108 - let agent = Agent::new(bsky_session); 109 - match agent.did().await { 110 - Some(did) => { 111 - session.insert("did", did).unwrap(); 112 - Redirect::to("/") 113 - .see_other() 114 - .respond_to(&request) 115 - .map_into_boxed_body() 116 - } 117 - None => { 118 - let html = ErrorTemplate { 119 - title: "Error", 120 - error: "The OAuth agent did not return a DID. May try re-logging in.", 121 - }; 122 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 123 - } 124 - } 125 - } 126 - Err(err) => { 127 - log::error!("Error: {err}"); 128 - let html = ErrorTemplate { 129 - title: "Error", 130 - error: "OAuth error, check the logs", 131 - }; 132 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 133 - } 134 - } 135 - } 136 - 137 - /// Takes you to the login page 138 - #[get("/login")] 139 - pub async fn login() -> Result<impl Responder> { 140 - let html = LoginTemplate { 141 - title: "Log in", 142 - error: None, 143 - }; 144 - Ok(web::Html::new( 145 - html.render().expect("template should be valid"), 146 - )) 147 - } 148 - 149 - /// Logs you out by destroying your cookie on the server and web browser 150 - #[get("/logout")] 151 - pub async fn logout(request: HttpRequest, session: Session) -> HttpResponse { 152 - session.purge(); 153 - Redirect::to("/") 154 - .see_other() 155 - .respond_to(&request) 156 - .map_into_boxed_body() 157 - } 158 - 159 - /// The post body for logging in 160 - #[derive(Serialize, Deserialize, Clone)] 161 - pub struct LoginForm { 162 - pub handle: String, 163 - } 164 - 165 - /// Login endpoint 166 - #[post("/login")] 167 - pub async fn login_post( 168 - request: HttpRequest, 169 - params: web::Form<LoginForm>, 170 - oauth_client: web::Data<OAuthClientType>, 171 - ) -> HttpResponse { 172 - // This will act the same as the js method isValidHandle to make sure it is valid 173 - match atrium_api::types::string::Handle::new(params.handle.clone()) { 174 - Ok(handle) => { 175 - //Creates the oauth url to redirect to for the user to log in with their credentials 176 - let oauth_url = oauth_client 177 - .authorize( 178 - &handle, 179 - AuthorizeOptions { 180 - scopes: vec![ 181 - Scope::Known(KnownScope::Atproto), 182 - // Using granular scope for status records only 183 - // This replaces TransitionGeneric with specific permissions 184 - Scope::Unknown("repo:io.zzstoatzz.status.record".to_string()), 185 - // Need to read profiles for the feed page 186 - Scope::Unknown("rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview".to_string()), 187 - // Need to read following list for following feed 188 - Scope::Unknown("rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app".to_string()), 189 - ], 190 - ..Default::default() 191 - }, 192 - ) 193 - .await; 194 - match oauth_url { 195 - Ok(url) => Redirect::to(url) 196 - .see_other() 197 - .respond_to(&request) 198 - .map_into_boxed_body(), 199 - Err(err) => { 200 - log::error!("Error: {err}"); 201 - let html = LoginTemplate { 202 - title: "Log in", 203 - error: Some("OAuth error"), 204 - }; 205 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 206 - } 207 - } 208 - } 209 - Err(err) => { 210 - let html: LoginTemplate<'_> = LoginTemplate { 211 - title: "Log in", 212 - error: Some(err), 213 - }; 214 - HttpResponse::Ok().body(html.render().expect("template should be valid")) 215 - } 216 - } 217 - }
-50
src/api/mod.rs
··· 1 - pub mod auth; 2 - pub mod preferences; 3 - pub mod status_read; 4 - pub mod status_util; 5 - pub mod status_write; 6 - pub mod webhooks; 7 - 8 - pub use crate::api::status_util::HandleResolver; 9 - pub use auth::OAuthClientType; 10 - 11 - use actix_web::web; 12 - 13 - /// Configure all API routes 14 - pub fn configure_routes(cfg: &mut web::ServiceConfig) { 15 - cfg 16 - // Auth routes 17 - .service(auth::client_metadata) 18 - .service(auth::oauth_callback) 19 - .service(auth::login) 20 - .service(auth::logout) 21 - .service(auth::login_post) 22 - // Status page routes (read) 23 - .service(status_read::home) 24 - .service(status_read::user_status_page) 25 - .service(status_read::feed) 26 - // Status JSON API routes (read) 27 - .service(status_read::owner_status_json) 28 - .service(status_read::user_status_json) 29 - .service(status_read::status_json) 30 - .service(status_read::api_feed) 31 - // Emoji + following routes 32 - .service(status_read::get_frequent_emojis) 33 - .service(status_read::get_custom_emojis) 34 - .service(status_write::upload_emoji) 35 - .service(status_read::get_following) 36 - // Status management routes (write) 37 - .service(status_write::status) 38 - .service(status_write::clear_status) 39 - .service(status_write::delete_status) 40 - .service(status_write::hide_status) 41 - // Preferences routes 42 - .service(preferences::get_preferences) 43 - .service(preferences::save_preferences) 44 - // Webhook routes 45 - .service(webhooks::list_webhooks) 46 - .service(webhooks::create_webhook) 47 - .service(webhooks::update_webhook) 48 - .service(webhooks::rotate_secret) 49 - .service(webhooks::delete_webhook); 50 - }
-75
src/api/preferences.rs
··· 1 - use crate::{db, error_handler::AppError}; 2 - use actix_session::Session; 3 - use actix_web::{Responder, Result, get, post, web}; 4 - use async_sqlite::Pool; 5 - use atrium_api::types::string::Did; 6 - use serde::Deserialize; 7 - use std::sync::Arc; 8 - 9 - #[derive(Deserialize)] 10 - pub struct PreferencesUpdate { 11 - pub font_family: Option<String>, 12 - pub accent_color: Option<String>, 13 - } 14 - 15 - /// Get user preferences 16 - #[get("/api/preferences")] 17 - pub async fn get_preferences( 18 - session: Session, 19 - db_pool: web::Data<Arc<Pool>>, 20 - ) -> Result<impl Responder> { 21 - let did = session.get::<Did>("did")?; 22 - 23 - if let Some(did) = did { 24 - let prefs = db::get_user_preferences(&db_pool, did.as_str()) 25 - .await 26 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 27 - Ok(web::Json(serde_json::json!({ 28 - "font_family": prefs.font_family, 29 - "accent_color": prefs.accent_color 30 - }))) 31 - } else { 32 - Ok(web::Json(serde_json::json!({ 33 - "error": "Not authenticated" 34 - }))) 35 - } 36 - } 37 - 38 - /// Save user preferences 39 - #[post("/api/preferences")] 40 - pub async fn save_preferences( 41 - session: Session, 42 - db_pool: web::Data<Arc<Pool>>, 43 - payload: web::Json<PreferencesUpdate>, 44 - ) -> Result<impl Responder> { 45 - let did = session.get::<Did>("did")?; 46 - 47 - if let Some(did) = did { 48 - let mut prefs = db::get_user_preferences(&db_pool, did.as_str()) 49 - .await 50 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 51 - 52 - if let Some(font) = &payload.font_family { 53 - prefs.font_family = font.clone(); 54 - } 55 - if let Some(color) = &payload.accent_color { 56 - prefs.accent_color = color.clone(); 57 - } 58 - prefs.updated_at = std::time::SystemTime::now() 59 - .duration_since(std::time::UNIX_EPOCH) 60 - .unwrap() 61 - .as_secs() as i64; 62 - 63 - db::save_user_preferences(&db_pool, &prefs) 64 - .await 65 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 66 - 67 - Ok(web::Json(serde_json::json!({ 68 - "success": true 69 - }))) 70 - } else { 71 - Ok(web::Json(serde_json::json!({ 72 - "error": "Not authenticated" 73 - }))) 74 - } 75 - }
-448
src/api/status_read.rs
··· 1 - use crate::config::Config; 2 - use crate::db; 3 - use crate::resolver::HickoryDnsTxtResolver; 4 - use crate::{ 5 - api::auth::OAuthClientType, 6 - db::StatusFromDb, 7 - templates::{ErrorTemplate, FeedTemplate, StatusTemplate}, 8 - }; 9 - use actix_session::Session; 10 - use actix_web::{Responder, Result, get, web}; 11 - use askama::Template; 12 - use async_sqlite::Pool; 13 - use atrium_api::types::string::Did; 14 - use atrium_common::resolver::Resolver; 15 - use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; 16 - use atrium_oauth::DefaultHttpClient; 17 - use serde_json::json; 18 - use std::sync::Arc; 19 - 20 - use crate::api::status_util::{HandleResolver, is_admin}; 21 - 22 - /// Homepage - shows logged-in user's status, or owner's status if not logged in 23 - #[get("/")] 24 - pub async fn home( 25 - session: Session, 26 - _oauth_client: web::Data<OAuthClientType>, 27 - db_pool: web::Data<Arc<Pool>>, 28 - handle_resolver: web::Data<HandleResolver>, 29 - ) -> Result<impl Responder> { 30 - // Default owner of the domain 31 - const OWNER_HANDLE: &str = "zzstoatzz.io"; 32 - 33 - match session.get::<String>("did").unwrap_or(None) { 34 - Some(did_string) => { 35 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 36 - let handle = match handle_resolver.resolve(&did).await { 37 - Ok(did_doc) => did_doc 38 - .also_known_as 39 - .and_then(|aka| aka.first().map(|h| h.replace("at://", ""))) 40 - .unwrap_or_else(|| did_string.clone()), 41 - Err(_) => did_string.clone(), 42 - }; 43 - let current_status = StatusFromDb::my_status(&db_pool, &did) 44 - .await 45 - .unwrap_or(None) 46 - .and_then(|s| { 47 - if let Some(expires_at) = s.expires_at { 48 - if chrono::Utc::now() > expires_at { 49 - return None; 50 - } 51 - } 52 - Some(s) 53 - }); 54 - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 55 - .await 56 - .unwrap_or_else(|err| { 57 - log::error!("Error loading status history: {err}"); 58 - vec![] 59 - }); 60 - let is_admin_flag = is_admin(did.as_str()); 61 - let html = StatusTemplate { 62 - title: "your status", 63 - handle, 64 - current_status, 65 - history, 66 - is_owner: true, 67 - is_admin: is_admin_flag, 68 - } 69 - .render() 70 - .expect("template should be valid"); 71 - Ok(web::Html::new(html)) 72 - } 73 - None => { 74 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 75 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 76 - http_client: Arc::new(DefaultHttpClient::default()), 77 - }); 78 - let owner_handle = 79 - atrium_api::types::string::Handle::new(OWNER_HANDLE.to_string()).ok(); 80 - let owner_did = if let Some(handle) = owner_handle { 81 - atproto_handle_resolver.resolve(&handle).await.ok() 82 - } else { 83 - None 84 - }; 85 - let current_status = if let Some(ref did) = owner_did { 86 - StatusFromDb::my_status(&db_pool, did) 87 - .await 88 - .unwrap_or(None) 89 - .and_then(|s| { 90 - if let Some(expires_at) = s.expires_at { 91 - if chrono::Utc::now() > expires_at { 92 - return None; 93 - } 94 - } 95 - Some(s) 96 - }) 97 - } else { 98 - None 99 - }; 100 - let history = if let Some(ref did) = owner_did { 101 - StatusFromDb::load_user_statuses(&db_pool, did, 10) 102 - .await 103 - .unwrap_or_else(|err| { 104 - log::error!("Error loading status history: {err}"); 105 - vec![] 106 - }) 107 - } else { 108 - vec![] 109 - }; 110 - let html = StatusTemplate { 111 - title: "nate's status", 112 - handle: OWNER_HANDLE.to_string(), 113 - current_status, 114 - history, 115 - is_owner: false, 116 - is_admin: false, 117 - } 118 - .render() 119 - .expect("template should be valid"); 120 - Ok(web::Html::new(html)) 121 - } 122 - } 123 - } 124 - 125 - /// View a specific user's status page by handle 126 - #[get("/@{handle}")] 127 - pub async fn user_status_page( 128 - handle: web::Path<String>, 129 - session: Session, 130 - db_pool: web::Data<Arc<Pool>>, 131 - _handle_resolver: web::Data<HandleResolver>, 132 - ) -> Result<impl Responder> { 133 - let handle = handle.into_inner(); 134 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 135 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 136 - http_client: Arc::new(DefaultHttpClient::default()), 137 - }); 138 - let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 139 - let did = match handle_obj { 140 - Some(h) => match atproto_handle_resolver.resolve(&h).await { 141 - Ok(did) => did, 142 - Err(_) => { 143 - let html = ErrorTemplate { 144 - title: "User not found", 145 - error: &format!("Could not find user @{}.", handle), 146 - } 147 - .render() 148 - .expect("template should be valid"); 149 - return Ok(web::Html::new(html)); 150 - } 151 - }, 152 - None => { 153 - let html = ErrorTemplate { 154 - title: "Invalid handle", 155 - error: &format!("'{}' is not a valid handle format.", handle), 156 - } 157 - .render() 158 - .expect("template should be valid"); 159 - return Ok(web::Html::new(html)); 160 - } 161 - }; 162 - let is_owner = match session.get::<String>("did").unwrap_or(None) { 163 - Some(session_did) => session_did == did.to_string(), 164 - None => false, 165 - }; 166 - let current_status = StatusFromDb::my_status(&db_pool, &did) 167 - .await 168 - .unwrap_or(None) 169 - .and_then(|s| { 170 - if let Some(expires_at) = s.expires_at { 171 - if chrono::Utc::now() > expires_at { 172 - return None; 173 - } 174 - } 175 - Some(s) 176 - }); 177 - let history = StatusFromDb::load_user_statuses(&db_pool, &did, 10) 178 - .await 179 - .unwrap_or_else(|err| { 180 - log::error!("Error loading status history: {err}"); 181 - vec![] 182 - }); 183 - let html = StatusTemplate { 184 - title: &format!("@{} status", handle), 185 - handle, 186 - current_status, 187 - history, 188 - is_owner, 189 - is_admin: false, 190 - } 191 - .render() 192 - .expect("template should be valid"); 193 - Ok(web::Html::new(html)) 194 - } 195 - 196 - #[get("/json")] 197 - pub async fn owner_status_json( 198 - _session: Session, 199 - db_pool: web::Data<Arc<Pool>>, 200 - _handle_resolver: web::Data<HandleResolver>, 201 - ) -> Result<impl Responder> { 202 - // Resolve owner handle to DID (zzstoatzz.io) 203 - let owner_handle = atrium_api::types::string::Handle::new("zzstoatzz.io".to_string()).ok(); 204 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 205 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 206 - http_client: Arc::new(DefaultHttpClient::default()), 207 - }); 208 - let did = if let Some(handle) = owner_handle { 209 - atproto_handle_resolver.resolve(&handle).await.ok() 210 - } else { 211 - None 212 - }; 213 - let current_status = if let Some(did) = did { 214 - StatusFromDb::my_status(&db_pool, &did) 215 - .await 216 - .unwrap_or(None) 217 - .and_then(|s| { 218 - if let Some(expires_at) = s.expires_at { 219 - if chrono::Utc::now() > expires_at { 220 - return None; 221 - } 222 - } 223 - Some(s) 224 - }) 225 - } else { 226 - None 227 - }; 228 - let response = if let Some(status_data) = current_status { 229 - json!({ "status": "known", "emoji": status_data.status, "text": status_data.text, "since": status_data.started_at.to_rfc3339(), "expires": status_data.expires_at.map(|e| e.to_rfc3339()) }) 230 - } else { 231 - json!({ "status": "unknown", "message": "No current status is known" }) 232 - }; 233 - Ok(web::Json(response)) 234 - } 235 - 236 - #[get("/@{handle}/json")] 237 - pub async fn user_status_json( 238 - handle: web::Path<String>, 239 - _session: Session, 240 - db_pool: web::Data<Arc<Pool>>, 241 - ) -> Result<impl Responder> { 242 - let handle = handle.into_inner(); 243 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 244 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 245 - http_client: Arc::new(DefaultHttpClient::default()), 246 - }); 247 - let handle_obj = atrium_api::types::string::Handle::new(handle.clone()).ok(); 248 - let did = if let Some(h) = handle_obj { 249 - atproto_handle_resolver.resolve(&h).await.ok() 250 - } else { 251 - None 252 - }; 253 - if let Some(did) = did { 254 - let current_status = StatusFromDb::my_status(&db_pool, &did) 255 - .await 256 - .unwrap_or(None) 257 - .and_then(|s| { 258 - if let Some(expires_at) = s.expires_at { 259 - if chrono::Utc::now() > expires_at { 260 - return None; 261 - } 262 - } 263 - Some(s) 264 - }); 265 - let response = if let Some(status_data) = current_status { 266 - json!({ "status": "known", "emoji": status_data.status, "text": status_data.text, "since": status_data.started_at.to_rfc3339(), "expires": status_data.expires_at.map(|e| e.to_rfc3339()) }) 267 - } else { 268 - json!({ "status": "unknown", "message": format!("No current status is known for @{}", handle) }) 269 - }; 270 - Ok(web::Json(response)) 271 - } else { 272 - Ok(web::Json( 273 - json!({ "status": "unknown", "message": format!("Unknown user @{}", handle) }), 274 - )) 275 - } 276 - } 277 - 278 - #[get("/api/status")] 279 - pub async fn status_json(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 280 - // Owner: zzstoatzz.io 281 - let atproto_handle_resolver = AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 282 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 283 - http_client: Arc::new(DefaultHttpClient::default()), 284 - }); 285 - let owner_handle = atrium_api::types::string::Handle::new("zzstoatzz.io".to_string()).ok(); 286 - let did = if let Some(h) = owner_handle { 287 - atproto_handle_resolver.resolve(&h).await.ok() 288 - } else { 289 - None 290 - }; 291 - let current_status = if let Some(ref did) = did { 292 - StatusFromDb::my_status(&db_pool, did) 293 - .await 294 - .unwrap_or(None) 295 - .and_then(|s| { 296 - if let Some(expires_at) = s.expires_at { 297 - if chrono::Utc::now() > expires_at { 298 - return None; 299 - } 300 - } 301 - Some(s) 302 - }) 303 - } else { 304 - None 305 - }; 306 - let response = if let Some(status_data) = current_status { 307 - json!({ "status": "known", "emoji": status_data.status, "text": status_data.text, "since": status_data.started_at.to_rfc3339(), "expires": status_data.expires_at.map(|e| e.to_rfc3339()) }) 308 - } else { 309 - json!({ "status": "unknown", "message": "No current status is known" }) 310 - }; 311 - Ok(web::Json(response)) 312 - } 313 - 314 - #[get("/feed")] 315 - pub async fn feed( 316 - session: Session, 317 - _db_pool: web::Data<Arc<Pool>>, 318 - handle_resolver: web::Data<HandleResolver>, 319 - app_config: web::Data<Config>, 320 - ) -> Result<impl Responder> { 321 - let did_opt = session.get::<String>("did").unwrap_or(None); 322 - let is_admin_flag = did_opt.as_deref().map(is_admin).unwrap_or(false); 323 - 324 - let mut profile: Option<crate::templates::Profile> = None; 325 - if let Some(did_str) = did_opt.clone() { 326 - let mut handle_opt: Option<String> = None; 327 - if let Ok(doc) = handle_resolver 328 - .resolve(&atrium_api::types::string::Did::new(did_str.clone()).expect("did")) 329 - .await 330 - { 331 - if let Some(h) = doc.also_known_as.and_then(|aka| aka.first().cloned()) { 332 - handle_opt = Some(h.replace("at://", "")); 333 - } 334 - } 335 - profile = Some(crate::templates::Profile { 336 - did: did_str, 337 - display_name: None, 338 - handle: handle_opt, 339 - }); 340 - } 341 - 342 - let html = FeedTemplate { 343 - title: "feed", 344 - profile, 345 - statuses: vec![], 346 - is_admin: is_admin_flag, 347 - dev_mode: app_config.dev_mode, 348 - } 349 - .render() 350 - .expect("template should be valid"); 351 - Ok(web::Html::new(html)) 352 - } 353 - 354 - #[get("/api/feed")] 355 - pub async fn api_feed( 356 - db_pool: web::Data<Arc<Pool>>, 357 - handle_resolver: web::Data<HandleResolver>, 358 - query: web::Query<std::collections::HashMap<String, String>>, 359 - ) -> Result<impl Responder> { 360 - // Paginated feed 361 - let offset = query 362 - .get("offset") 363 - .and_then(|s| s.parse::<i32>().ok()) 364 - .unwrap_or(0); 365 - let limit = query 366 - .get("limit") 367 - .and_then(|s| s.parse::<i32>().ok()) 368 - .unwrap_or(20) 369 - .clamp(5, 50); 370 - 371 - let statuses = StatusFromDb::load_statuses_paginated(&db_pool, offset, limit) 372 - .await 373 - .unwrap_or_default(); 374 - let mut enriched = Vec::with_capacity(statuses.len()); 375 - for mut s in statuses { 376 - // Resolve handle lazily 377 - let did = Did::new(s.author_did.clone()).expect("did"); 378 - if let Ok(doc) = handle_resolver.resolve(&did).await { 379 - if let Some(h) = doc.also_known_as.and_then(|aka| aka.first().cloned()) { 380 - s.handle = Some(h.replace("at://", "")); 381 - } 382 - } 383 - enriched.push(s); 384 - } 385 - let has_more = (enriched.len() as i32) == limit; 386 - Ok(web::Json( 387 - json!({ "statuses": enriched, "has_more": has_more, "next_offset": offset + (enriched.len() as i32) }), 388 - )) 389 - } 390 - 391 - #[get("/api/frequent-emojis")] 392 - pub async fn get_frequent_emojis(db_pool: web::Data<Arc<Pool>>) -> Result<impl Responder> { 393 - let emojis = db::get_frequent_emojis(&db_pool, 20) 394 - .await 395 - .unwrap_or_default(); 396 - // Legacy response shape: raw array, not wrapped 397 - Ok(web::Json(emojis)) 398 - } 399 - 400 - #[get("/api/custom-emojis")] 401 - pub async fn get_custom_emojis(app_config: web::Data<Config>) -> Result<impl Responder> { 402 - // Response shape expected by UI: 403 - // [ { "name": "sparkle", "filename": "sparkle.png" }, ... ] 404 - let dir = app_config.emoji_dir.clone(); 405 - let fs_dir = std::path::Path::new(&dir); 406 - let fallback = std::path::Path::new("static/emojis"); 407 - 408 - let mut map: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new(); 409 - let read_dirs = [fs_dir, fallback]; 410 - for d in read_dirs.iter() { 411 - if let Ok(entries) = std::fs::read_dir(d) { 412 - for entry in entries.flatten() { 413 - let p = entry.path(); 414 - if let (Some(stem), Some(ext)) = (p.file_stem(), p.extension()) { 415 - let name = stem.to_string_lossy().to_string(); 416 - let ext = ext.to_string_lossy().to_ascii_lowercase(); 417 - if ext == "png" || ext == "gif" { 418 - // prefer png over gif if duplicates 419 - let filename = format!("{}.{ext}", name); 420 - map.entry(name) 421 - .and_modify(|v| { 422 - if v.ends_with(".gif") && ext == "png" { 423 - *v = filename.clone(); 424 - } 425 - }) 426 - .or_insert(filename); 427 - } 428 - } 429 - } 430 - } 431 - } 432 - 433 - let custom: Vec<serde_json::Value> = map 434 - .into_iter() 435 - .map(|(name, filename)| json!({ "name": name, "filename": filename })) 436 - .collect(); 437 - Ok(web::Json(custom)) 438 - } 439 - 440 - #[get("/api/following")] 441 - pub async fn get_following( 442 - _session: Session, 443 - _oauth_client: web::Data<OAuthClientType>, 444 - _db_pool: web::Data<Arc<Pool>>, 445 - ) -> Result<impl Responder> { 446 - // Placeholder: follow list disabled here to keep module slim 447 - Ok(web::Json(json!({ "follows": [] }))) 448 - }
-54
src/api/status_util.rs
··· 1 - use atrium_identity::did::CommonDidResolver; 2 - use atrium_oauth::DefaultHttpClient; 3 - use serde::{Deserialize, Serialize}; 4 - use std::sync::Arc; 5 - 6 - /// HandleResolver to make it easier to access the OAuthClient in web requests 7 - pub type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 8 - 9 - /// Admin DID for moderation 10 - pub const ADMIN_DID: &str = "did:plc:xbtmt2zjwlrfegqvch7fboei"; // zzstoatzz.io 11 - 12 - /// Check if a DID is the admin 13 - pub fn is_admin(did: &str) -> bool { 14 - did == ADMIN_DID 15 - } 16 - 17 - /// The post body for changing your status 18 - #[derive(Serialize, Deserialize, Clone)] 19 - pub struct StatusForm { 20 - pub status: String, 21 - pub text: Option<String>, 22 - pub expires_in: Option<String>, // e.g., "1h", "30m", "1d", etc. 23 - } 24 - 25 - /// The post body for deleting a specific status 26 - #[derive(Serialize, Deserialize)] 27 - pub struct DeleteRequest { 28 - pub uri: String, 29 - } 30 - 31 - /// Hide/unhide a status (admin only) 32 - #[derive(Deserialize)] 33 - pub struct HideStatusRequest { 34 - pub uri: String, 35 - pub hidden: bool, 36 - } 37 - 38 - /// Parse duration string like "1h", "30m", "1d" into chrono::Duration 39 - pub fn parse_duration(duration_str: &str) -> Option<chrono::Duration> { 40 - if duration_str.is_empty() { 41 - return None; 42 - } 43 - 44 - let (num_str, unit) = duration_str.split_at(duration_str.len() - 1); 45 - let num: i64 = num_str.parse().ok()?; 46 - 47 - match unit { 48 - "m" => Some(chrono::Duration::minutes(num)), 49 - "h" => Some(chrono::Duration::hours(num)), 50 - "d" => Some(chrono::Duration::days(num)), 51 - "w" => Some(chrono::Duration::weeks(num)), 52 - _ => None, 53 - } 54 - }
-373
src/api/status_write.rs
··· 1 - use crate::config::Config; 2 - use crate::{ 3 - api::auth::OAuthClientType, db::StatusFromDb, error_handler::AppError, 4 - lexicons::record::KnownRecord, rate_limiter::RateLimiter, 5 - }; 6 - use actix_multipart::Multipart; 7 - use actix_session::Session; 8 - use actix_web::{HttpRequest, HttpResponse, Responder, post, web}; 9 - use async_sqlite::{Pool, rusqlite}; 10 - use atrium_api::{ 11 - agent::Agent, 12 - types::string::{Datetime, Did}, 13 - }; 14 - use futures_util::TryStreamExt as _; 15 - use std::sync::Arc; 16 - 17 - use crate::api::status_util::{HideStatusRequest, StatusForm, parse_duration}; 18 - 19 - #[post("/admin/upload-emoji")] 20 - pub async fn upload_emoji( 21 - session: Session, 22 - mut payload: Multipart, 23 - app_config: web::Data<Config>, 24 - ) -> Result<impl Responder, AppError> { 25 - if session.get::<String>("did").unwrap_or(None).is_none() { 26 - return Ok(HttpResponse::Unauthorized().body("Not authenticated")); 27 - } 28 - let mut name: Option<String> = None; 29 - let mut file_bytes: Option<Vec<u8>> = None; 30 - while let Some(item) = payload 31 - .try_next() 32 - .await 33 - .map_err(|e| AppError::ValidationError(e.to_string()))? 34 - { 35 - let mut field = item; 36 - let disp = field.content_disposition().clone(); 37 - let field_name = disp.get_name().unwrap_or(""); 38 - if field_name == "name" { 39 - let mut buf = Vec::new(); 40 - while let Some(chunk) = field 41 - .try_next() 42 - .await 43 - .map_err(|e| AppError::ValidationError(e.to_string()))? 44 - { 45 - buf.extend_from_slice(&chunk); 46 - } 47 - name = Some(String::from_utf8_lossy(&buf).trim().to_string()); 48 - } else if field_name == "file" { 49 - let mut buf = Vec::new(); 50 - while let Some(chunk) = field 51 - .try_next() 52 - .await 53 - .map_err(|e| AppError::ValidationError(e.to_string()))? 54 - { 55 - buf.extend_from_slice(&chunk); 56 - } 57 - file_bytes = Some(buf); 58 - } 59 - } 60 - let file_bytes = file_bytes.ok_or_else(|| AppError::ValidationError("No file".into()))?; 61 - // Basic validation omitted for brevity 62 - let emoji_dir = app_config.emoji_dir.clone(); 63 - let filename = name 64 - .filter(|s| !s.is_empty()) 65 - .unwrap_or_else(|| format!("emoji_{}", chrono::Utc::now().timestamp())); 66 - let path_png = format!("{}/{}.png", emoji_dir, filename); 67 - std::fs::write(&path_png, &file_bytes).map_err(|e| AppError::ValidationError(e.to_string()))?; 68 - Ok(HttpResponse::Ok().json(serde_json::json!({"ok": true, "name": filename}))) 69 - } 70 - 71 - /// Clear the user's status by deleting the ATProto record 72 - #[post("/status/clear")] 73 - pub async fn clear_status( 74 - request: HttpRequest, 75 - session: Session, 76 - oauth_client: web::Data<OAuthClientType>, 77 - db_pool: web::Data<Arc<Pool>>, 78 - ) -> HttpResponse { 79 - match session.get::<String>("did").unwrap_or(None) { 80 - Some(did_string) => { 81 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 82 - match StatusFromDb::my_status(&db_pool, &did).await { 83 - Ok(Some(current_status)) => { 84 - let parts: Vec<&str> = current_status.uri.split('/').collect(); 85 - if let Some(rkey) = parts.last() { 86 - match oauth_client.restore(&did).await { 87 - Ok(session) => { 88 - let agent = Agent::new(session); 89 - let delete_request = 90 - atrium_api::com::atproto::repo::delete_record::InputData { 91 - collection: atrium_api::types::string::Nsid::new( 92 - "io.zzstoatzz.status.record".to_string(), 93 - ) 94 - .expect("valid nsid"), 95 - repo: did.clone().into(), 96 - rkey: atrium_api::types::string::RecordKey::new( 97 - rkey.to_string(), 98 - ) 99 - .expect("valid rkey"), 100 - swap_commit: None, 101 - swap_record: None, 102 - }; 103 - match agent 104 - .api 105 - .com 106 - .atproto 107 - .repo 108 - .delete_record(delete_request.into()) 109 - .await 110 - { 111 - Ok(_) => { 112 - let _ = StatusFromDb::delete_by_uri( 113 - &db_pool, 114 - current_status.uri.clone(), 115 - ) 116 - .await; 117 - let pool = db_pool.get_ref().clone(); 118 - let did_for_event = did_string.clone(); 119 - let uri = current_status.uri.clone(); 120 - tokio::spawn(async move { 121 - crate::webhooks::emit_deleted( 122 - pool, 123 - &did_for_event, 124 - &uri, 125 - ) 126 - .await; 127 - }); 128 - web::Redirect::to("/") 129 - .see_other() 130 - .respond_to(&request) 131 - .map_into_boxed_body() 132 - } 133 - Err(e) => { 134 - log::error!("Failed to delete status from ATProto: {e}"); 135 - HttpResponse::InternalServerError() 136 - .body("Failed to clear status") 137 - } 138 - } 139 - } 140 - Err(e) => { 141 - log::error!("Failed to restore OAuth session: {e}"); 142 - HttpResponse::InternalServerError().body("Session error") 143 - } 144 - } 145 - } else { 146 - HttpResponse::BadRequest().body("Invalid status URI") 147 - } 148 - } 149 - Ok(None) => web::Redirect::to("/") 150 - .see_other() 151 - .respond_to(&request) 152 - .map_into_boxed_body(), 153 - Err(e) => { 154 - log::error!("Database error: {e}"); 155 - HttpResponse::InternalServerError().body("Database error") 156 - } 157 - } 158 - } 159 - None => web::Redirect::to("/login") 160 - .see_other() 161 - .respond_to(&request) 162 - .map_into_boxed_body(), 163 - } 164 - } 165 - 166 - /// Delete a specific status by URI (JSON endpoint) 167 - #[post("/status/delete")] 168 - pub async fn delete_status( 169 - session: Session, 170 - oauth_client: web::Data<OAuthClientType>, 171 - db_pool: web::Data<Arc<Pool>>, 172 - req: web::Json<crate::api::status_util::DeleteRequest>, 173 - ) -> HttpResponse { 174 - match session.get::<String>("did").unwrap_or(None) { 175 - Some(did_string) => { 176 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 177 - let uri_parts: Vec<&str> = req.uri.split('/').collect(); 178 - if uri_parts.len() < 5 { 179 - return HttpResponse::BadRequest() 180 - .json(serde_json::json!({"error":"Invalid status URI format"})); 181 - } 182 - let uri_did_part = uri_parts[2]; 183 - if uri_did_part != did_string { 184 - return HttpResponse::Forbidden() 185 - .json(serde_json::json!({"error":"You can only delete your own statuses"})); 186 - } 187 - if let Some(rkey) = uri_parts.last() { 188 - match oauth_client.restore(&did).await { 189 - Ok(session) => { 190 - let agent = Agent::new(session); 191 - let delete_request = 192 - atrium_api::com::atproto::repo::delete_record::InputData { 193 - collection: atrium_api::types::string::Nsid::new( 194 - "io.zzstoatzz.status.record".to_string(), 195 - ) 196 - .expect("valid nsid"), 197 - repo: did.clone().into(), 198 - rkey: atrium_api::types::string::RecordKey::new(rkey.to_string()) 199 - .expect("valid rkey"), 200 - swap_commit: None, 201 - swap_record: None, 202 - }; 203 - match agent 204 - .api 205 - .com 206 - .atproto 207 - .repo 208 - .delete_record(delete_request.into()) 209 - .await 210 - { 211 - Ok(_) => { 212 - let _ = 213 - StatusFromDb::delete_by_uri(&db_pool, req.uri.clone()).await; 214 - let pool = db_pool.get_ref().clone(); 215 - let did_for_event = did_string.clone(); 216 - let uri = req.uri.clone(); 217 - tokio::spawn(async move { 218 - crate::webhooks::emit_deleted(pool, &did_for_event, &uri).await; 219 - }); 220 - HttpResponse::Ok().json(serde_json::json!({"success":true})) 221 - } 222 - Err(e) => { 223 - log::error!("Failed to delete status from ATProto: {e}"); 224 - HttpResponse::InternalServerError() 225 - .json(serde_json::json!({"error":"Failed to delete status"})) 226 - } 227 - } 228 - } 229 - Err(e) => { 230 - log::error!("Failed to restore OAuth session: {e}"); 231 - HttpResponse::InternalServerError() 232 - .json(serde_json::json!({"error":"Session error"})) 233 - } 234 - } 235 - } else { 236 - HttpResponse::BadRequest().json(serde_json::json!({"error":"Invalid status URI"})) 237 - } 238 - } 239 - None => HttpResponse::Unauthorized().json(serde_json::json!({"error":"Not authenticated"})), 240 - } 241 - } 242 - 243 - /// Hide/unhide a status (admin only) 244 - #[post("/admin/hide-status")] 245 - pub async fn hide_status( 246 - session: Session, 247 - db_pool: web::Data<Arc<Pool>>, 248 - req: web::Json<HideStatusRequest>, 249 - ) -> HttpResponse { 250 - match session.get::<String>("did").unwrap_or(None) { 251 - Some(did_string) => { 252 - if did_string != crate::api::status_util::ADMIN_DID { 253 - return HttpResponse::Forbidden() 254 - .json(serde_json::json!({"error":"Admin access required"})); 255 - } 256 - let uri = req.uri.clone(); 257 - let hidden = req.hidden; 258 - let result = db_pool 259 - .conn(move |conn| { 260 - conn.execute( 261 - "UPDATE status SET hidden = ?1 WHERE uri = ?2", 262 - rusqlite::params![hidden, uri], 263 - ) 264 - }) 265 - .await; 266 - match result { 267 - Ok(rows_affected) if rows_affected > 0 => HttpResponse::Ok().json(serde_json::json!({"success":true,"message": if hidden {"Status hidden"} else {"Status unhidden"}})), 268 - Ok(_) => HttpResponse::NotFound().json(serde_json::json!({"error":"Status not found"})), 269 - Err(err) => { log::error!("Error updating hidden status: {}", err); HttpResponse::InternalServerError().json(serde_json::json!({"error":"Database error"})) } 270 - } 271 - } 272 - None => HttpResponse::Unauthorized().json(serde_json::json!({"error":"Not authenticated"})), 273 - } 274 - } 275 - 276 - /// Creates a new status 277 - #[post("/status")] 278 - pub async fn status( 279 - request: HttpRequest, 280 - session: Session, 281 - oauth_client: web::Data<OAuthClientType>, 282 - db_pool: web::Data<Arc<Pool>>, 283 - form: web::Form<StatusForm>, 284 - rate_limiter: web::Data<RateLimiter>, 285 - ) -> Result<HttpResponse, AppError> { 286 - let client_key = RateLimiter::get_client_key(&request); 287 - if !rate_limiter.check_rate_limit(&client_key) { 288 - return Err(AppError::RateLimitExceeded); 289 - } 290 - match session.get::<String>("did").unwrap_or(None) { 291 - Some(did_string) => { 292 - let did = Did::new(did_string.clone()).expect("failed to parse did"); 293 - match oauth_client.restore(&did).await { 294 - Ok(session) => { 295 - let agent = Agent::new(session); 296 - let expires = form 297 - .expires_in 298 - .as_ref() 299 - .and_then(|exp| parse_duration(exp)) 300 - .and_then(|duration| { 301 - let expiry_time = chrono::Utc::now() + duration; 302 - Some(Datetime::new(expiry_time.to_rfc3339().parse().ok()?)) 303 - }); 304 - let status: KnownRecord = 305 - crate::lexicons::io::zzstoatzz::status::record::RecordData { 306 - created_at: Datetime::now(), 307 - emoji: form.status.clone(), 308 - text: form.text.clone(), 309 - expires, 310 - } 311 - .into(); 312 - let create_result = agent 313 - .api 314 - .com 315 - .atproto 316 - .repo 317 - .create_record( 318 - atrium_api::com::atproto::repo::create_record::InputData { 319 - collection: "io.zzstoatzz.status.record".parse().unwrap(), 320 - repo: did.into(), 321 - rkey: None, 322 - record: status.into(), 323 - swap_commit: None, 324 - validate: None, 325 - } 326 - .into(), 327 - ) 328 - .await; 329 - match create_result { 330 - Ok(record) => { 331 - let mut status = StatusFromDb::new( 332 - record.uri.clone(), 333 - did_string, 334 - form.status.clone(), 335 - ); 336 - status.text = form.text.clone(); 337 - if let Some(exp_str) = &form.expires_in { 338 - if let Some(duration) = parse_duration(exp_str) { 339 - status.expires_at = Some(chrono::Utc::now() + duration); 340 - } 341 - } 342 - let _ = status.save(db_pool.clone()).await; 343 - { 344 - let pool = db_pool.get_ref().clone(); 345 - let s = status.clone(); 346 - tokio::spawn(async move { 347 - crate::webhooks::emit_created(pool, &s).await; 348 - }); 349 - } 350 - Ok(web::Redirect::to("/") 351 - .see_other() 352 - .respond_to(&request) 353 - .map_into_boxed_body()) 354 - } 355 - Err(err) => { 356 - log::error!("Error creating status: {err}"); 357 - Ok(HttpResponse::Ok() 358 - .body("Was an error creating the status, please check the logs.")) 359 - } 360 - } 361 - } 362 - Err(err) => { 363 - session.purge(); 364 - log::error!("Error restoring session: {err}"); 365 - Err(AppError::AuthenticationError("Session error".to_string())) 366 - } 367 - } 368 - } 369 - None => Err(AppError::AuthenticationError( 370 - "You must be logged in to create a status.".to_string(), 371 - )), 372 - } 373 - }
-234
src/api/webhooks.rs
··· 1 - use crate::{config::Config, db, error_handler::AppError}; 2 - use actix_session::Session; 3 - use actix_web::{HttpResponse, Responder, Result, delete, get, post, put, web}; 4 - use async_sqlite::Pool; 5 - use atrium_api::types::string::Did; 6 - use serde::Deserialize; 7 - use std::sync::Arc; 8 - use url::Url; 9 - 10 - #[derive(Deserialize)] 11 - pub struct CreateWebhookRequest { 12 - pub url: String, 13 - pub secret: Option<String>, 14 - pub events: Option<String>, 15 - } 16 - 17 - #[derive(Deserialize)] 18 - pub struct UpdateWebhookRequest { 19 - pub url: Option<String>, 20 - pub events: Option<String>, 21 - pub active: Option<bool>, 22 - } 23 - 24 - #[get("/api/webhooks")] 25 - pub async fn list_webhooks( 26 - session: Session, 27 - db_pool: web::Data<Arc<Pool>>, 28 - ) -> Result<impl Responder> { 29 - let did = session.get::<Did>("did")?; 30 - if let Some(did) = did { 31 - let hooks = db::get_user_webhooks(&db_pool, did.as_str()) 32 - .await 33 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 34 - let response: Vec<serde_json::Value> = hooks 35 - .into_iter() 36 - .map(|h| { 37 - serde_json::json!({ 38 - "id": h.id, 39 - "url": h.url, 40 - "events": h.events, 41 - "active": h.active, 42 - "created_at": h.created_at, 43 - "updated_at": h.updated_at, 44 - "secret_masked": h.masked_secret() 45 - }) 46 - }) 47 - .collect(); 48 - Ok(web::Json(serde_json::json!({ "webhooks": response }))) 49 - } else { 50 - Ok(web::Json( 51 - serde_json::json!({ "error": "Not authenticated" }), 52 - )) 53 - } 54 - } 55 - 56 - #[post("/api/webhooks")] 57 - pub async fn create_webhook( 58 - session: Session, 59 - db_pool: web::Data<Arc<Pool>>, 60 - app_config: web::Data<Config>, 61 - payload: web::Json<CreateWebhookRequest>, 62 - ) -> Result<impl Responder> { 63 - let did = session.get::<Did>("did")?; 64 - if let Some(did) = did { 65 - // Robust URL + SSRF validation 66 - if let Err(msg) = validate_url(&payload.url, &app_config) { 67 - return Ok(web::Json(serde_json::json!({ "error": msg }))); 68 - } 69 - // Events validation 70 - if let Some(events_str) = &payload.events { 71 - if let Err(msg) = validate_events(events_str) { 72 - return Ok(web::Json(serde_json::json!({ "error": msg }))); 73 - } 74 - } 75 - let (id, secret) = db::create_webhook( 76 - &db_pool, 77 - did.as_str(), 78 - &payload.url, 79 - payload.secret.as_deref(), 80 - payload.events.as_deref(), 81 - ) 82 - .await 83 - .map_err(|e| AppError::DatabaseError(e.to_string()))?; 84 - 85 - Ok(web::Json(serde_json::json!({ 86 - "id": id, 87 - "secret": secret, // Only returned once on creation 88 - }))) 89 - } else { 90 - Ok(web::Json( 91 - serde_json::json!({ "error": "Not authenticated" }), 92 - )) 93 - } 94 - } 95 - 96 - #[put("/api/webhooks/{id}")] 97 - pub async fn update_webhook( 98 - session: Session, 99 - db_pool: web::Data<Arc<Pool>>, 100 - path: web::Path<i64>, 101 - payload: web::Json<UpdateWebhookRequest>, 102 - app_config: web::Data<Config>, 103 - ) -> impl Responder { 104 - match session.get::<Did>("did").unwrap_or(None) { 105 - Some(did) => { 106 - let id = path.into_inner(); 107 - if let Some(url) = &payload.url { 108 - if let Err(msg) = validate_url(url, &app_config) { 109 - return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })); 110 - } 111 - } 112 - if let Some(events_str) = &payload.events { 113 - if let Err(msg) = validate_events(events_str) { 114 - return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })); 115 - } 116 - } 117 - let res = db::update_webhook( 118 - &db_pool, 119 - did.as_str(), 120 - id, 121 - payload.url.as_deref(), 122 - payload.events.as_deref(), 123 - payload.active, 124 - ) 125 - .await; 126 - match res { 127 - Ok(_) => HttpResponse::Ok().json(serde_json::json!({ "success": true })), 128 - Err(e) => HttpResponse::InternalServerError() 129 - .json(serde_json::json!({ "error": e.to_string() })), 130 - } 131 - } 132 - None => { 133 - HttpResponse::Unauthorized().json(serde_json::json!({ "error": "Not authenticated" })) 134 - } 135 - } 136 - } 137 - 138 - fn validate_events(s: &str) -> Result<(), &'static str> { 139 - if s.trim().is_empty() { 140 - return Ok(()); 141 - } 142 - const ALLOWED: &[&str] = &["status.created", "status.deleted"]; 143 - for ev in s.split(',').map(|e| e.trim()) { 144 - if !ALLOWED.contains(&ev) { 145 - return Err("Unsupported event type"); 146 - } 147 - } 148 - Ok(()) 149 - } 150 - 151 - fn validate_url(raw: &str, cfg: &Config) -> Result<(), &'static str> { 152 - let url = Url::parse(raw).map_err(|_| "Invalid URL")?; 153 - let scheme = url.scheme(); 154 - let host = url.host_str().ok_or("Missing host")?.to_ascii_lowercase(); 155 - 156 - // Treat localhost explicitly 157 - let host_is_localname = host == "localhost"; 158 - 159 - // If host is an IP literal, apply standard library checks 160 - let ip_check_blocks = if let Ok(ip) = host.parse::<std::net::IpAddr>() { 161 - match ip { 162 - std::net::IpAddr::V4(v4) => { 163 - v4.is_private() 164 - || v4.is_loopback() 165 - || v4.is_link_local() 166 - || v4.is_multicast() 167 - || v4.is_unspecified() 168 - } 169 - std::net::IpAddr::V6(v6) => { 170 - v6.is_unique_local() || v6.is_loopback() || v6.is_multicast() || v6.is_unspecified() 171 - } 172 - } 173 - } else { 174 - false 175 - }; 176 - 177 - // Enforce HTTPS in production 178 - let is_production = !cfg.oauth_redirect_base.starts_with("http://localhost") 179 - && !cfg.oauth_redirect_base.starts_with("http://127.0.0.1"); 180 - if is_production && scheme != "https" { 181 - return Err("HTTPS required in production"); 182 - } 183 - 184 - // Basic SSRF protection in production 185 - if (host_is_localname || ip_check_blocks) && is_production { 186 - return Err("Private/local hosts not allowed"); 187 - } 188 - 189 - Ok(()) 190 - } 191 - 192 - #[post("/api/webhooks/{id}/rotate")] 193 - pub async fn rotate_secret( 194 - session: Session, 195 - db_pool: web::Data<Arc<Pool>>, 196 - path: web::Path<i64>, 197 - ) -> impl Responder { 198 - match session.get::<Did>("did").unwrap_or(None) { 199 - Some(did) => { 200 - let id = path.into_inner(); 201 - match db::rotate_webhook_secret(&db_pool, did.as_str(), id).await { 202 - Ok(new_secret) => { 203 - HttpResponse::Ok().json(serde_json::json!({ "secret": new_secret })) 204 - } 205 - Err(e) => HttpResponse::InternalServerError() 206 - .json(serde_json::json!({ "error": e.to_string() })), 207 - } 208 - } 209 - None => { 210 - HttpResponse::Unauthorized().json(serde_json::json!({ "error": "Not authenticated" })) 211 - } 212 - } 213 - } 214 - 215 - #[delete("/api/webhooks/{id}")] 216 - pub async fn delete_webhook( 217 - session: Session, 218 - db_pool: web::Data<Arc<Pool>>, 219 - path: web::Path<i64>, 220 - ) -> impl Responder { 221 - match session.get::<Did>("did").unwrap_or(None) { 222 - Some(did) => { 223 - let id = path.into_inner(); 224 - match db::delete_webhook(&db_pool, did.as_str(), id).await { 225 - Ok(_) => HttpResponse::Ok().json(serde_json::json!({ "success": true })), 226 - Err(e) => HttpResponse::InternalServerError() 227 - .json(serde_json::json!({ "error": e.to_string() })), 228 - } 229 - } 230 - None => { 231 - HttpResponse::Unauthorized().json(serde_json::json!({ "error": "Not authenticated" })) 232 - } 233 - } 234 - }
-70
src/config.rs
··· 1 - use serde::Deserialize; 2 - use std::env; 3 - 4 - /// Application configuration loaded from environment variables 5 - #[derive(Debug, Clone, Deserialize)] 6 - #[allow(dead_code)] 7 - pub struct Config { 8 - /// The admin DID for moderation (intentionally hardcoded for security) 9 - pub admin_did: String, 10 - 11 - /// Owner handle for the default status page 12 - pub owner_handle: String, 13 - 14 - /// Database URL (defaults to local SQLite) 15 - pub database_url: String, 16 - 17 - /// OAuth redirect base URL 18 - pub oauth_redirect_base: String, 19 - 20 - /// Server host 21 - pub server_host: String, 22 - 23 - /// Server port 24 - pub server_port: u16, 25 - 26 - /// Enable firehose ingester 27 - pub enable_firehose: bool, 28 - 29 - /// Log level 30 - pub log_level: String, 31 - 32 - /// Dev mode for testing with dummy data 33 - pub dev_mode: bool, 34 - 35 - /// Directory to serve and manage custom emojis from 36 - pub emoji_dir: String, 37 - } 38 - 39 - impl Config { 40 - /// Load configuration from environment variables with sensible defaults 41 - pub fn from_env() -> Result<Self, env::VarError> { 42 - // Admin DID is intentionally hardcoded as discussed 43 - let admin_did = "did:plc:xbtmt2zjwlrfegqvch7fboei".to_string(); 44 - 45 - Ok(Config { 46 - admin_did, 47 - owner_handle: env::var("OWNER_HANDLE").unwrap_or_else(|_| "zzstoatzz.io".to_string()), 48 - database_url: env::var("DATABASE_URL") 49 - .unwrap_or_else(|_| "sqlite://./statusphere.sqlite3".to_string()), 50 - oauth_redirect_base: env::var("OAUTH_REDIRECT_BASE") 51 - .unwrap_or_else(|_| "http://localhost:8080".to_string()), 52 - server_host: env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()), 53 - server_port: env::var("SERVER_PORT") 54 - .unwrap_or_else(|_| "8080".to_string()) 55 - .parse() 56 - .unwrap_or(8080), 57 - enable_firehose: env::var("ENABLE_FIREHOSE") 58 - .unwrap_or_else(|_| "false".to_string()) 59 - .parse() 60 - .unwrap_or(false), 61 - log_level: env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), 62 - dev_mode: env::var("DEV_MODE") 63 - .unwrap_or_else(|_| "false".to_string()) 64 - .parse() 65 - .unwrap_or(false), 66 - // Default to static/emojis for local dev; override in prod to /data/emojis 67 - emoji_dir: env::var("EMOJI_DIR").unwrap_or_else(|_| "static/emojis".to_string()), 68 - }) 69 - } 70 - }
-116
src/db/mod.rs
··· 1 - pub mod models; 2 - pub mod queries; 3 - pub mod webhooks; 4 - 5 - pub use models::{AuthSession, AuthState, StatusFromDb}; 6 - pub use queries::{get_frequent_emojis, get_user_preferences, save_user_preferences}; 7 - pub use webhooks::{ 8 - Webhook, create_webhook, delete_webhook, get_user_webhooks, rotate_webhook_secret, 9 - update_webhook, 10 - }; 11 - 12 - use async_sqlite::Pool; 13 - 14 - /// Creates the tables in the db. 15 - pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { 16 - pool.conn(move |conn| { 17 - conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); 18 - 19 - // status 20 - conn.execute( 21 - "CREATE TABLE IF NOT EXISTS status ( 22 - uri TEXT PRIMARY KEY, 23 - authorDid TEXT NOT NULL, 24 - emoji TEXT NOT NULL, 25 - text TEXT, 26 - startedAt INTEGER NOT NULL, 27 - expiresAt INTEGER, 28 - indexedAt INTEGER NOT NULL 29 - )", 30 - [], 31 - ) 32 - .unwrap(); 33 - 34 - // auth_session 35 - conn.execute( 36 - "CREATE TABLE IF NOT EXISTS auth_session ( 37 - key TEXT PRIMARY KEY, 38 - session TEXT NOT NULL 39 - )", 40 - [], 41 - ) 42 - .unwrap(); 43 - 44 - // auth_state 45 - conn.execute( 46 - "CREATE TABLE IF NOT EXISTS auth_state ( 47 - key TEXT PRIMARY KEY, 48 - state TEXT NOT NULL 49 - )", 50 - [], 51 - ) 52 - .unwrap(); 53 - 54 - // user_preferences 55 - conn.execute( 56 - "CREATE TABLE IF NOT EXISTS user_preferences ( 57 - did TEXT PRIMARY KEY, 58 - font_family TEXT DEFAULT 'mono', 59 - accent_color TEXT DEFAULT '#1DA1F2', 60 - updated_at INTEGER NOT NULL 61 - )", 62 - [], 63 - ) 64 - .unwrap(); 65 - 66 - // webhooks 67 - conn.execute( 68 - "CREATE TABLE IF NOT EXISTS webhooks ( 69 - id INTEGER PRIMARY KEY AUTOINCREMENT, 70 - did TEXT NOT NULL, 71 - url TEXT NOT NULL, 72 - secret TEXT NOT NULL, 73 - events TEXT DEFAULT '*', 74 - active BOOLEAN DEFAULT TRUE, 75 - created_at INTEGER NOT NULL, 76 - updated_at INTEGER NOT NULL 77 - )", 78 - [], 79 - ) 80 - .unwrap(); 81 - 82 - // index for fast lookups by did 83 - conn.execute( 84 - "CREATE INDEX IF NOT EXISTS idx_webhooks_did ON webhooks(did)", 85 - [], 86 - ) 87 - .unwrap(); 88 - 89 - // Note: custom_emojis table removed - we serve emojis directly from static/emojis/ directory 90 - 91 - // Add indexes for performance optimization 92 - // Index on startedAt for feed queries (ORDER BY startedAt DESC) 93 - conn.execute( 94 - "CREATE INDEX IF NOT EXISTS idx_status_startedAt ON status(startedAt DESC)", 95 - [], 96 - ) 97 - .unwrap(); 98 - 99 - // Composite index for user status queries (WHERE authorDid = ? ORDER BY startedAt DESC) 100 - conn.execute( 101 - "CREATE INDEX IF NOT EXISTS idx_status_authorDid_startedAt ON status(authorDid, startedAt DESC)", 102 - [], 103 - ) 104 - .unwrap(); 105 - 106 - // Add hidden column for moderation (won't error if already exists) 107 - let _ = conn.execute( 108 - "ALTER TABLE status ADD COLUMN hidden BOOLEAN DEFAULT FALSE", 109 - [], 110 - ); 111 - 112 - Ok(()) 113 - }) 114 - .await?; 115 - Ok(()) 116 - }
-459
src/db/models.rs
··· 1 - use actix_web::web::Data; 2 - use async_sqlite::{ 3 - Pool, 4 - rusqlite::{Error, Row, types::Type}, 5 - }; 6 - use atrium_api::types::string::Did; 7 - use chrono::{DateTime, Utc}; 8 - use serde::{Deserialize, Serialize}; 9 - use std::{ 10 - sync::Arc, 11 - time::{SystemTime, UNIX_EPOCH}, 12 - }; 13 - 14 - #[derive(Debug, Clone, Deserialize, Serialize)] 15 - pub struct StatusFromDb { 16 - pub uri: String, 17 - pub author_did: String, 18 - pub status: String, // Keep for backwards compat, but this is the emoji 19 - pub text: Option<String>, 20 - pub started_at: DateTime<Utc>, 21 - pub expires_at: Option<DateTime<Utc>>, 22 - pub indexed_at: DateTime<Utc>, 23 - pub handle: Option<String>, 24 - } 25 - 26 - impl StatusFromDb { 27 - /// Creates a new [StatusFromDb] 28 - pub fn new(uri: String, author_did: String, status: String) -> Self { 29 - let now = chrono::Utc::now(); 30 - Self { 31 - uri, 32 - author_did, 33 - status, 34 - text: None, 35 - started_at: now, 36 - expires_at: None, 37 - indexed_at: now, 38 - handle: None, 39 - } 40 - } 41 - 42 - /// Helper to map from [Row] to [StatusDb] 43 - fn map_from_row(row: &Row) -> Result<Self, async_sqlite::rusqlite::Error> { 44 - Ok(Self { 45 - uri: row.get(0)?, 46 - author_did: row.get(1)?, 47 - status: row.get(2)?, // emoji 48 - text: row.get(3)?, 49 - //DateTimes are stored as INTEGERS then parsed into a DateTime<UTC> 50 - started_at: { 51 - let timestamp: i64 = row.get(4)?; 52 - DateTime::from_timestamp(timestamp, 0).ok_or_else(|| { 53 - Error::InvalidColumnType(4, "Invalid timestamp".parse().unwrap(), Type::Text) 54 - })? 55 - }, 56 - expires_at: { 57 - let timestamp: Option<i64> = row.get(5)?; 58 - timestamp.and_then(|ts| DateTime::from_timestamp(ts, 0)) 59 - }, 60 - //DateTimes are stored as INTEGERS then parsed into a DateTime<UTC> 61 - indexed_at: { 62 - let timestamp: i64 = row.get(6)?; 63 - DateTime::from_timestamp(timestamp, 0).ok_or_else(|| { 64 - Error::InvalidColumnType(6, "Invalid timestamp".parse().unwrap(), Type::Text) 65 - })? 66 - }, 67 - handle: None, 68 - }) 69 - } 70 - 71 - /// Check if status is expired 72 - pub fn is_expired(&self) -> bool { 73 - if let Some(expires_at) = self.expires_at { 74 - Utc::now() > expires_at 75 - } else { 76 - false 77 - } 78 - } 79 - 80 - /// Saves the [StatusDb] 81 - pub async fn save(&self, pool: Data<Arc<Pool>>) -> Result<(), async_sqlite::Error> { 82 - let cloned_self = self.clone(); 83 - pool.conn(move |conn| { 84 - conn.execute( 85 - "INSERT INTO status (uri, authorDid, emoji, text, startedAt, expiresAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", 86 - async_sqlite::rusqlite::params![ 87 - &cloned_self.uri, 88 - &cloned_self.author_did, 89 - &cloned_self.status, // emoji value 90 - &cloned_self.text, 91 - &cloned_self.started_at.timestamp().to_string(), 92 - &cloned_self.expires_at.map(|e| e.timestamp().to_string()), 93 - &cloned_self.indexed_at.timestamp().to_string(), 94 - ], 95 - ) 96 - }) 97 - .await?; 98 - Ok(()) 99 - } 100 - 101 - /// Saves or updates a status by its did(uri) 102 - pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> { 103 - let cloned_self = self.clone(); 104 - pool.conn(move |conn| { 105 - //We check to see if the session already exists, if so we need to update not insert 106 - let mut stmt = conn.prepare("SELECT COUNT(*) FROM status WHERE uri = ?1")?; 107 - let count: i64 = stmt.query_row([&cloned_self.uri], |row| row.get(0))?; 108 - match count > 0 { 109 - true => { 110 - let mut update_stmt = 111 - conn.prepare("UPDATE status SET emoji = ?2, text = ?3, startedAt = ?4, expiresAt = ?5, indexedAt = ?6 WHERE uri = ?1")?; 112 - update_stmt.execute(async_sqlite::rusqlite::params![ 113 - &cloned_self.uri, 114 - &cloned_self.status, 115 - &cloned_self.text, 116 - &cloned_self.started_at.timestamp().to_string(), 117 - &cloned_self.expires_at.map(|e| e.timestamp().to_string()), 118 - &cloned_self.indexed_at.timestamp().to_string() 119 - ])?; 120 - Ok(()) 121 - } 122 - false => { 123 - conn.execute( 124 - "INSERT INTO status (uri, authorDid, emoji, text, startedAt, expiresAt, indexedAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", 125 - async_sqlite::rusqlite::params![ 126 - &cloned_self.uri, 127 - &cloned_self.author_did, 128 - &cloned_self.status, // emoji value 129 - &cloned_self.text, 130 - &cloned_self.started_at.timestamp().to_string(), 131 - &cloned_self.expires_at.map(|e| e.timestamp().to_string()), 132 - &cloned_self.indexed_at.timestamp().to_string(), 133 - ], 134 - )?; 135 - Ok(()) 136 - } 137 - } 138 - }) 139 - .await?; 140 - Ok(()) 141 - } 142 - 143 - pub async fn delete_by_uri(pool: &Pool, uri: String) -> Result<(), async_sqlite::Error> { 144 - pool.conn(move |conn| { 145 - let mut stmt = conn.prepare("DELETE FROM status WHERE uri = ?1")?; 146 - stmt.execute([&uri]) 147 - }) 148 - .await?; 149 - Ok(()) 150 - } 151 - 152 - /// Loads the last 10 statuses we have saved 153 - #[allow(dead_code)] 154 - pub async fn load_latest_statuses( 155 - pool: &Data<Arc<Pool>>, 156 - ) -> Result<Vec<Self>, async_sqlite::Error> { 157 - pool 158 - .conn(move |conn| { 159 - let mut stmt = 160 - conn.prepare("SELECT * FROM status WHERE (hidden IS NULL OR hidden = FALSE) ORDER BY startedAt DESC LIMIT 10")?; 161 - let status_iter = stmt 162 - .query_map([], |row| Ok(Self::map_from_row(row).unwrap())) 163 - .unwrap(); 164 - 165 - let mut statuses = Vec::new(); 166 - for status in status_iter { 167 - statuses.push(status?); 168 - } 169 - Ok(statuses) 170 - }) 171 - .await 172 - } 173 - 174 - /// Loads paginated statuses for infinite scrolling 175 - #[allow(dead_code)] 176 - pub async fn load_statuses_paginated( 177 - pool: &Data<Arc<Pool>>, 178 - offset: i32, 179 - limit: i32, 180 - ) -> Result<Vec<Self>, async_sqlite::Error> { 181 - pool 182 - .conn(move |conn| { 183 - let mut stmt = conn.prepare( 184 - "SELECT * FROM status WHERE (hidden IS NULL OR hidden = FALSE) ORDER BY startedAt DESC LIMIT ?1 OFFSET ?2" 185 - )?; 186 - let status_iter = stmt 187 - .query_map(async_sqlite::rusqlite::params![limit, offset], |row| { 188 - Ok(Self::map_from_row(row).unwrap()) 189 - }) 190 - .unwrap(); 191 - 192 - let mut statuses = Vec::new(); 193 - for status in status_iter { 194 - statuses.push(status?); 195 - } 196 - Ok(statuses) 197 - }) 198 - .await 199 - } 200 - 201 - /// Loads the logged-in users current status 202 - pub async fn my_status( 203 - pool: &Data<Arc<Pool>>, 204 - did: &Did, 205 - ) -> Result<Option<Self>, async_sqlite::Error> { 206 - let did = did.to_string(); 207 - pool.conn(move |conn| { 208 - let mut stmt = conn.prepare( 209 - "SELECT * FROM status WHERE authorDid = ?1 ORDER BY startedAt DESC LIMIT 1", 210 - )?; 211 - stmt.query_row([did.as_str()], Self::map_from_row) 212 - .map(Some) 213 - .or_else(|err| { 214 - if err == async_sqlite::rusqlite::Error::QueryReturnedNoRows { 215 - Ok(None) 216 - } else { 217 - Err(err) 218 - } 219 - }) 220 - }) 221 - .await 222 - } 223 - 224 - /// Loads user's status history 225 - pub async fn load_user_statuses( 226 - pool: &Data<Arc<Pool>>, 227 - did: &Did, 228 - limit: usize, 229 - ) -> Result<Vec<Self>, async_sqlite::Error> { 230 - let did = did.to_string(); 231 - pool.conn(move |conn| { 232 - let mut stmt = conn.prepare( 233 - "SELECT * FROM status WHERE authorDid = ?1 ORDER BY startedAt DESC LIMIT ?2", 234 - )?; 235 - let status_iter = stmt.query_map([did.as_str(), &limit.to_string()], |row| { 236 - Self::map_from_row(row) 237 - })?; 238 - let mut statuses = vec![]; 239 - for status in status_iter { 240 - statuses.push(status?); 241 - } 242 - Ok(statuses) 243 - }) 244 - .await 245 - } 246 - 247 - /// ui helper to show a handle or did if the handle cannot be found 248 - pub fn author_display_name(&self) -> String { 249 - match self.handle.as_ref() { 250 - Some(handle) => handle.to_string(), 251 - None => self.author_did.to_string(), 252 - } 253 - } 254 - } 255 - 256 - /// AuthSession table data type 257 - #[derive(Debug, Clone, Deserialize, Serialize)] 258 - pub struct AuthSession { 259 - pub key: String, 260 - pub session: String, 261 - } 262 - 263 - impl AuthSession { 264 - /// Creates a new [AuthSession] 265 - pub fn new<V>(key: String, session: V) -> Self 266 - where 267 - V: Serialize, 268 - { 269 - let session = serde_json::to_string(&session).unwrap(); 270 - Self { 271 - key: key.to_string(), 272 - session, 273 - } 274 - } 275 - 276 - /// Helper to map from [Row] to [AuthSession] 277 - fn map_from_row(row: &Row) -> Result<Self, Error> { 278 - let key: String = row.get(0)?; 279 - let session: String = row.get(1)?; 280 - Ok(Self { key, session }) 281 - } 282 - 283 - /// Gets a session by the users did(key) 284 - pub async fn get_by_did(pool: &Pool, did: String) -> Result<Option<Self>, async_sqlite::Error> { 285 - let did = Did::new(did).unwrap(); 286 - pool.conn(move |conn| { 287 - let mut stmt = conn.prepare("SELECT * FROM auth_session WHERE key = ?1")?; 288 - stmt.query_row([did.as_str()], Self::map_from_row) 289 - .map(Some) 290 - .or_else(|err| { 291 - if err == Error::QueryReturnedNoRows { 292 - Ok(None) 293 - } else { 294 - Err(err) 295 - } 296 - }) 297 - }) 298 - .await 299 - } 300 - 301 - /// Saves or updates the session by its did(key) 302 - pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> { 303 - let cloned_self = self.clone(); 304 - pool.conn(move |conn| { 305 - //We check to see if the session already exists, if so we need to update not insert 306 - let mut stmt = conn.prepare("SELECT COUNT(*) FROM auth_session WHERE key = ?1")?; 307 - let count: i64 = stmt.query_row([&cloned_self.key], |row| row.get(0))?; 308 - match count > 0 { 309 - true => { 310 - let mut update_stmt = 311 - conn.prepare("UPDATE auth_session SET session = ?2 WHERE key = ?1")?; 312 - update_stmt.execute([&cloned_self.key, &cloned_self.session])?; 313 - Ok(()) 314 - } 315 - false => { 316 - conn.execute( 317 - "INSERT INTO auth_session (key, session) VALUES (?1, ?2)", 318 - [&cloned_self.key, &cloned_self.session], 319 - )?; 320 - Ok(()) 321 - } 322 - } 323 - }) 324 - .await?; 325 - Ok(()) 326 - } 327 - 328 - /// Deletes the session by did 329 - pub async fn delete_by_did(pool: &Pool, did: String) -> Result<(), async_sqlite::Error> { 330 - pool.conn(move |conn| { 331 - let mut stmt = conn.prepare("DELETE FROM auth_session WHERE key = ?1")?; 332 - stmt.execute([&did]) 333 - }) 334 - .await?; 335 - Ok(()) 336 - } 337 - 338 - /// Deletes all the sessions 339 - pub async fn delete_all(pool: &Pool) -> Result<(), async_sqlite::Error> { 340 - pool.conn(move |conn| { 341 - let mut stmt = conn.prepare("DELETE FROM auth_session")?; 342 - stmt.execute([]) 343 - }) 344 - .await?; 345 - Ok(()) 346 - } 347 - } 348 - 349 - /// AuthState table datatype 350 - #[derive(Debug, Clone, Deserialize, Serialize)] 351 - pub struct AuthState { 352 - pub key: String, 353 - pub state: String, 354 - } 355 - 356 - impl AuthState { 357 - /// Creates a new [AuthState] 358 - pub fn new<V>(key: String, state: V) -> Self 359 - where 360 - V: Serialize, 361 - { 362 - let state = serde_json::to_string(&state).unwrap(); 363 - Self { 364 - key: key.to_string(), 365 - state, 366 - } 367 - } 368 - 369 - /// Helper to map from [Row] to [AuthState] 370 - fn map_from_row(row: &Row) -> Result<Self, Error> { 371 - let key: String = row.get(0)?; 372 - let state: String = row.get(1)?; 373 - Ok(Self { key, state }) 374 - } 375 - 376 - /// Gets a state by the users key 377 - pub async fn get_by_key(pool: &Pool, key: String) -> Result<Option<Self>, async_sqlite::Error> { 378 - pool.conn(move |conn| { 379 - let mut stmt = conn.prepare("SELECT * FROM auth_state WHERE key = ?1")?; 380 - stmt.query_row([key.as_str()], Self::map_from_row) 381 - .map(Some) 382 - .or_else(|err| { 383 - if err == Error::QueryReturnedNoRows { 384 - Ok(None) 385 - } else { 386 - Err(err) 387 - } 388 - }) 389 - }) 390 - .await 391 - } 392 - 393 - /// Saves or updates the state by its key 394 - pub async fn save_or_update(&self, pool: &Pool) -> Result<(), async_sqlite::Error> { 395 - let cloned_self = self.clone(); 396 - pool.conn(move |conn| { 397 - //We check to see if the state already exists, if so we need to update 398 - let mut stmt = conn.prepare("SELECT COUNT(*) FROM auth_state WHERE key = ?1")?; 399 - let count: i64 = stmt.query_row([&cloned_self.key], |row| row.get(0))?; 400 - match count > 0 { 401 - true => { 402 - let mut update_stmt = 403 - conn.prepare("UPDATE auth_state SET state = ?2 WHERE key = ?1")?; 404 - update_stmt.execute([&cloned_self.key, &cloned_self.state])?; 405 - Ok(()) 406 - } 407 - false => { 408 - conn.execute( 409 - "INSERT INTO auth_state (key, state) VALUES (?1, ?2)", 410 - [&cloned_self.key, &cloned_self.state], 411 - )?; 412 - Ok(()) 413 - } 414 - } 415 - }) 416 - .await?; 417 - Ok(()) 418 - } 419 - 420 - pub async fn delete_by_key(pool: &Pool, key: String) -> Result<(), async_sqlite::Error> { 421 - pool.conn(move |conn| { 422 - let mut stmt = conn.prepare("DELETE FROM auth_state WHERE key = ?1")?; 423 - stmt.execute([&key]) 424 - }) 425 - .await?; 426 - Ok(()) 427 - } 428 - 429 - pub async fn delete_all(pool: &Pool) -> Result<(), async_sqlite::Error> { 430 - pool.conn(move |conn| { 431 - let mut stmt = conn.prepare("DELETE FROM auth_state")?; 432 - stmt.execute([]) 433 - }) 434 - .await?; 435 - Ok(()) 436 - } 437 - } 438 - 439 - #[derive(Debug, Clone, Serialize, Deserialize)] 440 - pub struct UserPreferences { 441 - pub did: String, 442 - pub font_family: String, 443 - pub accent_color: String, 444 - pub updated_at: i64, 445 - } 446 - 447 - impl Default for UserPreferences { 448 - fn default() -> Self { 449 - Self { 450 - did: String::new(), 451 - font_family: "mono".to_string(), 452 - accent_color: "#1DA1F2".to_string(), 453 - updated_at: SystemTime::now() 454 - .duration_since(UNIX_EPOCH) 455 - .unwrap() 456 - .as_secs() as i64, 457 - } 458 - } 459 - }
-88
src/db/queries.rs
··· 1 - use async_sqlite::Pool; 2 - 3 - use super::models::UserPreferences; 4 - 5 - /// Get the most frequently used emojis from all statuses 6 - pub async fn get_frequent_emojis( 7 - pool: &Pool, 8 - limit: usize, 9 - ) -> Result<Vec<String>, async_sqlite::Error> { 10 - pool.conn(move |conn| { 11 - let mut stmt = conn.prepare( 12 - "SELECT emoji, COUNT(*) as count 13 - FROM status 14 - GROUP BY emoji 15 - ORDER BY count DESC 16 - LIMIT ?1", 17 - )?; 18 - 19 - let emoji_iter = stmt.query_map([limit], |row| row.get::<_, String>(0))?; 20 - 21 - let mut emojis = Vec::new(); 22 - for emoji in emoji_iter { 23 - emojis.push(emoji?); 24 - } 25 - 26 - Ok(emojis) 27 - }) 28 - .await 29 - } 30 - 31 - /// Get user preferences for a given DID 32 - pub async fn get_user_preferences( 33 - pool: &Pool, 34 - did: &str, 35 - ) -> Result<UserPreferences, async_sqlite::Error> { 36 - let did = did.to_string(); 37 - pool.conn(move |conn| { 38 - let mut stmt = conn.prepare( 39 - "SELECT did, font_family, accent_color, updated_at 40 - FROM user_preferences 41 - WHERE did = ?1", 42 - )?; 43 - 44 - let result = stmt.query_row([&did], |row| { 45 - Ok(UserPreferences { 46 - did: row.get(0)?, 47 - font_family: row.get(1)?, 48 - accent_color: row.get(2)?, 49 - updated_at: row.get(3)?, 50 - }) 51 - }); 52 - 53 - match result { 54 - Ok(prefs) => Ok(prefs), 55 - Err(async_sqlite::rusqlite::Error::QueryReturnedNoRows) => { 56 - // Return default preferences for new users 57 - Ok(UserPreferences { 58 - did: did.clone(), 59 - ..Default::default() 60 - }) 61 - } 62 - Err(e) => Err(e), 63 - } 64 - }) 65 - .await 66 - } 67 - 68 - /// Save user preferences 69 - pub async fn save_user_preferences( 70 - pool: &Pool, 71 - prefs: &UserPreferences, 72 - ) -> Result<(), async_sqlite::Error> { 73 - let prefs = prefs.clone(); 74 - pool.conn(move |conn| { 75 - conn.execute( 76 - "INSERT OR REPLACE INTO user_preferences (did, font_family, accent_color, updated_at) 77 - VALUES (?1, ?2, ?3, ?4)", 78 - ( 79 - &prefs.did, 80 - &prefs.font_family, 81 - &prefs.accent_color, 82 - &prefs.updated_at, 83 - ), 84 - )?; 85 - Ok(()) 86 - }) 87 - .await 88 - }
-189
src/db/webhooks.rs
··· 1 - use async_sqlite::Pool; 2 - use rand::{Rng, distributions::Alphanumeric}; 3 - use serde::{Deserialize, Serialize}; 4 - 5 - #[derive(Debug, Clone, Serialize, Deserialize)] 6 - pub struct Webhook { 7 - pub id: i64, 8 - pub did: String, 9 - pub url: String, 10 - pub secret: String, 11 - pub events: String, // comma-separated or "*" 12 - pub active: bool, 13 - pub created_at: i64, 14 - pub updated_at: i64, 15 - } 16 - 17 - impl Webhook { 18 - fn now() -> i64 { 19 - std::time::SystemTime::now() 20 - .duration_since(std::time::UNIX_EPOCH) 21 - .unwrap() 22 - .as_secs() as i64 23 - } 24 - 25 - pub fn masked_secret(&self) -> String { 26 - let len = self.secret.len(); 27 - if len <= 4 { 28 - return "****".to_string(); 29 - } 30 - let suffix = &self.secret[len - 4..]; 31 - format!("****{}", suffix) 32 - } 33 - } 34 - 35 - pub fn generate_secret() -> String { 36 - rand::thread_rng() 37 - .sample_iter(&Alphanumeric) 38 - .take(40) 39 - .map(char::from) 40 - .collect() 41 - } 42 - 43 - pub async fn get_user_webhooks( 44 - pool: &Pool, 45 - did: &str, 46 - ) -> Result<Vec<Webhook>, async_sqlite::Error> { 47 - let did = did.to_string(); 48 - pool.conn(move |conn| { 49 - let mut stmt = conn.prepare( 50 - "SELECT id, did, url, secret, events, COALESCE(active, 1), created_at, updated_at FROM webhooks WHERE did = ?1 ORDER BY id DESC", 51 - )?; 52 - let iter = stmt.query_map([&did], |row| { 53 - Ok(Webhook { 54 - id: row.get(0)?, 55 - did: row.get(1)?, 56 - url: row.get(2)?, 57 - secret: row.get(3)?, 58 - events: row.get(4)?, 59 - active: row.get::<_, Option<bool>>(5)?.unwrap_or(true), 60 - created_at: row.get(6)?, 61 - updated_at: row.get(7)?, 62 - }) 63 - })?; 64 - let mut v = Vec::new(); 65 - for item in iter { 66 - v.push(item?); 67 - } 68 - Ok(v) 69 - }) 70 - .await 71 - } 72 - 73 - pub async fn create_webhook( 74 - pool: &Pool, 75 - did: &str, 76 - url: &str, 77 - secret_opt: Option<&str>, 78 - events: Option<&str>, 79 - ) -> Result<(i64, String), async_sqlite::Error> { 80 - let secret = secret_opt.unwrap_or(&generate_secret()).to_string(); 81 - let now = Webhook::now(); 82 - let did_owned = did.to_string(); 83 - let url_owned = url.to_string(); 84 - let events_owned = events.unwrap_or("*").to_string(); 85 - let secret_for_insert = secret.clone(); 86 - 87 - let id = pool 88 - .conn(move |conn| { 89 - conn.execute( 90 - "INSERT INTO webhooks (did, url, secret, events, active, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6)", 91 - (&did_owned, &url_owned, &secret_for_insert, &events_owned, now, now), 92 - )?; 93 - Ok(conn.last_insert_rowid()) 94 - }) 95 - .await?; 96 - Ok((id, secret)) 97 - } 98 - 99 - pub async fn update_webhook( 100 - pool: &Pool, 101 - did: &str, 102 - id: i64, 103 - url: Option<&str>, 104 - events: Option<&str>, 105 - active: Option<bool>, 106 - ) -> Result<(), async_sqlite::Error> { 107 - let now = Webhook::now(); 108 - let did_owned = did.to_string(); 109 - let url_owned = url.map(|s| s.to_string()); 110 - let events_owned = events.map(|s| s.to_string()); 111 - pool.conn(move |conn| { 112 - // Ensure ownership 113 - let mut check = conn.prepare("SELECT COUNT(*) FROM webhooks WHERE id = ?1 AND did = ?2")?; 114 - let count: i64 = check.query_row((id, &did_owned), |row| row.get(0))?; 115 - if count == 0 { 116 - return Ok(0); 117 - } 118 - 119 - // Build dynamic update 120 - let mut fields = Vec::new(); 121 - if url_owned.is_some() { 122 - fields.push("url = ?"); 123 - } 124 - if events_owned.is_some() { 125 - fields.push("events = ?"); 126 - } 127 - if active.is_some() { 128 - fields.push("active = ?"); 129 - } 130 - fields.push("updated_at = ?"); 131 - let sql = format!( 132 - "UPDATE webhooks SET {} WHERE id = ? AND did = ?", 133 - fields.join(", ") 134 - ); 135 - 136 - let mut stmt = conn.prepare(&sql)?; 137 - let mut params: Vec<Box<dyn async_sqlite::rusqlite::ToSql>> = Vec::new(); 138 - if let Some(u) = url_owned { 139 - params.push(Box::new(u)); 140 - } 141 - if let Some(e) = events_owned { 142 - params.push(Box::new(e)); 143 - } 144 - if let Some(a) = active { 145 - params.push(Box::new(a)); 146 - } 147 - params.push(Box::new(now)); 148 - params.push(Box::new(id)); 149 - params.push(Box::new(did_owned)); 150 - 151 - let params_ref: Vec<&dyn async_sqlite::rusqlite::ToSql> = 152 - params.iter().map(|b| &**b).collect(); 153 - let _ = stmt.execute(params_ref.as_slice())?; 154 - Ok(1) 155 - }) 156 - .await?; 157 - Ok(()) 158 - } 159 - 160 - pub async fn rotate_webhook_secret( 161 - pool: &Pool, 162 - did: &str, 163 - id: i64, 164 - ) -> Result<String, async_sqlite::Error> { 165 - let new_secret = generate_secret(); 166 - let now = Webhook::now(); 167 - let did_owned = did.to_string(); 168 - let new_for_update = new_secret.clone(); 169 - pool.conn(move |conn| { 170 - let mut stmt = conn.prepare( 171 - "UPDATE webhooks SET secret = ?1, updated_at = ?2 WHERE id = ?3 AND did = ?4", 172 - )?; 173 - let _ = stmt.execute((&new_for_update, now, id, &did_owned))?; 174 - Ok(()) 175 - }) 176 - .await?; 177 - Ok(new_secret) 178 - } 179 - 180 - pub async fn delete_webhook(pool: &Pool, did: &str, id: i64) -> Result<(), async_sqlite::Error> { 181 - let did_owned = did.to_string(); 182 - pool.conn(move |conn| { 183 - let mut stmt = conn.prepare("DELETE FROM webhooks WHERE id = ?1 AND did = ?2")?; 184 - let _ = stmt.execute((id, &did_owned))?; 185 - Ok(()) 186 - }) 187 - .await?; 188 - Ok(()) 189 - }
-118
src/emoji.rs
··· 1 - use once_cell::sync::OnceCell; 2 - use std::{collections::HashSet, fs, path::Path, sync::Arc}; 3 - 4 - use crate::config::Config; 5 - 6 - /// Ensure the runtime emoji directory exists, and sync new emojis from the bundled 7 - /// `static/emojis` directory. Only copies files that don't already exist in the runtime dir, 8 - /// preserving manual uploads and deletions. 9 - pub fn init_runtime_dir(config: &Config) { 10 - let runtime_emoji_dir = &config.emoji_dir; 11 - let bundled_emoji_dir = "static/emojis"; 12 - 13 - if let Err(e) = fs::create_dir_all(runtime_emoji_dir) { 14 - log::warn!( 15 - "Failed to ensure emoji directory exists at {}: {}", 16 - runtime_emoji_dir, 17 - e 18 - ); 19 - return; 20 - } 21 - 22 - // Skip sync if runtime dir is the same as bundled (local dev) 23 - if runtime_emoji_dir == bundled_emoji_dir { 24 - return; 25 - } 26 - 27 - if !Path::new(bundled_emoji_dir).exists() { 28 - return; 29 - } 30 - 31 - match fs::read_dir(bundled_emoji_dir) { 32 - Ok(entries) => { 33 - let mut copied = 0; 34 - for entry in entries.flatten() { 35 - let path = entry.path(); 36 - if let Some(name) = path.file_name() { 37 - let dest = Path::new(runtime_emoji_dir).join(name); 38 - // Only copy if destination doesn't exist (preserves manual changes) 39 - if path.is_file() && !dest.exists() { 40 - match fs::copy(&path, &dest) { 41 - Ok(_) => copied += 1, 42 - Err(err) => { 43 - log::warn!("Failed to sync emoji {:?} -> {:?}: {}", path, dest, err) 44 - } 45 - } 46 - } 47 - } 48 - } 49 - if copied > 0 { 50 - log::info!( 51 - "Synced {} new emoji(s) from {} to {}", 52 - copied, 53 - bundled_emoji_dir, 54 - runtime_emoji_dir 55 - ); 56 - } 57 - } 58 - Err(err) => log::warn!( 59 - "Failed to read bundled emoji directory {}: {}", 60 - bundled_emoji_dir, 61 - err 62 - ), 63 - } 64 - } 65 - 66 - #[allow(dead_code)] 67 - static BUILTIN_SLUGS: OnceCell<Arc<HashSet<String>>> = OnceCell::new(); 68 - 69 - #[allow(dead_code)] 70 - async fn load_builtin_slugs_inner() -> Arc<HashSet<String>> { 71 - // Fetch emoji data and collect first short_name as slug 72 - let url = "https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json"; 73 - let client = reqwest::Client::new(); 74 - let mut set = HashSet::new(); 75 - if let Ok(resp) = client.get(url).send().await { 76 - if let Ok(json) = resp.json::<serde_json::Value>().await { 77 - if let Some(arr) = json.as_array() { 78 - for item in arr { 79 - if let Some(shorts) = item.get("short_names").and_then(|v| v.as_array()) { 80 - if let Some(first) = shorts.first().and_then(|v| v.as_str()) { 81 - set.insert(first.to_lowercase()); 82 - } 83 - } else if let Some(name) = item.get("name").and_then(|v| v.as_str()) { 84 - // Fallback: slugify the name 85 - let slug: String = name 86 - .chars() 87 - .map(|c| { 88 - if c.is_ascii_alphanumeric() { 89 - c.to_ascii_lowercase() 90 - } else { 91 - '-' 92 - } 93 - }) 94 - .collect::<String>() 95 - .trim_matches('-') 96 - .to_string(); 97 - if !slug.is_empty() { 98 - set.insert(slug); 99 - } 100 - } 101 - } 102 - } 103 - } 104 - } 105 - Arc::new(set) 106 - } 107 - 108 - #[allow(dead_code)] 109 - pub async fn is_builtin_slug(name: &str) -> bool { 110 - let name = name.to_lowercase(); 111 - if let Some(cache) = BUILTIN_SLUGS.get() { 112 - return cache.contains(&name); 113 - } 114 - let set = load_builtin_slugs_inner().await; 115 - let contains = set.contains(&name); 116 - let _ = BUILTIN_SLUGS.set(set); 117 - contains 118 - }
-118
src/error_handler.rs
··· 1 - use actix_web::{HttpResponse, error::ResponseError, http::StatusCode}; 2 - use std::fmt; 3 - 4 - #[derive(Debug)] 5 - pub enum AppError { 6 - InternalError(String), 7 - DatabaseError(String), 8 - AuthenticationError(String), 9 - #[allow(dead_code)] // Keep for potential future use 10 - ValidationError(String), 11 - #[allow(dead_code)] // Keep for potential future use 12 - NotFound(String), 13 - RateLimitExceeded, 14 - } 15 - 16 - impl fmt::Display for AppError { 17 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 18 - match self { 19 - AppError::InternalError(msg) => write!(f, "Internal server error: {}", msg), 20 - AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg), 21 - AppError::AuthenticationError(msg) => write!(f, "Authentication error: {}", msg), 22 - AppError::ValidationError(msg) => write!(f, "Validation error: {}", msg), 23 - AppError::NotFound(msg) => write!(f, "Not found: {}", msg), 24 - AppError::RateLimitExceeded => write!(f, "Rate limit exceeded"), 25 - } 26 - } 27 - } 28 - 29 - impl ResponseError for AppError { 30 - fn error_response(&self) -> HttpResponse { 31 - let (status_code, error_message) = match self { 32 - AppError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), 33 - AppError::DatabaseError(_) => ( 34 - StatusCode::INTERNAL_SERVER_ERROR, 35 - "Database error occurred".to_string(), 36 - ), 37 - AppError::AuthenticationError(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), 38 - AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg.clone()), 39 - AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), 40 - AppError::RateLimitExceeded => ( 41 - StatusCode::TOO_MANY_REQUESTS, 42 - "Rate limit exceeded. Please try again later.".to_string(), 43 - ), 44 - }; 45 - 46 - HttpResponse::build(status_code).body(format!( 47 - "Error {}: {}", 48 - status_code.as_u16(), 49 - error_message 50 - )) 51 - } 52 - 53 - fn status_code(&self) -> StatusCode { 54 - match self { 55 - AppError::InternalError(_) | AppError::DatabaseError(_) => { 56 - StatusCode::INTERNAL_SERVER_ERROR 57 - } 58 - AppError::AuthenticationError(_) => StatusCode::UNAUTHORIZED, 59 - AppError::ValidationError(_) => StatusCode::BAD_REQUEST, 60 - AppError::NotFound(_) => StatusCode::NOT_FOUND, 61 - AppError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS, 62 - } 63 - } 64 - } 65 - 66 - // Conversion helpers 67 - impl From<async_sqlite::Error> for AppError { 68 - fn from(err: async_sqlite::Error) -> Self { 69 - AppError::DatabaseError(err.to_string()) 70 - } 71 - } 72 - 73 - impl From<serde_json::Error> for AppError { 74 - fn from(err: serde_json::Error) -> Self { 75 - AppError::InternalError(err.to_string()) 76 - } 77 - } 78 - 79 - // Helper function to wrap results - removed as unused 80 - // If needed in the future, use: result.map_err(|e| ErrorInternalServerError(e)) 81 - 82 - #[cfg(test)] 83 - mod tests { 84 - use super::*; 85 - 86 - #[test] 87 - fn test_error_display() { 88 - let err = AppError::ValidationError("Invalid input".to_string()); 89 - assert_eq!(err.to_string(), "Validation error: Invalid input"); 90 - 91 - let err = AppError::RateLimitExceeded; 92 - assert_eq!(err.to_string(), "Rate limit exceeded"); 93 - } 94 - 95 - #[test] 96 - fn test_error_status_codes() { 97 - assert_eq!( 98 - AppError::InternalError("test".to_string()).status_code(), 99 - StatusCode::INTERNAL_SERVER_ERROR 100 - ); 101 - assert_eq!( 102 - AppError::ValidationError("test".to_string()).status_code(), 103 - StatusCode::BAD_REQUEST 104 - ); 105 - assert_eq!( 106 - AppError::AuthenticationError("test".to_string()).status_code(), 107 - StatusCode::UNAUTHORIZED 108 - ); 109 - assert_eq!( 110 - AppError::NotFound("test".to_string()).status_code(), 111 - StatusCode::NOT_FOUND 112 - ); 113 - assert_eq!( 114 - AppError::RateLimitExceeded.status_code(), 115 - StatusCode::TOO_MANY_REQUESTS 116 - ); 117 - } 118 - }
-118
src/ingester.rs
··· 1 - use crate::db::StatusFromDb; 2 - use crate::lexicons; 3 - use anyhow::anyhow; 4 - use async_sqlite::Pool; 5 - use async_trait::async_trait; 6 - use log::error; 7 - use rocketman::{ 8 - connection::JetstreamConnection, 9 - handler, 10 - ingestion::LexiconIngestor, 11 - options::JetstreamOptions, 12 - types::event::{Event, Operation}, 13 - }; 14 - use serde_json::Value; 15 - use std::{ 16 - collections::HashMap, 17 - sync::{Arc, Mutex}, 18 - }; 19 - 20 - #[async_trait] 21 - impl LexiconIngestor for StatusSphereIngester { 22 - async fn ingest(&self, message: Event<Value>) -> anyhow::Result<()> { 23 - if let Some(commit) = &message.commit { 24 - //We manually construct the uri since Jetstream does not provide it 25 - //at://{users did}/{collection: xyz.statusphere.status}{records key} 26 - let record_uri = format!("at://{}/{}/{}", message.did, commit.collection, commit.rkey); 27 - match commit.operation { 28 - Operation::Create | Operation::Update => { 29 - if let Some(record) = &commit.record { 30 - let status_at_proto_record = serde_json::from_value::< 31 - lexicons::io::zzstoatzz::status::record::RecordData, 32 - >(record.clone())?; 33 - 34 - if let Some(ref _cid) = commit.cid { 35 - // Although esquema does not have full validation yet, 36 - // if you get to this point, 37 - // You know the data structure is the same 38 - let created = status_at_proto_record.created_at.as_ref(); 39 - let right_now = chrono::Utc::now(); 40 - // We save or update the record in the db 41 - StatusFromDb { 42 - uri: record_uri, 43 - author_did: message.did.clone(), 44 - status: status_at_proto_record.emoji.clone(), 45 - text: status_at_proto_record.text.clone(), 46 - expires_at: status_at_proto_record.expires.as_ref().map(|e| { 47 - // Convert ATProto Datetime to chrono DateTime 48 - chrono::DateTime::parse_from_rfc3339(e.as_str()) 49 - .ok() 50 - .map(|dt| dt.with_timezone(&chrono::Utc)) 51 - .unwrap_or_else(chrono::Utc::now) 52 - }), 53 - started_at: created.to_utc(), 54 - indexed_at: right_now, 55 - handle: None, 56 - } 57 - .save_or_update(&self.db_pool) 58 - .await?; 59 - } 60 - } 61 - } 62 - Operation::Delete => StatusFromDb::delete_by_uri(&self.db_pool, record_uri).await?, 63 - } 64 - } else { 65 - return Err(anyhow!("Message has no commit")); 66 - } 67 - Ok(()) 68 - } 69 - } 70 - pub struct StatusSphereIngester { 71 - db_pool: Arc<Pool>, 72 - } 73 - 74 - pub async fn start_ingester(db_pool: Arc<Pool>) { 75 - // init the builder 76 - let opts = JetstreamOptions::builder() 77 - // listen for our status record collection 78 - .wanted_collections(vec!["io.zzstoatzz.status.record".parse().unwrap()]) 79 - .build(); 80 - // create the jetstream connector 81 - let jetstream = JetstreamConnection::new(opts); 82 - 83 - // create your ingesters 84 - let mut ingesters: HashMap<String, Box<dyn LexiconIngestor + Send + Sync>> = HashMap::new(); 85 - ingesters.insert( 86 - // your EXACT nsid 87 - "io.zzstoatzz.status.record".parse().unwrap(), 88 - Box::new(StatusSphereIngester { db_pool }), 89 - ); 90 - 91 - // tracks the last message we've processed 92 - let cursor: Arc<Mutex<Option<u64>>> = Arc::new(Mutex::new(None)); 93 - 94 - // get channels 95 - let msg_rx = jetstream.get_msg_rx(); 96 - let reconnect_tx = jetstream.get_reconnect_tx(); 97 - 98 - // spawn a task to process messages from the queue. 99 - // this is a simple implementation, you can use a more complex one based on needs. 100 - let c_cursor = cursor.clone(); 101 - tokio::spawn(async move { 102 - while let Ok(message) = msg_rx.recv_async().await { 103 - if let Err(e) = 104 - handler::handle_message(message, &ingesters, reconnect_tx.clone(), c_cursor.clone()) 105 - .await 106 - { 107 - error!("Error processing message: {}", e); 108 - }; 109 - } 110 - }); 111 - 112 - // connect to jetstream 113 - // retries internally, but may fail if there is an extreme error. 114 - if let Err(e) = jetstream.connect(cursor.clone()).await { 115 - error!("Failed to connect to Jetstream: {}", e); 116 - std::process::exit(1); 117 - } 118 - }
-3
src/lexicons/app.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - //!Definitions for the `app` namespace. 3 - pub mod status;
-3
src/lexicons/io.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - //!Definitions for the `io` namespace. 3 - pub mod zzstoatzz;
-3
src/lexicons/io/zzstoatzz.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - //!Definitions for the `io.zzstoatzz` namespace. 3 - pub mod status;
-9
src/lexicons/io/zzstoatzz/status.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - //!Definitions for the `io.zzstoatzz.status` namespace. 3 - pub mod record; 4 - #[derive(Debug)] 5 - pub struct Record; 6 - impl atrium_api::types::Collection for Record { 7 - const NSID: &'static str = "io.zzstoatzz.status.record"; 8 - type Record = record::Record; 9 - }
-23
src/lexicons/io/zzstoatzz/status/record.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - //!Definitions for the `io.zzstoatzz.status.record` namespace. 3 - use atrium_api::types::TryFromUnknown; 4 - #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 - #[serde(rename_all = "camelCase")] 6 - pub struct RecordData { 7 - ///When this status was created 8 - pub created_at: atrium_api::types::string::Datetime, 9 - ///Status emoji 10 - pub emoji: String, 11 - ///Optional expiration timestamp for this status 12 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 13 - pub expires: core::option::Option<atrium_api::types::string::Datetime>, 14 - ///Optional status text description 15 - #[serde(skip_serializing_if = "core::option::Option::is_none")] 16 - pub text: core::option::Option<String>, 17 - } 18 - pub type Record = atrium_api::types::Object<RecordData>; 19 - impl From<atrium_api::types::Unknown> for RecordData { 20 - fn from(value: atrium_api::types::Unknown) -> Self { 21 - Self::try_from_unknown(value).unwrap() 22 - } 23 - }
-3
src/lexicons/mod.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - pub mod io; 3 - pub mod record;
-23
src/lexicons/record.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - //!A collection of known record types. 3 - #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 4 - #[serde(tag = "$type")] 5 - pub enum KnownRecord { 6 - #[serde(rename = "io.zzstoatzz.status.record")] 7 - LexiconsIoZzstoatzzStatusRecord(Box<crate::lexicons::io::zzstoatzz::status::record::Record>), 8 - } 9 - impl From<crate::lexicons::io::zzstoatzz::status::record::Record> for KnownRecord { 10 - fn from(record: crate::lexicons::io::zzstoatzz::status::record::Record) -> Self { 11 - KnownRecord::LexiconsIoZzstoatzzStatusRecord(Box::new(record)) 12 - } 13 - } 14 - impl From<crate::lexicons::io::zzstoatzz::status::record::RecordData> for KnownRecord { 15 - fn from(record_data: crate::lexicons::io::zzstoatzz::status::record::RecordData) -> Self { 16 - KnownRecord::LexiconsIoZzstoatzzStatusRecord(Box::new(record_data.into())) 17 - } 18 - } 19 - impl From<KnownRecord> for atrium_api::types::Unknown { 20 - fn from(val: KnownRecord) -> Self { 21 - atrium_api::types::TryIntoUnknown::try_into_unknown(&val).unwrap() 22 - } 23 - }
-3
src/lexicons/xyz.rs
··· 1 - // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 - //!Definitions for the `xyz` namespace. 3 - pub mod statusphere;
-398
src/main.rs
··· 1 - #![allow(clippy::collapsible_if)] 2 - 3 - use crate::resolver::HickoryDnsTxtResolver; 4 - use crate::{ 5 - api::{HandleResolver, OAuthClientType}, 6 - db::create_tables_in_database, 7 - ingester::start_ingester, 8 - rate_limiter::RateLimiter, 9 - storage::{SqliteSessionStore, SqliteStateStore}, 10 - }; 11 - use actix_files::Files; 12 - use actix_session::{SessionMiddleware, config::PersistentSession, storage::CookieSessionStore}; 13 - use actix_web::{ 14 - App, HttpServer, 15 - cookie::{self, Key}, 16 - middleware, web, 17 - }; 18 - use async_sqlite::PoolBuilder; 19 - use atrium_identity::{ 20 - did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}, 21 - handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 22 - }; 23 - use atrium_oauth::{ 24 - AtprotoClientMetadata, AtprotoLocalhostClientMetadata, AuthMethod, DefaultHttpClient, 25 - GrantType, KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 26 - }; 27 - use dotenv::dotenv; 28 - use std::{io::Error, sync::Arc, time::Duration}; 29 - 30 - mod api; 31 - mod config; 32 - mod db; 33 - mod emoji; 34 - mod error_handler; 35 - mod ingester; 36 - #[allow(dead_code)] 37 - mod lexicons; 38 - mod rate_limiter; 39 - mod resolver; 40 - mod storage; 41 - mod templates; 42 - mod webhooks; 43 - 44 - #[actix_web::main] 45 - async fn main() -> std::io::Result<()> { 46 - dotenv().ok(); 47 - 48 - // Load configuration 49 - let config = config::Config::from_env().expect("Failed to load configuration"); 50 - let app_config = config.clone(); 51 - 52 - env_logger::init_from_env(env_logger::Env::new().default_filter_or(&config.log_level)); 53 - let host = config.server_host.clone(); 54 - let port = config.server_port; 55 - 56 - // Use database URL from config 57 - let db_connection_string = if config.database_url.starts_with("sqlite://") { 58 - config 59 - .database_url 60 - .strip_prefix("sqlite://") 61 - .unwrap_or(&config.database_url) 62 - .to_string() 63 - } else { 64 - config.database_url.clone() 65 - }; 66 - 67 - //Crates a db pool to share resources to the db 68 - let pool = match PoolBuilder::new().path(db_connection_string).open().await { 69 - Ok(pool) => pool, 70 - Err(err) => { 71 - log::error!("Error creating the sqlite pool: {}", err); 72 - return Err(Error::other("sqlite pool could not be created.")); 73 - } 74 - }; 75 - 76 - //Creates the DB and tables 77 - create_tables_in_database(&pool) 78 - .await 79 - .expect("Could not create the database"); 80 - 81 - //Create a new handle resolver for the home page 82 - let http_client = Arc::new(DefaultHttpClient::default()); 83 - 84 - let handle_resolver = CommonDidResolver::new(CommonDidResolverConfig { 85 - plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 86 - http_client: http_client.clone(), 87 - }); 88 - let handle_resolver: HandleResolver = Arc::new(handle_resolver); 89 - 90 - // Create a new OAuth client 91 - let http_client = Arc::new(DefaultHttpClient::default()); 92 - 93 - // Check if we're running in production (non-localhost) or locally 94 - let is_production = !config.oauth_redirect_base.starts_with("http://localhost") 95 - && !config.oauth_redirect_base.starts_with("http://127.0.0.1"); 96 - 97 - let client: OAuthClientType = if is_production { 98 - // Production configuration with AtprotoClientMetadata 99 - log::debug!( 100 - "Configuring OAuth for production with URL: {}", 101 - config.oauth_redirect_base 102 - ); 103 - 104 - let oauth_config = OAuthClientConfig { 105 - client_metadata: AtprotoClientMetadata { 106 - client_id: format!("{}/oauth-client-metadata.json", config.oauth_redirect_base), 107 - client_uri: Some(config.oauth_redirect_base.clone()), 108 - redirect_uris: vec![format!("{}/oauth/callback", config.oauth_redirect_base)], 109 - token_endpoint_auth_method: AuthMethod::None, 110 - grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 111 - scopes: vec![ 112 - Scope::Known(KnownScope::Atproto), 113 - // Using granular scope for status records only 114 - // This replaces TransitionGeneric with specific permissions 115 - Scope::Unknown("repo:io.zzstoatzz.status.record".to_string()), 116 - // Need to read profiles for the feed page 117 - Scope::Unknown("rpc:app.bsky.actor.getProfile".to_string()), 118 - ], 119 - jwks_uri: None, 120 - token_endpoint_auth_signing_alg: None, 121 - }, 122 - keys: None, 123 - resolver: OAuthResolverConfig { 124 - did_resolver: CommonDidResolver::new(CommonDidResolverConfig { 125 - plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 126 - http_client: http_client.clone(), 127 - }), 128 - handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 129 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 130 - http_client: http_client.clone(), 131 - }), 132 - authorization_server_metadata: Default::default(), 133 - protected_resource_metadata: Default::default(), 134 - }, 135 - state_store: SqliteStateStore::new(pool.clone()), 136 - session_store: SqliteSessionStore::new(pool.clone()), 137 - }; 138 - Arc::new(OAuthClient::new(oauth_config).expect("failed to create OAuth client")) 139 - } else { 140 - // Local development configuration with AtprotoLocalhostClientMetadata 141 - log::debug!( 142 - "Configuring OAuth for local development at {}:{}", 143 - host, 144 - port 145 - ); 146 - 147 - let oauth_config = OAuthClientConfig { 148 - client_metadata: AtprotoLocalhostClientMetadata { 149 - redirect_uris: Some(vec![format!( 150 - //This must match the endpoint you use the callback function 151 - "http://{host}:{port}/oauth/callback" 152 - )]), 153 - scopes: Some(vec![ 154 - Scope::Known(KnownScope::Atproto), 155 - // Using granular scope for status records only 156 - // This replaces TransitionGeneric with specific permissions 157 - Scope::Unknown("repo:io.zzstoatzz.status.record".to_string()), 158 - // Need to read profiles for the feed page 159 - Scope::Unknown( 160 - "rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview" 161 - .to_string(), 162 - ), 163 - // Need to read following list for following feed 164 - Scope::Unknown( 165 - "rpc:app.bsky.graph.getFollows?aud=did:web:api.bsky.app".to_string(), 166 - ), 167 - ]), 168 - }, 169 - keys: None, 170 - resolver: OAuthResolverConfig { 171 - did_resolver: CommonDidResolver::new(CommonDidResolverConfig { 172 - plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 173 - http_client: http_client.clone(), 174 - }), 175 - handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig { 176 - dns_txt_resolver: HickoryDnsTxtResolver::default(), 177 - http_client: http_client.clone(), 178 - }), 179 - authorization_server_metadata: Default::default(), 180 - protected_resource_metadata: Default::default(), 181 - }, 182 - state_store: SqliteStateStore::new(pool.clone()), 183 - session_store: SqliteSessionStore::new(pool.clone()), 184 - }; 185 - Arc::new(OAuthClient::new(oauth_config).expect("failed to create OAuth client")) 186 - }; 187 - // Only start the firehose ingester if enabled (from config) 188 - if app_config.enable_firehose { 189 - let arc_pool = Arc::new(pool.clone()); 190 - log::debug!("Starting Jetstream firehose ingester"); 191 - //Spawns the ingester that listens for other's Statusphere updates 192 - tokio::spawn(async move { 193 - start_ingester(arc_pool).await; 194 - }); 195 - } else { 196 - log::debug!("Jetstream firehose disabled (set ENABLE_FIREHOSE=true to enable)"); 197 - } 198 - let arc_pool = Arc::new(pool.clone()); 199 - 200 - // Create rate limiter - 30 requests per minute per IP 201 - let rate_limiter = web::Data::new(RateLimiter::new(30, Duration::from_secs(60))); 202 - 203 - // Initialize runtime emoji directory (kept out of main for clarity) 204 - emoji::init_runtime_dir(&config); 205 - 206 - log::debug!("starting HTTP server at http://{host}:{port}"); 207 - HttpServer::new(move || { 208 - App::new() 209 - .wrap(middleware::Logger::default()) 210 - .app_data(web::Data::new(client.clone())) 211 - .app_data(web::Data::new(arc_pool.clone())) 212 - .app_data(web::Data::new(handle_resolver.clone())) 213 - .app_data(web::Data::new(app_config.clone())) 214 - .app_data(rate_limiter.clone()) 215 - .wrap( 216 - SessionMiddleware::builder(CookieSessionStore::default(), Key::from(&[0; 64])) 217 - //TODO will need to set to true in production 218 - .cookie_secure(false) 219 - // customize session and cookie expiration 220 - .session_lifecycle( 221 - PersistentSession::default().session_ttl(cookie::time::Duration::days(14)), 222 - ) 223 - .build(), 224 - ) 225 - .service(Files::new("/static", "static").show_files_listing()) 226 - .service( 227 - Files::new("/emojis", app_config.emoji_dir.clone()) 228 - .use_last_modified(true) 229 - .use_etag(true) 230 - .show_files_listing(), 231 - ) 232 - .configure(api::configure_routes) 233 - }) 234 - .bind((host.as_str(), port))? 235 - .run() 236 - .await 237 - } 238 - 239 - #[cfg(test)] 240 - mod tests { 241 - use super::*; 242 - use crate::api::status_read::{api_feed, feed, get_custom_emojis}; 243 - use actix_web::{App, test}; 244 - 245 - #[actix_web::test] 246 - async fn test_health_check() { 247 - // Simple test to verify our test infrastructure works 248 - assert_eq!(2 + 2, 4); 249 - } 250 - 251 - #[actix_web::test] 252 - async fn test_custom_emojis_endpoint() { 253 - // Test that the custom emojis endpoint returns JSON 254 - let cfg = crate::config::Config::from_env().expect("load config"); 255 - let app = test::init_service( 256 - App::new() 257 - .app_data(web::Data::new(cfg)) 258 - .service(get_custom_emojis), 259 - ) 260 - .await; 261 - 262 - let req = test::TestRequest::get() 263 - .uri("/api/custom-emojis") 264 - .to_request(); 265 - 266 - let resp = test::call_service(&app, req).await; 267 - assert!(resp.status().is_success()); 268 - } 269 - 270 - #[actix_web::test] 271 - async fn test_feed_html_has_status_list_container() { 272 - use async_sqlite::PoolBuilder; 273 - use atrium_identity::did::{ 274 - CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL, 275 - }; 276 - use atrium_oauth::DefaultHttpClient; 277 - 278 - let cfg = crate::config::Config::from_env().expect("load config"); 279 - let pool = PoolBuilder::new() 280 - .path(":memory:") 281 - .open() 282 - .await 283 - .expect("pool"); 284 - let arc_pool = std::sync::Arc::new(pool); 285 - 286 - let resolver = CommonDidResolver::new(CommonDidResolverConfig { 287 - plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 288 - http_client: std::sync::Arc::new(DefaultHttpClient::default()), 289 - }); 290 - let handle_resolver = std::sync::Arc::new(resolver); 291 - 292 - let app = test::init_service( 293 - App::new() 294 - .app_data(web::Data::new(cfg)) 295 - .app_data(web::Data::new(arc_pool)) 296 - .app_data(web::Data::new(handle_resolver)) 297 - .service(feed), 298 - ) 299 - .await; 300 - 301 - let req = test::TestRequest::get().uri("/feed").to_request(); 302 - let resp = test::call_service(&app, req).await; 303 - assert!(resp.status().is_success()); 304 - let body = test::read_body(resp).await; 305 - let html = String::from_utf8(body.to_vec()).expect("utf8"); 306 - assert!( 307 - html.contains("class=\"status-list\""), 308 - "feed HTML must include an empty .status-list container for client-side population" 309 - ); 310 - } 311 - 312 - #[actix_web::test] 313 - async fn test_api_feed_shape() { 314 - use async_sqlite::PoolBuilder; 315 - use atrium_identity::did::{ 316 - CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL, 317 - }; 318 - use atrium_oauth::DefaultHttpClient; 319 - use serde_json::Value; 320 - 321 - let pool = PoolBuilder::new() 322 - .path(":memory:") 323 - .open() 324 - .await 325 - .expect("pool"); 326 - let arc_pool = std::sync::Arc::new(pool); 327 - let resolver = CommonDidResolver::new(CommonDidResolverConfig { 328 - plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(), 329 - http_client: std::sync::Arc::new(DefaultHttpClient::default()), 330 - }); 331 - let handle_resolver = std::sync::Arc::new(resolver); 332 - 333 - let app = test::init_service( 334 - App::new() 335 - .app_data(web::Data::new(arc_pool)) 336 - .app_data(web::Data::new(handle_resolver)) 337 - .service(api_feed), 338 - ) 339 - .await; 340 - 341 - let req = test::TestRequest::get() 342 - .uri("/api/feed?offset=0&limit=20") 343 - .to_request(); 344 - let resp = test::call_service(&app, req).await; 345 - assert!(resp.status().is_success()); 346 - let body = test::read_body(resp).await; 347 - let v: Value = serde_json::from_slice(&body).expect("json"); 348 - assert!( 349 - v.get("statuses").map(|s| s.is_array()).unwrap_or(false), 350 - "statuses must be an array" 351 - ); 352 - assert!(v.get("has_more").is_some(), "has_more present"); 353 - assert!(v.get("next_offset").is_some(), "next_offset present"); 354 - } 355 - 356 - #[actix_web::test] 357 - async fn test_rate_limiting() { 358 - // Simple test of the rate limiter directly 359 - let rate_limiter = RateLimiter::new(3, Duration::from_secs(60)); 360 - 361 - // Should allow first 3 requests from same IP 362 - for i in 0..3 { 363 - assert!( 364 - rate_limiter.check_rate_limit("test_ip"), 365 - "Request {} should be allowed", 366 - i + 1 367 - ); 368 - } 369 - 370 - // 4th request should be blocked 371 - assert!( 372 - !rate_limiter.check_rate_limit("test_ip"), 373 - "4th request should be blocked" 374 - ); 375 - 376 - // Different IP should have its own limit 377 - assert!( 378 - rate_limiter.check_rate_limit("different_ip"), 379 - "Different IP should have its own rate limit" 380 - ); 381 - } 382 - 383 - #[actix_web::test] 384 - async fn test_error_handling() { 385 - use crate::error_handler::AppError; 386 - use actix_web::{ResponseError, http::StatusCode}; 387 - 388 - // Test that our error types return correct status codes 389 - let err = AppError::ValidationError("test".to_string()); 390 - assert_eq!(err.status_code(), StatusCode::BAD_REQUEST); 391 - 392 - let err = AppError::RateLimitExceeded; 393 - assert_eq!(err.status_code(), StatusCode::TOO_MANY_REQUESTS); 394 - 395 - let err = AppError::AuthenticationError("test".to_string()); 396 - assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); 397 - } 398 - }
-110
src/rate_limiter.rs
··· 1 - use actix_web::HttpRequest; 2 - use std::collections::HashMap; 3 - use std::sync::{Arc, Mutex}; 4 - use std::time::{Duration, Instant}; 5 - 6 - #[derive(Clone)] 7 - pub struct RateLimiter { 8 - buckets: Arc<Mutex<HashMap<String, TokenBucket>>>, 9 - max_tokens: u32, 10 - refill_rate: Duration, 11 - } 12 - 13 - struct TokenBucket { 14 - tokens: u32, 15 - last_refill: Instant, 16 - } 17 - 18 - impl RateLimiter { 19 - pub fn new(max_tokens: u32, refill_rate: Duration) -> Self { 20 - Self { 21 - buckets: Arc::new(Mutex::new(HashMap::new())), 22 - max_tokens, 23 - refill_rate, 24 - } 25 - } 26 - 27 - pub fn check_rate_limit(&self, key: &str) -> bool { 28 - let mut buckets = self.buckets.lock().unwrap(); 29 - let now = Instant::now(); 30 - 31 - let bucket = buckets.entry(key.to_string()).or_insert(TokenBucket { 32 - tokens: self.max_tokens, 33 - last_refill: now, 34 - }); 35 - 36 - // Refill tokens based on elapsed time 37 - let elapsed = now.duration_since(bucket.last_refill); 38 - let tokens_to_add = (elapsed.as_secs_f64() / self.refill_rate.as_secs_f64() 39 - * self.max_tokens as f64) as u32; 40 - 41 - if tokens_to_add > 0 { 42 - bucket.tokens = (bucket.tokens + tokens_to_add).min(self.max_tokens); 43 - bucket.last_refill = now; 44 - } 45 - 46 - // Check if we have tokens available 47 - if bucket.tokens > 0 { 48 - bucket.tokens -= 1; 49 - true 50 - } else { 51 - false 52 - } 53 - } 54 - 55 - pub fn get_client_key(req: &HttpRequest) -> String { 56 - // Use IP address as the key for rate limiting 57 - req.connection_info() 58 - .realip_remote_addr() 59 - .unwrap_or("unknown") 60 - .to_string() 61 - } 62 - } 63 - 64 - #[cfg(test)] 65 - mod tests { 66 - use super::*; 67 - use std::thread; 68 - 69 - #[test] 70 - fn test_rate_limiter_basic() { 71 - let limiter = RateLimiter::new(5, Duration::from_secs(1)); 72 - 73 - // Should allow first 5 requests 74 - for _ in 0..5 { 75 - assert!(limiter.check_rate_limit("test_client")); 76 - } 77 - 78 - // 6th request should be blocked 79 - assert!(!limiter.check_rate_limit("test_client")); 80 - } 81 - 82 - #[test] 83 - fn test_rate_limiter_refill() { 84 - let limiter = RateLimiter::new(2, Duration::from_millis(100)); 85 - 86 - // Use up tokens 87 - assert!(limiter.check_rate_limit("test_client")); 88 - assert!(limiter.check_rate_limit("test_client")); 89 - assert!(!limiter.check_rate_limit("test_client")); 90 - 91 - // Wait for refill 92 - thread::sleep(Duration::from_millis(150)); 93 - 94 - // Should have tokens again 95 - assert!(limiter.check_rate_limit("test_client")); 96 - } 97 - 98 - #[test] 99 - fn test_rate_limiter_different_clients() { 100 - let limiter = RateLimiter::new(1, Duration::from_secs(1)); 101 - 102 - // Different clients should have separate buckets 103 - assert!(limiter.check_rate_limit("client1")); 104 - assert!(limiter.check_rate_limit("client2")); 105 - 106 - // But same client should be limited 107 - assert!(!limiter.check_rate_limit("client1")); 108 - assert!(!limiter.check_rate_limit("client2")); 109 - } 110 - }
-32
src/resolver.rs
··· 1 - use atrium_identity::handle::DnsTxtResolver; 2 - use hickory_resolver::TokioAsyncResolver; 3 - 4 - /// Setup for dns resolver for the handle resolver 5 - pub struct HickoryDnsTxtResolver { 6 - resolver: hickory_resolver::TokioAsyncResolver, 7 - } 8 - 9 - impl Default for HickoryDnsTxtResolver { 10 - fn default() -> Self { 11 - Self { 12 - resolver: TokioAsyncResolver::tokio_from_system_conf() 13 - .expect("failed to create resolver"), 14 - } 15 - } 16 - } 17 - 18 - impl DnsTxtResolver for HickoryDnsTxtResolver { 19 - async fn resolve( 20 - &self, 21 - query: &str, 22 - ) -> core::result::Result<Vec<String>, Box<dyn std::error::Error + Send + Sync + 'static>> { 23 - println!("Resolving TXT for: {}", query); 24 - Ok(self 25 - .resolver 26 - .txt_lookup(query) 27 - .await? 28 - .iter() 29 - .map(|txt| txt.to_string()) 30 - .collect()) 31 - } 32 - }
-143
src/storage.rs
··· 1 - /// Storage impls to persis OAuth sessions if you are not using the memory stores 2 - /// https://github.com/bluesky-social/statusphere-example-app/blob/main/src/auth/storage.ts 3 - use crate::db::{AuthSession, AuthState}; 4 - use async_sqlite::Pool; 5 - use atrium_api::types::string::Did; 6 - use atrium_common::store::Store; 7 - use atrium_oauth::store::session::SessionStore; 8 - use atrium_oauth::store::state::StateStore; 9 - use serde::Serialize; 10 - use serde::de::DeserializeOwned; 11 - use std::fmt::Debug; 12 - use std::hash::Hash; 13 - use thiserror::Error; 14 - 15 - #[derive(Error, Debug)] 16 - pub enum SqliteStoreError { 17 - #[error("Invalid session")] 18 - InvalidSession, 19 - #[error("Database error: {0}")] 20 - DatabaseError(async_sqlite::Error), 21 - } 22 - 23 - ///Persistent session store in sqlite 24 - impl SessionStore for SqliteSessionStore {} 25 - 26 - pub struct SqliteSessionStore { 27 - db_pool: Pool, 28 - } 29 - 30 - impl SqliteSessionStore { 31 - pub fn new(db: Pool) -> Self { 32 - Self { db_pool: db } 33 - } 34 - } 35 - 36 - impl<K, V> Store<K, V> for SqliteSessionStore 37 - where 38 - K: Debug + Eq + Hash + Send + Sync + 'static + From<Did> + AsRef<str>, 39 - V: Debug + Clone + Send + Sync + 'static + Serialize + DeserializeOwned, 40 - { 41 - type Error = SqliteStoreError; 42 - async fn get(&self, key: &K) -> Result<Option<V>, Self::Error> { 43 - let did = key.as_ref().to_string(); 44 - match AuthSession::get_by_did(&self.db_pool, did).await { 45 - Ok(Some(auth_session)) => { 46 - let deserialized_session: V = serde_json::from_str(&auth_session.session) 47 - .map_err(|_| SqliteStoreError::InvalidSession)?; 48 - Ok(Some(deserialized_session)) 49 - } 50 - Ok(None) => Ok(None), 51 - Err(db_error) => { 52 - log::error!("Database error: {db_error}"); 53 - Err(SqliteStoreError::DatabaseError(db_error)) 54 - } 55 - } 56 - } 57 - 58 - async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 59 - let did = key.as_ref().to_string(); 60 - let auth_session = AuthSession::new(did, value); 61 - auth_session 62 - .save_or_update(&self.db_pool) 63 - .await 64 - .map_err(SqliteStoreError::DatabaseError)?; 65 - Ok(()) 66 - } 67 - 68 - async fn del(&self, _key: &K) -> Result<(), Self::Error> { 69 - let did = _key.as_ref().to_string(); 70 - AuthSession::delete_by_did(&self.db_pool, did) 71 - .await 72 - .map_err(SqliteStoreError::DatabaseError)?; 73 - Ok(()) 74 - } 75 - 76 - async fn clear(&self) -> Result<(), Self::Error> { 77 - AuthSession::delete_all(&self.db_pool) 78 - .await 79 - .map_err(SqliteStoreError::DatabaseError)?; 80 - Ok(()) 81 - } 82 - } 83 - 84 - ///Persistent session state in sqlite 85 - impl StateStore for SqliteStateStore {} 86 - 87 - pub struct SqliteStateStore { 88 - db_pool: Pool, 89 - } 90 - 91 - impl SqliteStateStore { 92 - pub fn new(db: Pool) -> Self { 93 - Self { db_pool: db } 94 - } 95 - } 96 - 97 - impl<K, V> Store<K, V> for SqliteStateStore 98 - where 99 - K: Debug + Eq + Hash + Send + Sync + 'static + From<Did> + AsRef<str>, 100 - V: Debug + Clone + Send + Sync + 'static + Serialize + DeserializeOwned, 101 - { 102 - type Error = SqliteStoreError; 103 - async fn get(&self, key: &K) -> Result<Option<V>, Self::Error> { 104 - let key = key.as_ref().to_string(); 105 - match AuthState::get_by_key(&self.db_pool, key).await { 106 - Ok(Some(auth_state)) => { 107 - let deserialized_state: V = serde_json::from_str(&auth_state.state) 108 - .map_err(|_| SqliteStoreError::InvalidSession)?; 109 - Ok(Some(deserialized_state)) 110 - } 111 - Ok(None) => Ok(None), 112 - Err(db_error) => { 113 - log::error!("Database error: {db_error}"); 114 - Err(SqliteStoreError::DatabaseError(db_error)) 115 - } 116 - } 117 - } 118 - 119 - async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 120 - let did = key.as_ref().to_string(); 121 - let auth_state = AuthState::new(did, value); 122 - auth_state 123 - .save_or_update(&self.db_pool) 124 - .await 125 - .map_err(SqliteStoreError::DatabaseError)?; 126 - Ok(()) 127 - } 128 - 129 - async fn del(&self, _key: &K) -> Result<(), Self::Error> { 130 - let key = _key.as_ref().to_string(); 131 - AuthState::delete_by_key(&self.db_pool, key) 132 - .await 133 - .map_err(SqliteStoreError::DatabaseError)?; 134 - Ok(()) 135 - } 136 - 137 - async fn clear(&self) -> Result<(), Self::Error> { 138 - AuthState::delete_all(&self.db_pool) 139 - .await 140 - .map_err(SqliteStoreError::DatabaseError)?; 141 - Ok(()) 142 - } 143 - }
-51
src/templates.rs
··· 1 - ///The askama template types for HTML 2 - /// 3 - use crate::db::StatusFromDb; 4 - use askama::Template; 5 - use serde::{Deserialize, Serialize}; 6 - 7 - #[derive(Serialize, Deserialize, Debug, Clone)] 8 - pub struct Profile { 9 - pub did: String, 10 - pub display_name: Option<String>, 11 - pub handle: Option<String>, 12 - } 13 - 14 - #[derive(Template)] 15 - #[template(path = "login.html")] 16 - pub struct LoginTemplate<'a> { 17 - #[allow(dead_code)] 18 - pub title: &'a str, 19 - pub error: Option<&'a str>, 20 - } 21 - 22 - #[derive(Template)] 23 - #[template(path = "error.html")] 24 - pub struct ErrorTemplate<'a> { 25 - #[allow(dead_code)] 26 - pub title: &'a str, 27 - pub error: &'a str, 28 - } 29 - 30 - #[derive(Template)] 31 - #[template(path = "status.html")] 32 - pub struct StatusTemplate<'a> { 33 - #[allow(dead_code)] 34 - pub title: &'a str, 35 - pub handle: String, 36 - pub current_status: Option<StatusFromDb>, 37 - pub history: Vec<StatusFromDb>, 38 - pub is_owner: bool, 39 - pub is_admin: bool, 40 - } 41 - 42 - #[derive(Template)] 43 - #[template(path = "feed.html")] 44 - pub struct FeedTemplate<'a> { 45 - #[allow(dead_code)] 46 - pub title: &'a str, 47 - pub profile: Option<Profile>, 48 - pub statuses: Vec<StatusFromDb>, 49 - pub is_admin: bool, 50 - pub dev_mode: bool, 51 - }
-183
src/webhooks.rs
··· 1 - use async_sqlite::Pool; 2 - use hmac::{Hmac, Mac}; 3 - use once_cell::sync::Lazy; 4 - use reqwest::Client; 5 - use serde::Serialize; 6 - use sha2::Sha256; 7 - 8 - use crate::db::{StatusFromDb, Webhook, get_user_webhooks}; 9 - use futures_util::future; 10 - 11 - #[derive(Serialize)] 12 - pub struct StatusEvent<'a> { 13 - pub event: &'a str, // "status.created" | "status.deleted" | "status.cleared" 14 - pub did: &'a str, 15 - pub handle: Option<&'a str>, 16 - pub status: Option<&'a str>, 17 - pub text: Option<&'a str>, 18 - pub uri: Option<&'a str>, 19 - pub since: Option<&'a str>, 20 - pub expires: Option<&'a str>, 21 - } 22 - 23 - fn should_send(h: &Webhook, event: &str) -> bool { 24 - if !h.active { 25 - return false; 26 - } 27 - let events = h.events.trim(); 28 - if events == "*" || events.is_empty() { 29 - return true; 30 - } 31 - events 32 - .split(',') 33 - .map(|e| e.trim()) 34 - .any(|e| e.eq_ignore_ascii_case(event)) 35 - } 36 - 37 - fn hmac_sig_hex(secret: &str, ts: &str, payload: &[u8]) -> String { 38 - let mut mac = 39 - Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size"); 40 - mac.update(ts.as_bytes()); 41 - mac.update(b"."); 42 - mac.update(payload); 43 - hex::encode(mac.finalize().into_bytes()) 44 - } 45 - 46 - static HTTP: Lazy<Client> = Lazy::new(Client::new); 47 - 48 - pub async fn send_status_event(pool: std::sync::Arc<Pool>, did: &str, event: StatusEvent<'_>) { 49 - let hooks = match get_user_webhooks(&pool, did).await { 50 - Ok(h) => h, 51 - Err(e) => { 52 - log::error!("webhooks: failed to load webhooks for {}: {}", did, e); 53 - return; 54 - } 55 - }; 56 - let payload = match serde_json::to_vec(&event) { 57 - Ok(p) => p, 58 - Err(e) => { 59 - log::error!("webhooks: failed to serialize payload: {}", e); 60 - return; 61 - } 62 - }; 63 - let ts = chrono::Utc::now().timestamp().to_string(); 64 - 65 - let futures = hooks 66 - .into_iter() 67 - .filter(|h| should_send(h, event.event)) 68 - .map(|h| { 69 - let payload = payload.clone(); 70 - let ts = ts.clone(); 71 - let client = HTTP.clone(); 72 - async move { 73 - let sig = hmac_sig_hex(&h.secret, &ts, &payload); 74 - let res = client 75 - .post(&h.url) 76 - .header("User-Agent", "status-webhooks/1.0") 77 - .header("Content-Type", "application/json") 78 - .header("X-Status-Webhook-Timestamp", &ts) 79 - .header("X-Status-Webhook-Signature", format!("sha256={}", sig)) 80 - .timeout(std::time::Duration::from_secs(5)) 81 - .body(payload) 82 - .send() 83 - .await; 84 - 85 - match res { 86 - Ok(resp) => { 87 - if !resp.status().is_success() { 88 - let status = resp.status(); 89 - let body = resp.text().await.unwrap_or_default(); 90 - log::warn!( 91 - "webhook delivery failed: {} -> {} body={}", 92 - &h.url, 93 - status, 94 - body 95 - ); 96 - } 97 - } 98 - Err(e) => log::warn!("webhook delivery error to {}: {}", &h.url, e), 99 - } 100 - } 101 - }); 102 - 103 - future::join_all(futures).await; 104 - } 105 - 106 - pub async fn emit_created(pool: std::sync::Arc<Pool>, s: &StatusFromDb) { 107 - let did = s.author_did.clone(); 108 - let emoji = s.status.clone(); 109 - let text = s.text.clone(); 110 - let uri = s.uri.clone(); 111 - let since = s.started_at.to_rfc3339(); 112 - let expires = s.expires_at.map(|e| e.to_rfc3339()); 113 - let event = StatusEvent { 114 - event: "status.created", 115 - did: &did, 116 - handle: None, 117 - status: Some(&emoji), 118 - text: text.as_deref(), 119 - uri: Some(&uri), 120 - since: Some(&since), 121 - expires: expires.as_deref(), 122 - }; 123 - send_status_event(pool, &did, event).await; 124 - } 125 - 126 - pub async fn emit_deleted(pool: std::sync::Arc<Pool>, did: &str, uri: &str) { 127 - let did_owned = did.to_string(); 128 - let uri_owned = uri.to_string(); 129 - let event = StatusEvent { 130 - event: "status.deleted", 131 - did: &did_owned, 132 - handle: None, 133 - status: None, 134 - text: None, 135 - uri: Some(&uri_owned), 136 - since: None, 137 - expires: None, 138 - }; 139 - send_status_event(pool, &did_owned, event).await; 140 - } 141 - 142 - #[cfg(test)] 143 - mod tests { 144 - use super::*; 145 - 146 - #[test] 147 - fn test_should_send_wildcard() { 148 - let h = Webhook { 149 - id: 1, 150 - did: "d".into(), 151 - url: "u".into(), 152 - secret: "s".into(), 153 - events: "*".into(), 154 - active: true, 155 - created_at: 0, 156 - updated_at: 0, 157 - }; 158 - assert!(should_send(&h, "status.created")); 159 - } 160 - 161 - #[test] 162 - fn test_should_send_specific() { 163 - let h = Webhook { 164 - id: 1, 165 - did: "d".into(), 166 - url: "u".into(), 167 - secret: "s".into(), 168 - events: "status.deleted".into(), 169 - active: true, 170 - created_at: 0, 171 - updated_at: 0, 172 - }; 173 - assert!(should_send(&h, "status.deleted")); 174 - assert!(!should_send(&h, "status.created")); 175 - } 176 - 177 - #[test] 178 - fn test_hmac_sig_hex() { 179 - let sig = hmac_sig_hex("secret", "1234567890", b"{\"a\":1}"); 180 - // Deterministic expected if inputs fixed 181 - assert_eq!(sig.len(), 64); 182 - } 183 - }
-120
static/emoji-data.js
··· 1 - // Fetch emoji data from CDN 2 - // Using emoji-datasource which provides comprehensive emoji data with search keywords 3 - async function loadEmojiData() { 4 - try { 5 - console.log('Loading emoji data from CDN...'); 6 - // Using jsdelivr CDN for emoji-datasource-apple (or could use google/twitter/facebook) 7 - const response = await fetch('https://cdn.jsdelivr.net/npm/emoji-datasource@15.1.0/emoji.json'); 8 - if (!response.ok) { 9 - throw new Error(`Failed to fetch emoji data: ${response.status}`); 10 - } 11 - const emojiData = await response.json(); 12 - console.log(`Loaded ${emojiData.length} emojis from CDN`); 13 - 14 - // Transform into a simpler format for our needs 15 - const emojis = {}; // char -> keywords[] 16 - const slugs = {}; // char -> slug (first short_name fallback from name) 17 - const reserved = new Set(); // all slugs 18 - const categories = { 19 - frequent: ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏'], 20 - people: [], 21 - nature: [], 22 - food: [], 23 - activity: [], 24 - travel: [], 25 - objects: [], 26 - symbols: [], 27 - flags: [] 28 - }; 29 - 30 - emojiData.forEach(emoji => { 31 - // Get the actual emoji character 32 - const char = emoji.unified.split('-').map(u => String.fromCodePoint(parseInt(u, 16))).join(''); 33 - 34 - // Build search keywords from short_names and text 35 - const keywords = [ 36 - ...(emoji.short_names || []), 37 - ...(emoji.name ? emoji.name.toLowerCase().split(' ') : []) 38 - ].flat(); 39 - 40 - // Add the name itself as keywords 41 - if (emoji.name) { 42 - keywords.push(...emoji.name.toLowerCase().split(/[\s_-]+/)); 43 - } 44 - 45 - // Add any additional search terms from the texts field 46 - if (emoji.texts) { 47 - keywords.push(...emoji.texts); 48 - } 49 - 50 - emojis[char] = keywords; 51 - 52 - // Pick a slug: prefer the first short_name 53 - let slug = null; 54 - if (emoji.short_names && emoji.short_names.length > 0) { 55 - slug = emoji.short_names[0].toLowerCase(); 56 - } else if (emoji.name) { 57 - slug = emoji.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); 58 - } 59 - if (slug) { 60 - slugs[char] = slug; 61 - reserved.add(slug); 62 - } 63 - 64 - // Add to category 65 - const categoryMap = { 66 - 'Smileys & Emotion': 'people', 67 - 'People & Body': 'people', 68 - 'Animals & Nature': 'nature', 69 - 'Food & Drink': 'food', 70 - 'Activities': 'activity', 71 - 'Travel & Places': 'travel', 72 - 'Objects': 'objects', 73 - 'Symbols': 'symbols', 74 - 'Flags': 'flags' 75 - }; 76 - 77 - const category = categoryMap[emoji.category]; 78 - if (category && categories[category]) { 79 - categories[category].push(char); 80 - } 81 - }); 82 - 83 - console.log(`Built emoji database with ${Object.keys(emojis).length} emojis`); 84 - return { emojis, categories, slugs, reserved: Array.from(reserved) }; 85 - } catch (error) { 86 - console.error('Failed to load emoji data:', error); 87 - // Fallback to a minimal set if the CDN fails 88 - return { 89 - emojis: { 90 - '😊': ['smile', 'happy'], 91 - '👍': ['thumbs up', 'good'], 92 - '❤️': ['heart', 'love'], 93 - '😂': ['laugh', 'lol'], 94 - '🎉': ['party', 'celebrate'] 95 - }, 96 - categories: { 97 - frequent: ['😊', '👍', '❤️', '😂', '🎉'], 98 - people: ['😊', '😂'], 99 - nature: [], 100 - food: [], 101 - activity: [], 102 - travel: [], 103 - objects: [], 104 - symbols: ['❤️'], 105 - flags: [] 106 - }, 107 - slugs: { 108 - '😊': 'smile', 109 - '👍': 'thumbsup', 110 - '❤️': 'heart', 111 - '😂': 'joy', 112 - '🎉': 'tada' 113 - }, 114 - reserved: ['smile','thumbsup','heart','joy','tada'] 115 - }; 116 - } 117 - } 118 - 119 - // Export for use in the main page 120 - window.emojiDataLoader = { loadEmojiData };
-117
static/emoji-resolver.js
··· 1 - // Emoji Resolver Module - Handles mapping emoji names to correct filenames 2 - (function() { 3 - 'use strict'; 4 - 5 - // Cache for emoji name -> filename mapping 6 - let emojiMap = null; 7 - let loadPromise = null; 8 - 9 - // Load emoji mapping from API 10 - async function loadEmojiMap() { 11 - if (emojiMap) return emojiMap; 12 - if (loadPromise) return loadPromise; 13 - 14 - loadPromise = fetch('/api/custom-emojis') 15 - .then(response => response.json()) 16 - .then(data => { 17 - if (!Array.isArray(data)) { 18 - console.error('Invalid emoji data received'); 19 - return new Map(); 20 - } 21 - emojiMap = new Map(data.map(emoji => [emoji.name, emoji.filename])); 22 - return emojiMap; 23 - }) 24 - .catch(err => { 25 - console.error('Failed to load emoji map:', err); 26 - emojiMap = new Map(); 27 - return emojiMap; 28 - }); 29 - 30 - return loadPromise; 31 - } 32 - 33 - // Get the correct emoji filename for a given name 34 - function getEmojiFilename(emojiName) { 35 - if (!emojiMap) return null; 36 - return emojiMap.get(emojiName); 37 - } 38 - 39 - // Update a single emoji image element 40 - function updateEmojiImage(img) { 41 - const emojiName = img.getAttribute('data-emoji-name'); 42 - if (!emojiName) return; 43 - 44 - const filename = getEmojiFilename(emojiName); 45 - if (filename) { 46 - // Found the correct filename, update src 47 - img.src = `/emojis/${filename}`; 48 - // Remove placeholder class if present 49 - img.classList.remove('emoji-placeholder'); 50 - // Remove the error handler since we have the correct path 51 - img.onerror = null; 52 - } else { 53 - // Emoji not found in map, try common extensions as fallback 54 - // This handles newly added emojis that aren't in the cached map yet 55 - img.src = `/emojis/${emojiName}.png`; 56 - img.onerror = function() { 57 - this.onerror = null; 58 - this.src = `/emojis/${emojiName}.gif`; 59 - }; 60 - img.classList.remove('emoji-placeholder'); 61 - } 62 - } 63 - 64 - // Update all emoji images on the page 65 - function updateAllEmojiImages() { 66 - const images = document.querySelectorAll('img[data-emoji-name]'); 67 - images.forEach(updateEmojiImage); 68 - } 69 - 70 - // Initialize on DOM ready 71 - async function initialize() { 72 - // Load the emoji map 73 - await loadEmojiMap(); 74 - // Update all existing emoji images 75 - updateAllEmojiImages(); 76 - 77 - // Set up a MutationObserver to handle dynamically added content 78 - const observer = new MutationObserver((mutations) => { 79 - mutations.forEach((mutation) => { 80 - mutation.addedNodes.forEach((node) => { 81 - if (node.nodeType === Node.ELEMENT_NODE) { 82 - // Check if the added node is an emoji image 83 - if (node.tagName === 'IMG' && node.getAttribute('data-emoji-name')) { 84 - updateEmojiImage(node); 85 - } 86 - // Also check descendants 87 - const images = node.querySelectorAll?.('img[data-emoji-name]'); 88 - images?.forEach(updateEmojiImage); 89 - } 90 - }); 91 - }); 92 - }); 93 - 94 - // Start observing the document body for changes 95 - observer.observe(document.body, { 96 - childList: true, 97 - subtree: true 98 - }); 99 - } 100 - 101 - // Export to global scope 102 - window.EmojiResolver = { 103 - loadEmojiMap, 104 - getEmojiFilename, 105 - updateEmojiImage, 106 - updateAllEmojiImages, 107 - initialize 108 - }; 109 - 110 - // Auto-initialize when DOM is ready 111 - if (document.readyState === 'loading') { 112 - document.addEventListener('DOMContentLoaded', initialize); 113 - } else { 114 - // DOM is already ready 115 - initialize(); 116 - } 117 - })();
static/emojis/according-to-all-known-laws-of-aviation-there-is-no-way-a-bufo-should-be-able-to-fly.png

This is a binary file and will not be displayed.

static/emojis/add-bufo.png

This is a binary file and will not be displayed.

static/emojis/all-the-bufo.png

This is a binary file and will not be displayed.

static/emojis/angry-karen-bufo-would-like-to-speak-with-your-manager.png

This is a binary file and will not be displayed.

static/emojis/australian-bufo.png

This is a binary file and will not be displayed.

static/emojis/awesomebufo.png

This is a binary file and will not be displayed.

static/emojis/be-the-bufo-you-want-to-see.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_0_0.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_0_1.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_0_2.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_0_3.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_1_0.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_1_1.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_1_2.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_1_3.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_2_0.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_2_1.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_2_2.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_2_3.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_3_0.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_3_1.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_3_2.png

This is a binary file and will not be displayed.

static/emojis/bigbufo_3_3.png

This is a binary file and will not be displayed.

static/emojis/blockheads-bufo.png

This is a binary file and will not be displayed.

static/emojis/breaking-bufo.png

This is a binary file and will not be displayed.

static/emojis/bronze-bufo.png

This is a binary file and will not be displayed.

static/emojis/buff-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo's-a-gamer-girl-but-specifically-nyt-games.png

This is a binary file and will not be displayed.

static/emojis/bufo+1.png

This is a binary file and will not be displayed.

static/emojis/bufo-0-10.png

This is a binary file and will not be displayed.

static/emojis/bufo-10-4.png

This is a binary file and will not be displayed.

static/emojis/bufo-10.png

This is a binary file and will not be displayed.

static/emojis/bufo-2022.png

This is a binary file and will not be displayed.

static/emojis/bufo-achieving-coding-flow.png

This is a binary file and will not be displayed.

static/emojis/bufo-ack.png

This is a binary file and will not be displayed.

static/emojis/bufo-actually.png

This is a binary file and will not be displayed.

static/emojis/bufo-adding-bugs-to-the-code.gif

This is a binary file and will not be displayed.

static/emojis/bufo-adidas.png

This is a binary file and will not be displayed.

static/emojis/bufo-ages-rapidly-in-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-aight-imma-head-out.gif

This is a binary file and will not be displayed.

static/emojis/bufo-airpods.png

This is a binary file and will not be displayed.

static/emojis/bufo-alarma.gif

This is a binary file and will not be displayed.

static/emojis/bufo-all-good.png

This is a binary file and will not be displayed.

static/emojis/bufo-all-warm-and-fuzzy-inside.png

This is a binary file and will not be displayed.

static/emojis/bufo-am-i.png

This is a binary file and will not be displayed.

static/emojis/bufo-amaze.gif

This is a binary file and will not be displayed.

static/emojis/bufo-ambiently-existing.png

This is a binary file and will not be displayed.

static/emojis/bufo-american-football.png

This is a binary file and will not be displayed.

static/emojis/bufo-android.png

This is a binary file and will not be displayed.

static/emojis/bufo-angel.png

This is a binary file and will not be displayed.

static/emojis/bufo-angrily-gives-you-a-birthday-gift.png

This is a binary file and will not be displayed.

static/emojis/bufo-angrily-gives-you-white-elephant-gift.png

This is a binary file and will not be displayed.

static/emojis/bufo-angry-at-fly.png

This is a binary file and will not be displayed.

static/emojis/bufo-angry-bullfrog-screech.gif

This is a binary file and will not be displayed.

static/emojis/bufo-angry.gif

This is a binary file and will not be displayed.

static/emojis/bufo-angry.png

This is a binary file and will not be displayed.

static/emojis/bufo-angryandfrozen.png

This is a binary file and will not be displayed.

static/emojis/bufo-anime-glasses.png

This is a binary file and will not be displayed.

static/emojis/bufo-appears.gif

This is a binary file and will not be displayed.

static/emojis/bufo-apple.png

This is a binary file and will not be displayed.

static/emojis/bufo-appreciates-jwst-pillars-of-creation.png

This is a binary file and will not be displayed.

static/emojis/bufo-approve.png

This is a binary file and will not be displayed.

static/emojis/bufo-arabicus.png

This is a binary file and will not be displayed.

static/emojis/bufo-are-you-seeing-this.gif

This is a binary file and will not be displayed.

static/emojis/bufo-arr.png

This is a binary file and will not be displayed.

static/emojis/bufo-arrr.png

This is a binary file and will not be displayed.

static/emojis/bufo-arrrrrr.png

This is a binary file and will not be displayed.

static/emojis/bufo-arrrrrrr.png

This is a binary file and will not be displayed.

static/emojis/bufo-arrrrrrrrr.png

This is a binary file and will not be displayed.

static/emojis/bufo-arrrrrrrrrrrrrrr.png

This is a binary file and will not be displayed.

static/emojis/bufo-artist.png

This is a binary file and will not be displayed.

static/emojis/bufo-asks-politely-to-stop.png

This is a binary file and will not be displayed.

static/emojis/bufo-assists-with-the-landing.gif

This is a binary file and will not be displayed.

static/emojis/bufo-atc.png

This is a binary file and will not be displayed.

static/emojis/bufo-away.png

This is a binary file and will not be displayed.

static/emojis/bufo-awkward-smile-nod.gif

This is a binary file and will not be displayed.

static/emojis/bufo-awkward-smile.png

This is a binary file and will not be displayed.

static/emojis/bufo-ayy.png

This is a binary file and will not be displayed.

static/emojis/bufo-baby.png

This is a binary file and will not be displayed.

static/emojis/bufo-babysits-an-urgent-ticket.png

This is a binary file and will not be displayed.

static/emojis/bufo-back-pat.png

This is a binary file and will not be displayed.

static/emojis/bufo-backpack.png

This is a binary file and will not be displayed.

static/emojis/bufo-backpat.png

This is a binary file and will not be displayed.

static/emojis/bufo-bag-of-bufos.png

This is a binary file and will not be displayed.

static/emojis/bufo-bait.png

This is a binary file and will not be displayed.

static/emojis/bufo-baker.png

This is a binary file and will not be displayed.

static/emojis/bufo-baller.png

This is a binary file and will not be displayed.

static/emojis/bufo-bandana.png

This is a binary file and will not be displayed.

static/emojis/bufo-banging-head-against-the-wall.gif

This is a binary file and will not be displayed.

static/emojis/bufo-barbie.png

This is a binary file and will not be displayed.

static/emojis/bufo-barney.png

This is a binary file and will not be displayed.

static/emojis/bufo-barrister.png

This is a binary file and will not be displayed.

static/emojis/bufo-baseball.png

This is a binary file and will not be displayed.

static/emojis/bufo-basketball.png

This is a binary file and will not be displayed.

static/emojis/bufo-batman.png

This is a binary file and will not be displayed.

static/emojis/bufo-be-my-valentine.png

This is a binary file and will not be displayed.

static/emojis/bufo-became-a-stranger-whose-laugh-you-can-recognize-anywhere.png

This is a binary file and will not be displayed.

static/emojis/bufo-bee-leaf.png

This is a binary file and will not be displayed.

static/emojis/bufo-bee-sad.png

This is a binary file and will not be displayed.

static/emojis/bufo-bee.png

This is a binary file and will not be displayed.

static/emojis/bufo-beer.png

This is a binary file and will not be displayed.

static/emojis/bufo-begrudgingly-offers-you-a-plus.png

This is a binary file and will not be displayed.

static/emojis/bufo-begs-for-ethernet-cable.png

This is a binary file and will not be displayed.

static/emojis/bufo-behind-bars.png

This is a binary file and will not be displayed.

static/emojis/bufo-bell-pepper.png

This is a binary file and will not be displayed.

static/emojis/bufo-betray-but-its-a-hotdog.png

This is a binary file and will not be displayed.

static/emojis/bufo-betray.png

This is a binary file and will not be displayed.

static/emojis/bufo-big-eyes-stare.png

This is a binary file and will not be displayed.

static/emojis/bufo-bigfoot.png

This is a binary file and will not be displayed.

static/emojis/bufo-bill-pay.png

This is a binary file and will not be displayed.

static/emojis/bufo-bird.png

This is a binary file and will not be displayed.

static/emojis/bufo-birthday-but-not-particularly-happy.png

This is a binary file and will not be displayed.

static/emojis/bufo-black-history.png

This is a binary file and will not be displayed.

static/emojis/bufo-black-tea.png

This is a binary file and will not be displayed.

static/emojis/bufo-blank-stare.png

This is a binary file and will not be displayed.

static/emojis/bufo-blank-stare_0_0.png

This is a binary file and will not be displayed.

static/emojis/bufo-blank-stare_0_1.png

This is a binary file and will not be displayed.

static/emojis/bufo-blank-stare_1_0.png

This is a binary file and will not be displayed.

static/emojis/bufo-blank-stare_1_1.png

This is a binary file and will not be displayed.

static/emojis/bufo-blanket.png

This is a binary file and will not be displayed.

static/emojis/bufo-blem.png

This is a binary file and will not be displayed.

static/emojis/bufo-blep.png

This is a binary file and will not be displayed.

static/emojis/bufo-bless-back.png

This is a binary file and will not be displayed.

static/emojis/bufo-bless.png

This is a binary file and will not be displayed.

static/emojis/bufo-blesses-this-pr.png

This is a binary file and will not be displayed.

static/emojis/bufo-block.png

This is a binary file and will not be displayed.

static/emojis/bufo-blogging.png

This is a binary file and will not be displayed.

static/emojis/bufo-bloody-mary.png

This is a binary file and will not be displayed.

static/emojis/bufo-blows-the-magic-conch.png

This is a binary file and will not be displayed.

static/emojis/bufo-blue.png

This is a binary file and will not be displayed.

static/emojis/bufo-blueberries.png

This is a binary file and will not be displayed.

static/emojis/bufo-blush.gif

This is a binary file and will not be displayed.

static/emojis/bufo-bob-ross.png

This is a binary file and will not be displayed.

static/emojis/bufo-boba-army.png

This is a binary file and will not be displayed.

static/emojis/bufo-boba.png

This is a binary file and will not be displayed.

static/emojis/bufo-boi.gif

This is a binary file and will not be displayed.

static/emojis/bufo-boiii.gif

This is a binary file and will not be displayed.

static/emojis/bufo-bongo.gif

This is a binary file and will not be displayed.

static/emojis/bufo-bonk.png

This is a binary file and will not be displayed.

static/emojis/bufo-bops-you-on-the-head-with-a-baguette.png

This is a binary file and will not be displayed.

static/emojis/bufo-bops-you-on-the-head-with-a-rolled-up-newspaper.png

This is a binary file and will not be displayed.

static/emojis/bufo-bouge.png

This is a binary file and will not be displayed.

static/emojis/bufo-bouncer-says-its-time-to-go-now.png

This is a binary file and will not be displayed.

static/emojis/bufo-bouquet.png

This is a binary file and will not be displayed.

static/emojis/bufo-bourgeoisie.png

This is a binary file and will not be displayed.

static/emojis/bufo-bowser.png

This is a binary file and will not be displayed.

static/emojis/bufo-box-of-chocolates.png

This is a binary file and will not be displayed.

static/emojis/bufo-brain-damage-escalates-to-new-heights.gif

This is a binary file and will not be displayed.

static/emojis/bufo-brain-damage-intensifies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-brain-damage-intesifies-more.gif

This is a binary file and will not be displayed.

static/emojis/bufo-brain-damage.png

This is a binary file and will not be displayed.

static/emojis/bufo-brain-exploding.gif

This is a binary file and will not be displayed.

static/emojis/bufo-brain.png

This is a binary file and will not be displayed.

static/emojis/bufo-breakdown.png

This is a binary file and will not be displayed.

static/emojis/bufo-breaks-tech-bros-heart.png

This is a binary file and will not be displayed.

static/emojis/bufo-breaks-up-with-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-breaks-your-heart.png

This is a binary file and will not be displayed.

static/emojis/bufo-brick.png

This is a binary file and will not be displayed.

static/emojis/bufo-brings-a-new-meaning-to-brain-freeze-by-bopping-you-on-the-head-with-a-popsicle.gif

This is a binary file and will not be displayed.

static/emojis/bufo-brings-a-new-meaning-to-gaveled-by-slamming-the-hammer-very-loud.gif

This is a binary file and will not be displayed.

static/emojis/bufo-brings-magic-to-the-riot.gif

This is a binary file and will not be displayed.

static/emojis/bufo-broccoli.png

This is a binary file and will not be displayed.

static/emojis/bufo-broke-his-toe-and-isn't-sure-what-to-do-about-the-12k-he-signed-up-for.png

This is a binary file and will not be displayed.

static/emojis/bufo-broke.png

This is a binary file and will not be displayed.

static/emojis/bufo-broom.png

This is a binary file and will not be displayed.

static/emojis/bufo-brought-a-taco.png

This is a binary file and will not be displayed.

static/emojis/bufo-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-but-anatomically-correct.png

This is a binary file and will not be displayed.

static/emojis/bufo-but-instead-of-green-its-hotdogs.png

This is a binary file and will not be displayed.

static/emojis/bufo-but-instead-of-green-its-pizza.png

This is a binary file and will not be displayed.

static/emojis/bufo-but-you-can-feel-the-electro-house-music-in-the-gif-and-oh-yea-theres-also-a-dapper-chicken.gif

This is a binary file and will not be displayed.

static/emojis/bufo-but-you-can-see-the-bufo-in-bufos-eyes.png

This is a binary file and will not be displayed.

static/emojis/bufo-but-you-can-see-the-hotdog-in-their-eyes.png

This is a binary file and will not be displayed.

static/emojis/bufo-buy-high-sell-low.png

This is a binary file and will not be displayed.

static/emojis/bufo-buy-low-sell-high.png

This is a binary file and will not be displayed.

static/emojis/bufo-cache-buddy.png

This is a binary file and will not be displayed.

static/emojis/bufo-cackle.gif

This is a binary file and will not be displayed.

static/emojis/bufo-call-for-help.png

This is a binary file and will not be displayed.

static/emojis/bufo-came-into-the-office-just-to-use-the-printer.png

This is a binary file and will not be displayed.

static/emojis/bufo-can't-believe-heartbreak-feels-good-in-a-place-like-this.png

This is a binary file and will not be displayed.

static/emojis/bufo-can't-help-but-wonder-who-watches-the-watchmen.png

This is a binary file and will not be displayed.

static/emojis/bufo-canada.png

This is a binary file and will not be displayed.

static/emojis/bufo-cant-believe-your-audacity.png

This is a binary file and will not be displayed.

static/emojis/bufo-cant-find-a-pull-request.png

This is a binary file and will not be displayed.

static/emojis/bufo-cant-find-an-issue.png

This is a binary file and will not be displayed.

static/emojis/bufo-cant-stop-thinking-about-usher-killing-it-on-roller-skates.png

This is a binary file and will not be displayed.

static/emojis/bufo-cant-take-it-anymore.png

This is a binary file and will not be displayed.

static/emojis/bufo-cantelope.png

This is a binary file and will not be displayed.

static/emojis/bufo-capri-sun.png

This is a binary file and will not be displayed.

static/emojis/bufo-captain-obvious.png

This is a binary file and will not be displayed.

static/emojis/bufo-caribou.png

This is a binary file and will not be displayed.

static/emojis/bufo-carnage.png

This is a binary file and will not be displayed.

static/emojis/bufo-carrot.png

This is a binary file and will not be displayed.

static/emojis/bufo-cash-money.png

This is a binary file and will not be displayed.

static/emojis/bufo-cash-squint.png

This is a binary file and will not be displayed.

static/emojis/bufo-casts-a-spell-on-you.gif

This is a binary file and will not be displayed.

static/emojis/bufo-catch.png

This is a binary file and will not be displayed.

static/emojis/bufo-caught-a-radioactive-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-caught-a-small-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-caused-an-incident.png

This is a binary file and will not be displayed.

static/emojis/bufo-celebrate.png

This is a binary file and will not be displayed.

static/emojis/bufo-censored.png

This is a binary file and will not be displayed.

static/emojis/bufo-chappell-roan.png

This is a binary file and will not be displayed.

static/emojis/bufo-chatting.gif

This is a binary file and will not be displayed.

static/emojis/bufo-check.png

This is a binary file and will not be displayed.

static/emojis/bufo-checks-out-the-vibe.png

This is a binary file and will not be displayed.

static/emojis/bufo-cheese.png

This is a binary file and will not be displayed.

static/emojis/bufo-chef.png

This is a binary file and will not be displayed.

static/emojis/bufo-chefkiss-with-hat.png

This is a binary file and will not be displayed.

static/emojis/bufo-chefkiss.png

This is a binary file and will not be displayed.

static/emojis/bufo-cherries.png

This is a binary file and will not be displayed.

static/emojis/bufo-chicken.png

This is a binary file and will not be displayed.

static/emojis/bufo-chomp.gif

This is a binary file and will not be displayed.

static/emojis/bufo-christmas.gif

This is a binary file and will not be displayed.

static/emojis/bufo-chungus.png

This is a binary file and will not be displayed.

static/emojis/bufo-churns-the-butter.gif

This is a binary file and will not be displayed.

static/emojis/bufo-clap-hd.gif

This is a binary file and will not be displayed.

static/emojis/bufo-clap.gif

This is a binary file and will not be displayed.

static/emojis/bufo-claus.png

This is a binary file and will not be displayed.

static/emojis/bufo-clown.png

This is a binary file and will not be displayed.

static/emojis/bufo-coconut.png

This is a binary file and will not be displayed.

static/emojis/bufo-code-freeze.png

This is a binary file and will not be displayed.

static/emojis/bufo-coding.png

This is a binary file and will not be displayed.

static/emojis/bufo-coffee-happy.png

This is a binary file and will not be displayed.

static/emojis/bufo-coin.gif

This is a binary file and will not be displayed.

static/emojis/bufo-come-to-the-dark-side.png

This is a binary file and will not be displayed.

static/emojis/bufo-comfy.gif

This is a binary file and will not be displayed.

static/emojis/bufo-commits-digital-piracy.png

This is a binary file and will not be displayed.

static/emojis/bufo-competes-in-the-bufo-bracket.png

This is a binary file and will not be displayed.

static/emojis/bufo-complies-with-the-chinese-government.png

This is a binary file and will not be displayed.

static/emojis/bufo-concerned.png

This is a binary file and will not be displayed.

static/emojis/bufo-cone-of-shame.png

This is a binary file and will not be displayed.

static/emojis/bufo-confetti.png

This is a binary file and will not be displayed.

static/emojis/bufo-confused.gif

This is a binary file and will not be displayed.

static/emojis/bufo-congrats.png

This is a binary file and will not be displayed.

static/emojis/bufo-cookie.png

This is a binary file and will not be displayed.

static/emojis/bufo-cool-glasses.gif

This is a binary file and will not be displayed.

static/emojis/bufo-corn.png

This is a binary file and will not be displayed.

static/emojis/bufo-cornucopia.png

This is a binary file and will not be displayed.

static/emojis/bufo-covid.png

This is a binary file and will not be displayed.

static/emojis/bufo-cowboy.png

This is a binary file and will not be displayed.

static/emojis/bufo-cozy-blanky.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-blue-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-blue.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-cyan-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-cyan.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-green-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-green.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-lime-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-lime.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-orange-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-orange.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-pink-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-pink.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-purple-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-purple.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-red-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-red.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-yellow-bounce.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crewmate-yellow.png

This is a binary file and will not be displayed.

static/emojis/bufo-crewmates.gif

This is a binary file and will not be displayed.

static/emojis/bufo-cries-into-his-beer.png

This is a binary file and will not be displayed.

static/emojis/bufo-crikey.png

This is a binary file and will not be displayed.

static/emojis/bufo-croptop.png

This is a binary file and will not be displayed.

static/emojis/bufo-crumbs.png

This is a binary file and will not be displayed.

static/emojis/bufo-crustacean.png

This is a binary file and will not be displayed.

static/emojis/bufo-cry-pray.png

This is a binary file and will not be displayed.

static/emojis/bufo-cry.png

This is a binary file and will not be displayed.

static/emojis/bufo-crying-in-the-rain.png

This is a binary file and will not be displayed.

static/emojis/bufo-crying-jail.png

This is a binary file and will not be displayed.

static/emojis/bufo-crying-stop.gif

This is a binary file and will not be displayed.

static/emojis/bufo-crying-tears-of-crying-tears-of-joy.png

This is a binary file and will not be displayed.

static/emojis/bufo-crying-why.png

This is a binary file and will not be displayed.

static/emojis/bufo-crying.gif

This is a binary file and will not be displayed.

static/emojis/bufo-cubo.png

This is a binary file and will not be displayed.

static/emojis/bufo-cucumber.png

This is a binary file and will not be displayed.

static/emojis/bufo-cuddle.png

This is a binary file and will not be displayed.

static/emojis/bufo-cupcake.png

This is a binary file and will not be displayed.

static/emojis/bufo-cuppa.png

This is a binary file and will not be displayed.

static/emojis/bufo-cute-dance.gif

This is a binary file and will not be displayed.

static/emojis/bufo-cute.png

This is a binary file and will not be displayed.

static/emojis/bufo-dab.png

This is a binary file and will not be displayed.

static/emojis/bufo-dancing.gif

This is a binary file and will not be displayed.

static/emojis/bufo-dapper.png

This is a binary file and will not be displayed.

static/emojis/bufo-dbz.png

This is a binary file and will not be displayed.

static/emojis/bufo-deal-with-it.png

This is a binary file and will not be displayed.

static/emojis/bufo-declines-your-suppository-offer.png

This is a binary file and will not be displayed.

static/emojis/bufo-deep-hmm.gif

This is a binary file and will not be displayed.

static/emojis/bufo-defend.png

This is a binary file and will not be displayed.

static/emojis/bufo-delurk.gif

This is a binary file and will not be displayed.

static/emojis/bufo-demands-more-nom-noms.gif

This is a binary file and will not be displayed.

static/emojis/bufo-demure.png

This is a binary file and will not be displayed.

static/emojis/bufo-desperately-needs-mavis-beacon.gif

This is a binary file and will not be displayed.

static/emojis/bufo-detective.png

This is a binary file and will not be displayed.

static/emojis/bufo-develops-clairvoyance-while-trapped-in-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-devil.png

This is a binary file and will not be displayed.

static/emojis/bufo-devouring-his-son.png

This is a binary file and will not be displayed.

static/emojis/bufo-di-beppo.png

This is a binary file and will not be displayed.

static/emojis/bufo-did-not-make-it-through-the-heatwave.png

This is a binary file and will not be displayed.

static/emojis/bufo-didnt-get-any-sleep.png

This is a binary file and will not be displayed.

static/emojis/bufo-didnt-listen-to-willy-wonka.png

This is a binary file and will not be displayed.

static/emojis/bufo-disappointed.png

This is a binary file and will not be displayed.

static/emojis/bufo-disco.png

This is a binary file and will not be displayed.

static/emojis/bufo-discombobulated.png

This is a binary file and will not be displayed.

static/emojis/bufo-disguise.png

This is a binary file and will not be displayed.

static/emojis/bufo-ditto.png

This is a binary file and will not be displayed.

static/emojis/bufo-dizzy.gif

This is a binary file and will not be displayed.

static/emojis/bufo-do-not-panic.png

This is a binary file and will not be displayed.

static/emojis/bufo-dodge.png

This is a binary file and will not be displayed.

static/emojis/bufo-doesnt-believe-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-doesnt-understand-how-this-meeting-isnt-an-email.jpg

This is a binary file and will not be displayed.

static/emojis/bufo-doesnt-wanna-get-out-of-the-bath-yet.png

This is a binary file and will not be displayed.

static/emojis/bufo-dog.png

This is a binary file and will not be displayed.

static/emojis/bufo-domo.png

This is a binary file and will not be displayed.

static/emojis/bufo-done-check.gif

This is a binary file and will not be displayed.

static/emojis/bufo-dont-even-see-the-code-anymore.gif

This is a binary file and will not be displayed.

static/emojis/bufo-dont-trust-whats-over-there.png

This is a binary file and will not be displayed.

static/emojis/bufo-dont.png

This is a binary file and will not be displayed.

static/emojis/bufo-double-chin.png

This is a binary file and will not be displayed.

static/emojis/bufo-double-vaccinated.png

This is a binary file and will not be displayed.

static/emojis/bufo-doubt.png

This is a binary file and will not be displayed.

static/emojis/bufo-dough.png

This is a binary file and will not be displayed.

static/emojis/bufo-downvote.png

This is a binary file and will not be displayed.

static/emojis/bufo-dr-depper.png

This is a binary file and will not be displayed.

static/emojis/bufo-dragon.png

This is a binary file and will not be displayed.

static/emojis/bufo-drags-knee.png

This is a binary file and will not be displayed.

static/emojis/bufo-drake-no.png

This is a binary file and will not be displayed.

static/emojis/bufo-drake-yes.png

This is a binary file and will not be displayed.

static/emojis/bufo-drifts-through-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-drinking-baja-blast.png

This is a binary file and will not be displayed.

static/emojis/bufo-drinking-boba.png

This is a binary file and will not be displayed.

static/emojis/bufo-drinking-coffee.gif

This is a binary file and will not be displayed.

static/emojis/bufo-drinking-coke.png

This is a binary file and will not be displayed.

static/emojis/bufo-drinking-pepsi.png

This is a binary file and will not be displayed.

static/emojis/bufo-drinking-pumpkin-spice-latte.png

This is a binary file and will not be displayed.

static/emojis/bufo-drinks-from-the-fire-hose.png

This is a binary file and will not be displayed.

static/emojis/bufo-drops-everything-now.gif

This is a binary file and will not be displayed.

static/emojis/bufo-drowning-in-leeks.png

This is a binary file and will not be displayed.

static/emojis/bufo-drowns-in-memories-of-ocean.png

This is a binary file and will not be displayed.

static/emojis/bufo-drowns-in-tickets-but-ok.png

This is a binary file and will not be displayed.

static/emojis/bufo-drumroll.png

This is a binary file and will not be displayed.

static/emojis/bufo-easter-bunny.png

This is a binary file and will not be displayed.

static/emojis/bufo-eating-hotdog.png

This is a binary file and will not be displayed.

static/emojis/bufo-eating-lollipop.png

This is a binary file and will not be displayed.

static/emojis/bufo-eats-a-bufo-taco.png

This is a binary file and will not be displayed.

static/emojis/bufo-eats-all-your-honey.png

This is a binary file and will not be displayed.

static/emojis/bufo-eats-bufo-taco.png

This is a binary file and will not be displayed.

static/emojis/bufo-egg.png

This is a binary file and will not be displayed.

static/emojis/bufo-elite.png

This is a binary file and will not be displayed.

static/emojis/bufo-emo.png

This is a binary file and will not be displayed.

static/emojis/bufo-ends-the-holy-war-by-offering-the-objectively-best-programming-language.png

This is a binary file and will not be displayed.

static/emojis/bufo-enjoys-life-in-the-windows-xp-background.png

This is a binary file and will not be displayed.

static/emojis/bufo-enjoys-life.png

This is a binary file and will not be displayed.

static/emojis/bufo-enraged.png

This is a binary file and will not be displayed.

static/emojis/bufo-enter.gif

This is a binary file and will not be displayed.

static/emojis/bufo-enters-the-void.gif

This is a binary file and will not be displayed.

static/emojis/bufo-entrance.gif

This is a binary file and will not be displayed.

static/emojis/bufo-ethereum.png

This is a binary file and will not be displayed.

static/emojis/bufo-everything-is-on-fire.gif

This is a binary file and will not be displayed.

static/emojis/bufo-evil.png

This is a binary file and will not be displayed.

static/emojis/bufo-excited-but-sad.png

This is a binary file and will not be displayed.

static/emojis/bufo-excited.gif

This is a binary file and will not be displayed.

static/emojis/bufo-existential-dread-sets-in.gif

This is a binary file and will not be displayed.

static/emojis/bufo-exit.gif

This is a binary file and will not be displayed.

static/emojis/bufo-experiences-euneirophrenia.png

This is a binary file and will not be displayed.

static/emojis/bufo-extra-cool.gif

This is a binary file and will not be displayed.

static/emojis/bufo-eye-twitch.gif

This is a binary file and will not be displayed.

static/emojis/bufo-eyeballs-bloodshot.png

This is a binary file and will not be displayed.

static/emojis/bufo-eyeballs.png

This is a binary file and will not be displayed.

static/emojis/bufo-eyes.png

This is a binary file and will not be displayed.

static/emojis/bufo-fab.png

This is a binary file and will not be displayed.

static/emojis/bufo-facepalm.png

This is a binary file and will not be displayed.

static/emojis/bufo-failed-the-load-test.png

This is a binary file and will not be displayed.

static/emojis/bufo-fails-the-vibe-check.png

This is a binary file and will not be displayed.

static/emojis/bufo-fancy-tea.png

This is a binary file and will not be displayed.

static/emojis/bufo-farmer.png

This is a binary file and will not be displayed.

static/emojis/bufo-fastest-rubber-stamp-in-the-west.png

This is a binary file and will not be displayed.

static/emojis/bufo-fedora.png

This is a binary file and will not be displayed.

static/emojis/bufo-feel-better.png

This is a binary file and will not be displayed.

static/emojis/bufo-feeling-pretty-might-delete-later.png

This is a binary file and will not be displayed.

static/emojis/bufo-feels-appreciated.png

This is a binary file and will not be displayed.

static/emojis/bufo-feels-nothing.png

This is a binary file and will not be displayed.

static/emojis/bufo-fell-asleep.png

This is a binary file and will not be displayed.

static/emojis/bufo-fellow-kids.png

This is a binary file and will not be displayed.

static/emojis/bufo-fieri.png

This is a binary file and will not be displayed.

static/emojis/bufo-fight.png

This is a binary file and will not be displayed.

static/emojis/bufo-fine-art.png

This is a binary file and will not be displayed.

static/emojis/bufo-fingerguns-back.png

This is a binary file and will not be displayed.

static/emojis/bufo-fingerguns.png

This is a binary file and will not be displayed.

static/emojis/bufo-fire-engine.png

This is a binary file and will not be displayed.

static/emojis/bufo-fire.gif

This is a binary file and will not be displayed.

static/emojis/bufo-firefighter.png

This is a binary file and will not be displayed.

static/emojis/bufo-fish-bulb.png

This is a binary file and will not be displayed.

static/emojis/bufo-fish.png

This is a binary file and will not be displayed.

static/emojis/bufo-fistbump.gif

This is a binary file and will not be displayed.

static/emojis/bufo-flex.gif

This is a binary file and will not be displayed.

static/emojis/bufo-flipoff.png

This is a binary file and will not be displayed.

static/emojis/bufo-flips-table.png

This is a binary file and will not be displayed.

static/emojis/bufo-folder.png

This is a binary file and will not be displayed.

static/emojis/bufo-fomo.png

This is a binary file and will not be displayed.

static/emojis/bufo-food-please.png

This is a binary file and will not be displayed.

static/emojis/bufo-football.png

This is a binary file and will not be displayed.

static/emojis/bufo-for-dummies.png

This is a binary file and will not be displayed.

static/emojis/bufo-forgot-how-to-type.gif

This is a binary file and will not be displayed.

static/emojis/bufo-forgot-that-you-existed-it-isnt-love-it-isnt-hate-its-just-indifference.png

This is a binary file and will not be displayed.

static/emojis/bufo-found-some-more-leeks.png

This is a binary file and will not be displayed.

static/emojis/bufo-found-the-leeks.png

This is a binary file and will not be displayed.

static/emojis/bufo-found-yet-another-juicebox.png

This is a binary file and will not be displayed.

static/emojis/bufo-french.png

This is a binary file and will not be displayed.

static/emojis/bufo-friends.png

This is a binary file and will not be displayed.

static/emojis/bufo-frustrated-with-flower.png

This is a binary file and will not be displayed.

static/emojis/bufo-fu%C3%9Fball.png

This is a binary file and will not be displayed.

static/emojis/bufo-fun-is-over.png

This is a binary file and will not be displayed.

static/emojis/bufo-furiously-tries-to-write-python.gif

This is a binary file and will not be displayed.

static/emojis/bufo-furiously-writes-an-epic-update.gif

This is a binary file and will not be displayed.

static/emojis/bufo-furiously-writes-you-a-peer-review.gif

This is a binary file and will not be displayed.

static/emojis/bufo-futbol.gif

This is a binary file and will not be displayed.

static/emojis/bufo-futbol.png

This is a binary file and will not be displayed.

static/emojis/bufo-gamer.png

This is a binary file and will not be displayed.

static/emojis/bufo-gaming.png

This is a binary file and will not be displayed.

static/emojis/bufo-gandalf-has-seen-things.png

This is a binary file and will not be displayed.

static/emojis/bufo-gandalf-wat.png

This is a binary file and will not be displayed.

static/emojis/bufo-gandalf.gif

This is a binary file and will not be displayed.

static/emojis/bufo-gardener.png

This is a binary file and will not be displayed.

static/emojis/bufo-garlic.png

This is a binary file and will not be displayed.

static/emojis/bufo-gavel-dual-wield.png

This is a binary file and will not be displayed.

static/emojis/bufo-gavel.png

This is a binary file and will not be displayed.

static/emojis/bufo-gen-z.png

This is a binary file and will not be displayed.

static/emojis/bufo-gentleman.png

This is a binary file and will not be displayed.

static/emojis/bufo-germany.gif

This is a binary file and will not be displayed.

static/emojis/bufo-get-in-loser-were-going-shopping.png

This is a binary file and will not be displayed.

static/emojis/bufo-gets-downloaded-from-the-cloud.gif

This is a binary file and will not be displayed.

static/emojis/bufo-gets-hit-in-the-face-with-an-egg.png

This is a binary file and will not be displayed.

static/emojis/bufo-gets-uploaded-to-the-cloud.gif

This is a binary file and will not be displayed.

static/emojis/bufo-gets-whiplash.png

This is a binary file and will not be displayed.

static/emojis/bufo-ghost-costume.png

This is a binary file and will not be displayed.

static/emojis/bufo-ghost.png

This is a binary file and will not be displayed.

static/emojis/bufo-giggling-in-a-cat-onesie.gif

This is a binary file and will not be displayed.

static/emojis/bufo-give-money.png

This is a binary file and will not be displayed.

static/emojis/bufo-give-pack-of-ice.png

This is a binary file and will not be displayed.

static/emojis/bufo-give.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-a-fake-moustache.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-a-magic-number.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-an-idea.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-approval.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-can-of-worms.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-databricks.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-j.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-star.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-you-a-feature-flag.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-you-a-hotdog.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-you-some-extra-brain.png

This is a binary file and will not be displayed.

static/emojis/bufo-gives-you-some-rice.png

This is a binary file and will not be displayed.

static/emojis/bufo-glasses.png

This is a binary file and will not be displayed.

static/emojis/bufo-glitch.gif

This is a binary file and will not be displayed.

static/emojis/bufo-goal.png

This is a binary file and will not be displayed.

static/emojis/bufo-goes-super-saiyan.png

This is a binary file and will not be displayed.

static/emojis/bufo-goes-to-space.png

This is a binary file and will not be displayed.

static/emojis/bufo-goggles-are-too-tight.png

This is a binary file and will not be displayed.

static/emojis/bufo-good-morning.png

This is a binary file and will not be displayed.

static/emojis/bufo-good-vibe.gif

This is a binary file and will not be displayed.

static/emojis/bufo-goose-hat-happy-dance.gif

This is a binary file and will not be displayed.

static/emojis/bufo-got-a-tan.png

This is a binary file and will not be displayed.

static/emojis/bufo-got-zapped.png

This is a binary file and will not be displayed.

static/emojis/bufo-grapes.png

This is a binary file and will not be displayed.

static/emojis/bufo-grasping-at-straws.png

This is a binary file and will not be displayed.

static/emojis/bufo-grenade.gif

This is a binary file and will not be displayed.

static/emojis/bufo-grimaces-with-eyebrows.png

This is a binary file and will not be displayed.

static/emojis/bufo-guitar.gif

This is a binary file and will not be displayed.

static/emojis/bufo-ha-ha.png

This is a binary file and will not be displayed.

static/emojis/bufo-hacker.png

This is a binary file and will not be displayed.

static/emojis/bufo-hackerman.gif

This is a binary file and will not be displayed.

static/emojis/bufo-haha-yes-haha-yes.png

This is a binary file and will not be displayed.

static/emojis/bufo-hahabusiness.png

This is a binary file and will not be displayed.

static/emojis/bufo-halloween-pumpkin.png

This is a binary file and will not be displayed.

static/emojis/bufo-halloween.gif

This is a binary file and will not be displayed.

static/emojis/bufo-hands-on-hips-annoyed.png

This is a binary file and will not be displayed.

static/emojis/bufo-hands.png

This is a binary file and will not be displayed.

static/emojis/bufo-hangs-ten.png

This is a binary file and will not be displayed.

static/emojis/bufo-hangs-up.gif

This is a binary file and will not be displayed.

static/emojis/bufo-hannibal-lecter.png

This is a binary file and will not be displayed.

static/emojis/bufo-hanson.png

This is a binary file and will not be displayed.

static/emojis/bufo-happy-hour.gif

This is a binary file and will not be displayed.

static/emojis/bufo-happy-new-year.png

This is a binary file and will not be displayed.

static/emojis/bufo-happy.png

This is a binary file and will not be displayed.

static/emojis/bufo-hardhat.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-5-dollar-footlong.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-banana.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-bbq.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-big-wrench.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-blue-wrench.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-crush.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-dr-pepper.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-fresh-slice.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-headache.gif

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-hot-take.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-question.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-sandwich.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-spoon.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-a-timtam.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-accepted-its-horrible-fate.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-activated.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-another-sandwich.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-been-cleaning.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-gotta-poop-but-hes-stuck-in-a-long-meeting.gif

This is a binary file and will not be displayed.

static/emojis/bufo-has-infiltrated-your-secure-system.gif

This is a binary file and will not be displayed.

static/emojis/bufo-has-midas-touch.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-read-enough-documentation-for-today.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-some-ketchup.png

This is a binary file and will not be displayed.

static/emojis/bufo-has-thread-for-guts.png

This is a binary file and will not be displayed.

static/emojis/bufo-hasnt-worked-a-full-week-so-far-this-year.png

This is a binary file and will not be displayed.

static/emojis/bufo-hat.png

This is a binary file and will not be displayed.

static/emojis/bufo-hazmat.png

This is a binary file and will not be displayed.

static/emojis/bufo-headbang.gif

This is a binary file and will not be displayed.

static/emojis/bufo-headphones.png

This is a binary file and will not be displayed.

static/emojis/bufo-heart-but-its-anatomically-correct.png

This is a binary file and will not be displayed.

static/emojis/bufo-heart.png

This is a binary file and will not be displayed.

static/emojis/bufo-hearts.png

This is a binary file and will not be displayed.

static/emojis/bufo-hehe.gif

This is a binary file and will not be displayed.

static/emojis/bufo-hell.gif

This is a binary file and will not be displayed.

static/emojis/bufo-hello.gif

This is a binary file and will not be displayed.

static/emojis/bufo-heralds-an-incident.png

This is a binary file and will not be displayed.

static/emojis/bufo-heralds-taco-taking.png

This is a binary file and will not be displayed.

static/emojis/bufo-heralds-your-success.png

This is a binary file and will not be displayed.

static/emojis/bufo-here-to-make-a-dill-for-more-pickles.png

This is a binary file and will not be displayed.

static/emojis/bufo-hides.png

This is a binary file and will not be displayed.

static/emojis/bufo-high-speed-train.png

This is a binary file and will not be displayed.

static/emojis/bufo-highfive-1.png

This is a binary file and will not be displayed.

static/emojis/bufo-highfive-2.png

This is a binary file and will not be displayed.

static/emojis/bufo-hipster.png

This is a binary file and will not be displayed.

static/emojis/bufo-hmm-no.gif

This is a binary file and will not be displayed.

static/emojis/bufo-hmm-yes.gif

This is a binary file and will not be displayed.

static/emojis/bufo-hmm.png

This is a binary file and will not be displayed.

static/emojis/bufo-holding-space-for-defying-gravity.png

This is a binary file and will not be displayed.

static/emojis/bufo-holds-pumpkin.png

This is a binary file and will not be displayed.

static/emojis/bufo-homologates.png

This is a binary file and will not be displayed.

static/emojis/bufo-hop-in-we're-going-to-flavortown.png

This is a binary file and will not be displayed.

static/emojis/bufo-hopes-you-also-are-having-a-good-day.png

This is a binary file and will not be displayed.

static/emojis/bufo-hopes-you-are-having-a-good-day.png

This is a binary file and will not be displayed.

static/emojis/bufo-hot-pocket.png

This is a binary file and will not be displayed.

static/emojis/bufo-hotdog-rocket.png

This is a binary file and will not be displayed.

static/emojis/bufo-howdy.png

This is a binary file and will not be displayed.

static/emojis/bufo-hug.png

This is a binary file and will not be displayed.

static/emojis/bufo-hugs-moo-deng.png

This is a binary file and will not be displayed.

static/emojis/bufo-hype.gif

This is a binary file and will not be displayed.

static/emojis/bufo-i-just-love-it-so-much.png

This is a binary file and will not be displayed.

static/emojis/bufo-ice-cream.png

This is a binary file and will not be displayed.

static/emojis/bufo-idk-but-okay-i-guess-so.png

This is a binary file and will not be displayed.

static/emojis/bufo-idk.png

This is a binary file and will not be displayed.

static/emojis/bufo-im-in-danger.png

This is a binary file and will not be displayed.

static/emojis/bufo-imposter.png

This is a binary file and will not be displayed.

static/emojis/bufo-in-a-pear-tree.png

This is a binary file and will not be displayed.

static/emojis/bufo-in-his-cozy-bed-hoping-he-never-gets-capitated.png

This is a binary file and will not be displayed.

static/emojis/bufo-in-rome.png

This is a binary file and will not be displayed.

static/emojis/bufo-inception.png

This is a binary file and will not be displayed.

static/emojis/bufo-increases-his-dimensionality-while-trapped-in-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-innocent.gif

This is a binary file and will not be displayed.

static/emojis/bufo-inspecting.png

This is a binary file and will not be displayed.

static/emojis/bufo-inspired.png

This is a binary file and will not be displayed.

static/emojis/bufo-instigates-a-dramatic-turn-of-events.png

This is a binary file and will not be displayed.

static/emojis/bufo-intensifies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-intern.png

This is a binary file and will not be displayed.

static/emojis/bufo-investigates.png

This is a binary file and will not be displayed.

static/emojis/bufo-iphone.png

This is a binary file and will not be displayed.

static/emojis/bufo-irl.png

This is a binary file and will not be displayed.

static/emojis/bufo-iron-throne.png

This is a binary file and will not be displayed.

static/emojis/bufo-ironside.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-a-little-worried-but-still-trying-to-be-supportive.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-a-part-of-gen-z.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-about-to-zap-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-all-ears.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-angry-at-the-water-cooler-bottle-company-for-missing-yet-another-delivery.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-at-his-wits-end.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-at-the-dentist.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-better-known-for-the-things-he-does-on-the-mattress.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-exhausted-rooting-for-the-antihero.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-flying-and-is-the-plane.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-getting-abducted.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-getting-paged-now.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-glad-the-british-were-kicked-out.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-happy-youre-happy.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-having-a-really-bad-time.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-in-a-never-ending-meeting.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-in-on-the-joke.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-inhaling-this-popcorn.gif

This is a binary file and will not be displayed.

static/emojis/bufo-is-it-done.gif

This is a binary file and will not be displayed.

static/emojis/bufo-is-jealous-its-your-birthday.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-jean-baptise-emanuel-zorg.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-keeping-his-eye-on-you.gif

This is a binary file and will not be displayed.

static/emojis/bufo-is-lonely.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-lost-in-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-lost.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-omniscient.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-on-a-sled.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-panicking.gif

This is a binary file and will not be displayed.

static/emojis/bufo-is-petting-your-cat.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-petting-your-dog.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-proud-of-you.gif

This is a binary file and will not be displayed.

static/emojis/bufo-is-ready-for-xmas.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-ready-to-build-when-you-are.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-ready-to-burn-down-the-mta-because-their-train-skipped-their-station-again.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-ready-to-consume-his-daily-sodium-intake-in-one-sitting.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-ready-to-eat.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-ready-to-riot.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-romantic.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-sad-no-one-complimented-their-agent-47-cosplay.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-safe-behind-bars.gif

This is a binary file and will not be displayed.

static/emojis/bufo-is-so-happy-youre-here.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-the-perfect-human-form.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-unconcerned.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-up-to-something.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-very-upset-now.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-watching-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-working-through-the-tears.png

This is a binary file and will not be displayed.

static/emojis/bufo-is-working-too-much.png

This is a binary file and will not be displayed.

static/emojis/bufo-isitdone.gif

This is a binary file and will not be displayed.

static/emojis/bufo-isnt-angry-just-disappointed.png

This is a binary file and will not be displayed.

static/emojis/bufo-isnt-going-to-rewind-the-vhs-before-returning-it.png

This is a binary file and will not be displayed.

static/emojis/bufo-isnt-reading-all-that.png

This is a binary file and will not be displayed.

static/emojis/bufo-it-bar.png

This is a binary file and will not be displayed.

static/emojis/bufo-italian.png

This is a binary file and will not be displayed.

static/emojis/bufo-its-over-9000.png

This is a binary file and will not be displayed.

static/emojis/bufo-its-too-early-for-this.png

This is a binary file and will not be displayed.

static/emojis/bufo-jam.gif

This is a binary file and will not be displayed.

static/emojis/bufo-jammies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-jammin.gif

This is a binary file and will not be displayed.

static/emojis/bufo-jealous.png

This is a binary file and will not be displayed.

static/emojis/bufo-jedi.gif

This is a binary file and will not be displayed.

static/emojis/bufo-jomo.png

This is a binary file and will not be displayed.

static/emojis/bufo-judge.gif

This is a binary file and will not be displayed.

static/emojis/bufo-judges.png

This is a binary file and will not be displayed.

static/emojis/bufo-juice.png

This is a binary file and will not be displayed.

static/emojis/bufo-juicebox.png

This is a binary file and will not be displayed.

static/emojis/bufo-juicy.png

This is a binary file and will not be displayed.

static/emojis/bufo-just-a-little-sad.png

This is a binary file and will not be displayed.

static/emojis/bufo-just-a-little-salty.png

This is a binary file and will not be displayed.

static/emojis/bufo-just-checking.gif

This is a binary file and will not be displayed.

static/emojis/bufo-just-finished-a-workout.png

This is a binary file and will not be displayed.

static/emojis/bufo-just-got-back-from-the-dentist.png

This is a binary file and will not be displayed.

static/emojis/bufo-just-ice.png

This is a binary file and will not be displayed.

static/emojis/bufo-just-walked-into-an-awkward-conversation-and-is-now-trying-to-figure-out-how-to-leave.png

This is a binary file and will not be displayed.

static/emojis/bufo-just-wanted-you-to-know-this-is-him-trying.jpg

This is a binary file and will not be displayed.

static/emojis/bufo-justice.png

This is a binary file and will not be displayed.

static/emojis/bufo-karen.gif

This is a binary file and will not be displayed.

static/emojis/bufo-keeps-his-password-written-on-a-post-it-note-stuck-to-his-monitor.png

This is a binary file and will not be displayed.

static/emojis/bufo-keyboard.gif

This is a binary file and will not be displayed.

static/emojis/bufo-kills-you-with-kindness.png

This is a binary file and will not be displayed.

static/emojis/bufo-king.png

This is a binary file and will not be displayed.

static/emojis/bufo-kiwi.png

This is a binary file and will not be displayed.

static/emojis/bufo-knife-cries-right.png

This is a binary file and will not be displayed.

static/emojis/bufo-knife-crying-left.gif

This is a binary file and will not be displayed.

static/emojis/bufo-knife-crying-right.gif

This is a binary file and will not be displayed.

static/emojis/bufo-knife-crying.gif

This is a binary file and will not be displayed.

static/emojis/bufo-knife.png

This is a binary file and will not be displayed.

static/emojis/bufo-knows-age-is-just-a-number.png

This is a binary file and will not be displayed.

static/emojis/bufo-knows-his-customers.jpeg

This is a binary file and will not be displayed.

static/emojis/bufo-knows-his-customers.jpg

This is a binary file and will not be displayed.

static/emojis/bufo-knows-this-is-a-total-bop.gif

This is a binary file and will not be displayed.

static/emojis/bufo-knuckle-sandwich.gif

This is a binary file and will not be displayed.

static/emojis/bufo-knuckles.png

This is a binary file and will not be displayed.

static/emojis/bufo-koi.png

This is a binary file and will not be displayed.

static/emojis/bufo-kudo.png

This is a binary file and will not be displayed.

static/emojis/bufo-kuzco-has-not-learned-his-lesson-yet.png

This is a binary file and will not be displayed.

static/emojis/bufo-kuzco.png

This is a binary file and will not be displayed.

static/emojis/bufo-laser-eyes.jpeg

This is a binary file and will not be displayed.

static/emojis/bufo-laser-eyes.jpg

This is a binary file and will not be displayed.

static/emojis/bufo-late-to-the-convo.png

This is a binary file and will not be displayed.

static/emojis/bufo-laugh-xd.png

This is a binary file and will not be displayed.

static/emojis/bufo-laughing-popcorn.png

This is a binary file and will not be displayed.

static/emojis/bufo-laughs-to-mask-the-pain.png

This is a binary file and will not be displayed.

static/emojis/bufo-leads-the-way-to-better-docs.png

This is a binary file and will not be displayed.

static/emojis/bufo-leaves-you-on-seen.png

This is a binary file and will not be displayed.

static/emojis/bufo-left-a-comment.png

This is a binary file and will not be displayed.

static/emojis/bufo-left-multiple-comments.png

This is a binary file and will not be displayed.

static/emojis/bufo-lemon.png

This is a binary file and will not be displayed.

static/emojis/bufo-leprechaun.png

This is a binary file and will not be displayed.

static/emojis/bufo-let-them-eat-cake.png

This is a binary file and will not be displayed.

static/emojis/bufo-lgtm.png

This is a binary file and will not be displayed.

static/emojis/bufo-liberty-forgot-her-torch.png

This is a binary file and will not be displayed.

static/emojis/bufo-liberty.png

This is a binary file and will not be displayed.

static/emojis/bufo-librarian.png

This is a binary file and will not be displayed.

static/emojis/bufo-lick.gif

This is a binary file and will not be displayed.

static/emojis/bufo-licks-his-hway-out-of-prison.gif

This is a binary file and will not be displayed.

static/emojis/bufo-lies-awake-in-panic.png

This is a binary file and will not be displayed.

static/emojis/bufo-life-saver.png

This is a binary file and will not be displayed.

static/emojis/bufo-likes-that-idea.png

This is a binary file and will not be displayed.

static/emojis/bufo-link.png

This is a binary file and will not be displayed.

static/emojis/bufo-listens-to-his-conscience.png

This is a binary file and will not be displayed.

static/emojis/bufo-lit.gif

This is a binary file and will not be displayed.

static/emojis/bufo-littlefoot-is-upset.png

This is a binary file and will not be displayed.

static/emojis/bufo-loading.gif

This is a binary file and will not be displayed.

static/emojis/bufo-lol-cry.gif

This is a binary file and will not be displayed.

static/emojis/bufo-lol.png

This is a binary file and will not be displayed.

static/emojis/bufo-lolsob.png

This is a binary file and will not be displayed.

static/emojis/bufo-long.png

This is a binary file and will not be displayed.

static/emojis/bufo-lookin-dope.png

This is a binary file and will not be displayed.

static/emojis/bufo-looking-very-much.gif

This is a binary file and will not be displayed.

static/emojis/bufo-looks-a-little-closer.png

This is a binary file and will not be displayed.

static/emojis/bufo-looks-for-a-pull-request.png

This is a binary file and will not be displayed.

static/emojis/bufo-looks-for-an-issue.png

This is a binary file and will not be displayed.

static/emojis/bufo-looks-like-hes-listening-but-hes-not.png

This is a binary file and will not be displayed.

static/emojis/bufo-looks-out-of-the-window.png

This is a binary file and will not be displayed.

static/emojis/bufo-loves-blobs.png

This is a binary file and will not be displayed.

static/emojis/bufo-loves-disco.png

This is a binary file and will not be displayed.

static/emojis/bufo-loves-doges.gif

This is a binary file and will not be displayed.

static/emojis/bufo-loves-pho.png

This is a binary file and will not be displayed.

static/emojis/bufo-loves-rice-and-beans.png

This is a binary file and will not be displayed.

static/emojis/bufo-loves-ruby.png

This is a binary file and will not be displayed.

static/emojis/bufo-loves-this-song.png

This is a binary file and will not be displayed.

static/emojis/bufo-luigi.png

This is a binary file and will not be displayed.

static/emojis/bufo-lunch.png

This is a binary file and will not be displayed.

static/emojis/bufo-lurk-delurk.gif

This is a binary file and will not be displayed.

static/emojis/bufo-lurk.gif

This is a binary file and will not be displayed.

static/emojis/bufo-lurk.png

This is a binary file and will not be displayed.

static/emojis/bufo-macbook.png

This is a binary file and will not be displayed.

static/emojis/bufo-made-salad.png

This is a binary file and will not be displayed.

static/emojis/bufo-made-you-a-burrito.png

This is a binary file and will not be displayed.

static/emojis/bufo-magician.png

This is a binary file and will not be displayed.

static/emojis/bufo-make-it-rain.gif

This is a binary file and will not be displayed.

static/emojis/bufo-makes-it-rain.png

This is a binary file and will not be displayed.

static/emojis/bufo-makes-the-dream-work.png

This is a binary file and will not be displayed.

static/emojis/bufo-mama-mia-thatsa-one-spicy-a-meatball.png

This is a binary file and will not be displayed.

static/emojis/bufo-marine.png

This is a binary file and will not be displayed.

static/emojis/bufo-mario.png

This is a binary file and will not be displayed.

static/emojis/bufo-mask.png

This is a binary file and will not be displayed.

static/emojis/bufo-matrix.gif

This is a binary file and will not be displayed.

static/emojis/bufo-medal.png

This is a binary file and will not be displayed.

static/emojis/bufo-meltdown.png

This is a binary file and will not be displayed.

static/emojis/bufo-melting.png

This is a binary file and will not be displayed.

static/emojis/bufo-micdrop.gif

This is a binary file and will not be displayed.

static/emojis/bufo-midsommar.png

This is a binary file and will not be displayed.

static/emojis/bufo-midwest-princess.png

This is a binary file and will not be displayed.

static/emojis/bufo-mild-panic.png

This is a binary file and will not be displayed.

static/emojis/bufo-mildly-aggravated.png

This is a binary file and will not be displayed.

static/emojis/bufo-milk.jpeg

This is a binary file and will not be displayed.

static/emojis/bufo-milk.jpg

This is a binary file and will not be displayed.

static/emojis/bufo-mindblown.png

This is a binary file and will not be displayed.

static/emojis/bufo-minecraft-attack.gif

This is a binary file and will not be displayed.

static/emojis/bufo-minecraft-defend.gif

This is a binary file and will not be displayed.

static/emojis/bufo-mischievous.png

This is a binary file and will not be displayed.

static/emojis/bufo-mitosis.gif

This is a binary file and will not be displayed.

static/emojis/bufo-mittens.png

This is a binary file and will not be displayed.

static/emojis/bufo-modern-art.png

This is a binary file and will not be displayed.

static/emojis/bufo-monocle.png

This is a binary file and will not be displayed.

static/emojis/bufo-monstera.png

This is a binary file and will not be displayed.

static/emojis/bufo-morning-starbucks.png

This is a binary file and will not be displayed.

static/emojis/bufo-morning-sun.png

This is a binary file and will not be displayed.

static/emojis/bufo-morning.png

This is a binary file and will not be displayed.

static/emojis/bufo-mrtayto.png

This is a binary file and will not be displayed.

static/emojis/bufo-mushroom.png

This is a binary file and will not be displayed.

static/emojis/bufo-mustache.png

This is a binary file and will not be displayed.

static/emojis/bufo-my-pho.png

This is a binary file and will not be displayed.

static/emojis/bufo-nah.png

This is a binary file and will not be displayed.

static/emojis/bufo-naked.png

This is a binary file and will not be displayed.

static/emojis/bufo-naptime.png

This is a binary file and will not be displayed.

static/emojis/bufo-needs-some-hot-tea-to-process-this-news.png

This is a binary file and will not be displayed.

static/emojis/bufo-needs-to-vent.png

This is a binary file and will not be displayed.

static/emojis/bufo-nefarious.png

This is a binary file and will not be displayed.

static/emojis/bufo-nervous-but-cute.png

This is a binary file and will not be displayed.

static/emojis/bufo-nervous.gif

This is a binary file and will not be displayed.

static/emojis/bufo-night.png

This is a binary file and will not be displayed.

static/emojis/bufo-ninja.png

This is a binary file and will not be displayed.

static/emojis/bufo-no-capes.png

This is a binary file and will not be displayed.

static/emojis/bufo-no-more-today-thank-you.gif

This is a binary file and will not be displayed.

static/emojis/bufo-no-prob.png

This is a binary file and will not be displayed.

static/emojis/bufo-no-problem.png

This is a binary file and will not be displayed.

static/emojis/bufo-no-ragrets.png

This is a binary file and will not be displayed.

static/emojis/bufo-no-sleep.png

This is a binary file and will not be displayed.

static/emojis/bufo-no-u.png

This is a binary file and will not be displayed.

static/emojis/bufo-no.gif

This is a binary file and will not be displayed.

static/emojis/bufo-nod.gif

This is a binary file and will not be displayed.

static/emojis/bufo-noodles.gif

This is a binary file and will not be displayed.

static/emojis/bufo-nope.gif

This is a binary file and will not be displayed.

static/emojis/bufo-nosy.png

This is a binary file and will not be displayed.

static/emojis/bufo-not-bad-by-dalle.png

This is a binary file and will not be displayed.

static/emojis/bufo-not-my-problem.png

This is a binary file and will not be displayed.

static/emojis/bufo-not-respecting-your-personal-space.png

This is a binary file and will not be displayed.

static/emojis/bufo-notice-me-senpai.gif

This is a binary file and will not be displayed.

static/emojis/bufo-notification.png

This is a binary file and will not be displayed.

static/emojis/bufo-np.png

This is a binary file and will not be displayed.

static/emojis/bufo-nun.png

This is a binary file and will not be displayed.

static/emojis/bufo-nyc.png

This is a binary file and will not be displayed.

static/emojis/bufo-oatly.png

This is a binary file and will not be displayed.

static/emojis/bufo-oblivious-and-innocent.png

This is a binary file and will not be displayed.

static/emojis/bufo-of-liberty.png

This is a binary file and will not be displayed.

static/emojis/bufo-offering-bufo-offering-bufo-offering-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-1.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-13.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-2.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-200.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-21.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-3.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-5.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-8.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-bagel.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-ball-of-mud.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-banana-in-these-trying-times.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-beer.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-bicycle.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-bolillo-para-el-susto.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-book.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-brain.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-bufo-egg-in-this-trying-time.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-burger.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-cake.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-clover.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-comment.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-cookie.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-deploy-lock.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-factory.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-flan.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-flowchart-to-help-you-navigate-this-workflow.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-focaccia.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-furby.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-gavel.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-generator.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-hario-scale.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-hot-take.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-jetpack-zebra.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-kakapo.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-like.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-little-band-aid-for-a-big-problem.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-llama.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-loading-spinner-spinning.gif

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-loading-spinner.gif

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-lock.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-mac-m1-chip.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-pager.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-piece-of-cake.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-pr.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-pull-request.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-rock.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-roomba.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-ruby.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-sandbox.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-shocked-pikachu.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-speedy-recovery.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-status.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-taco.gif

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-telescope.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-tiny-wood-stove.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-torta-ahogada.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-webhook-but-the-logo-is-canonically-correct.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-webhook.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a-wednesday.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-a11y.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-ai.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-airwrap.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-airpod-pro.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-easter-egg.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-eclair.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-egg-in-this-trying-time.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-ethernet-cable.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-export-of-your-data.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-extinguisher.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-idea.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-incident.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-issue.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-an-outage.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-approval.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-avocado.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-bento.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-big-band-aid-for-a-little-problem.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-bitcoin.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-boba.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-boss-coffee.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-box.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-bufo-cubo.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-bufo-offers.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-bufomelon.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-calculated-decision-to-leave-tech-debt-for-now-and-clean-it-up-later.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-caribufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-chart-with-upwards-trend.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-chatgpt.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-chrome.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-coffee.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-copilot.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-corn.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-corporate-red-tape.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-covid.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-csharp.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-d20.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-datadog.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-discord.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-dnd.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-empty-wallet.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-f5.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-factorio.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-falafel.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-fart-cloud.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-firefox.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-flatbread.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-footsie.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-friday.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-fud.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-gatorade.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-git-mailing-list.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-golden-handcuffs.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-google-doc.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-google-drive.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-google-sheets.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-hello-kitty.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-help.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-hotdog.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-jira.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-ldap.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-lego.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-model-1857-12-pounder-napoleon-cannon.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-moneybag.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-new-jira.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-nothing.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-notion.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-oatmilk.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-openai.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-pancakes.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-peanuts.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-pineapple.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-power.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-prescription-strength-painkillers.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-python.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-securifriend.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-solar-eclipse.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-spam.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-stash-of-tea-from-the-office-for-the-weekend.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-tayto.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-terraform.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-the-cloud.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-the-power.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-the-weeknd.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-thoughts-and-prayers.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-thread.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-thundercats.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-tim-tams.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-tree.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-turkish-delights.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-ube.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-watermelon.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-comically-oversized-waffle.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-db-for-your-customer-data.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-gdpr-compliant-cookie.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-kfc-16-piece-family-size-bucket-of-fried-chicken.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-monster-early-in-the-morning.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-pint-m8.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-red-bull-early-in-the-morning.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-a-suspiciously-not-urgent-ticket.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-an-urgent-ticket.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-dangerously-high-rate-limits.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-his-crypto-before-he-pumps-and-dumps-it.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-logs.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-money-in-this-trying-time.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-the-best-emoji-culture-ever.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-the-moon.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-you-the-world.png

This is a binary file and will not be displayed.

static/emojis/bufo-offers-yubikey.png

This is a binary file and will not be displayed.

static/emojis/bufo-office.png

This is a binary file and will not be displayed.

static/emojis/bufo-oh-hai.png

This is a binary file and will not be displayed.

static/emojis/bufo-oh-no.png

This is a binary file and will not be displayed.

static/emojis/bufo-oh-yeah.png

This is a binary file and will not be displayed.

static/emojis/bufo-ok.png

This is a binary file and will not be displayed.

static/emojis/bufo-okay-pretty-salty-now.png

This is a binary file and will not be displayed.

static/emojis/bufo-old.png

This is a binary file and will not be displayed.

static/emojis/bufo-olives.png

This is a binary file and will not be displayed.

static/emojis/bufo-omg.png

This is a binary file and will not be displayed.

static/emojis/bufo-on-fire-but-still-excited.png

This is a binary file and will not be displayed.

static/emojis/bufo-on-the-ceiling.png

This is a binary file and will not be displayed.

static/emojis/bufo-oncall-secondary.gif

This is a binary file and will not be displayed.

static/emojis/bufo-onion.png

This is a binary file and will not be displayed.

static/emojis/bufo-open-mic.png

This is a binary file and will not be displayed.

static/emojis/bufo-opens-a-haberdashery.png

This is a binary file and will not be displayed.

static/emojis/bufo-orange.png

This is a binary file and will not be displayed.

static/emojis/bufo-oreilly.png

This is a binary file and will not be displayed.

static/emojis/bufo-pager-duty.png

This is a binary file and will not be displayed.

static/emojis/bufo-pajama-party.gif

This is a binary file and will not be displayed.

static/emojis/bufo-palpatine.png

This is a binary file and will not be displayed.

static/emojis/bufo-panic.png

This is a binary file and will not be displayed.

static/emojis/bufo-parrot.gif

This is a binary file and will not be displayed.

static/emojis/bufo-party-birthday.png

This is a binary file and will not be displayed.

static/emojis/bufo-party-conga-line.gif

This is a binary file and will not be displayed.

static/emojis/bufo-party.gif

This is a binary file and will not be displayed.

static/emojis/bufo-passed-the-load-test.png

This is a binary file and will not be displayed.

static/emojis/bufo-passes-the-vibe-check.png

This is a binary file and will not be displayed.

static/emojis/bufo-pat.gif

This is a binary file and will not be displayed.

static/emojis/bufo-peaks-on-you-from-above.png

This is a binary file and will not be displayed.

static/emojis/bufo-peaky-blinder.png

This is a binary file and will not be displayed.

static/emojis/bufo-pear.png

This is a binary file and will not be displayed.

static/emojis/bufo-pearly-whites.png

This is a binary file and will not be displayed.

static/emojis/bufo-peek-wall.png

This is a binary file and will not be displayed.

static/emojis/bufo-peek.png

This is a binary file and will not be displayed.

static/emojis/bufo-peeking.gif

This is a binary file and will not be displayed.

static/emojis/bufo-pensivity-turned-discomfort-upon-realization-of-reality.gif

This is a binary file and will not be displayed.

static/emojis/bufo-phew.png

This is a binary file and will not be displayed.

static/emojis/bufo-phonecall.png

This is a binary file and will not be displayed.

static/emojis/bufo-photographer.png

This is a binary file and will not be displayed.

static/emojis/bufo-picked-you-a-flower.png

This is a binary file and will not be displayed.

static/emojis/bufo-pikmin.png

This is a binary file and will not be displayed.

static/emojis/bufo-pilgrim.png

This is a binary file and will not be displayed.

static/emojis/bufo-pinch-hitter.png

This is a binary file and will not be displayed.

static/emojis/bufo-pineapple.png

This is a binary file and will not be displayed.

static/emojis/bufo-ping.png

This is a binary file and will not be displayed.

static/emojis/bufo-pirate.png

This is a binary file and will not be displayed.

static/emojis/bufo-pitchfork.png

This is a binary file and will not be displayed.

static/emojis/bufo-pitchforks.png

This is a binary file and will not be displayed.

static/emojis/bufo-pizza-hut.png

This is a binary file and will not be displayed.

static/emojis/bufo-placeholder.png

This is a binary file and will not be displayed.

static/emojis/bufo-platformizes.jpeg

This is a binary file and will not be displayed.

static/emojis/bufo-platformizes.jpg

This is a binary file and will not be displayed.

static/emojis/bufo-plays-some-smooth-jazz-intensity-1.gif

This is a binary file and will not be displayed.

static/emojis/bufo-plays-some-smooth-jazz.png

This is a binary file and will not be displayed.

static/emojis/bufo-pleading-1.png

This is a binary file and will not be displayed.

static/emojis/bufo-pleading.png

This is a binary file and will not be displayed.

static/emojis/bufo-please.png

This is a binary file and will not be displayed.

static/emojis/bufo-pog-surprise.png

This is a binary file and will not be displayed.

static/emojis/bufo-pog.png

This is a binary file and will not be displayed.

static/emojis/bufo-pointing-down-there.gif

This is a binary file and will not be displayed.

static/emojis/bufo-pointing-over-there.gif

This is a binary file and will not be displayed.

static/emojis/bufo-pointing-right-there.gif

This is a binary file and will not be displayed.

static/emojis/bufo-pointing-up-there.gif

This is a binary file and will not be displayed.

static/emojis/bufo-police.png

This is a binary file and will not be displayed.

static/emojis/bufo-poliwhirl.png

This is a binary file and will not be displayed.

static/emojis/bufo-ponders-2.png

This is a binary file and will not be displayed.

static/emojis/bufo-ponders-3.png

This is a binary file and will not be displayed.

static/emojis/bufo-ponders.png

This is a binary file and will not be displayed.

static/emojis/bufo-poo.png

This is a binary file and will not be displayed.

static/emojis/bufo-poof.gif

This is a binary file and will not be displayed.

static/emojis/bufo-popcorn.gif

This is a binary file and will not be displayed.

static/emojis/bufo-popping-out-of-the-coffee-upsidedown.gif

This is a binary file and will not be displayed.

static/emojis/bufo-popping-out-of-the-coffee.gif

This is a binary file and will not be displayed.

static/emojis/bufo-popping-out-of-the-toilet.gif

This is a binary file and will not be displayed.

static/emojis/bufo-pops-by.gif

This is a binary file and will not be displayed.

static/emojis/bufo-pops-out-for-a-quick-bite-to-eat.png

This is a binary file and will not be displayed.

static/emojis/bufo-possessed.png

This is a binary file and will not be displayed.

static/emojis/bufo-potato.png

This is a binary file and will not be displayed.

static/emojis/bufo-pours-one-out.gif

This is a binary file and will not be displayed.

static/emojis/bufo-praise.png

This is a binary file and will not be displayed.

static/emojis/bufo-pray-partying.png

This is a binary file and will not be displayed.

static/emojis/bufo-pray.png

This is a binary file and will not be displayed.

static/emojis/bufo-praying-his-qa-is-on-point.png

This is a binary file and will not be displayed.

static/emojis/bufo-prays-for-this-to-be-over-already-intensifies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-prays-for-this-to-be-over-already.png

This is a binary file and will not be displayed.

static/emojis/bufo-prays-to-azure.png

This is a binary file and will not be displayed.

static/emojis/bufo-prays-to-nvidia.png

This is a binary file and will not be displayed.

static/emojis/bufo-prays-to-pagerduty.png

This is a binary file and will not be displayed.

static/emojis/bufo-preach.png

This is a binary file and will not be displayed.

static/emojis/bufo-presents-to-the-bufos.png

This is a binary file and will not be displayed.

static/emojis/bufo-pretends-to-have-authority.png

This is a binary file and will not be displayed.

static/emojis/bufo-pretty-dang-sad.png

This is a binary file and will not be displayed.

static/emojis/bufo-pride.gif

This is a binary file and will not be displayed.

static/emojis/bufo-psychic.png

This is a binary file and will not be displayed.

static/emojis/bufo-pumpkin-head.png

This is a binary file and will not be displayed.

static/emojis/bufo-pumpkin.png

This is a binary file and will not be displayed.

static/emojis/bufo-pushes-to-prod.gif

This is a binary file and will not be displayed.

static/emojis/bufo-put-on-active-noise-cancelling-headphones-but-can-still-hear-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-quadruple-vaccinated.png

This is a binary file and will not be displayed.

static/emojis/bufo-question.png

This is a binary file and will not be displayed.

static/emojis/bufo-rad.png

This is a binary file and will not be displayed.

static/emojis/bufo-rainbow-moustache.png

This is a binary file and will not be displayed.

static/emojis/bufo-rainbow.gif

This is a binary file and will not be displayed.

static/emojis/bufo-raised-hand.png

This is a binary file and will not be displayed.

static/emojis/bufo-ramen.gif

This is a binary file and will not be displayed.

static/emojis/bufo-reading.png

This is a binary file and will not be displayed.

static/emojis/bufo-reads-and-analyzes-doc-intensifies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-reads-and-analyzes-doc.png

This is a binary file and will not be displayed.

static/emojis/bufo-red-flags.gif

This is a binary file and will not be displayed.

static/emojis/bufo-redacted.png

This is a binary file and will not be displayed.

static/emojis/bufo-regret.png

This is a binary file and will not be displayed.

static/emojis/bufo-remains-perturbed-from-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-remembers-bad-time.png

This is a binary file and will not be displayed.

static/emojis/bufo-returns-to-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-retweet.png

This is a binary file and will not be displayed.

static/emojis/bufo-reverse.png

This is a binary file and will not be displayed.

static/emojis/bufo-review.png

This is a binary file and will not be displayed.

static/emojis/bufo-revokes-his-approval.png

This is a binary file and will not be displayed.

static/emojis/bufo-rich.png

This is a binary file and will not be displayed.

static/emojis/bufo-rick.png

This is a binary file and will not be displayed.

static/emojis/bufo-rides-in-style.png

This is a binary file and will not be displayed.

static/emojis/bufo-riding-goose.gif

This is a binary file and will not be displayed.

static/emojis/bufo-riot.gif

This is a binary file and will not be displayed.

static/emojis/bufo-rip.png

This is a binary file and will not be displayed.

static/emojis/bufo-roasted.png

This is a binary file and will not be displayed.

static/emojis/bufo-robs-you.gif

This is a binary file and will not be displayed.

static/emojis/bufo-rocket.png

This is a binary file and will not be displayed.

static/emojis/bufo-rofl.png

This is a binary file and will not be displayed.

static/emojis/bufo-roll-fast.gif

This is a binary file and will not be displayed.

static/emojis/bufo-roll-safe.png

This is a binary file and will not be displayed.

static/emojis/bufo-roll-the-dice.png

This is a binary file and will not be displayed.

static/emojis/bufo-roll.gif

This is a binary file and will not be displayed.

static/emojis/bufo-rolling-out.png

This is a binary file and will not be displayed.

static/emojis/bufo-rose.png

This is a binary file and will not be displayed.

static/emojis/bufo-ross.png

This is a binary file and will not be displayed.

static/emojis/bufo-royalty-sparkle.gif

This is a binary file and will not be displayed.

static/emojis/bufo-royalty.png

This is a binary file and will not be displayed.

static/emojis/bufo-rude.png

This is a binary file and will not be displayed.

static/emojis/bufo-rudolph.gif

This is a binary file and will not be displayed.

static/emojis/bufo-run-right.gif

This is a binary file and will not be displayed.

static/emojis/bufo-run.gif

This is a binary file and will not be displayed.

static/emojis/bufo-rush.png

This is a binary file and will not be displayed.

static/emojis/bufo-sad-baguette.png

This is a binary file and will not be displayed.

static/emojis/bufo-sad-but-ok.png

This is a binary file and will not be displayed.

static/emojis/bufo-sad-rain.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sad-swinging.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sad-vibe.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sad.png

This is a binary file and will not be displayed.

static/emojis/bufo-sailor-moon.png

This is a binary file and will not be displayed.

static/emojis/bufo-salad.png

This is a binary file and will not be displayed.

static/emojis/bufo-salivating.png

This is a binary file and will not be displayed.

static/emojis/bufo-salty.png

This is a binary file and will not be displayed.

static/emojis/bufo-salute.png

This is a binary file and will not be displayed.

static/emojis/bufo-same.png

This is a binary file and will not be displayed.

static/emojis/bufo-santa.png

This is a binary file and will not be displayed.

static/emojis/bufo-saves-hyrule.png

This is a binary file and will not be displayed.

static/emojis/bufo-says-good-morning-to-test-the-waters.png

This is a binary file and will not be displayed.

static/emojis/bufo-scheduled.png

This is a binary file and will not be displayed.

static/emojis/bufo-science-intensifies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-science.png

This is a binary file and will not be displayed.

static/emojis/bufo-scientist-intensifies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-scientist.png

This is a binary file and will not be displayed.

static/emojis/bufo-screams-into-the-ambient-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-security-jacket.png

This is a binary file and will not be displayed.

static/emojis/bufo-sees-what-you-did-there.png

This is a binary file and will not be displayed.

static/emojis/bufo-segway.png

This is a binary file and will not be displayed.

static/emojis/bufo-sends-a-demand-signal.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sends-to-print.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sends-you-to-the-shadow-realm.png

This is a binary file and will not be displayed.

static/emojis/bufo-shakes-up-your-etch-a-sketch.png

This is a binary file and will not be displayed.

static/emojis/bufo-shaking-eyes.gif

This is a binary file and will not be displayed.

static/emojis/bufo-shaking-head.gif

This is a binary file and will not be displayed.

static/emojis/bufo-shame.png

This is a binary file and will not be displayed.

static/emojis/bufo-shares-his-banana.png

This is a binary file and will not be displayed.

static/emojis/bufo-sheesh.png

This is a binary file and will not be displayed.

static/emojis/bufo-shh-barking-puppy.png

This is a binary file and will not be displayed.

static/emojis/bufo-shh.png

This is a binary file and will not be displayed.

static/emojis/bufo-shifty.gif

This is a binary file and will not be displayed.

static/emojis/bufo-ship.png

This is a binary file and will not be displayed.

static/emojis/bufo-shipit.png

This is a binary file and will not be displayed.

static/emojis/bufo-shipping.gif

This is a binary file and will not be displayed.

static/emojis/bufo-shower.png

This is a binary file and will not be displayed.

static/emojis/bufo-showing-off-baby.png

This is a binary file and will not be displayed.

static/emojis/bufo-showing-off-babypilot.png

This is a binary file and will not be displayed.

static/emojis/bufo-shredding.gif

This is a binary file and will not be displayed.

static/emojis/bufo-shrek-but-canonically-correct.png

This is a binary file and will not be displayed.

static/emojis/bufo-shrek.png

This is a binary file and will not be displayed.

static/emojis/bufo-shrooms.png

This is a binary file and will not be displayed.

static/emojis/bufo-shrug.png

This is a binary file and will not be displayed.

static/emojis/bufo-shy.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sigh.png

This is a binary file and will not be displayed.

static/emojis/bufo-silly-goose-dance.gif

This is a binary file and will not be displayed.

static/emojis/bufo-silly.png

This is a binary file and will not be displayed.

static/emojis/bufo-simba.png

This is a binary file and will not be displayed.

static/emojis/bufo-single-tear.png

This is a binary file and will not be displayed.

static/emojis/bufo-sinks.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sip.png

This is a binary file and will not be displayed.

static/emojis/bufo-sipping-on-juice.png

This is a binary file and will not be displayed.

static/emojis/bufo-sips-coffee.gif

This is a binary file and will not be displayed.

static/emojis/bufo-siren.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sit.png

This is a binary file and will not be displayed.

static/emojis/bufo-sith.gif

This is a binary file and will not be displayed.

static/emojis/bufo-skeledance.gif

This is a binary file and will not be displayed.

static/emojis/bufo-skellington-1.png

This is a binary file and will not be displayed.

static/emojis/bufo-skellington.png

This is a binary file and will not be displayed.

static/emojis/bufo-skiing.png

This is a binary file and will not be displayed.

static/emojis/bufo-slay.png

This is a binary file and will not be displayed.

static/emojis/bufo-sleep.png

This is a binary file and will not be displayed.

static/emojis/bufo-slinging-bagels.png

This is a binary file and will not be displayed.

static/emojis/bufo-slowly-heads-out.gif

This is a binary file and will not be displayed.

static/emojis/bufo-slowly-lurks-in.gif

This is a binary file and will not be displayed.

static/emojis/bufo-smile.png

This is a binary file and will not be displayed.

static/emojis/bufo-smirk.png

This is a binary file and will not be displayed.

static/emojis/bufo-smol.png

This is a binary file and will not be displayed.

static/emojis/bufo-smug.png

This is a binary file and will not be displayed.

static/emojis/bufo-smugo.png

This is a binary file and will not be displayed.

static/emojis/bufo-snail.png

This is a binary file and will not be displayed.

static/emojis/bufo-snaps-a-pic.png

This is a binary file and will not be displayed.

static/emojis/bufo-snore.png

This is a binary file and will not be displayed.

static/emojis/bufo-snow.png

This is a binary file and will not be displayed.

static/emojis/bufo-sobbing.gif

This is a binary file and will not be displayed.

static/emojis/bufo-soccer.png

This is a binary file and will not be displayed.

static/emojis/bufo-softball.png

This is a binary file and will not be displayed.

static/emojis/bufo-sombrero.png

This is a binary file and will not be displayed.

static/emojis/bufo-speaking-math.png

This is a binary file and will not be displayed.

static/emojis/bufo-spider.png

This is a binary file and will not be displayed.

static/emojis/bufo-spit.png

This is a binary file and will not be displayed.

static/emojis/bufo-spooky-szn.png

This is a binary file and will not be displayed.

static/emojis/bufo-sports.png

This is a binary file and will not be displayed.

static/emojis/bufo-squad.gif

This is a binary file and will not be displayed.

static/emojis/bufo-squash.png

This is a binary file and will not be displayed.

static/emojis/bufo-sriracha.png

This is a binary file and will not be displayed.

static/emojis/bufo-stab-murder.gif

This is a binary file and will not be displayed.

static/emojis/bufo-stab-reverse.gif

This is a binary file and will not be displayed.

static/emojis/bufo-stab.gif

This is a binary file and will not be displayed.

static/emojis/bufo-stamp.png

This is a binary file and will not be displayed.

static/emojis/bufo-standing.png

This is a binary file and will not be displayed.

static/emojis/bufo-stare.png

This is a binary file and will not be displayed.

static/emojis/bufo-stargazing.png

This is a binary file and will not be displayed.

static/emojis/bufo-stars-in-a-old-timey-talkie.png

This is a binary file and will not be displayed.

static/emojis/bufo-starstruck.png

This is a binary file and will not be displayed.

static/emojis/bufo-stay-puft-marshmallow.png

This is a binary file and will not be displayed.

static/emojis/bufo-steals-your-thunder.png

This is a binary file and will not be displayed.

static/emojis/bufo-stick-reverse.gif

This is a binary file and will not be displayed.

static/emojis/bufo-stick.gif

This is a binary file and will not be displayed.

static/emojis/bufo-stole-caribufos-antler.png

This is a binary file and will not be displayed.

static/emojis/bufo-stoned.png

This is a binary file and will not be displayed.

static/emojis/bufo-stonks.png

This is a binary file and will not be displayed.

static/emojis/bufo-stonks2.png

This is a binary file and will not be displayed.

static/emojis/bufo-stop.gif

This is a binary file and will not be displayed.

static/emojis/bufo-stop.png

This is a binary file and will not be displayed.

static/emojis/bufo-stopsign.gif

This is a binary file and will not be displayed.

static/emojis/bufo-strains-his-neck.png

This is a binary file and will not be displayed.

static/emojis/bufo-strange.png

This is a binary file and will not be displayed.

static/emojis/bufo-strawberry.png

This is a binary file and will not be displayed.

static/emojis/bufo-strikes-a-deal.png

This is a binary file and will not be displayed.

static/emojis/bufo-strikes-the-match-he's-ready-for-inferno.png

This is a binary file and will not be displayed.

static/emojis/bufo-stripe.png

This is a binary file and will not be displayed.

static/emojis/bufo-stuffed.png

This is a binary file and will not be displayed.

static/emojis/bufo-style.png

This is a binary file and will not be displayed.

static/emojis/bufo-sun-bless.png

This is a binary file and will not be displayed.

static/emojis/bufo-sunny-side-up.png

This is a binary file and will not be displayed.

static/emojis/bufo-surf.png

This is a binary file and will not be displayed.

static/emojis/bufo-sus.png

This is a binary file and will not be displayed.

static/emojis/bufo-sushi.png

This is a binary file and will not be displayed.

static/emojis/bufo-sussy-eyebrows.gif

This is a binary file and will not be displayed.

static/emojis/bufo-sweat.png

This is a binary file and will not be displayed.

static/emojis/bufo-sweep.png

This is a binary file and will not be displayed.

static/emojis/bufo-sweet-dreams.png

This is a binary file and will not be displayed.

static/emojis/bufo-sweet-potato.png

This is a binary file and will not be displayed.

static/emojis/bufo-swims.png

This is a binary file and will not be displayed.

static/emojis/bufo-sword.png

This is a binary file and will not be displayed.

static/emojis/bufo-taco.png

This is a binary file and will not be displayed.

static/emojis/bufo-tada.png

This is a binary file and will not be displayed.

static/emojis/bufo-take-my-money.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-a-bath.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-bufo-give.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-five-corndogs-to-the-movies-by-himself-as-his-me-time.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-hotdog.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-slack.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-spam.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-approval.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-boba.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-bufo-taco.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-burrito.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-copilot.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-fud-away.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-golden-handcuffs.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-incident.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-nose.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-your-pizza.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-yubikey.png

This is a binary file and will not be displayed.

static/emojis/bufo-takes-zoom.png

This is a binary file and will not be displayed.

static/emojis/bufo-talks-to-brick-wall.gif

This is a binary file and will not be displayed.

static/emojis/bufo-tapioca-pearl.png

This is a binary file and will not be displayed.

static/emojis/bufo-tea.png

This is a binary file and will not be displayed.

static/emojis/bufo-teal.png

This is a binary file and will not be displayed.

static/emojis/bufo-tears-of-joy.png

This is a binary file and will not be displayed.

static/emojis/bufo-tense.png

This is a binary file and will not be displayed.

static/emojis/bufo-tequila.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-bufo-for-thanking-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-the-sr-bufo-for-their-wisdom.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-you-for-the-approval.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-you-for-the-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-you-for-the-comment.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-you-for-the-new-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-you-for-your-issue.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-you-for-your-pr.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks-you-for-your-service.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanks.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanksgiving.png

This is a binary file and will not be displayed.

static/emojis/bufo-thanos.png

This is a binary file and will not be displayed.

static/emojis/bufo-thats-a-knee-slapper.png

This is a binary file and will not be displayed.

static/emojis/bufo-the-builder.png

This is a binary file and will not be displayed.

static/emojis/bufo-the-crying-osha-compliant-builder.png

This is a binary file and will not be displayed.

static/emojis/bufo-the-osha-compliant-builder.png

This is a binary file and will not be displayed.

static/emojis/bufo-think.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinking-about-holidays.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinking.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-a11y.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-azure-front-door-intensifies.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-azure-front-door.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-azure.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-cheeky-nandos.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-chocolate.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-climbing.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-docs.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-fishsticks.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-mountains.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-omelette.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-pancakes.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-quarter.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-redis.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-rubberduck.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-steak.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-steakholder.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-teams.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-telemetry.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-terraform.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-ufo.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-about-vacation.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-he-gets-paid-too-much-to-work-here.png

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-of-shamenun.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thinks-this-is-a-total-bop.gif

This is a binary file and will not be displayed.

static/emojis/bufo-this-is-fine.png

This is a binary file and will not be displayed.

static/emojis/bufo-this.png

This is a binary file and will not be displayed.

static/emojis/bufo-this2.png

This is a binary file and will not be displayed.

static/emojis/bufo-thonk.png

This is a binary file and will not be displayed.

static/emojis/bufo-thonks-from-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-threatens-to-hit-you-with-the-chancla-and-he-means-it.png

This is a binary file and will not be displayed.

static/emojis/bufo-threatens-to-thwack-you-with-a-slipper-and-he-means-it.png

This is a binary file and will not be displayed.

static/emojis/bufo-throws-brick.gif

This is a binary file and will not be displayed.

static/emojis/bufo-thumbsup.png

This is a binary file and will not be displayed.

static/emojis/bufo-thunk.png

This is a binary file and will not be displayed.

static/emojis/bufo-thwack.gif

This is a binary file and will not be displayed.

static/emojis/bufo-timeout.png

This is a binary file and will not be displayed.

static/emojis/bufo-tin-foil-hat.gif

This is a binary file and will not be displayed.

static/emojis/bufo-tin-foil-hat2.png

This is a binary file and will not be displayed.

static/emojis/bufo-tips-hat.png

This is a binary file and will not be displayed.

static/emojis/bufo-tired-of-rooting-for-the-anti-hero.png

This is a binary file and will not be displayed.

static/emojis/bufo-tired-yes.gif

This is a binary file and will not be displayed.

static/emojis/bufo-tired.png

This is a binary file and will not be displayed.

static/emojis/bufo-toad.png

This is a binary file and will not be displayed.

static/emojis/bufo-tofu.png

This is a binary file and will not be displayed.

static/emojis/bufo-toilet-rocket.gif

This is a binary file and will not be displayed.

static/emojis/bufo-tomato.png

This is a binary file and will not be displayed.

static/emojis/bufo-tongue.gif

This is a binary file and will not be displayed.

static/emojis/bufo-too-many-pings.gif

This is a binary file and will not be displayed.

static/emojis/bufo-took-too-much.gif

This is a binary file and will not be displayed.

static/emojis/bufo-tooth.png

This is a binary file and will not be displayed.

static/emojis/bufo-tophat.png

This is a binary file and will not be displayed.

static/emojis/bufo-tortoise.png

This is a binary file and will not be displayed.

static/emojis/bufo-torus.gif

This is a binary file and will not be displayed.

static/emojis/bufo-trailhead.png

This is a binary file and will not be displayed.

static/emojis/bufo-train.png

This is a binary file and will not be displayed.

static/emojis/bufo-transfixed.png

This is a binary file and will not be displayed.

static/emojis/bufo-transmutes-reality.gif

This is a binary file and will not be displayed.

static/emojis/bufo-trash-can.png

This is a binary file and will not be displayed.

static/emojis/bufo-travels.png

This is a binary file and will not be displayed.

static/emojis/bufo-tries-some-yummy-yummy-crossplane.png

This is a binary file and will not be displayed.

static/emojis/bufo-tries-to-fight-you-but-his-arms-are-too-short-so-count-yourself-lucky.gif

This is a binary file and will not be displayed.

static/emojis/bufo-tries-to-hug-you-back-but-his-arms-are-too-short.png

This is a binary file and will not be displayed.

static/emojis/bufo-tries-to-hug-you-but-his-arms-are-too-short.png

This is a binary file and will not be displayed.

static/emojis/bufo-triple-vaccinated.png

This is a binary file and will not be displayed.

static/emojis/bufo-tripping.gif

This is a binary file and will not be displayed.

static/emojis/bufo-trying-to-relax-while-procrastinating-but-its-not-working.png

This is a binary file and will not be displayed.

static/emojis/bufo-turns-the-tables.png

This is a binary file and will not be displayed.

static/emojis/bufo-tux.png

This is a binary file and will not be displayed.

static/emojis/bufo-typing.gif

This is a binary file and will not be displayed.

static/emojis/bufo-u-dead.png

This is a binary file and will not be displayed.

static/emojis/bufo-ufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-ugh.png

This is a binary file and will not be displayed.

static/emojis/bufo-uh-okay-i-guess-so.png

This is a binary file and will not be displayed.

static/emojis/bufo-uhhh.png

This is a binary file and will not be displayed.

static/emojis/bufo-underpaid-postage-at-usps-and-now-they're-coming-after-him-for-the-money-he-owes.png

This is a binary file and will not be displayed.

static/emojis/bufo-unicorn.png

This is a binary file and will not be displayed.

static/emojis/bufo-universe.png

This is a binary file and will not be displayed.

static/emojis/bufo-unlocked-transdimensional-travel-while-in-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-uno.png

This is a binary file and will not be displayed.

static/emojis/bufo-upvote.png

This is a binary file and will not be displayed.

static/emojis/bufo-uses-100-percent-of-his-brain.png

This is a binary file and will not be displayed.

static/emojis/bufo-uwu.png

This is a binary file and will not be displayed.

static/emojis/bufo-vaccinated.png

This is a binary file and will not be displayed.

static/emojis/bufo-vaccinates-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-vampire.png

This is a binary file and will not be displayed.

static/emojis/bufo-venom.png

This is a binary file and will not be displayed.

static/emojis/bufo-ventilator.png

This is a binary file and will not be displayed.

static/emojis/bufo-very-angry.gif

This is a binary file and will not be displayed.

static/emojis/bufo-vibe-dance.gif

This is a binary file and will not be displayed.

static/emojis/bufo-vibe.gif

This is a binary file and will not be displayed.

static/emojis/bufo-vomit.png

This is a binary file and will not be displayed.

static/emojis/bufo-waddle.gif

This is a binary file and will not be displayed.

static/emojis/bufo-waiting-for-aws-to-deep-archive-our-data.png

This is a binary file and will not be displayed.

static/emojis/bufo-waiting-for-azure.png

This is a binary file and will not be displayed.

static/emojis/bufo-waits-in-queue.png

This is a binary file and will not be displayed.

static/emojis/bufo-waldo.png

This is a binary file and will not be displayed.

static/emojis/bufo-walk-away.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wallop.png

This is a binary file and will not be displayed.

static/emojis/bufo-wants-a-refund.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wants-to-have-a-calm-and-civilized-conversation-with-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-wants-to-know-your-spaghetti-policy-at-the-movies.png

This is a binary file and will not be displayed.

static/emojis/bufo-wants-to-return-his-vacuum-that-he-bought-at-costco-four-years-ago-for-a-full-refund.png

This is a binary file and will not be displayed.

static/emojis/bufo-wants-you-to-buy-his-crypto.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wards-off-the-evil-spirits.gif

This is a binary file and will not be displayed.

static/emojis/bufo-warhol.png

This is a binary file and will not be displayed.

static/emojis/bufo-was-eavesdropping-and-got-offended-by-your-convo-but-now-has-to-pretend-he-didnt-hear-you.png

This is a binary file and will not be displayed.

static/emojis/bufo-was-in-paris.png

This is a binary file and will not be displayed.

static/emojis/bufo-wat.png

This is a binary file and will not be displayed.

static/emojis/bufo-watches-from-a-distance.png

This is a binary file and will not be displayed.

static/emojis/bufo-watches-the-rain.gif

This is a binary file and will not be displayed.

static/emojis/bufo-watching-the-clock.png

This is a binary file and will not be displayed.

static/emojis/bufo-watermelon.png

This is a binary file and will not be displayed.

static/emojis/bufo-wave.gif

This is a binary file and will not be displayed.

static/emojis/bufo-waves-hello-from-the-void.png

This is a binary file and will not be displayed.

static/emojis/bufo-wears-a-paper-crown.png

This is a binary file and will not be displayed.

static/emojis/bufo-wears-the-cone-of-shame.png

This is a binary file and will not be displayed.

static/emojis/bufo-wedding.png

This is a binary file and will not be displayed.

static/emojis/bufo-welcome.png

This is a binary file and will not be displayed.

static/emojis/bufo-welp.png

This is a binary file and will not be displayed.

static/emojis/bufo-whack.gif

This is a binary file and will not be displayed.

static/emojis/bufo-what-are-you-doing-with-that.png

This is a binary file and will not be displayed.

static/emojis/bufo-what-did-you-just-say.png

This is a binary file and will not be displayed.

static/emojis/bufo-what-have-i-done.png

This is a binary file and will not be displayed.

static/emojis/bufo-what-have-you-done.png

This is a binary file and will not be displayed.

static/emojis/bufo-what-if.png

This is a binary file and will not be displayed.

static/emojis/bufo-whatever.png

This is a binary file and will not be displayed.

static/emojis/bufo-whew.png

This is a binary file and will not be displayed.

static/emojis/bufo-whisky.png

This is a binary file and will not be displayed.

static/emojis/bufo-who-me.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wholesome.png

This is a binary file and will not be displayed.

static/emojis/bufo-why-must-it-all-be-this-way.gif

This is a binary file and will not be displayed.

static/emojis/bufo-why-must-it-be-this-way.png

This is a binary file and will not be displayed.

static/emojis/bufo-wicked.png

This is a binary file and will not be displayed.

static/emojis/bufo-wide.png

This is a binary file and will not be displayed.

static/emojis/bufo-wider-01.png

This is a binary file and will not be displayed.

static/emojis/bufo-wider-02.png

This is a binary file and will not be displayed.

static/emojis/bufo-wider-03.png

This is a binary file and will not be displayed.

static/emojis/bufo-wider-04.png

This is a binary file and will not be displayed.

static/emojis/bufo-wields-mjolnir.png

This is a binary file and will not be displayed.

static/emojis/bufo-wields-the-hylian-shield.png

This is a binary file and will not be displayed.

static/emojis/bufo-will-miss-you.gif

This is a binary file and will not be displayed.

static/emojis/bufo-will-never-walk-cornelia-street-again.gif

This is a binary file and will not be displayed.

static/emojis/bufo-will-not-be-going-to-space-today.png

This is a binary file and will not be displayed.

static/emojis/bufo-wine.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wink.gif

This is a binary file and will not be displayed.

static/emojis/bufo-winter.png

This is a binary file and will not be displayed.

static/emojis/bufo-wishes-you-a-happy-valentines-day.png

This is a binary file and will not be displayed.

static/emojis/bufo-with-a-drive-by-hot-take.gif

This is a binary file and will not be displayed.

static/emojis/bufo-with-a-fresh-do.png

This is a binary file and will not be displayed.

static/emojis/bufo-with-a-pearl-earring.png

This is a binary file and will not be displayed.

static/emojis/bufo-wizard-magic-charge.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wizard.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wonders-if-deliciousness-of-this-cheese-is-worth-the-pain-his-lactose-intolerance-will-cause.png

This is a binary file and will not be displayed.

static/emojis/bufo-workin-up-a-sweat-after-eating-a-wendys-double-loaded-double-baked-baked-potato-during-summer.png

This is a binary file and will not be displayed.

static/emojis/bufo-worldstar.png

This is a binary file and will not be displayed.

static/emojis/bufo-worried.png

This is a binary file and will not be displayed.

static/emojis/bufo-worry-coffee.png

This is a binary file and will not be displayed.

static/emojis/bufo-worry.png

This is a binary file and will not be displayed.

static/emojis/bufo-would-like-a-bite-of-your-cookie.png

This is a binary file and will not be displayed.

static/emojis/bufo-writes-a-doc.png

This is a binary file and will not be displayed.

static/emojis/bufo-wtf.gif

This is a binary file and will not be displayed.

static/emojis/bufo-wut.png

This is a binary file and will not be displayed.

static/emojis/bufo-yah.png

This is a binary file and will not be displayed.

static/emojis/bufo-yay-awkward-eyes.gif

This is a binary file and will not be displayed.

static/emojis/bufo-yay-confetti.gif

This is a binary file and will not be displayed.

static/emojis/bufo-yay-judge.gif

This is a binary file and will not be displayed.

static/emojis/bufo-yay.gif

This is a binary file and will not be displayed.

static/emojis/bufo-yayy.png

This is a binary file and will not be displayed.

static/emojis/bufo-yeehaw.png

This is a binary file and will not be displayed.

static/emojis/bufo-yells-at-old-bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo-yes.png

This is a binary file and will not be displayed.

static/emojis/bufo-yismail.png

This is a binary file and will not be displayed.

static/emojis/bufo-you-sure-about-that.png

This is a binary file and will not be displayed.

static/emojis/bufo-yugioh.png

This is a binary file and will not be displayed.

static/emojis/bufo-yummy.png

This is a binary file and will not be displayed.

static/emojis/bufo-zoom-right.gif

This is a binary file and will not be displayed.

static/emojis/bufo-zoom.gif

This is a binary file and will not be displayed.

static/emojis/bufo.png

This is a binary file and will not be displayed.

static/emojis/bufo_wants_his_money.png

This is a binary file and will not be displayed.

static/emojis/bufobot.png

This is a binary file and will not be displayed.

static/emojis/bufochu.png

This is a binary file and will not be displayed.

static/emojis/bufocopter.png

This is a binary file and will not be displayed.

static/emojis/bufoda.png

This is a binary file and will not be displayed.

static/emojis/bufodile.png

This is a binary file and will not be displayed.

static/emojis/bufofoop.gif

This is a binary file and will not be displayed.

static/emojis/bufoheimer.png

This is a binary file and will not be displayed.

static/emojis/bufohub.png

This is a binary file and will not be displayed.

static/emojis/bufoling.png

This is a binary file and will not be displayed.

static/emojis/bufolo.png

This is a binary file and will not be displayed.

static/emojis/bufolta.png

This is a binary file and will not be displayed.

static/emojis/bufonana.png

This is a binary file and will not be displayed.

static/emojis/bufone.png

This is a binary file and will not be displayed.

static/emojis/bufonomical.png

This is a binary file and will not be displayed.

static/emojis/bufopilot.png

This is a binary file and will not be displayed.

static/emojis/bufopoof.gif

This is a binary file and will not be displayed.

static/emojis/buforang.png

This is a binary file and will not be displayed.

static/emojis/buforce-be-with-you.png

This is a binary file and will not be displayed.

static/emojis/buforead.png

This is a binary file and will not be displayed.

static/emojis/buforever.gif

This is a binary file and will not be displayed.

static/emojis/bufos-got-your-back.png

This is a binary file and will not be displayed.

static/emojis/bufos-in-love.png

This is a binary file and will not be displayed.

static/emojis/bufos-jumping-on-the-bed.gif

This is a binary file and will not be displayed.

static/emojis/bufos-lips-are-sealed.png

This is a binary file and will not be displayed.

static/emojis/bufovacado.png

This is a binary file and will not be displayed.

static/emojis/bufowhirl.png

This is a binary file and will not be displayed.

static/emojis/bufrogu.png

This is a binary file and will not be displayed.

static/emojis/but-wait-theres-bufo.png

This is a binary file and will not be displayed.

static/emojis/child-bufo-only-has-deku-sticks-to-save-hyrule.png

This is a binary file and will not be displayed.

static/emojis/chonky-bufo-wants-to-be-held.png

This is a binary file and will not be displayed.

static/emojis/christmas-bufo-on-a-goose.gif

This is a binary file and will not be displayed.

static/emojis/circle-of-bufo.png

This is a binary file and will not be displayed.

static/emojis/confused-math-bufo.png

This is a binary file and will not be displayed.

static/emojis/confusion.png

This is a binary file and will not be displayed.

static/emojis/constipated-bufo-is-trying-his-hardest.gif

This is a binary file and will not be displayed.

static/emojis/copper-bufo.png

This is a binary file and will not be displayed.

static/emojis/corrupted-bufo.png

This is a binary file and will not be displayed.

static/emojis/count-bufo.png

This is a binary file and will not be displayed.

static/emojis/daily-dose-of-bufo-vitamins.png

This is a binary file and will not be displayed.

static/emojis/dalmatian-bufo.png

This is a binary file and will not be displayed.

static/emojis/death-by-a-thousand-bufo-stabs.gif

This is a binary file and will not be displayed.

static/emojis/doctor-bufo.png

This is a binary file and will not be displayed.

static/emojis/dont-make-bufo-tap-the-sign.png

This is a binary file and will not be displayed.

static/emojis/double-bufo-sideeye.png

This is a binary file and will not be displayed.

static/emojis/egg-bufo.png

This is a binary file and will not be displayed.

static/emojis/eggplant-bufo.png

This is a binary file and will not be displayed.

static/emojis/et-tu-bufo.png

This is a binary file and will not be displayed.

static/emojis/everybody-loves-bufo.png

This is a binary file and will not be displayed.

static/emojis/existential-bufo.gif

This is a binary file and will not be displayed.

static/emojis/feelsgoodbufo.png

This is a binary file and will not be displayed.

static/emojis/fix-it-bufo.png

This is a binary file and will not be displayed.

static/emojis/friendly-neighborhood-bufo.png

This is a binary file and will not be displayed.

static/emojis/future-bufos.png

This is a binary file and will not be displayed.

static/emojis/get-in-lets-bufo.png

This is a binary file and will not be displayed.

static/emojis/get-out-of-bufos-swamp.png

This is a binary file and will not be displayed.

static/emojis/ghost-bufo-of-future-past-is-disappointed-in-your-lack-of-foresight.png

This is a binary file and will not be displayed.

static/emojis/gold-bufo.png

This is a binary file and will not be displayed.

static/emojis/good-news-bufo-offers-suppository.png

This is a binary file and will not be displayed.

static/emojis/google-sheet-bufo.jpeg

This is a binary file and will not be displayed.

static/emojis/great-white-bufo.png

This is a binary file and will not be displayed.

static/emojis/happy-bufo-brings-you-a-deescalation-coffee.png

This is a binary file and will not be displayed.

static/emojis/happy-bufo-brings-you-a-deescalation-tea.png

This is a binary file and will not be displayed.

static/emojis/heavy-is-the-bufo-that-wears-the-crown.png

This is a binary file and will not be displayed.

static/emojis/holiday-bufo-offers-you-a-candy-cane.png

This is a binary file and will not be displayed.

static/emojis/house-of-bufo.jpg

This is a binary file and will not be displayed.

static/emojis/i-dont-trust-bufo.png

This is a binary file and will not be displayed.

static/emojis/i-heart-bufo.png

This is a binary file and will not be displayed.

static/emojis/i-think-you-should-leave-with-bufo.gif

This is a binary file and will not be displayed.

static/emojis/if-bufo-fits-bufo-sits.png

This is a binary file and will not be displayed.

static/emojis/interdimensional-bufo-rests-atop-the-terrarium-of-existence.png

This is a binary file and will not be displayed.

static/emojis/it-takes-a-bufo-to-know-a-bufo.png

This is a binary file and will not be displayed.

static/emojis/its-been-such-a-long-day-that-bufo-doesnt-really-care-anymore.png

This is a binary file and will not be displayed.

static/emojis/just-a-bunch-of-bufos.png

This is a binary file and will not be displayed.

static/emojis/just-hear-bufo-out-for-a-sec.png

This is a binary file and will not be displayed.

static/emojis/kermit-the-bufo.png

This is a binary file and will not be displayed.

static/emojis/king-bufo.png

This is a binary file and will not be displayed.

static/emojis/kirbufo.png

This is a binary file and will not be displayed.

static/emojis/le-bufo.png

This is a binary file and will not be displayed.

static/emojis/live-laugh-bufo.png

This is a binary file and will not be displayed.

static/emojis/loch-ness-bufo.png

This is a binary file and will not be displayed.

static/emojis/looks-good-to-bufo.png

This is a binary file and will not be displayed.

static/emojis/low-fidelity-bufo-cant-believe-youve-done-this.png

This is a binary file and will not be displayed.

static/emojis/low-fidelity-bufo-concerned.png

This is a binary file and will not be displayed.

static/emojis/low-fidelity-bufo-excited.png

This is a binary file and will not be displayed.

static/emojis/low-fidelity-bufo-gets-whiplash.png

This is a binary file and will not be displayed.

static/emojis/m-bufo.png

This is a binary file and will not be displayed.

static/emojis/maam-this-is-a-bufo.png

This is a binary file and will not be displayed.

static/emojis/many-bufos.png

This is a binary file and will not be displayed.

static/emojis/maybe-a-bufo-bigfoot.png

This is a binary file and will not be displayed.

static/emojis/mega-bufo.png

This is a binary file and will not be displayed.

static/emojis/mrs-bufo.png

This is a binary file and will not be displayed.

static/emojis/my-name-is-buford-and-i-am-bufo's-father.png

This is a binary file and will not be displayed.

static/emojis/nobufo.png

This is a binary file and will not be displayed.

static/emojis/not-bufo.png

This is a binary file and will not be displayed.

static/emojis/nothing-inauthentic-bout-this-bufo-yeah-hes-the-real-thing-baby.png

This is a binary file and will not be displayed.

static/emojis/old-bufo-yells-at-cloud.jpeg

This is a binary file and will not be displayed.

static/emojis/old-bufo-yells-at-cloud.jpg

This is a binary file and will not be displayed.

static/emojis/old-bufo-yells-at-hubble.png

This is a binary file and will not be displayed.

static/emojis/old-man-yells-at-bufo.png

This is a binary file and will not be displayed.

static/emojis/old-man-yells-at-old-bufo.png

This is a binary file and will not be displayed.

static/emojis/one-of-101-bufos.png

This is a binary file and will not be displayed.

static/emojis/our-bufo-is-in-another-castle.png

This is a binary file and will not be displayed.

static/emojis/paper-bufo.png

This is a binary file and will not be displayed.

static/emojis/party-bufo.gif

This is a binary file and will not be displayed.

static/emojis/pixel-bufo.jpg

This is a binary file and will not be displayed.

static/emojis/planet-bufo.gif

This is a binary file and will not be displayed.

static/emojis/please-converse-using-only-bufo.png

This is a binary file and will not be displayed.

static/emojis/poison-dart-bufo.png

This is a binary file and will not be displayed.

static/emojis/pour-one-out-for-bufo.gif

This is a binary file and will not be displayed.

static/emojis/press-x-to-bufo.png

This is a binary file and will not be displayed.

static/emojis/princebufo.png

This is a binary file and will not be displayed.

static/emojis/proud-bufo-is-excited.gif

This is a binary file and will not be displayed.

static/emojis/radioactive-bufo.gif

This is a binary file and will not be displayed.

static/emojis/ratomilton.png

This is a binary file and will not be displayed.

static/emojis/sad-bufo.png

This is a binary file and will not be displayed.

static/emojis/safe-driver-bufo.png

This is a binary file and will not be displayed.

static/emojis/se%C3%B1or-bufo.png

This is a binary file and will not be displayed.

static/emojis/sen%CC%83or-bufo.png

This is a binary file and will not be displayed.

static/emojis/shiny-bufo.gif

This is a binary file and will not be displayed.

static/emojis/shut-up-and-take-my-bufo.png

This is a binary file and will not be displayed.

static/emojis/silver-bufo.png

This is a binary file and will not be displayed.

static/emojis/sir-bufo-esquire.png

This is a binary file and will not be displayed.

static/emojis/sir-this-is-a-bufo.png

This is a binary file and will not be displayed.

static/emojis/sleepy-bufo.png

This is a binary file and will not be displayed.

static/emojis/smol-bufo-feels-blessed.png

This is a binary file and will not be displayed.

static/emojis/smol-bufo-has-a-smol-pull-request-that-needs-reviews-and-he-promises-it-will-only-take-a-minute.png

This is a binary file and will not be displayed.

static/emojis/so-bufoful.gif

This is a binary file and will not be displayed.

static/emojis/spider-bufo.png

This is a binary file and will not be displayed.

static/emojis/spotify-wrapped-reminded-bufo-his-listening-patterns-are-a-little-unhinged.png

This is a binary file and will not be displayed.

static/emojis/super-bufo-bros.png

This is a binary file and will not be displayed.

static/emojis/super-bufo.png

This is a binary file and will not be displayed.

static/emojis/tabufo.png

This is a binary file and will not be displayed.

static/emojis/teamwork-makes-the-bufo-work.png

This is a binary file and will not be displayed.

static/emojis/ted-bufo.png

This is a binary file and will not be displayed.

static/emojis/the-bufo-nightmare-before-christmas.png

This is a binary file and will not be displayed.

static/emojis/the-bufo-we-deserve.png

This is a binary file and will not be displayed.

static/emojis/the-bufos-new-groove.png

This is a binary file and will not be displayed.

static/emojis/the-creation-of-bufo.png

This is a binary file and will not be displayed.

static/emojis/the-more-you-bufo.png

This is a binary file and will not be displayed.

static/emojis/the-pinkest-bufo-there-ever-was.png

This is a binary file and will not be displayed.

static/emojis/the_bufo_formerly_know_as_froge.png

This is a binary file and will not be displayed.

static/emojis/theres-a-bufo-for-that.png

This is a binary file and will not be displayed.

static/emojis/this-8-dollar-starbucks-drink-isnt-helping-bufo-feel-any-better.png

This is a binary file and will not be displayed.

static/emojis/this-is-bufo.png

This is a binary file and will not be displayed.

static/emojis/this-will-be-bufos-little-secret.gif

This is a binary file and will not be displayed.

static/emojis/triumphant-bufo.png

This is a binary file and will not be displayed.

static/emojis/two-bufos-beefin.png

This is a binary file and will not be displayed.

static/emojis/up-and-to-the-bufo.png

This is a binary file and will not be displayed.

static/emojis/vin-bufo.png

This is a binary file and will not be displayed.

static/emojis/vintage-bufo.png

This is a binary file and will not be displayed.

static/emojis/whatever-youre-doing-its-attracting-the-bufos.png

This is a binary file and will not be displayed.

static/emojis/when-bufo-falls-in-love.png

This is a binary file and will not be displayed.

static/emojis/whenlifegetsatbufo.png

This is a binary file and will not be displayed.

static/emojis/with-friends-like-this-bufo-doesnt-need-enemies.png

This is a binary file and will not be displayed.

static/emojis/wreck-it-bufo.png

This is a binary file and will not be displayed.

static/emojis/wrong-frog.png

This is a binary file and will not be displayed.

static/emojis/yay-bufo-1.gif

This is a binary file and will not be displayed.

static/emojis/yay-bufo-2.gif

This is a binary file and will not be displayed.

static/emojis/yay-bufo-3.gif

This is a binary file and will not be displayed.

static/emojis/yay-bufo-4.gif

This is a binary file and will not be displayed.

static/emojis/yeag.png

This is a binary file and will not be displayed.

static/emojis/you-have-awoken-the-bufo.png

This is a binary file and will not be displayed.

static/emojis/you-have-exquisite-taste-in-bufo.png

This is a binary file and will not be displayed.

static/emojis/you-left-your-typewriter-at-bufos-apartment.png

This is a binary file and will not be displayed.

-8
static/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 2 - <!-- Outer ring --> 3 - <circle cx="16" cy="16" r="14" fill="none" stroke="#4a9eff" stroke-width="2"/> 4 - <!-- Inner status dot --> 5 - <circle cx="16" cy="16" r="8" fill="#4a9eff"/> 6 - <!-- Small highlight to give it depth --> 7 - <circle cx="18" cy="14" r="3" fill="#6bb2ff" opacity="0.7"/> 8 - </svg>
-57
static/markdown.js
··· 1 - // Lightweight markdown link renderer for status text 2 - // Converts [text](url) into <a href> with basic sanitization 3 - (function () { 4 - const MD_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; 5 - 6 - function escapeHtml(str) { 7 - return String(str) 8 - .replace(/&/g, "&amp;") 9 - .replace(/</g, "&lt;") 10 - .replace(/>/g, "&gt;") 11 - .replace(/"/g, "&quot;") 12 - .replace(/'/g, "&#39;"); 13 - } 14 - 15 - function normalizeUrl(url) { 16 - let u = url.trim(); 17 - // If no scheme and looks like a domain, prefix with https:// 18 - if (!/^([a-z]+:)?\/\//i.test(u)) { 19 - u = 'https://' + u; 20 - } 21 - try { 22 - const parsed = new URL(u); 23 - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { 24 - return parsed.toString(); 25 - } 26 - return null; // disallow other protocols 27 - } catch (_) { 28 - return null; 29 - } 30 - } 31 - 32 - function linkifyMarkdown(text) { 33 - return text.replace(MD_LINK_RE, (_m, label, url) => { 34 - const safeUrl = normalizeUrl(url); 35 - const safeLabel = escapeHtml(label); 36 - if (!safeUrl) return `[${safeLabel}](${escapeHtml(url)})`; 37 - return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow">${safeLabel}</a>`; 38 - }); 39 - } 40 - 41 - function renderMarkdownLinksIn(root) { 42 - const scope = root || document; 43 - const nodes = scope.querySelectorAll('.status-text:not([data-md-rendered]), .history-text:not([data-md-rendered])'); 44 - nodes.forEach((el) => { 45 - const original = el.textContent || ''; 46 - const rendered = linkifyMarkdown(original); 47 - if (rendered !== original) { 48 - el.innerHTML = rendered; 49 - } 50 - el.setAttribute('data-md-rendered', 'true'); 51 - }); 52 - } 53 - 54 - // Expose globally 55 - window.renderMarkdownLinksIn = renderMarkdownLinksIn; 56 - })(); 57 -
-39
static/settings.js
··· 1 - // Shared font map configuration 2 - const FONT_MAP = { 3 - 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 4 - 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 5 - 'serif': 'ui-serif, Georgia, Cambria, serif', 6 - 'comic': '"Comic Sans MS", "Comic Sans", cursive' 7 - }; 8 - 9 - // Check if user is authenticated by looking for auth-specific data 10 - function isAuthenticated() { 11 - // Check for data attribute that indicates authentication status 12 - return document.body.dataset.authenticated === 'true'; 13 - } 14 - 15 - // Helper to save preferences to API 16 - async function savePreferencesToAPI(updates) { 17 - if (!isAuthenticated()) return; 18 - 19 - try { 20 - await fetch('/api/preferences', { 21 - method: 'POST', 22 - headers: { 'Content-Type': 'application/json' }, 23 - body: JSON.stringify(updates) 24 - }); 25 - } catch (err) { 26 - console.log('Failed to save preferences to server'); 27 - } 28 - } 29 - 30 - // Apply font to the document 31 - function applyFont(fontKey) { 32 - const fontFamily = FONT_MAP[fontKey] || FONT_MAP.mono; 33 - document.documentElement.style.setProperty('--font-family', fontFamily); 34 - } 35 - 36 - // Apply accent color to the document 37 - function applyAccentColor(color) { 38 - document.documentElement.style.setProperty('--accent', color); 39 - }
-249
static/timestamps.js
··· 1 - // Beautiful timestamp formatting with hover tooltips 2 - // Provides minute-resolution display by default with full timestamp on hover 3 - 4 - const TimestampFormatter = { 5 - // Format a timestamp with appropriate granularity 6 - formatRelative(date, now = new Date()) { 7 - const diffMs = now - date; 8 - const diffSecs = Math.floor(diffMs / 1000); 9 - const diffMins = Math.floor(diffMs / 60000); 10 - const diffHours = Math.floor(diffMs / 3600000); 11 - const diffDays = Math.floor(diffMs / 86400000); 12 - 13 - // For very recent times, show "just now" 14 - if (diffSecs < 30) { 15 - return 'just now'; 16 - } 17 - 18 - // Under 1 hour: show minutes 19 - if (diffMins < 60) { 20 - return `${diffMins}m ago`; 21 - } 22 - 23 - // Under 24 hours: show hours and minutes 24 - if (diffHours < 24) { 25 - const remainingMins = diffMins % 60; 26 - if (remainingMins === 0) { 27 - return `${diffHours}h ago`; 28 - } 29 - return `${diffHours}h ${remainingMins}m ago`; 30 - } 31 - 32 - // Under 7 days: show days and hours 33 - if (diffDays < 7) { 34 - const remainingHours = diffHours % 24; 35 - if (remainingHours === 0) { 36 - return `${diffDays}d ago`; 37 - } 38 - return `${diffDays}d ${remainingHours}h ago`; 39 - } 40 - 41 - // Over a week: show date with time 42 - const timeStr = date.toLocaleTimeString('en-US', { 43 - hour: 'numeric', 44 - minute: '2-digit', 45 - hour12: true 46 - }).toLowerCase(); 47 - 48 - // If same year, don't show year 49 - if (date.getFullYear() === now.getFullYear()) { 50 - return date.toLocaleDateString('en-US', { 51 - month: 'short', 52 - day: 'numeric' 53 - }) + ', ' + timeStr; 54 - } 55 - 56 - // Different year: show full date 57 - return date.toLocaleDateString('en-US', { 58 - month: 'short', 59 - day: 'numeric', 60 - year: 'numeric' 61 - }) + ', ' + timeStr; 62 - }, 63 - 64 - // Format future timestamps (for expiry times) 65 - formatFuture(date, now = new Date()) { 66 - const diffMs = date - now; 67 - const diffSecs = Math.floor(diffMs / 1000); 68 - const diffMins = Math.floor(diffMs / 60000); 69 - const diffHours = Math.floor(diffMs / 3600000); 70 - const diffDays = Math.floor(diffMs / 86400000); 71 - 72 - if (diffSecs < 60) { 73 - return 'expires soon'; 74 - } 75 - 76 - if (diffMins < 60) { 77 - return `expires in ${diffMins}m`; 78 - } 79 - 80 - if (diffHours < 24) { 81 - const remainingMins = diffMins % 60; 82 - if (remainingMins === 0) { 83 - return `expires in ${diffHours}h`; 84 - } 85 - return `expires in ${diffHours}h ${remainingMins}m`; 86 - } 87 - 88 - if (diffDays < 7) { 89 - const remainingHours = diffHours % 24; 90 - if (remainingHours === 0) { 91 - return `expires in ${diffDays}d`; 92 - } 93 - return `expires in ${diffDays}d ${remainingHours}h`; 94 - } 95 - 96 - // Over a week: show date 97 - return 'expires ' + date.toLocaleDateString('en-US', { 98 - month: 'short', 99 - day: 'numeric', 100 - hour: 'numeric', 101 - minute: '2-digit', 102 - hour12: true 103 - }).toLowerCase(); 104 - }, 105 - 106 - // Format for history view (compact but informative) 107 - formatCompact(date, now = new Date()) { 108 - const diffMs = now - date; 109 - const diffMins = Math.floor(diffMs / 60000); 110 - const diffHours = Math.floor(diffMs / 3600000); 111 - const diffDays = Math.floor(diffMs / 86400000); 112 - 113 - // Today: show time only 114 - if (date.toDateString() === now.toDateString()) { 115 - return date.toLocaleTimeString('en-US', { 116 - hour: 'numeric', 117 - minute: '2-digit', 118 - hour12: true 119 - }).toLowerCase(); 120 - } 121 - 122 - // Yesterday: show "yesterday" + time 123 - const yesterday = new Date(now); 124 - yesterday.setDate(yesterday.getDate() - 1); 125 - if (date.toDateString() === yesterday.toDateString()) { 126 - return 'yesterday, ' + date.toLocaleTimeString('en-US', { 127 - hour: 'numeric', 128 - minute: '2-digit', 129 - hour12: true 130 - }).toLowerCase(); 131 - } 132 - 133 - // Within 7 days: show day of week + time 134 - if (diffDays < 7) { 135 - const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }).toLowerCase(); 136 - const time = date.toLocaleTimeString('en-US', { 137 - hour: 'numeric', 138 - minute: '2-digit', 139 - hour12: true 140 - }).toLowerCase(); 141 - return `${dayName}, ${time}`; 142 - } 143 - 144 - // Same year: show month, day, time 145 - if (date.getFullYear() === now.getFullYear()) { 146 - return date.toLocaleDateString('en-US', { 147 - month: 'short', 148 - day: 'numeric', 149 - hour: 'numeric', 150 - minute: '2-digit', 151 - hour12: true 152 - }).toLowerCase(); 153 - } 154 - 155 - // Different year: show full date 156 - return date.toLocaleDateString('en-US', { 157 - month: 'short', 158 - day: 'numeric', 159 - year: 'numeric', 160 - hour: 'numeric', 161 - minute: '2-digit', 162 - hour12: true 163 - }).toLowerCase(); 164 - }, 165 - 166 - // Get full timestamp for tooltip 167 - getFullTimestamp(date) { 168 - // Get day of week 169 - const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }); 170 - 171 - // Get month and day 172 - const monthDay = date.toLocaleDateString('en-US', { 173 - month: 'long', 174 - day: 'numeric', 175 - year: 'numeric' 176 - }); 177 - 178 - // Get time with seconds 179 - const time = date.toLocaleTimeString('en-US', { 180 - hour: 'numeric', 181 - minute: '2-digit', 182 - second: '2-digit', 183 - hour12: true 184 - }); 185 - 186 - // Get timezone 187 - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 188 - const tzAbbr = date.toLocaleTimeString('en-US', { 189 - timeZoneName: 'short' 190 - }).split(' ').pop(); 191 - 192 - return `${dayName}, ${monthDay} at ${time} ${tzAbbr}`; 193 - }, 194 - 195 - // Initialize all timestamps on the page 196 - initialize() { 197 - const updateTimestamps = () => { 198 - const now = new Date(); 199 - 200 - document.querySelectorAll('.local-time').forEach(el => { 201 - const timestamp = el.getAttribute('data-timestamp'); 202 - if (!timestamp) return; 203 - 204 - const date = new Date(timestamp); 205 - const format = el.getAttribute('data-format'); 206 - const prefix = el.getAttribute('data-prefix'); 207 - 208 - let text = ''; 209 - 210 - // Determine format type 211 - if (prefix === 'expires' || prefix === 'clears') { 212 - text = this.formatFuture(date, now); 213 - } else if (format === 'compact' || format === 'short') { 214 - text = this.formatCompact(date, now); 215 - } else if (prefix === 'since') { 216 - const relativeText = this.formatRelative(date, now); 217 - text = `since ${relativeText}`.replace('since just now', 'just started'); 218 - } else { 219 - text = this.formatRelative(date, now); 220 - } 221 - 222 - // Update text content 223 - el.textContent = text; 224 - 225 - // Add tooltip with full timestamp 226 - const fullTimestamp = this.getFullTimestamp(date); 227 - el.setAttribute('title', fullTimestamp); 228 - el.style.cursor = 'help'; 229 - el.style.display = 'inline-block'; 230 - el.style.lineHeight = '1.2'; 231 - el.style.alignSelf = 'flex-start'; 232 - el.style.width = 'auto'; 233 - }); 234 - }; 235 - 236 - // Initial update 237 - updateTimestamps(); 238 - 239 - // Update every 30 seconds for better granularity 240 - setInterval(updateTimestamps, 30000); 241 - } 242 - }; 243 - 244 - // Auto-initialize when DOM is ready 245 - if (document.readyState === 'loading') { 246 - document.addEventListener('DOMContentLoaded', () => TimestampFormatter.initialize()); 247 - } else { 248 - TimestampFormatter.initialize(); 249 - }
-12
static/webhook_guide.css
··· 1 - .wh-tabs { display: flex; gap: 8px; margin: 10px 0; } 2 - .wh-dynamic .wh-tabs { justify-content: flex-end; } 3 - .wh-tabs button { border: 1px solid var(--border-color, #2a2a2a); background: var(--bg-secondary, #0f0f0f); color: var(--text, #fff); padding: 6px 10px; border-radius: 8px; cursor: pointer; font-size: 12px; } 4 - .wh-tabs button.active { background: var(--accent, #1DA1F2); color: #000; border-color: var(--accent, #1DA1F2); } 5 - .wh-snippet { display: none; } 6 - .wh-snippet.active { display: block; } 7 - .wh-static h4 { margin: 0 0 6px 0; } 8 - .wh-static ul { margin: 0 0 8px 18px; padding: 0; } 9 - .wh-static pre { background: #0b0b0b; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; } 10 - @media (max-width: 900px) { 11 - .wh-dynamic .wh-tabs { justify-content: flex-start; } 12 - }
-13
static/webhook_guide.js
··· 1 - document.addEventListener('DOMContentLoaded', () => { 2 - const tabs = document.querySelectorAll('#wh-lang-tabs [data-lang]'); 3 - const blocks = document.querySelectorAll('.wh-snippet[data-lang]'); 4 - if (!tabs.length || !blocks.length) return; 5 - const activate = (lang) => { 6 - tabs.forEach(t => t.classList.toggle('active', t.dataset.lang === lang)); 7 - blocks.forEach(b => b.classList.toggle('active', b.dataset.lang === lang)); 8 - }; 9 - tabs.forEach(btn => btn.addEventListener('click', () => activate(btn.dataset.lang))); 10 - // default 11 - activate('node'); 12 - }); 13 -
-258
templates/base.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>{% block title %}status.zzstoatzz.io{% endblock %}</title> 7 - 8 - <!-- Favicon --> 9 - <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 10 - <link rel="alternate icon" href="/static/favicon.png"> 11 - 12 - <!-- Open Graph / Facebook --> 13 - <meta property="og:type" content="website"> 14 - <meta property="og:url" content="https://status.zzstoatzz.io{% block og_url %}{% endblock %}"> 15 - <meta property="og:title" content="{% block og_title %}status{% endblock %}"> 16 - <meta property="og:description" content="{% block og_description %}like slack status, but decoupled from any platform{% endblock %}"> 17 - <meta property="og:image" content="{% block og_image %}https://status.zzstoatzz.io/og-image.png{% endblock %}"> 18 - 19 - <!-- Twitter --> 20 - <meta property="twitter:card" content="summary"> 21 - <meta property="twitter:url" content="https://status.zzstoatzz.io{% block twitter_url %}{% endblock %}"> 22 - <meta property="twitter:title" content="{% block twitter_title %}status{% endblock %}"> 23 - <meta property="twitter:description" content="{% block twitter_description %}like slack status, but decoupled from any platform{% endblock %}"> 24 - <meta property="twitter:image" content="{% block twitter_image %}https://status.zzstoatzz.io/og-image.png{% endblock %}"> 25 - 26 - <!-- Shared Timestamp Formatter --> 27 - <script src="/static/timestamps.js"></script> 28 - 29 - <!-- Shared Settings Module --> 30 - <script src="/static/settings.js"></script> 31 - <!-- Markdown link renderer for statuses --> 32 - <script src="/static/markdown.js"></script> 33 - 34 - <!-- Emoji Resolver for correct file extensions --> 35 - <script src="/static/emoji-resolver.js"></script> 36 - 37 - <!-- Apply User Settings --> 38 - <script> 39 - // Apply saved settings immediately to prevent flash 40 - (function() { 41 - const savedFont = localStorage.getItem('fontFamily') || 'mono'; 42 - const savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 43 - 44 - // Use shared FONT_MAP from settings.js (will be available after load) 45 - // For immediate application, we still need local fontMap to prevent flash 46 - const fontMap = { 47 - 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 48 - 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 49 - 'serif': 'ui-serif, Georgia, Cambria, serif', 50 - 'comic': '"Comic Sans MS", "Comic Sans", cursive' 51 - }; 52 - 53 - document.documentElement.style.setProperty('--font-family', fontMap[savedFont] || fontMap.mono); 54 - document.documentElement.style.setProperty('--accent', savedAccent); 55 - })(); 56 - </script> 57 - 58 - <style> 59 - /* Bug Report Button Styles */ 60 - .bug-report-button { 61 - position: fixed; 62 - bottom: 20px; 63 - right: 20px; 64 - width: 48px; 65 - height: 48px; 66 - background: var(--accent, #1DA1F2); 67 - border: none; 68 - border-radius: 50%; 69 - cursor: pointer; 70 - display: flex; 71 - align-items: center; 72 - justify-content: center; 73 - box-shadow: 0 2px 8px rgba(0,0,0,0.2); 74 - transition: all 0.3s ease; 75 - z-index: 9999; 76 - } 77 - 78 - .bug-report-button:hover { 79 - transform: scale(1.1); 80 - box-shadow: 0 4px 12px rgba(0,0,0,0.3); 81 - } 82 - 83 - .bug-report-button svg { 84 - width: 24px; 85 - height: 24px; 86 - fill: white; 87 - } 88 - 89 - .bug-report-tooltip { 90 - position: absolute; 91 - bottom: 60px; 92 - right: 0; 93 - background: var(--bg-secondary, #1a1a1a); 94 - color: var(--text-primary, #ffffff); 95 - border: 1px solid var(--border-color, #2a2a2a); 96 - border-radius: 8px; 97 - padding: 8px 12px; 98 - font-size: 14px; 99 - white-space: nowrap; 100 - opacity: 0; 101 - pointer-events: none; 102 - transition: opacity 0.3s ease; 103 - } 104 - 105 - .bug-report-button:hover .bug-report-tooltip { 106 - opacity: 1; 107 - } 108 - 109 - @media (max-width: 640px) { 110 - .bug-report-button { 111 - width: 40px; 112 - height: 40px; 113 - bottom: 15px; 114 - right: 15px; 115 - } 116 - 117 - .bug-report-button svg { 118 - width: 20px; 119 - height: 20px; 120 - } 121 - } 122 - /* GitHub Footer Styles */ 123 - .github-footer { 124 - position: fixed; 125 - bottom: 20px; 126 - left: 20px; 127 - display: flex; 128 - align-items: center; 129 - gap: 8px; 130 - font-size: 14px; 131 - color: var(--text-tertiary, #6c757d); 132 - text-decoration: none; 133 - transition: all 0.3s ease; 134 - z-index: 9998; 135 - } 136 - 137 - .github-footer:hover { 138 - color: var(--accent, #1DA1F2); 139 - } 140 - 141 - .github-footer svg { 142 - width: 20px; 143 - height: 20px; 144 - fill: currentColor; 145 - transition: transform 0.3s ease; 146 - } 147 - 148 - .github-footer:hover svg { 149 - transform: scale(1.1); 150 - } 151 - 152 - @media (max-width: 640px) { 153 - .github-footer { 154 - bottom: 15px; 155 - left: 15px; 156 - font-size: 12px; 157 - } 158 - 159 - .github-footer svg { 160 - width: 18px; 161 - height: 18px; 162 - } 163 - 164 - .github-footer span { 165 - display: none; 166 - } 167 - } 168 - </style> 169 - </head> 170 - <body> 171 - {% block content %}{% endblock %} 172 - 173 - <!-- GitHub Footer --> 174 - <a href="https://github.com/zzstoatzz/status" class="github-footer" target="_blank" rel="noopener noreferrer" aria-label="View source on GitHub"> 175 - <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 176 - <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/> 177 - </svg> 178 - <span>zzstoatzz/status</span> 179 - </a> 180 - 181 - <!-- Bug Report Button --> 182 - <button class="bug-report-button" id="bug-report-button" aria-label="Report a bug"> 183 - <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 184 - <path d="M20 8h-2.81a5.985 5.985 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/> 185 - </svg> 186 - <span class="bug-report-tooltip">report a bug</span> 187 - </button> 188 - 189 - <script> 190 - // Bug Report Button Handler 191 - document.addEventListener('DOMContentLoaded', function() { 192 - const bugButton = document.getElementById('bug-report-button'); 193 - if (bugButton) { 194 - bugButton.addEventListener('click', function() { 195 - // Gather context information 196 - const context = { 197 - page: window.location.pathname, 198 - url: window.location.href, 199 - userAgent: navigator.userAgent, 200 - screenResolution: `${window.screen.width}x${window.screen.height}`, 201 - viewportSize: `${window.innerWidth}x${window.innerHeight}`, 202 - theme: document.documentElement.getAttribute('data-theme') || 'light', 203 - timestamp: new Date().toISOString() 204 - }; 205 - 206 - // Build issue title and body 207 - const title = '[Bug Report] Issue on ' + context.page; 208 - const body = `## Bug Description 209 - <!-- Please describe the bug you encountered --> 210 - 211 - 212 - ## Steps to Reproduce 213 - <!-- Please list the steps to reproduce the bug --> 214 - 1. 215 - 2. 216 - 3. 217 - 218 - ## Expected Behavior 219 - <!-- What did you expect to happen? --> 220 - 221 - 222 - ## Actual Behavior 223 - <!-- What actually happened? --> 224 - 225 - 226 - ## Context Information 227 - - **Page**: ${context.page} 228 - - **URL**: ${context.url} 229 - - **Timestamp**: ${context.timestamp} 230 - - **Theme**: ${context.theme} 231 - - **Viewport**: ${context.viewportSize} 232 - - **Screen**: ${context.screenResolution} 233 - - **User Agent**: ${context.userAgent} 234 - 235 - ## Additional Information 236 - <!-- Any additional information, screenshots, etc. --> 237 - `; 238 - 239 - // Create GitHub issue URL 240 - const githubRepo = 'https://github.com/zzstoatzz/status'; 241 - const issueUrl = `${githubRepo}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`; 242 - 243 - // Open in new tab 244 - window.open(issueUrl, '_blank'); 245 - }); 246 - } 247 - }); 248 - </script> 249 - <script> 250 - // Render markdown links in any status/history text on load 251 - document.addEventListener('DOMContentLoaded', function () { 252 - if (window.renderMarkdownLinksIn) { 253 - window.renderMarkdownLinksIn(document); 254 - } 255 - }); 256 - </script> 257 - </body> 258 - </html>
-306
templates/error.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div id="root"> 5 - <div class="container"> 6 - <!-- Header --> 7 - <header class="header"> 8 - <h1>status.zzstoatzz.io</h1> 9 - <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"> 10 - <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 11 - <circle cx="12" cy="12" r="5"></circle> 12 - <line x1="12" y1="1" x2="12" y2="3"></line> 13 - <line x1="12" y1="21" x2="12" y2="23"></line> 14 - <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> 15 - <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> 16 - <line x1="1" y1="12" x2="3" y2="12"></line> 17 - <line x1="21" y1="12" x2="23" y2="12"></line> 18 - <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> 19 - <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> 20 - </svg> 21 - <svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 22 - <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> 23 - </svg> 24 - </button> 25 - </header> 26 - 27 - <!-- Error Card --> 28 - <div class="error-card"> 29 - <div class="error-header"> 30 - <span class="error-emoji">⚠️</span> 31 - <h2>something went wrong</h2> 32 - </div> 33 - 34 - <div class="error-content"> 35 - <p class="error-message">{{error}}</p> 36 - </div> 37 - 38 - <div class="error-actions"> 39 - <a href="/" class="button button-primary">go home</a> 40 - <a href="/feed" class="button button-secondary">view feed</a> 41 - </div> 42 - </div> 43 - </div> 44 - </div> 45 - 46 - <style> 47 - :root { 48 - --bg-primary: #ffffff; 49 - --bg-secondary: #f8f9fa; 50 - --bg-tertiary: #ffffff; 51 - --text-primary: #1a1a1a; 52 - --text-secondary: #6c757d; 53 - --text-tertiary: #adb5bd; 54 - --border-color: #e9ecef; 55 - --accent: #4a9eff; 56 - --accent-hover: color-mix(in srgb, var(--accent) 85%, black); 57 - --danger: #dc3545; 58 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); 59 - --shadow-md: 0 4px 6px rgba(0,0,0,0.07); 60 - --radius: 12px; 61 - --radius-sm: 8px; 62 - } 63 - 64 - [data-theme="dark"] { 65 - --bg-primary: #0a0a0a; 66 - --bg-secondary: #1a1a1a; 67 - --bg-tertiary: #2a2a2a; 68 - --text-primary: #ffffff; 69 - --text-secondary: #adb5bd; 70 - --text-tertiary: #6c757d; 71 - --border-color: #2a2a2a; 72 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); 73 - --shadow-md: 0 4px 6px rgba(0,0,0,0.3); 74 - } 75 - 76 - * { 77 - margin: 0; 78 - padding: 0; 79 - box-sizing: border-box; 80 - } 81 - 82 - /* Force all elements to use monospace font */ 83 - input, button, select, textarea { 84 - font-family: inherit; 85 - } 86 - 87 - body { 88 - font-family: var(--font-family); 89 - background: var(--bg-primary); 90 - color: var(--text-primary); 91 - line-height: 1.6; 92 - transition: background 0.3s, color 0.3s; 93 - } 94 - 95 - #root { 96 - min-height: 100vh; 97 - display: flex; 98 - align-items: center; 99 - justify-content: center; 100 - padding: 2rem 1rem; 101 - } 102 - 103 - .container { 104 - width: 100%; 105 - max-width: 500px; 106 - } 107 - 108 - /* Header */ 109 - .header { 110 - display: flex; 111 - justify-content: space-between; 112 - align-items: center; 113 - margin-bottom: 2rem; 114 - } 115 - 116 - .header h1 { 117 - font-size: 1.25rem; 118 - font-weight: 600; 119 - color: var(--text-secondary); 120 - } 121 - 122 - .theme-toggle { 123 - background: var(--bg-secondary); 124 - border: 1px solid var(--border-color); 125 - border-radius: var(--radius-sm); 126 - padding: 0.5rem; 127 - cursor: pointer; 128 - display: flex; 129 - align-items: center; 130 - justify-content: center; 131 - transition: all 0.2s; 132 - } 133 - 134 - .theme-toggle:hover { 135 - background: var(--bg-tertiary); 136 - border-color: var(--accent); 137 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1); 138 - } 139 - 140 - .theme-toggle svg { 141 - stroke: var(--text-secondary); 142 - transition: stroke 0.2s; 143 - } 144 - 145 - .theme-toggle:hover svg { 146 - stroke: var(--accent); 147 - } 148 - 149 - .sun-icon, .moon-icon { 150 - display: none; 151 - } 152 - 153 - [data-theme="light"] .sun-icon { 154 - display: block; 155 - } 156 - 157 - [data-theme="dark"] .moon-icon { 158 - display: block; 159 - } 160 - 161 - /* Error Card */ 162 - .error-card { 163 - background: var(--bg-secondary); 164 - border: 1px solid var(--border-color); 165 - border-radius: var(--radius); 166 - padding: 2rem; 167 - box-shadow: var(--shadow-sm); 168 - } 169 - 170 - .error-header { 171 - text-align: center; 172 - margin-bottom: 2rem; 173 - } 174 - 175 - .error-emoji { 176 - font-size: 3rem; 177 - display: block; 178 - margin-bottom: 1rem; 179 - } 180 - 181 - .error-header h2 { 182 - font-size: 1.25rem; 183 - font-weight: 600; 184 - color: var(--text-primary); 185 - } 186 - 187 - /* Error Content */ 188 - .error-content { 189 - background: rgba(220, 53, 69, 0.05); 190 - border: 1px solid rgba(220, 53, 69, 0.2); 191 - border-radius: var(--radius-sm); 192 - padding: 1rem; 193 - margin-bottom: 2rem; 194 - } 195 - 196 - .error-message { 197 - color: var(--danger); 198 - font-size: 0.875rem; 199 - line-height: 1.5; 200 - word-break: break-word; 201 - } 202 - 203 - /* Error Actions */ 204 - .error-actions { 205 - display: flex; 206 - gap: 0.75rem; 207 - justify-content: center; 208 - } 209 - 210 - /* Buttons */ 211 - .button { 212 - display: inline-block; 213 - padding: 0.75rem 1.5rem; 214 - border-radius: var(--radius-sm); 215 - font-size: 0.875rem; 216 - font-weight: 500; 217 - text-decoration: none; 218 - cursor: pointer; 219 - border: none; 220 - transition: all 0.2s; 221 - font-family: inherit; 222 - text-align: center; 223 - } 224 - 225 - .button-primary { 226 - background: var(--accent); 227 - color: white; 228 - } 229 - 230 - .button-primary:hover { 231 - background: var(--accent-hover); 232 - } 233 - 234 - .button-secondary { 235 - background: transparent; 236 - color: var(--text-secondary); 237 - border: 1px solid var(--border-color); 238 - } 239 - 240 - .button-secondary:hover { 241 - background: var(--bg-tertiary); 242 - border-color: var(--text-secondary); 243 - } 244 - 245 - /* Mobile adjustments */ 246 - @media (max-width: 640px) { 247 - #root { 248 - padding: 1rem; 249 - } 250 - 251 - .error-card { 252 - padding: 1.5rem; 253 - } 254 - 255 - .error-actions { 256 - flex-direction: column; 257 - } 258 - 259 - .button { 260 - width: 100%; 261 - } 262 - } 263 - </style> 264 - 265 - <script> 266 - // Theme management 267 - const initTheme = () => { 268 - const saved = localStorage.getItem('theme'); 269 - const theme = saved || 'system'; 270 - 271 - if (theme === 'system') { 272 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 273 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 274 - } else { 275 - document.body.setAttribute('data-theme', theme); 276 - } 277 - }; 278 - 279 - const toggleTheme = () => { 280 - const saved = localStorage.getItem('theme') || 'system'; 281 - const themes = ['system', 'light', 'dark']; 282 - const currentIndex = themes.indexOf(saved); 283 - const next = themes[(currentIndex + 1) % themes.length]; 284 - 285 - localStorage.setItem('theme', next); 286 - 287 - if (next === 'system') { 288 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 289 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 290 - } else { 291 - document.body.setAttribute('data-theme', next); 292 - } 293 - }; 294 - 295 - // Initialize on page load 296 - document.addEventListener('DOMContentLoaded', () => { 297 - initTheme(); 298 - 299 - // Theme toggle 300 - const themeToggle = document.getElementById('theme-toggle'); 301 - if (themeToggle) { 302 - themeToggle.addEventListener('click', toggleTheme); 303 - } 304 - }); 305 - </script> 306 - {%endblock content%}
-1351
templates/feed.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div id="root"> 5 - <div class="container"> 6 - <!-- Header --> 7 - <header class="header"> 8 - <div class="feed-title-wrapper"> 9 - <h1 id="feed-title">global feed</h1> 10 - {% if let Some(p) = &profile %} 11 - <label class="feed-toggle" for="feed-toggle-input"> 12 - <input type="checkbox" id="feed-toggle-input" /> 13 - <span class="toggle-slider"></span> 14 - </label> 15 - {% endif %} 16 - </div> 17 - <div class="header-actions"> 18 - <a href="/" class="nav-button" aria-label="Your status" title="Your status"> 19 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 20 - <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path> 21 - <polyline points="9 22 9 12 15 12 15 22"></polyline> 22 - </svg> 23 - </a> 24 - {% if let Some(p) = &profile %} 25 - <button class="settings-toggle" id="settings-toggle" aria-label="Settings"> 26 - <img src="https://api.iconify.design/lucide:settings.svg" width="20" height="20" alt="Settings" class="settings-icon"> 27 - </button> 28 - {% endif %} 29 - <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"> 30 - <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 31 - <circle cx="12" cy="12" r="5"></circle> 32 - <line x1="12" y1="1" x2="12" y2="3"></line> 33 - <line x1="12" y1="21" x2="12" y2="23"></line> 34 - <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> 35 - <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> 36 - <line x1="1" y1="12" x2="3" y2="12"></line> 37 - <line x1="21" y1="12" x2="23" y2="12"></line> 38 - <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> 39 - <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> 40 - </svg> 41 - <svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 42 - <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> 43 - </svg> 44 - <span class="theme-indicator" id="theme-indicator"></span> 45 - </button> 46 - </div> 47 - </header> 48 - 49 - {% if is_admin %} 50 - <!-- Admin Upload (fixed top-left) --> 51 - <div class="admin-panel" id="admin-panel"> 52 - <button class="admin-toggle" id="admin-toggle" title="admin tools" aria-label="admin tools">⚙️</button> 53 - <div class="admin-content" id="admin-content" style="display:none;"> 54 - <div class="admin-section"> 55 - <div class="admin-title">upload emoji</div> 56 - <form id="emoji-upload-form"> 57 - <input type="text" id="emoji-name" placeholder="name (optional)" maxlength="40" /> 58 - <input type="file" id="emoji-file" accept="image/png,image/gif" required /> 59 - <button type="submit">upload</button> 60 - </form> 61 - <div class="admin-msg" id="admin-msg" aria-live="polite"></div> 62 - </div> 63 - </div> 64 - </div> 65 - {% endif %} 66 - 67 - <!-- Simple Settings (logged in users only) --> 68 - {% if let Some(p) = &profile %} 69 - <div class="simple-settings hidden" id="simple-settings"> 70 - <div class="settings-row"> 71 - <label>font</label> 72 - <div class="button-group"> 73 - <button class="font-btn active" data-font="system">system</button> 74 - <button class="font-btn" data-font="mono">mono</button> 75 - <button class="font-btn" data-font="serif">serif</button> 76 - <button class="font-btn" data-font="comic">comic</button> 77 - </div> 78 - </div> 79 - <div class="settings-row"> 80 - <label>accent</label> 81 - <input type="color" id="accent-color" value="#1DA1F2"> 82 - <div class="preset-colors"> 83 - <button class="color-preset" data-color="#1DA1F2" style="background: #1DA1F2"></button> 84 - <button class="color-preset" data-color="#FF6B6B" style="background: #FF6B6B"></button> 85 - <button class="color-preset" data-color="#4ECDC4" style="background: #4ECDC4"></button> 86 - <button class="color-preset" data-color="#FFEAA7" style="background: #FFEAA7"></button> 87 - <button class="color-preset" data-color="#A29BFE" style="background: #A29BFE"></button> 88 - <button class="color-preset" data-color="#FD79A8" style="background: #FD79A8"></button> 89 - </div> 90 - </div> 91 - </div> 92 - {% endif %} 93 - 94 - <!-- Session Info --> 95 - {% if let Some(p) = &profile %} 96 - <div class="session-card"> 97 - <div class="session-info"> 98 - <span>logged in as <strong>{% if let Some(name) = &p.display_name %}{% if !name.is_empty() %}{{ name }}{% else %}{% if let Some(h) = &p.handle %}{{ h }}{% else %}{{ p.did }}{% endif %}{% endif %}{% else %}{% if let Some(h) = &p.handle %}{{ h }}{% else %}{{ p.did }}{% endif %}{% endif %}</strong></span> 99 - <div class="session-actions"> 100 - <a href="/" class="button button-primary">your status</a> 101 - <form action="/logout" method="get" style="display: inline;"> 102 - <button type="submit" class="button button-secondary">log out</button> 103 - </form> 104 - </div> 105 - </div> 106 - </div> 107 - {% else %} 108 - <div class="session-card"> 109 - <div class="session-info"> 110 - <span>viewing public feed</span> 111 - <a href="/login" class="button button-primary">log in to set status</a> 112 - </div> 113 - </div> 114 - {% endif %} 115 - 116 - <!-- Feed --> 117 - <div class="feed-container"> 118 - <div class="feed-header-with-indicator"> 119 - <h2>recent updates</h2> 120 - {% if dev_mode %} 121 - <div class="dev-indicator" title="dev mode: showing dummy data mixed with real posts">dev</div> 122 - {% endif %} 123 - </div> 124 - 125 - <div class="status-list"> 126 - {% if !statuses.is_empty() %} 127 - {% for status in statuses %} 128 - <div class="status-item" data-did="{{ status.author_did }}"> 129 - <span class="status-emoji"> 130 - {% if status.status.starts_with("custom:") %} 131 - {% let emoji_name = status.status.strip_prefix("custom:").unwrap() %} 132 - <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" 133 - alt="{{emoji_name}}" title="{{emoji_name}}" class="custom-emoji-display emoji-placeholder" 134 - data-emoji-name="{{emoji_name}}"> 135 - {% else %} 136 - <span title="{{status.status}}">{{status.status}}</span> 137 - {% endif %} 138 - </span> 139 - <div class="status-content"> 140 - <div class="status-main"> 141 - <a class="status-author" href="/@{{status.author_display_name()}}">@{{status.author_display_name()}}</a> 142 - {% if status.text.is_some() %} 143 - <span class="status-text">{{ status.text.as_ref().unwrap() }}</span> 144 - {% endif %} 145 - </div> 146 - <div class="status-meta"> 147 - <span class="local-time" data-timestamp="{{ status.started_at.to_rfc3339() }}" data-format="relative"></span> 148 - {% if status.expires_at.is_some() %} 149 - {% if !status.is_expired() %} 150 - • <span class="local-time" data-timestamp="{{ status.expires_at.as_ref().unwrap().to_rfc3339() }}" data-prefix="expires"></span> 151 - {% else %} 152 - • expired 153 - {% endif %} 154 - {% endif %} 155 - {% if is_admin %} 156 - • <button class="hide-button" data-uri="{{ status.uri }}" style="color: var(--text-tertiary); background: none; border: none; cursor: pointer; text-decoration: underline;">hide</button> 157 - {% endif %} 158 - </div> 159 - </div> 160 - </div> 161 - {% endfor %} 162 - {% else %} 163 - <!-- empty at render; JS will populate via /api/feed --> 164 - {% endif %} 165 - </div> 166 - 167 - <!-- Loading indicator --> 168 - <div id="loading-indicator" style="display: none; text-align: center; padding: 2rem;"> 169 - <span style="color: var(--text-tertiary);">Loading more...</span> 170 - </div> 171 - 172 - <!-- End of feed indicator --> 173 - <div id="end-of-feed" style="display: none; text-align: center; padding: 2rem;"> 174 - <span style="color: var(--text-tertiary);">you've reached the beginning of time ✨</span> 175 - </div> 176 - </div> 177 - 178 - <!-- Bottom Navigation --> 179 - <nav class="bottom-nav"> 180 - <a href="/" class="nav-button-bottom"> 181 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 182 - <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path> 183 - <polyline points="9 22 9 12 15 12 15 22"></polyline> 184 - </svg> 185 - <span>your status</span> 186 - </a> 187 - </nav> 188 - </div> 189 - </div> 190 - 191 - <style> 192 - :root { 193 - --bg-primary: #ffffff; 194 - --bg-secondary: #f8f9fa; 195 - --bg-tertiary: #ffffff; 196 - --text-primary: #1a1a1a; 197 - --text-secondary: #6c757d; 198 - --text-tertiary: #adb5bd; 199 - --border-color: #e9ecef; 200 - --accent: #4a9eff; 201 - --accent-hover: color-mix(in srgb, var(--accent) 85%, black); 202 - --danger: #dc3545; 203 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); 204 - --shadow-md: 0 4px 6px rgba(0,0,0,0.07); 205 - --radius: 12px; 206 - --radius-sm: 8px; 207 - } 208 - 209 - [data-theme="dark"] { 210 - --bg-primary: #0a0a0a; 211 - --bg-secondary: #1a1a1a; 212 - --bg-tertiary: #2a2a2a; 213 - --text-primary: #ffffff; 214 - --text-secondary: #adb5bd; 215 - --text-tertiary: #6c757d; 216 - --border-color: #2a2a2a; 217 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); 218 - --shadow-md: 0 4px 6px rgba(0,0,0,0.3); 219 - } 220 - 221 - * { 222 - margin: 0; 223 - padding: 0; 224 - box-sizing: border-box; 225 - } 226 - 227 - /* Force all elements to use monospace font */ 228 - input, button, select, textarea { 229 - font-family: inherit; 230 - } 231 - 232 - body { 233 - font-family: var(--font-family); 234 - background: var(--bg-primary); 235 - color: var(--text-primary); 236 - line-height: 1.6; 237 - transition: background 0.3s, color 0.3s; 238 - } 239 - 240 - #root { 241 - min-height: 100vh; 242 - display: flex; 243 - align-items: center; 244 - justify-content: center; 245 - padding: 2rem 1rem; 246 - } 247 - 248 - .container { 249 - width: 100%; 250 - max-width: 700px; 251 - } 252 - 253 - /* Header */ 254 - .header { 255 - display: flex; 256 - justify-content: space-between; 257 - align-items: center; 258 - margin-bottom: 2rem; 259 - } 260 - 261 - .header h1 { 262 - font-size: 1.5rem; 263 - font-weight: 600; 264 - color: var(--text-secondary); 265 - } 266 - 267 - .header-actions { 268 - display: flex; 269 - gap: 0.75rem; 270 - align-items: center; 271 - } 272 - 273 - .nav-button { 274 - display: flex; 275 - align-items: center; 276 - justify-content: center; 277 - background: var(--bg-secondary); 278 - border: 1px solid var(--border-color); 279 - border-radius: var(--radius-sm); 280 - padding: 0.5rem; 281 - color: var(--text-secondary); 282 - transition: all 0.2s; 283 - text-decoration: none; 284 - } 285 - 286 - .nav-button:hover { 287 - background: var(--bg-tertiary); 288 - border-color: var(--accent); 289 - color: var(--accent); 290 - } 291 - 292 - .nav-button svg { 293 - stroke: var(--accent); 294 - } 295 - 296 - /* Simple Settings */ 297 - .simple-settings { 298 - margin: 1rem 0; 299 - padding: 1rem; 300 - background: var(--bg-secondary); 301 - border-radius: var(--radius); 302 - display: flex; 303 - flex-direction: column; 304 - gap: 1rem; 305 - transition: all 0.3s ease; 306 - transform-origin: top; 307 - } 308 - 309 - .simple-settings.hidden { 310 - display: none; 311 - } 312 - 313 - .settings-row { 314 - display: flex; 315 - align-items: center; 316 - gap: 1rem; 317 - } 318 - 319 - .settings-row label { 320 - min-width: 60px; 321 - color: var(--text-secondary); 322 - font-size: 0.9rem; 323 - } 324 - 325 - .button-group { 326 - display: flex; 327 - gap: 0.25rem; 328 - } 329 - 330 - .font-btn { 331 - padding: 0.25rem 0.75rem; 332 - background: transparent; 333 - border: 1px solid var(--border-color); 334 - border-radius: var(--radius-sm); 335 - color: var(--text-secondary); 336 - cursor: pointer; 337 - transition: all 0.2s; 338 - font-size: 0.85rem; 339 - } 340 - 341 - .font-btn:hover { 342 - border-color: var(--accent); 343 - color: var(--text-primary); 344 - } 345 - 346 - .font-btn.active { 347 - background: var(--accent); 348 - border-color: var(--accent); 349 - color: white; 350 - } 351 - 352 - #accent-color { 353 - width: 50px; 354 - height: 32px; 355 - border: 1px solid var(--border-color); 356 - border-radius: var(--radius-sm); 357 - cursor: pointer; 358 - } 359 - 360 - .preset-colors { 361 - display: flex; 362 - gap: 0.25rem; 363 - } 364 - 365 - .color-preset { 366 - width: 24px; 367 - height: 24px; 368 - border: 2px solid transparent; 369 - border-radius: var(--radius-sm); 370 - cursor: pointer; 371 - transition: all 0.2s; 372 - } 373 - 374 - .color-preset:hover { 375 - transform: scale(1.2); 376 - border-color: var(--text-primary); 377 - } 378 - 379 - /* Settings toggle button */ 380 - .settings-toggle { 381 - background: var(--bg-secondary); 382 - border: 1px solid var(--border-color); 383 - border-radius: var(--radius-sm); 384 - padding: 0.5rem; 385 - cursor: pointer; 386 - display: flex; 387 - align-items: center; 388 - justify-content: center; 389 - transition: all 0.2s; 390 - } 391 - 392 - .settings-toggle:hover { 393 - background: var(--bg-tertiary); 394 - border-color: var(--accent); 395 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1); 396 - } 397 - 398 - .settings-icon { 399 - filter: invert(50%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) contrast(100%); 400 - transition: filter 0.2s; 401 - } 402 - 403 - .settings-toggle:hover .settings-icon { 404 - filter: invert(50%) sepia(100%) saturate(500%) hue-rotate(190deg) brightness(100%) contrast(100%); 405 - } 406 - 407 - .theme-toggle { 408 - position: relative; 409 - background: var(--bg-secondary); 410 - border: 1px solid var(--border-color); 411 - border-radius: var(--radius-sm); 412 - padding: 0.5rem; 413 - cursor: pointer; 414 - display: flex; 415 - align-items: center; 416 - justify-content: center; 417 - transition: all 0.2s; 418 - } 419 - 420 - .theme-toggle:hover { 421 - background: var(--bg-tertiary); 422 - border-color: var(--accent); 423 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1); 424 - } 425 - 426 - .theme-toggle svg { 427 - stroke: var(--accent); 428 - } 429 - 430 - .sun-icon, .moon-icon { 431 - display: none; 432 - } 433 - 434 - [data-theme="light"] .sun-icon { 435 - display: block; 436 - stroke: #f39c12; 437 - } 438 - 439 - [data-theme="dark"] .moon-icon { 440 - display: block; 441 - stroke: #8e44ad; 442 - } 443 - 444 - .theme-indicator { 445 - position: absolute; 446 - top: calc(100% + 0.5rem); 447 - right: 0; 448 - background: var(--bg-secondary); 449 - border: 1px solid var(--border-color); 450 - border-radius: var(--radius-sm); 451 - padding: 0.25rem 0.5rem; 452 - font-size: 0.75rem; 453 - color: var(--text-secondary); 454 - white-space: nowrap; 455 - opacity: 0; 456 - pointer-events: none; 457 - transition: opacity 0.2s; 458 - z-index: 1000; 459 - } 460 - 461 - .theme-indicator.visible { 462 - opacity: 1; 463 - } 464 - 465 - /* Session Card */ 466 - .session-card { 467 - background: var(--bg-secondary); 468 - border: 1px solid var(--border-color); 469 - border-radius: var(--radius); 470 - padding: 1rem; 471 - margin-bottom: 1.5rem; 472 - } 473 - 474 - .session-info { 475 - display: flex; 476 - justify-content: space-between; 477 - align-items: center; 478 - flex-wrap: wrap; 479 - gap: 1rem; 480 - } 481 - 482 - .session-info strong { 483 - color: var(--text-primary); 484 - } 485 - 486 - .session-actions { 487 - display: flex; 488 - gap: 0.5rem; 489 - } 490 - 491 - /* Buttons */ 492 - .button { 493 - display: inline-block; 494 - padding: 0.5rem 1rem; 495 - border-radius: var(--radius-sm); 496 - font-size: 0.875rem; 497 - font-weight: 500; 498 - text-decoration: none; 499 - cursor: pointer; 500 - border: none; 501 - transition: all 0.2s; 502 - font-family: inherit; 503 - } 504 - 505 - .button-primary { 506 - background: var(--accent); 507 - color: white; 508 - } 509 - 510 - .button-primary:hover { 511 - background: var(--accent-hover); 512 - } 513 - 514 - .button-secondary { 515 - background: transparent; 516 - color: var(--text-secondary); 517 - border: 1px solid var(--border-color); 518 - } 519 - 520 - .button-secondary:hover { 521 - background: var(--bg-tertiary); 522 - border-color: var(--text-secondary); 523 - } 524 - 525 - /* Feed Container */ 526 - .feed-container { 527 - margin: 2rem 0; 528 - } 529 - 530 - .feed-title-wrapper { 531 - display: flex; 532 - align-items: center; 533 - gap: 0.75rem; 534 - } 535 - 536 - #feed-title { 537 - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 538 - } 539 - 540 - .feed-toggle { 541 - position: relative; 542 - display: inline-block; 543 - width: 40px; 544 - height: 20px; 545 - cursor: pointer; 546 - } 547 - 548 - .feed-toggle input { 549 - opacity: 0; 550 - width: 0; 551 - height: 0; 552 - } 553 - 554 - .toggle-slider { 555 - position: absolute; 556 - inset: 0; 557 - background: var(--border-color); 558 - border-radius: 20px; 559 - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 560 - } 561 - 562 - .toggle-slider::before { 563 - position: absolute; 564 - content: ""; 565 - height: 16px; 566 - width: 16px; 567 - left: 2px; 568 - top: 2px; 569 - background: white; 570 - border-radius: 50%; 571 - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 572 - } 573 - 574 - .feed-toggle input:checked + .toggle-slider { 575 - background: var(--accent); 576 - } 577 - 578 - .feed-toggle input:checked + .toggle-slider::before { 579 - transform: translateX(20px); 580 - } 581 - 582 - .feed-header-with-indicator { 583 - display: flex; 584 - justify-content: space-between; 585 - align-items: center; 586 - margin-bottom: 1.5rem; 587 - } 588 - 589 - .feed-container h2 { 590 - font-size: 1rem; 591 - font-weight: 500; 592 - color: var(--text-secondary); 593 - text-transform: uppercase; 594 - letter-spacing: 0.05em; 595 - margin: 0; 596 - } 597 - 598 - .dev-indicator { 599 - font-size: 0.75rem; 600 - color: var(--text-tertiary); 601 - background: var(--bg-secondary); 602 - border: 1px solid var(--border-color); 603 - border-radius: var(--radius-sm); 604 - padding: 0.25rem 0.5rem; 605 - opacity: 0.7; 606 - } 607 - 608 - /* Status List */ 609 - .status-list { 610 - display: flex; 611 - flex-direction: column; 612 - gap: 1rem; 613 - } 614 - 615 - .status-item { 616 - display: flex; 617 - gap: 1rem; 618 - padding: 1rem; 619 - background: var(--bg-secondary); 620 - border: 1px solid var(--border-color); 621 - border-radius: var(--radius); 622 - transition: all 0.2s; 623 - } 624 - 625 - .status-item:hover { 626 - border-color: var(--accent); 627 - box-shadow: var(--shadow-sm); 628 - } 629 - 630 - .status-emoji { 631 - font-size: 2rem; 632 - line-height: 1; 633 - } 634 - 635 - .custom-emoji-display { 636 - width: 2rem; 637 - height: 2rem; 638 - object-fit: contain; 639 - vertical-align: middle; 640 - /* Ensure GIFs animate */ 641 - image-rendering: auto; 642 - } 643 - 644 - .status-content { 645 - flex: 1; 646 - display: flex; 647 - flex-direction: column; 648 - gap: 0.25rem; 649 - min-width: 0; 650 - } 651 - 652 - .status-main { 653 - display: flex; 654 - flex-wrap: wrap; 655 - gap: 0.5rem; 656 - align-items: baseline; 657 - } 658 - 659 - .status-author { 660 - color: var(--text-secondary); 661 - font-weight: 600; 662 - text-decoration: none; 663 - transition: color 0.2s; 664 - } 665 - 666 - .status-author:link, 667 - .status-author:visited { 668 - color: var(--text-secondary); 669 - } 670 - 671 - .status-author:hover, 672 - .status-author:active { 673 - color: var(--accent); 674 - } 675 - 676 - .status-text { 677 - color: var(--text-primary); 678 - } 679 - 680 - .status-text a { 681 - color: var(--accent); 682 - text-decoration: underline; 683 - text-underline-offset: 2px; 684 - } 685 - 686 - .status-meta { 687 - font-size: 0.875rem; 688 - color: var(--text-tertiary); 689 - } 690 - 691 - /* Empty State */ 692 - .empty-state { 693 - text-align: center; 694 - padding: 3rem; 695 - background: var(--bg-secondary); 696 - border: 1px solid var(--border-color); 697 - border-radius: var(--radius); 698 - } 699 - 700 - .empty-emoji { 701 - font-size: 3rem; 702 - display: block; 703 - margin-bottom: 1rem; 704 - } 705 - 706 - .empty-state p { 707 - color: var(--text-tertiary); 708 - } 709 - 710 - /* Bottom Navigation */ 711 - .bottom-nav { 712 - display: flex; 713 - justify-content: center; 714 - gap: 1rem; 715 - padding-top: 2rem; 716 - margin-top: 2rem; 717 - border-top: 1px solid var(--border-color); 718 - } 719 - 720 - .nav-button-bottom { 721 - display: flex; 722 - align-items: center; 723 - gap: 0.5rem; 724 - padding: 0.75rem 1.25rem; 725 - background: var(--bg-secondary); 726 - border: 1px solid var(--border-color); 727 - border-radius: var(--radius-sm); 728 - color: var(--text-secondary); 729 - text-decoration: none; 730 - transition: all 0.2s; 731 - font-size: 0.875rem; 732 - } 733 - 734 - .nav-button-bottom:hover { 735 - background: var(--bg-tertiary); 736 - border-color: var(--accent); 737 - color: var(--accent); 738 - } 739 - 740 - .nav-button-bottom svg { 741 - stroke: currentColor; 742 - flex-shrink: 0; 743 - } 744 - 745 - /* Mobile adjustments */ 746 - @media (max-width: 640px) { 747 - #root { 748 - padding: 1rem; 749 - } 750 - 751 - .header h1 { 752 - font-size: 1.25rem; 753 - } 754 - 755 - .status-item { 756 - padding: 0.75rem; 757 - } 758 - 759 - .status-emoji { 760 - font-size: 1.5rem; 761 - } 762 - } 763 - 764 - /* Admin panel (top-left) */ 765 - .admin-panel { 766 - position: fixed; 767 - top: 12px; 768 - left: 12px; 769 - z-index: 9999; 770 - } 771 - .admin-toggle { 772 - background: var(--bg-tertiary); 773 - border: 1px solid var(--border-color); 774 - border-radius: 10px; 775 - padding: 6px 8px; 776 - cursor: pointer; 777 - color: var(--text-secondary); 778 - } 779 - .admin-content { 780 - margin-top: 8px; 781 - background: var(--bg-tertiary); 782 - border: 1px solid var(--border-color); 783 - border-radius: 12px; 784 - padding: 10px; 785 - width: 240px; 786 - box-shadow: var(--shadow-md); 787 - } 788 - .admin-title { 789 - font-size: 12px; 790 - color: var(--text-secondary); 791 - margin-bottom: 6px; 792 - } 793 - .admin-content input[type="text"], 794 - .admin-content input[type="file"] { 795 - width: 100%; 796 - margin-bottom: 8px; 797 - } 798 - .admin-content button[type="submit"] { 799 - width: 100%; 800 - background: var(--accent); 801 - color: #fff; 802 - border: none; 803 - border-radius: 8px; 804 - padding: 6px 8px; 805 - cursor: pointer; 806 - } 807 - .admin-msg { font-size: 12px; color: var(--text-secondary); margin-top: 6px; } 808 - </style> 809 - 810 - <script> 811 - // Theme management 812 - const initTheme = () => { 813 - const saved = localStorage.getItem('theme'); 814 - const theme = saved || 'system'; 815 - 816 - if (theme === 'system') { 817 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 818 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 819 - } else { 820 - document.body.setAttribute('data-theme', theme); 821 - } 822 - }; 823 - 824 - const toggleTheme = () => { 825 - const saved = localStorage.getItem('theme') || 'system'; 826 - const themes = ['system', 'light', 'dark']; 827 - const currentIndex = themes.indexOf(saved); 828 - const next = themes[(currentIndex + 1) % themes.length]; 829 - 830 - localStorage.setItem('theme', next); 831 - 832 - if (next === 'system') { 833 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 834 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 835 - } else { 836 - document.body.setAttribute('data-theme', next); 837 - } 838 - 839 - // Show theme indicator 840 - const indicator = document.getElementById('theme-indicator'); 841 - if (indicator) { 842 - indicator.textContent = next; 843 - indicator.classList.add('visible'); 844 - setTimeout(() => { 845 - indicator.classList.remove('visible'); 846 - }, 1500); 847 - } 848 - }; 849 - 850 - // Simple settings 851 - const initSettings = async () => { 852 - // Try to load from API first, fall back to localStorage 853 - let savedFont = localStorage.getItem('fontFamily') || 'mono'; 854 - let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 855 - 856 - // If user is logged in, fetch from API 857 - const isLoggedIn = document.querySelector('.settings-toggle'); 858 - if (isLoggedIn) { 859 - try { 860 - const response = await fetch('/api/preferences'); 861 - if (response.ok) { 862 - const data = await response.json(); 863 - if (!data.error) { 864 - savedFont = data.font_family || savedFont; 865 - savedAccent = data.accent_color || savedAccent; 866 - // Sync to localStorage 867 - localStorage.setItem('fontFamily', savedFont); 868 - localStorage.setItem('accentColor', savedAccent); 869 - } 870 - } 871 - } catch (err) { 872 - console.log('Using localStorage preferences'); 873 - } 874 - } 875 - 876 - // Apply font family 877 - const fontMap = { 878 - 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 879 - 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 880 - 'serif': 'ui-serif, Georgia, Cambria, serif', 881 - 'comic': '"Comic Sans MS", "Comic Sans", cursive' 882 - }; 883 - document.documentElement.style.setProperty('--font-family', fontMap[savedFont] || fontMap.system); 884 - 885 - // Update buttons 886 - document.querySelectorAll('.font-btn').forEach(btn => { 887 - btn.classList.toggle('active', btn.dataset.font === savedFont); 888 - }); 889 - 890 - // Apply accent color 891 - document.documentElement.style.setProperty('--accent', savedAccent); 892 - const accentInput = document.getElementById('accent-color'); 893 - if (accentInput) { 894 - accentInput.value = savedAccent; 895 - } 896 - }; 897 - 898 - // Timestamp formatting is handled by /static/timestamps.js 899 - 900 - // Fetch user's following list 901 - const fetchFollowing = async () => { 902 - try { 903 - const response = await fetch('/api/following'); 904 - if (!response.ok) { 905 - console.error('Failed to fetch following list'); 906 - return null; 907 - } 908 - const data = await response.json(); 909 - return data.follows; 910 - } catch (error) { 911 - console.error('Error fetching following:', error); 912 - return null; 913 - } 914 - }; 915 - 916 - // Check if we need to load more content after filtering 917 - const checkNeedMoreContent = () => { 918 - // Check if filtered content fills the viewport 919 - setTimeout(() => { 920 - if (document.documentElement.scrollHeight <= window.innerHeight && hasMore && !isLoading) { 921 - loadMoreStatuses(); 922 - } 923 - }, 50); // Small delay to ensure layout is updated 924 - }; 925 - 926 - // Apply following filter to existing statuses 927 - const applyFollowingFilter = (active) => { 928 - const statusItems = document.querySelectorAll('.status-item'); 929 - 930 - statusItems.forEach(item => { 931 - if (!active || !followingDids) { 932 - // Show all if filter is off or we don't have following data 933 - item.style.display = ''; 934 - } else { 935 - const authorDid = item.getAttribute('data-did'); 936 - // Check if this author is in our following list OR is the current user 937 - if (followingDids.includes(authorDid) || authorDid === currentUserDid) { 938 - item.style.display = ''; 939 - } else { 940 - item.style.display = 'none'; 941 - } 942 - } 943 - }); 944 - 945 - // After filtering, check if we need more content 946 - if (active) { 947 - checkNeedMoreContent(); 948 - } 949 - }; 950 - 951 - // Infinite scroll variables 952 - let isLoading = false; 953 - let offset = {% if !statuses.is_empty() %}{{ statuses.len() }}{% else %}0{% endif %}; 954 - let hasMore = true; 955 - 956 - // Following filter variables 957 - let followingDids = null; 958 - let filterActive = false; 959 - // Store current user's DID to include their own posts in following feed 960 - const currentUserDid = {% if let Some(p) = &profile %}"{{ p.did }}"{% else %}null{% endif %}; 961 - 962 - // Load more statuses 963 - const loadMoreStatuses = async () => { 964 - if (isLoading || !hasMore) return; 965 - 966 - isLoading = true; 967 - const loadingIndicator = document.getElementById('loading-indicator'); 968 - loadingIndicator.style.display = 'block'; 969 - 970 - try { 971 - const response = await fetch(`/api/feed?offset=${offset}&limit=20`); 972 - const data = await response.json(); 973 - const newStatuses = Array.isArray(data) ? data : (data.statuses || []); 974 - 975 - if (newStatuses.length === 0) { 976 - hasMore = false; 977 - loadingIndicator.style.display = 'none'; 978 - document.getElementById('end-of-feed').style.display = 'block'; 979 - return; 980 - } 981 - 982 - const statusList = document.querySelector('.status-list'); 983 - 984 - // Render new statuses 985 - newStatuses.forEach(status => { 986 - const statusItem = document.createElement('div'); 987 - statusItem.className = 'status-item'; 988 - statusItem.setAttribute('data-did', status.author_did); 989 - 990 - let emojiHtml = ''; 991 - if (status.status.startsWith('custom:')) { 992 - const emojiName = status.status.substring(7); 993 - emojiHtml = `<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" alt="${emojiName}" title="${emojiName}" class="custom-emoji-display emoji-placeholder" data-emoji-name="${emojiName}">`; 994 - } else { 995 - emojiHtml = `<span title="${status.status}">${status.status}</span>`; 996 - } 997 - 998 - // Build expiry HTML if present 999 - let expiryHtml = ''; 1000 - if (status.expires_at) { 1001 - const expiryDate = new Date(status.expires_at); 1002 - const now = new Date(); 1003 - if (expiryDate > now) { 1004 - expiryHtml = ` • <span class="local-time" data-timestamp="${status.expires_at}" data-prefix="expires"></span>`; 1005 - } else { 1006 - expiryHtml = ' • expired'; 1007 - } 1008 - } 1009 - 1010 - const displayName = status.handle || status.author_did; 1011 - const profileUrl = status.handle ? `/@${status.handle}` : '#'; 1012 - 1013 - statusItem.innerHTML = ` 1014 - <span class="status-emoji">${emojiHtml}</span> 1015 - <div class="status-content"> 1016 - <div class="status-main"> 1017 - <a class="status-author" href="${profileUrl}">@${displayName}</a> 1018 - ${status.text ? `<span class="status-text">${status.text}</span>` : ''} 1019 - </div> 1020 - <div class="status-meta"> 1021 - <span class="local-time" data-timestamp="${status.started_at}" data-format="relative"></span>${expiryHtml} 1022 - </div> 1023 - </div> 1024 - `; 1025 - 1026 - statusList.appendChild(statusItem); 1027 - // Render markdown links in the newly added item 1028 - if (window.renderMarkdownLinksIn) { 1029 - window.renderMarkdownLinksIn(statusItem); 1030 - } 1031 - }); 1032 - 1033 - // Re-initialize timestamps for newly added elements 1034 - if (typeof TimestampFormatter !== 'undefined') { 1035 - TimestampFormatter.initialize(); 1036 - } 1037 - 1038 - // Apply filter to newly added items if active 1039 - if (filterActive && followingDids) { 1040 - applyFollowingFilter(true); 1041 - } 1042 - 1043 - offset += newStatuses.length; 1044 - if (!Array.isArray(data) && typeof data === 'object') { 1045 - if (typeof data.next_offset === 'number') offset = data.next_offset; 1046 - if (typeof data.has_more === 'boolean') hasMore = data.has_more; 1047 - } 1048 - loadingIndicator.style.display = 'none'; 1049 - } catch (error) { 1050 - console.error('Error loading more statuses:', error); 1051 - loadingIndicator.style.display = 'none'; 1052 - } finally { 1053 - isLoading = false; 1054 - } 1055 - }; 1056 - 1057 - // Check scroll position 1058 - const checkScroll = () => { 1059 - const scrollHeight = document.documentElement.scrollHeight; 1060 - const scrollTop = window.scrollY; 1061 - const clientHeight = window.innerHeight; 1062 - 1063 - // Load more when user is 200px from the bottom 1064 - if (scrollTop + clientHeight >= scrollHeight - 200) { 1065 - loadMoreStatuses(); 1066 - } 1067 - }; 1068 - 1069 - // Initialize on page load 1070 - document.addEventListener('DOMContentLoaded', async () => { 1071 - initTheme(); 1072 - await initSettings(); 1073 - // Timestamps are auto-initialized by timestamps.js 1074 - // Always load initial page of statuses so feed is never empty on first render 1075 - try { await loadMoreStatuses(); } catch (e) { console.error('initial load failed', e); } 1076 - 1077 - // Settings toggle 1078 - const settingsToggle = document.getElementById('settings-toggle'); 1079 - const settingsPanel = document.getElementById('simple-settings'); 1080 - if (settingsToggle && settingsPanel) { 1081 - settingsToggle.addEventListener('click', () => { 1082 - settingsPanel.classList.toggle('hidden'); 1083 - }); 1084 - } 1085 - 1086 - // Helper to save preferences to API 1087 - const savePreferencesToAPI = async (updates) => { 1088 - try { 1089 - await fetch('/api/preferences', { 1090 - method: 'POST', 1091 - headers: { 'Content-Type': 'application/json' }, 1092 - body: JSON.stringify(updates) 1093 - }); 1094 - } catch (err) { 1095 - console.log('Failed to save preferences to server'); 1096 - } 1097 - }; 1098 - 1099 - // Font family buttons 1100 - document.querySelectorAll('.font-btn').forEach(btn => { 1101 - btn.addEventListener('click', () => { 1102 - const font = btn.dataset.font; 1103 - localStorage.setItem('fontFamily', font); 1104 - 1105 - // Update UI 1106 - document.querySelectorAll('.font-btn').forEach(b => b.classList.remove('active')); 1107 - btn.classList.add('active'); 1108 - 1109 - // Apply 1110 - const fontMap = { 1111 - 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 1112 - 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 1113 - 'serif': 'ui-serif, Georgia, Cambria, serif', 1114 - 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1115 - }; 1116 - document.documentElement.style.setProperty('--font-family', fontMap[font] || fontMap.system); 1117 - 1118 - // Save to API if logged in 1119 - if (document.querySelector('.settings-toggle')) { 1120 - savePreferencesToAPI({ font_family: font }); 1121 - } 1122 - }); 1123 - }); 1124 - 1125 - // Accent color 1126 - const accentInput = document.getElementById('accent-color'); 1127 - if (accentInput) { 1128 - accentInput.addEventListener('input', () => { 1129 - const color = accentInput.value; 1130 - localStorage.setItem('accentColor', color); 1131 - document.documentElement.style.setProperty('--accent', color); 1132 - 1133 - // Save to API if logged in 1134 - if (document.querySelector('.settings-toggle')) { 1135 - savePreferencesToAPI({ accent_color: color }); 1136 - } 1137 - }); 1138 - } 1139 - 1140 - // Color presets 1141 - document.querySelectorAll('.color-preset').forEach(btn => { 1142 - btn.addEventListener('click', () => { 1143 - const color = btn.dataset.color; 1144 - localStorage.setItem('accentColor', color); 1145 - document.documentElement.style.setProperty('--accent', color); 1146 - if (accentInput) { 1147 - accentInput.value = color; 1148 - } 1149 - 1150 - // Save to API if logged in 1151 - if (document.querySelector('.settings-toggle')) { 1152 - savePreferencesToAPI({ accent_color: color }); 1153 - } 1154 - }); 1155 - }); 1156 - 1157 - // Theme toggle 1158 - const themeToggle = document.getElementById('theme-toggle'); 1159 - if (themeToggle) { 1160 - themeToggle.addEventListener('click', toggleTheme); 1161 - } 1162 - 1163 - // Feed toggle 1164 - const feedToggle = document.getElementById('feed-toggle-input'); 1165 - const feedTitle = document.getElementById('feed-title'); 1166 - 1167 - if (feedToggle && feedTitle) { 1168 - // Restore preference from localStorage 1169 - const savedPreference = localStorage.getItem('followingFilterActive'); 1170 - if (savedPreference === 'true') { 1171 - feedToggle.checked = true; 1172 - filterActive = true; 1173 - feedTitle.textContent = 'following feed'; 1174 - 1175 - // Fetch following list and apply filter 1176 - fetchFollowing().then(follows => { 1177 - if (follows) { 1178 - followingDids = follows; 1179 - // Cache the following list with timestamp 1180 - localStorage.setItem('followingDids', JSON.stringify(follows)); 1181 - localStorage.setItem('followingDidsTimestamp', Date.now().toString()); 1182 - applyFollowingFilter(true); 1183 - } 1184 - }); 1185 - } 1186 - 1187 - feedToggle.addEventListener('change', async (e) => { 1188 - filterActive = e.target.checked; 1189 - localStorage.setItem('followingFilterActive', filterActive.toString()); 1190 - 1191 - // Animate title change 1192 - feedTitle.style.opacity = '0'; 1193 - 1194 - setTimeout(() => { 1195 - feedTitle.textContent = filterActive ? 'following feed' : 'global feed'; 1196 - feedTitle.style.opacity = '1'; 1197 - }, 150); 1198 - 1199 - if (filterActive) { 1200 - // Check if we have cached following list (valid for 1 hour) 1201 - const cachedFollows = localStorage.getItem('followingDids'); 1202 - const cacheTimestamp = localStorage.getItem('followingDidsTimestamp'); 1203 - const oneHour = 60 * 60 * 1000; 1204 - 1205 - if (cachedFollows && cacheTimestamp && 1206 - (Date.now() - parseInt(cacheTimestamp)) < oneHour) { 1207 - // Use cached data 1208 - followingDids = JSON.parse(cachedFollows); 1209 - } else { 1210 - // Fetch fresh data 1211 - const follows = await fetchFollowing(); 1212 - if (follows) { 1213 - followingDids = follows; 1214 - // Cache the following list 1215 - localStorage.setItem('followingDids', JSON.stringify(follows)); 1216 - localStorage.setItem('followingDidsTimestamp', Date.now().toString()); 1217 - } else { 1218 - // Failed to fetch, disable filter 1219 - filterActive = false; 1220 - e.target.checked = false; 1221 - localStorage.setItem('followingFilterActive', 'false'); 1222 - feedTitle.textContent = 'global feed'; 1223 - alert('Failed to fetch following list'); 1224 - return; 1225 - } 1226 - } 1227 - } 1228 - 1229 - applyFollowingFilter(filterActive); 1230 - }); 1231 - } 1232 - 1233 - // Set up infinite scrolling 1234 - window.addEventListener('scroll', checkScroll); 1235 - 1236 - // Check if we need to load more on initial page load 1237 - // (in case the initial content doesn't fill the viewport) 1238 - setTimeout(() => { 1239 - if (document.documentElement.scrollHeight <= window.innerHeight) { 1240 - loadMoreStatuses(); 1241 - } 1242 - }, 100); 1243 - 1244 - // Timestamps auto-update via timestamps.js 1245 - 1246 - // Admin hide button functionality 1247 - document.querySelectorAll('.hide-button').forEach(button => { 1248 - button.addEventListener('click', async (e) => { 1249 - const uri = e.target.dataset.uri; 1250 - if (!uri) return; 1251 - 1252 - if (!confirm('Hide this status from the global feed?')) { 1253 - return; 1254 - } 1255 - 1256 - try { 1257 - const response = await fetch('/admin/hide-status', { 1258 - method: 'POST', 1259 - headers: { 1260 - 'Content-Type': 'application/json', 1261 - }, 1262 - body: JSON.stringify({ 1263 - uri: uri, 1264 - hidden: true 1265 - }) 1266 - }); 1267 - 1268 - const result = await response.json(); 1269 - 1270 - if (response.ok) { 1271 - // Remove the status from the feed 1272 - e.target.closest('.status-item').style.display = 'none'; 1273 - } else { 1274 - alert(result.error || 'Failed to hide status'); 1275 - } 1276 - } catch (error) { 1277 - console.error('Error hiding status:', error); 1278 - alert('Failed to hide status'); 1279 - } 1280 - }); 1281 - }); 1282 - }); 1283 - </script> 1284 - <script> 1285 - // Admin upload toggles and submit 1286 - document.addEventListener('DOMContentLoaded', function () { 1287 - const toggle = document.getElementById('admin-toggle'); 1288 - const content = document.getElementById('admin-content'); 1289 - const form = document.getElementById('emoji-upload-form'); 1290 - const file = document.getElementById('emoji-file'); 1291 - const name = document.getElementById('emoji-name'); 1292 - const msg = document.getElementById('admin-msg'); 1293 - if (!toggle || !content || !form) return; 1294 - 1295 - toggle.addEventListener('click', () => { 1296 - content.style.display = content.style.display === 'none' ? 'block' : 'none'; 1297 - }); 1298 - 1299 - form.addEventListener('submit', async (e) => { 1300 - e.preventDefault(); 1301 - msg.textContent = ''; 1302 - if (!file.files || file.files.length === 0) { 1303 - msg.textContent = 'choose a PNG or GIF'; 1304 - return; 1305 - } 1306 - // Require a name; prefill from filename if empty 1307 - if (!name.value.trim().length) { 1308 - const base = (file.files[0].name || '').replace(/\.[^.]+$/, ''); 1309 - const sanitized = base.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); 1310 - name.value = sanitized || ''; 1311 - } 1312 - if (!name.value.trim().length) { 1313 - msg.textContent = 'please choose a name'; 1314 - return; 1315 - } 1316 - // Client-side reserved check (best-effort) 1317 - if (window.__reservedEmojiNames && window.__reservedEmojiNames.has(name.value.trim().toLowerCase())) { 1318 - msg.textContent = 'that name is reserved by a standard emoji'; 1319 - return; 1320 - } 1321 - const f = file.files[0]; 1322 - if (!['image/png','image/gif'].includes(f.type)) { 1323 - msg.textContent = 'only PNG or GIF'; 1324 - return; 1325 - } 1326 - const fd = new FormData(); 1327 - fd.append('file', f); 1328 - if (name.value.trim().length) fd.append('name', name.value.trim()); 1329 - try { 1330 - const res = await fetch('/admin/upload-emoji', { method: 'POST', body: fd }); 1331 - const json = await res.json(); 1332 - if (!res.ok || !json.success) { 1333 - if (json && json.code === 'name_exists') { 1334 - msg.textContent = 'that name already exists — please pick another'; 1335 - } else { 1336 - msg.textContent = (json && json.error) || 'upload failed'; 1337 - } 1338 - return; 1339 - } 1340 - // Notify listeners (e.g., emoji picker) and close panel 1341 - document.dispatchEvent(new CustomEvent('custom-emoji-uploaded', { detail: json })); 1342 - content.style.display = 'none'; 1343 - form.reset(); 1344 - msg.textContent = ''; 1345 - } catch (err) { 1346 - msg.textContent = 'network error'; 1347 - } 1348 - }); 1349 - }); 1350 - </script> 1351 - {%endblock content%}
-429
templates/login.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block content %} 4 - <div id="root"> 5 - <div class="container"> 6 - <!-- Header --> 7 - <header class="header"> 8 - <h1>status.zzstoatzz.io</h1> 9 - <div class="header-actions"> 10 - <a href="/" class="nav-button" aria-label="Home" title="Home"> 11 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 12 - <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path> 13 - <polyline points="9 22 9 12 15 12 15 22"></polyline> 14 - </svg> 15 - </a> 16 - <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"> 17 - <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 18 - <circle cx="12" cy="12" r="5"></circle> 19 - <line x1="12" y1="1" x2="12" y2="3"></line> 20 - <line x1="12" y1="21" x2="12" y2="23"></line> 21 - <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> 22 - <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> 23 - <line x1="1" y1="12" x2="3" y2="12"></line> 24 - <line x1="21" y1="12" x2="23" y2="12"></line> 25 - <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> 26 - <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> 27 - </svg> 28 - <svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 29 - <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> 30 - </svg> 31 - </button> 32 - </div> 33 - </header> 34 - 35 - <!-- Login Card --> 36 - <div class="login-card"> 37 - <div class="login-header"> 38 - <span class="login-emoji">🔐</span> 39 - <h2>log in with bluesky</h2> 40 - <p>enter your handle to set your status</p> 41 - </div> 42 - 43 - <form action="/login" method="post" class="login-form"> 44 - <div class="input-group"> 45 - <label for="handle">handle</label> 46 - <input 47 - type="text" 48 - id="handle" 49 - name="handle" 50 - placeholder="alice.bsky.social" 51 - required 52 - autocomplete="username" 53 - autofocus 54 - /> 55 - </div> 56 - 57 - {% if let Some(error) = self.error %} 58 - <div class="error-message"> 59 - <span>⚠️</span> {{error}} 60 - </div> 61 - {% endif %} 62 - 63 - <button type="submit" class="submit-button">log in</button> 64 - </form> 65 - 66 - <div class="signup-cta"> 67 - <p>don't have a bluesky account?</p> 68 - <a href="https://bsky.app" target="_blank" rel="noopener">sign up for bluesky →</a> 69 - </div> 70 - </div> 71 - 72 - <!-- Navigation --> 73 - <nav class="nav-links"> 74 - <a href="/feed">view global feed</a> 75 - </nav> 76 - </div> 77 - </div> 78 - 79 - <style> 80 - :root { 81 - --bg-primary: #ffffff; 82 - --bg-secondary: #f8f9fa; 83 - --bg-tertiary: #ffffff; 84 - --text-primary: #1a1a1a; 85 - --text-secondary: #6c757d; 86 - --text-tertiary: #adb5bd; 87 - --border-color: #e9ecef; 88 - --accent: #4a9eff; 89 - --accent-hover: color-mix(in srgb, var(--accent) 85%, black); 90 - --danger: #dc3545; 91 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); 92 - --shadow-md: 0 4px 6px rgba(0,0,0,0.07); 93 - --radius: 12px; 94 - --radius-sm: 8px; 95 - } 96 - 97 - [data-theme="dark"] { 98 - --bg-primary: #0a0a0a; 99 - --bg-secondary: #1a1a1a; 100 - --bg-tertiary: #2a2a2a; 101 - --text-primary: #ffffff; 102 - --text-secondary: #adb5bd; 103 - --text-tertiary: #6c757d; 104 - --border-color: #2a2a2a; 105 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); 106 - --shadow-md: 0 4px 6px rgba(0,0,0,0.3); 107 - } 108 - 109 - * { 110 - margin: 0; 111 - padding: 0; 112 - box-sizing: border-box; 113 - } 114 - 115 - /* Force all elements to use monospace font */ 116 - input, button, select, textarea { 117 - font-family: inherit; 118 - } 119 - 120 - body { 121 - font-family: var(--font-family); 122 - background: var(--bg-primary); 123 - color: var(--text-primary); 124 - line-height: 1.6; 125 - transition: background 0.3s, color 0.3s; 126 - } 127 - 128 - #root { 129 - min-height: 100vh; 130 - display: flex; 131 - align-items: center; 132 - justify-content: center; 133 - padding: 2rem 1rem; 134 - } 135 - 136 - .container { 137 - width: 100%; 138 - max-width: 500px; 139 - } 140 - 141 - /* Header */ 142 - .header { 143 - display: flex; 144 - justify-content: space-between; 145 - align-items: center; 146 - margin-bottom: 2rem; 147 - } 148 - 149 - .header h1 { 150 - font-size: 1.25rem; 151 - font-weight: 600; 152 - color: var(--text-secondary); 153 - } 154 - 155 - .theme-toggle { 156 - background: var(--bg-secondary); 157 - border: 1px solid var(--border-color); 158 - border-radius: var(--radius-sm); 159 - padding: 0.5rem; 160 - cursor: pointer; 161 - display: flex; 162 - align-items: center; 163 - justify-content: center; 164 - transition: all 0.2s; 165 - } 166 - 167 - .theme-toggle:hover { 168 - background: var(--bg-tertiary); 169 - border-color: var(--accent); 170 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1); 171 - } 172 - 173 - .theme-toggle svg { 174 - stroke: var(--text-secondary); 175 - transition: stroke 0.2s; 176 - } 177 - 178 - .theme-toggle:hover svg { 179 - stroke: var(--accent); 180 - } 181 - 182 - .sun-icon, .moon-icon { 183 - display: none; 184 - } 185 - 186 - [data-theme="light"] .sun-icon { 187 - display: block; 188 - } 189 - 190 - [data-theme="dark"] .moon-icon { 191 - display: block; 192 - } 193 - 194 - .header-actions { 195 - display: flex; 196 - gap: 0.75rem; 197 - align-items: center; 198 - } 199 - 200 - .nav-button { 201 - display: flex; 202 - align-items: center; 203 - justify-content: center; 204 - background: var(--bg-secondary); 205 - border: 1px solid var(--border-color); 206 - border-radius: var(--radius-sm); 207 - padding: 0.5rem; 208 - color: var(--text-secondary); 209 - transition: all 0.2s; 210 - text-decoration: none; 211 - } 212 - 213 - .nav-button:hover { 214 - background: var(--bg-tertiary); 215 - border-color: var(--accent); 216 - color: var(--accent); 217 - } 218 - 219 - .nav-button svg { 220 - stroke: currentColor; 221 - } 222 - 223 - /* Login Card */ 224 - .login-card { 225 - background: var(--bg-secondary); 226 - border: 1px solid var(--border-color); 227 - border-radius: var(--radius); 228 - padding: 2rem; 229 - box-shadow: var(--shadow-sm); 230 - } 231 - 232 - .login-header { 233 - text-align: center; 234 - margin-bottom: 2rem; 235 - } 236 - 237 - .login-emoji { 238 - font-size: 3rem; 239 - display: block; 240 - margin-bottom: 1rem; 241 - } 242 - 243 - .login-header h2 { 244 - font-size: 1.25rem; 245 - font-weight: 600; 246 - color: var(--text-primary); 247 - margin-bottom: 0.5rem; 248 - } 249 - 250 - .login-header p { 251 - color: var(--text-secondary); 252 - font-size: 0.875rem; 253 - } 254 - 255 - /* Form */ 256 - .login-form { 257 - display: flex; 258 - flex-direction: column; 259 - gap: 1.5rem; 260 - } 261 - 262 - .input-group { 263 - display: flex; 264 - flex-direction: column; 265 - gap: 0.5rem; 266 - } 267 - 268 - .input-group label { 269 - font-size: 0.875rem; 270 - color: var(--text-secondary); 271 - font-weight: 500; 272 - } 273 - 274 - .input-group input { 275 - padding: 0.75rem; 276 - background: var(--bg-primary); 277 - border: 1px solid var(--border-color); 278 - border-radius: var(--radius-sm); 279 - font-size: 1rem; 280 - color: var(--text-primary); 281 - transition: border-color 0.2s; 282 - } 283 - 284 - .input-group input:focus { 285 - outline: none; 286 - border-color: var(--accent); 287 - box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1); 288 - } 289 - 290 - .input-group input::placeholder { 291 - color: var(--text-tertiary); 292 - } 293 - 294 - /* Error Message */ 295 - .error-message { 296 - background: rgba(220, 53, 69, 0.1); 297 - border: 1px solid rgba(220, 53, 69, 0.3); 298 - border-radius: var(--radius-sm); 299 - padding: 0.75rem; 300 - color: var(--danger); 301 - font-size: 0.875rem; 302 - display: flex; 303 - align-items: center; 304 - gap: 0.5rem; 305 - } 306 - 307 - /* Submit Button */ 308 - .submit-button { 309 - background: var(--accent); 310 - color: white; 311 - border: none; 312 - padding: 0.75rem 1.5rem; 313 - border-radius: var(--radius-sm); 314 - font-size: 1rem; 315 - font-weight: 500; 316 - cursor: pointer; 317 - transition: background 0.2s; 318 - } 319 - 320 - .submit-button:hover { 321 - background: var(--accent-hover); 322 - } 323 - 324 - .submit-button:active { 325 - transform: translateY(1px); 326 - } 327 - 328 - /* Signup CTA */ 329 - .signup-cta { 330 - text-align: center; 331 - margin-top: 2rem; 332 - padding-top: 2rem; 333 - border-top: 1px solid var(--border-color); 334 - } 335 - 336 - .signup-cta p { 337 - color: var(--text-secondary); 338 - font-size: 0.875rem; 339 - margin-bottom: 0.5rem; 340 - } 341 - 342 - .signup-cta a { 343 - color: var(--accent); 344 - text-decoration: none; 345 - font-size: 0.875rem; 346 - font-weight: 500; 347 - transition: color 0.2s; 348 - } 349 - 350 - .signup-cta a:hover { 351 - color: var(--accent-hover); 352 - text-decoration: underline; 353 - } 354 - 355 - /* Navigation */ 356 - .nav-links { 357 - display: flex; 358 - justify-content: center; 359 - gap: 2rem; 360 - margin-top: 2rem; 361 - padding-top: 2rem; 362 - border-top: 1px solid var(--border-color); 363 - } 364 - 365 - .nav-links a { 366 - color: var(--accent); 367 - text-decoration: none; 368 - font-size: 0.875rem; 369 - transition: color 0.2s; 370 - } 371 - 372 - .nav-links a:hover { 373 - color: var(--accent-hover); 374 - } 375 - 376 - /* Mobile adjustments */ 377 - @media (max-width: 640px) { 378 - #root { 379 - padding: 1rem; 380 - } 381 - 382 - .login-card { 383 - padding: 1.5rem; 384 - } 385 - } 386 - </style> 387 - 388 - <script> 389 - // Theme management 390 - const initTheme = () => { 391 - const saved = localStorage.getItem('theme'); 392 - const theme = saved || 'system'; 393 - 394 - if (theme === 'system') { 395 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 396 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 397 - } else { 398 - document.body.setAttribute('data-theme', theme); 399 - } 400 - }; 401 - 402 - const toggleTheme = () => { 403 - const saved = localStorage.getItem('theme') || 'system'; 404 - const themes = ['system', 'light', 'dark']; 405 - const currentIndex = themes.indexOf(saved); 406 - const next = themes[(currentIndex + 1) % themes.length]; 407 - 408 - localStorage.setItem('theme', next); 409 - 410 - if (next === 'system') { 411 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 412 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 413 - } else { 414 - document.body.setAttribute('data-theme', next); 415 - } 416 - }; 417 - 418 - // Initialize on page load 419 - document.addEventListener('DOMContentLoaded', () => { 420 - initTheme(); 421 - 422 - // Theme toggle 423 - const themeToggle = document.getElementById('theme-toggle'); 424 - if (themeToggle) { 425 - themeToggle.addEventListener('click', toggleTheme); 426 - } 427 - }); 428 - </script> 429 - {%endblock content%}
-2810
templates/status.html
··· 1 - {% extends "base.html" %} 2 - 3 - {% block title %}@{{ handle }} - status.zzstoatzz.io{% endblock %} 4 - {% block og_url %}/@{{ handle }}{% endblock %} 5 - {% block og_title %}@{{ handle }}'s status{% endblock %} 6 - {% block og_description %}{% if let Some(current) = current_status %}{{ current.status }} {% if current.text.is_some() %}{{ current.text.as_ref().unwrap() }}{% endif %}{% else %}no status currently set{% endif %}{% endblock %} 7 - {% block twitter_url %}/@{{ handle }}{% endblock %} 8 - {% block twitter_title %}@{{ handle }}'s status{% endblock %} 9 - {% block twitter_description %}{% if let Some(current) = current_status %}{{ current.status }} {% if current.text.is_some() %}{{ current.text.as_ref().unwrap() }}{% endif %}{% else %}no status currently set{% endif %}{% endblock %} 10 - 11 - {% block content %} 12 - <div id="root"> 13 - <div class="container"> 14 - <!-- Header --> 15 - <header class="header"> 16 - <h1><a href="https://bsky.app/profile/{{ handle }}" target="_blank" rel="noopener" class="handle-link">@{{ handle }}</a></h1> 17 - <div class="header-actions"> 18 - <a href="/feed" class="nav-button" aria-label="Global feed" title="Global feed"> 19 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 20 - <circle cx="12" cy="12" r="10"></circle> 21 - <line x1="2" y1="12" x2="22" y2="12"></line> 22 - <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> 23 - </svg> 24 - </a> 25 - {% if is_owner %} 26 - <button class="settings-toggle" id="settings-toggle" aria-label="Settings"> 27 - <img src="https://api.iconify.design/lucide:settings.svg" width="20" height="20" alt="Settings" class="settings-icon"> 28 - </button> 29 - <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"> 30 - <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 31 - <circle cx="12" cy="12" r="5"></circle> 32 - <line x1="12" y1="1" x2="12" y2="3"></line> 33 - <line x1="12" y1="21" x2="12" y2="23"></line> 34 - <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> 35 - <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> 36 - <line x1="1" y1="12" x2="3" y2="12"></line> 37 - <line x1="21" y1="12" x2="23" y2="12"></line> 38 - <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> 39 - <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> 40 - </svg> 41 - <svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 42 - <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> 43 - </svg> 44 - <span class="theme-indicator" id="theme-indicator"></span> 45 - </button> 46 - {% endif %} 47 - </div> 48 - </header> 49 - 50 - {% if is_admin %} 51 - <!-- Admin Upload (fixed top-left) --> 52 - <div class="admin-panel" id="admin-panel"> 53 - <button class="admin-toggle" id="admin-toggle" title="admin tools" aria-label="admin tools">⚙️</button> 54 - <div class="admin-content" id="admin-content" style="display:none;"> 55 - <div class="admin-section"> 56 - <div class="admin-title">upload emoji</div> 57 - <form id="emoji-upload-form"> 58 - <input type="text" id="emoji-name" placeholder="name (optional)" maxlength="40" /> 59 - <input type="file" id="emoji-file" accept="image/png,image/gif" required /> 60 - <button type="submit">upload</button> 61 - </form> 62 - <div class="admin-msg" id="admin-msg" aria-live="polite"></div> 63 - </div> 64 - </div> 65 - </div> 66 - {% endif %} 67 - 68 - <!-- Simple Settings (owner only) --> 69 - {% if is_owner %} 70 - <div class="simple-settings hidden" id="simple-settings"> 71 - <div class="settings-row"> 72 - <label>font</label> 73 - <div class="button-group"> 74 - <button class="font-btn active" data-font="system">system</button> 75 - <button class="font-btn" data-font="mono">mono</button> 76 - <button class="font-btn" data-font="serif">serif</button> 77 - <button class="font-btn" data-font="comic">comic</button> 78 - </div> 79 - </div> 80 - <div class="settings-row"> 81 - <label>accent</label> 82 - <input type="color" id="accent-color" value="#1DA1F2"> 83 - <div class="preset-colors"> 84 - <button class="color-preset" data-color="#1DA1F2" style="background: #1DA1F2"></button> 85 - <button class="color-preset" data-color="#FF6B6B" style="background: #FF6B6B"></button> 86 - <button class="color-preset" data-color="#4ECDC4" style="background: #4ECDC4"></button> 87 - <button class="color-preset" data-color="#FFEAA7" style="background: #FFEAA7"></button> 88 - <button class="color-preset" data-color="#A29BFE" style="background: #A29BFE"></button> 89 - <button class="color-preset" data-color="#FD79A8" style="background: #FD79A8"></button> 90 - </div> 91 - </div> 92 - <div class="settings-row"> 93 - <label>integrations</label> 94 - <button id="open-webhook-config" class="nav-button">configure webhooks</button> 95 - </div> 96 - </div> 97 - {% endif %} 98 - 99 - <!-- Current Status Display --> 100 - <div class="status-display"> 101 - {% if let Some(current) = current_status %} 102 - <div class="current-status"> 103 - <span class="status-emoji"> 104 - {% if current.status.starts_with("custom:") %} 105 - {% let emoji_name = current.status.strip_prefix("custom:").unwrap() %} 106 - <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" 107 - alt="{{emoji_name}}" title="{{emoji_name}}" class="custom-emoji-display emoji-placeholder" 108 - data-emoji-name="{{emoji_name}}"> 109 - {% else %} 110 - <span title="{{ current.status }}">{{ current.status }}</span> 111 - {% endif %} 112 - </span> 113 - <div class="status-content"> 114 - {% if current.text.is_some() %} 115 - <p class="status-text">{{ current.text.as_ref().unwrap() }}</p> 116 - {% endif %} 117 - <p class="status-meta"> 118 - <span class="local-time" data-timestamp="{{ current.started_at.to_rfc3339() }}" data-prefix="since"></span> 119 - {% if current.expires_at.is_some() && !current.is_expired() %} 120 - • <span class="expires-indicator"><span class="local-time" data-timestamp="{{ current.expires_at.as_ref().unwrap().to_rfc3339() }}" data-prefix="clears"></span></span> 121 - {% endif %} 122 - </p> 123 - </div> 124 - </div> 125 - {% else %} 126 - <div class="no-status"> 127 - <span class="status-emoji">💭</span> 128 - <p class="status-text">no status set</p> 129 - </div> 130 - {% endif %} 131 - </div> 132 - 133 - {% if is_owner %} 134 - <!-- Status Editor --> 135 - <div class="status-editor"> 136 - <form action="/status" method="post" id="status-form"> 137 - <div class="input-group"> 138 - <div class="status-text-row"> 139 - <button type="button" class="emoji-trigger" id="emoji-trigger"> 140 - <span id="selected-emoji"> 141 - {% if let Some(current) = current_status.as_ref() %} 142 - {% if current.status.starts_with("custom:") %} 143 - {% let emoji_name = current.status.strip_prefix("custom:").unwrap() %} 144 - <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" 145 - alt="{{emoji_name}}" title="{{emoji_name}}" class="emoji-placeholder" 146 - data-emoji-name="{{emoji_name}}"> 147 - {% else %} 148 - <span title="{{ current.status }}">{{ current.status }}</span> 149 - {% endif %} 150 - {% else %} 151 - <span title="happy">😊</span> 152 - {% endif %} 153 - </span> 154 - </button> 155 - <input type="hidden" name="status" id="status-input" value="{% if let Some(current) = current_status.as_ref() %}{{ current.status }}{% else %}😊{% endif %}" required> 156 - 157 - <input 158 - type="text" 159 - name="text" 160 - id="status-text" 161 - placeholder="what's your status?" 162 - maxlength="100" 163 - value="" 164 - autocomplete="off" 165 - > 166 - </div> 167 - 168 - <div class="input-actions"> 169 - <button type="button" class="clear-after-btn" id="clear-after-btn"> 170 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 171 - <circle cx="12" cy="12" r="10"></circle> 172 - <polyline points="12 6 12 12 16 14"></polyline> 173 - </svg> 174 - <span id="clear-text">don't clear</span> 175 - </button> 176 - 177 - <button type="submit" class="save-btn">set</button> 178 - </div> 179 - </div> 180 - 181 - <!-- Hidden expiration select --> 182 - <select name="expires_in" id="expires_in" hidden> 183 - <option value="" selected>Never</option> 184 - <option value="30m">30 minutes</option> 185 - <option value="1h">1 hour</option> 186 - <option value="2h">2 hours</option> 187 - <option value="4h">4 hours</option> 188 - <option value="8h">8 hours</option> 189 - <option value="1d">1 day</option> 190 - <option value="1w">1 week</option> 191 - </select> 192 - </form> 193 - </div> 194 - 195 - <!-- Emoji Picker Modal --> 196 - <div class="emoji-picker-overlay hidden" id="emoji-picker-overlay" aria-hidden="true"> 197 - <div class="emoji-picker" id="emoji-picker" role="dialog" aria-modal="true" aria-labelledby="emoji-picker-title"> 198 - <div class="emoji-picker-header"> 199 - <div> 200 - <h2 id="emoji-picker-title">pick an emoji</h2> 201 - <p class="emoji-picker-subtitle">custom and unicode options side by side</p> 202 - </div> 203 - <button type="button" class="emoji-picker-close" id="emoji-picker-close" aria-label="close emoji picker">✕</button> 204 - </div> 205 - <div class="emoji-search-container"> 206 - <input type="text" 207 - id="emoji-search" 208 - class="emoji-search" 209 - placeholder="Search emojis..." 210 - autocomplete="off"> 211 - </div> 212 - <div class="emoji-picker-body"> 213 - <div class="emoji-categories" id="emoji-categories"> 214 - <button type="button" class="category-btn active" data-category="frequent">Frequent</button> 215 - <button type="button" class="category-btn" data-category="custom">Custom</button> 216 - <button type="button" class="category-btn" data-category="people">People</button> 217 - <button type="button" class="category-btn" data-category="nature">Nature</button> 218 - <button type="button" class="category-btn" data-category="food">Food</button> 219 - <button type="button" class="category-btn" data-category="activity">Activity</button> 220 - <button type="button" class="category-btn" data-category="travel">Travel</button> 221 - <button type="button" class="category-btn" data-category="objects">Objects</button> 222 - <button type="button" class="category-btn" data-category="symbols">Symbols</button> 223 - <button type="button" class="category-btn" data-category="flags">Flags</button> 224 - </div> 225 - <div class="emoji-grid" id="emoji-grid"> 226 - <!-- Will be populated by JavaScript --> 227 - </div> 228 - <div class="bufo-helper" id="bufo-helper" style="display: none;"> 229 - <a href="https://find-bufo.fly.dev/" target="_blank" rel="noopener noreferrer"> 230 - need help finding a bufo? 231 - </a> 232 - </div> 233 - </div> 234 - </div> 235 - </div> 236 - 237 - <!-- Clear Time Picker (hidden by default) --> 238 - <div class="clear-picker" id="clear-picker" style="display: none;"> 239 - <button class="clear-option active" data-value="">don't clear</button> 240 - <button class="clear-option" data-value="30m">30 minutes</button> 241 - <button class="clear-option" data-value="1h">1 hour</button> 242 - <button class="clear-option" data-value="2h">2 hours</button> 243 - <button class="clear-option" data-value="4h">4 hours</button> 244 - <button class="clear-option" data-value="8h">8 hours</button> 245 - <button class="clear-option" data-value="1d">Today</button> 246 - <button class="clear-option" data-value="1w">This week</button> 247 - <button class="clear-option" data-value="custom">Custom...</button> 248 - <div class="custom-datetime" id="custom-datetime" style="display: none;"> 249 - <input type="datetime-local" id="custom-datetime-input" /> 250 - <button type="button" class="custom-datetime-set" id="custom-datetime-set">set</button> 251 - </div> 252 - </div> 253 - 254 - <!-- Session Info --> 255 - <div class="session-info"> 256 - <a href="/logout" class="logout-link">log out</a> 257 - </div> 258 - {% endif %} 259 - 260 - <!-- Webhook Full-Page Modal --> 261 - <div id="webhook-modal" class="webhook-modal hidden" aria-hidden="true"> 262 - <div class="webhook-modal-content"> 263 - <div class="webhook-modal-header"> 264 - <h2>webhooks</h2> 265 - <button id="close-webhook-modal" aria-label="Close">✕</button> 266 - </div> 267 - <div class="webhook-modal-body"> 268 - <div class="webhook-intro" style="margin-bottom:10px;"> 269 - <p>Send signed events when your status changes. Configure a URL that accepts JSON POSTs. We include an HMAC-SHA256 signature in <code>X-Status-Webhook-Signature</code> and a UNIX timestamp in <code>X-Status-Webhook-Timestamp</code>.</p> 270 - </div> 271 - <form id="create-webhook-form" class="webhook-form" aria-label="create webhook"> 272 - <div style="display:flex; flex-direction:column; gap:4px;"> 273 - <input type="url" id="wh-url" placeholder="Webhook URL (https://example.com/webhook)" required /> 274 - <div class="field-help">HTTPS required in production. Local/private hosts allowed only in local dev.</div> 275 - </div> 276 - <div style="display:flex; flex-direction:column; gap:4px;"> 277 - <input type="text" id="wh-secret" placeholder="Secret (optional – autogenerated)" /> 278 - <div class="field-help">Used to sign requests with HMAC-SHA256. Reveal only on creation/rotation.</div> 279 - </div> 280 - <div style="display:flex; flex-direction:column; gap:4px;"> 281 - <input type="text" id="wh-events" placeholder="Events (optional, default *) e.g. status.created,status.deleted" /> 282 - <div class="field-help">Comma-separated. Supported: <code>status.created</code>, <code>status.deleted</code> or <code>*</code>.</div> 283 - </div> 284 - <button type="submit" aria-label="add webhook">add webhook</button> 285 - <div class="field-help">You can add multiple webhooks. Toggle active, rotate secrets, or delete below.</div> 286 - </form> 287 - <div id="webhook-list" class="webhook-list" aria-live="polite"></div> 288 - 289 - <details class="wh-guide" id="webhook-guide"> 290 - <summary>Integration guide</summary> 291 - <div class="content"> 292 - <div class="wh-grid"> 293 - <div class="wh-static"> 294 - <h4>Request</h4> 295 - <ul> 296 - <li>Method: POST</li> 297 - <li>Content-Type: application/json</li> 298 - <li>Header <code>X-Status-Webhook-Timestamp</code>: UNIX seconds</li> 299 - <li>Header <code>X-Status-Webhook-Signature</code>: <code>sha256=&lt;hex&gt;</code></li> 300 - </ul> 301 - <h4>Payload</h4> 302 - <pre><code>{ 303 - "event": "status.created", // or "status.deleted" 304 - "did": "did:plc:...", 305 - "handle": null, 306 - "status": "🙂", // created only 307 - "text": "in a meeting", // optional 308 - "uri": "at://...", // record URI 309 - "since": "2025-09-10T16:00:00Z", // created only 310 - "expires": null // created only 311 - }</code></pre> 312 - </div> 313 - <div class="wh-dynamic"> 314 - <div id="wh-lang-tabs" class="wh-tabs" role="tablist" aria-label="language selector"> 315 - <button type="button" data-lang="node" role="tab" aria-selected="true">Node</button> 316 - <button type="button" data-lang="rust" role="tab">Rust</button> 317 - <button type="button" data-lang="python" role="tab">Python</button> 318 - <button type="button" data-lang="go" role="tab">Go</button> 319 - </div> 320 - <div class="wh-snippet" data-lang="node"> 321 - <h4>Verify signature</h4> 322 - <p>Compute HMAC-SHA256 over <code>timestamp + "." + rawBody</code> using your secret. Compare to header (without the <code>sha256=</code> prefix) with constant-time equality, and reject if timestamp is too old (e.g., &gt; 5 minutes).</p> 323 - <pre><code>// Node (TypeScript) 324 - import crypto from 'node:crypto'; 325 - 326 - function verify(req: any, rawBody: Buffer, secret: string): boolean { 327 - const ts = req.headers['x-status-webhook-timestamp']; 328 - const sig = String(req.headers['x-status-webhook-signature'] || '').replace(/^sha256=/, ''); 329 - if (!ts || !sig) return false; 330 - const now = Math.floor(Date.now()/1000); 331 - if (Math.abs(now - Number(ts)) > 300) return false; // 5m 332 - const mac = crypto.createHmac('sha256', secret).update(String(ts)).update('.').update(rawBody).digest('hex'); 333 - return crypto.timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(sig, 'hex')); 334 - } 335 - </code></pre> 336 - </div> 337 - <div class="wh-snippet" data-lang="rust"> 338 - <pre><code>// Rust (axum-ish) 339 - use hmac::{Hmac, Mac}; 340 - use sha2::Sha256; 341 - 342 - fn verify(ts: &str, sig_hdr: &str, body: &[u8], secret: &str) -> bool { 343 - let sig = sig_hdr.strip_prefix("sha256=").unwrap_or(sig_hdr); 344 - if let Ok(ts_int) = ts.parse::<i64>() { 345 - if (chrono::Utc::now().timestamp() - ts_int).abs() > 300 { return false; } 346 - } else { return false; } 347 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); 348 - mac.update(ts.as_bytes()); 349 - mac.update(b"."); 350 - mac.update(body); 351 - let calc = hex::encode(mac.finalize().into_bytes()); 352 - subtle::ConstantTimeEq::ct_eq(calc.as_bytes(), sig.as_bytes()).into() 353 - } 354 - </code></pre> 355 - </div> 356 - <div class="wh-snippet" data-lang="python"> 357 - <pre><code># Python (Flask example) 358 - import hmac, hashlib, time 359 - from flask import request 360 - 361 - def verify(secret: str, raw_body: bytes) -> bool: 362 - ts = request.headers.get('X-Status-Webhook-Timestamp') 363 - sig_hdr = request.headers.get('X-Status-Webhook-Signature', '') 364 - if not ts or not sig_hdr.startswith('sha256='): 365 - return False 366 - if abs(int(time.time()) - int(ts)) > 300: 367 - return False 368 - expected = hmac.new(secret.encode(), (ts + '.').encode() + raw_body, hashlib.sha256).hexdigest() 369 - actual = sig_hdr[len('sha256='):] 370 - return hmac.compare_digest(expected, actual) 371 - </code></pre> 372 - </div> 373 - <div class="wh-snippet" data-lang="go"> 374 - <pre><code>// Go (net/http) 375 - package main 376 - 377 - import ( 378 - "crypto/hmac" 379 - "crypto/sha256" 380 - "encoding/hex" 381 - "net/http" 382 - "strconv" 383 - "time" 384 - ) 385 - 386 - func verify(r *http.Request, body []byte, secret string) bool { 387 - ts := r.Header.Get("X-Status-Webhook-Timestamp") 388 - sig := r.Header.Get("X-Status-Webhook-Signature") 389 - if ts == "" || sig == "" { return false } 390 - if len(sig) > 7 && sig[:7] == "sha256=" { sig = sig[7:] } 391 - tsv, err := strconv.ParseInt(ts, 10, 64) 392 - if err != nil || time.Now().Unix()-tsv > 300 || tsv-time.Now().Unix() > 300 { return false } 393 - mac := hmac.New(sha256.New, []byte(secret)) 394 - mac.Write([]byte(ts)) 395 - mac.Write([]byte(".")) 396 - mac.Write(body) 397 - expected := hex.EncodeToString(mac.Sum(nil)) 398 - return hmac.Equal([]byte(expected), []byte(sig)) 399 - } 400 - </code></pre> 401 - </div> 402 - </div> 403 - </div> 404 - </div> 405 - </details> 406 - <link rel="stylesheet" href="/static/webhook_guide.css"> 407 - <script src="/static/webhook_guide.js"></script> 408 - </div> 409 - </div> 410 - </div> 411 - 412 - <!-- History --> 413 - {% if !history.is_empty() %} 414 - <div class="history"> 415 - <h3>recent</h3> 416 - {% for status in history %} 417 - {% if loop.index0 < 5 %} 418 - <div class="history-item"> 419 - <span class="history-emoji"> 420 - {% if status.status.starts_with("custom:") %} 421 - {% let emoji_name = status.status.strip_prefix("custom:").unwrap() %} 422 - <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" 423 - alt="{{emoji_name}}" title="{{emoji_name}}" class="custom-emoji-display emoji-placeholder" 424 - data-emoji-name="{{emoji_name}}"> 425 - {% else %} 426 - <span title="{{ status.status }}">{{ status.status }}</span> 427 - {% endif %} 428 - </span> 429 - <div class="history-content"> 430 - {% if status.text.is_some() %} 431 - <span class="history-text">{{ status.text.as_ref().unwrap() }}</span> 432 - {% endif %} 433 - <span class="history-time local-time" data-timestamp="{{ status.started_at.to_rfc3339() }}" data-format="compact"></span> 434 - </div> 435 - {% if is_owner %} 436 - <button type="button" class="history-delete" data-uri="{{ status.uri }}" title="Delete this status"> 437 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 438 - <line x1="18" y1="6" x2="6" y2="18"></line> 439 - <line x1="6" y1="6" x2="18" y2="18"></line> 440 - </svg> 441 - </button> 442 - {% endif %} 443 - </div> 444 - {% endif %} 445 - {% endfor %} 446 - </div> 447 - {% endif %} 448 - 449 - <!-- Bottom Navigation --> 450 - <nav class="bottom-nav"> 451 - <a href="/feed" class="nav-button-bottom"> 452 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 453 - <circle cx="12" cy="12" r="10"></circle> 454 - <line x1="2" y1="12" x2="22" y2="12"></line> 455 - <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> 456 - </svg> 457 - <span>global feed</span> 458 - </a> 459 - {% if !is_owner %} 460 - <a href="/login" class="nav-button-bottom"> 461 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 462 - <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path> 463 - <polyline points="10 17 15 12 10 7"></polyline> 464 - <line x1="15" y1="12" x2="3" y2="12"></line> 465 - </svg> 466 - <span>set your status</span> 467 - </a> 468 - {% endif %} 469 - </nav> 470 - </div> 471 - </div> 472 - 473 - <script src="/static/emoji-data.js"></script> 474 - <style> 475 - body { 476 - font-family: var(--font-family) !important; 477 - } 478 - 479 - body.modal-open { 480 - overflow: hidden; 481 - position: fixed; 482 - width: 100%; 483 - } 484 - 485 - :root { 486 - --bg-primary: #ffffff; 487 - --bg-secondary: #f8f9fa; 488 - --bg-tertiary: #ffffff; 489 - --text-primary: #1a1a1a; 490 - --text-secondary: #6c757d; 491 - --text-tertiary: #adb5bd; 492 - --border-color: #e9ecef; 493 - --accent: #4a9eff; 494 - --accent-hover: color-mix(in srgb, var(--accent) 85%, black); 495 - --danger: #dc3545; 496 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); 497 - --shadow-md: 0 4px 6px rgba(0,0,0,0.07); 498 - --radius: 12px; 499 - --radius-sm: 8px; 500 - } 501 - 502 - [data-theme="dark"] { 503 - --bg-primary: #0a0a0a; 504 - --bg-secondary: #1a1a1a; 505 - --bg-tertiary: #2a2a2a; 506 - --text-primary: #ffffff; 507 - --text-secondary: #adb5bd; 508 - --text-tertiary: #6c757d; 509 - --border-color: #2a2a2a; 510 - --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); 511 - --shadow-md: 0 4px 6px rgba(0,0,0,0.3); 512 - } 513 - 514 - * { 515 - margin: 0; 516 - padding: 0; 517 - box-sizing: border-box; 518 - } 519 - 520 - /* Force all elements to use monospace font */ 521 - input, button, select, textarea { 522 - font-family: inherit; 523 - } 524 - 525 - body { 526 - background: var(--bg-primary); 527 - color: var(--text-primary); 528 - line-height: 1.6; 529 - transition: background 0.3s, color 0.3s; 530 - } 531 - 532 - #root { 533 - min-height: 100vh; 534 - display: flex; 535 - align-items: center; 536 - justify-content: center; 537 - padding: 2rem 1rem; 538 - } 539 - 540 - .container { 541 - width: 100%; 542 - max-width: 600px; 543 - } 544 - 545 - /* Header */ 546 - .header { 547 - display: flex; 548 - justify-content: space-between; 549 - align-items: center; 550 - margin-bottom: 2rem; 551 - } 552 - 553 - /* Admin panel (top-left) */ 554 - .admin-panel { 555 - position: fixed; 556 - top: 12px; 557 - left: 12px; 558 - z-index: 9999; 559 - } 560 - .admin-toggle { 561 - background: var(--bg-tertiary); 562 - border: 1px solid var(--border-color); 563 - border-radius: 10px; 564 - padding: 6px 8px; 565 - cursor: pointer; 566 - color: var(--text-secondary); 567 - } 568 - .admin-content { 569 - margin-top: 8px; 570 - background: var(--bg-tertiary); 571 - border: 1px solid var(--border-color); 572 - border-radius: 12px; 573 - padding: 10px; 574 - width: 240px; 575 - box-shadow: var(--shadow-md); 576 - } 577 - .admin-title { 578 - font-size: 12px; 579 - color: var(--text-secondary); 580 - margin-bottom: 6px; 581 - } 582 - .admin-content input[type="text"], 583 - .admin-content input[type="file"] { 584 - width: 100%; 585 - margin-bottom: 8px; 586 - } 587 - .admin-content button[type="submit"] { 588 - width: 100%; 589 - background: var(--accent); 590 - color: #fff; 591 - border: none; 592 - border-radius: 8px; 593 - padding: 6px 8px; 594 - cursor: pointer; 595 - } 596 - .admin-msg { font-size: 12px; color: var(--text-secondary); margin-top: 6px; } 597 - 598 - .header-actions { 599 - display: flex; 600 - gap: 0.75rem; 601 - align-items: center; 602 - } 603 - 604 - .header h1 { 605 - font-size: 1.5rem; 606 - font-weight: 600; 607 - max-width: calc(100% - 60px); 608 - overflow: hidden; 609 - text-overflow: ellipsis; 610 - white-space: nowrap; 611 - } 612 - 613 - .handle-link { 614 - color: var(--text-secondary); 615 - text-decoration: none; 616 - transition: all 0.3s ease; 617 - display: inline-block; 618 - max-width: 100%; 619 - overflow: hidden; 620 - text-overflow: ellipsis; 621 - white-space: nowrap; 622 - position: relative; 623 - padding: 0.25rem 0.5rem; 624 - border-radius: var(--radius-sm); 625 - } 626 - 627 - .handle-link:link, 628 - .handle-link:visited { 629 - color: var(--text-secondary); 630 - } 631 - 632 - .handle-link::before { 633 - content: ''; 634 - position: absolute; 635 - top: -2px; 636 - right: -2px; 637 - bottom: -2px; 638 - left: -2px; 639 - inset: -2px; 640 - border-radius: var(--radius-sm); 641 - background: linear-gradient(45deg, var(--accent), transparent); 642 - opacity: 0; 643 - transition: opacity 0.3s ease; 644 - z-index: -1; 645 - filter: blur(8px); 646 - } 647 - 648 - .handle-link:hover, 649 - .handle-link:active { 650 - color: var(--accent); 651 - text-shadow: 0 0 12px var(--accent); 652 - } 653 - 654 - .handle-link:hover::before { 655 - opacity: 0; 656 - } 657 - 658 - .nav-button { 659 - display: flex; 660 - align-items: center; 661 - justify-content: center; 662 - background: var(--bg-secondary); 663 - border: 1px solid var(--border-color); 664 - border-radius: var(--radius-sm); 665 - padding: 0.5rem; 666 - color: var(--text-secondary); 667 - transition: all 0.2s; 668 - text-decoration: none; 669 - } 670 - 671 - .nav-button:hover { 672 - background: var(--bg-tertiary); 673 - border-color: var(--accent); 674 - color: var(--accent); 675 - } 676 - 677 - .nav-button svg { 678 - stroke: var(--accent); 679 - } 680 - 681 - /* Simple Settings */ 682 - .simple-settings { 683 - margin: 1rem 0; 684 - padding: 1rem; 685 - background: var(--bg-secondary); 686 - border-radius: var(--radius); 687 - display: flex; 688 - flex-direction: column; 689 - gap: 1rem; 690 - transition: all 0.3s ease; 691 - transform-origin: top; 692 - } 693 - 694 - .simple-settings.hidden { 695 - display: none; 696 - } 697 - 698 - .settings-row { 699 - display: flex; 700 - align-items: center; 701 - gap: 1rem; 702 - } 703 - 704 - .settings-row label { 705 - min-width: 60px; 706 - color: var(--text-secondary); 707 - font-size: 0.9rem; 708 - } 709 - 710 - .button-group { 711 - display: flex; 712 - gap: 0.25rem; 713 - } 714 - 715 - .font-btn { 716 - padding: 0.25rem 0.75rem; 717 - background: transparent; 718 - border: 1px solid var(--border-color); 719 - border-radius: var(--radius-sm); 720 - color: var(--text-secondary); 721 - cursor: pointer; 722 - transition: all 0.2s; 723 - font-size: 0.85rem; 724 - } 725 - 726 - .font-btn:hover { 727 - border-color: var(--accent); 728 - color: var(--text-primary); 729 - } 730 - 731 - .font-btn.active { 732 - background: var(--accent); 733 - border-color: var(--accent); 734 - color: white; 735 - } 736 - 737 - #accent-color { 738 - width: 50px; 739 - height: 32px; 740 - border: 2px solid var(--border-color); 741 - border-radius: var(--radius-sm); 742 - cursor: pointer; 743 - transition: border-color 0.2s, box-shadow 0.2s; 744 - } 745 - 746 - #accent-color:hover { 747 - border-color: var(--accent); 748 - } 749 - 750 - #accent-color:focus { 751 - outline: none; 752 - border-color: var(--accent); 753 - box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1); 754 - } 755 - 756 - .preset-colors { 757 - display: flex; 758 - gap: 0.25rem; 759 - } 760 - 761 - .color-preset { 762 - width: 24px; 763 - height: 24px; 764 - border: 2px solid transparent; 765 - border-radius: var(--radius-sm); 766 - cursor: pointer; 767 - transition: all 0.2s; 768 - } 769 - 770 - .color-preset:hover { 771 - transform: scale(1.2); 772 - border-color: var(--text-primary); 773 - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 774 - } 775 - 776 - .color-preset.active { 777 - border-color: var(--accent); 778 - box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.2); 779 - } 780 - 781 - /* Settings toggle button */ 782 - .settings-toggle { 783 - background: var(--bg-secondary); 784 - border: 1px solid var(--border-color); 785 - border-radius: var(--radius-sm); 786 - padding: 0.5rem; 787 - cursor: pointer; 788 - display: flex; 789 - align-items: center; 790 - justify-content: center; 791 - transition: all 0.2s; 792 - } 793 - 794 - .settings-toggle:hover { 795 - background: var(--bg-tertiary); 796 - border-color: var(--accent); 797 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1); 798 - } 799 - 800 - .settings-icon { 801 - filter: invert(50%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) contrast(100%); 802 - transition: filter 0.2s; 803 - } 804 - 805 - .settings-toggle:hover .settings-icon { 806 - filter: invert(50%) sepia(100%) saturate(500%) hue-rotate(190deg) brightness(100%) contrast(100%); 807 - } 808 - 809 - .theme-toggle { 810 - background: var(--bg-secondary); 811 - border: 1px solid var(--border-color); 812 - border-radius: var(--radius-sm); 813 - padding: 0.5rem; 814 - cursor: pointer; 815 - display: flex; 816 - align-items: center; 817 - justify-content: center; 818 - transition: all 0.2s; 819 - } 820 - 821 - .theme-toggle:hover { 822 - background: var(--bg-tertiary); 823 - border-color: var(--accent); 824 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.1); 825 - } 826 - 827 - .theme-toggle { 828 - position: relative; 829 - } 830 - 831 - .theme-toggle svg { 832 - stroke: var(--accent); 833 - } 834 - 835 - .theme-indicator { 836 - position: absolute; 837 - top: calc(100% + 0.5rem); 838 - right: 0; 839 - background: var(--bg-secondary); 840 - border: 1px solid var(--border-color); 841 - border-radius: var(--radius-sm); 842 - padding: 0.25rem 0.5rem; 843 - font-size: 0.75rem; 844 - color: var(--text-secondary); 845 - white-space: nowrap; 846 - opacity: 0; 847 - pointer-events: none; 848 - transition: opacity 0.2s; 849 - z-index: 1000; 850 - } 851 - 852 - .theme-indicator.visible { 853 - opacity: 1; 854 - } 855 - 856 - .sun-icon, .moon-icon { 857 - display: none; 858 - } 859 - 860 - [data-theme="light"] .sun-icon { 861 - display: block; 862 - stroke: #f39c12; 863 - } 864 - 865 - [data-theme="dark"] .moon-icon { 866 - display: block; 867 - stroke: #8e44ad; 868 - } 869 - 870 - /* Status Display */ 871 - .status-display { 872 - background: var(--bg-secondary); 873 - border: 1px solid var(--border-color); 874 - border-radius: var(--radius); 875 - padding: 2rem; 876 - margin-bottom: 1.5rem; 877 - text-align: center; 878 - } 879 - 880 - .current-status, .no-status { 881 - display: flex; 882 - flex-direction: column; 883 - align-items: center; 884 - gap: 1rem; 885 - } 886 - 887 - @keyframes subtle-pulse { 888 - 0%, 100% { 889 - transform: scale(1); 890 - filter: drop-shadow(0 0 0 transparent); 891 - } 892 - 50% { 893 - transform: scale(1.01); 894 - filter: drop-shadow(0 0 6px var(--accent)); 895 - } 896 - } 897 - 898 - .status-emoji { 899 - width: 3.5rem; 900 - height: 3.5rem; 901 - font-size: 3.5rem; 902 - line-height: 1; 903 - display: flex; 904 - align-items: center; 905 - justify-content: center; 906 - position: relative; 907 - } 908 - 909 - .current-status .status-emoji { 910 - animation: subtle-pulse 4s ease-in-out infinite; 911 - } 912 - 913 - .custom-emoji-display { 914 - width: 100%; 915 - height: 100%; 916 - object-fit: contain; 917 - /* Ensure GIFs animate */ 918 - image-rendering: auto; 919 - } 920 - 921 - .status-content { 922 - display: flex; 923 - flex-direction: column; 924 - gap: 0.25rem; 925 - } 926 - 927 - .status-text { 928 - font-size: 1.25rem; 929 - color: var(--text-primary); 930 - margin: 0; 931 - } 932 - 933 - .status-text a { 934 - color: var(--accent); 935 - text-decoration: underline; 936 - text-underline-offset: 2px; 937 - } 938 - 939 - .no-status .status-text { 940 - color: var(--text-tertiary); 941 - } 942 - 943 - .status-meta { 944 - font-size: 0.875rem; 945 - color: var(--text-secondary); 946 - margin: 0; 947 - } 948 - 949 - .expires-indicator { 950 - color: var(--accent); 951 - font-weight: 500; 952 - position: relative; 953 - } 954 - 955 - .expires-indicator::before { 956 - content: '⏱'; 957 - margin-right: 0.25rem; 958 - opacity: 0.7; 959 - } 960 - 961 - /* Status Editor */ 962 - .status-editor { 963 - margin-bottom: 1.5rem; 964 - } 965 - 966 - .input-group { 967 - display: flex; 968 - gap: 0.5rem; 969 - align-items: center; 970 - background: var(--bg-secondary); 971 - border: 1px solid var(--border-color); 972 - border-radius: var(--radius); 973 - padding: 0.5rem; 974 - transition: border-color 0.2s; 975 - } 976 - 977 - /* Status text row - keeps emoji and input together */ 978 - .status-text-row { 979 - display: flex; 980 - gap: 0.5rem; 981 - align-items: center; 982 - flex: 1; 983 - } 984 - 985 - .input-group:focus-within { 986 - border-color: var(--accent); 987 - box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.1); 988 - } 989 - 990 - .emoji-trigger { 991 - background: transparent; 992 - border: none; 993 - font-size: 1.75rem; 994 - cursor: pointer; 995 - padding: 0.25rem; 996 - border-radius: var(--radius-sm); 997 - transition: background 0.2s; 998 - flex-shrink: 0; 999 - display: flex; 1000 - align-items: center; 1001 - justify-content: center; 1002 - width: 3rem; 1003 - height: 3rem; 1004 - overflow: hidden; 1005 - } 1006 - 1007 - .emoji-trigger img { 1008 - max-width: 100%; 1009 - max-height: 100%; 1010 - object-fit: contain; 1011 - } 1012 - 1013 - .emoji-trigger:hover { 1014 - background: var(--bg-tertiary); 1015 - } 1016 - 1017 - #status-text { 1018 - flex: 1; 1019 - background: transparent; 1020 - border: none; 1021 - font-size: 1rem; 1022 - color: var(--text-primary); 1023 - outline: none; 1024 - } 1025 - 1026 - #status-text::placeholder { 1027 - color: var(--text-tertiary); 1028 - } 1029 - 1030 - .input-actions { 1031 - display: flex; 1032 - gap: 0.5rem; 1033 - align-items: center; 1034 - } 1035 - 1036 - .clear-after-btn { 1037 - display: flex; 1038 - align-items: center; 1039 - gap: 0.25rem; 1040 - background: transparent; 1041 - border: 1px solid var(--border-color); 1042 - border-radius: var(--radius-sm); 1043 - padding: 0.5rem 0.75rem; 1044 - font-size: 0.875rem; 1045 - color: var(--text-secondary); 1046 - cursor: pointer; 1047 - transition: all 0.2s; 1048 - } 1049 - 1050 - .clear-after-btn:hover { 1051 - background: var(--bg-tertiary); 1052 - border-color: var(--accent); 1053 - color: var(--accent); 1054 - } 1055 - 1056 - .save-btn { 1057 - background: var(--accent); 1058 - color: white; 1059 - border: none; 1060 - border-radius: var(--radius-sm); 1061 - padding: 0.5rem 1.25rem; 1062 - font-size: 0.875rem; 1063 - font-weight: 500; 1064 - cursor: pointer; 1065 - transition: all 0.2s; 1066 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 1067 - } 1068 - 1069 - .save-btn:hover { 1070 - background: var(--accent-hover); 1071 - transform: translateY(-1px); 1072 - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); 1073 - } 1074 - 1075 - .save-btn:disabled { 1076 - opacity: 0.5; 1077 - cursor: not-allowed; 1078 - } 1079 - 1080 - /* Emoji Picker */ 1081 - .emoji-picker-overlay { 1082 - position: fixed; 1083 - top: 0; 1084 - right: 0; 1085 - bottom: 0; 1086 - left: 0; 1087 - inset: 0; /* Modern browsers */ 1088 - background: rgba(6, 6, 8, 0.75); 1089 - backdrop-filter: blur(6px); 1090 - display: flex; 1091 - align-items: center; 1092 - justify-content: center; 1093 - padding: clamp(1rem, 6vw, 2.5rem); 1094 - z-index: 1400; 1095 - } 1096 - 1097 - .emoji-picker-overlay.hidden { 1098 - display: none; 1099 - } 1100 - 1101 - .emoji-picker { 1102 - position: relative; 1103 - z-index: 1; 1104 - width: min(960px, 94vw); 1105 - height: min(90vh, 820px); 1106 - background: var(--bg-secondary); 1107 - border: 1px solid var(--border-color); 1108 - border-radius: clamp(var(--radius), 2vw, 24px); 1109 - box-shadow: 0 32px 80px rgba(0, 0, 0, 0.45); 1110 - display: flex; 1111 - flex-direction: column; 1112 - gap: 1.25rem; 1113 - padding: clamp(1.25rem, 5vw, 2.5rem); 1114 - overflow: hidden; 1115 - } 1116 - 1117 - 1118 - .emoji-picker-header { 1119 - display: flex; 1120 - align-items: flex-start; 1121 - justify-content: space-between; 1122 - gap: 1rem; 1123 - } 1124 - 1125 - .emoji-picker-header h2 { 1126 - margin: 0; 1127 - font-size: 1.5rem; 1128 - color: var(--text-primary); 1129 - } 1130 - 1131 - .emoji-picker-subtitle { 1132 - margin: 0.25rem 0 0; 1133 - font-size: 0.875rem; 1134 - color: var(--text-tertiary); 1135 - } 1136 - 1137 - .emoji-picker-close { 1138 - border: 1px solid var(--border-color); 1139 - background: var(--bg-tertiary); 1140 - color: var(--text-secondary); 1141 - border-radius: 999px; 1142 - width: 2.25rem; 1143 - height: 2.25rem; 1144 - display: flex; 1145 - align-items: center; 1146 - justify-content: center; 1147 - cursor: pointer; 1148 - transition: all 0.2s ease; 1149 - } 1150 - 1151 - .emoji-picker-close:hover, 1152 - .emoji-picker-close:focus-visible { 1153 - color: white; 1154 - background: var(--accent); 1155 - border-color: transparent; 1156 - outline: none; 1157 - } 1158 - 1159 - .emoji-search-container { 1160 - margin: 0; 1161 - } 1162 - 1163 - .emoji-search { 1164 - width: 100%; 1165 - padding: 0.75rem 1rem; 1166 - background: var(--bg-primary); 1167 - border: 1px solid var(--border-color); 1168 - border-radius: var(--radius); 1169 - color: var(--text-primary); 1170 - font-size: 0.95rem; 1171 - outline: none; 1172 - transition: border-color 0.2s ease, box-shadow 0.2s ease; 1173 - } 1174 - 1175 - .emoji-search:focus { 1176 - border-color: var(--accent); 1177 - box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.12); 1178 - } 1179 - 1180 - .emoji-search::placeholder { 1181 - color: var(--text-tertiary); 1182 - } 1183 - 1184 - .emoji-picker-body { 1185 - flex: 1; 1186 - display: flex; 1187 - flex-direction: column; 1188 - gap: 0.75rem; 1189 - min-height: 0; 1190 - } 1191 - 1192 - .emoji-categories { 1193 - display: flex; 1194 - gap: 0.75rem; 1195 - overflow-x: auto; 1196 - padding-bottom: 0.5rem; 1197 - border-bottom: 1px solid rgba(255, 255, 255, 0.06); 1198 - scrollbar-width: none; 1199 - } 1200 - 1201 - .emoji-categories.hidden { 1202 - display: none; 1203 - } 1204 - 1205 - .emoji-categories::-webkit-scrollbar { 1206 - display: none; 1207 - } 1208 - 1209 - .category-btn { 1210 - background: transparent; 1211 - border: 1px solid transparent; 1212 - color: var(--text-secondary); 1213 - font-size: 0.8rem; 1214 - padding: 0.4rem 0.75rem; 1215 - cursor: pointer; 1216 - white-space: nowrap; 1217 - border-radius: 999px; 1218 - transition: all 0.2s ease; 1219 - } 1220 - 1221 - .category-btn:hover, 1222 - .category-btn:focus-visible { 1223 - border-color: var(--accent); 1224 - color: var(--accent); 1225 - outline: none; 1226 - } 1227 - 1228 - .category-btn.active { 1229 - background: var(--accent); 1230 - color: white; 1231 - border-color: transparent; 1232 - } 1233 - 1234 - .emoji-grid { 1235 - flex: 1; 1236 - display: grid; 1237 - grid-template-columns: repeat(auto-fill, minmax(min(84px, 18vw), 1fr)); 1238 - gap: 0.75rem; 1239 - padding-right: 0.25rem; 1240 - overflow-y: auto; 1241 - scrollbar-width: none; 1242 - } 1243 - 1244 - .emoji-grid::-webkit-scrollbar { 1245 - display: none; 1246 - } 1247 - 1248 - .emoji-option { 1249 - background: transparent; 1250 - border: none; 1251 - font-size: 2.4rem; 1252 - cursor: pointer; 1253 - transition: transform 0.15s ease; 1254 - display: flex; 1255 - align-items: center; 1256 - justify-content: center; 1257 - width: 100%; 1258 - aspect-ratio: 1; 1259 - } 1260 - 1261 - .emoji-option:hover { 1262 - transform: translateY(-3px) scale(1.05); 1263 - } 1264 - 1265 - .emoji-option:focus-visible { 1266 - outline: 2px solid var(--accent); 1267 - outline-offset: 6px; 1268 - border-radius: 16px; 1269 - } 1270 - 1271 - .emoji-option.custom-emoji img { 1272 - width: 75%; 1273 - height: 75%; 1274 - object-fit: contain; 1275 - } 1276 - 1277 - .emoji-grid-placeholder, 1278 - .emoji-loading-message { 1279 - text-align: center; 1280 - color: var(--text-tertiary); 1281 - padding: 2rem 1rem; 1282 - font-size: 0.95rem; 1283 - } 1284 - 1285 - .bufo-helper { 1286 - text-align: center; 1287 - padding: 1rem; 1288 - border-top: 1px solid var(--border); 1289 - } 1290 - 1291 - .bufo-helper a { 1292 - color: var(--text-secondary); 1293 - text-decoration: none; 1294 - font-size: 0.9rem; 1295 - transition: color 0.2s ease; 1296 - } 1297 - 1298 - .bufo-helper a:hover { 1299 - color: var(--accent); 1300 - text-decoration: underline; 1301 - } 1302 - 1303 - .emoji-picker-overlay::before { 1304 - content: ''; 1305 - position: fixed; 1306 - top: 0; 1307 - right: 0; 1308 - bottom: 0; 1309 - left: 0; 1310 - } 1311 - 1312 - @media (max-width: 640px) { 1313 - .emoji-picker-overlay { 1314 - padding: 0; 1315 - align-items: stretch; 1316 - } 1317 - 1318 - .emoji-picker { 1319 - width: 100%; 1320 - height: 100%; 1321 - max-height: none; 1322 - border-radius: 0; 1323 - padding: 1.5rem 1rem; 1324 - gap: 1rem; 1325 - } 1326 - 1327 - .emoji-grid { 1328 - grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); 1329 - gap: 0.5rem; 1330 - } 1331 - } 1332 - 1333 - @media (prefers-reduced-motion: reduce) { 1334 - .emoji-picker-overlay { 1335 - backdrop-filter: none; 1336 - background: rgba(6, 6, 8, 0.85); 1337 - } 1338 - 1339 - .emoji-picker, 1340 - .emoji-option { 1341 - transition: none; 1342 - } 1343 - } 1344 - 1345 - /* Clear Picker */ 1346 - .clear-picker { 1347 - position: absolute; 1348 - background: var(--bg-secondary); 1349 - border: 1px solid var(--border-color); 1350 - border-radius: var(--radius); 1351 - padding: 0.75rem; 1352 - box-shadow: var(--shadow-md); 1353 - z-index: 1000; 1354 - display: flex; 1355 - flex-direction: column; 1356 - gap: 0.375rem; 1357 - min-width: 180px; 1358 - } 1359 - 1360 - .clear-option { 1361 - background: transparent; 1362 - border: none; 1363 - color: var(--text-primary); 1364 - font-size: 0.875rem; 1365 - padding: 0.75rem 1.25rem; 1366 - text-align: left; 1367 - cursor: pointer; 1368 - border-radius: var(--radius-sm); 1369 - transition: background 0.2s; 1370 - white-space: nowrap; 1371 - } 1372 - 1373 - .clear-option:hover { 1374 - background: var(--bg-tertiary); 1375 - color: var(--accent); 1376 - } 1377 - 1378 - .clear-option.active { 1379 - background: var(--accent); 1380 - color: white; 1381 - } 1382 - 1383 - /* Custom datetime input */ 1384 - .custom-datetime { 1385 - padding: 0.75rem; 1386 - border-top: 1px solid var(--border-color); 1387 - display: flex; 1388 - gap: 0.5rem; 1389 - align-items: center; 1390 - } 1391 - 1392 - .custom-datetime input { 1393 - flex: 1; 1394 - padding: 0.5rem; 1395 - background: var(--bg-primary); 1396 - border: 1px solid var(--border-color); 1397 - border-radius: var(--radius-sm); 1398 - color: var(--text-primary); 1399 - font-size: 0.875rem; 1400 - font-family: inherit; 1401 - } 1402 - 1403 - .custom-datetime-set { 1404 - padding: 0.5rem 1rem; 1405 - background: var(--accent); 1406 - color: white; 1407 - border: none; 1408 - border-radius: var(--radius-sm); 1409 - font-size: 0.875rem; 1410 - cursor: pointer; 1411 - transition: background 0.2s; 1412 - font-family: inherit; 1413 - } 1414 - 1415 - .custom-datetime-set:hover { 1416 - background: var(--accent-hover); 1417 - } 1418 - 1419 - /* Session Info */ 1420 - .session-info { 1421 - text-align: center; 1422 - margin: 2rem 0; 1423 - } 1424 - 1425 - .logout-link { 1426 - color: var(--text-tertiary); 1427 - text-decoration: none; 1428 - font-size: 0.875rem; 1429 - transition: color 0.2s; 1430 - } 1431 - 1432 - .logout-link:hover { 1433 - color: var(--accent); 1434 - } 1435 - 1436 - /* History */ 1437 - .history { 1438 - margin: 2rem 0; 1439 - padding-top: 2rem; 1440 - border-top: 1px solid var(--border-color); 1441 - } 1442 - 1443 - .history h3 { 1444 - font-size: 0.875rem; 1445 - font-weight: 500; 1446 - color: var(--text-secondary); 1447 - margin-bottom: 1rem; 1448 - text-transform: uppercase; 1449 - letter-spacing: 0.05em; 1450 - } 1451 - 1452 - .history-item { 1453 - display: flex; 1454 - align-items: center; 1455 - gap: 0.75rem; 1456 - padding: 0.5rem 0; 1457 - position: relative; 1458 - } 1459 - 1460 - .history-emoji { 1461 - width: 1.5rem; 1462 - height: 1.5rem; 1463 - font-size: 1.25rem; 1464 - display: flex; 1465 - align-items: center; 1466 - justify-content: center; 1467 - flex-shrink: 0; 1468 - } 1469 - 1470 - .history-emoji .custom-emoji-display { 1471 - width: 100%; 1472 - height: 100%; 1473 - } 1474 - 1475 - .history-content { 1476 - display: flex; 1477 - flex-direction: column; 1478 - gap: 0.25rem; 1479 - flex: 1; 1480 - min-width: 0; 1481 - } 1482 - 1483 - .history-text { 1484 - color: var(--text-primary); 1485 - font-size: 0.875rem; 1486 - overflow: hidden; 1487 - text-overflow: ellipsis; 1488 - white-space: nowrap; 1489 - } 1490 - 1491 - .history-text a { 1492 - color: var(--accent); 1493 - text-decoration: underline; 1494 - text-underline-offset: 2px; 1495 - } 1496 - 1497 - .history-time { 1498 - color: var(--text-tertiary); 1499 - font-size: 0.75rem; 1500 - } 1501 - 1502 - .history-delete { 1503 - background: transparent; 1504 - border: none; 1505 - color: var(--text-tertiary); 1506 - cursor: pointer; 1507 - padding: 0.25rem; 1508 - border-radius: var(--radius-sm); 1509 - opacity: 0; 1510 - transition: all 0.2s; 1511 - } 1512 - 1513 - .history-item:hover .history-delete { 1514 - opacity: 1; 1515 - } 1516 - 1517 - .history-delete:hover { 1518 - background: var(--bg-tertiary); 1519 - color: var(--danger); 1520 - } 1521 - 1522 - /* Bottom Navigation */ 1523 - .bottom-nav { 1524 - display: flex; 1525 - justify-content: center; 1526 - gap: 1rem; 1527 - padding-top: 2rem; 1528 - margin-top: 2rem; 1529 - border-top: 1px solid var(--border-color); 1530 - } 1531 - 1532 - .nav-button-bottom { 1533 - display: flex; 1534 - align-items: center; 1535 - gap: 0.5rem; 1536 - padding: 0.75rem 1.25rem; 1537 - background: var(--bg-secondary); 1538 - border: 1px solid var(--border-color); 1539 - border-radius: var(--radius-sm); 1540 - color: var(--text-secondary); 1541 - text-decoration: none; 1542 - transition: all 0.2s; 1543 - font-size: 0.875rem; 1544 - } 1545 - 1546 - .nav-button-bottom:hover { 1547 - background: var(--bg-tertiary); 1548 - border-color: var(--accent); 1549 - color: var(--accent); 1550 - } 1551 - 1552 - .nav-button-bottom svg { 1553 - stroke: currentColor; 1554 - flex-shrink: 0; 1555 - } 1556 - 1557 - /* Mobile adjustments */ 1558 - @media (max-width: 640px) { 1559 - #root { 1560 - padding: 1rem; 1561 - } 1562 - 1563 - .container { 1564 - max-width: 100%; 1565 - } 1566 - 1567 - .header h1 { 1568 - font-size: 1.25rem; 1569 - } 1570 - 1571 - .status-emoji { 1572 - font-size: 3rem; 1573 - } 1574 - 1575 - /* Better mobile layout for status input */ 1576 - .input-group { 1577 - flex-direction: column; 1578 - gap: 0.75rem; 1579 - padding: 0.75rem; 1580 - } 1581 - 1582 - /* Status text row - keep emoji and input on same line */ 1583 - .status-text-row { 1584 - display: flex; 1585 - gap: 0.5rem; 1586 - align-items: center; 1587 - width: 100%; 1588 - } 1589 - 1590 - .emoji-trigger { 1591 - font-size: 1.5rem; 1592 - width: 2.5rem; 1593 - height: 2.5rem; 1594 - flex-shrink: 0; 1595 - } 1596 - 1597 - #status-text { 1598 - flex: 1; 1599 - min-width: 0; 1600 - padding: 0.5rem; 1601 - font-size: 1rem; 1602 - } 1603 - 1604 - .input-actions { 1605 - width: 100%; 1606 - justify-content: space-between; 1607 - gap: 0.5rem; 1608 - } 1609 - 1610 - .clear-after-btn { 1611 - flex: 1; 1612 - justify-content: center; 1613 - } 1614 - 1615 - .clear-after-btn span { 1616 - display: inline-block; 1617 - max-width: 100%; 1618 - overflow: hidden; 1619 - text-overflow: ellipsis; 1620 - white-space: nowrap; 1621 - } 1622 - 1623 - .save-btn { 1624 - min-width: 80px; 1625 - } 1626 - 1627 - .emoji-picker { 1628 - width: calc(100vw - 2rem); 1629 - left: 1rem; 1630 - right: 1rem; 1631 - max-width: none; 1632 - } 1633 - 1634 - .clear-picker { 1635 - width: calc(100vw - 2rem); 1636 - left: 1rem !important; 1637 - right: 1rem; 1638 - max-width: none; 1639 - } 1640 - 1641 - /* History section improvements */ 1642 - .history { 1643 - padding: 1.5rem 0; 1644 - } 1645 - 1646 - .history h3 { 1647 - font-size: 0.75rem; 1648 - text-align: center; 1649 - margin-bottom: 1.25rem; 1650 - } 1651 - 1652 - .history-item { 1653 - padding: 0.75rem 0; 1654 - border-bottom: 1px solid var(--border-color); 1655 - } 1656 - 1657 - .history-item:last-child { 1658 - border-bottom: none; 1659 - } 1660 - } 1661 - /* Webhook Modal */ 1662 - .webhook-modal.hidden { display: none; } 1663 - .webhook-modal { 1664 - position: fixed; 1665 - top: 0; 1666 - right: 0; 1667 - bottom: 0; 1668 - left: 0; 1669 - inset: 0; /* Modern browsers */ 1670 - background: rgba(0,0,0,0.6); 1671 - z-index: 1000; 1672 - display: flex; 1673 - align-items: center; 1674 - justify-content: center; 1675 - } 1676 - .webhook-modal-content { 1677 - width: 96vw; 1678 - height: 92vh; 1679 - max-width: 1400px; 1680 - max-height: 92vh; 1681 - background: var(--bg, #111); 1682 - color: var(--text, #fff); 1683 - border: 1px solid var(--border-color, #2a2a2a); 1684 - border-radius: 12px; 1685 - display: flex; 1686 - flex-direction: column; 1687 - } 1688 - .webhook-modal-header { 1689 - display: flex; 1690 - align-items: center; 1691 - justify-content: space-between; 1692 - padding: 16px 20px; 1693 - border-bottom: 1px solid var(--border-color, #2a2a2a); 1694 - } 1695 - .webhook-modal-header h2 { margin: 0; font-size: 20px; } 1696 - .webhook-modal-header button { 1697 - background: transparent; 1698 - color: inherit; 1699 - border: 1px solid var(--border-color, #2a2a2a); 1700 - border-radius: 8px; 1701 - padding: 6px 10px; 1702 - cursor: pointer; 1703 - } 1704 - .webhook-modal-body { 1705 - padding: 16px 20px; 1706 - overflow: auto; 1707 - height: calc(92vh - 60px); 1708 - } 1709 - .webhook-form { 1710 - display: grid; 1711 - grid-template-columns: 1.2fr 0.8fr 1fr auto; 1712 - gap: 8px; 1713 - margin-bottom: 16px; 1714 - } 1715 - .webhook-form input { 1716 - background: var(--bg-secondary, #0d0d0d); 1717 - color: var(--text, #fff); 1718 - border: 1px solid var(--border-color, #2a2a2a); 1719 - border-radius: 8px; 1720 - padding: 10px 12px; 1721 - } 1722 - .webhook-form button { 1723 - background: var(--accent, #1DA1F2); 1724 - color: #000; 1725 - border: none; 1726 - border-radius: 8px; 1727 - padding: 10px 12px; 1728 - cursor: pointer; 1729 - } 1730 - .field-help { font-size: 12px; opacity: 0.8; margin-top: 2px; grid-column: 1 / -1; } 1731 - .webhook-list .item { 1732 - border: 1px solid var(--border-color, #2a2a2a); 1733 - border-radius: 8px; 1734 - padding: 12px; 1735 - margin-bottom: 10px; 1736 - display: grid; 1737 - grid-template-columns: 1fr auto; 1738 - gap: 8px; 1739 - } 1740 - .webhook-list .meta { font-size: 12px; opacity: 0.8; } 1741 - .webhook-actions { display: flex; gap: 8px; align-items: center; } 1742 - .webhook-actions button { 1743 - background: transparent; 1744 - color: inherit; 1745 - border: 1px solid var(--border-color, #2a2a2a); 1746 - border-radius: 8px; 1747 - padding: 6px 10px; 1748 - cursor: pointer; 1749 - } 1750 - .webhook-actions .danger { border-color: #803; color: #f77; } 1751 - .webhook-active { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } 1752 - .webhook-active input { transform: translateY(1px); } 1753 - 1754 - /* Collapsible guide */ 1755 - .wh-guide { 1756 - margin-top: 20px; 1757 - border: 1px solid var(--border-color, #2a2a2a); 1758 - border-radius: 10px; 1759 - overflow: hidden; 1760 - } 1761 - .wh-guide summary { 1762 - padding: 12px 14px; 1763 - cursor: pointer; 1764 - background: var(--bg-secondary, #0f0f0f); 1765 - font-weight: 600; 1766 - outline: none; 1767 - } 1768 - .wh-guide .content { padding: 14px; } 1769 - .wh-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } 1770 - .wh-grid pre { background: #0b0b0b; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; } 1771 - @media (max-width: 900px) { .wh-grid { grid-template-columns: 1fr; } } 1772 - </style> 1773 - 1774 - <script> 1775 - // Theme management 1776 - const initTheme = () => { 1777 - const saved = localStorage.getItem('theme'); 1778 - const theme = saved || 'system'; 1779 - 1780 - if (theme === 'system') { 1781 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 1782 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 1783 - } else { 1784 - document.body.setAttribute('data-theme', theme); 1785 - } 1786 - }; 1787 - 1788 - const toggleTheme = () => { 1789 - const saved = localStorage.getItem('theme') || 'system'; 1790 - const themes = ['system', 'light', 'dark']; 1791 - const currentIndex = themes.indexOf(saved); 1792 - const next = themes[(currentIndex + 1) % themes.length]; 1793 - 1794 - localStorage.setItem('theme', next); 1795 - 1796 - if (next === 'system') { 1797 - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 1798 - document.body.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); 1799 - } else { 1800 - document.body.setAttribute('data-theme', next); 1801 - } 1802 - 1803 - // Show theme indicator 1804 - const indicator = document.getElementById('theme-indicator'); 1805 - if (indicator) { 1806 - indicator.textContent = next; 1807 - indicator.classList.add('visible'); 1808 - setTimeout(() => { 1809 - indicator.classList.remove('visible'); 1810 - }, 1500); 1811 - } 1812 - }; 1813 - 1814 - // Dynamic emoji data - will be loaded from CDN 1815 - let emojiKeywords = {}; 1816 - let emojiData = { 1817 - frequent: [], 1818 - people: [], 1819 - nature: [], 1820 - food: [], 1821 - activity: [], 1822 - travel: [], 1823 - objects: [], 1824 - symbols: [], 1825 - custom: [] 1826 - }; 1827 - // Initialize on page load 1828 - // Timestamp formatting is handled by /static/timestamps.js 1829 - 1830 - document.addEventListener('DOMContentLoaded', async () => { 1831 - initTheme(); 1832 - // Timestamps are auto-initialized by timestamps.js 1833 - 1834 - // Simple settings 1835 - const initSettings = async () => { 1836 - // Try to load from API first, fall back to localStorage 1837 - let savedFont = localStorage.getItem('fontFamily') || 'mono'; 1838 - let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 1839 - 1840 - // If user is logged in, fetch from API 1841 - const isOwner = document.querySelector('.settings-toggle'); 1842 - if (isOwner) { 1843 - try { 1844 - const response = await fetch('/api/preferences'); 1845 - if (response.ok) { 1846 - const data = await response.json(); 1847 - if (!data.error) { 1848 - savedFont = data.font_family || savedFont; 1849 - savedAccent = data.accent_color || savedAccent; 1850 - // Sync to localStorage 1851 - localStorage.setItem('fontFamily', savedFont); 1852 - localStorage.setItem('accentColor', savedAccent); 1853 - } 1854 - } 1855 - } catch (err) { 1856 - console.log('Using localStorage preferences'); 1857 - } 1858 - } 1859 - 1860 - // Apply font family 1861 - const fontMap = { 1862 - 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 1863 - 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 1864 - 'serif': 'ui-serif, Georgia, Cambria, serif', 1865 - 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1866 - }; 1867 - document.documentElement.style.setProperty('--font-family', fontMap[savedFont] || fontMap.system); 1868 - 1869 - // Update buttons 1870 - document.querySelectorAll('.font-btn').forEach(btn => { 1871 - btn.classList.toggle('active', btn.dataset.font === savedFont); 1872 - }); 1873 - 1874 - // Apply accent color 1875 - document.documentElement.style.setProperty('--accent', savedAccent); 1876 - const accentInput = document.getElementById('accent-color'); 1877 - if (accentInput) { 1878 - accentInput.value = savedAccent; 1879 - } 1880 - 1881 - // Return the loaded values for use elsewhere 1882 - return { savedFont, savedAccent }; 1883 - }; 1884 - 1885 - // Settings toggle 1886 - const settingsToggle = document.getElementById('settings-toggle'); 1887 - const settingsPanel = document.getElementById('simple-settings'); 1888 - if (settingsToggle && settingsPanel) { 1889 - settingsToggle.addEventListener('click', () => { 1890 - settingsPanel.classList.toggle('hidden'); 1891 - }); 1892 - } 1893 - 1894 - // Helper to save preferences to API 1895 - const savePreferencesToAPI = async (updates) => { 1896 - try { 1897 - await fetch('/api/preferences', { 1898 - method: 'POST', 1899 - headers: { 'Content-Type': 'application/json' }, 1900 - body: JSON.stringify(updates) 1901 - }); 1902 - } catch (err) { 1903 - console.log('Failed to save preferences to server'); 1904 - } 1905 - }; 1906 - 1907 - // Font family buttons 1908 - document.querySelectorAll('.font-btn').forEach(btn => { 1909 - btn.addEventListener('click', () => { 1910 - const font = btn.dataset.font; 1911 - localStorage.setItem('fontFamily', font); 1912 - 1913 - // Update UI 1914 - document.querySelectorAll('.font-btn').forEach(b => b.classList.remove('active')); 1915 - btn.classList.add('active'); 1916 - 1917 - // Apply 1918 - const fontMap = { 1919 - 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 1920 - 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 1921 - 'serif': 'ui-serif, Georgia, Cambria, serif', 1922 - 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1923 - }; 1924 - document.documentElement.style.setProperty('--font-family', fontMap[font] || fontMap.system); 1925 - 1926 - // Save to API if logged in 1927 - if (document.querySelector('.settings-toggle')) { 1928 - savePreferencesToAPI({ font_family: font }); 1929 - } 1930 - }); 1931 - }); 1932 - 1933 - // Accent color 1934 - const accentInput = document.getElementById('accent-color'); 1935 - if (accentInput) { 1936 - accentInput.addEventListener('input', () => { 1937 - const color = accentInput.value; 1938 - localStorage.setItem('accentColor', color); 1939 - document.documentElement.style.setProperty('--accent', color); 1940 - updateActivePreset(color); 1941 - 1942 - // Save to API if logged in 1943 - if (document.querySelector('.settings-toggle')) { 1944 - savePreferencesToAPI({ accent_color: color }); 1945 - } 1946 - }); 1947 - } 1948 - 1949 - // Function to update active preset 1950 - const updateActivePreset = (currentColor) => { 1951 - document.querySelectorAll('.color-preset').forEach(preset => { 1952 - preset.classList.toggle('active', preset.dataset.color.toLowerCase() === currentColor.toLowerCase()); 1953 - }); 1954 - }; 1955 - 1956 - // Color presets 1957 - document.querySelectorAll('.color-preset').forEach(btn => { 1958 - btn.addEventListener('click', () => { 1959 - const color = btn.dataset.color; 1960 - localStorage.setItem('accentColor', color); 1961 - document.documentElement.style.setProperty('--accent', color); 1962 - if (accentInput) { 1963 - accentInput.value = color; 1964 - } 1965 - 1966 - updateActivePreset(color); 1967 - 1968 - // Save to API if logged in 1969 - if (document.querySelector('.settings-toggle')) { 1970 - savePreferencesToAPI({ accent_color: color }); 1971 - } 1972 - }); 1973 - }); 1974 - 1975 - // Initialize settings first, then set active preset 1976 - const { savedFont, savedAccent } = await initSettings(); 1977 - 1978 - // Set initial active preset after settings are loaded with the actual loaded accent 1979 - updateActivePreset(savedAccent); 1980 - 1981 - // Load emoji data 1982 - if (window.emojiDataLoader) { 1983 - const data = await window.emojiDataLoader.loadEmojiData(); 1984 - emojiKeywords = data.emojis; 1985 - window.__emojiSlugs = data.slugs || {}; 1986 - window.__reservedEmojiNames = new Set(data.reserved || []); 1987 - 1988 - // Webhook modal handlers (owner only) 1989 - const openWebhookBtn = document.getElementById('open-webhook-config'); 1990 - const modal = document.getElementById('webhook-modal'); 1991 - const closeModalBtn = document.getElementById('close-webhook-modal'); 1992 - const listEl = document.getElementById('webhook-list'); 1993 - const formEl = document.getElementById('create-webhook-form'); 1994 - 1995 - if (openWebhookBtn && modal && closeModalBtn && listEl && formEl) { 1996 - const fetchWebhooks = async () => { 1997 - const res = await fetch('/api/webhooks'); 1998 - if (!res.ok) return []; 1999 - const data = await res.json(); 2000 - return data.webhooks || []; 2001 - }; 2002 - 2003 - const renderWebhooks = (hooks) => { 2004 - if (!hooks.length) { 2005 - listEl.innerHTML = '<p class="meta">no webhooks configured yet</p>'; 2006 - return; 2007 - } 2008 - listEl.innerHTML = ''; 2009 - hooks.forEach(h => { 2010 - const item = document.createElement('div'); 2011 - item.className = 'item'; 2012 - item.innerHTML = ` 2013 - <div> 2014 - <div><strong>${h.url}</strong></div> 2015 - <div class="meta"> 2016 - events: ${h.events || '*'} • secret: ${h.secret_masked} • ${h.active ? 'active' : 'inactive'} 2017 - </div> 2018 - </div> 2019 - <div class="webhook-actions"> 2020 - <label class="webhook-active"><input type="checkbox" ${h.active ? 'checked' : ''} data-action="toggle" data-id="${h.id}"> active</label> 2021 - <button data-action="rotate" data-id="${h.id}">rotate secret</button> 2022 - <button class="danger" data-action="delete" data-id="${h.id}">delete</button> 2023 - </div> 2024 - `; 2025 - listEl.appendChild(item); 2026 - }); 2027 - }; 2028 - 2029 - const openModal = async () => { 2030 - modal.classList.remove('hidden'); 2031 - modal.setAttribute('aria-hidden', 'false'); 2032 - const hooks = await fetchWebhooks(); 2033 - renderWebhooks(hooks); 2034 - }; 2035 - 2036 - const closeModal = () => { 2037 - modal.classList.add('hidden'); 2038 - modal.setAttribute('aria-hidden', 'true'); 2039 - }; 2040 - 2041 - openWebhookBtn.addEventListener('click', openModal); 2042 - closeModalBtn.addEventListener('click', closeModal); 2043 - modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); 2044 - 2045 - formEl.addEventListener('submit', async (e) => { 2046 - e.preventDefault(); 2047 - const url = document.getElementById('wh-url').value.trim(); 2048 - const secret = document.getElementById('wh-secret').value.trim(); 2049 - const events = document.getElementById('wh-events').value.trim(); 2050 - const res = await fetch('/api/webhooks', { 2051 - method: 'POST', 2052 - headers: { 'Content-Type': 'application/json' }, 2053 - body: JSON.stringify({ url, secret: secret || null, events: events || null }) 2054 - }); 2055 - if (res.ok) { 2056 - const hooks = await fetchWebhooks(); 2057 - renderWebhooks(hooks); 2058 - formEl.reset(); 2059 - try { const data = await res.json(); if (data.secret) alert('Webhook created. Save this secret now: ' + data.secret); } catch {} 2060 - } else { 2061 - alert('Failed to create webhook'); 2062 - } 2063 - }); 2064 - 2065 - listEl.addEventListener('click', async (e) => { 2066 - const btn = e.target.closest('button'); 2067 - if (!btn) return; 2068 - const id = btn.dataset.id; 2069 - const action = btn.dataset.action; 2070 - if (action === 'delete') { 2071 - if (!confirm('Delete this webhook?')) return; 2072 - const res = await fetch(`/api/webhooks/${id}`, { method: 'DELETE' }); 2073 - if (res.ok) { 2074 - const hooks = await fetchWebhooks(); 2075 - renderWebhooks(hooks); 2076 - } 2077 - } else if (action === 'rotate') { 2078 - const res = await fetch(`/api/webhooks/${id}/rotate`, { method: 'POST' }); 2079 - if (res.ok) { 2080 - const data = await res.json(); 2081 - alert('New secret: ' + data.secret + '\nSave it now.'); 2082 - const hooks = await fetchWebhooks(); 2083 - renderWebhooks(hooks); 2084 - } 2085 - } 2086 - }); 2087 - 2088 - listEl.addEventListener('change', async (e) => { 2089 - const input = e.target.closest('input[type="checkbox"][data-action="toggle"]'); 2090 - if (!input) return; 2091 - const id = input.dataset.id; 2092 - const active = !!input.checked; 2093 - await fetch(`/api/webhooks/${id}`, { 2094 - method: 'PUT', headers: { 'Content-Type': 'application/json' }, 2095 - body: JSON.stringify({ active }) 2096 - }); 2097 - }); 2098 - } 2099 - 2100 - // Load frequent emojis from API 2101 - let frequentEmojis = ['😊', '👍', '❤️', '😂', '🎉', '🔥', '✨', '💯', '🚀', '💪', '🙏', '👏']; // defaults 2102 - try { 2103 - const response = await fetch('/api/frequent-emojis'); 2104 - if (response.ok) { 2105 - frequentEmojis = await response.json(); 2106 - } 2107 - } catch (e) { 2108 - console.log('Using default frequent emojis'); 2109 - } 2110 - 2111 - emojiData = { 2112 - ...data.categories, 2113 - frequent: frequentEmojis, 2114 - custom: [] // Will be filled with custom emojis 2115 - }; 2116 - } 2117 - 2118 - // Theme toggle 2119 - const themeToggle = document.getElementById('theme-toggle'); 2120 - if (themeToggle) { 2121 - themeToggle.addEventListener('click', toggleTheme); 2122 - } 2123 - 2124 - // Only initialize interactive elements if user is owner 2125 - {% if is_owner %} 2126 - 2127 - // Emoji picker 2128 - const emojiTrigger = document.getElementById('emoji-trigger'); 2129 - const emojiPickerOverlay = document.getElementById('emoji-picker-overlay'); 2130 - const emojiPicker = document.getElementById('emoji-picker'); 2131 - const emojiPickerClose = document.getElementById('emoji-picker-close'); 2132 - const emojiGrid = document.getElementById('emoji-grid'); 2133 - const selectedEmoji = document.getElementById('selected-emoji'); 2134 - const statusInput = document.getElementById('status-input'); 2135 - const emojiSearch = document.getElementById('emoji-search'); 2136 - const emojiCategories = document.getElementById('emoji-categories'); 2137 - let lastFocusBeforeEmojiPicker = null; 2138 - let scrollPosition = 0; 2139 - let isScrollLocked = false; 2140 - let keydownBound = false; 2141 - 2142 - // Clear time picker 2143 - const clearAfterBtn = document.getElementById('clear-after-btn'); 2144 - const clearPicker = document.getElementById('clear-picker'); 2145 - const clearText = document.getElementById('clear-text'); 2146 - const expiresSelect = document.getElementById('expires_in'); 2147 - 2148 - const isEmojiPickerOpen = () => emojiPickerOverlay && !emojiPickerOverlay.classList.contains('hidden'); 2149 - 2150 - const handleKeydown = (e) => { 2151 - if (e.key === 'Escape' && isEmojiPickerOpen()) { 2152 - closeEmojiPicker(); 2153 - } 2154 - }; 2155 - 2156 - // Clean up event listener on page unload 2157 - window.addEventListener('beforeunload', () => { 2158 - if (keydownBound) { 2159 - document.removeEventListener('keydown', handleKeydown); 2160 - keydownBound = false; 2161 - } 2162 - }); 2163 - 2164 - const openEmojiPicker = () => { 2165 - if (!emojiPickerOverlay || !emojiPicker) return; 2166 - 2167 - lastFocusBeforeEmojiPicker = document.activeElement; 2168 - scrollPosition = window.scrollY || document.documentElement.scrollTop || 0; 2169 - 2170 - emojiPickerOverlay.classList.remove('hidden'); 2171 - emojiPickerOverlay.setAttribute('aria-hidden', 'false'); 2172 - document.body.classList.add('modal-open'); 2173 - document.body.style.top = `-${scrollPosition}px`; 2174 - isScrollLocked = true; 2175 - if (clearPicker) clearPicker.style.display = 'none'; 2176 - 2177 - if (emojiSearch) { 2178 - emojiSearch.value = ''; 2179 - if (emojiCategories) emojiCategories.classList.remove('hidden'); 2180 - requestAnimationFrame(() => { 2181 - try { emojiSearch.focus(); } catch (_) {} 2182 - }); 2183 - } 2184 - 2185 - ensureCustomEmojis({ showLoading: false, showErrors: false }).then(() => { 2186 - loadEmojiCategory('frequent'); 2187 - }); 2188 - 2189 - if (!keydownBound) { 2190 - document.addEventListener('keydown', handleKeydown); 2191 - keydownBound = true; 2192 - } 2193 - }; 2194 - 2195 - const closeEmojiPicker = () => { 2196 - if (!emojiPickerOverlay) return; 2197 - emojiPickerOverlay.classList.add('hidden'); 2198 - emojiPickerOverlay.setAttribute('aria-hidden', 'true'); 2199 - document.body.classList.remove('modal-open'); 2200 - document.body.style.top = ''; 2201 - if (isScrollLocked) { 2202 - window.scrollTo(0, scrollPosition); 2203 - } 2204 - scrollPosition = 0; 2205 - isScrollLocked = false; 2206 - if (lastFocusBeforeEmojiPicker && typeof lastFocusBeforeEmojiPicker.focus === 'function') { 2207 - const focusTarget = lastFocusBeforeEmojiPicker; 2208 - requestAnimationFrame(() => { 2209 - try { focusTarget.focus(); } catch (_) {} 2210 - }); 2211 - } 2212 - lastFocusBeforeEmojiPicker = null; 2213 - 2214 - if (keydownBound) { 2215 - document.removeEventListener('keydown', handleKeydown); 2216 - keydownBound = false; 2217 - } 2218 - }; 2219 - 2220 - // Show emoji picker 2221 - if (emojiTrigger && emojiPickerOverlay) { 2222 - emojiTrigger.addEventListener('click', (e) => { 2223 - e.preventDefault(); 2224 - e.stopPropagation(); 2225 - if (isEmojiPickerOpen()) { 2226 - closeEmojiPicker(); 2227 - } else { 2228 - openEmojiPicker(); 2229 - } 2230 - }); 2231 - } 2232 - 2233 - if (emojiPickerClose) { 2234 - emojiPickerClose.addEventListener('click', (e) => { 2235 - e.preventDefault(); 2236 - closeEmojiPicker(); 2237 - }); 2238 - } 2239 - 2240 - if (emojiPickerOverlay) { 2241 - emojiPickerOverlay.addEventListener('click', (e) => { 2242 - // Only close if clicking the overlay itself, not the picker content 2243 - if (e.target === emojiPickerOverlay) { 2244 - closeEmojiPicker(); 2245 - } 2246 - }); 2247 - } 2248 - 2249 - // Emoji data caches 2250 - let customEmojis = []; 2251 - let customEmojiMap = new Map(); 2252 - let customEmojiFetchPromise = null; 2253 - let searchDebounce; 2254 - const SEARCH_DEBOUNCE_MS = 120; 2255 - 2256 - const showGridMessage = (message, className = 'emoji-grid-placeholder') => { 2257 - if (!emojiGrid) return; 2258 - const placeholder = document.createElement('div'); 2259 - placeholder.className = className; 2260 - placeholder.textContent = message; 2261 - emojiGrid.replaceChildren(placeholder); 2262 - }; 2263 - 2264 - const createCustomEmojiButton = (emoji) => { 2265 - const button = document.createElement('button'); 2266 - button.type = 'button'; 2267 - button.className = 'emoji-option custom-emoji'; 2268 - button.dataset.emoji = `custom:${emoji.name}`; 2269 - button.dataset.name = emoji.name; 2270 - 2271 - const img = document.createElement('img'); 2272 - // Use placeholder initially to avoid 404s 2273 - img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 2274 - img.dataset.emojiName = emoji.name; 2275 - img.dataset.emojiFilename = emoji.filename; 2276 - img.alt = emoji.name; 2277 - img.title = emoji.name; 2278 - button.appendChild(img); 2279 - 2280 - // Load the actual image after a brief delay to let resolver initialize 2281 - requestAnimationFrame(() => { 2282 - img.src = `/emojis/${emoji.filename}`; 2283 - }); 2284 - 2285 - return button; 2286 - }; 2287 - 2288 - const createStandardEmojiButton = (emoji, title = '') => { 2289 - const button = document.createElement('button'); 2290 - button.type = 'button'; 2291 - button.className = 'emoji-option'; 2292 - button.dataset.emoji = emoji; 2293 - if (title) { 2294 - button.dataset.name = title; 2295 - button.title = title; 2296 - } 2297 - button.textContent = emoji; 2298 - return button; 2299 - }; 2300 - 2301 - const renderEmojiButtons = (buttons) => { 2302 - if (!emojiGrid) return; 2303 - const fragment = document.createDocumentFragment(); 2304 - buttons.forEach(btn => fragment.appendChild(btn)); 2305 - emojiGrid.replaceChildren(fragment); 2306 - }; 2307 - 2308 - const fetchCustomEmojis = async () => { 2309 - const response = await fetch('/api/custom-emojis'); 2310 - const data = await response.json(); 2311 - if (!Array.isArray(data)) { 2312 - throw new Error('Invalid custom emoji payload'); 2313 - } 2314 - customEmojis = data; 2315 - customEmojiMap = new Map(data.map(emoji => [emoji.name, emoji])); 2316 - return customEmojis; 2317 - }; 2318 - 2319 - const ensureCustomEmojis = async ({ showLoading = false, showErrors = true } = {}) => { 2320 - if (customEmojis.length) return customEmojis; 2321 - if (showLoading) { 2322 - showGridMessage('loading custom emojis…', 'emoji-loading-message'); 2323 - } 2324 - if (!customEmojiFetchPromise) { 2325 - customEmojiFetchPromise = fetchCustomEmojis() 2326 - .catch(err => { 2327 - console.error('Failed to load custom emojis:', err); 2328 - customEmojis = []; 2329 - customEmojiMap = new Map(); 2330 - if (showErrors) { 2331 - showGridMessage('failed to load custom emojis'); 2332 - } 2333 - throw err; 2334 - }) 2335 - .finally(() => { 2336 - customEmojiFetchPromise = null; 2337 - }); 2338 - } 2339 - try { 2340 - await customEmojiFetchPromise; 2341 - } catch (_) { 2342 - return []; 2343 - } 2344 - return customEmojis; 2345 - }; 2346 - 2347 - // Quietly preload custom emojis so the custom tab feels instant 2348 - ensureCustomEmojis({ showLoading: false, showErrors: false }).catch(() => {}); 2349 - 2350 - const updateActiveCategory = (category) => { 2351 - document.querySelectorAll('.category-btn').forEach(btn => { 2352 - btn.classList.toggle('active', btn.getAttribute('data-category') === category); 2353 - }); 2354 - }; 2355 - 2356 - const loadEmojiCategory = async (category) => { 2357 - if (!emojiGrid) return; 2358 - 2359 - const buttons = []; 2360 - const emojis = emojiData[category] || []; 2361 - 2362 - if (category === 'frequent') { 2363 - if (emojis.some(emoji => emoji.startsWith('custom:'))) { 2364 - await ensureCustomEmojis({ showLoading: false, showErrors: false }); 2365 - } 2366 - for (const emoji of emojis) { 2367 - if (emoji.startsWith('custom:')) { 2368 - const name = emoji.replace('custom:', ''); 2369 - const custom = customEmojiMap.get(name); 2370 - if (custom) { 2371 - buttons.push(createCustomEmojiButton(custom)); 2372 - } 2373 - } else { 2374 - const slug = (window.__emojiSlugs && window.__emojiSlugs[emoji]) || ''; 2375 - buttons.push(createStandardEmojiButton(emoji, slug)); 2376 - } 2377 - } 2378 - } else if (category === 'custom') { 2379 - const data = await ensureCustomEmojis({ showLoading: true }); 2380 - if (!data.length) { 2381 - showGridMessage('no custom emojis yet'); 2382 - updateActiveCategory(category); 2383 - return; 2384 - } 2385 - for (const emoji of data) { 2386 - buttons.push(createCustomEmojiButton(emoji)); 2387 - } 2388 - } else { 2389 - for (const emoji of emojis) { 2390 - const slug = (window.__emojiSlugs && window.__emojiSlugs[emoji]) || ''; 2391 - buttons.push(createStandardEmojiButton(emoji, slug)); 2392 - } 2393 - } 2394 - 2395 - if (buttons.length) { 2396 - renderEmojiButtons(buttons); 2397 - } else if (category === 'custom') { 2398 - showGridMessage('no custom emojis yet'); 2399 - } else { 2400 - showGridMessage('no emojis in this category'); 2401 - } 2402 - 2403 - updateActiveCategory(category); 2404 - 2405 - // Show/hide bufo helper based on category 2406 - const bufoHelper = document.getElementById('bufo-helper'); 2407 - if (bufoHelper) { 2408 - bufoHelper.style.display = category === 'custom' ? 'block' : 'none'; 2409 - } 2410 - }; 2411 - 2412 - const applyEmojiSelection = (button) => { 2413 - if (!button || !selectedEmoji || !statusInput) return; 2414 - const emojiValue = button.getAttribute('data-emoji'); 2415 - if (!emojiValue) return; 2416 - 2417 - selectedEmoji.innerHTML = ''; 2418 - if (button.classList.contains('custom-emoji')) { 2419 - const img = button.querySelector('img'); 2420 - if (img) { 2421 - const previewImg = document.createElement('img'); 2422 - previewImg.src = img.src; 2423 - previewImg.alt = img.alt; 2424 - previewImg.title = img.title || img.alt || ''; 2425 - previewImg.style.width = '100%'; 2426 - previewImg.style.height = '100%'; 2427 - previewImg.style.objectFit = 'contain'; 2428 - selectedEmoji.appendChild(previewImg); 2429 - } 2430 - } else { 2431 - selectedEmoji.textContent = emojiValue; 2432 - } 2433 - 2434 - statusInput.value = emojiValue; 2435 - if (emojiSearch) { 2436 - emojiSearch.value = ''; 2437 - } 2438 - if (emojiCategories) { 2439 - emojiCategories.classList.remove('hidden'); 2440 - } 2441 - closeEmojiPicker(); 2442 - checkForChanges(); 2443 - }; 2444 - 2445 - if (emojiGrid) { 2446 - emojiGrid.addEventListener('click', (e) => { 2447 - const button = e.target.closest('.emoji-option'); 2448 - if (!button || !emojiGrid.contains(button)) return; 2449 - e.preventDefault(); 2450 - applyEmojiSelection(button); 2451 - }); 2452 - } 2453 - 2454 - // Category buttons 2455 - document.querySelectorAll('.category-btn').forEach(btn => { 2456 - btn.addEventListener('click', async (event) => { 2457 - event.preventDefault(); 2458 - event.stopPropagation(); // Prevent event bubbling that might close the modal 2459 - const category = btn.getAttribute('data-category'); 2460 - await loadEmojiCategory(category); 2461 - }); 2462 - }); 2463 - 2464 - // Show clear picker 2465 - if (clearAfterBtn && clearPicker) { 2466 - clearAfterBtn.addEventListener('click', (e) => { 2467 - e.stopPropagation(); 2468 - clearPicker.style.display = clearPicker.style.display === 'none' ? 'block' : 'none'; 2469 - closeEmojiPicker(); 2470 - 2471 - // Position picker 2472 - const rect = clearAfterBtn.getBoundingClientRect(); 2473 - clearPicker.style.top = rect.bottom + 'px'; 2474 - clearPicker.style.left = rect.left + 'px'; 2475 - }); 2476 - } 2477 - 2478 - // Custom datetime elements 2479 - const customDatetime = document.getElementById('custom-datetime'); 2480 - const customDatetimeInput = document.getElementById('custom-datetime-input'); 2481 - const customDatetimeSet = document.getElementById('custom-datetime-set'); 2482 - 2483 - // Set default value for custom datetime to 1 hour from now 2484 - const defaultCustomTime = new Date(); 2485 - defaultCustomTime.setHours(defaultCustomTime.getHours() + 1); 2486 - customDatetimeInput.value = defaultCustomTime.toISOString().slice(0, 16); 2487 - 2488 - // Clear options 2489 - document.querySelectorAll('.clear-option').forEach(btn => { 2490 - btn.addEventListener('click', () => { 2491 - const value = btn.getAttribute('data-value'); 2492 - 2493 - if (value === 'custom') { 2494 - // Show custom datetime input 2495 - customDatetime.style.display = customDatetime.style.display === 'none' ? 'flex' : 'none'; 2496 - // Update active state 2497 - document.querySelectorAll('.clear-option').forEach(opt => { 2498 - opt.classList.toggle('active', opt === btn); 2499 - }); 2500 - } else { 2501 - // Hide custom datetime input 2502 - customDatetime.style.display = 'none'; 2503 - 2504 - expiresSelect.value = value; 2505 - 2506 - // Update button text 2507 - const labels = { 2508 - '': "don't clear", 2509 - '30m': '30m', 2510 - '1h': '1h', 2511 - '2h': '2h', 2512 - '4h': '4h', 2513 - '8h': '8h', 2514 - '1d': 'Today', 2515 - '1w': 'This week' 2516 - }; 2517 - clearText.textContent = labels[value] || value; 2518 - 2519 - // Update active state 2520 - document.querySelectorAll('.clear-option').forEach(opt => { 2521 - opt.classList.toggle('active', opt === btn); 2522 - }); 2523 - 2524 - clearPicker.style.display = 'none'; 2525 - } 2526 - }); 2527 - }); 2528 - 2529 - // Handle custom datetime set 2530 - if (customDatetimeSet) { 2531 - customDatetimeSet.addEventListener('click', () => { 2532 - const selectedDate = new Date(customDatetimeInput.value); 2533 - const now = new Date(); 2534 - 2535 - if (selectedDate <= now) { 2536 - alert('Please select a future time'); 2537 - return; 2538 - } 2539 - 2540 - // Calculate duration from now 2541 - const diffMs = selectedDate - now; 2542 - const diffMins = Math.floor(diffMs / 60000); 2543 - const diffHours = Math.floor(diffMs / 3600000); 2544 - const diffDays = Math.floor(diffMs / 86400000); 2545 - 2546 - let durationStr; 2547 - if (diffMins < 60) { 2548 - durationStr = `${diffMins}m`; 2549 - } else if (diffHours < 24) { 2550 - durationStr = `${diffHours}h`; 2551 - } else if (diffDays < 7) { 2552 - durationStr = `${diffDays}d`; 2553 - } else { 2554 - durationStr = `${Math.floor(diffDays / 7)}w`; 2555 - } 2556 - 2557 - // Set the hidden select value 2558 - expiresSelect.value = durationStr; 2559 - 2560 - // Update button text to show the selected date/time 2561 - const dateStr = selectedDate.toLocaleDateString('en-US', { 2562 - month: 'short', 2563 - day: 'numeric', 2564 - hour: 'numeric', 2565 - minute: '2-digit', 2566 - hour12: true 2567 - }).toLowerCase(); 2568 - clearText.textContent = dateStr; 2569 - 2570 - // Hide the picker 2571 - clearPicker.style.display = 'none'; 2572 - customDatetime.style.display = 'none'; 2573 - }); 2574 - } 2575 - 2576 - // Close pickers on outside click 2577 - document.addEventListener('click', (e) => { 2578 - if (clearPicker && !clearPicker.contains(e.target) && e.target !== clearAfterBtn) { 2579 - clearPicker.style.display = 'none'; 2580 - } 2581 - }); 2582 - 2583 - // Search emoji function 2584 - const searchEmojis = async (rawQuery) => { 2585 - if (!emojiGrid) return; 2586 - const query = (rawQuery || '').trim(); 2587 - 2588 - if (!query) { 2589 - if (emojiCategories) emojiCategories.classList.remove('hidden'); 2590 - await loadEmojiCategory('frequent'); 2591 - return; 2592 - } 2593 - 2594 - if (emojiCategories) emojiCategories.classList.add('hidden'); 2595 - 2596 - const lowerQuery = query.toLowerCase(); 2597 - const buttons = []; 2598 - const seen = new Set(); 2599 - 2600 - await ensureCustomEmojis({ showLoading: false, showErrors: false }); 2601 - 2602 - for (const emoji of customEmojis) { 2603 - if (emoji.name.toLowerCase().includes(lowerQuery)) { 2604 - buttons.push(createCustomEmojiButton(emoji)); 2605 - seen.add(`custom:${emoji.name}`); 2606 - if (buttons.length >= 80) break; 2607 - } 2608 - } 2609 - 2610 - if (buttons.length < 80) { 2611 - for (const [emoji, keywords] of Object.entries(emojiKeywords)) { 2612 - if (seen.has(emoji)) continue; 2613 - if (emoji.includes(query) || keywords.some(keyword => keyword.includes(lowerQuery))) { 2614 - buttons.push(createStandardEmojiButton(emoji)); 2615 - seen.add(emoji); 2616 - if (buttons.length >= 80) break; 2617 - } 2618 - } 2619 - } 2620 - 2621 - if (buttons.length < 80) { 2622 - outer: for (const categoryEmojis of Object.values(emojiData)) { 2623 - for (const emoji of categoryEmojis) { 2624 - if (seen.has(emoji)) continue; 2625 - if (emoji.includes(query)) { 2626 - buttons.push(createStandardEmojiButton(emoji)); 2627 - seen.add(emoji); 2628 - if (buttons.length >= 80) break outer; 2629 - } 2630 - } 2631 - } 2632 - } 2633 - 2634 - if (buttons.length) { 2635 - renderEmojiButtons(buttons); 2636 - } else { 2637 - showGridMessage('No emojis found'); 2638 - } 2639 - 2640 - // Hide bufo helper during search 2641 - const bufoHelper = document.getElementById('bufo-helper'); 2642 - if (bufoHelper) { 2643 - bufoHelper.style.display = 'none'; 2644 - } 2645 - }; 2646 - 2647 - // Emoji search input handler 2648 - if (emojiSearch) { 2649 - emojiSearch.addEventListener('input', (e) => { 2650 - const value = e.target.value; 2651 - if (searchDebounce) clearTimeout(searchDebounce); 2652 - searchDebounce = setTimeout(() => { 2653 - searchEmojis(value); 2654 - }, SEARCH_DEBOUNCE_MS); 2655 - }); 2656 - 2657 - // Focus search when picker opens 2658 - emojiSearch.addEventListener('focus', () => { 2659 - if (!emojiSearch.value) { 2660 - loadEmojiCategory('frequent'); 2661 - } 2662 - }); 2663 - } 2664 - 2665 - // Form validation 2666 - const form = document.getElementById('status-form'); 2667 - const statusText = document.getElementById('status-text'); 2668 - const saveBtn = form.querySelector('.save-btn'); 2669 - 2670 - // Current status for comparison 2671 - const currentStatus = { 2672 - emoji: {% if let Some(current) = current_status.as_ref() %}"{{current.status}}"{% else %}null{% endif %}, 2673 - text: {% if let Some(current) = current_status.as_ref() %}{% if current.text.is_some() %}"{{ current.text.as_ref().unwrap() }}"{% else %}""{% endif %}{% else %}""{% endif %} 2674 - }; 2675 - 2676 - const checkForChanges = () => { 2677 - const newEmoji = statusInput.value; 2678 - const newText = statusText.value.trim(); 2679 - 2680 - // Check if this is identical to current status 2681 - // If there's no current status (emoji is null), allow any emoji selection as a change 2682 - const hasCurrentStatus = currentStatus.emoji !== null; 2683 - const isIdentical = hasCurrentStatus && 2684 - currentStatus.emoji === newEmoji && 2685 - currentStatus.text === newText; 2686 - 2687 - if (isIdentical) { 2688 - saveBtn.disabled = true; 2689 - saveBtn.textContent = 'No changes'; 2690 - } else { 2691 - saveBtn.disabled = false; 2692 - saveBtn.textContent = 'set'; 2693 - } 2694 - }; 2695 - 2696 - statusText.addEventListener('input', checkForChanges); 2697 - statusInput.addEventListener('change', checkForChanges); 2698 - 2699 - // Initial check 2700 - checkForChanges(); 2701 - 2702 - {% endif %} 2703 - 2704 - // Handle delete buttons for history items 2705 - document.querySelectorAll('.history-delete').forEach(btn => { 2706 - btn.addEventListener('click', async (e) => { 2707 - const uri = btn.getAttribute('data-uri'); 2708 - 2709 - if (confirm('Delete this status? This cannot be undone.')) { 2710 - try { 2711 - const response = await fetch('/status/delete', { 2712 - method: 'POST', 2713 - headers: { 2714 - 'Content-Type': 'application/json', 2715 - }, 2716 - body: JSON.stringify({ uri }) 2717 - }); 2718 - 2719 - if (response.ok) { 2720 - // Remove the history item from the DOM 2721 - btn.closest('.history-item').remove(); 2722 - 2723 - // If no more history items, remove the entire history section 2724 - const historyItems = document.querySelectorAll('.history-item'); 2725 - if (historyItems.length === 0) { 2726 - const historySection = document.querySelector('.history'); 2727 - if (historySection) { 2728 - historySection.remove(); 2729 - } 2730 - } 2731 - } else { 2732 - alert('Failed to delete status'); 2733 - } 2734 - } catch (error) { 2735 - console.error('Error deleting status:', error); 2736 - alert('Failed to delete status'); 2737 - } 2738 - } 2739 - }); 2740 - }); 2741 - }); 2742 - </script> 2743 - <script> 2744 - // Admin upload toggles and submit 2745 - document.addEventListener('DOMContentLoaded', function () { 2746 - const toggle = document.getElementById('admin-toggle'); 2747 - const content = document.getElementById('admin-content'); 2748 - const form = document.getElementById('emoji-upload-form'); 2749 - const file = document.getElementById('emoji-file'); 2750 - const name = document.getElementById('emoji-name'); 2751 - const msg = document.getElementById('admin-msg'); 2752 - if (!toggle || !content || !form) return; 2753 - 2754 - toggle.addEventListener('click', () => { 2755 - content.style.display = content.style.display === 'none' ? 'block' : 'none'; 2756 - }); 2757 - 2758 - form.addEventListener('submit', async (e) => { 2759 - e.preventDefault(); 2760 - msg.textContent = ''; 2761 - if (!file.files || file.files.length === 0) { 2762 - msg.textContent = 'choose a PNG or GIF'; 2763 - return; 2764 - } 2765 - // Require a name; prefill from filename if empty 2766 - if (!name.value.trim().length) { 2767 - const base = (file.files[0].name || '').replace(/\.[^.]+$/, ''); 2768 - const sanitized = base.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); 2769 - name.value = sanitized || ''; 2770 - } 2771 - if (!name.value.trim().length) { 2772 - msg.textContent = 'please choose a name'; 2773 - return; 2774 - } 2775 - // Client-side reserved check (best-effort) 2776 - if (window.__reservedEmojiNames && window.__reservedEmojiNames.has(name.value.trim().toLowerCase())) { 2777 - msg.textContent = 'that name is reserved by a standard emoji'; 2778 - return; 2779 - } 2780 - const f = file.files[0]; 2781 - if (!['image/png','image/gif'].includes(f.type)) { 2782 - msg.textContent = 'only PNG or GIF'; 2783 - return; 2784 - } 2785 - const fd = new FormData(); 2786 - fd.append('file', f); 2787 - if (name.value.trim().length) fd.append('name', name.value.trim()); 2788 - try { 2789 - const res = await fetch('/admin/upload-emoji', { method: 'POST', body: fd }); 2790 - const json = await res.json(); 2791 - if (!res.ok || !json.success) { 2792 - if (json && json.code === 'name_exists') { 2793 - msg.textContent = 'that name already exists — please pick another'; 2794 - } else { 2795 - msg.textContent = (json && json.error) || 'upload failed'; 2796 - } 2797 - return; 2798 - } 2799 - // Notify listeners (e.g., emoji picker) and close panel 2800 - document.dispatchEvent(new CustomEvent('custom-emoji-uploaded', { detail: json })); 2801 - content.style.display = 'none'; 2802 - form.reset(); 2803 - msg.textContent = ''; 2804 - } catch (err) { 2805 - msg.textContent = 'network error'; 2806 - } 2807 - }); 2808 - }); 2809 - </script> 2810 - {%endblock content%}