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

merge main (resolve conflicts with quickslice rewrite)

Changed files
+14645
.github
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"
+33
.github/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: [ main ] 6 + pull_request: 7 + branches: [ main ] 8 + 9 + env: 10 + CARGO_TERM_COLOR: always 11 + 12 + jobs: 13 + test: 14 + name: Test 15 + runs-on: ubuntu-latest 16 + steps: 17 + - uses: actions/checkout@v4 18 + - uses: dtolnay/rust-toolchain@stable 19 + with: 20 + components: rustfmt, clippy 21 + - uses: Swatinem/rust-cache@v2 22 + 23 + - name: Check formatting 24 + run: cargo fmt -- --check 25 + 26 + - name: Build 27 + run: cargo build --verbose 28 + 29 + - name: Run clippy 30 + run: cargo clippy -- -D warnings 31 + 32 + - name: Run tests 33 + run: cargo test --verbose
+54
.github/workflows/claude-code-review.yml
··· 1 + name: Claude Code Review 2 + 3 + on: 4 + pull_request: 5 + types: [opened, synchronize] 6 + # Optional: Only run on specific file changes 7 + # paths: 8 + # - "src/**/*.ts" 9 + # - "src/**/*.tsx" 10 + # - "src/**/*.js" 11 + # - "src/**/*.jsx" 12 + 13 + jobs: 14 + claude-review: 15 + # Optional: Filter by PR author 16 + # if: | 17 + # github.event.pull_request.user.login == 'external-contributor' || 18 + # github.event.pull_request.user.login == 'new-developer' || 19 + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 + 21 + runs-on: ubuntu-latest 22 + permissions: 23 + contents: read 24 + pull-requests: read 25 + issues: read 26 + id-token: write 27 + 28 + steps: 29 + - name: Checkout repository 30 + uses: actions/checkout@v4 31 + with: 32 + fetch-depth: 1 33 + 34 + - name: Run Claude Code Review 35 + id: claude-review 36 + uses: anthropics/claude-code-action@v1 37 + with: 38 + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 + prompt: | 40 + Please review this pull request and provide feedback on: 41 + - Code quality and best practices 42 + - Potential bugs or issues 43 + - Performance considerations 44 + - Security concerns 45 + - Test coverage 46 + 47 + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. 48 + 49 + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. 50 + 51 + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 52 + # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 53 + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' 54 +
+50
.github/workflows/claude.yml
··· 1 + name: Claude Code 2 + 3 + on: 4 + issue_comment: 5 + types: [created] 6 + pull_request_review_comment: 7 + types: [created] 8 + issues: 9 + types: [opened, assigned] 10 + pull_request_review: 11 + types: [submitted] 12 + 13 + jobs: 14 + claude: 15 + if: | 16 + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 + runs-on: ubuntu-latest 21 + permissions: 22 + contents: read 23 + pull-requests: read 24 + issues: read 25 + id-token: write 26 + actions: read # Required for Claude to read CI results on PRs 27 + steps: 28 + - name: Checkout repository 29 + uses: actions/checkout@v4 30 + with: 31 + fetch-depth: 1 32 + 33 + - name: Run Claude Code 34 + id: claude 35 + uses: anthropics/claude-code-action@v1 36 + with: 37 + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 + 39 + # This is an optional setting that allows Claude to read CI results on PRs 40 + additional_permissions: | 41 + actions: read 42 + 43 + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 44 + # prompt: 'Update the pull request description to include a summary of changes.' 45 + 46 + # Optional: Add claude_args to customize behavior and configuration 47 + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 48 + # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 49 + # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' 50 +
+52
.github/workflows/fly-review.yml
··· 1 + name: Deploy Review App 2 + on: 3 + # Run this workflow on every PR event. Existing review apps will be updated when the PR is updated. 4 + pull_request: 5 + types: [opened, reopened, synchronize, closed] 6 + paths: 7 + - 'src/**' 8 + - 'templates/**' 9 + - 'static/**' 10 + - 'Cargo.toml' 11 + - 'Cargo.lock' 12 + - 'Dockerfile' 13 + - 'fly.toml' 14 + - 'fly.review.toml' 15 + - 'build.rs' 16 + - 'sqlx-data.json' 17 + env: 18 + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 + # Set these to your Fly.io organization and preferred region. 20 + FLY_REGION: ewr 21 + FLY_ORG: personal 22 + 23 + jobs: 24 + review_app: 25 + runs-on: ubuntu-latest 26 + outputs: 27 + url: ${{ steps.deploy.outputs.url }} 28 + # Only run one deployment at a time per PR. 29 + concurrency: 30 + group: pr-${{ github.event.number }} 31 + 32 + # Deploying apps with this "review" environment allows the URL for the app to be displayed in the PR UI. 33 + environment: 34 + name: review 35 + # The script in the `deploy` sets the URL output for each review app. 36 + url: ${{ steps.deploy.outputs.url }} 37 + steps: 38 + - name: Get code 39 + uses: actions/checkout@v4 40 + 41 + - name: Deploy PR app to Fly.io 42 + id: deploy 43 + uses: superfly/fly-pr-review-apps@1.2.1 44 + with: 45 + name: zzstoatzz-status-pr-${{ github.event.number }} 46 + config: fly.review.toml 47 + # Use smaller resources for review apps 48 + vmsize: shared-cpu-1x 49 + memory: 256 50 + # Set OAUTH_REDIRECT_BASE dynamically for OAuth redirects 51 + secrets: | 52 + OAUTH_REDIRECT_BASE=https://zzstoatzz-status-pr-${{ github.event.number }}.fly.dev
+16
.github/workflows/fly.yml
··· 1 + name: Fly Deploy 2 + on: 3 + push: 4 + branches: 5 + - main 6 + jobs: 7 + deploy: 8 + name: Deploy app 9 + runs-on: ubuntu-latest 10 + concurrency: deploy-group # ensure only one action runs at a time 11 + steps: 12 + - uses: actions/checkout@v4 13 + - uses: superfly/flyctl-actions/setup-flyctl@master 14 + - run: flyctl deploy --remote-only 15 + env: 16 + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+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
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Bailey Townsend 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+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%}