Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 16 17 # production 18 /build 19 20 # misc 21 .DS_Store
··· 16 17 # production 18 /build 19 + /result 20 21 # misc 22 .DS_Store
-3
.gitmodules
··· 1 - [submodule "cli/jacquard"] 2 - path = cli/jacquard 3 - url = https://tangled.org/@nonbinary.computer/jacquard
···
-1
.tangled/workflows/deploy-wisp.yml
··· 42 43 - name: 'Deploy to Wisp.place' 44 command: | 45 - echo 46 ./cli/target/release/wisp-cli \ 47 "$WISP_HANDLE" \ 48 --path "$SITE_PATH" \
··· 42 43 - name: 'Deploy to Wisp.place' 44 command: | 45 ./cli/target/release/wisp-cli \ 46 "$WISP_HANDLE" \ 47 --path "$SITE_PATH" \
+4
.tangled/workflows/test.yml
··· 14 - name: install dependencies 15 command: | 16 export PATH="$HOME/.nix-profile/bin:$PATH" 17 bun install 18 19 - name: run all tests
··· 14 - name: install dependencies 15 command: | 16 export PATH="$HOME/.nix-profile/bin:$PATH" 17 + 18 + # have to regenerate otherwise it wont install necessary dependencies to run 19 + rm -rf bun.lock package-lock.json 20 + bun install @oven/bun-linux-aarch64 21 bun install 22 23 - name: run all tests
+99 -5
README.md
··· 1 # Wisp.place 2 - A static site hosting service built on the AT Protocol. [https://wisp.place](https://wisp.place) 3 4 - /src is the main backend 5 6 - /hosting-service is the microservice that serves on-disk caches of sites pulled from the firehose and pdses 7 8 - /cli is the wisp-cli, a way to upload sites directly to the pds 9 10 - full readme soon
··· 1 # Wisp.place 2 3 + Decentralized static site hosting on the AT Protocol. [https://wisp.place](https://wisp.place) 4 + 5 + ## What is this? 6 7 + Host static sites in your AT Protocol repo, served with CDN distribution. Your PDS holds the cryptographically signed manifest and files - the source of truth. Hosting services index and serve them fast. 8 9 + ## Quick Start 10 11 + ```bash 12 + # Using the web interface 13 + Visit https://wisp.place and sign in 14 + 15 + # Or use the CLI 16 + cd cli 17 + cargo build --release 18 + ./target/release/wisp-cli your-handle.bsky.social --path ./my-site --site my-site 19 + ``` 20 + 21 + Your site appears at `https://sites.wisp.place/{your-did}/{site-name}` or your custom domain. 22 + 23 + ## Architecture 24 + 25 + - **`/src`** - Main backend (OAuth, site management, custom domains) 26 + - **`/hosting-service`** - Microservice that serves cached sites from disk 27 + - **`/cli`** - Rust CLI for direct PDS uploads 28 + - **`/public`** - React frontend 29 + 30 + ### How it works 31 + 32 + 1. Sites stored as `place.wisp.fs` records in your AT Protocol repo 33 + 2. Files compressed (gzip) and base64-encoded as blobs 34 + 3. Hosting service watches firehose, caches sites locally 35 + 4. Sites served via custom domains or `*.wisp.place` subdomains 36 + 37 + ## Development 38 + 39 + ```bash 40 + # Backend 41 + bun install 42 + bun run src/index.ts 43 + 44 + # Hosting service 45 + cd hosting-service 46 + npm run start 47 + 48 + # CLI 49 + cd cli 50 + cargo build 51 + ``` 52 + 53 + ## Features 54 + 55 + ### URL Redirects and Rewrites 56 + 57 + The hosting service supports Netlify-style `_redirects` files for managing URLs. Place a `_redirects` file in your site root to enable: 58 + 59 + - **301/302 Redirects**: Permanent and temporary URL redirects 60 + - **200 Rewrites**: Serve different content without changing the URL 61 + - **404 Custom Pages**: Custom error pages for specific paths 62 + - **Splats & Placeholders**: Dynamic path matching (`/blog/:year/:month/:day`, `/news/*`) 63 + - **Query Parameter Matching**: Redirect based on URL parameters 64 + - **Conditional Redirects**: Route by country, language, or cookie presence 65 + - **Force Redirects**: Override existing files with redirects 66 + 67 + Example `_redirects`: 68 + ``` 69 + # Single-page app routing (React, Vue, etc.) 70 + /* /index.html 200 71 + 72 + # Simple redirects 73 + /home / 74 + /old-blog/* /blog/:splat 75 + 76 + # API proxy 77 + /api/* https://api.example.com/:splat 200 78 + 79 + # Country-based routing 80 + / /us/ 302 Country=us 81 + / /uk/ 302 Country=gb 82 + ``` 83 + 84 + ## Limits 85 + 86 + - Max file size: 100MB (PDS limit) 87 + - Max files: 2000 88 + 89 + ## Tech Stack 90 + 91 + - Backend: Bun + Elysia + PostgreSQL 92 + - Frontend: React 19 + Tailwind 4 + Radix UI 93 + - Hosting: Node microservice using Hono 94 + - CLI: Rust + Jacquard (AT Protocol library) 95 + - Protocol: AT Protocol OAuth + custom lexicons 96 + 97 + ## License 98 + 99 + MIT 100 + 101 + ## Links 102 + 103 + - [AT Protocol](https://atproto.com) 104 + - [Jacquard Library](https://tangled.org/@nonbinary.computer/jacquard)
+29
bun.lock
··· 1 { 2 "lockfileVersion": 1, 3 "workspaces": { 4 "": { 5 "name": "elysia-static", ··· 20 "@radix-ui/react-slot": "^1.2.3", 21 "@radix-ui/react-tabs": "^1.1.13", 22 "@tanstack/react-query": "^5.90.2", 23 "class-variance-authority": "^0.7.1", 24 "clsx": "^2.1.1", 25 "elysia": "latest", ··· 51 "protobufjs", 52 ], 53 "packages": { 54 "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="], 55 56 "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], ··· 100 "@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="], 101 102 "@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, "sha512-V0srjUgy6mQ5yf9+MSNBLs457m4qclEaWZsnqIE7RfYywvntexTAbMoo7J7ONfTNwdmA9Gw4oLak2z2cDAET4w=="], 103 104 "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], 105 ··· 341 342 "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 343 344 "@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="], 345 346 "@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="], ··· 367 368 "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], 369 370 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 371 372 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], ··· 376 "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], 377 378 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 379 380 "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 381 ··· 468 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 469 470 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 471 472 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 473
··· 1 { 2 "lockfileVersion": 1, 3 + "configVersion": 0, 4 "workspaces": { 5 "": { 6 "name": "elysia-static", ··· 21 "@radix-ui/react-slot": "^1.2.3", 22 "@radix-ui/react-tabs": "^1.1.13", 23 "@tanstack/react-query": "^5.90.2", 24 + "actor-typeahead": "^0.1.1", 25 + "atproto-ui": "^0.11.3", 26 "class-variance-authority": "^0.7.1", 27 "clsx": "^2.1.1", 28 "elysia": "latest", ··· 54 "protobufjs", 55 ], 56 "packages": { 57 + "@atcute/atproto": ["@atcute/atproto@3.1.9", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" } }, "sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w=="], 58 + 59 + "@atcute/bluesky": ["@atcute/bluesky@3.2.10", "", { "dependencies": { "@atcute/atproto": "^3.1.9", "@atcute/lexicons": "^1.2.2" } }, "sha512-qwQWTzRf3umnh2u41gdU+xWYkbzGlKDupc3zeOB+YjmuP1N9wEaUhwS8H7vgrqr0xC9SGNDjeUVcjC4m5BPLBg=="], 60 + 61 + "@atcute/client": ["@atcute/client@4.0.5", "", { "dependencies": { "@atcute/identity": "^1.1.1", "@atcute/lexicons": "^1.2.2" } }, "sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA=="], 62 + 63 + "@atcute/identity": ["@atcute/identity@1.1.1", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@badrap/valita": "^0.4.6" } }, "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q=="], 64 + 65 + "@atcute/identity-resolver": ["@atcute/identity-resolver@1.1.4", "", { "dependencies": { "@atcute/lexicons": "^1.2.2", "@atcute/util-fetch": "^1.0.3", "@badrap/valita": "^0.4.6" }, "peerDependencies": { "@atcute/identity": "^1.0.0" } }, "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA=="], 66 + 67 + "@atcute/lexicons": ["@atcute/lexicons@1.2.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "esm-env": "^1.2.2" } }, "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA=="], 68 + 69 + "@atcute/tangled": ["@atcute/tangled@1.0.10", "", { "dependencies": { "@atcute/atproto": "^3.1.8", "@atcute/lexicons": "^1.2.2" } }, "sha512-DGconZIN5TpLBah+aHGbWI1tMsL7XzyVEbr/fW4CbcLWYKICU6SAUZ0YnZ+5GvltjlORWHUy7hfftvoh4zodIA=="], 70 + 71 + "@atcute/util-fetch": ["@atcute/util-fetch@1.0.3", "", { "dependencies": { "@badrap/valita": "^0.4.6" } }, "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ=="], 72 + 73 "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.2", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "zod": "^3.23.8" } }, "sha512-ca2B7xR43tVoQ8XxBvha58DXwIH8cIyKQl6lpOKGkPUrJuFoO4iCLlDiSDi2Ueh+yE1rMDPP/qveHdajgDX3WQ=="], 74 75 "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], ··· 119 "@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="], 120 121 "@atproto/xrpc-server": ["@atproto/xrpc-server@0.9.5", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@atproto/xrpc": "^0.7.5", "cbor-x": "^1.5.1", "express": "^4.17.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "rate-limiter-flexible": "^2.4.1", "uint8arrays": "3.0.0", "ws": "^8.12.0", "zod": "^3.23.8" } }, "sha512-V0srjUgy6mQ5yf9+MSNBLs457m4qclEaWZsnqIE7RfYywvntexTAbMoo7J7ONfTNwdmA9Gw4oLak2z2cDAET4w=="], 122 + 123 + "@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="], 124 125 "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], 126 ··· 362 363 "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], 364 365 + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], 366 + 367 "@tanstack/query-core": ["@tanstack/query-core@5.90.7", "", {}, "sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ=="], 368 369 "@tanstack/react-query": ["@tanstack/react-query@5.90.7", "", { "dependencies": { "@tanstack/query-core": "5.90.7" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ=="], ··· 390 391 "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], 392 393 + "actor-typeahead": ["actor-typeahead@0.1.1", "", {}, "sha512-ilsBwzplKwMSBiO6Tg6RdaZ5xxqgXds5jCQuHV+ib9Aq3ja9g0T7u2Y1PmihotmS7l5RxhpGI/tPm3ljoRDRwg=="], 394 + 395 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 396 397 "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], ··· 401 "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], 402 403 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 404 + 405 + "atproto-ui": ["atproto-ui@0.11.3", "", { "dependencies": { "@atcute/atproto": "^3.1.7", "@atcute/bluesky": "^3.2.3", "@atcute/client": "^4.0.3", "@atcute/identity-resolver": "^1.1.3", "@atcute/tangled": "^1.0.10" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-NIBsORuo9lpCpr1SNKcKhNvqOVpsEy9IoHqFe1CM9gNTArpQL1hUcoP1Cou9a1O5qzCul9kaiu5xBHnB81I/WQ=="], 406 407 "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 408 ··· 495 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 496 497 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 498 + 499 + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 500 501 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 502
+1
cli/.gitignore
··· 1 .DS_STORE 2 jacquard/ 3 binaries/
··· 1 + test/ 2 .DS_STORE 3 jacquard/ 4 binaries/
+627 -67
cli/Cargo.lock
··· 139 140 [[package]] 141 name = "async-compression" 142 - version = "0.4.32" 143 source = "registry+https://github.com/rust-lang/crates.io-index" 144 - checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0" 145 dependencies = [ 146 "compression-codecs", 147 "compression-core", ··· 158 dependencies = [ 159 "proc-macro2", 160 "quote", 161 - "syn 2.0.108", 162 ] 163 164 [[package]] ··· 174 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 175 176 [[package]] 177 name = "backtrace" 178 version = "0.3.76" 179 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 274 "proc-macro2", 275 "quote", 276 "rustversion", 277 - "syn 2.0.108", 278 ] 279 280 [[package]] ··· 348 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 349 350 [[package]] 351 name = "bytes" 352 version = "1.10.1" 353 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 367 368 [[package]] 369 name = "cc" 370 - version = "1.2.44" 371 source = "registry+https://github.com/rust-lang/crates.io-index" 372 - checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" 373 dependencies = [ 374 "find-msvc-tools", 375 "shlex", ··· 494 "heck 0.5.0", 495 "proc-macro2", 496 "quote", 497 - "syn 2.0.108", 498 ] 499 500 [[package]] ··· 521 522 [[package]] 523 name = "compression-codecs" 524 - version = "0.4.31" 525 source = "registry+https://github.com/rust-lang/crates.io-index" 526 - checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" 527 dependencies = [ 528 "compression-core", 529 "flate2", ··· 532 533 [[package]] 534 name = "compression-core" 535 - version = "0.4.29" 536 source = "registry+https://github.com/rust-lang/crates.io-index" 537 - checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" 538 539 [[package]] 540 name = "const-oid" ··· 549 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 550 551 [[package]] 552 name = "core-foundation" 553 version = "0.9.4" 554 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 665 "proc-macro2", 666 "quote", 667 "strsim", 668 - "syn 2.0.108", 669 ] 670 671 [[package]] ··· 676 dependencies = [ 677 "darling_core", 678 "quote", 679 - "syn 2.0.108", 680 ] 681 682 [[package]] ··· 716 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 717 dependencies = [ 718 "data-encoding", 719 - "syn 2.0.108", 720 ] 721 722 [[package]] ··· 751 ] 752 753 [[package]] 754 name = "digest" 755 version = "0.10.7" 756 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 791 dependencies = [ 792 "proc-macro2", 793 "quote", 794 - "syn 2.0.108", 795 ] 796 797 [[package]] ··· 852 "heck 0.5.0", 853 "proc-macro2", 854 "quote", 855 - "syn 2.0.108", 856 ] 857 858 [[package]] ··· 956 ] 957 958 [[package]] 959 name = "futures-channel" 960 version = "0.3.31" 961 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 989 checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 990 991 [[package]] 992 name = "futures-macro" 993 version = "0.3.31" 994 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 996 dependencies = [ 997 "proc-macro2", 998 "quote", 999 - "syn 2.0.108", 1000 ] 1001 1002 [[package]] ··· 1027 "pin-project-lite", 1028 "pin-utils", 1029 "slab", 1030 ] 1031 1032 [[package]] ··· 1236 "markup5ever", 1237 "proc-macro2", 1238 "quote", 1239 - "syn 2.0.108", 1240 ] 1241 1242 [[package]] ··· 1274 ] 1275 1276 [[package]] 1277 name = "httparse" 1278 version = "1.10.1" 1279 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1287 1288 [[package]] 1289 name = "hyper" 1290 - version = "1.7.0" 1291 source = "registry+https://github.com/rust-lang/crates.io-index" 1292 - checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" 1293 dependencies = [ 1294 "atomic-waker", 1295 "bytes", ··· 1299 "http", 1300 "http-body", 1301 "httparse", 1302 "itoa", 1303 "pin-project-lite", 1304 "pin-utils", ··· 1362 "js-sys", 1363 "log", 1364 "wasm-bindgen", 1365 - "windows-core", 1366 ] 1367 1368 [[package]] ··· 1554 1555 [[package]] 1556 name = "iri-string" 1557 - version = "0.7.8" 1558 source = "registry+https://github.com/rust-lang/crates.io-index" 1559 - checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" 1560 dependencies = [ 1561 "memchr", 1562 "serde", ··· 1583 [[package]] 1584 name = "jacquard" 1585 version = "0.9.0" 1586 dependencies = [ 1587 "bytes", 1588 "getrandom 0.2.16", ··· 1610 [[package]] 1611 name = "jacquard-api" 1612 version = "0.9.0" 1613 dependencies = [ 1614 "bon", 1615 "bytes", ··· 1627 [[package]] 1628 name = "jacquard-common" 1629 version = "0.9.0" 1630 dependencies = [ 1631 "base64 0.22.1", 1632 "bon", 1633 "bytes", 1634 "chrono", 1635 "cid", 1636 "getrandom 0.2.16", 1637 "getrandom 0.3.4", 1638 "http", ··· 1642 "miette", 1643 "multibase", 1644 "multihash", 1645 "ouroboros", 1646 "p256", 1647 "rand 0.9.2", ··· 1655 "smol_str", 1656 "thiserror 2.0.17", 1657 "tokio", 1658 "tokio-util", 1659 "trait-variant", 1660 "url", ··· 1663 [[package]] 1664 name = "jacquard-derive" 1665 version = "0.9.0" 1666 dependencies = [ 1667 "heck 0.5.0", 1668 "jacquard-lexicon", 1669 "proc-macro2", 1670 "quote", 1671 - "syn 2.0.108", 1672 ] 1673 1674 [[package]] 1675 name = "jacquard-identity" 1676 - version = "0.9.0" 1677 dependencies = [ 1678 "bon", 1679 "bytes", ··· 1698 1699 [[package]] 1700 name = "jacquard-lexicon" 1701 - version = "0.9.0" 1702 dependencies = [ 1703 "cid", 1704 "dashmap", ··· 1716 "serde_repr", 1717 "serde_with", 1718 "sha2", 1719 - "syn 2.0.108", 1720 "thiserror 2.0.17", 1721 "unicode-segmentation", 1722 ] ··· 1724 [[package]] 1725 name = "jacquard-oauth" 1726 version = "0.9.0" 1727 dependencies = [ 1728 "base64 0.22.1", 1729 "bytes", ··· 1849 source = "registry+https://github.com/rust-lang/crates.io-index" 1850 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1851 dependencies = [ 1852 - "spin", 1853 ] 1854 1855 [[package]] ··· 1909 checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 1910 1911 [[package]] 1912 name = "lru-cache" 1913 version = "0.1.2" 1914 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1965 "quote", 1966 "syn 1.0.109", 1967 ] 1968 1969 [[package]] 1970 name = "memchr" ··· 1999 dependencies = [ 2000 "proc-macro2", 2001 "quote", 2002 - "syn 2.0.108", 2003 ] 2004 2005 [[package]] ··· 2101 ] 2102 2103 [[package]] 2104 name = "ndk-context" 2105 version = "0.1.1" 2106 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2123 ] 2124 2125 [[package]] 2126 name = "num-bigint-dig" 2127 - version = "0.8.5" 2128 source = "registry+https://github.com/rust-lang/crates.io-index" 2129 - checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" 2130 dependencies = [ 2131 "lazy_static", 2132 "libm", ··· 2240 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 2241 2242 [[package]] 2243 name = "option-ext" 2244 version = "0.2.0" 2245 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2266 "proc-macro2", 2267 "proc-macro2-diagnostics", 2268 "quote", 2269 - "syn 2.0.108", 2270 ] 2271 2272 [[package]] ··· 2296 "elliptic-curve", 2297 "primeorder", 2298 ] 2299 2300 [[package]] 2301 name = "parking_lot" ··· 2374 ] 2375 2376 [[package]] 2377 name = "pin-project-lite" 2378 version = "0.2.16" 2379 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2443 checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2444 dependencies = [ 2445 "proc-macro2", 2446 - "syn 2.0.108", 2447 ] 2448 2449 [[package]] ··· 2496 dependencies = [ 2497 "proc-macro2", 2498 "quote", 2499 - "syn 2.0.108", 2500 "version_check", 2501 "yansi", 2502 ] ··· 2564 2565 [[package]] 2566 name = "quote" 2567 - version = "1.0.41" 2568 source = "registry+https://github.com/rust-lang/crates.io-index" 2569 - checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" 2570 dependencies = [ 2571 "proc-macro2", 2572 ] ··· 2679 dependencies = [ 2680 "proc-macro2", 2681 "quote", 2682 - "syn 2.0.108", 2683 ] 2684 2685 [[package]] ··· 2745 "tokio", 2746 "tokio-rustls", 2747 "tokio-util", 2748 - "tower", 2749 - "tower-http", 2750 "tower-service", 2751 "url", 2752 "wasm-bindgen", ··· 2857 2858 [[package]] 2859 name = "rustls" 2860 - version = "0.23.34" 2861 source = "registry+https://github.com/rust-lang/crates.io-index" 2862 - checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" 2863 dependencies = [ 2864 "once_cell", 2865 "ring", ··· 2867 "rustls-webpki", 2868 "subtle", 2869 "zeroize", 2870 ] 2871 2872 [[package]] ··· 2918 ] 2919 2920 [[package]] 2921 name = "schemars" 2922 version = "0.9.0" 2923 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2931 2932 [[package]] 2933 name = "schemars" 2934 - version = "1.0.4" 2935 source = "registry+https://github.com/rust-lang/crates.io-index" 2936 - checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" 2937 dependencies = [ 2938 "dyn-clone", 2939 "ref-cast", ··· 2942 ] 2943 2944 [[package]] 2945 name = "scopeguard" 2946 version = "1.2.0" 2947 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2962 ] 2963 2964 [[package]] 2965 name = "serde" 2966 version = "1.0.228" 2967 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2998 dependencies = [ 2999 "proc-macro2", 3000 "quote", 3001 - "syn 2.0.108", 3002 ] 3003 3004 [[package]] ··· 3040 ] 3041 3042 [[package]] 3043 name = "serde_repr" 3044 version = "0.1.20" 3045 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3047 dependencies = [ 3048 "proc-macro2", 3049 "quote", 3050 - "syn 2.0.108", 3051 ] 3052 3053 [[package]] ··· 3074 "indexmap 1.9.3", 3075 "indexmap 2.12.0", 3076 "schemars 0.9.0", 3077 - "schemars 1.0.4", 3078 "serde_core", 3079 "serde_json", 3080 "serde_with_macros", ··· 3090 "darling", 3091 "proc-macro2", 3092 "quote", 3093 - "syn 2.0.108", 3094 ] 3095 3096 [[package]] ··· 3108 "cfg-if", 3109 "cpufeatures", 3110 "digest", 3111 ] 3112 3113 [[package]] ··· 3205 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3206 3207 [[package]] 3208 name = "spki" 3209 version = "0.7.3" 3210 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3236 "quote", 3237 "serde", 3238 "sha2", 3239 - "syn 2.0.108", 3240 "thiserror 1.0.69", 3241 ] 3242 ··· 3317 3318 [[package]] 3319 name = "syn" 3320 - version = "2.0.108" 3321 source = "registry+https://github.com/rust-lang/crates.io-index" 3322 - checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" 3323 dependencies = [ 3324 "proc-macro2", 3325 "quote", ··· 3343 dependencies = [ 3344 "proc-macro2", 3345 "quote", 3346 - "syn 2.0.108", 3347 ] 3348 3349 [[package]] ··· 3443 dependencies = [ 3444 "proc-macro2", 3445 "quote", 3446 - "syn 2.0.108", 3447 ] 3448 3449 [[package]] ··· 3454 dependencies = [ 3455 "proc-macro2", 3456 "quote", 3457 - "syn 2.0.108", 3458 ] 3459 3460 [[package]] ··· 3561 dependencies = [ 3562 "proc-macro2", 3563 "quote", 3564 - "syn 2.0.108", 3565 ] 3566 3567 [[package]] ··· 3575 ] 3576 3577 [[package]] 3578 name = "tokio-util" 3579 - version = "0.7.16" 3580 source = "registry+https://github.com/rust-lang/crates.io-index" 3581 - checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" 3582 dependencies = [ 3583 "bytes", 3584 "futures-core", 3585 "futures-sink", 3586 "pin-project-lite", 3587 "tokio", 3588 ] 3589 3590 [[package]] 3591 name = "tower" 3592 version = "0.5.2" 3593 source = "registry+https://github.com/rust-lang/crates.io-index" 3594 checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" ··· 3600 "tokio", 3601 "tower-layer", 3602 "tower-service", 3603 ] 3604 3605 [[package]] ··· 3615 "http-body", 3616 "iri-string", 3617 "pin-project-lite", 3618 - "tower", 3619 "tower-layer", 3620 "tower-service", 3621 ] ··· 3638 source = "registry+https://github.com/rust-lang/crates.io-index" 3639 checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 3640 dependencies = [ 3641 "pin-project-lite", 3642 "tracing-attributes", 3643 "tracing-core", ··· 3651 dependencies = [ 3652 "proc-macro2", 3653 "quote", 3654 - "syn 2.0.108", 3655 ] 3656 3657 [[package]] ··· 3661 checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 3662 dependencies = [ 3663 "once_cell", 3664 ] 3665 3666 [[package]] ··· 3671 dependencies = [ 3672 "proc-macro2", 3673 "quote", 3674 - "syn 2.0.108", 3675 ] 3676 3677 [[package]] ··· 3687 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3688 3689 [[package]] 3690 name = "twoway" 3691 version = "0.1.8" 3692 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3736 version = "0.2.2" 3737 source = "registry+https://github.com/rust-lang/crates.io-index" 3738 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 3739 3740 [[package]] 3741 name = "unsigned-varint" ··· 3786 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 3787 3788 [[package]] 3789 name = "version_check" 3790 version = "0.9.5" 3791 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3870 "bumpalo", 3871 "proc-macro2", 3872 "quote", 3873 - "syn 2.0.108", 3874 "wasm-bindgen-shared", 3875 ] 3876 ··· 3969 ] 3970 3971 [[package]] 3972 name = "windows-core" 3973 version = "0.62.2" 3974 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3982 ] 3983 3984 [[package]] 3985 name = "windows-implement" 3986 version = "0.60.2" 3987 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3989 dependencies = [ 3990 "proc-macro2", 3991 "quote", 3992 - "syn 2.0.108", 3993 ] 3994 3995 [[package]] ··· 4000 dependencies = [ 4001 "proc-macro2", 4002 "quote", 4003 - "syn 2.0.108", 4004 ] 4005 4006 [[package]] ··· 4014 version = "0.2.1" 4015 source = "registry+https://github.com/rust-lang/crates.io-index" 4016 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 4017 4018 [[package]] 4019 name = "windows-registry" ··· 4171 ] 4172 4173 [[package]] 4174 name = "windows_aarch64_gnullvm" 4175 version = "0.42.2" 4176 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4362 4363 [[package]] 4364 name = "wisp-cli" 4365 - version = "0.1.0" 4366 dependencies = [ 4367 "base64 0.22.1", 4368 "bytes", 4369 "clap", 4370 "flate2", 4371 "futures", ··· 4378 "jacquard-oauth", 4379 "miette", 4380 "mime_guess", 4381 "reqwest", 4382 "rustversion", 4383 "serde", 4384 "serde_json", 4385 "shellexpand", 4386 "tokio", 4387 "walkdir", 4388 ] 4389 ··· 4435 dependencies = [ 4436 "proc-macro2", 4437 "quote", 4438 - "syn 2.0.108", 4439 "synstructure", 4440 ] 4441 ··· 4456 dependencies = [ 4457 "proc-macro2", 4458 "quote", 4459 - "syn 2.0.108", 4460 ] 4461 4462 [[package]] ··· 4476 dependencies = [ 4477 "proc-macro2", 4478 "quote", 4479 - "syn 2.0.108", 4480 "synstructure", 4481 ] 4482 ··· 4519 dependencies = [ 4520 "proc-macro2", 4521 "quote", 4522 - "syn 2.0.108", 4523 ]
··· 139 140 [[package]] 141 name = "async-compression" 142 + version = "0.4.33" 143 source = "registry+https://github.com/rust-lang/crates.io-index" 144 + checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" 145 dependencies = [ 146 "compression-codecs", 147 "compression-core", ··· 158 dependencies = [ 159 "proc-macro2", 160 "quote", 161 + "syn 2.0.110", 162 ] 163 164 [[package]] ··· 174 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 175 176 [[package]] 177 + name = "axum" 178 + version = "0.7.9" 179 + source = "registry+https://github.com/rust-lang/crates.io-index" 180 + checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" 181 + dependencies = [ 182 + "async-trait", 183 + "axum-core", 184 + "bytes", 185 + "futures-util", 186 + "http", 187 + "http-body", 188 + "http-body-util", 189 + "hyper", 190 + "hyper-util", 191 + "itoa", 192 + "matchit", 193 + "memchr", 194 + "mime", 195 + "percent-encoding", 196 + "pin-project-lite", 197 + "rustversion", 198 + "serde", 199 + "serde_json", 200 + "serde_path_to_error", 201 + "serde_urlencoded", 202 + "sync_wrapper", 203 + "tokio", 204 + "tower 0.5.2", 205 + "tower-layer", 206 + "tower-service", 207 + "tracing", 208 + ] 209 + 210 + [[package]] 211 + name = "axum-core" 212 + version = "0.4.5" 213 + source = "registry+https://github.com/rust-lang/crates.io-index" 214 + checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" 215 + dependencies = [ 216 + "async-trait", 217 + "bytes", 218 + "futures-util", 219 + "http", 220 + "http-body", 221 + "http-body-util", 222 + "mime", 223 + "pin-project-lite", 224 + "rustversion", 225 + "sync_wrapper", 226 + "tower-layer", 227 + "tower-service", 228 + "tracing", 229 + ] 230 + 231 + [[package]] 232 name = "backtrace" 233 version = "0.3.76" 234 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 329 "proc-macro2", 330 "quote", 331 "rustversion", 332 + "syn 2.0.110", 333 ] 334 335 [[package]] ··· 403 checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 404 405 [[package]] 406 + name = "byteorder" 407 + version = "1.5.0" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 410 + 411 + [[package]] 412 name = "bytes" 413 version = "1.10.1" 414 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 428 429 [[package]] 430 name = "cc" 431 + version = "1.2.45" 432 source = "registry+https://github.com/rust-lang/crates.io-index" 433 + checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" 434 dependencies = [ 435 "find-msvc-tools", 436 "shlex", ··· 555 "heck 0.5.0", 556 "proc-macro2", 557 "quote", 558 + "syn 2.0.110", 559 ] 560 561 [[package]] ··· 582 583 [[package]] 584 name = "compression-codecs" 585 + version = "0.4.32" 586 source = "registry+https://github.com/rust-lang/crates.io-index" 587 + checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" 588 dependencies = [ 589 "compression-core", 590 "flate2", ··· 593 594 [[package]] 595 name = "compression-core" 596 + version = "0.4.30" 597 source = "registry+https://github.com/rust-lang/crates.io-index" 598 + checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582" 599 600 [[package]] 601 name = "const-oid" ··· 610 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 611 612 [[package]] 613 + name = "cordyceps" 614 + version = "0.3.4" 615 + source = "registry+https://github.com/rust-lang/crates.io-index" 616 + checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" 617 + dependencies = [ 618 + "loom", 619 + "tracing", 620 + ] 621 + 622 + [[package]] 623 name = "core-foundation" 624 version = "0.9.4" 625 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 736 "proc-macro2", 737 "quote", 738 "strsim", 739 + "syn 2.0.110", 740 ] 741 742 [[package]] ··· 747 dependencies = [ 748 "darling_core", 749 "quote", 750 + "syn 2.0.110", 751 ] 752 753 [[package]] ··· 787 checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" 788 dependencies = [ 789 "data-encoding", 790 + "syn 2.0.110", 791 ] 792 793 [[package]] ··· 822 ] 823 824 [[package]] 825 + name = "derive_more" 826 + version = "1.0.0" 827 + source = "registry+https://github.com/rust-lang/crates.io-index" 828 + checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" 829 + dependencies = [ 830 + "derive_more-impl", 831 + ] 832 + 833 + [[package]] 834 + name = "derive_more-impl" 835 + version = "1.0.0" 836 + source = "registry+https://github.com/rust-lang/crates.io-index" 837 + checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" 838 + dependencies = [ 839 + "proc-macro2", 840 + "quote", 841 + "syn 2.0.110", 842 + "unicode-xid", 843 + ] 844 + 845 + [[package]] 846 + name = "diatomic-waker" 847 + version = "0.2.3" 848 + source = "registry+https://github.com/rust-lang/crates.io-index" 849 + checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" 850 + 851 + [[package]] 852 name = "digest" 853 version = "0.10.7" 854 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 889 dependencies = [ 890 "proc-macro2", 891 "quote", 892 + "syn 2.0.110", 893 ] 894 895 [[package]] ··· 950 "heck 0.5.0", 951 "proc-macro2", 952 "quote", 953 + "syn 2.0.110", 954 ] 955 956 [[package]] ··· 1054 ] 1055 1056 [[package]] 1057 + name = "futures-buffered" 1058 + version = "0.2.12" 1059 + source = "registry+https://github.com/rust-lang/crates.io-index" 1060 + checksum = "a8e0e1f38ec07ba4abbde21eed377082f17ccb988be9d988a5adbf4bafc118fd" 1061 + dependencies = [ 1062 + "cordyceps", 1063 + "diatomic-waker", 1064 + "futures-core", 1065 + "pin-project-lite", 1066 + "spin 0.10.0", 1067 + ] 1068 + 1069 + [[package]] 1070 name = "futures-channel" 1071 version = "0.3.31" 1072 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1100 checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 1101 1102 [[package]] 1103 + name = "futures-lite" 1104 + version = "2.6.1" 1105 + source = "registry+https://github.com/rust-lang/crates.io-index" 1106 + checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" 1107 + dependencies = [ 1108 + "fastrand", 1109 + "futures-core", 1110 + "futures-io", 1111 + "parking", 1112 + "pin-project-lite", 1113 + ] 1114 + 1115 + [[package]] 1116 name = "futures-macro" 1117 version = "0.3.31" 1118 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1120 dependencies = [ 1121 "proc-macro2", 1122 "quote", 1123 + "syn 2.0.110", 1124 ] 1125 1126 [[package]] ··· 1151 "pin-project-lite", 1152 "pin-utils", 1153 "slab", 1154 + ] 1155 + 1156 + [[package]] 1157 + name = "generator" 1158 + version = "0.8.7" 1159 + source = "registry+https://github.com/rust-lang/crates.io-index" 1160 + checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" 1161 + dependencies = [ 1162 + "cc", 1163 + "cfg-if", 1164 + "libc", 1165 + "log", 1166 + "rustversion", 1167 + "windows", 1168 ] 1169 1170 [[package]] ··· 1374 "markup5ever", 1375 "proc-macro2", 1376 "quote", 1377 + "syn 2.0.110", 1378 ] 1379 1380 [[package]] ··· 1412 ] 1413 1414 [[package]] 1415 + name = "http-range-header" 1416 + version = "0.4.2" 1417 + source = "registry+https://github.com/rust-lang/crates.io-index" 1418 + checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" 1419 + 1420 + [[package]] 1421 name = "httparse" 1422 version = "1.10.1" 1423 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1431 1432 [[package]] 1433 name = "hyper" 1434 + version = "1.8.0" 1435 source = "registry+https://github.com/rust-lang/crates.io-index" 1436 + checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f" 1437 dependencies = [ 1438 "atomic-waker", 1439 "bytes", ··· 1443 "http", 1444 "http-body", 1445 "httparse", 1446 + "httpdate", 1447 "itoa", 1448 "pin-project-lite", 1449 "pin-utils", ··· 1507 "js-sys", 1508 "log", 1509 "wasm-bindgen", 1510 + "windows-core 0.62.2", 1511 ] 1512 1513 [[package]] ··· 1699 1700 [[package]] 1701 name = "iri-string" 1702 + version = "0.7.9" 1703 source = "registry+https://github.com/rust-lang/crates.io-index" 1704 + checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" 1705 dependencies = [ 1706 "memchr", 1707 "serde", ··· 1728 [[package]] 1729 name = "jacquard" 1730 version = "0.9.0" 1731 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1732 dependencies = [ 1733 "bytes", 1734 "getrandom 0.2.16", ··· 1756 [[package]] 1757 name = "jacquard-api" 1758 version = "0.9.0" 1759 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1760 dependencies = [ 1761 "bon", 1762 "bytes", ··· 1774 [[package]] 1775 name = "jacquard-common" 1776 version = "0.9.0" 1777 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1778 dependencies = [ 1779 "base64 0.22.1", 1780 "bon", 1781 "bytes", 1782 "chrono", 1783 + "ciborium", 1784 "cid", 1785 + "futures", 1786 "getrandom 0.2.16", 1787 "getrandom 0.3.4", 1788 "http", ··· 1792 "miette", 1793 "multibase", 1794 "multihash", 1795 + "n0-future", 1796 "ouroboros", 1797 "p256", 1798 "rand 0.9.2", ··· 1806 "smol_str", 1807 "thiserror 2.0.17", 1808 "tokio", 1809 + "tokio-tungstenite-wasm", 1810 "tokio-util", 1811 "trait-variant", 1812 "url", ··· 1815 [[package]] 1816 name = "jacquard-derive" 1817 version = "0.9.0" 1818 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1819 dependencies = [ 1820 "heck 0.5.0", 1821 "jacquard-lexicon", 1822 "proc-macro2", 1823 "quote", 1824 + "syn 2.0.110", 1825 ] 1826 1827 [[package]] 1828 name = "jacquard-identity" 1829 + version = "0.9.1" 1830 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1831 dependencies = [ 1832 "bon", 1833 "bytes", ··· 1852 1853 [[package]] 1854 name = "jacquard-lexicon" 1855 + version = "0.9.1" 1856 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1857 dependencies = [ 1858 "cid", 1859 "dashmap", ··· 1871 "serde_repr", 1872 "serde_with", 1873 "sha2", 1874 + "syn 2.0.110", 1875 "thiserror 2.0.17", 1876 "unicode-segmentation", 1877 ] ··· 1879 [[package]] 1880 name = "jacquard-oauth" 1881 version = "0.9.0" 1882 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65" 1883 dependencies = [ 1884 "base64 0.22.1", 1885 "bytes", ··· 2005 source = "registry+https://github.com/rust-lang/crates.io-index" 2006 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 2007 dependencies = [ 2008 + "spin 0.9.8", 2009 ] 2010 2011 [[package]] ··· 2065 checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" 2066 2067 [[package]] 2068 + name = "loom" 2069 + version = "0.7.2" 2070 + source = "registry+https://github.com/rust-lang/crates.io-index" 2071 + checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" 2072 + dependencies = [ 2073 + "cfg-if", 2074 + "generator", 2075 + "scoped-tls", 2076 + "tracing", 2077 + "tracing-subscriber", 2078 + ] 2079 + 2080 + [[package]] 2081 name = "lru-cache" 2082 version = "0.1.2" 2083 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2134 "quote", 2135 "syn 1.0.109", 2136 ] 2137 + 2138 + [[package]] 2139 + name = "matchers" 2140 + version = "0.2.0" 2141 + source = "registry+https://github.com/rust-lang/crates.io-index" 2142 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 2143 + dependencies = [ 2144 + "regex-automata", 2145 + ] 2146 + 2147 + [[package]] 2148 + name = "matchit" 2149 + version = "0.7.3" 2150 + source = "registry+https://github.com/rust-lang/crates.io-index" 2151 + checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 2152 2153 [[package]] 2154 name = "memchr" ··· 2183 dependencies = [ 2184 "proc-macro2", 2185 "quote", 2186 + "syn 2.0.110", 2187 ] 2188 2189 [[package]] ··· 2285 ] 2286 2287 [[package]] 2288 + name = "n0-future" 2289 + version = "0.1.3" 2290 + source = "registry+https://github.com/rust-lang/crates.io-index" 2291 + checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" 2292 + dependencies = [ 2293 + "cfg_aliases", 2294 + "derive_more", 2295 + "futures-buffered", 2296 + "futures-lite", 2297 + "futures-util", 2298 + "js-sys", 2299 + "pin-project", 2300 + "send_wrapper", 2301 + "tokio", 2302 + "tokio-util", 2303 + "wasm-bindgen", 2304 + "wasm-bindgen-futures", 2305 + "web-time", 2306 + ] 2307 + 2308 + [[package]] 2309 name = "ndk-context" 2310 version = "0.1.1" 2311 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2328 ] 2329 2330 [[package]] 2331 + name = "nu-ansi-term" 2332 + version = "0.50.3" 2333 + source = "registry+https://github.com/rust-lang/crates.io-index" 2334 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2335 + dependencies = [ 2336 + "windows-sys 0.61.2", 2337 + ] 2338 + 2339 + [[package]] 2340 name = "num-bigint-dig" 2341 + version = "0.8.6" 2342 source = "registry+https://github.com/rust-lang/crates.io-index" 2343 + checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" 2344 dependencies = [ 2345 "lazy_static", 2346 "libm", ··· 2454 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 2455 2456 [[package]] 2457 + name = "openssl-probe" 2458 + version = "0.1.6" 2459 + source = "registry+https://github.com/rust-lang/crates.io-index" 2460 + checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 2461 + 2462 + [[package]] 2463 name = "option-ext" 2464 version = "0.2.0" 2465 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2486 "proc-macro2", 2487 "proc-macro2-diagnostics", 2488 "quote", 2489 + "syn 2.0.110", 2490 ] 2491 2492 [[package]] ··· 2516 "elliptic-curve", 2517 "primeorder", 2518 ] 2519 + 2520 + [[package]] 2521 + name = "parking" 2522 + version = "2.2.1" 2523 + source = "registry+https://github.com/rust-lang/crates.io-index" 2524 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 2525 2526 [[package]] 2527 name = "parking_lot" ··· 2600 ] 2601 2602 [[package]] 2603 + name = "pin-project" 2604 + version = "1.1.10" 2605 + source = "registry+https://github.com/rust-lang/crates.io-index" 2606 + checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" 2607 + dependencies = [ 2608 + "pin-project-internal", 2609 + ] 2610 + 2611 + [[package]] 2612 + name = "pin-project-internal" 2613 + version = "1.1.10" 2614 + source = "registry+https://github.com/rust-lang/crates.io-index" 2615 + checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" 2616 + dependencies = [ 2617 + "proc-macro2", 2618 + "quote", 2619 + "syn 2.0.110", 2620 + ] 2621 + 2622 + [[package]] 2623 name = "pin-project-lite" 2624 version = "0.2.16" 2625 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2689 checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2690 dependencies = [ 2691 "proc-macro2", 2692 + "syn 2.0.110", 2693 ] 2694 2695 [[package]] ··· 2742 dependencies = [ 2743 "proc-macro2", 2744 "quote", 2745 + "syn 2.0.110", 2746 "version_check", 2747 "yansi", 2748 ] ··· 2810 2811 [[package]] 2812 name = "quote" 2813 + version = "1.0.42" 2814 source = "registry+https://github.com/rust-lang/crates.io-index" 2815 + checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" 2816 dependencies = [ 2817 "proc-macro2", 2818 ] ··· 2925 dependencies = [ 2926 "proc-macro2", 2927 "quote", 2928 + "syn 2.0.110", 2929 ] 2930 2931 [[package]] ··· 2991 "tokio", 2992 "tokio-rustls", 2993 "tokio-util", 2994 + "tower 0.5.2", 2995 + "tower-http 0.6.6", 2996 "tower-service", 2997 "url", 2998 "wasm-bindgen", ··· 3103 3104 [[package]] 3105 name = "rustls" 3106 + version = "0.23.35" 3107 source = "registry+https://github.com/rust-lang/crates.io-index" 3108 + checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" 3109 dependencies = [ 3110 "once_cell", 3111 "ring", ··· 3113 "rustls-webpki", 3114 "subtle", 3115 "zeroize", 3116 + ] 3117 + 3118 + [[package]] 3119 + name = "rustls-native-certs" 3120 + version = "0.8.2" 3121 + source = "registry+https://github.com/rust-lang/crates.io-index" 3122 + checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" 3123 + dependencies = [ 3124 + "openssl-probe", 3125 + "rustls-pki-types", 3126 + "schannel", 3127 + "security-framework", 3128 ] 3129 3130 [[package]] ··· 3176 ] 3177 3178 [[package]] 3179 + name = "schannel" 3180 + version = "0.1.28" 3181 + source = "registry+https://github.com/rust-lang/crates.io-index" 3182 + checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" 3183 + dependencies = [ 3184 + "windows-sys 0.61.2", 3185 + ] 3186 + 3187 + [[package]] 3188 name = "schemars" 3189 version = "0.9.0" 3190 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3198 3199 [[package]] 3200 name = "schemars" 3201 + version = "1.1.0" 3202 source = "registry+https://github.com/rust-lang/crates.io-index" 3203 + checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" 3204 dependencies = [ 3205 "dyn-clone", 3206 "ref-cast", ··· 3209 ] 3210 3211 [[package]] 3212 + name = "scoped-tls" 3213 + version = "1.0.1" 3214 + source = "registry+https://github.com/rust-lang/crates.io-index" 3215 + checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 3216 + 3217 + [[package]] 3218 name = "scopeguard" 3219 version = "1.2.0" 3220 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3235 ] 3236 3237 [[package]] 3238 + name = "security-framework" 3239 + version = "3.5.1" 3240 + source = "registry+https://github.com/rust-lang/crates.io-index" 3241 + checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" 3242 + dependencies = [ 3243 + "bitflags", 3244 + "core-foundation 0.10.1", 3245 + "core-foundation-sys", 3246 + "libc", 3247 + "security-framework-sys", 3248 + ] 3249 + 3250 + [[package]] 3251 + name = "security-framework-sys" 3252 + version = "2.15.0" 3253 + source = "registry+https://github.com/rust-lang/crates.io-index" 3254 + checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" 3255 + dependencies = [ 3256 + "core-foundation-sys", 3257 + "libc", 3258 + ] 3259 + 3260 + [[package]] 3261 + name = "send_wrapper" 3262 + version = "0.6.0" 3263 + source = "registry+https://github.com/rust-lang/crates.io-index" 3264 + checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" 3265 + 3266 + [[package]] 3267 name = "serde" 3268 version = "1.0.228" 3269 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3300 dependencies = [ 3301 "proc-macro2", 3302 "quote", 3303 + "syn 2.0.110", 3304 ] 3305 3306 [[package]] ··· 3342 ] 3343 3344 [[package]] 3345 + name = "serde_path_to_error" 3346 + version = "0.1.20" 3347 + source = "registry+https://github.com/rust-lang/crates.io-index" 3348 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 3349 + dependencies = [ 3350 + "itoa", 3351 + "serde", 3352 + "serde_core", 3353 + ] 3354 + 3355 + [[package]] 3356 name = "serde_repr" 3357 version = "0.1.20" 3358 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3360 dependencies = [ 3361 "proc-macro2", 3362 "quote", 3363 + "syn 2.0.110", 3364 ] 3365 3366 [[package]] ··· 3387 "indexmap 1.9.3", 3388 "indexmap 2.12.0", 3389 "schemars 0.9.0", 3390 + "schemars 1.1.0", 3391 "serde_core", 3392 "serde_json", 3393 "serde_with_macros", ··· 3403 "darling", 3404 "proc-macro2", 3405 "quote", 3406 + "syn 2.0.110", 3407 + ] 3408 + 3409 + [[package]] 3410 + name = "sha1" 3411 + version = "0.10.6" 3412 + source = "registry+https://github.com/rust-lang/crates.io-index" 3413 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 3414 + dependencies = [ 3415 + "cfg-if", 3416 + "cpufeatures", 3417 + "digest", 3418 ] 3419 3420 [[package]] ··· 3432 "cfg-if", 3433 "cpufeatures", 3434 "digest", 3435 + ] 3436 + 3437 + [[package]] 3438 + name = "sharded-slab" 3439 + version = "0.1.7" 3440 + source = "registry+https://github.com/rust-lang/crates.io-index" 3441 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3442 + dependencies = [ 3443 + "lazy_static", 3444 ] 3445 3446 [[package]] ··· 3538 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3539 3540 [[package]] 3541 + name = "spin" 3542 + version = "0.10.0" 3543 + source = "registry+https://github.com/rust-lang/crates.io-index" 3544 + checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" 3545 + 3546 + [[package]] 3547 name = "spki" 3548 version = "0.7.3" 3549 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3575 "quote", 3576 "serde", 3577 "sha2", 3578 + "syn 2.0.110", 3579 "thiserror 1.0.69", 3580 ] 3581 ··· 3656 3657 [[package]] 3658 name = "syn" 3659 + version = "2.0.110" 3660 source = "registry+https://github.com/rust-lang/crates.io-index" 3661 + checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" 3662 dependencies = [ 3663 "proc-macro2", 3664 "quote", ··· 3682 dependencies = [ 3683 "proc-macro2", 3684 "quote", 3685 + "syn 2.0.110", 3686 ] 3687 3688 [[package]] ··· 3782 dependencies = [ 3783 "proc-macro2", 3784 "quote", 3785 + "syn 2.0.110", 3786 ] 3787 3788 [[package]] ··· 3793 dependencies = [ 3794 "proc-macro2", 3795 "quote", 3796 + "syn 2.0.110", 3797 + ] 3798 + 3799 + [[package]] 3800 + name = "thread_local" 3801 + version = "1.1.9" 3802 + source = "registry+https://github.com/rust-lang/crates.io-index" 3803 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 3804 + dependencies = [ 3805 + "cfg-if", 3806 ] 3807 3808 [[package]] ··· 3909 dependencies = [ 3910 "proc-macro2", 3911 "quote", 3912 + "syn 2.0.110", 3913 ] 3914 3915 [[package]] ··· 3923 ] 3924 3925 [[package]] 3926 + name = "tokio-tungstenite" 3927 + version = "0.24.0" 3928 + source = "registry+https://github.com/rust-lang/crates.io-index" 3929 + checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" 3930 + dependencies = [ 3931 + "futures-util", 3932 + "log", 3933 + "rustls", 3934 + "rustls-native-certs", 3935 + "rustls-pki-types", 3936 + "tokio", 3937 + "tokio-rustls", 3938 + "tungstenite", 3939 + ] 3940 + 3941 + [[package]] 3942 + name = "tokio-tungstenite-wasm" 3943 + version = "0.4.0" 3944 + source = "registry+https://github.com/rust-lang/crates.io-index" 3945 + checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae" 3946 + dependencies = [ 3947 + "futures-channel", 3948 + "futures-util", 3949 + "http", 3950 + "httparse", 3951 + "js-sys", 3952 + "rustls", 3953 + "thiserror 1.0.69", 3954 + "tokio", 3955 + "tokio-tungstenite", 3956 + "wasm-bindgen", 3957 + "web-sys", 3958 + ] 3959 + 3960 + [[package]] 3961 name = "tokio-util" 3962 + version = "0.7.17" 3963 source = "registry+https://github.com/rust-lang/crates.io-index" 3964 + checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" 3965 dependencies = [ 3966 "bytes", 3967 "futures-core", 3968 "futures-sink", 3969 + "futures-util", 3970 "pin-project-lite", 3971 "tokio", 3972 ] 3973 3974 [[package]] 3975 name = "tower" 3976 + version = "0.4.13" 3977 + source = "registry+https://github.com/rust-lang/crates.io-index" 3978 + checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" 3979 + dependencies = [ 3980 + "tower-layer", 3981 + "tower-service", 3982 + "tracing", 3983 + ] 3984 + 3985 + [[package]] 3986 + name = "tower" 3987 version = "0.5.2" 3988 source = "registry+https://github.com/rust-lang/crates.io-index" 3989 checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" ··· 3995 "tokio", 3996 "tower-layer", 3997 "tower-service", 3998 + "tracing", 3999 + ] 4000 + 4001 + [[package]] 4002 + name = "tower-http" 4003 + version = "0.5.2" 4004 + source = "registry+https://github.com/rust-lang/crates.io-index" 4005 + checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" 4006 + dependencies = [ 4007 + "async-compression", 4008 + "bitflags", 4009 + "bytes", 4010 + "futures-core", 4011 + "futures-util", 4012 + "http", 4013 + "http-body", 4014 + "http-body-util", 4015 + "http-range-header", 4016 + "httpdate", 4017 + "mime", 4018 + "mime_guess", 4019 + "percent-encoding", 4020 + "pin-project-lite", 4021 + "tokio", 4022 + "tokio-util", 4023 + "tower-layer", 4024 + "tower-service", 4025 + "tracing", 4026 ] 4027 4028 [[package]] ··· 4038 "http-body", 4039 "iri-string", 4040 "pin-project-lite", 4041 + "tower 0.5.2", 4042 "tower-layer", 4043 "tower-service", 4044 ] ··· 4061 source = "registry+https://github.com/rust-lang/crates.io-index" 4062 checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 4063 dependencies = [ 4064 + "log", 4065 "pin-project-lite", 4066 "tracing-attributes", 4067 "tracing-core", ··· 4075 dependencies = [ 4076 "proc-macro2", 4077 "quote", 4078 + "syn 2.0.110", 4079 ] 4080 4081 [[package]] ··· 4085 checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" 4086 dependencies = [ 4087 "once_cell", 4088 + "valuable", 4089 + ] 4090 + 4091 + [[package]] 4092 + name = "tracing-log" 4093 + version = "0.2.0" 4094 + source = "registry+https://github.com/rust-lang/crates.io-index" 4095 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 4096 + dependencies = [ 4097 + "log", 4098 + "once_cell", 4099 + "tracing-core", 4100 + ] 4101 + 4102 + [[package]] 4103 + name = "tracing-subscriber" 4104 + version = "0.3.20" 4105 + source = "registry+https://github.com/rust-lang/crates.io-index" 4106 + checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" 4107 + dependencies = [ 4108 + "matchers", 4109 + "nu-ansi-term", 4110 + "once_cell", 4111 + "regex-automata", 4112 + "sharded-slab", 4113 + "smallvec", 4114 + "thread_local", 4115 + "tracing", 4116 + "tracing-core", 4117 + "tracing-log", 4118 ] 4119 4120 [[package]] ··· 4125 dependencies = [ 4126 "proc-macro2", 4127 "quote", 4128 + "syn 2.0.110", 4129 ] 4130 4131 [[package]] ··· 4141 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 4142 4143 [[package]] 4144 + name = "tungstenite" 4145 + version = "0.24.0" 4146 + source = "registry+https://github.com/rust-lang/crates.io-index" 4147 + checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" 4148 + dependencies = [ 4149 + "byteorder", 4150 + "bytes", 4151 + "data-encoding", 4152 + "http", 4153 + "httparse", 4154 + "log", 4155 + "rand 0.8.5", 4156 + "rustls", 4157 + "rustls-pki-types", 4158 + "sha1", 4159 + "thiserror 1.0.69", 4160 + "utf-8", 4161 + ] 4162 + 4163 + [[package]] 4164 name = "twoway" 4165 version = "0.1.8" 4166 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4210 version = "0.2.2" 4211 source = "registry+https://github.com/rust-lang/crates.io-index" 4212 checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" 4213 + 4214 + [[package]] 4215 + name = "unicode-xid" 4216 + version = "0.2.6" 4217 + source = "registry+https://github.com/rust-lang/crates.io-index" 4218 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 4219 4220 [[package]] 4221 name = "unsigned-varint" ··· 4266 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 4267 4268 [[package]] 4269 + name = "valuable" 4270 + version = "0.1.1" 4271 + source = "registry+https://github.com/rust-lang/crates.io-index" 4272 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 4273 + 4274 + [[package]] 4275 name = "version_check" 4276 version = "0.9.5" 4277 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4356 "bumpalo", 4357 "proc-macro2", 4358 "quote", 4359 + "syn 2.0.110", 4360 "wasm-bindgen-shared", 4361 ] 4362 ··· 4455 ] 4456 4457 [[package]] 4458 + name = "windows" 4459 + version = "0.61.3" 4460 + source = "registry+https://github.com/rust-lang/crates.io-index" 4461 + checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" 4462 + dependencies = [ 4463 + "windows-collections", 4464 + "windows-core 0.61.2", 4465 + "windows-future", 4466 + "windows-link 0.1.3", 4467 + "windows-numerics", 4468 + ] 4469 + 4470 + [[package]] 4471 + name = "windows-collections" 4472 + version = "0.2.0" 4473 + source = "registry+https://github.com/rust-lang/crates.io-index" 4474 + checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" 4475 + dependencies = [ 4476 + "windows-core 0.61.2", 4477 + ] 4478 + 4479 + [[package]] 4480 + name = "windows-core" 4481 + version = "0.61.2" 4482 + source = "registry+https://github.com/rust-lang/crates.io-index" 4483 + checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" 4484 + dependencies = [ 4485 + "windows-implement", 4486 + "windows-interface", 4487 + "windows-link 0.1.3", 4488 + "windows-result 0.3.4", 4489 + "windows-strings 0.4.2", 4490 + ] 4491 + 4492 + [[package]] 4493 name = "windows-core" 4494 version = "0.62.2" 4495 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4503 ] 4504 4505 [[package]] 4506 + name = "windows-future" 4507 + version = "0.2.1" 4508 + source = "registry+https://github.com/rust-lang/crates.io-index" 4509 + checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" 4510 + dependencies = [ 4511 + "windows-core 0.61.2", 4512 + "windows-link 0.1.3", 4513 + "windows-threading", 4514 + ] 4515 + 4516 + [[package]] 4517 name = "windows-implement" 4518 version = "0.60.2" 4519 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4521 dependencies = [ 4522 "proc-macro2", 4523 "quote", 4524 + "syn 2.0.110", 4525 ] 4526 4527 [[package]] ··· 4532 dependencies = [ 4533 "proc-macro2", 4534 "quote", 4535 + "syn 2.0.110", 4536 ] 4537 4538 [[package]] ··· 4546 version = "0.2.1" 4547 source = "registry+https://github.com/rust-lang/crates.io-index" 4548 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 4549 + 4550 + [[package]] 4551 + name = "windows-numerics" 4552 + version = "0.2.0" 4553 + source = "registry+https://github.com/rust-lang/crates.io-index" 4554 + checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" 4555 + dependencies = [ 4556 + "windows-core 0.61.2", 4557 + "windows-link 0.1.3", 4558 + ] 4559 4560 [[package]] 4561 name = "windows-registry" ··· 4713 ] 4714 4715 [[package]] 4716 + name = "windows-threading" 4717 + version = "0.1.0" 4718 + source = "registry+https://github.com/rust-lang/crates.io-index" 4719 + checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" 4720 + dependencies = [ 4721 + "windows-link 0.1.3", 4722 + ] 4723 + 4724 + [[package]] 4725 name = "windows_aarch64_gnullvm" 4726 version = "0.42.2" 4727 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4913 4914 [[package]] 4915 name = "wisp-cli" 4916 + version = "0.2.0" 4917 dependencies = [ 4918 + "axum", 4919 "base64 0.22.1", 4920 "bytes", 4921 + "chrono", 4922 "clap", 4923 "flate2", 4924 "futures", ··· 4931 "jacquard-oauth", 4932 "miette", 4933 "mime_guess", 4934 + "multibase", 4935 + "multihash", 4936 + "n0-future", 4937 "reqwest", 4938 "rustversion", 4939 "serde", 4940 "serde_json", 4941 + "sha2", 4942 "shellexpand", 4943 "tokio", 4944 + "tower 0.4.13", 4945 + "tower-http 0.5.2", 4946 + "url", 4947 "walkdir", 4948 ] 4949 ··· 4995 dependencies = [ 4996 "proc-macro2", 4997 "quote", 4998 + "syn 2.0.110", 4999 "synstructure", 5000 ] 5001 ··· 5016 dependencies = [ 5017 "proc-macro2", 5018 "quote", 5019 + "syn 2.0.110", 5020 ] 5021 5022 [[package]] ··· 5036 dependencies = [ 5037 "proc-macro2", 5038 "quote", 5039 + "syn 2.0.110", 5040 "synstructure", 5041 ] 5042 ··· 5079 dependencies = [ 5080 "proc-macro2", 5081 "quote", 5082 + "syn 2.0.110", 5083 ]
+17 -8
cli/Cargo.toml
··· 1 [package] 2 name = "wisp-cli" 3 - version = "0.1.0" 4 edition = "2024" 5 6 [features] ··· 8 place_wisp = [] 9 10 [dependencies] 11 - jacquard = { path = "jacquard/crates/jacquard", features = ["loopback"] } 12 - jacquard-oauth = { path = "jacquard/crates/jacquard-oauth" } 13 - jacquard-api = { path = "jacquard/crates/jacquard-api" } 14 - jacquard-common = { path = "jacquard/crates/jacquard-common" } 15 - jacquard-identity = { path = "jacquard/crates/jacquard-identity", features = ["dns"] } 16 - jacquard-derive = { path = "jacquard/crates/jacquard-derive" } 17 - jacquard-lexicon = { path = "jacquard/crates/jacquard-lexicon" } 18 clap = { version = "4.5.51", features = ["derive"] } 19 tokio = { version = "1.48", features = ["full"] } 20 miette = { version = "7.6.0", features = ["fancy"] } ··· 30 mime_guess = "2.0" 31 bytes = "1.10" 32 futures = "0.3.31"
··· 1 [package] 2 name = "wisp-cli" 3 + version = "0.2.0" 4 edition = "2024" 5 6 [features] ··· 8 place_wisp = [] 9 10 [dependencies] 11 + jacquard = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["loopback"] } 12 + jacquard-oauth = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 13 + jacquard-api = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 14 + jacquard-common = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["websocket"] } 15 + jacquard-identity = { git = "https://tangled.org/@nonbinary.computer/jacquard", features = ["dns"] } 16 + jacquard-derive = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 17 + jacquard-lexicon = { git = "https://tangled.org/@nonbinary.computer/jacquard" } 18 clap = { version = "4.5.51", features = ["derive"] } 19 tokio = { version = "1.48", features = ["full"] } 20 miette = { version = "7.6.0", features = ["fancy"] } ··· 30 mime_guess = "2.0" 31 bytes = "1.10" 32 futures = "0.3.31" 33 + multihash = "0.19.3" 34 + multibase = "0.9" 35 + sha2 = "0.10" 36 + axum = "0.7" 37 + tower-http = { version = "0.5", features = ["fs", "compression-gzip"] } 38 + tower = "0.4" 39 + n0-future = "0.1" 40 + chrono = "0.4" 41 + url = "2.5"
+271
cli/README.md
···
··· 1 + # Wisp CLI 2 + 3 + A command-line tool for deploying static sites to your AT Protocol repo to be served on [wisp.place](https://wisp.place), an AT indexer to serve such sites. 4 + 5 + ## Why? 6 + 7 + The PDS serves as a way to verfiably, cryptographically prove that you own your site. That it was you (or at least someone who controls your account) who uploaded it. It is also a manifest of each file in the site to ensure file integrity. Keeping hosting seperate ensures that you could move your site across other servers or even serverless solutions to ensure speedy delievery while keeping it backed by an absolute source of truth being the manifest record and the blobs of each file in your repo. 8 + 9 + ## Features 10 + 11 + - Deploy static sites directly to your AT Protocol repo 12 + - Supports both OAuth and app password authentication 13 + - Preserves directory structure and file integrity 14 + 15 + ## Soon 16 + 17 + -- Host sites 18 + -- Manage and delete sites 19 + -- Metrics and logs for self hosting. 20 + 21 + ## Installation 22 + 23 + ### From Source 24 + 25 + ```bash 26 + cargo build --release 27 + ``` 28 + 29 + Check out the build scripts for cross complation using nix-shell. 30 + 31 + The binary will be available at `target/release/wisp-cli`. 32 + 33 + ## Usage 34 + 35 + ### Basic Deployment 36 + 37 + Deploy the current directory: 38 + 39 + ```bash 40 + wisp-cli nekomimi.ppet --path . --site my-site 41 + ``` 42 + 43 + Deploy a specific directory: 44 + 45 + ```bash 46 + wisp-cli alice.bsky.social --path ./dist/ --site my-site 47 + ``` 48 + 49 + ### Authentication Methods 50 + 51 + #### OAuth (Recommended) 52 + 53 + By default, the CLI uses OAuth authentication with a local loopback server: 54 + 55 + ```bash 56 + wisp-cli alice.bsky.social --path ./my-site --site my-site 57 + ``` 58 + 59 + This will: 60 + 1. Open your browser for authentication 61 + 2. Save the session to a file (default: `/tmp/wisp-oauth-session.json`) 62 + 3. Reuse the session for future deployments 63 + 64 + Specify a custom session file location: 65 + 66 + ```bash 67 + wisp-cli alice.bsky.social --path ./my-site --site my-site --store ~/.wisp-session.json 68 + ``` 69 + 70 + #### App Password 71 + 72 + For headless environments or CI/CD, use an app password: 73 + 74 + ```bash 75 + wisp-cli alice.bsky.social --path ./my-site --site my-site --password YOUR_APP_PASSWORD 76 + ``` 77 + 78 + **Note:** When using `--password`, the `--store` option is ignored. 79 + 80 + ## Command-Line Options 81 + 82 + ``` 83 + wisp-cli [OPTIONS] <INPUT> 84 + 85 + Arguments: 86 + <INPUT> Handle (e.g., alice.bsky.social), DID, or PDS URL 87 + 88 + Options: 89 + -p, --path <PATH> Path to the directory containing your static site [default: .] 90 + -s, --site <SITE> Site name (defaults to directory name) 91 + --store <STORE> Path to auth store file (only used with OAuth) [default: /tmp/wisp-oauth-session.json] 92 + --password <PASSWORD> App Password for authentication (alternative to OAuth) 93 + -h, --help Print help 94 + -V, --version Print version 95 + ``` 96 + 97 + ## How It Works 98 + 99 + 1. **Authentication**: Authenticates using OAuth or app password 100 + 2. **File Processing**: 101 + - Recursively walks the directory tree 102 + - Skips hidden files (starting with `.`) 103 + - Detects MIME types automatically 104 + - Compresses files with gzip 105 + - Base64 encodes compressed content 106 + 3. **Upload**: 107 + - Uploads files as blobs to your PDS 108 + - Processes up to 5 files concurrently 109 + - Creates a `place.wisp.fs` record with the site manifest 110 + 4. **Deployment**: Site is immediately available at `https://sites.wisp.place/{did}/{site-name}` 111 + 112 + ## File Processing 113 + 114 + All files are automatically: 115 + 116 + - **Compressed** with gzip (level 9) 117 + - **Base64 encoded** to bypass PDS content sniffing 118 + - **Uploaded** as `application/octet-stream` blobs 119 + - **Stored** with original MIME type metadata 120 + 121 + The hosting service automatically decompresses non HTML/CSS/JS files when serving them. 122 + 123 + ## Limitations 124 + 125 + - **Max file size**: 100MB per file (after compression) (this is a PDS limit, but not enforced by the CLI in case yours is higher) 126 + - **Max file count**: 2000 files 127 + - **Site name** must follow AT Protocol rkey format rules (alphanumeric, hyphens, underscores) 128 + 129 + ## Deploy with CI/CD 130 + 131 + ### GitHub Actions 132 + 133 + ```yaml 134 + name: Deploy to Wisp 135 + on: 136 + push: 137 + branches: [main] 138 + 139 + jobs: 140 + deploy: 141 + runs-on: ubuntu-latest 142 + steps: 143 + - uses: actions/checkout@v3 144 + 145 + - name: Setup Node 146 + uses: actions/setup-node@v3 147 + with: 148 + node-version: '25' 149 + 150 + - name: Install dependencies 151 + run: npm install 152 + 153 + - name: Build site 154 + run: npm run build 155 + 156 + - name: Download Wisp CLI 157 + run: | 158 + curl -L https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 159 + chmod +x wisp-cli 160 + 161 + - name: Deploy to Wisp 162 + env: 163 + WISP_APP_PASSWORD: ${{ secrets.WISP_APP_PASSWORD }} 164 + run: | 165 + ./wisp-cli alice.bsky.social \ 166 + --path ./dist \ 167 + --site my-site \ 168 + --password "$WISP_APP_PASSWORD" 169 + ``` 170 + 171 + ### Tangled.org 172 + 173 + ```yaml 174 + when: 175 + - event: ['push'] 176 + branch: ['main'] 177 + - event: ['manual'] 178 + 179 + engine: 'nixery' 180 + 181 + clone: 182 + skip: false 183 + depth: 1 184 + submodules: false 185 + 186 + dependencies: 187 + nixpkgs: 188 + - nodejs 189 + - coreutils 190 + - curl 191 + github:NixOS/nixpkgs/nixpkgs-unstable: 192 + - bun 193 + 194 + environment: 195 + SITE_PATH: 'dist' 196 + SITE_NAME: 'my-site' 197 + WISP_HANDLE: 'your-handle.bsky.social' 198 + 199 + steps: 200 + - name: build site 201 + command: | 202 + export PATH="$HOME/.nix-profile/bin:$PATH" 203 + 204 + # regenerate lockfile 205 + rm package-lock.json bun.lock 206 + bun install @rolldown/binding-linux-arm64-gnu --save-optional 207 + bun install 208 + 209 + # build with vite 210 + bun node_modules/.bin/vite build 211 + 212 + - name: deploy to wisp 213 + command: | 214 + # Download Wisp CLI 215 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 216 + chmod +x wisp-cli 217 + 218 + # Deploy to Wisp 219 + ./wisp-cli \ 220 + "$WISP_HANDLE" \ 221 + --path "$SITE_PATH" \ 222 + --site "$SITE_NAME" \ 223 + --password "$WISP_APP_PASSWORD" 224 + ``` 225 + 226 + ### Generic Shell Script 227 + 228 + ```bash 229 + # Use app password from environment variable 230 + wisp-cli alice.bsky.social --path ./dist --site my-site --password "$WISP_APP_PASSWORD" 231 + ``` 232 + 233 + ## Output 234 + 235 + Upon successful deployment, you'll see: 236 + 237 + ``` 238 + Deployed site 'my-site': at://did:plc:abc123xyz/place.wisp.fs/my-site 239 + Available at: https://sites.wisp.place/did:plc:abc123xyz/my-site 240 + ``` 241 + 242 + ### Dependencies 243 + 244 + - **jacquard**: AT Protocol client library 245 + - **clap**: Command-line argument parsing 246 + - **tokio**: Async runtime 247 + - **flate2**: Gzip compression 248 + - **base64**: Base64 encoding 249 + - **walkdir**: Directory traversal 250 + - **mime_guess**: MIME type detection 251 + 252 + ## License 253 + 254 + MIT License 255 + 256 + ## Contributing 257 + 258 + Just don't give me entirely claude slop especailly not in the PR description itself. You should be responsible for code you submit and aware of what it even is you're submitting. 259 + 260 + ## Links 261 + 262 + - **Website**: https://wisp.place 263 + - **Main Repository**: https://tangled.org/@nekomimi.pet/wisp.place-monorepo 264 + - **AT Protocol**: https://atproto.com 265 + - **Jacquard Library**: https://tangled.org/@nonbinary.computer/jacquard 266 + 267 + ## Support 268 + 269 + For issues and questions: 270 + - Check the main wisp.place documentation 271 + - Open an issue in the main repository
+85
cli/src/blob_map.rs
···
··· 1 + use jacquard_common::types::blob::BlobRef; 2 + use jacquard_common::IntoStatic; 3 + use std::collections::HashMap; 4 + 5 + use crate::place_wisp::fs::{Directory, EntryNode}; 6 + 7 + /// Extract blob information from a directory tree 8 + /// Returns a map of file paths to their blob refs and CIDs 9 + /// 10 + /// This mirrors the TypeScript implementation in src/lib/wisp-utils.ts lines 275-302 11 + pub fn extract_blob_map( 12 + directory: &Directory, 13 + ) -> HashMap<String, (BlobRef<'static>, String)> { 14 + extract_blob_map_recursive(directory, String::new()) 15 + } 16 + 17 + fn extract_blob_map_recursive( 18 + directory: &Directory, 19 + current_path: String, 20 + ) -> HashMap<String, (BlobRef<'static>, String)> { 21 + let mut blob_map = HashMap::new(); 22 + 23 + for entry in &directory.entries { 24 + let full_path = if current_path.is_empty() { 25 + entry.name.to_string() 26 + } else { 27 + format!("{}/{}", current_path, entry.name) 28 + }; 29 + 30 + match &entry.node { 31 + EntryNode::File(file_node) => { 32 + // Extract CID from blob ref 33 + // BlobRef is an enum with Blob variant, which has a ref field (CidLink) 34 + let blob_ref = &file_node.blob; 35 + let cid_string = blob_ref.blob().r#ref.to_string(); 36 + 37 + // Store with full path (mirrors TypeScript implementation) 38 + blob_map.insert( 39 + full_path, 40 + (blob_ref.clone().into_static(), cid_string) 41 + ); 42 + } 43 + EntryNode::Directory(subdir) => { 44 + let sub_map = extract_blob_map_recursive(subdir, full_path); 45 + blob_map.extend(sub_map); 46 + } 47 + EntryNode::Unknown(_) => { 48 + // Skip unknown node types 49 + } 50 + } 51 + } 52 + 53 + blob_map 54 + } 55 + 56 + /// Normalize file path by removing base folder prefix 57 + /// Example: "cobblemon/index.html" -> "index.html" 58 + /// 59 + /// Note: This function is kept for reference but is no longer used in production code. 60 + /// The TypeScript server has a similar normalization (src/routes/wisp.ts line 291) to handle 61 + /// uploads that include a base folder prefix, but our CLI doesn't need this since we 62 + /// track full paths consistently. 63 + #[allow(dead_code)] 64 + pub fn normalize_path(path: &str) -> String { 65 + // Remove base folder prefix (everything before first /) 66 + if let Some(idx) = path.find('/') { 67 + path[idx + 1..].to_string() 68 + } else { 69 + path.to_string() 70 + } 71 + } 72 + 73 + #[cfg(test)] 74 + mod tests { 75 + use super::*; 76 + 77 + #[test] 78 + fn test_normalize_path() { 79 + assert_eq!(normalize_path("index.html"), "index.html"); 80 + assert_eq!(normalize_path("cobblemon/index.html"), "index.html"); 81 + assert_eq!(normalize_path("folder/subfolder/file.txt"), "subfolder/file.txt"); 82 + assert_eq!(normalize_path("a/b/c/d.txt"), "b/c/d.txt"); 83 + } 84 + } 85 +
+66
cli/src/cid.rs
···
··· 1 + use jacquard_common::types::cid::IpldCid; 2 + use sha2::{Digest, Sha256}; 3 + 4 + /// Compute CID (Content Identifier) for blob content 5 + /// Uses the same algorithm as AT Protocol: CIDv1 with raw codec (0x55) and SHA-256 6 + /// 7 + /// CRITICAL: This must be called on BASE64-ENCODED GZIPPED content, not just gzipped content 8 + /// 9 + /// Based on @atproto/common/src/ipld.ts sha256RawToCid implementation 10 + pub fn compute_cid(content: &[u8]) -> String { 11 + // Use node crypto to compute sha256 hash (same as AT Protocol) 12 + let hash = Sha256::digest(content); 13 + 14 + // Create multihash (code 0x12 = sha2-256) 15 + let multihash = multihash::Multihash::wrap(0x12, &hash) 16 + .expect("SHA-256 hash should always fit in multihash"); 17 + 18 + // Create CIDv1 with raw codec (0x55) 19 + let cid = IpldCid::new_v1(0x55, multihash); 20 + 21 + // Convert to base32 string representation 22 + cid.to_string_of_base(multibase::Base::Base32Lower) 23 + .unwrap_or_else(|_| cid.to_string()) 24 + } 25 + 26 + #[cfg(test)] 27 + mod tests { 28 + use super::*; 29 + use base64::Engine; 30 + 31 + #[test] 32 + fn test_compute_cid() { 33 + // Test with a simple string: "hello" 34 + let content = b"hello"; 35 + let cid = compute_cid(content); 36 + 37 + // CID should start with 'baf' for raw codec base32 38 + assert!(cid.starts_with("baf")); 39 + } 40 + 41 + #[test] 42 + fn test_compute_cid_base64_encoded() { 43 + // Simulate the actual use case: gzipped then base64 encoded 44 + use flate2::write::GzEncoder; 45 + use flate2::Compression; 46 + use std::io::Write; 47 + 48 + let original = b"hello world"; 49 + 50 + // Gzip compress 51 + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); 52 + encoder.write_all(original).unwrap(); 53 + let gzipped = encoder.finish().unwrap(); 54 + 55 + // Base64 encode the gzipped data 56 + let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 57 + 58 + // Compute CID on the base64 bytes 59 + let cid = compute_cid(&base64_bytes); 60 + 61 + // Should be a valid CID 62 + assert!(cid.starts_with("baf")); 63 + assert!(cid.len() > 10); 64 + } 65 + } 66 +
+71
cli/src/download.rs
···
··· 1 + use base64::Engine; 2 + use bytes::Bytes; 3 + use flate2::read::GzDecoder; 4 + use jacquard_common::types::blob::BlobRef; 5 + use miette::IntoDiagnostic; 6 + use std::io::Read; 7 + use url::Url; 8 + 9 + /// Download a blob from the PDS 10 + pub async fn download_blob(pds_url: &Url, blob_ref: &BlobRef<'_>, did: &str) -> miette::Result<Bytes> { 11 + // Extract CID from blob ref 12 + let cid = blob_ref.blob().r#ref.to_string(); 13 + 14 + // Construct blob download URL 15 + // The correct endpoint is: /xrpc/com.atproto.sync.getBlob?did={did}&cid={cid} 16 + let blob_url = pds_url 17 + .join(&format!("/xrpc/com.atproto.sync.getBlob?did={}&cid={}", did, cid)) 18 + .into_diagnostic()?; 19 + 20 + let client = reqwest::Client::new(); 21 + let response = client 22 + .get(blob_url) 23 + .send() 24 + .await 25 + .into_diagnostic()?; 26 + 27 + if !response.status().is_success() { 28 + return Err(miette::miette!( 29 + "Failed to download blob: {}", 30 + response.status() 31 + )); 32 + } 33 + 34 + let bytes = response.bytes().await.into_diagnostic()?; 35 + Ok(bytes) 36 + } 37 + 38 + /// Decompress and decode a blob (base64 + gzip) 39 + pub fn decompress_blob(data: &[u8], is_base64: bool, is_gzipped: bool) -> miette::Result<Vec<u8>> { 40 + let mut current_data = data.to_vec(); 41 + 42 + // First, decode base64 if needed 43 + if is_base64 { 44 + current_data = base64::prelude::BASE64_STANDARD 45 + .decode(&current_data) 46 + .into_diagnostic()?; 47 + } 48 + 49 + // Then, decompress gzip if needed 50 + if is_gzipped { 51 + let mut decoder = GzDecoder::new(&current_data[..]); 52 + let mut decompressed = Vec::new(); 53 + decoder.read_to_end(&mut decompressed).into_diagnostic()?; 54 + current_data = decompressed; 55 + } 56 + 57 + Ok(current_data) 58 + } 59 + 60 + /// Download and decompress a blob 61 + pub async fn download_and_decompress_blob( 62 + pds_url: &Url, 63 + blob_ref: &BlobRef<'_>, 64 + did: &str, 65 + is_base64: bool, 66 + is_gzipped: bool, 67 + ) -> miette::Result<Vec<u8>> { 68 + let data = download_blob(pds_url, blob_ref, did).await?; 69 + decompress_blob(&data, is_base64, is_gzipped) 70 + } 71 +
+243 -56
cli/src/main.rs
··· 1 mod builder_types; 2 mod place_wisp; 3 4 - use clap::Parser; 5 use jacquard::CowStr; 6 - use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession}; 7 use jacquard::oauth::client::OAuthClient; 8 use jacquard::oauth::loopback::LoopbackConfig; 9 use jacquard::prelude::IdentityResolver; ··· 11 use jacquard_common::types::blob::MimeType; 12 use miette::IntoDiagnostic; 13 use std::path::{Path, PathBuf}; 14 use flate2::Compression; 15 use flate2::write::GzEncoder; 16 use std::io::Write; ··· 20 use place_wisp::fs::*; 21 22 #[derive(Parser, Debug)] 23 - #[command(author, version, about = "Deploy a static site to wisp.place")] 24 struct Args { 25 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 26 - input: CowStr<'static>, 27 28 /// Path to the directory containing your static site 29 - #[arg(short, long, default_value = ".")] 30 - path: PathBuf, 31 32 /// Site name (defaults to directory name) 33 - #[arg(short, long)] 34 site: Option<String>, 35 36 - /// Path to auth store file (will be created if missing, only used with OAuth) 37 - #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 38 - store: String, 39 40 - /// App Password for authentication (alternative to OAuth) 41 - #[arg(long)] 42 password: Option<CowStr<'static>>, 43 } 44 45 #[tokio::main] 46 async fn main() -> miette::Result<()> { 47 let args = Args::parse(); 48 49 - // Dispatch to appropriate authentication method 50 - if let Some(password) = args.password { 51 - run_with_app_password(args.input, password, args.path, args.site).await 52 - } else { 53 - run_with_oauth(args.input, args.store, args.path, args.site).await 54 } 55 } 56 ··· 107 108 println!("Deploying site '{}'...", site_name); 109 110 - // Build directory tree 111 - let root_dir = build_directory(agent, &path).await?; 112 113 - // Count total files 114 - let file_count = count_files(&root_dir); 115 116 // Create the Fs record 117 let fs_record = Fs::new() 118 .site(CowStr::from(site_name.clone())) 119 .root(root_dir) 120 - .file_count(file_count as i64) 121 .created_at(Datetime::now()) 122 .build(); 123 ··· 132 .and_then(|s| s.split('/').next()) 133 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 134 135 - println!("Deployed site '{}': {}", site_name, output.uri); 136 - println!("Available at: https://sites.wisp.place/{}/{}", did, site_name); 137 138 Ok(()) 139 } 140 141 /// Recursively build a Directory from a filesystem path 142 fn build_directory<'a>( 143 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 144 dir_path: &'a Path, 145 - ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>> 146 { 147 Box::pin(async move { 148 // Collect all directory entries first ··· 170 let metadata = entry.metadata().into_diagnostic()?; 171 172 if metadata.is_file() { 173 - file_tasks.push((name_str, path)); 174 } else if metadata.is_dir() { 175 dir_tasks.push((name_str, path)); 176 } 177 } 178 179 // Process files concurrently with a limit of 5 180 - let file_entries: Vec<Entry> = stream::iter(file_tasks) 181 - .map(|(name, path)| async move { 182 - let file_node = process_file(agent, &path).await?; 183 - Ok::<_, miette::Report>(Entry::new() 184 .name(CowStr::from(name)) 185 .node(EntryNode::File(Box::new(file_node))) 186 - .build()) 187 }) 188 .buffer_unordered(5) 189 .collect::<Vec<_>>() 190 .await 191 .into_iter() 192 .collect::<miette::Result<Vec<_>>>()?; 193 194 // Process directories recursively (sequentially to avoid too much nesting) 195 let mut dir_entries = Vec::new(); 196 for (name, path) in dir_tasks { 197 - let subdir = build_directory(agent, &path).await?; 198 dir_entries.push(Entry::new() 199 .name(CowStr::from(name)) 200 .node(EntryNode::Directory(Box::new(subdir))) 201 .build()); 202 } 203 204 // Combine file and directory entries 205 let mut entries = file_entries; 206 entries.extend(dir_entries); 207 208 - Ok(Directory::new() 209 .r#type(CowStr::from("directory")) 210 .entries(entries) 211 - .build()) 212 }) 213 } 214 215 - /// Process a single file: gzip -> base64 -> upload blob 216 async fn process_file( 217 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 218 file_path: &Path, 219 - ) -> miette::Result<File<'static>> 220 { 221 // Read file 222 let file_data = std::fs::read(file_path).into_diagnostic()?; ··· 234 // Base64 encode the gzipped data 235 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 236 237 - // Upload blob as octet-stream 238 let blob = agent.upload_blob( 239 base64_bytes, 240 MimeType::new_static("application/octet-stream"), 241 ).await?; 242 243 - Ok(File::new() 244 - .r#type(CowStr::from("file")) 245 - .blob(blob) 246 - .encoding(CowStr::from("gzip")) 247 - .mime_type(CowStr::from(original_mime)) 248 - .base64(true) 249 - .build()) 250 } 251 252 - /// Count total files in a directory tree 253 - fn count_files(dir: &Directory) -> usize { 254 - let mut count = 0; 255 - for entry in &dir.entries { 256 - match &entry.node { 257 - EntryNode::File(_) => count += 1, 258 - EntryNode::Directory(subdir) => count += count_files(subdir), 259 - _ => {} // Unknown variants 260 - } 261 - } 262 - count 263 - }
··· 1 mod builder_types; 2 mod place_wisp; 3 + mod cid; 4 + mod blob_map; 5 + mod metadata; 6 + mod download; 7 + mod pull; 8 + mod serve; 9 10 + use clap::{Parser, Subcommand}; 11 use jacquard::CowStr; 12 + use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession}; 13 use jacquard::oauth::client::OAuthClient; 14 use jacquard::oauth::loopback::LoopbackConfig; 15 use jacquard::prelude::IdentityResolver; ··· 17 use jacquard_common::types::blob::MimeType; 18 use miette::IntoDiagnostic; 19 use std::path::{Path, PathBuf}; 20 + use std::collections::HashMap; 21 use flate2::Compression; 22 use flate2::write::GzEncoder; 23 use std::io::Write; ··· 27 use place_wisp::fs::*; 28 29 #[derive(Parser, Debug)] 30 + #[command(author, version, about = "wisp.place CLI tool")] 31 struct Args { 32 + #[command(subcommand)] 33 + command: Option<Commands>, 34 + 35 + // Deploy arguments (when no subcommand is specified) 36 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 37 + #[arg(global = true, conflicts_with = "command")] 38 + input: Option<CowStr<'static>>, 39 40 /// Path to the directory containing your static site 41 + #[arg(short, long, global = true, conflicts_with = "command")] 42 + path: Option<PathBuf>, 43 44 /// Site name (defaults to directory name) 45 + #[arg(short, long, global = true, conflicts_with = "command")] 46 site: Option<String>, 47 48 + /// Path to auth store file 49 + #[arg(long, global = true, conflicts_with = "command")] 50 + store: Option<String>, 51 52 + /// App Password for authentication 53 + #[arg(long, global = true, conflicts_with = "command")] 54 password: Option<CowStr<'static>>, 55 } 56 57 + #[derive(Subcommand, Debug)] 58 + enum Commands { 59 + /// Deploy a static site to wisp.place (default command) 60 + Deploy { 61 + /// Handle (e.g., alice.bsky.social), DID, or PDS URL 62 + input: CowStr<'static>, 63 + 64 + /// Path to the directory containing your static site 65 + #[arg(short, long, default_value = ".")] 66 + path: PathBuf, 67 + 68 + /// Site name (defaults to directory name) 69 + #[arg(short, long)] 70 + site: Option<String>, 71 + 72 + /// Path to auth store file (will be created if missing, only used with OAuth) 73 + #[arg(long, default_value = "/tmp/wisp-oauth-session.json")] 74 + store: String, 75 + 76 + /// App Password for authentication (alternative to OAuth) 77 + #[arg(long)] 78 + password: Option<CowStr<'static>>, 79 + }, 80 + /// Pull a site from the PDS to a local directory 81 + Pull { 82 + /// Handle (e.g., alice.bsky.social) or DID 83 + input: CowStr<'static>, 84 + 85 + /// Site name (record key) 86 + #[arg(short, long)] 87 + site: String, 88 + 89 + /// Output directory for the downloaded site 90 + #[arg(short, long, default_value = ".")] 91 + output: PathBuf, 92 + }, 93 + /// Serve a site locally with real-time firehose updates 94 + Serve { 95 + /// Handle (e.g., alice.bsky.social) or DID 96 + input: CowStr<'static>, 97 + 98 + /// Site name (record key) 99 + #[arg(short, long)] 100 + site: String, 101 + 102 + /// Output directory for the site files 103 + #[arg(short, long, default_value = ".")] 104 + output: PathBuf, 105 + 106 + /// Port to serve on 107 + #[arg(short, long, default_value = "8080")] 108 + port: u16, 109 + }, 110 + } 111 + 112 #[tokio::main] 113 async fn main() -> miette::Result<()> { 114 let args = Args::parse(); 115 116 + match args.command { 117 + Some(Commands::Deploy { input, path, site, store, password }) => { 118 + // Dispatch to appropriate authentication method 119 + if let Some(password) = password { 120 + run_with_app_password(input, password, path, site).await 121 + } else { 122 + run_with_oauth(input, store, path, site).await 123 + } 124 + } 125 + Some(Commands::Pull { input, site, output }) => { 126 + pull::pull_site(input, CowStr::from(site), output).await 127 + } 128 + Some(Commands::Serve { input, site, output, port }) => { 129 + serve::serve_site(input, CowStr::from(site), output, port).await 130 + } 131 + None => { 132 + // Legacy mode: if input is provided, assume deploy command 133 + if let Some(input) = args.input { 134 + let path = args.path.unwrap_or_else(|| PathBuf::from(".")); 135 + let store = args.store.unwrap_or_else(|| "/tmp/wisp-oauth-session.json".to_string()); 136 + 137 + // Dispatch to appropriate authentication method 138 + if let Some(password) = args.password { 139 + run_with_app_password(input, password, path, args.site).await 140 + } else { 141 + run_with_oauth(input, store, path, args.site).await 142 + } 143 + } else { 144 + // No command and no input, show help 145 + use clap::CommandFactory; 146 + Args::command().print_help().into_diagnostic()?; 147 + Ok(()) 148 + } 149 + } 150 } 151 } 152 ··· 203 204 println!("Deploying site '{}'...", site_name); 205 206 + // Try to fetch existing manifest for incremental updates 207 + let existing_blob_map: HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)> = { 208 + use jacquard_common::types::string::AtUri; 209 + 210 + // Get the DID for this session 211 + let session_info = agent.session_info().await; 212 + if let Some((did, _)) = session_info { 213 + // Construct the AT URI for the record 214 + let uri_string = format!("at://{}/place.wisp.fs/{}", did, site_name); 215 + if let Ok(uri) = AtUri::new(&uri_string) { 216 + match agent.get_record::<Fs>(&uri).await { 217 + Ok(response) => { 218 + match response.into_output() { 219 + Ok(record_output) => { 220 + let existing_manifest = record_output.value; 221 + let blob_map = blob_map::extract_blob_map(&existing_manifest.root); 222 + println!("Found existing manifest with {} files, checking for changes...", blob_map.len()); 223 + blob_map 224 + } 225 + Err(_) => { 226 + println!("No existing manifest found, uploading all files..."); 227 + HashMap::new() 228 + } 229 + } 230 + } 231 + Err(_) => { 232 + // Record doesn't exist yet - this is a new site 233 + println!("No existing manifest found, uploading all files..."); 234 + HashMap::new() 235 + } 236 + } 237 + } else { 238 + println!("No existing manifest found (invalid URI), uploading all files..."); 239 + HashMap::new() 240 + } 241 + } else { 242 + println!("No existing manifest found (could not get DID), uploading all files..."); 243 + HashMap::new() 244 + } 245 + }; 246 247 + // Build directory tree 248 + let (root_dir, total_files, reused_count) = build_directory(agent, &path, &existing_blob_map, String::new()).await?; 249 + let uploaded_count = total_files - reused_count; 250 251 // Create the Fs record 252 let fs_record = Fs::new() 253 .site(CowStr::from(site_name.clone())) 254 .root(root_dir) 255 + .file_count(total_files as i64) 256 .created_at(Datetime::now()) 257 .build(); 258 ··· 267 .and_then(|s| s.split('/').next()) 268 .ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?; 269 270 + println!("\nโœ“ Deployed site '{}': {}", site_name, output.uri); 271 + println!(" Total files: {} ({} reused, {} uploaded)", total_files, reused_count, uploaded_count); 272 + println!(" Available at: https://sites.wisp.place/{}/{}", did, site_name); 273 274 Ok(()) 275 } 276 277 /// Recursively build a Directory from a filesystem path 278 + /// current_path is the path from the root of the site (e.g., "" for root, "config" for config dir) 279 fn build_directory<'a>( 280 agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>, 281 dir_path: &'a Path, 282 + existing_blobs: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 283 + current_path: String, 284 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<(Directory<'static>, usize, usize)>> + 'a>> 285 { 286 Box::pin(async move { 287 // Collect all directory entries first ··· 309 let metadata = entry.metadata().into_diagnostic()?; 310 311 if metadata.is_file() { 312 + // Construct full path for this file (for blob map lookup) 313 + let full_path = if current_path.is_empty() { 314 + name_str.clone() 315 + } else { 316 + format!("{}/{}", current_path, name_str) 317 + }; 318 + file_tasks.push((name_str, path, full_path)); 319 } else if metadata.is_dir() { 320 dir_tasks.push((name_str, path)); 321 } 322 } 323 324 // Process files concurrently with a limit of 5 325 + let file_results: Vec<(Entry<'static>, bool)> = stream::iter(file_tasks) 326 + .map(|(name, path, full_path)| async move { 327 + let (file_node, reused) = process_file(agent, &path, &full_path, existing_blobs).await?; 328 + let entry = Entry::new() 329 .name(CowStr::from(name)) 330 .node(EntryNode::File(Box::new(file_node))) 331 + .build(); 332 + Ok::<_, miette::Report>((entry, reused)) 333 }) 334 .buffer_unordered(5) 335 .collect::<Vec<_>>() 336 .await 337 .into_iter() 338 .collect::<miette::Result<Vec<_>>>()?; 339 + 340 + let mut file_entries = Vec::new(); 341 + let mut reused_count = 0; 342 + let mut total_files = 0; 343 + 344 + for (entry, reused) in file_results { 345 + file_entries.push(entry); 346 + total_files += 1; 347 + if reused { 348 + reused_count += 1; 349 + } 350 + } 351 352 // Process directories recursively (sequentially to avoid too much nesting) 353 let mut dir_entries = Vec::new(); 354 for (name, path) in dir_tasks { 355 + // Construct full path for subdirectory 356 + let subdir_path = if current_path.is_empty() { 357 + name.clone() 358 + } else { 359 + format!("{}/{}", current_path, name) 360 + }; 361 + let (subdir, sub_total, sub_reused) = build_directory(agent, &path, existing_blobs, subdir_path).await?; 362 dir_entries.push(Entry::new() 363 .name(CowStr::from(name)) 364 .node(EntryNode::Directory(Box::new(subdir))) 365 .build()); 366 + total_files += sub_total; 367 + reused_count += sub_reused; 368 } 369 370 // Combine file and directory entries 371 let mut entries = file_entries; 372 entries.extend(dir_entries); 373 374 + let directory = Directory::new() 375 .r#type(CowStr::from("directory")) 376 .entries(entries) 377 + .build(); 378 + 379 + Ok((directory, total_files, reused_count)) 380 }) 381 } 382 383 + /// Process a single file: gzip -> base64 -> upload blob (or reuse existing) 384 + /// Returns (File, reused: bool) 385 + /// file_path_key is the full path from the site root (e.g., "config/file.json") for blob map lookup 386 async fn process_file( 387 agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>, 388 file_path: &Path, 389 + file_path_key: &str, 390 + existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 391 + ) -> miette::Result<(File<'static>, bool)> 392 { 393 // Read file 394 let file_data = std::fs::read(file_path).into_diagnostic()?; ··· 406 // Base64 encode the gzipped data 407 let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes(); 408 409 + // Compute CID for this file (CRITICAL: on base64-encoded gzipped content) 410 + let file_cid = cid::compute_cid(&base64_bytes); 411 + 412 + // Check if we have an existing blob with the same CID 413 + let existing_blob = existing_blobs.get(file_path_key); 414 + 415 + if let Some((existing_blob_ref, existing_cid)) = existing_blob { 416 + if existing_cid == &file_cid { 417 + // CIDs match - reuse existing blob 418 + println!(" โœ“ Reusing blob for {} (CID: {})", file_path_key, file_cid); 419 + return Ok(( 420 + File::new() 421 + .r#type(CowStr::from("file")) 422 + .blob(existing_blob_ref.clone()) 423 + .encoding(CowStr::from("gzip")) 424 + .mime_type(CowStr::from(original_mime)) 425 + .base64(true) 426 + .build(), 427 + true 428 + )); 429 + } 430 + } 431 + 432 + // File is new or changed - upload it 433 + println!(" โ†‘ Uploading {} ({} bytes, CID: {})", file_path_key, base64_bytes.len(), file_cid); 434 let blob = agent.upload_blob( 435 base64_bytes, 436 MimeType::new_static("application/octet-stream"), 437 ).await?; 438 439 + Ok(( 440 + File::new() 441 + .r#type(CowStr::from("file")) 442 + .blob(blob) 443 + .encoding(CowStr::from("gzip")) 444 + .mime_type(CowStr::from(original_mime)) 445 + .base64(true) 446 + .build(), 447 + false 448 + )) 449 } 450
+46
cli/src/metadata.rs
···
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::collections::HashMap; 3 + use std::path::Path; 4 + use miette::IntoDiagnostic; 5 + 6 + /// Metadata tracking file CIDs for incremental updates 7 + #[derive(Debug, Clone, Serialize, Deserialize)] 8 + pub struct SiteMetadata { 9 + /// Record CID from the PDS 10 + pub record_cid: String, 11 + /// Map of file paths to their blob CIDs 12 + pub file_cids: HashMap<String, String>, 13 + /// Timestamp when the site was last synced 14 + pub last_sync: i64, 15 + } 16 + 17 + impl SiteMetadata { 18 + pub fn new(record_cid: String, file_cids: HashMap<String, String>) -> Self { 19 + Self { 20 + record_cid, 21 + file_cids, 22 + last_sync: chrono::Utc::now().timestamp(), 23 + } 24 + } 25 + 26 + /// Load metadata from a directory 27 + pub fn load(dir: &Path) -> miette::Result<Option<Self>> { 28 + let metadata_path = dir.join(".wisp-metadata.json"); 29 + if !metadata_path.exists() { 30 + return Ok(None); 31 + } 32 + 33 + let contents = std::fs::read_to_string(&metadata_path).into_diagnostic()?; 34 + let metadata: SiteMetadata = serde_json::from_str(&contents).into_diagnostic()?; 35 + Ok(Some(metadata)) 36 + } 37 + 38 + /// Save metadata to a directory 39 + pub fn save(&self, dir: &Path) -> miette::Result<()> { 40 + let metadata_path = dir.join(".wisp-metadata.json"); 41 + let contents = serde_json::to_string_pretty(self).into_diagnostic()?; 42 + std::fs::write(&metadata_path, contents).into_diagnostic()?; 43 + Ok(()) 44 + } 45 + } 46 +
+305
cli/src/pull.rs
···
··· 1 + use crate::blob_map; 2 + use crate::download; 3 + use crate::metadata::SiteMetadata; 4 + use crate::place_wisp::fs::*; 5 + use jacquard::CowStr; 6 + use jacquard::prelude::IdentityResolver; 7 + use jacquard_common::types::string::Did; 8 + use jacquard_common::xrpc::XrpcExt; 9 + use jacquard_identity::PublicResolver; 10 + use miette::IntoDiagnostic; 11 + use std::collections::HashMap; 12 + use std::path::{Path, PathBuf}; 13 + use url::Url; 14 + 15 + /// Pull a site from the PDS to a local directory 16 + pub async fn pull_site( 17 + input: CowStr<'static>, 18 + rkey: CowStr<'static>, 19 + output_dir: PathBuf, 20 + ) -> miette::Result<()> { 21 + println!("Pulling site {} from {}...", rkey, input); 22 + 23 + // Resolve handle to DID if needed 24 + let resolver = PublicResolver::default(); 25 + let did = if input.starts_with("did:") { 26 + Did::new(&input).into_diagnostic()? 27 + } else { 28 + // It's a handle, resolve it 29 + let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?; 30 + resolver.resolve_handle(&handle).await.into_diagnostic()? 31 + }; 32 + 33 + // Resolve PDS endpoint for the DID 34 + let pds_url = resolver.pds_for_did(&did).await.into_diagnostic()?; 35 + println!("Resolved PDS: {}", pds_url); 36 + 37 + // Fetch the place.wisp.fs record 38 + 39 + println!("Fetching record from PDS..."); 40 + let client = reqwest::Client::new(); 41 + 42 + // Use com.atproto.repo.getRecord 43 + use jacquard::api::com_atproto::repo::get_record::GetRecord; 44 + use jacquard_common::types::string::Rkey as RkeyType; 45 + let rkey_parsed = RkeyType::new(&rkey).into_diagnostic()?; 46 + 47 + use jacquard_common::types::ident::AtIdentifier; 48 + use jacquard_common::types::string::RecordKey; 49 + let request = GetRecord::new() 50 + .repo(AtIdentifier::Did(did.clone())) 51 + .collection(CowStr::from("place.wisp.fs")) 52 + .rkey(RecordKey::from(rkey_parsed)) 53 + .build(); 54 + 55 + let response = client 56 + .xrpc(pds_url.clone()) 57 + .send(&request) 58 + .await 59 + .into_diagnostic()?; 60 + 61 + let record_output = response.into_output().into_diagnostic()?; 62 + let record_cid = record_output.cid.as_ref().map(|c| c.to_string()).unwrap_or_default(); 63 + 64 + // Parse the record value as Fs 65 + use jacquard_common::types::value::from_data; 66 + let fs_record: Fs = from_data(&record_output.value).into_diagnostic()?; 67 + 68 + let file_count = fs_record.file_count.map(|c| c.to_string()).unwrap_or_else(|| "?".to_string()); 69 + println!("Found site '{}' with {} files", fs_record.site, file_count); 70 + 71 + // Load existing metadata for incremental updates 72 + let existing_metadata = SiteMetadata::load(&output_dir)?; 73 + let existing_file_cids = existing_metadata 74 + .as_ref() 75 + .map(|m| m.file_cids.clone()) 76 + .unwrap_or_default(); 77 + 78 + // Extract blob map from the new manifest 79 + let new_blob_map = blob_map::extract_blob_map(&fs_record.root); 80 + let new_file_cids: HashMap<String, String> = new_blob_map 81 + .iter() 82 + .map(|(path, (_blob_ref, cid))| (path.clone(), cid.clone())) 83 + .collect(); 84 + 85 + // Clean up any leftover temp directories from previous failed attempts 86 + let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new(".")); 87 + let output_name = output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy(); 88 + let temp_prefix = format!(".tmp-{}-", output_name); 89 + 90 + if let Ok(entries) = parent.read_dir() { 91 + for entry in entries.flatten() { 92 + let name = entry.file_name(); 93 + if name.to_string_lossy().starts_with(&temp_prefix) { 94 + let _ = std::fs::remove_dir_all(entry.path()); 95 + } 96 + } 97 + } 98 + 99 + // Check if we need to update (but only if output directory actually exists with files) 100 + if let Some(metadata) = &existing_metadata { 101 + if metadata.record_cid == record_cid { 102 + // Verify that the output directory actually exists and has content 103 + let has_content = output_dir.exists() && 104 + output_dir.read_dir() 105 + .map(|mut entries| entries.any(|e| { 106 + if let Ok(entry) = e { 107 + !entry.file_name().to_string_lossy().starts_with(".wisp-metadata") 108 + } else { 109 + false 110 + } 111 + })) 112 + .unwrap_or(false); 113 + 114 + if has_content { 115 + println!("Site is already up to date!"); 116 + return Ok(()); 117 + } 118 + } 119 + } 120 + 121 + // Create temporary directory for atomic update 122 + // Place temp dir in parent directory to avoid issues with non-existent output_dir 123 + let parent = output_dir.parent().unwrap_or_else(|| std::path::Path::new(".")); 124 + let temp_dir_name = format!( 125 + ".tmp-{}-{}", 126 + output_dir.file_name().unwrap_or_else(|| std::ffi::OsStr::new("site")).to_string_lossy(), 127 + chrono::Utc::now().timestamp() 128 + ); 129 + let temp_dir = parent.join(temp_dir_name); 130 + std::fs::create_dir_all(&temp_dir).into_diagnostic()?; 131 + 132 + println!("Downloading files..."); 133 + let mut downloaded = 0; 134 + let mut reused = 0; 135 + 136 + // Download files recursively 137 + let download_result = download_directory( 138 + &fs_record.root, 139 + &temp_dir, 140 + &pds_url, 141 + did.as_str(), 142 + &new_blob_map, 143 + &existing_file_cids, 144 + &output_dir, 145 + String::new(), 146 + &mut downloaded, 147 + &mut reused, 148 + ) 149 + .await; 150 + 151 + // If download failed, clean up temp directory 152 + if let Err(e) = download_result { 153 + let _ = std::fs::remove_dir_all(&temp_dir); 154 + return Err(e); 155 + } 156 + 157 + println!( 158 + "Downloaded {} files, reused {} files", 159 + downloaded, reused 160 + ); 161 + 162 + // Save metadata 163 + let metadata = SiteMetadata::new(record_cid, new_file_cids); 164 + metadata.save(&temp_dir)?; 165 + 166 + // Move files from temp to output directory 167 + let output_abs = std::fs::canonicalize(&output_dir).unwrap_or_else(|_| output_dir.clone()); 168 + let current_dir = std::env::current_dir().into_diagnostic()?; 169 + 170 + // Special handling for pulling to current directory 171 + if output_abs == current_dir { 172 + // Move files from temp to current directory 173 + for entry in std::fs::read_dir(&temp_dir).into_diagnostic()? { 174 + let entry = entry.into_diagnostic()?; 175 + let dest = current_dir.join(entry.file_name()); 176 + 177 + // Remove existing file/dir if it exists 178 + if dest.exists() { 179 + if dest.is_dir() { 180 + std::fs::remove_dir_all(&dest).into_diagnostic()?; 181 + } else { 182 + std::fs::remove_file(&dest).into_diagnostic()?; 183 + } 184 + } 185 + 186 + // Move from temp to current dir 187 + std::fs::rename(entry.path(), dest).into_diagnostic()?; 188 + } 189 + 190 + // Clean up temp directory 191 + std::fs::remove_dir_all(&temp_dir).into_diagnostic()?; 192 + } else { 193 + // If output directory exists and has content, remove it first 194 + if output_dir.exists() { 195 + std::fs::remove_dir_all(&output_dir).into_diagnostic()?; 196 + } 197 + 198 + // Ensure parent directory exists 199 + if let Some(parent) = output_dir.parent() { 200 + if !parent.as_os_str().is_empty() && !parent.exists() { 201 + std::fs::create_dir_all(parent).into_diagnostic()?; 202 + } 203 + } 204 + 205 + // Rename temp to final location 206 + match std::fs::rename(&temp_dir, &output_dir) { 207 + Ok(_) => {}, 208 + Err(e) => { 209 + // Clean up temp directory on failure 210 + let _ = std::fs::remove_dir_all(&temp_dir); 211 + return Err(miette::miette!("Failed to move temp directory: {}", e)); 212 + } 213 + } 214 + } 215 + 216 + println!("โœ“ Site pulled successfully to {}", output_dir.display()); 217 + 218 + Ok(()) 219 + } 220 + 221 + /// Recursively download a directory 222 + fn download_directory<'a>( 223 + dir: &'a Directory<'_>, 224 + output_dir: &'a Path, 225 + pds_url: &'a Url, 226 + did: &'a str, 227 + new_blob_map: &'a HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>, 228 + existing_file_cids: &'a HashMap<String, String>, 229 + existing_output_dir: &'a Path, 230 + path_prefix: String, 231 + downloaded: &'a mut usize, 232 + reused: &'a mut usize, 233 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send + 'a>> { 234 + Box::pin(async move { 235 + for entry in &dir.entries { 236 + let entry_name = entry.name.as_str(); 237 + let current_path = if path_prefix.is_empty() { 238 + entry_name.to_string() 239 + } else { 240 + format!("{}/{}", path_prefix, entry_name) 241 + }; 242 + 243 + match &entry.node { 244 + EntryNode::File(file) => { 245 + let output_path = output_dir.join(entry_name); 246 + 247 + // Check if file CID matches existing 248 + if let Some((_blob_ref, new_cid)) = new_blob_map.get(&current_path) { 249 + if let Some(existing_cid) = existing_file_cids.get(&current_path) { 250 + if existing_cid == new_cid { 251 + // File unchanged, copy from existing directory 252 + let existing_path = existing_output_dir.join(&current_path); 253 + if existing_path.exists() { 254 + std::fs::copy(&existing_path, &output_path).into_diagnostic()?; 255 + *reused += 1; 256 + println!(" โœ“ Reused {}", current_path); 257 + continue; 258 + } 259 + } 260 + } 261 + } 262 + 263 + // File is new or changed, download it 264 + println!(" โ†“ Downloading {}", current_path); 265 + let data = download::download_and_decompress_blob( 266 + pds_url, 267 + &file.blob, 268 + did, 269 + file.base64.unwrap_or(false), 270 + file.encoding.as_ref().map(|e| e.as_str() == "gzip").unwrap_or(false), 271 + ) 272 + .await?; 273 + 274 + std::fs::write(&output_path, data).into_diagnostic()?; 275 + *downloaded += 1; 276 + } 277 + EntryNode::Directory(subdir) => { 278 + let subdir_path = output_dir.join(entry_name); 279 + std::fs::create_dir_all(&subdir_path).into_diagnostic()?; 280 + 281 + download_directory( 282 + subdir, 283 + &subdir_path, 284 + pds_url, 285 + did, 286 + new_blob_map, 287 + existing_file_cids, 288 + existing_output_dir, 289 + current_path, 290 + downloaded, 291 + reused, 292 + ) 293 + .await?; 294 + } 295 + EntryNode::Unknown(_) => { 296 + // Skip unknown node types 297 + println!(" โš  Skipping unknown node type for {}", current_path); 298 + } 299 + } 300 + } 301 + 302 + Ok(()) 303 + }) 304 + } 305 +
+202
cli/src/serve.rs
···
··· 1 + use crate::pull::pull_site; 2 + use axum::Router; 3 + use jacquard::CowStr; 4 + use jacquard_common::jetstream::{CommitOperation, JetstreamMessage, JetstreamParams}; 5 + use jacquard_common::types::string::Did; 6 + use jacquard_common::xrpc::{SubscriptionClient, TungsteniteSubscriptionClient}; 7 + use miette::IntoDiagnostic; 8 + use n0_future::StreamExt; 9 + use std::path::PathBuf; 10 + use std::sync::Arc; 11 + use tokio::sync::RwLock; 12 + use tower_http::compression::CompressionLayer; 13 + use tower_http::services::ServeDir; 14 + use url::Url; 15 + 16 + /// Shared state for the server 17 + #[derive(Clone)] 18 + struct ServerState { 19 + did: CowStr<'static>, 20 + rkey: CowStr<'static>, 21 + output_dir: PathBuf, 22 + last_cid: Arc<RwLock<Option<String>>>, 23 + } 24 + 25 + /// Serve a site locally with real-time firehose updates 26 + pub async fn serve_site( 27 + input: CowStr<'static>, 28 + rkey: CowStr<'static>, 29 + output_dir: PathBuf, 30 + port: u16, 31 + ) -> miette::Result<()> { 32 + println!("Serving site {} from {} on port {}...", rkey, input, port); 33 + 34 + // Resolve handle to DID if needed 35 + use jacquard_identity::PublicResolver; 36 + use jacquard::prelude::IdentityResolver; 37 + 38 + let resolver = PublicResolver::default(); 39 + let did = if input.starts_with("did:") { 40 + Did::new(&input).into_diagnostic()? 41 + } else { 42 + // It's a handle, resolve it 43 + let handle = jacquard_common::types::string::Handle::new(&input).into_diagnostic()?; 44 + resolver.resolve_handle(&handle).await.into_diagnostic()? 45 + }; 46 + 47 + println!("Resolved to DID: {}", did.as_str()); 48 + 49 + // Create output directory if it doesn't exist 50 + std::fs::create_dir_all(&output_dir).into_diagnostic()?; 51 + 52 + // Initial pull of the site 53 + println!("Performing initial pull..."); 54 + let did_str = CowStr::from(did.as_str().to_string()); 55 + pull_site(did_str.clone(), rkey.clone(), output_dir.clone()).await?; 56 + 57 + // Create shared state 58 + let state = ServerState { 59 + did: did_str.clone(), 60 + rkey: rkey.clone(), 61 + output_dir: output_dir.clone(), 62 + last_cid: Arc::new(RwLock::new(None)), 63 + }; 64 + 65 + // Start firehose listener in background 66 + let firehose_state = state.clone(); 67 + tokio::spawn(async move { 68 + if let Err(e) = watch_firehose(firehose_state).await { 69 + eprintln!("Firehose error: {}", e); 70 + } 71 + }); 72 + 73 + // Create HTTP server with gzip compression 74 + let app = Router::new() 75 + .fallback_service( 76 + ServeDir::new(&output_dir) 77 + .precompressed_gzip() 78 + ) 79 + .layer(CompressionLayer::new()) 80 + .with_state(state); 81 + 82 + let addr = format!("0.0.0.0:{}", port); 83 + let listener = tokio::net::TcpListener::bind(&addr) 84 + .await 85 + .into_diagnostic()?; 86 + 87 + println!("\nโœ“ Server running at http://localhost:{}", port); 88 + println!(" Watching for updates on the firehose...\n"); 89 + 90 + axum::serve(listener, app).await.into_diagnostic()?; 91 + 92 + Ok(()) 93 + } 94 + 95 + /// Watch the firehose for updates to the specific site 96 + fn watch_firehose(state: ServerState) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<()>> + Send>> { 97 + Box::pin(async move { 98 + let jetstream_url = Url::parse("wss://jetstream1.us-east.fire.hose.cam") 99 + .into_diagnostic()?; 100 + 101 + println!("[Firehose] Connecting to Jetstream..."); 102 + 103 + // Create subscription client 104 + let client = TungsteniteSubscriptionClient::from_base_uri(jetstream_url); 105 + 106 + // Subscribe with no filters (we'll filter manually) 107 + // Jetstream doesn't support filtering by collection in the params builder 108 + let params = JetstreamParams::new().build(); 109 + 110 + let stream = client.subscribe(&params).await.into_diagnostic()?; 111 + println!("[Firehose] Connected! Watching for updates..."); 112 + 113 + // Convert to typed message stream 114 + let (_sink, mut messages) = stream.into_stream(); 115 + 116 + loop { 117 + match messages.next().await { 118 + Some(Ok(msg)) => { 119 + if let Err(e) = handle_firehose_message(&state, msg).await { 120 + eprintln!("[Firehose] Error handling message: {}", e); 121 + } 122 + } 123 + Some(Err(e)) => { 124 + eprintln!("[Firehose] Stream error: {}", e); 125 + // Try to reconnect after a delay 126 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 127 + return Box::pin(watch_firehose(state)).await; 128 + } 129 + None => { 130 + println!("[Firehose] Stream ended, reconnecting..."); 131 + tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 132 + return Box::pin(watch_firehose(state)).await; 133 + } 134 + } 135 + } 136 + }) 137 + } 138 + 139 + /// Handle a firehose message 140 + async fn handle_firehose_message( 141 + state: &ServerState, 142 + msg: JetstreamMessage<'_>, 143 + ) -> miette::Result<()> { 144 + match msg { 145 + JetstreamMessage::Commit { 146 + did, 147 + commit, 148 + .. 149 + } => { 150 + // Check if this is our site 151 + if did.as_str() == state.did.as_str() 152 + && commit.collection.as_str() == "place.wisp.fs" 153 + && commit.rkey.as_str() == state.rkey.as_str() 154 + { 155 + match commit.operation { 156 + CommitOperation::Create | CommitOperation::Update => { 157 + let new_cid = commit.cid.as_ref().map(|c| c.to_string()); 158 + 159 + // Check if CID changed 160 + let should_update = { 161 + let last_cid = state.last_cid.read().await; 162 + new_cid != *last_cid 163 + }; 164 + 165 + if should_update { 166 + println!("\n[Update] Detected change to site {} (CID: {:?})", state.rkey, new_cid); 167 + println!("[Update] Pulling latest version..."); 168 + 169 + // Pull the updated site 170 + match pull_site( 171 + state.did.clone(), 172 + state.rkey.clone(), 173 + state.output_dir.clone(), 174 + ) 175 + .await 176 + { 177 + Ok(_) => { 178 + // Update last CID 179 + let mut last_cid = state.last_cid.write().await; 180 + *last_cid = new_cid; 181 + println!("[Update] โœ“ Site updated successfully!\n"); 182 + } 183 + Err(e) => { 184 + eprintln!("[Update] Failed to pull site: {}", e); 185 + } 186 + } 187 + } 188 + } 189 + CommitOperation::Delete => { 190 + println!("\n[Update] Site {} was deleted", state.rkey); 191 + } 192 + } 193 + } 194 + } 195 + _ => { 196 + // Ignore identity and account messages 197 + } 198 + } 199 + 200 + Ok(()) 201 + } 202 +
+90
crates.nix
···
··· 1 + {...}: { 2 + perSystem = { 3 + pkgs, 4 + config, 5 + lib, 6 + inputs', 7 + ... 8 + }: { 9 + # declare projects 10 + nci.projects."wisp-place-cli" = { 11 + path = ./cli; 12 + export = false; 13 + }; 14 + nci.toolchains.mkBuild = _: 15 + with inputs'.fenix.packages; 16 + combine [ 17 + minimal.rustc 18 + minimal.cargo 19 + targets.x86_64-pc-windows-gnu.latest.rust-std 20 + targets.x86_64-unknown-linux-gnu.latest.rust-std 21 + targets.aarch64-apple-darwin.latest.rust-std 22 + targets.aarch64-unknown-linux-gnu.latest.rust-std 23 + ]; 24 + # configure crates 25 + nci.crates."wisp-cli" = { 26 + profiles = { 27 + dev.runTests = false; 28 + release.runTests = false; 29 + }; 30 + targets."x86_64-unknown-linux-gnu" = let 31 + targetPkgs = pkgs.pkgsCross.gnu64; 32 + targetCC = targetPkgs.stdenv.cc; 33 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 34 + in rec { 35 + default = true; 36 + depsDrvConfig.mkDerivation = { 37 + nativeBuildInputs = [targetCC]; 38 + }; 39 + depsDrvConfig.env = rec { 40 + TARGET_CC = "${targetCC.targetPrefix}cc"; 41 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 42 + }; 43 + drvConfig = depsDrvConfig; 44 + }; 45 + targets."x86_64-pc-windows-gnu" = let 46 + targetPkgs = pkgs.pkgsCross.mingwW64; 47 + targetCC = targetPkgs.stdenv.cc; 48 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 49 + in rec { 50 + depsDrvConfig.mkDerivation = { 51 + nativeBuildInputs = [targetCC]; 52 + buildInputs = with targetPkgs; [windows.pthreads]; 53 + }; 54 + depsDrvConfig.env = rec { 55 + TARGET_CC = "${targetCC.targetPrefix}cc"; 56 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 57 + }; 58 + drvConfig = depsDrvConfig; 59 + }; 60 + targets."aarch64-apple-darwin" = let 61 + targetPkgs = pkgs.pkgsCross.aarch64-darwin; 62 + targetCC = targetPkgs.stdenv.cc; 63 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 64 + in rec { 65 + depsDrvConfig.mkDerivation = { 66 + nativeBuildInputs = [targetCC]; 67 + }; 68 + depsDrvConfig.env = rec { 69 + TARGET_CC = "${targetCC.targetPrefix}cc"; 70 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 71 + }; 72 + drvConfig = depsDrvConfig; 73 + }; 74 + targets."aarch64-unknown-linux-gnu" = let 75 + targetPkgs = pkgs.pkgsCross.aarch64-multiplatform; 76 + targetCC = targetPkgs.stdenv.cc; 77 + targetCargoEnvVarTarget = targetPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget; 78 + in rec { 79 + depsDrvConfig.mkDerivation = { 80 + nativeBuildInputs = [targetCC]; 81 + }; 82 + depsDrvConfig.env = rec { 83 + TARGET_CC = "${targetCC.targetPrefix}cc"; 84 + "CARGO_TARGET_${targetCargoEnvVarTarget}_LINKER" = TARGET_CC; 85 + }; 86 + drvConfig = depsDrvConfig; 87 + }; 88 + }; 89 + }; 90 + }
+318
flake.lock
···
··· 1 + { 2 + "nodes": { 3 + "crane": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1758758545, 7 + "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", 8 + "owner": "ipetkov", 9 + "repo": "crane", 10 + "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", 11 + "type": "github" 12 + }, 13 + "original": { 14 + "owner": "ipetkov", 15 + "ref": "v0.21.1", 16 + "repo": "crane", 17 + "type": "github" 18 + } 19 + }, 20 + "dream2nix": { 21 + "inputs": { 22 + "nixpkgs": [ 23 + "nci", 24 + "nixpkgs" 25 + ], 26 + "purescript-overlay": "purescript-overlay", 27 + "pyproject-nix": "pyproject-nix" 28 + }, 29 + "locked": { 30 + "lastModified": 1754978539, 31 + "narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=", 32 + "owner": "nix-community", 33 + "repo": "dream2nix", 34 + "rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214", 35 + "type": "github" 36 + }, 37 + "original": { 38 + "owner": "nix-community", 39 + "repo": "dream2nix", 40 + "type": "github" 41 + } 42 + }, 43 + "fenix": { 44 + "inputs": { 45 + "nixpkgs": [ 46 + "nixpkgs" 47 + ], 48 + "rust-analyzer-src": "rust-analyzer-src" 49 + }, 50 + "locked": { 51 + "lastModified": 1762584108, 52 + "narHash": "sha256-wZUW7dlXMXaRdvNbaADqhF8gg9bAfFiMV+iyFQiDv+Y=", 53 + "owner": "nix-community", 54 + "repo": "fenix", 55 + "rev": "32f3ad3b6c690061173e1ac16708874975ec6056", 56 + "type": "github" 57 + }, 58 + "original": { 59 + "owner": "nix-community", 60 + "repo": "fenix", 61 + "type": "github" 62 + } 63 + }, 64 + "flake-compat": { 65 + "flake": false, 66 + "locked": { 67 + "lastModified": 1696426674, 68 + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 69 + "owner": "edolstra", 70 + "repo": "flake-compat", 71 + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 72 + "type": "github" 73 + }, 74 + "original": { 75 + "owner": "edolstra", 76 + "repo": "flake-compat", 77 + "type": "github" 78 + } 79 + }, 80 + "mk-naked-shell": { 81 + "flake": false, 82 + "locked": { 83 + "lastModified": 1681286841, 84 + "narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=", 85 + "owner": "90-008", 86 + "repo": "mk-naked-shell", 87 + "rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd", 88 + "type": "github" 89 + }, 90 + "original": { 91 + "owner": "90-008", 92 + "repo": "mk-naked-shell", 93 + "type": "github" 94 + } 95 + }, 96 + "nci": { 97 + "inputs": { 98 + "crane": "crane", 99 + "dream2nix": "dream2nix", 100 + "mk-naked-shell": "mk-naked-shell", 101 + "nixpkgs": [ 102 + "nixpkgs" 103 + ], 104 + "parts": "parts", 105 + "rust-overlay": "rust-overlay", 106 + "treefmt": "treefmt" 107 + }, 108 + "locked": { 109 + "lastModified": 1762582646, 110 + "narHash": "sha256-MMzE4xccG+8qbLhdaZoeFDUKWUOn3B4lhp5dZmgukmM=", 111 + "owner": "90-008", 112 + "repo": "nix-cargo-integration", 113 + "rev": "0993c449377049fa8868a664e8290ac6658e0b9a", 114 + "type": "github" 115 + }, 116 + "original": { 117 + "owner": "90-008", 118 + "repo": "nix-cargo-integration", 119 + "type": "github" 120 + } 121 + }, 122 + "nixpkgs": { 123 + "locked": { 124 + "lastModified": 1762361079, 125 + "narHash": "sha256-lz718rr1BDpZBYk7+G8cE6wee3PiBUpn8aomG/vLLiY=", 126 + "owner": "nixos", 127 + "repo": "nixpkgs", 128 + "rev": "ffcdcf99d65c61956d882df249a9be53e5902ea5", 129 + "type": "github" 130 + }, 131 + "original": { 132 + "owner": "nixos", 133 + "ref": "nixpkgs-unstable", 134 + "repo": "nixpkgs", 135 + "type": "github" 136 + } 137 + }, 138 + "parts": { 139 + "inputs": { 140 + "nixpkgs-lib": [ 141 + "nci", 142 + "nixpkgs" 143 + ] 144 + }, 145 + "locked": { 146 + "lastModified": 1762440070, 147 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 148 + "owner": "hercules-ci", 149 + "repo": "flake-parts", 150 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 151 + "type": "github" 152 + }, 153 + "original": { 154 + "owner": "hercules-ci", 155 + "repo": "flake-parts", 156 + "type": "github" 157 + } 158 + }, 159 + "parts_2": { 160 + "inputs": { 161 + "nixpkgs-lib": [ 162 + "nixpkgs" 163 + ] 164 + }, 165 + "locked": { 166 + "lastModified": 1762440070, 167 + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", 168 + "owner": "hercules-ci", 169 + "repo": "flake-parts", 170 + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", 171 + "type": "github" 172 + }, 173 + "original": { 174 + "owner": "hercules-ci", 175 + "repo": "flake-parts", 176 + "type": "github" 177 + } 178 + }, 179 + "purescript-overlay": { 180 + "inputs": { 181 + "flake-compat": "flake-compat", 182 + "nixpkgs": [ 183 + "nci", 184 + "dream2nix", 185 + "nixpkgs" 186 + ], 187 + "slimlock": "slimlock" 188 + }, 189 + "locked": { 190 + "lastModified": 1728546539, 191 + "narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=", 192 + "owner": "thomashoneyman", 193 + "repo": "purescript-overlay", 194 + "rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4", 195 + "type": "github" 196 + }, 197 + "original": { 198 + "owner": "thomashoneyman", 199 + "repo": "purescript-overlay", 200 + "type": "github" 201 + } 202 + }, 203 + "pyproject-nix": { 204 + "inputs": { 205 + "nixpkgs": [ 206 + "nci", 207 + "dream2nix", 208 + "nixpkgs" 209 + ] 210 + }, 211 + "locked": { 212 + "lastModified": 1752481895, 213 + "narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=", 214 + "owner": "pyproject-nix", 215 + "repo": "pyproject.nix", 216 + "rev": "16ee295c25107a94e59a7fc7f2e5322851781162", 217 + "type": "github" 218 + }, 219 + "original": { 220 + "owner": "pyproject-nix", 221 + "repo": "pyproject.nix", 222 + "type": "github" 223 + } 224 + }, 225 + "root": { 226 + "inputs": { 227 + "fenix": "fenix", 228 + "nci": "nci", 229 + "nixpkgs": "nixpkgs", 230 + "parts": "parts_2" 231 + } 232 + }, 233 + "rust-analyzer-src": { 234 + "flake": false, 235 + "locked": { 236 + "lastModified": 1762438844, 237 + "narHash": "sha256-ApIKJf6CcMsV2nYBXhGF95BmZMO/QXPhgfSnkA/rVUo=", 238 + "owner": "rust-lang", 239 + "repo": "rust-analyzer", 240 + "rev": "4bf516ee5a960c1e2eee9fedd9b1c9e976a19c86", 241 + "type": "github" 242 + }, 243 + "original": { 244 + "owner": "rust-lang", 245 + "ref": "nightly", 246 + "repo": "rust-analyzer", 247 + "type": "github" 248 + } 249 + }, 250 + "rust-overlay": { 251 + "inputs": { 252 + "nixpkgs": [ 253 + "nci", 254 + "nixpkgs" 255 + ] 256 + }, 257 + "locked": { 258 + "lastModified": 1762569282, 259 + "narHash": "sha256-vINZAJpXQTZd5cfh06Rcw7hesH7sGSvi+Tn+HUieJn8=", 260 + "owner": "oxalica", 261 + "repo": "rust-overlay", 262 + "rev": "a35a6144b976f70827c2fe2f5c89d16d8f9179d8", 263 + "type": "github" 264 + }, 265 + "original": { 266 + "owner": "oxalica", 267 + "repo": "rust-overlay", 268 + "type": "github" 269 + } 270 + }, 271 + "slimlock": { 272 + "inputs": { 273 + "nixpkgs": [ 274 + "nci", 275 + "dream2nix", 276 + "purescript-overlay", 277 + "nixpkgs" 278 + ] 279 + }, 280 + "locked": { 281 + "lastModified": 1688756706, 282 + "narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=", 283 + "owner": "thomashoneyman", 284 + "repo": "slimlock", 285 + "rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c", 286 + "type": "github" 287 + }, 288 + "original": { 289 + "owner": "thomashoneyman", 290 + "repo": "slimlock", 291 + "type": "github" 292 + } 293 + }, 294 + "treefmt": { 295 + "inputs": { 296 + "nixpkgs": [ 297 + "nci", 298 + "nixpkgs" 299 + ] 300 + }, 301 + "locked": { 302 + "lastModified": 1762410071, 303 + "narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=", 304 + "owner": "numtide", 305 + "repo": "treefmt-nix", 306 + "rev": "97a30861b13c3731a84e09405414398fbf3e109f", 307 + "type": "github" 308 + }, 309 + "original": { 310 + "owner": "numtide", 311 + "repo": "treefmt-nix", 312 + "type": "github" 313 + } 314 + } 315 + }, 316 + "root": "root", 317 + "version": 7 318 + }
+59
flake.nix
···
··· 1 + { 2 + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 3 + inputs.nci.url = "github:90-008/nix-cargo-integration"; 4 + inputs.nci.inputs.nixpkgs.follows = "nixpkgs"; 5 + inputs.parts.url = "github:hercules-ci/flake-parts"; 6 + inputs.parts.inputs.nixpkgs-lib.follows = "nixpkgs"; 7 + inputs.fenix = { 8 + url = "github:nix-community/fenix"; 9 + inputs.nixpkgs.follows = "nixpkgs"; 10 + }; 11 + 12 + outputs = inputs @ { 13 + parts, 14 + nci, 15 + ... 16 + }: 17 + parts.lib.mkFlake {inherit inputs;} { 18 + systems = ["x86_64-linux" "aarch64-darwin"]; 19 + imports = [ 20 + nci.flakeModule 21 + ./crates.nix 22 + ]; 23 + perSystem = { 24 + pkgs, 25 + config, 26 + ... 27 + }: let 28 + crateOutputs = config.nci.outputs."wisp-cli"; 29 + mkRenamedPackage = name: pkg: isWindows: pkgs.runCommand name {} '' 30 + mkdir -p $out/bin 31 + if [ -f ${pkg}/bin/wisp-cli.exe ]; then 32 + cp ${pkg}/bin/wisp-cli.exe $out/bin/${name} 33 + elif [ -f ${pkg}/bin/wisp-cli ]; then 34 + cp ${pkg}/bin/wisp-cli $out/bin/${name} 35 + else 36 + echo "Error: Could not find wisp-cli binary in ${pkg}/bin/" 37 + ls -la ${pkg}/bin/ || true 38 + exit 1 39 + fi 40 + ''; 41 + in { 42 + devShells.default = crateOutputs.devShell; 43 + packages.default = crateOutputs.packages.release; 44 + packages.wisp-cli-x86_64-linux = mkRenamedPackage "wisp-cli-x86_64-linux" crateOutputs.packages.release false; 45 + packages.wisp-cli-aarch64-linux = mkRenamedPackage "wisp-cli-aarch64-linux" crateOutputs.allTargets."aarch64-unknown-linux-gnu".packages.release false; 46 + packages.wisp-cli-x86_64-windows = mkRenamedPackage "wisp-cli-x86_64-windows.exe" crateOutputs.allTargets."x86_64-pc-windows-gnu".packages.release true; 47 + packages.wisp-cli-aarch64-darwin = mkRenamedPackage "wisp-cli-aarch64-darwin" crateOutputs.allTargets."aarch64-apple-darwin".packages.release false; 48 + packages.all = pkgs.symlinkJoin { 49 + name = "wisp-cli-all"; 50 + paths = [ 51 + config.packages.wisp-cli-x86_64-linux 52 + config.packages.wisp-cli-aarch64-linux 53 + config.packages.wisp-cli-x86_64-windows 54 + config.packages.wisp-cli-aarch64-darwin 55 + ]; 56 + }; 57 + }; 58 + }; 59 + }
-123
hosting-service/EXAMPLE.md
··· 1 - # HTML Path Rewriting Example 2 - 3 - This document demonstrates how HTML path rewriting works when serving sites via the `/s/:identifier/:site/*` route. 4 - 5 - ## Problem 6 - 7 - When you create a static site with absolute paths like `/style.css` or `/images/logo.png`, these paths work fine when served from the root domain. However, when served from a subdirectory like `/s/alice.bsky.social/mysite/`, these absolute paths break because they resolve to the server root instead of the site root. 8 - 9 - ## Solution 10 - 11 - The hosting service automatically rewrites absolute paths in HTML files to work correctly in the subdirectory context. 12 - 13 - ## Example 14 - 15 - **Original HTML file (index.html):** 16 - ```html 17 - <!DOCTYPE html> 18 - <html> 19 - <head> 20 - <meta charset="UTF-8"> 21 - <title>My Site</title> 22 - <link rel="stylesheet" href="/style.css"> 23 - <link rel="icon" href="/favicon.ico"> 24 - <script src="/app.js"></script> 25 - </head> 26 - <body> 27 - <header> 28 - <img src="/images/logo.png" alt="Logo"> 29 - <nav> 30 - <a href="/">Home</a> 31 - <a href="/about">About</a> 32 - <a href="/contact">Contact</a> 33 - </nav> 34 - </header> 35 - 36 - <main> 37 - <h1>Welcome</h1> 38 - <img src="/images/hero.jpg" 39 - srcset="/images/hero.jpg 1x, /images/hero@2x.jpg 2x" 40 - alt="Hero"> 41 - 42 - <form action="/submit" method="post"> 43 - <input type="text" name="email"> 44 - <button>Submit</button> 45 - </form> 46 - </main> 47 - 48 - <footer> 49 - <a href="https://example.com">External Link</a> 50 - <a href="#top">Back to Top</a> 51 - </footer> 52 - </body> 53 - </html> 54 - ``` 55 - 56 - **When accessed via `/s/alice.bsky.social/mysite/`, the HTML is rewritten to:** 57 - ```html 58 - <!DOCTYPE html> 59 - <html> 60 - <head> 61 - <meta charset="UTF-8"> 62 - <title>My Site</title> 63 - <link rel="stylesheet" href="/s/alice.bsky.social/mysite/style.css"> 64 - <link rel="icon" href="/s/alice.bsky.social/mysite/favicon.ico"> 65 - <script src="/s/alice.bsky.social/mysite/app.js"></script> 66 - </head> 67 - <body> 68 - <header> 69 - <img src="/s/alice.bsky.social/mysite/images/logo.png" alt="Logo"> 70 - <nav> 71 - <a href="/s/alice.bsky.social/mysite/">Home</a> 72 - <a href="/s/alice.bsky.social/mysite/about">About</a> 73 - <a href="/s/alice.bsky.social/mysite/contact">Contact</a> 74 - </nav> 75 - </header> 76 - 77 - <main> 78 - <h1>Welcome</h1> 79 - <img src="/s/alice.bsky.social/mysite/images/hero.jpg" 80 - srcset="/s/alice.bsky.social/mysite/images/hero.jpg 1x, /s/alice.bsky.social/mysite/images/hero@2x.jpg 2x" 81 - alt="Hero"> 82 - 83 - <form action="/s/alice.bsky.social/mysite/submit" method="post"> 84 - <input type="text" name="email"> 85 - <button>Submit</button> 86 - </form> 87 - </main> 88 - 89 - <footer> 90 - <a href="https://example.com">External Link</a> 91 - <a href="#top">Back to Top</a> 92 - </footer> 93 - </body> 94 - </html> 95 - ``` 96 - 97 - ## What's Preserved 98 - 99 - Notice that: 100 - - โœ… Absolute paths are rewritten: `/style.css` โ†’ `/s/alice.bsky.social/mysite/style.css` 101 - - โœ… External URLs are preserved: `https://example.com` stays the same 102 - - โœ… Anchors are preserved: `#top` stays the same 103 - - โœ… The rewriting is safe and won't break your site 104 - 105 - ## Supported Attributes 106 - 107 - The rewriter handles these HTML attributes: 108 - - `src` - images, scripts, iframes, videos, audio 109 - - `href` - links, stylesheets 110 - - `action` - forms 111 - - `data` - objects 112 - - `poster` - video posters 113 - - `srcset` - responsive images 114 - 115 - ## Testing Your Site 116 - 117 - To test if your site works with path rewriting: 118 - 119 - 1. Upload your site to your PDS as a `place.wisp.fs` record 120 - 2. Access it via: `https://hosting.wisp.place/s/YOUR_HANDLE/SITE_NAME/` 121 - 3. Check that all resources load correctly 122 - 123 - If you're using relative paths already (like `./style.css` or `../images/logo.png`), they'll work without any rewriting.
···
+134
hosting-service/example-_redirects
···
··· 1 + # Example _redirects file for Wisp hosting 2 + # Place this file in the root directory of your site as "_redirects" 3 + # Lines starting with # are comments 4 + 5 + # =================================== 6 + # SIMPLE REDIRECTS 7 + # =================================== 8 + 9 + # Redirect home page 10 + # /home / 11 + 12 + # Redirect old URLs to new ones 13 + # /old-blog /blog 14 + # /about-us /about 15 + 16 + # =================================== 17 + # SPLAT REDIRECTS (WILDCARDS) 18 + # =================================== 19 + 20 + # Redirect entire directories 21 + # /news/* /blog/:splat 22 + # /old-site/* /new-site/:splat 23 + 24 + # =================================== 25 + # PLACEHOLDER REDIRECTS 26 + # =================================== 27 + 28 + # Restructure blog URLs 29 + # /blog/:year/:month/:day/:slug /posts/:year-:month-:day/:slug 30 + 31 + # Capture multiple parameters 32 + # /products/:category/:id /shop/:category/item/:id 33 + 34 + # =================================== 35 + # STATUS CODES 36 + # =================================== 37 + 38 + # Permanent redirect (301) - default if not specified 39 + # /permanent-move /new-location 301 40 + 41 + # Temporary redirect (302) 42 + # /temp-redirect /temp-location 302 43 + 44 + # Rewrite (200) - serves different content, URL stays the same 45 + # /api/* /functions/:splat 200 46 + 47 + # Custom 404 page 48 + # /shop/* /shop-closed.html 404 49 + 50 + # =================================== 51 + # FORCE REDIRECTS 52 + # =================================== 53 + 54 + # Force redirect even if file exists (note the ! after status code) 55 + # /override-file /other-file.html 200! 56 + 57 + # =================================== 58 + # CONDITIONAL REDIRECTS 59 + # =================================== 60 + 61 + # Country-based redirects (ISO 3166-1 alpha-2 codes) 62 + # / /us/ 302 Country=us 63 + # / /uk/ 302 Country=gb 64 + # / /anz/ 302 Country=au,nz 65 + 66 + # Language-based redirects 67 + # /products /en/products 301 Language=en 68 + # /products /de/products 301 Language=de 69 + # /products /fr/products 301 Language=fr 70 + 71 + # Cookie-based redirects (checks if cookie exists) 72 + # /* /legacy/:splat 200 Cookie=is_legacy 73 + 74 + # =================================== 75 + # QUERY PARAMETERS 76 + # =================================== 77 + 78 + # Match specific query parameters 79 + # /store id=:id /blog/:id 301 80 + 81 + # Multiple parameters 82 + # /search q=:query category=:cat /find/:cat/:query 301 83 + 84 + # =================================== 85 + # DOMAIN-LEVEL REDIRECTS 86 + # =================================== 87 + 88 + # Redirect to different domain (must include protocol) 89 + # /external https://example.com/path 90 + 91 + # Redirect entire subdomain 92 + # http://blog.example.com/* https://example.com/blog/:splat 301! 93 + # https://blog.example.com/* https://example.com/blog/:splat 301! 94 + 95 + # =================================== 96 + # COMMON PATTERNS 97 + # =================================== 98 + 99 + # Remove .html extensions 100 + # /page.html /page 101 + 102 + # Add trailing slash 103 + # /about /about/ 104 + 105 + # Single-page app fallback (serve index.html for all paths) 106 + # /* /index.html 200 107 + 108 + # API proxy 109 + # /api/* https://api.example.com/:splat 200 110 + 111 + # =================================== 112 + # CUSTOM ERROR PAGES 113 + # =================================== 114 + 115 + # Language-specific 404 pages 116 + # /en/* /en/404.html 404 117 + # /de/* /de/404.html 404 118 + 119 + # Section-specific 404 pages 120 + # /shop/* /shop/not-found.html 404 121 + # /blog/* /blog/404.html 404 122 + 123 + # =================================== 124 + # NOTES 125 + # =================================== 126 + # 127 + # - Rules are processed in order (first match wins) 128 + # - More specific rules should come before general ones 129 + # - Splats (*) can only be used at the end of a path 130 + # - Query parameters are automatically preserved for 200, 301, 302 131 + # - Trailing slashes are normalized (/ and no / are treated the same) 132 + # - Default status code is 301 if not specified 133 + # 134 +
+16
hosting-service/src/index.ts
··· 4 import { logger } from './lib/observability'; 5 import { mkdirSync, existsSync } from 'fs'; 6 import { backfillCache } from './lib/backfill'; 7 8 const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; 9 const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; ··· 13 const hasBackfillFlag = args.includes('--backfill'); 14 const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true'; 15 16 // Ensure cache directory exists 17 if (!existsSync(CACHE_DIR)) { 18 mkdirSync(CACHE_DIR, { recursive: true }); 19 console.log('Created cache directory:', CACHE_DIR); 20 } 21 22 // Start firehose worker with observability logger 23 const firehose = new FirehoseWorker((msg, data) => { ··· 61 Health: http://localhost:${PORT}/health 62 Cache: ${CACHE_DIR} 63 Firehose: Connected to Firehose 64 `); 65 66 // Graceful shutdown 67 process.on('SIGINT', async () => { 68 console.log('\n๐Ÿ›‘ Shutting down...'); 69 firehose.stop(); 70 server.close(); 71 process.exit(0); 72 }); ··· 74 process.on('SIGTERM', async () => { 75 console.log('\n๐Ÿ›‘ Shutting down...'); 76 firehose.stop(); 77 server.close(); 78 process.exit(0); 79 });
··· 4 import { logger } from './lib/observability'; 5 import { mkdirSync, existsSync } from 'fs'; 6 import { backfillCache } from './lib/backfill'; 7 + import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db'; 8 9 const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; 10 const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; ··· 14 const hasBackfillFlag = args.includes('--backfill'); 15 const backfillOnStartup = hasBackfillFlag || process.env.BACKFILL_ON_STARTUP === 'true'; 16 17 + // Cache-only mode: service will only cache files locally, no DB writes 18 + const hasCacheOnlyFlag = args.includes('--cache-only'); 19 + export const CACHE_ONLY_MODE = hasCacheOnlyFlag || process.env.CACHE_ONLY_MODE === 'true'; 20 + 21 + // Configure cache-only mode in database module 22 + if (CACHE_ONLY_MODE) { 23 + setCacheOnlyMode(true); 24 + } 25 + 26 // Ensure cache directory exists 27 if (!existsSync(CACHE_DIR)) { 28 mkdirSync(CACHE_DIR, { recursive: true }); 29 console.log('Created cache directory:', CACHE_DIR); 30 } 31 + 32 + // Start domain cache cleanup 33 + startDomainCacheCleanup(); 34 35 // Start firehose worker with observability logger 36 const firehose = new FirehoseWorker((msg, data) => { ··· 74 Health: http://localhost:${PORT}/health 75 Cache: ${CACHE_DIR} 76 Firehose: Connected to Firehose 77 + Cache-Only: ${CACHE_ONLY_MODE ? 'ENABLED (no DB writes)' : 'DISABLED'} 78 `); 79 80 // Graceful shutdown 81 process.on('SIGINT', async () => { 82 console.log('\n๐Ÿ›‘ Shutting down...'); 83 firehose.stop(); 84 + stopDomainCacheCleanup(); 85 server.close(); 86 process.exit(0); 87 }); ··· 89 process.on('SIGTERM', async () => { 90 console.log('\n๐Ÿ›‘ Shutting down...'); 91 firehose.stop(); 92 + stopDomainCacheCleanup(); 93 server.close(); 94 process.exit(0); 95 });
+177
hosting-service/src/lib/cache.ts
···
··· 1 + // In-memory LRU cache for file contents and metadata 2 + 3 + interface CacheEntry<T> { 4 + value: T; 5 + size: number; 6 + timestamp: number; 7 + } 8 + 9 + interface CacheStats { 10 + hits: number; 11 + misses: number; 12 + evictions: number; 13 + currentSize: number; 14 + currentCount: number; 15 + } 16 + 17 + export class LRUCache<T> { 18 + private cache: Map<string, CacheEntry<T>>; 19 + private maxSize: number; 20 + private maxCount: number; 21 + private currentSize: number; 22 + private stats: CacheStats; 23 + 24 + constructor(maxSize: number, maxCount: number) { 25 + this.cache = new Map(); 26 + this.maxSize = maxSize; 27 + this.maxCount = maxCount; 28 + this.currentSize = 0; 29 + this.stats = { 30 + hits: 0, 31 + misses: 0, 32 + evictions: 0, 33 + currentSize: 0, 34 + currentCount: 0, 35 + }; 36 + } 37 + 38 + get(key: string): T | null { 39 + const entry = this.cache.get(key); 40 + if (!entry) { 41 + this.stats.misses++; 42 + return null; 43 + } 44 + 45 + // Move to end (most recently used) 46 + this.cache.delete(key); 47 + this.cache.set(key, entry); 48 + 49 + this.stats.hits++; 50 + return entry.value; 51 + } 52 + 53 + set(key: string, value: T, size: number): void { 54 + // Remove existing entry if present 55 + if (this.cache.has(key)) { 56 + const existing = this.cache.get(key)!; 57 + this.currentSize -= existing.size; 58 + this.cache.delete(key); 59 + } 60 + 61 + // Evict entries if needed 62 + while ( 63 + (this.cache.size >= this.maxCount || this.currentSize + size > this.maxSize) && 64 + this.cache.size > 0 65 + ) { 66 + const firstKey = this.cache.keys().next().value; 67 + if (!firstKey) break; // Should never happen, but satisfy TypeScript 68 + const firstEntry = this.cache.get(firstKey); 69 + if (!firstEntry) break; // Should never happen, but satisfy TypeScript 70 + this.cache.delete(firstKey); 71 + this.currentSize -= firstEntry.size; 72 + this.stats.evictions++; 73 + } 74 + 75 + // Add new entry 76 + this.cache.set(key, { 77 + value, 78 + size, 79 + timestamp: Date.now(), 80 + }); 81 + this.currentSize += size; 82 + 83 + // Update stats 84 + this.stats.currentSize = this.currentSize; 85 + this.stats.currentCount = this.cache.size; 86 + } 87 + 88 + delete(key: string): boolean { 89 + const entry = this.cache.get(key); 90 + if (!entry) return false; 91 + 92 + this.cache.delete(key); 93 + this.currentSize -= entry.size; 94 + this.stats.currentSize = this.currentSize; 95 + this.stats.currentCount = this.cache.size; 96 + return true; 97 + } 98 + 99 + // Invalidate all entries for a specific site 100 + invalidateSite(did: string, rkey: string): number { 101 + const prefix = `${did}:${rkey}:`; 102 + let count = 0; 103 + 104 + for (const key of Array.from(this.cache.keys())) { 105 + if (key.startsWith(prefix)) { 106 + this.delete(key); 107 + count++; 108 + } 109 + } 110 + 111 + return count; 112 + } 113 + 114 + // Get cache size 115 + size(): number { 116 + return this.cache.size; 117 + } 118 + 119 + clear(): void { 120 + this.cache.clear(); 121 + this.currentSize = 0; 122 + this.stats.currentSize = 0; 123 + this.stats.currentCount = 0; 124 + } 125 + 126 + getStats(): CacheStats { 127 + return { ...this.stats }; 128 + } 129 + 130 + // Get cache hit rate 131 + getHitRate(): number { 132 + const total = this.stats.hits + this.stats.misses; 133 + return total === 0 ? 0 : (this.stats.hits / total) * 100; 134 + } 135 + } 136 + 137 + // File metadata cache entry 138 + export interface FileMetadata { 139 + encoding?: 'gzip'; 140 + mimeType: string; 141 + } 142 + 143 + // Global cache instances 144 + const FILE_CACHE_SIZE = 100 * 1024 * 1024; // 100MB 145 + const FILE_CACHE_COUNT = 500; 146 + const METADATA_CACHE_COUNT = 2000; 147 + 148 + export const fileCache = new LRUCache<Buffer>(FILE_CACHE_SIZE, FILE_CACHE_COUNT); 149 + export const metadataCache = new LRUCache<FileMetadata>(1024 * 1024, METADATA_CACHE_COUNT); // 1MB for metadata 150 + export const rewrittenHtmlCache = new LRUCache<Buffer>(50 * 1024 * 1024, 200); // 50MB for rewritten HTML 151 + 152 + // Helper to generate cache keys 153 + export function getCacheKey(did: string, rkey: string, filePath: string, suffix?: string): string { 154 + const base = `${did}:${rkey}:${filePath}`; 155 + return suffix ? `${base}:${suffix}` : base; 156 + } 157 + 158 + // Invalidate all caches for a site 159 + export function invalidateSiteCache(did: string, rkey: string): void { 160 + const fileCount = fileCache.invalidateSite(did, rkey); 161 + const metaCount = metadataCache.invalidateSite(did, rkey); 162 + const htmlCount = rewrittenHtmlCache.invalidateSite(did, rkey); 163 + 164 + console.log(`[Cache] Invalidated site ${did}:${rkey} - ${fileCount} files, ${metaCount} metadata, ${htmlCount} HTML`); 165 + } 166 + 167 + // Get overall cache statistics 168 + export function getCacheStats() { 169 + return { 170 + files: fileCache.getStats(), 171 + fileHitRate: fileCache.getHitRate(), 172 + metadata: metadataCache.getStats(), 173 + metadataHitRate: metadataCache.getHitRate(), 174 + rewrittenHtml: rewrittenHtmlCache.getStats(), 175 + rewrittenHtmlHitRate: rewrittenHtmlCache.getHitRate(), 176 + }; 177 + }
+85
hosting-service/src/lib/db.ts
··· 1 import postgres from 'postgres'; 2 import { createHash } from 'crypto'; 3 4 const sql = postgres( 5 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', 6 { ··· 9 } 10 ); 11 12 export interface DomainLookup { 13 did: string; 14 rkey: string | null; ··· 27 export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 28 const key = domain.toLowerCase(); 29 30 // Query database 31 const result = await sql<DomainLookup[]>` 32 SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 33 `; 34 const data = result[0] || null; 35 36 return data; 37 } ··· 39 export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 40 const key = domain.toLowerCase(); 41 42 // Query database 43 const result = await sql<CustomDomainLookup[]>` 44 SELECT id, domain, did, rkey, verified FROM custom_domains ··· 46 `; 47 const data = result[0] || null; 48 49 return data; 50 } 51 52 export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 53 // Query database 54 const result = await sql<CustomDomainLookup[]>` 55 SELECT id, domain, did, rkey, verified FROM custom_domains ··· 57 `; 58 const data = result[0] || null; 59 60 return data; 61 } 62 63 export async function upsertSite(did: string, rkey: string, displayName?: string) { 64 try { 65 // Only set display_name if provided (not undefined/null/empty) 66 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
··· 1 import postgres from 'postgres'; 2 import { createHash } from 'crypto'; 3 4 + // Global cache-only mode flag (set by index.ts) 5 + let cacheOnlyMode = false; 6 + 7 + export function setCacheOnlyMode(enabled: boolean) { 8 + cacheOnlyMode = enabled; 9 + if (enabled) { 10 + console.log('[DB] Cache-only mode enabled - database writes will be skipped'); 11 + } 12 + } 13 + 14 const sql = postgres( 15 process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp', 16 { ··· 19 } 20 ); 21 22 + // Domain lookup cache with TTL 23 + const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes 24 + 25 + interface CachedDomain<T> { 26 + value: T; 27 + timestamp: number; 28 + } 29 + 30 + const domainCache = new Map<string, CachedDomain<DomainLookup | null>>(); 31 + const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>(); 32 + 33 + let cleanupInterval: NodeJS.Timeout | null = null; 34 + 35 + export function startDomainCacheCleanup() { 36 + if (cleanupInterval) return; 37 + 38 + cleanupInterval = setInterval(() => { 39 + const now = Date.now(); 40 + 41 + for (const [key, entry] of domainCache.entries()) { 42 + if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 43 + domainCache.delete(key); 44 + } 45 + } 46 + 47 + for (const [key, entry] of customDomainCache.entries()) { 48 + if (now - entry.timestamp > DOMAIN_CACHE_TTL) { 49 + customDomainCache.delete(key); 50 + } 51 + } 52 + }, 30 * 60 * 1000); // Run every 30 minutes 53 + } 54 + 55 + export function stopDomainCacheCleanup() { 56 + if (cleanupInterval) { 57 + clearInterval(cleanupInterval); 58 + cleanupInterval = null; 59 + } 60 + } 61 + 62 export interface DomainLookup { 63 did: string; 64 rkey: string | null; ··· 77 export async function getWispDomain(domain: string): Promise<DomainLookup | null> { 78 const key = domain.toLowerCase(); 79 80 + // Check cache first 81 + const cached = domainCache.get(key); 82 + if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 83 + return cached.value; 84 + } 85 + 86 // Query database 87 const result = await sql<DomainLookup[]>` 88 SELECT did, rkey FROM domains WHERE domain = ${key} LIMIT 1 89 `; 90 const data = result[0] || null; 91 + 92 + // Cache the result 93 + domainCache.set(key, { value: data, timestamp: Date.now() }); 94 95 return data; 96 } ··· 98 export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> { 99 const key = domain.toLowerCase(); 100 101 + // Check cache first 102 + const cached = customDomainCache.get(key); 103 + if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 104 + return cached.value; 105 + } 106 + 107 // Query database 108 const result = await sql<CustomDomainLookup[]>` 109 SELECT id, domain, did, rkey, verified FROM custom_domains ··· 111 `; 112 const data = result[0] || null; 113 114 + // Cache the result 115 + customDomainCache.set(key, { value: data, timestamp: Date.now() }); 116 + 117 return data; 118 } 119 120 export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> { 121 + const key = `hash:${hash}`; 122 + 123 + // Check cache first 124 + const cached = customDomainCache.get(key); 125 + if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { 126 + return cached.value; 127 + } 128 + 129 // Query database 130 const result = await sql<CustomDomainLookup[]>` 131 SELECT id, domain, did, rkey, verified FROM custom_domains ··· 133 `; 134 const data = result[0] || null; 135 136 + // Cache the result 137 + customDomainCache.set(key, { value: data, timestamp: Date.now() }); 138 + 139 return data; 140 } 141 142 export async function upsertSite(did: string, rkey: string, displayName?: string) { 143 + // Skip database writes in cache-only mode 144 + if (cacheOnlyMode) { 145 + console.log('[DB] Skipping upsertSite (cache-only mode)', { did, rkey }); 146 + return; 147 + } 148 + 149 try { 150 // Only set display_name if provided (not undefined/null/empty) 151 const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
+10 -1
hosting-service/src/lib/firehose.ts
··· 10 import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 import { Firehose } from '@atproto/sync' 12 import { IdResolver } from '@atproto/identity' 13 14 const CACHE_DIR = './cache/sites' 15 ··· 182 return 183 } 184 185 // Cache the record with verified CID (uses atomic swap internally) 186 // All instances cache locally for edge serving 187 await downloadAndCacheSite( ··· 193 ) 194 195 // Acquire distributed lock only for database write to prevent duplicate writes 196 const lockKey = `db:upsert:${did}:${site}` 197 const lockAcquired = await tryAcquireLock(lockKey) 198 ··· 210 211 try { 212 // Upsert site to database (only one instance does this) 213 await upsertSite(did, site, fsRecord.site) 214 this.log( 215 'Successfully processed create/update (cached + DB updated)', ··· 257 }) 258 } 259 260 - // Delete cache 261 this.deleteCache(did, site) 262 263 this.log('Successfully processed delete', { did, site })
··· 10 import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' 11 import { Firehose } from '@atproto/sync' 12 import { IdResolver } from '@atproto/identity' 13 + import { invalidateSiteCache } from './cache' 14 15 const CACHE_DIR = './cache/sites' 16 ··· 183 return 184 } 185 186 + // Invalidate in-memory caches before updating 187 + invalidateSiteCache(did, site) 188 + 189 // Cache the record with verified CID (uses atomic swap internally) 190 // All instances cache locally for edge serving 191 await downloadAndCacheSite( ··· 197 ) 198 199 // Acquire distributed lock only for database write to prevent duplicate writes 200 + // Note: upsertSite will check cache-only mode internally and skip if needed 201 const lockKey = `db:upsert:${did}:${site}` 202 const lockAcquired = await tryAcquireLock(lockKey) 203 ··· 215 216 try { 217 // Upsert site to database (only one instance does this) 218 + // In cache-only mode, this will be a no-op 219 await upsertSite(did, site, fsRecord.site) 220 this.log( 221 'Successfully processed create/update (cached + DB updated)', ··· 263 }) 264 } 265 266 + // Invalidate in-memory caches 267 + invalidateSiteCache(did, site) 268 + 269 + // Delete disk cache 270 this.deleteCache(did, site) 271 272 this.log('Successfully processed delete', { did, site })
+215
hosting-service/src/lib/redirects.test.ts
···
··· 1 + import { describe, it, expect } from 'bun:test' 2 + import { parseRedirectsFile, matchRedirectRule } from './redirects'; 3 + 4 + describe('parseRedirectsFile', () => { 5 + it('should parse simple redirects', () => { 6 + const content = ` 7 + # Comment line 8 + /old-path /new-path 9 + /home / 301 10 + `; 11 + const rules = parseRedirectsFile(content); 12 + expect(rules).toHaveLength(2); 13 + expect(rules[0]).toMatchObject({ 14 + from: '/old-path', 15 + to: '/new-path', 16 + status: 301, 17 + force: false, 18 + }); 19 + expect(rules[1]).toMatchObject({ 20 + from: '/home', 21 + to: '/', 22 + status: 301, 23 + force: false, 24 + }); 25 + }); 26 + 27 + it('should parse redirects with different status codes', () => { 28 + const content = ` 29 + /temp-redirect /target 302 30 + /rewrite /content 200 31 + /not-found /404 404 32 + `; 33 + const rules = parseRedirectsFile(content); 34 + expect(rules).toHaveLength(3); 35 + expect(rules[0]?.status).toBe(302); 36 + expect(rules[1]?.status).toBe(200); 37 + expect(rules[2]?.status).toBe(404); 38 + }); 39 + 40 + it('should parse force redirects', () => { 41 + const content = `/force-path /target 301!`; 42 + const rules = parseRedirectsFile(content); 43 + expect(rules[0]?.force).toBe(true); 44 + expect(rules[0]?.status).toBe(301); 45 + }); 46 + 47 + it('should parse splat redirects', () => { 48 + const content = `/news/* /blog/:splat`; 49 + const rules = parseRedirectsFile(content); 50 + expect(rules[0]?.from).toBe('/news/*'); 51 + expect(rules[0]?.to).toBe('/blog/:splat'); 52 + }); 53 + 54 + it('should parse placeholder redirects', () => { 55 + const content = `/blog/:year/:month/:day /posts/:year-:month-:day`; 56 + const rules = parseRedirectsFile(content); 57 + expect(rules[0]?.from).toBe('/blog/:year/:month/:day'); 58 + expect(rules[0]?.to).toBe('/posts/:year-:month-:day'); 59 + }); 60 + 61 + it('should parse country-based redirects', () => { 62 + const content = `/ /anz 302 Country=au,nz`; 63 + const rules = parseRedirectsFile(content); 64 + expect(rules[0]?.conditions?.country).toEqual(['au', 'nz']); 65 + }); 66 + 67 + it('should parse language-based redirects', () => { 68 + const content = `/products /en/products 301 Language=en`; 69 + const rules = parseRedirectsFile(content); 70 + expect(rules[0]?.conditions?.language).toEqual(['en']); 71 + }); 72 + 73 + it('should parse cookie-based redirects', () => { 74 + const content = `/* /legacy/:splat 200 Cookie=is_legacy,my_cookie`; 75 + const rules = parseRedirectsFile(content); 76 + expect(rules[0]?.conditions?.cookie).toEqual(['is_legacy', 'my_cookie']); 77 + }); 78 + }); 79 + 80 + describe('matchRedirectRule', () => { 81 + it('should match exact paths', () => { 82 + const rules = parseRedirectsFile('/old-path /new-path'); 83 + const match = matchRedirectRule('/old-path', rules); 84 + expect(match).toBeTruthy(); 85 + expect(match?.targetPath).toBe('/new-path'); 86 + expect(match?.status).toBe(301); 87 + }); 88 + 89 + it('should match paths with trailing slash', () => { 90 + const rules = parseRedirectsFile('/old-path /new-path'); 91 + const match = matchRedirectRule('/old-path/', rules); 92 + expect(match).toBeTruthy(); 93 + expect(match?.targetPath).toBe('/new-path'); 94 + }); 95 + 96 + it('should match splat patterns', () => { 97 + const rules = parseRedirectsFile('/news/* /blog/:splat'); 98 + const match = matchRedirectRule('/news/2024/01/15/my-post', rules); 99 + expect(match).toBeTruthy(); 100 + expect(match?.targetPath).toBe('/blog/2024/01/15/my-post'); 101 + }); 102 + 103 + it('should match placeholder patterns', () => { 104 + const rules = parseRedirectsFile('/blog/:year/:month/:day /posts/:year-:month-:day'); 105 + const match = matchRedirectRule('/blog/2024/01/15', rules); 106 + expect(match).toBeTruthy(); 107 + expect(match?.targetPath).toBe('/posts/2024-01-15'); 108 + }); 109 + 110 + it('should preserve query strings for 301/302 redirects', () => { 111 + const rules = parseRedirectsFile('/old /new 301'); 112 + const match = matchRedirectRule('/old', rules, { 113 + queryParams: { foo: 'bar', baz: 'qux' }, 114 + }); 115 + expect(match?.targetPath).toContain('?'); 116 + expect(match?.targetPath).toContain('foo=bar'); 117 + expect(match?.targetPath).toContain('baz=qux'); 118 + }); 119 + 120 + it('should match based on query parameters', () => { 121 + const rules = parseRedirectsFile('/store id=:id /blog/:id 301'); 122 + const match = matchRedirectRule('/store', rules, { 123 + queryParams: { id: 'my-post' }, 124 + }); 125 + expect(match).toBeTruthy(); 126 + expect(match?.targetPath).toContain('/blog/my-post'); 127 + }); 128 + 129 + it('should not match when query params are missing', () => { 130 + const rules = parseRedirectsFile('/store id=:id /blog/:id 301'); 131 + const match = matchRedirectRule('/store', rules, { 132 + queryParams: {}, 133 + }); 134 + expect(match).toBeNull(); 135 + }); 136 + 137 + it('should match based on country header', () => { 138 + const rules = parseRedirectsFile('/ /aus 302 Country=au'); 139 + const match = matchRedirectRule('/', rules, { 140 + headers: { 'cf-ipcountry': 'AU' }, 141 + }); 142 + expect(match).toBeTruthy(); 143 + expect(match?.targetPath).toBe('/aus'); 144 + }); 145 + 146 + it('should not match wrong country', () => { 147 + const rules = parseRedirectsFile('/ /aus 302 Country=au'); 148 + const match = matchRedirectRule('/', rules, { 149 + headers: { 'cf-ipcountry': 'US' }, 150 + }); 151 + expect(match).toBeNull(); 152 + }); 153 + 154 + it('should match based on language header', () => { 155 + const rules = parseRedirectsFile('/products /en/products 301 Language=en'); 156 + const match = matchRedirectRule('/products', rules, { 157 + headers: { 'accept-language': 'en-US,en;q=0.9' }, 158 + }); 159 + expect(match).toBeTruthy(); 160 + expect(match?.targetPath).toBe('/en/products'); 161 + }); 162 + 163 + it('should match based on cookie presence', () => { 164 + const rules = parseRedirectsFile('/* /legacy/:splat 200 Cookie=is_legacy'); 165 + const match = matchRedirectRule('/some-path', rules, { 166 + cookies: { is_legacy: 'true' }, 167 + }); 168 + expect(match).toBeTruthy(); 169 + expect(match?.targetPath).toBe('/legacy/some-path'); 170 + }); 171 + 172 + it('should return first matching rule', () => { 173 + const content = ` 174 + /path /first 175 + /path /second 176 + `; 177 + const rules = parseRedirectsFile(content); 178 + const match = matchRedirectRule('/path', rules); 179 + expect(match?.targetPath).toBe('/first'); 180 + }); 181 + 182 + it('should match more specific rules before general ones', () => { 183 + const content = ` 184 + /jobs/customer-ninja /careers/support 185 + /jobs/* /careers/:splat 186 + `; 187 + const rules = parseRedirectsFile(content); 188 + 189 + const match1 = matchRedirectRule('/jobs/customer-ninja', rules); 190 + expect(match1?.targetPath).toBe('/careers/support'); 191 + 192 + const match2 = matchRedirectRule('/jobs/developer', rules); 193 + expect(match2?.targetPath).toBe('/careers/developer'); 194 + }); 195 + 196 + it('should handle SPA routing pattern', () => { 197 + const rules = parseRedirectsFile('/* /index.html 200'); 198 + 199 + // Should match any path 200 + const match1 = matchRedirectRule('/about', rules); 201 + expect(match1).toBeTruthy(); 202 + expect(match1?.targetPath).toBe('/index.html'); 203 + expect(match1?.status).toBe(200); 204 + 205 + const match2 = matchRedirectRule('/users/123/profile', rules); 206 + expect(match2).toBeTruthy(); 207 + expect(match2?.targetPath).toBe('/index.html'); 208 + expect(match2?.status).toBe(200); 209 + 210 + const match3 = matchRedirectRule('/', rules); 211 + expect(match3).toBeTruthy(); 212 + expect(match3?.targetPath).toBe('/index.html'); 213 + }); 214 + }); 215 +
+413
hosting-service/src/lib/redirects.ts
···
··· 1 + import { readFile } from 'fs/promises'; 2 + import { existsSync } from 'fs'; 3 + 4 + export interface RedirectRule { 5 + from: string; 6 + to: string; 7 + status: number; 8 + force: boolean; 9 + conditions?: { 10 + country?: string[]; 11 + language?: string[]; 12 + role?: string[]; 13 + cookie?: string[]; 14 + }; 15 + // For pattern matching 16 + fromPattern?: RegExp; 17 + fromParams?: string[]; // Named parameters from the pattern 18 + queryParams?: Record<string, string>; // Expected query parameters 19 + } 20 + 21 + export interface RedirectMatch { 22 + rule: RedirectRule; 23 + targetPath: string; 24 + status: number; 25 + } 26 + 27 + /** 28 + * Parse a _redirects file into an array of redirect rules 29 + */ 30 + export function parseRedirectsFile(content: string): RedirectRule[] { 31 + const lines = content.split('\n'); 32 + const rules: RedirectRule[] = []; 33 + 34 + for (let lineNum = 0; lineNum < lines.length; lineNum++) { 35 + const lineRaw = lines[lineNum]; 36 + if (!lineRaw) continue; 37 + 38 + const line = lineRaw.trim(); 39 + 40 + // Skip empty lines and comments 41 + if (!line || line.startsWith('#')) { 42 + continue; 43 + } 44 + 45 + try { 46 + const rule = parseRedirectLine(line); 47 + if (rule && rule.fromPattern) { 48 + rules.push(rule); 49 + } 50 + } catch (err) { 51 + console.warn(`Failed to parse redirect rule on line ${lineNum + 1}: ${line}`, err); 52 + } 53 + } 54 + 55 + return rules; 56 + } 57 + 58 + /** 59 + * Parse a single redirect rule line 60 + * Format: /from [query_params] /to [status] [conditions] 61 + */ 62 + function parseRedirectLine(line: string): RedirectRule | null { 63 + // Split by whitespace, but respect quoted strings (though not commonly used) 64 + const parts = line.split(/\s+/); 65 + 66 + if (parts.length < 2) { 67 + return null; 68 + } 69 + 70 + let idx = 0; 71 + const from = parts[idx++]; 72 + 73 + if (!from) { 74 + return null; 75 + } 76 + 77 + let status = 301; // Default status 78 + let force = false; 79 + const conditions: NonNullable<RedirectRule['conditions']> = {}; 80 + const queryParams: Record<string, string> = {}; 81 + 82 + // Parse query parameters that come before the destination path 83 + // They look like: key=:value (and don't start with /) 84 + while (idx < parts.length) { 85 + const part = parts[idx]; 86 + if (!part) { 87 + idx++; 88 + continue; 89 + } 90 + 91 + // If it starts with / or http, it's the destination path 92 + if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) { 93 + break; 94 + } 95 + 96 + // If it contains = and comes before the destination, it's a query param 97 + if (part.includes('=')) { 98 + const splitIndex = part.indexOf('='); 99 + const key = part.slice(0, splitIndex); 100 + const value = part.slice(splitIndex + 1); 101 + 102 + if (key && value) { 103 + queryParams[key] = value; 104 + } 105 + idx++; 106 + } else { 107 + // Not a query param, must be destination or something else 108 + break; 109 + } 110 + } 111 + 112 + // Next part should be the destination 113 + if (idx >= parts.length) { 114 + return null; 115 + } 116 + 117 + const to = parts[idx++]; 118 + if (!to) { 119 + return null; 120 + } 121 + 122 + // Parse remaining parts for status code and conditions 123 + for (let i = idx; i < parts.length; i++) { 124 + const part = parts[i]; 125 + 126 + if (!part) continue; 127 + 128 + // Check for status code (with optional ! for force) 129 + if (/^\d+!?$/.test(part)) { 130 + if (part.endsWith('!')) { 131 + force = true; 132 + status = parseInt(part.slice(0, -1)); 133 + } else { 134 + status = parseInt(part); 135 + } 136 + continue; 137 + } 138 + 139 + // Check for condition parameters (Country=, Language=, Role=, Cookie=) 140 + if (part.includes('=')) { 141 + const splitIndex = part.indexOf('='); 142 + const key = part.slice(0, splitIndex); 143 + const value = part.slice(splitIndex + 1); 144 + 145 + if (!key || !value) continue; 146 + 147 + const keyLower = key.toLowerCase(); 148 + 149 + if (keyLower === 'country') { 150 + conditions.country = value.split(',').map(v => v.trim().toLowerCase()); 151 + } else if (keyLower === 'language') { 152 + conditions.language = value.split(',').map(v => v.trim().toLowerCase()); 153 + } else if (keyLower === 'role') { 154 + conditions.role = value.split(',').map(v => v.trim()); 155 + } else if (keyLower === 'cookie') { 156 + conditions.cookie = value.split(',').map(v => v.trim().toLowerCase()); 157 + } 158 + } 159 + } 160 + 161 + // Parse the 'from' pattern 162 + const { pattern, params } = convertPathToRegex(from); 163 + 164 + return { 165 + from, 166 + to, 167 + status, 168 + force, 169 + conditions: Object.keys(conditions).length > 0 ? conditions : undefined, 170 + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, 171 + fromPattern: pattern, 172 + fromParams: params, 173 + }; 174 + } 175 + 176 + /** 177 + * Convert a path pattern with placeholders and splats to a regex 178 + * Examples: 179 + * /blog/:year/:month/:day -> captures year, month, day 180 + * /news/* -> captures splat 181 + */ 182 + function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } { 183 + const params: string[] = []; 184 + let regexStr = '^'; 185 + 186 + // Split by query string if present 187 + const pathPart = pattern.split('?')[0] || pattern; 188 + 189 + // Escape special regex characters except * and : 190 + let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 191 + 192 + // Replace :param with named capture groups 193 + escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, paramName) => { 194 + params.push(paramName); 195 + // Match path segment (everything except / and ?) 196 + return '([^/?]+)'; 197 + }); 198 + 199 + // Replace * with splat capture (matches everything including /) 200 + if (escaped.includes('*')) { 201 + escaped = escaped.replace(/\*/g, '(.*)'); 202 + params.push('splat'); 203 + } 204 + 205 + regexStr += escaped; 206 + 207 + // Make trailing slash optional 208 + if (!regexStr.endsWith('.*')) { 209 + regexStr += '/?'; 210 + } 211 + 212 + regexStr += '$'; 213 + 214 + return { 215 + pattern: new RegExp(regexStr), 216 + params, 217 + }; 218 + } 219 + 220 + /** 221 + * Match a request path against redirect rules 222 + */ 223 + export function matchRedirectRule( 224 + requestPath: string, 225 + rules: RedirectRule[], 226 + context?: { 227 + queryParams?: Record<string, string>; 228 + headers?: Record<string, string>; 229 + cookies?: Record<string, string>; 230 + } 231 + ): RedirectMatch | null { 232 + // Normalize path: ensure leading slash, remove trailing slash (except for root) 233 + let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; 234 + 235 + for (const rule of rules) { 236 + // Check query parameter conditions first (if any) 237 + if (rule.queryParams) { 238 + // If rule requires query params but none provided, skip this rule 239 + if (!context?.queryParams) { 240 + continue; 241 + } 242 + 243 + const queryMatches = Object.entries(rule.queryParams).every(([key, value]) => { 244 + const actualValue = context.queryParams?.[key]; 245 + return actualValue !== undefined; 246 + }); 247 + 248 + if (!queryMatches) { 249 + continue; 250 + } 251 + } 252 + 253 + // Check conditional redirects (country, language, role, cookie) 254 + if (rule.conditions) { 255 + if (rule.conditions.country && context?.headers) { 256 + const cfCountry = context.headers['cf-ipcountry']; 257 + const xCountry = context.headers['x-country']; 258 + const country = (cfCountry?.toLowerCase() || xCountry?.toLowerCase()); 259 + if (!country || !rule.conditions.country.includes(country)) { 260 + continue; 261 + } 262 + } 263 + 264 + if (rule.conditions.language && context?.headers) { 265 + const acceptLang = context.headers['accept-language']; 266 + if (!acceptLang) { 267 + continue; 268 + } 269 + // Parse accept-language header (simplified) 270 + const langs = acceptLang.split(',').map(l => { 271 + const langPart = l.split(';')[0]; 272 + return langPart ? langPart.trim().toLowerCase() : ''; 273 + }).filter(l => l !== ''); 274 + const hasMatch = rule.conditions.language.some(lang => 275 + langs.some(l => l === lang || l.startsWith(lang + '-')) 276 + ); 277 + if (!hasMatch) { 278 + continue; 279 + } 280 + } 281 + 282 + if (rule.conditions.cookie && context?.cookies) { 283 + const hasCookie = rule.conditions.cookie.some(cookieName => 284 + context.cookies && cookieName in context.cookies 285 + ); 286 + if (!hasCookie) { 287 + continue; 288 + } 289 + } 290 + 291 + // Role-based redirects would need JWT verification - skip for now 292 + if (rule.conditions.role) { 293 + continue; 294 + } 295 + } 296 + 297 + // Match the path pattern 298 + const match = rule.fromPattern?.exec(normalizedPath); 299 + if (!match) { 300 + continue; 301 + } 302 + 303 + // Build the target path by replacing placeholders 304 + let targetPath = rule.to; 305 + 306 + // Replace captured parameters 307 + if (rule.fromParams && match.length > 1) { 308 + for (let i = 0; i < rule.fromParams.length; i++) { 309 + const paramName = rule.fromParams[i]; 310 + const paramValue = match[i + 1]; 311 + 312 + if (!paramName || !paramValue) continue; 313 + 314 + if (paramName === 'splat') { 315 + targetPath = targetPath.replace(':splat', paramValue); 316 + } else { 317 + targetPath = targetPath.replace(`:${paramName}`, paramValue); 318 + } 319 + } 320 + } 321 + 322 + // Handle query parameter replacements 323 + if (rule.queryParams && context?.queryParams) { 324 + for (const [key, placeholder] of Object.entries(rule.queryParams)) { 325 + const actualValue = context.queryParams[key]; 326 + if (actualValue && placeholder && placeholder.startsWith(':')) { 327 + const paramName = placeholder.slice(1); 328 + if (paramName) { 329 + targetPath = targetPath.replace(`:${paramName}`, actualValue); 330 + } 331 + } 332 + } 333 + } 334 + 335 + // Preserve query string for 200, 301, 302 redirects (unless target already has one) 336 + if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) { 337 + const queryString = Object.entries(context.queryParams) 338 + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) 339 + .join('&'); 340 + if (queryString) { 341 + targetPath += `?${queryString}`; 342 + } 343 + } 344 + 345 + return { 346 + rule, 347 + targetPath, 348 + status: rule.status, 349 + }; 350 + } 351 + 352 + return null; 353 + } 354 + 355 + /** 356 + * Load redirect rules from a cached site 357 + */ 358 + export async function loadRedirectRules(did: string, rkey: string): Promise<RedirectRule[]> { 359 + const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 360 + const redirectsPath = `${CACHE_DIR}/${did}/${rkey}/_redirects`; 361 + 362 + if (!existsSync(redirectsPath)) { 363 + return []; 364 + } 365 + 366 + try { 367 + const content = await readFile(redirectsPath, 'utf-8'); 368 + return parseRedirectsFile(content); 369 + } catch (err) { 370 + console.error('Failed to load _redirects file', err); 371 + return []; 372 + } 373 + } 374 + 375 + /** 376 + * Parse cookies from Cookie header 377 + */ 378 + export function parseCookies(cookieHeader?: string): Record<string, string> { 379 + if (!cookieHeader) return {}; 380 + 381 + const cookies: Record<string, string> = {}; 382 + const parts = cookieHeader.split(';'); 383 + 384 + for (const part of parts) { 385 + const [key, ...valueParts] = part.split('='); 386 + if (key && valueParts.length > 0) { 387 + cookies[key.trim()] = valueParts.join('=').trim(); 388 + } 389 + } 390 + 391 + return cookies; 392 + } 393 + 394 + /** 395 + * Parse query string into object 396 + */ 397 + export function parseQueryString(url: string): Record<string, string> { 398 + const queryStart = url.indexOf('?'); 399 + if (queryStart === -1) return {}; 400 + 401 + const queryString = url.slice(queryStart + 1); 402 + const params: Record<string, string> = {}; 403 + 404 + for (const pair of queryString.split('&')) { 405 + const [key, value] = pair.split('='); 406 + if (key) { 407 + params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; 408 + } 409 + } 410 + 411 + return params; 412 + } 413 +
+1 -1
hosting-service/src/lib/safe-fetch.ts
··· 25 const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads 26 const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB 27 const MAX_JSON_SIZE = 1024 * 1024; // 1MB 28 - const MAX_BLOB_SIZE = 100 * 1024 * 1024; // 100MB 29 const MAX_REDIRECTS = 10; 30 31 function isBlockedHost(hostname: string): boolean {
··· 25 const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads 26 const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB 27 const MAX_JSON_SIZE = 1024 * 1024; // 1MB 28 + const MAX_BLOB_SIZE = 500 * 1024 * 1024; // 500MB 29 const MAX_REDIRECTS = 10; 30 31 function isBlockedHost(hostname: string): boolean {
+2 -2
hosting-service/src/lib/utils.ts
··· 408 409 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 410 411 - // Allow up to 100MB per file blob, with 2 minute timeout 412 - let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024, timeout: 120000 }); 413 414 console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`); 415
··· 408 409 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 410 411 + // Allow up to 500MB per file blob, with 5 minute timeout 412 + let content = await safeFetchBlob(blobUrl, { maxSize: 500 * 1024 * 1024, timeout: 300000 }); 413 414 console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`); 415
+353 -132
hosting-service/src/server.ts
··· 2 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 3 import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 4 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 5 - import { existsSync, readFileSync } from 'fs'; 6 import { lookup } from 'mime-types'; 7 import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 8 9 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 10 ··· 21 return validRkeyPattern.test(rkey); 22 } 23 24 // Helper to serve files from cache 25 - async function serveFromCache(did: string, rkey: string, filePath: string) { 26 // Default to index.html if path is empty or ends with / 27 let requestPath = filePath || 'index.html'; 28 if (requestPath.endsWith('/')) { 29 requestPath += 'index.html'; 30 } 31 32 const cachedFile = getCachedFilePath(did, rkey, requestPath); 33 34 - if (existsSync(cachedFile)) { 35 - const content = readFileSync(cachedFile); 36 const metaFile = `${cachedFile}.meta`; 37 38 - console.log(`[DEBUG SERVE] ${requestPath}: file size=${content.length} bytes, metaFile exists=${existsSync(metaFile)}`); 39 40 - // Check if file has compression metadata 41 - if (existsSync(metaFile)) { 42 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 43 - console.log(`[DEBUG SERVE] ${requestPath}: meta=${JSON.stringify(meta)}`); 44 - 45 - // Check actual content for gzip magic bytes 46 - if (content.length >= 2) { 47 - const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b; 48 - console.log(`[DEBUG SERVE] ${requestPath}: has gzip magic bytes=${hasGzipMagic}`); 49 } 50 - 51 - if (meta.encoding === 'gzip' && meta.mimeType) { 52 - // Use shared function to determine if this should be served compressed 53 - const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 54 - 55 - if (!shouldServeCompressed) { 56 - // This shouldn't happen if caching is working correctly, but handle it gracefully 57 - console.log(`[DEBUG SERVE] ${requestPath}: decompressing file that shouldn't be compressed (${meta.mimeType})`); 58 - const { gunzipSync } = await import('zlib'); 59 - const decompressed = gunzipSync(content); 60 - console.log(`[DEBUG SERVE] ${requestPath}: decompressed from ${content.length} to ${decompressed.length} bytes`); 61 - return new Response(decompressed, { 62 - headers: { 63 - 'Content-Type': meta.mimeType, 64 - }, 65 - }); 66 - } 67 - 68 - // Serve gzipped content with proper headers (for HTML, CSS, JS, etc.) 69 - console.log(`[DEBUG SERVE] ${requestPath}: serving as gzipped with Content-Encoding header`); 70 - return new Response(content, { 71 - headers: { 72 - 'Content-Type': meta.mimeType, 73 - 'Content-Encoding': 'gzip', 74 - }, 75 - }); 76 - } 77 } 78 79 - // Serve non-compressed files normally 80 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 81 - return new Response(content, { 82 - headers: { 83 - 'Content-Type': mimeType, 84 - }, 85 - }); 86 } 87 88 // Try index.html for directory-like paths 89 if (!requestPath.includes('.')) { 90 - const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 91 - if (existsSync(indexFile)) { 92 - const content = readFileSync(indexFile); 93 - const metaFile = `${indexFile}.meta`; 94 95 - // Check if file has compression metadata 96 - if (existsSync(metaFile)) { 97 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 98 - if (meta.encoding === 'gzip' && meta.mimeType) { 99 - return new Response(content, { 100 - headers: { 101 - 'Content-Type': meta.mimeType, 102 - 'Content-Encoding': 'gzip', 103 - }, 104 - }); 105 - } 106 } 107 108 - return new Response(content, { 109 - headers: { 110 - 'Content-Type': 'text/html; charset=utf-8', 111 - }, 112 - }); 113 } 114 } 115 ··· 121 did: string, 122 rkey: string, 123 filePath: string, 124 - basePath: string 125 ) { 126 // Default to index.html if path is empty or ends with / 127 let requestPath = filePath || 'index.html'; 128 if (requestPath.endsWith('/')) { 129 requestPath += 'index.html'; 130 } 131 132 const cachedFile = getCachedFilePath(did, rkey, requestPath); 133 134 - if (existsSync(cachedFile)) { 135 - const metaFile = `${cachedFile}.meta`; 136 - let mimeType = lookup(cachedFile) || 'application/octet-stream'; 137 - let isGzipped = false; 138 139 - // Check if file has compression metadata 140 - if (existsSync(metaFile)) { 141 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 142 - if (meta.encoding === 'gzip' && meta.mimeType) { 143 - mimeType = meta.mimeType; 144 - isGzipped = true; 145 - } 146 } 147 148 // Check if this is HTML content that needs rewriting 149 - // We decompress, rewrite paths, then recompress for efficient delivery 150 if (isHtmlContent(requestPath, mimeType)) { 151 - let content: string; 152 if (isGzipped) { 153 const { gunzipSync } = await import('zlib'); 154 - const compressed = readFileSync(cachedFile); 155 - content = gunzipSync(compressed).toString('utf-8'); 156 } else { 157 - content = readFileSync(cachedFile, 'utf-8'); 158 } 159 - const rewritten = rewriteHtmlPaths(content, basePath, requestPath); 160 - 161 - // Recompress the HTML for efficient delivery 162 const { gzipSync } = await import('zlib'); 163 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 164 - 165 return new Response(recompressed, { 166 headers: { 167 'Content-Type': 'text/html; charset=utf-8', 168 'Content-Encoding': 'gzip', 169 }, 170 }); 171 } 172 173 - // Non-HTML files: serve gzipped content as-is with proper headers 174 - const content = readFileSync(cachedFile); 175 if (isGzipped) { 176 - // Use shared function to determine if this should be served compressed 177 const shouldServeCompressed = shouldCompressMimeType(mimeType); 178 - 179 if (!shouldServeCompressed) { 180 - // This shouldn't happen if caching is working correctly, but handle it gracefully 181 const { gunzipSync } = await import('zlib'); 182 const decompressed = gunzipSync(content); 183 - return new Response(decompressed, { 184 - headers: { 185 - 'Content-Type': mimeType, 186 - }, 187 - }); 188 } 189 - 190 - return new Response(content, { 191 headers: { 192 - 'Content-Type': mimeType, 193 'Content-Encoding': 'gzip', 194 }, 195 }); 196 } 197 - return new Response(content, { 198 - headers: { 199 - 'Content-Type': mimeType, 200 - }, 201 - }); 202 - } 203 204 - // Try index.html for directory-like paths 205 - if (!requestPath.includes('.')) { 206 - const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`); 207 - if (existsSync(indexFile)) { 208 - const metaFile = `${indexFile}.meta`; 209 - let isGzipped = false; 210 211 - if (existsSync(metaFile)) { 212 - const meta = JSON.parse(readFileSync(metaFile, 'utf-8')); 213 - if (meta.encoding === 'gzip') { 214 - isGzipped = true; 215 - } 216 } 217 218 - // HTML needs path rewriting, decompress, rewrite, then recompress 219 - let content: string; 220 if (isGzipped) { 221 const { gunzipSync } = await import('zlib'); 222 - const compressed = readFileSync(indexFile); 223 - content = gunzipSync(compressed).toString('utf-8'); 224 } else { 225 - content = readFileSync(indexFile, 'utf-8'); 226 } 227 - const indexPath = `${requestPath}/index.html`; 228 - const rewritten = rewriteHtmlPaths(content, basePath, indexPath); 229 - 230 - // Recompress the HTML for efficient delivery 231 const { gzipSync } = await import('zlib'); 232 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 233 - 234 return new Response(recompressed, { 235 headers: { 236 'Content-Type': 'text/html; charset=utf-8', 237 'Content-Encoding': 'gzip', 238 }, 239 }); 240 } ··· 264 265 try { 266 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 267 logger.info('Site cached successfully', { did, rkey }); 268 return true; 269 } catch (err) { ··· 331 332 // Serve with HTML path rewriting to handle absolute paths 333 const basePath = `/${identifier}/${site}/`; 334 - return serveFromCacheWithRewrite(did, site, filePath, basePath); 335 } 336 337 // Check if this is a DNS hash subdomain ··· 367 return c.text('Site not found', 404); 368 } 369 370 - return serveFromCache(customDomain.did, rkey, path); 371 } 372 373 // Route 2: Registered subdomains - /*.wisp.place/* ··· 391 return c.text('Site not found', 404); 392 } 393 394 - return serveFromCache(domainInfo.did, rkey, path); 395 } 396 397 // Route 1: Custom domains - /* ··· 414 return c.text('Site not found', 404); 415 } 416 417 - return serveFromCache(customDomain.did, rkey, path); 418 }); 419 420 // Internal observability endpoints (for admin panel) ··· 442 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 443 const stats = metricsCollector.getStats('hosting-service', timeWindow); 444 return c.json({ stats, timeWindow }); 445 }); 446 447 export default app;
··· 2 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 3 import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 4 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 5 + import { existsSync } from 'fs'; 6 + import { readFile, access } from 'fs/promises'; 7 import { lookup } from 'mime-types'; 8 import { logger, observabilityMiddleware, observabilityErrorHandler, logCollector, errorTracker, metricsCollector } from './lib/observability'; 9 + import { fileCache, metadataCache, rewrittenHtmlCache, getCacheKey, type FileMetadata } from './lib/cache'; 10 + import { loadRedirectRules, matchRedirectRule, parseCookies, parseQueryString, type RedirectRule } from './lib/redirects'; 11 12 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 13 ··· 24 return validRkeyPattern.test(rkey); 25 } 26 27 + /** 28 + * Async file existence check 29 + */ 30 + async function fileExists(path: string): Promise<boolean> { 31 + try { 32 + await access(path); 33 + return true; 34 + } catch { 35 + return false; 36 + } 37 + } 38 + 39 + // Cache for redirect rules (per site) 40 + const redirectRulesCache = new Map<string, RedirectRule[]>(); 41 + 42 + /** 43 + * Clear redirect rules cache for a specific site 44 + * Should be called when a site is updated/recached 45 + */ 46 + export function clearRedirectRulesCache(did: string, rkey: string) { 47 + const cacheKey = `${did}:${rkey}`; 48 + redirectRulesCache.delete(cacheKey); 49 + } 50 + 51 // Helper to serve files from cache 52 + async function serveFromCache( 53 + did: string, 54 + rkey: string, 55 + filePath: string, 56 + fullUrl?: string, 57 + headers?: Record<string, string> 58 + ) { 59 + // Check for redirect rules first 60 + const redirectCacheKey = `${did}:${rkey}`; 61 + let redirectRules = redirectRulesCache.get(redirectCacheKey); 62 + 63 + if (redirectRules === undefined) { 64 + // Load rules for the first time 65 + redirectRules = await loadRedirectRules(did, rkey); 66 + redirectRulesCache.set(redirectCacheKey, redirectRules); 67 + } 68 + 69 + // Apply redirect rules if any exist 70 + if (redirectRules.length > 0) { 71 + const requestPath = '/' + (filePath || ''); 72 + const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 73 + const cookies = parseCookies(headers?.['cookie']); 74 + 75 + const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 76 + queryParams, 77 + headers, 78 + cookies, 79 + }); 80 + 81 + if (redirectMatch) { 82 + const { targetPath, status } = redirectMatch; 83 + 84 + // Handle different status codes 85 + if (status === 200) { 86 + // Rewrite: serve different content but keep URL the same 87 + // Remove leading slash for internal path resolution 88 + const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 89 + return serveFileInternal(did, rkey, rewritePath); 90 + } else if (status === 301 || status === 302) { 91 + // External redirect: change the URL 92 + return new Response(null, { 93 + status, 94 + headers: { 95 + 'Location': targetPath, 96 + 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 97 + }, 98 + }); 99 + } else if (status === 404) { 100 + // Custom 404 page 101 + const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 102 + const response = await serveFileInternal(did, rkey, custom404Path); 103 + // Override status to 404 104 + return new Response(response.body, { 105 + status: 404, 106 + headers: response.headers, 107 + }); 108 + } 109 + } 110 + } 111 + 112 + // No redirect matched, serve normally 113 + return serveFileInternal(did, rkey, filePath); 114 + } 115 + 116 + // Internal function to serve a file (used by both normal serving and rewrites) 117 + async function serveFileInternal(did: string, rkey: string, filePath: string) { 118 // Default to index.html if path is empty or ends with / 119 let requestPath = filePath || 'index.html'; 120 if (requestPath.endsWith('/')) { 121 requestPath += 'index.html'; 122 } 123 124 + const cacheKey = getCacheKey(did, rkey, requestPath); 125 const cachedFile = getCachedFilePath(did, rkey, requestPath); 126 127 + // Check in-memory cache first 128 + let content = fileCache.get(cacheKey); 129 + let meta = metadataCache.get(cacheKey); 130 + 131 + if (!content && await fileExists(cachedFile)) { 132 + // Read from disk and cache 133 + content = await readFile(cachedFile); 134 + fileCache.set(cacheKey, content, content.length); 135 + 136 const metaFile = `${cachedFile}.meta`; 137 + if (await fileExists(metaFile)) { 138 + const metaJson = await readFile(metaFile, 'utf-8'); 139 + meta = JSON.parse(metaJson); 140 + metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 141 + } 142 + } 143 144 + if (content) { 145 + // Build headers with caching 146 + const headers: Record<string, string> = {}; 147 + 148 + if (meta && meta.encoding === 'gzip' && meta.mimeType) { 149 + const shouldServeCompressed = shouldCompressMimeType(meta.mimeType); 150 151 + if (!shouldServeCompressed) { 152 + const { gunzipSync } = await import('zlib'); 153 + const decompressed = gunzipSync(content); 154 + headers['Content-Type'] = meta.mimeType; 155 + headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 156 + return new Response(decompressed, { headers }); 157 } 158 + 159 + headers['Content-Type'] = meta.mimeType; 160 + headers['Content-Encoding'] = 'gzip'; 161 + headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 162 + ? 'public, max-age=300' 163 + : 'public, max-age=31536000, immutable'; 164 + return new Response(content, { headers }); 165 } 166 167 + // Non-compressed files 168 const mimeType = lookup(cachedFile) || 'application/octet-stream'; 169 + headers['Content-Type'] = mimeType; 170 + headers['Cache-Control'] = mimeType.startsWith('text/html') 171 + ? 'public, max-age=300' 172 + : 'public, max-age=31536000, immutable'; 173 + return new Response(content, { headers }); 174 } 175 176 // Try index.html for directory-like paths 177 if (!requestPath.includes('.')) { 178 + const indexPath = `${requestPath}/index.html`; 179 + const indexCacheKey = getCacheKey(did, rkey, indexPath); 180 + const indexFile = getCachedFilePath(did, rkey, indexPath); 181 182 + let indexContent = fileCache.get(indexCacheKey); 183 + let indexMeta = metadataCache.get(indexCacheKey); 184 + 185 + if (!indexContent && await fileExists(indexFile)) { 186 + indexContent = await readFile(indexFile); 187 + fileCache.set(indexCacheKey, indexContent, indexContent.length); 188 + 189 + const indexMetaFile = `${indexFile}.meta`; 190 + if (await fileExists(indexMetaFile)) { 191 + const metaJson = await readFile(indexMetaFile, 'utf-8'); 192 + indexMeta = JSON.parse(metaJson); 193 + metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 194 } 195 + } 196 197 + if (indexContent) { 198 + const headers: Record<string, string> = { 199 + 'Content-Type': 'text/html; charset=utf-8', 200 + 'Cache-Control': 'public, max-age=300', 201 + }; 202 + 203 + if (indexMeta && indexMeta.encoding === 'gzip') { 204 + headers['Content-Encoding'] = 'gzip'; 205 + } 206 + 207 + return new Response(indexContent, { headers }); 208 } 209 } 210 ··· 216 did: string, 217 rkey: string, 218 filePath: string, 219 + basePath: string, 220 + fullUrl?: string, 221 + headers?: Record<string, string> 222 ) { 223 + // Check for redirect rules first 224 + const redirectCacheKey = `${did}:${rkey}`; 225 + let redirectRules = redirectRulesCache.get(redirectCacheKey); 226 + 227 + if (redirectRules === undefined) { 228 + // Load rules for the first time 229 + redirectRules = await loadRedirectRules(did, rkey); 230 + redirectRulesCache.set(redirectCacheKey, redirectRules); 231 + } 232 + 233 + // Apply redirect rules if any exist 234 + if (redirectRules.length > 0) { 235 + const requestPath = '/' + (filePath || ''); 236 + const queryParams = fullUrl ? parseQueryString(fullUrl) : {}; 237 + const cookies = parseCookies(headers?.['cookie']); 238 + 239 + const redirectMatch = matchRedirectRule(requestPath, redirectRules, { 240 + queryParams, 241 + headers, 242 + cookies, 243 + }); 244 + 245 + if (redirectMatch) { 246 + const { targetPath, status } = redirectMatch; 247 + 248 + // Handle different status codes 249 + if (status === 200) { 250 + // Rewrite: serve different content but keep URL the same 251 + const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 252 + return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath); 253 + } else if (status === 301 || status === 302) { 254 + // External redirect: change the URL 255 + // For sites.wisp.place, we need to adjust the target path to include the base path 256 + // unless it's an absolute URL 257 + let redirectTarget = targetPath; 258 + if (!targetPath.startsWith('http://') && !targetPath.startsWith('https://')) { 259 + redirectTarget = basePath + (targetPath.startsWith('/') ? targetPath.slice(1) : targetPath); 260 + } 261 + return new Response(null, { 262 + status, 263 + headers: { 264 + 'Location': redirectTarget, 265 + 'Cache-Control': status === 301 ? 'public, max-age=31536000' : 'public, max-age=0', 266 + }, 267 + }); 268 + } else if (status === 404) { 269 + // Custom 404 page 270 + const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 271 + const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath); 272 + // Override status to 404 273 + return new Response(response.body, { 274 + status: 404, 275 + headers: response.headers, 276 + }); 277 + } 278 + } 279 + } 280 + 281 + // No redirect matched, serve normally 282 + return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 283 + } 284 + 285 + // Internal function to serve a file with rewriting 286 + async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 287 // Default to index.html if path is empty or ends with / 288 let requestPath = filePath || 'index.html'; 289 if (requestPath.endsWith('/')) { 290 requestPath += 'index.html'; 291 } 292 293 + const cacheKey = getCacheKey(did, rkey, requestPath); 294 const cachedFile = getCachedFilePath(did, rkey, requestPath); 295 296 + // Check for rewritten HTML in cache first (if it's HTML) 297 + const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 298 + if (isHtmlContent(requestPath, mimeTypeGuess)) { 299 + const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 300 + const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 301 + if (rewrittenContent) { 302 + return new Response(rewrittenContent, { 303 + headers: { 304 + 'Content-Type': 'text/html; charset=utf-8', 305 + 'Content-Encoding': 'gzip', 306 + 'Cache-Control': 'public, max-age=300', 307 + }, 308 + }); 309 + } 310 + } 311 312 + // Check in-memory file cache 313 + let content = fileCache.get(cacheKey); 314 + let meta = metadataCache.get(cacheKey); 315 + 316 + if (!content && await fileExists(cachedFile)) { 317 + // Read from disk and cache 318 + content = await readFile(cachedFile); 319 + fileCache.set(cacheKey, content, content.length); 320 + 321 + const metaFile = `${cachedFile}.meta`; 322 + if (await fileExists(metaFile)) { 323 + const metaJson = await readFile(metaFile, 'utf-8'); 324 + meta = JSON.parse(metaJson); 325 + metadataCache.set(cacheKey, meta!, JSON.stringify(meta).length); 326 } 327 + } 328 + 329 + if (content) { 330 + const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream'; 331 + const isGzipped = meta?.encoding === 'gzip'; 332 333 // Check if this is HTML content that needs rewriting 334 if (isHtmlContent(requestPath, mimeType)) { 335 + let htmlContent: string; 336 if (isGzipped) { 337 const { gunzipSync } = await import('zlib'); 338 + htmlContent = gunzipSync(content).toString('utf-8'); 339 } else { 340 + htmlContent = content.toString('utf-8'); 341 } 342 + const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath); 343 + 344 + // Recompress and cache the rewritten HTML 345 const { gzipSync } = await import('zlib'); 346 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 347 + 348 + const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 349 + rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 350 + 351 return new Response(recompressed, { 352 headers: { 353 'Content-Type': 'text/html; charset=utf-8', 354 'Content-Encoding': 'gzip', 355 + 'Cache-Control': 'public, max-age=300', 356 }, 357 }); 358 } 359 360 + // Non-HTML files: serve as-is 361 + const headers: Record<string, string> = { 362 + 'Content-Type': mimeType, 363 + 'Cache-Control': 'public, max-age=31536000, immutable', 364 + }; 365 + 366 if (isGzipped) { 367 const shouldServeCompressed = shouldCompressMimeType(mimeType); 368 if (!shouldServeCompressed) { 369 const { gunzipSync } = await import('zlib'); 370 const decompressed = gunzipSync(content); 371 + return new Response(decompressed, { headers }); 372 } 373 + headers['Content-Encoding'] = 'gzip'; 374 + } 375 + 376 + return new Response(content, { headers }); 377 + } 378 + 379 + // Try index.html for directory-like paths 380 + if (!requestPath.includes('.')) { 381 + const indexPath = `${requestPath}/index.html`; 382 + const indexCacheKey = getCacheKey(did, rkey, indexPath); 383 + const indexFile = getCachedFilePath(did, rkey, indexPath); 384 + 385 + // Check for rewritten index.html in cache 386 + const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 387 + const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 388 + if (rewrittenContent) { 389 + return new Response(rewrittenContent, { 390 headers: { 391 + 'Content-Type': 'text/html; charset=utf-8', 392 'Content-Encoding': 'gzip', 393 + 'Cache-Control': 'public, max-age=300', 394 }, 395 }); 396 } 397 398 + let indexContent = fileCache.get(indexCacheKey); 399 + let indexMeta = metadataCache.get(indexCacheKey); 400 + 401 + if (!indexContent && await fileExists(indexFile)) { 402 + indexContent = await readFile(indexFile); 403 + fileCache.set(indexCacheKey, indexContent, indexContent.length); 404 405 + const indexMetaFile = `${indexFile}.meta`; 406 + if (await fileExists(indexMetaFile)) { 407 + const metaJson = await readFile(indexMetaFile, 'utf-8'); 408 + indexMeta = JSON.parse(metaJson); 409 + metadataCache.set(indexCacheKey, indexMeta!, JSON.stringify(indexMeta).length); 410 } 411 + } 412 413 + if (indexContent) { 414 + const isGzipped = indexMeta?.encoding === 'gzip'; 415 + 416 + let htmlContent: string; 417 if (isGzipped) { 418 const { gunzipSync } = await import('zlib'); 419 + htmlContent = gunzipSync(indexContent).toString('utf-8'); 420 } else { 421 + htmlContent = indexContent.toString('utf-8'); 422 } 423 + const rewritten = rewriteHtmlPaths(htmlContent, basePath, indexPath); 424 + 425 const { gzipSync } = await import('zlib'); 426 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 427 + 428 + rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 429 + 430 return new Response(recompressed, { 431 headers: { 432 'Content-Type': 'text/html; charset=utf-8', 433 'Content-Encoding': 'gzip', 434 + 'Cache-Control': 'public, max-age=300', 435 }, 436 }); 437 } ··· 461 462 try { 463 await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid); 464 + // Clear redirect rules cache since the site was updated 465 + clearRedirectRulesCache(did, rkey); 466 logger.info('Site cached successfully', { did, rkey }); 467 return true; 468 } catch (err) { ··· 530 531 // Serve with HTML path rewriting to handle absolute paths 532 const basePath = `/${identifier}/${site}/`; 533 + const headers: Record<string, string> = {}; 534 + c.req.raw.headers.forEach((value, key) => { 535 + headers[key.toLowerCase()] = value; 536 + }); 537 + return serveFromCacheWithRewrite(did, site, filePath, basePath, c.req.url, headers); 538 } 539 540 // Check if this is a DNS hash subdomain ··· 570 return c.text('Site not found', 404); 571 } 572 573 + const headers: Record<string, string> = {}; 574 + c.req.raw.headers.forEach((value, key) => { 575 + headers[key.toLowerCase()] = value; 576 + }); 577 + return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 578 } 579 580 // Route 2: Registered subdomains - /*.wisp.place/* ··· 598 return c.text('Site not found', 404); 599 } 600 601 + const headers: Record<string, string> = {}; 602 + c.req.raw.headers.forEach((value, key) => { 603 + headers[key.toLowerCase()] = value; 604 + }); 605 + return serveFromCache(domainInfo.did, rkey, path, c.req.url, headers); 606 } 607 608 // Route 1: Custom domains - /* ··· 625 return c.text('Site not found', 404); 626 } 627 628 + const headers: Record<string, string> = {}; 629 + c.req.raw.headers.forEach((value, key) => { 630 + headers[key.toLowerCase()] = value; 631 + }); 632 + return serveFromCache(customDomain.did, rkey, path, c.req.url, headers); 633 }); 634 635 // Internal observability endpoints (for admin panel) ··· 657 const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000; 658 const stats = metricsCollector.getStats('hosting-service', timeWindow); 659 return c.json({ stats, timeWindow }); 660 + }); 661 + 662 + app.get('/__internal__/observability/cache', async (c) => { 663 + const { getCacheStats } = await import('./lib/cache'); 664 + const stats = getCacheStats(); 665 + return c.json({ cache: stats }); 666 }); 667 668 export default app;
+2
package.json
··· 24 "@radix-ui/react-slot": "^1.2.3", 25 "@radix-ui/react-tabs": "^1.1.13", 26 "@tanstack/react-query": "^5.90.2", 27 "class-variance-authority": "^0.7.1", 28 "clsx": "^2.1.1", 29 "elysia": "latest",
··· 24 "@radix-ui/react-slot": "^1.2.3", 25 "@radix-ui/react-tabs": "^1.1.13", 26 "@tanstack/react-query": "^5.90.2", 27 + "actor-typeahead": "^0.1.1", 28 + "atproto-ui": "^0.11.3", 29 "class-variance-authority": "^0.7.1", 30 "clsx": "^2.1.1", 31 "elysia": "latest",
+379
public/acceptable-use/acceptable-use.tsx
···
··· 1 + import { createRoot } from 'react-dom/client' 2 + import Layout from '@public/layouts' 3 + import { Button } from '@public/components/ui/button' 4 + import { Card } from '@public/components/ui/card' 5 + import { ArrowLeft, Shield, AlertCircle, CheckCircle, Scale } from 'lucide-react' 6 + 7 + function AcceptableUsePage() { 8 + return ( 9 + <div className="min-h-screen bg-background"> 10 + {/* Header */} 11 + <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 12 + <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 13 + <div className="flex items-center gap-2"> 14 + <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 15 + <span className="text-xl font-semibold text-foreground"> 16 + wisp.place 17 + </span> 18 + </div> 19 + <Button 20 + variant="ghost" 21 + size="sm" 22 + onClick={() => window.location.href = '/'} 23 + > 24 + <ArrowLeft className="w-4 h-4 mr-2" /> 25 + Back to Home 26 + </Button> 27 + </div> 28 + </header> 29 + 30 + {/* Hero Section */} 31 + <div className="bg-gradient-to-b from-accent/10 to-background border-b border-border/40"> 32 + <div className="container mx-auto px-4 py-16 max-w-4xl text-center"> 33 + <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent/20 mb-6"> 34 + <Shield className="w-8 h-8 text-accent" /> 35 + </div> 36 + <h1 className="text-4xl md:text-5xl font-bold mb-4">Acceptable Use Policy</h1> 37 + <div className="flex items-center justify-center gap-6 text-sm text-muted-foreground"> 38 + <div className="flex items-center gap-2"> 39 + <span className="font-medium">Effective:</span> 40 + <span>November 10, 2025</span> 41 + </div> 42 + <div className="h-4 w-px bg-border"></div> 43 + <div className="flex items-center gap-2"> 44 + <span className="font-medium">Last Updated:</span> 45 + <span>November 10, 2025</span> 46 + </div> 47 + </div> 48 + </div> 49 + </div> 50 + 51 + {/* Content */} 52 + <div className="container mx-auto px-4 py-12 max-w-4xl"> 53 + <article className="space-y-12"> 54 + {/* Our Philosophy */} 55 + <section> 56 + <h2 className="text-3xl font-bold mb-6 text-foreground">Our Philosophy</h2> 57 + <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 58 + <p> 59 + wisp.place exists to give you a corner of the internet that's truly yoursโ€”a place to create, experiment, and express yourself freely. We believe in the open web and the fundamental importance of free expression. We're not here to police your thoughts, moderate your aesthetics, or judge your taste. 60 + </p> 61 + <p> 62 + That said, we're also real people running real servers in real jurisdictions (the United States and the Netherlands), and there are legal and practical limits to what we can host. This policy aims to be as permissive as possible while keeping the lights on and staying on the right side of the law. 63 + </p> 64 + </div> 65 + </section> 66 + 67 + {/* What You Can Do */} 68 + <Card className="bg-green-500/5 border-green-500/20 p-8"> 69 + <div className="flex items-start gap-4"> 70 + <div className="flex-shrink-0"> 71 + <CheckCircle className="w-8 h-8 text-green-500" /> 72 + </div> 73 + <div className="space-y-4"> 74 + <h2 className="text-3xl font-bold text-foreground">What You Can Do</h2> 75 + <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 76 + <p> 77 + <strong className="text-green-600 dark:text-green-400">Almost anything.</strong> Seriously. Build weird art projects. Write controversial essays. Create spaces that would make corporate platforms nervous. Express unpopular opinions. Make things that are strange, provocative, uncomfortable, or just plain yours. 78 + </p> 79 + <p> 80 + We support creative and personal expression in all its forms, including adult content, political speech, counter-cultural work, and experimental projects. 81 + </p> 82 + </div> 83 + </div> 84 + </div> 85 + </Card> 86 + 87 + {/* What You Can't Do */} 88 + <section> 89 + <div className="flex items-center gap-3 mb-6"> 90 + <AlertCircle className="w-8 h-8 text-red-500" /> 91 + <h2 className="text-3xl font-bold text-foreground">What You Can't Do</h2> 92 + </div> 93 + 94 + <div className="space-y-8"> 95 + <Card className="p-6 border-2"> 96 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Illegal Content</h3> 97 + <p className="text-muted-foreground mb-4"> 98 + Don't host content that's illegal in the United States or the Netherlands. This includes but isn't limited to: 99 + </p> 100 + <ul className="space-y-3 text-muted-foreground"> 101 + <li className="flex items-start gap-3"> 102 + <span className="text-red-500 mt-1">โ€ข</span> 103 + <span><strong>Child sexual abuse material (CSAM)</strong> involving real minors in any form</span> 104 + </li> 105 + <li className="flex items-start gap-3"> 106 + <span className="text-red-500 mt-1">โ€ข</span> 107 + <span><strong>Realistic or AI-generated depictions</strong> of minors in sexual contexts, including photorealistic renders, deepfakes, or AI-generated imagery</span> 108 + </li> 109 + <li className="flex items-start gap-3"> 110 + <span className="text-red-500 mt-1">โ€ข</span> 111 + <span><strong>Non-consensual intimate imagery</strong> (revenge porn, deepfakes, hidden camera footage, etc.)</span> 112 + </li> 113 + <li className="flex items-start gap-3"> 114 + <span className="text-red-500 mt-1">โ€ข</span> 115 + <span>Content depicting or facilitating human trafficking, sexual exploitation, or sexual violence</span> 116 + </li> 117 + <li className="flex items-start gap-3"> 118 + <span className="text-red-500 mt-1">โ€ข</span> 119 + <span>Instructions for manufacturing explosives, biological weapons, or other instruments designed for mass harm</span> 120 + </li> 121 + <li className="flex items-start gap-3"> 122 + <span className="text-red-500 mt-1">โ€ข</span> 123 + <span>Content that facilitates imminent violence or terrorism</span> 124 + </li> 125 + <li className="flex items-start gap-3"> 126 + <span className="text-red-500 mt-1">โ€ข</span> 127 + <span>Stolen financial information, credentials, or personal data used for fraud</span> 128 + </li> 129 + </ul> 130 + </Card> 131 + 132 + <Card className="p-6 border-2"> 133 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Intellectual Property Violations</h3> 134 + <div className="space-y-4 text-muted-foreground"> 135 + <p> 136 + Don't host content that clearly violates someone else's copyright, trademark, or other intellectual property rights. We're required to respond to valid DMCA takedown notices. 137 + </p> 138 + <p> 139 + We understand that copyright law is complicated and sometimes ridiculous. We're not going to proactively scan your site or nitpick over fair use. But if we receive a legitimate legal complaint, we'll have to act on it. 140 + </p> 141 + </div> 142 + </Card> 143 + 144 + <Card className="p-6 border-2 border-red-500/30 bg-red-500/5"> 145 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Hate Content</h3> 146 + <div className="space-y-4 text-muted-foreground"> 147 + <p> 148 + You can express controversial ideas. You can be offensive. You can make people uncomfortable. But pure hateโ€”content that exists solely to dehumanize, threaten, or incite violence against people based on race, ethnicity, religion, gender, sexual orientation, disability, or similar characteristicsโ€”isn't welcome here. 149 + </p> 150 + <p> 151 + There's a difference between "I have deeply unpopular opinions about X" and "People like X should be eliminated." The former is protected expression. The latter isn't. 152 + </p> 153 + <div className="bg-background/50 border-l-4 border-red-500 p-4 rounded"> 154 + <p className="font-medium text-foreground"> 155 + <strong>A note on enforcement:</strong> While we're generally permissive and believe in giving people the benefit of the doubt, hate content is where we draw a hard line. I will be significantly more aggressive in moderating this type of content than anything else on this list. If your site exists primarily to spread hate or recruit people into hateful ideologies, you will be removed swiftly and without extensive appeals. This is non-negotiable. 156 + </p> 157 + </div> 158 + </div> 159 + </Card> 160 + 161 + <Card className="p-6 border-2"> 162 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Adult Content Guidelines</h3> 163 + <div className="space-y-4 text-muted-foreground"> 164 + <p> 165 + Adult content is allowed. This includes sexually explicit material, erotica, adult artwork, and NSFW creative expression. 166 + </p> 167 + <p className="font-medium">However:</p> 168 + <ul className="space-y-2"> 169 + <li className="flex items-start gap-3"> 170 + <span className="text-red-500 mt-1">โ€ข</span> 171 + <span>No content involving real minors in any sexual context whatsoever</span> 172 + </li> 173 + <li className="flex items-start gap-3"> 174 + <span className="text-red-500 mt-1">โ€ข</span> 175 + <span>No photorealistic, AI-generated, or otherwise realistic depictions of minors in sexual contexts</span> 176 + </li> 177 + <li className="flex items-start gap-3"> 178 + <span className="text-green-500 mt-1">โ€ข</span> 179 + <span>Clearly stylized drawings and written fiction are permitted, provided they remain obviously non-photographic in nature</span> 180 + </li> 181 + <li className="flex items-start gap-3"> 182 + <span className="text-red-500 mt-1">โ€ข</span> 183 + <span>No non-consensual content (revenge porn, voyeurism, etc.)</span> 184 + </li> 185 + <li className="flex items-start gap-3"> 186 + <span className="text-red-500 mt-1">โ€ข</span> 187 + <span>No content depicting illegal sexual acts (bestiality, necrophilia, etc.)</span> 188 + </li> 189 + <li className="flex items-start gap-3"> 190 + <span className="text-yellow-500 mt-1">โ€ข</span> 191 + <span>Adult content should be clearly marked as such if discoverable through public directories or search</span> 192 + </li> 193 + </ul> 194 + </div> 195 + </Card> 196 + 197 + <Card className="p-6 border-2"> 198 + <h3 className="text-2xl font-semibold mb-4 text-foreground">Malicious Technical Activity</h3> 199 + <p className="text-muted-foreground mb-4">Don't use your site to:</p> 200 + <ul className="space-y-2 text-muted-foreground"> 201 + <li className="flex items-start gap-3"> 202 + <span className="text-red-500 mt-1">โ€ข</span> 203 + <span>Distribute malware, viruses, or exploits</span> 204 + </li> 205 + <li className="flex items-start gap-3"> 206 + <span className="text-red-500 mt-1">โ€ข</span> 207 + <span>Conduct phishing or social engineering attacks</span> 208 + </li> 209 + <li className="flex items-start gap-3"> 210 + <span className="text-red-500 mt-1">โ€ข</span> 211 + <span>Launch DDoS attacks or network abuse</span> 212 + </li> 213 + <li className="flex items-start gap-3"> 214 + <span className="text-red-500 mt-1">โ€ข</span> 215 + <span>Mine cryptocurrency without explicit user consent</span> 216 + </li> 217 + <li className="flex items-start gap-3"> 218 + <span className="text-red-500 mt-1">โ€ข</span> 219 + <span>Scrape, spam, or abuse other services</span> 220 + </li> 221 + </ul> 222 + </Card> 223 + </div> 224 + </section> 225 + 226 + {/* Our Approach to Enforcement */} 227 + <section> 228 + <div className="flex items-center gap-3 mb-6"> 229 + <Scale className="w-8 h-8 text-accent" /> 230 + <h2 className="text-3xl font-bold text-foreground">Our Approach to Enforcement</h2> 231 + </div> 232 + <div className="space-y-6"> 233 + <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 234 + <p> 235 + <strong>We actively monitor for obvious violations.</strong> Not to censor your creativity or police your opinions, but to catch the clear-cut stuff that threatens the service's existence and makes this a worse place for everyone. We're looking for the blatantly illegal, the obviously harmfulโ€”the stuff that would get servers seized and communities destroyed. 236 + </p> 237 + <p> 238 + We're not reading your blog posts looking for wrongthink. We're making sure this platform doesn't become a haven for the kind of content that ruins good things. 239 + </p> 240 + </div> 241 + 242 + <Card className="p-6 bg-muted/30"> 243 + <p className="font-semibold mb-3 text-foreground">We take action when:</p> 244 + <ol className="space-y-2 text-muted-foreground"> 245 + <li className="flex items-start gap-3"> 246 + <span className="font-bold text-accent">1.</span> 247 + <span>We identify content that clearly violates this policy during routine monitoring</span> 248 + </li> 249 + <li className="flex items-start gap-3"> 250 + <span className="font-bold text-accent">2.</span> 251 + <span>We receive a valid legal complaint (DMCA, court order, etc.)</span> 252 + </li> 253 + <li className="flex items-start gap-3"> 254 + <span className="font-bold text-accent">3.</span> 255 + <span>Someone reports content that violates this policy and we can verify the violation</span> 256 + </li> 257 + <li className="flex items-start gap-3"> 258 + <span className="font-bold text-accent">4.</span> 259 + <span>Your site is causing technical problems for the service or other users</span> 260 + </li> 261 + </ol> 262 + </Card> 263 + 264 + <Card className="p-6 bg-muted/30"> 265 + <p className="font-semibold mb-3 text-foreground">When we do need to take action, we'll try to:</p> 266 + <ul className="space-y-2 text-muted-foreground"> 267 + <li className="flex items-start gap-3"> 268 + <span className="text-accent">โ€ข</span> 269 + <span>Contact you first when legally and practically possible</span> 270 + </li> 271 + <li className="flex items-start gap-3"> 272 + <span className="text-accent">โ€ข</span> 273 + <span>Be transparent about what's happening and why</span> 274 + </li> 275 + <li className="flex items-start gap-3"> 276 + <span className="text-accent">โ€ข</span> 277 + <span>Give you an opportunity to address the issue if appropriate</span> 278 + </li> 279 + </ul> 280 + </Card> 281 + 282 + <p className="text-muted-foreground"> 283 + For serious or repeated violations, we may suspend or terminate your account. 284 + </p> 285 + </div> 286 + </section> 287 + 288 + {/* Regional Compliance */} 289 + <Card className="p-6 bg-blue-500/5 border-blue-500/20"> 290 + <h2 className="text-2xl font-bold mb-4 text-foreground">Regional Compliance</h2> 291 + <p className="text-muted-foreground"> 292 + Our servers are located in the United States and the Netherlands. Content hosted on wisp.place must comply with the laws of both jurisdictions. While we aim to provide broad creative freedom, these legal requirements are non-negotiable. 293 + </p> 294 + </Card> 295 + 296 + {/* Changes to This Policy */} 297 + <section> 298 + <h2 className="text-2xl font-bold mb-4 text-foreground">Changes to This Policy</h2> 299 + <p className="text-muted-foreground"> 300 + We may update this policy as legal requirements or service realities change. If we make significant changes, we'll notify active users. 301 + </p> 302 + </section> 303 + 304 + {/* Questions or Reports */} 305 + <section> 306 + <h2 className="text-2xl font-bold mb-4 text-foreground">Questions or Reports</h2> 307 + <p className="text-muted-foreground"> 308 + If you have questions about this policy or need to report a violation, contact us at{' '} 309 + <a 310 + href="mailto:contact@wisp.place" 311 + className="text-accent hover:text-accent/80 transition-colors font-medium" 312 + > 313 + contact@wisp.place 314 + </a> 315 + . 316 + </p> 317 + </section> 318 + 319 + {/* Final Message */} 320 + <Card className="p-8 bg-accent/10 border-accent/30 border-2"> 321 + <p className="text-lg leading-relaxed text-foreground"> 322 + <strong>Remember:</strong> This policy exists to keep the service running and this community healthy, not to limit your creativity. When in doubt, ask yourself: "Is this likely to get real-world authorities knocking on doors or make this place worse for everyone?" If the answer is yes, it probably doesn't belong here. Everything else? Go wild. 323 + </p> 324 + </Card> 325 + </article> 326 + </div> 327 + 328 + {/* Footer */} 329 + <footer className="border-t border-border/40 bg-muted/20 mt-12"> 330 + <div className="container mx-auto px-4 py-8"> 331 + <div className="text-center text-sm text-muted-foreground"> 332 + <p> 333 + Built by{' '} 334 + <a 335 + href="https://bsky.app/profile/nekomimi.pet" 336 + target="_blank" 337 + rel="noopener noreferrer" 338 + className="text-accent hover:text-accent/80 transition-colors font-medium" 339 + > 340 + @nekomimi.pet 341 + </a> 342 + {' โ€ข '} 343 + Contact:{' '} 344 + <a 345 + href="mailto:contact@wisp.place" 346 + className="text-accent hover:text-accent/80 transition-colors font-medium" 347 + > 348 + contact@wisp.place 349 + </a> 350 + {' โ€ข '} 351 + Legal/DMCA:{' '} 352 + <a 353 + href="mailto:legal@wisp.place" 354 + className="text-accent hover:text-accent/80 transition-colors font-medium" 355 + > 356 + legal@wisp.place 357 + </a> 358 + </p> 359 + <p className="mt-2"> 360 + <a 361 + href="/acceptable-use" 362 + className="text-accent hover:text-accent/80 transition-colors font-medium" 363 + > 364 + Acceptable Use Policy 365 + </a> 366 + </p> 367 + </div> 368 + </div> 369 + </footer> 370 + </div> 371 + ) 372 + } 373 + 374 + const root = createRoot(document.getElementById('elysia')!) 375 + root.render( 376 + <Layout className="gap-6"> 377 + <AcceptableUsePage /> 378 + </Layout> 379 + )
+35
public/acceptable-use/index.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>Acceptable Use Policy - wisp.place</title> 7 + <meta name="description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." /> 8 + 9 + <!-- Open Graph / Facebook --> 10 + <meta property="og:type" content="website" /> 11 + <meta property="og:url" content="https://wisp.place/acceptable-use" /> 12 + <meta property="og:title" content="Acceptable Use Policy - wisp.place" /> 13 + <meta property="og:description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." /> 14 + <meta property="og:site_name" content="wisp.place" /> 15 + 16 + <!-- Twitter --> 17 + <meta name="twitter:card" content="summary_large_image" /> 18 + <meta name="twitter:url" content="https://wisp.place/acceptable-use" /> 19 + <meta name="twitter:title" content="Acceptable Use Policy - wisp.place" /> 20 + <meta name="twitter:description" content="Acceptable Use Policy for wisp.place - Guidelines for hosting content on our decentralized static site hosting platform." /> 21 + 22 + <!-- Theme --> 23 + <meta name="theme-color" content="#7c3aed" /> 24 + 25 + <link rel="icon" type="image/x-icon" href="../favicon.ico"> 26 + <link rel="icon" type="image/png" sizes="32x32" href="../favicon-32x32.png"> 27 + <link rel="icon" type="image/png" sizes="16x16" href="../favicon-16x16.png"> 28 + <link rel="apple-touch-icon" sizes="180x180" href="../apple-touch-icon.png"> 29 + <link rel="manifest" href="../site.webmanifest"> 30 + </head> 31 + <body> 32 + <div id="elysia"></div> 33 + <script type="module" src="./acceptable-use.tsx"></script> 34 + </body> 35 + </html>
+107 -48
public/editor/editor.tsx
··· 19 import { Label } from '@public/components/ui/label' 20 import { Badge } from '@public/components/ui/badge' 21 import { 22 - Globe, 23 Loader2, 24 Trash2, 25 LogOut ··· 38 const { userInfo, loading, fetchUserInfo } = useUserInfo() 39 const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData() 40 const { 41 - wispDomain, 42 customDomains, 43 domainsLoading, 44 verificationStatus, ··· 47 verifyDomain, 48 deleteCustomDomain, 49 mapWispDomain, 50 mapCustomDomain, 51 claimWispDomain, 52 checkWispAvailability ··· 75 if (site.domains) { 76 site.domains.forEach(domainInfo => { 77 if (domainInfo.type === 'wisp') { 78 - mappedDomains.add('wisp') 79 } else if (domainInfo.id) { 80 mappedDomains.add(domainInfo.id) 81 } ··· 90 91 setIsSavingConfig(true) 92 try { 93 - // Determine which domains should be mapped/unmapped 94 - const shouldMapWisp = selectedDomains.has('wisp') 95 - const isCurrentlyMappedToWisp = wispDomain && wispDomain.rkey === configuringSite.rkey 96 97 - // Handle wisp domain mapping 98 - if (shouldMapWisp && !isCurrentlyMappedToWisp) { 99 - await mapWispDomain(configuringSite.rkey) 100 - } else if (!shouldMapWisp && isCurrentlyMappedToWisp) { 101 - await mapWispDomain(null) 102 } 103 104 // Handle custom domain mappings 105 - const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => id !== 'wisp') 106 const currentlyMappedCustomDomains = customDomains.filter( 107 d => d.rkey === configuringSite.rkey 108 ) ··· 189 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 190 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 191 <div className="flex items-center gap-2"> 192 - <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 193 - <Globe className="w-5 h-5 text-primary-foreground" /> 194 - </div> 195 <span className="text-xl font-semibold text-foreground"> 196 wisp.place 197 </span> ··· 243 {/* Domains Tab */} 244 <TabsContent value="domains"> 245 <DomainsTab 246 - wispDomain={wispDomain} 247 customDomains={customDomains} 248 domainsLoading={domainsLoading} 249 verificationStatus={verificationStatus} ··· 251 onAddCustomDomain={addCustomDomain} 252 onVerifyDomain={verifyDomain} 253 onDeleteCustomDomain={deleteCustomDomain} 254 onClaimWispDomain={claimWispDomain} 255 onCheckWispAvailability={checkWispAvailability} 256 /> ··· 272 </Tabs> 273 </div> 274 275 {/* Site Configuration Modal */} 276 <Dialog 277 open={configuringSite !== null} ··· 297 <div className="space-y-3"> 298 <p className="text-sm font-medium">Available Domains:</p> 299 300 - {wispDomain && ( 301 - <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"> 302 - <Checkbox 303 - id="wisp" 304 - checked={selectedDomains.has('wisp')} 305 - onCheckedChange={(checked) => { 306 - const newSelected = new Set(selectedDomains) 307 - if (checked) { 308 - newSelected.add('wisp') 309 - } else { 310 - newSelected.delete('wisp') 311 - } 312 - setSelectedDomains(newSelected) 313 - }} 314 - /> 315 - <Label 316 - htmlFor="wisp" 317 - className="flex-1 cursor-pointer" 318 - > 319 - <div className="flex items-center justify-between"> 320 - <span className="font-mono text-sm"> 321 - {wispDomain.domain} 322 - </span> 323 - <Badge variant="secondary" className="text-xs ml-2"> 324 - Wisp 325 - </Badge> 326 - </div> 327 - </Label> 328 - </div> 329 - )} 330 331 {customDomains 332 .filter((d) => d.verified) ··· 367 </div> 368 ))} 369 370 - {customDomains.filter(d => d.verified).length === 0 && !wispDomain && ( 371 <p className="text-sm text-muted-foreground py-4 text-center"> 372 - No domains available. Add a custom domain or claim your wisp.place subdomain. 373 </p> 374 )} 375 </div>
··· 19 import { Label } from '@public/components/ui/label' 20 import { Badge } from '@public/components/ui/badge' 21 import { 22 Loader2, 23 Trash2, 24 LogOut ··· 37 const { userInfo, loading, fetchUserInfo } = useUserInfo() 38 const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData() 39 const { 40 + wispDomains, 41 customDomains, 42 domainsLoading, 43 verificationStatus, ··· 46 verifyDomain, 47 deleteCustomDomain, 48 mapWispDomain, 49 + deleteWispDomain, 50 mapCustomDomain, 51 claimWispDomain, 52 checkWispAvailability ··· 75 if (site.domains) { 76 site.domains.forEach(domainInfo => { 77 if (domainInfo.type === 'wisp') { 78 + // For wisp domains, use the domain itself as the identifier 79 + mappedDomains.add(`wisp:${domainInfo.domain}`) 80 } else if (domainInfo.id) { 81 mappedDomains.add(domainInfo.id) 82 } ··· 91 92 setIsSavingConfig(true) 93 try { 94 + // Handle wisp domain mappings 95 + const selectedWispDomainIds = Array.from(selectedDomains).filter(id => id.startsWith('wisp:')) 96 + const selectedWispDomains = selectedWispDomainIds.map(id => id.replace('wisp:', '')) 97 + 98 + // Get currently mapped wisp domains 99 + const currentlyMappedWispDomains = wispDomains.filter( 100 + d => d.rkey === configuringSite.rkey 101 + ) 102 + 103 + // Unmap wisp domains that are no longer selected 104 + for (const domain of currentlyMappedWispDomains) { 105 + if (!selectedWispDomains.includes(domain.domain)) { 106 + await mapWispDomain(domain.domain, null) 107 + } 108 + } 109 110 + // Map newly selected wisp domains 111 + for (const domainName of selectedWispDomains) { 112 + const isAlreadyMapped = currentlyMappedWispDomains.some(d => d.domain === domainName) 113 + if (!isAlreadyMapped) { 114 + await mapWispDomain(domainName, configuringSite.rkey) 115 + } 116 } 117 118 // Handle custom domain mappings 119 + const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => !id.startsWith('wisp:')) 120 const currentlyMappedCustomDomains = customDomains.filter( 121 d => d.rkey === configuringSite.rkey 122 ) ··· 203 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 204 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 205 <div className="flex items-center gap-2"> 206 + <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 207 <span className="text-xl font-semibold text-foreground"> 208 wisp.place 209 </span> ··· 255 {/* Domains Tab */} 256 <TabsContent value="domains"> 257 <DomainsTab 258 + wispDomains={wispDomains} 259 customDomains={customDomains} 260 domainsLoading={domainsLoading} 261 verificationStatus={verificationStatus} ··· 263 onAddCustomDomain={addCustomDomain} 264 onVerifyDomain={verifyDomain} 265 onDeleteCustomDomain={deleteCustomDomain} 266 + onDeleteWispDomain={deleteWispDomain} 267 onClaimWispDomain={claimWispDomain} 268 onCheckWispAvailability={checkWispAvailability} 269 /> ··· 285 </Tabs> 286 </div> 287 288 + {/* Footer */} 289 + <footer className="border-t border-border/40 bg-muted/20 mt-12"> 290 + <div className="container mx-auto px-4 py-8"> 291 + <div className="text-center text-sm text-muted-foreground"> 292 + <p> 293 + Built by{' '} 294 + <a 295 + href="https://bsky.app/profile/nekomimi.pet" 296 + target="_blank" 297 + rel="noopener noreferrer" 298 + className="text-accent hover:text-accent/80 transition-colors font-medium" 299 + > 300 + @nekomimi.pet 301 + </a> 302 + {' โ€ข '} 303 + Contact:{' '} 304 + <a 305 + href="mailto:contact@wisp.place" 306 + className="text-accent hover:text-accent/80 transition-colors font-medium" 307 + > 308 + contact@wisp.place 309 + </a> 310 + {' โ€ข '} 311 + Legal/DMCA:{' '} 312 + <a 313 + href="mailto:legal@wisp.place" 314 + className="text-accent hover:text-accent/80 transition-colors font-medium" 315 + > 316 + legal@wisp.place 317 + </a> 318 + </p> 319 + <p className="mt-2"> 320 + <a 321 + href="/acceptable-use" 322 + className="text-accent hover:text-accent/80 transition-colors font-medium" 323 + > 324 + Acceptable Use Policy 325 + </a> 326 + </p> 327 + </div> 328 + </div> 329 + </footer> 330 + 331 {/* Site Configuration Modal */} 332 <Dialog 333 open={configuringSite !== null} ··· 353 <div className="space-y-3"> 354 <p className="text-sm font-medium">Available Domains:</p> 355 356 + {wispDomains.map((wispDomain) => { 357 + const domainId = `wisp:${wispDomain.domain}` 358 + return ( 359 + <div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"> 360 + <Checkbox 361 + id={domainId} 362 + checked={selectedDomains.has(domainId)} 363 + onCheckedChange={(checked) => { 364 + const newSelected = new Set(selectedDomains) 365 + if (checked) { 366 + newSelected.add(domainId) 367 + } else { 368 + newSelected.delete(domainId) 369 + } 370 + setSelectedDomains(newSelected) 371 + }} 372 + /> 373 + <Label 374 + htmlFor={domainId} 375 + className="flex-1 cursor-pointer" 376 + > 377 + <div className="flex items-center justify-between"> 378 + <span className="font-mono text-sm"> 379 + {wispDomain.domain} 380 + </span> 381 + <Badge variant="secondary" className="text-xs ml-2"> 382 + Wisp 383 + </Badge> 384 + </div> 385 + </Label> 386 + </div> 387 + ) 388 + })} 389 390 {customDomains 391 .filter((d) => d.verified) ··· 426 </div> 427 ))} 428 429 + {customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && ( 430 <p className="text-sm text-muted-foreground py-4 text-center"> 431 + No domains available. Add a custom domain or claim a wisp.place subdomain. 432 </p> 433 )} 434 </div>
+35 -8
public/editor/hooks/useDomainData.ts
··· 18 type VerificationStatus = 'idle' | 'verifying' | 'success' | 'error' 19 20 export function useDomainData() { 21 - const [wispDomain, setWispDomain] = useState<WispDomain | null>(null) 22 const [customDomains, setCustomDomains] = useState<CustomDomain[]>([]) 23 const [domainsLoading, setDomainsLoading] = useState(true) 24 const [verificationStatus, setVerificationStatus] = useState<{ ··· 29 try { 30 const response = await fetch('/api/user/domains') 31 const data = await response.json() 32 - setWispDomain(data.wispDomain) 33 setCustomDomains(data.customDomains || []) 34 } catch (err) { 35 console.error('Failed to fetch domains:', err) ··· 117 } 118 } 119 120 - const mapWispDomain = async (siteRkey: string | null) => { 121 try { 122 const response = await fetch('/api/domain/wisp/map-site', { 123 method: 'POST', 124 headers: { 'Content-Type': 'application/json' }, 125 - body: JSON.stringify({ siteRkey }) 126 }) 127 const data = await response.json() 128 if (!data.success) throw new Error('Failed to map wisp domain') ··· 133 } 134 } 135 136 const mapCustomDomain = async (domainId: string, siteRkey: string | null) => { 137 try { 138 const response = await fetch(`/api/domain/custom/${domainId}/map-site`, { ··· 168 console.error('Claim domain error:', err) 169 const errorMessage = err instanceof Error ? err.message : 'Unknown error' 170 171 - // Handle "Already claimed" error more gracefully 172 - if (errorMessage.includes('Already claimed')) { 173 - alert('You have already claimed a wisp.place subdomain. Please refresh the page.') 174 await fetchDomains() 175 } else { 176 alert(`Failed to claim domain: ${errorMessage}`) ··· 196 } 197 198 return { 199 - wispDomain, 200 customDomains, 201 domainsLoading, 202 verificationStatus, ··· 205 verifyDomain, 206 deleteCustomDomain, 207 mapWispDomain, 208 mapCustomDomain, 209 claimWispDomain, 210 checkWispAvailability
··· 18 type VerificationStatus = 'idle' | 'verifying' | 'success' | 'error' 19 20 export function useDomainData() { 21 + const [wispDomains, setWispDomains] = useState<WispDomain[]>([]) 22 const [customDomains, setCustomDomains] = useState<CustomDomain[]>([]) 23 const [domainsLoading, setDomainsLoading] = useState(true) 24 const [verificationStatus, setVerificationStatus] = useState<{ ··· 29 try { 30 const response = await fetch('/api/user/domains') 31 const data = await response.json() 32 + setWispDomains(data.wispDomains || []) 33 setCustomDomains(data.customDomains || []) 34 } catch (err) { 35 console.error('Failed to fetch domains:', err) ··· 117 } 118 } 119 120 + const mapWispDomain = async (domain: string, siteRkey: string | null) => { 121 try { 122 const response = await fetch('/api/domain/wisp/map-site', { 123 method: 'POST', 124 headers: { 'Content-Type': 'application/json' }, 125 + body: JSON.stringify({ domain, siteRkey }) 126 }) 127 const data = await response.json() 128 if (!data.success) throw new Error('Failed to map wisp domain') ··· 133 } 134 } 135 136 + const deleteWispDomain = async (domain: string) => { 137 + if (!confirm('Are you sure you want to remove this wisp.place domain?')) { 138 + return false 139 + } 140 + 141 + try { 142 + const response = await fetch(`/api/domain/wisp/${encodeURIComponent(domain)}`, { 143 + method: 'DELETE' 144 + }) 145 + 146 + const data = await response.json() 147 + if (data.success) { 148 + await fetchDomains() 149 + return true 150 + } else { 151 + throw new Error('Failed to delete domain') 152 + } 153 + } catch (err) { 154 + console.error('Delete wisp domain error:', err) 155 + alert( 156 + `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` 157 + ) 158 + return false 159 + } 160 + } 161 + 162 const mapCustomDomain = async (domainId: string, siteRkey: string | null) => { 163 try { 164 const response = await fetch(`/api/domain/custom/${domainId}/map-site`, { ··· 194 console.error('Claim domain error:', err) 195 const errorMessage = err instanceof Error ? err.message : 'Unknown error' 196 197 + // Handle domain limit error more gracefully 198 + if (errorMessage.includes('Domain limit reached')) { 199 + alert('You have already claimed 3 wisp.place subdomains (maximum limit).') 200 await fetchDomains() 201 } else { 202 alert(`Failed to claim domain: ${errorMessage}`) ··· 222 } 223 224 return { 225 + wispDomains, 226 customDomains, 227 domainsLoading, 228 verificationStatus, ··· 231 verifyDomain, 232 deleteCustomDomain, 233 mapWispDomain, 234 + deleteWispDomain, 235 mapCustomDomain, 236 claimWispDomain, 237 checkWispAvailability
+78 -14
public/editor/tabs/CLITab.tsx
··· 16 <CardHeader> 17 <div className="flex items-center gap-2 mb-2"> 18 <CardTitle>Wisp CLI Tool</CardTitle> 19 - <Badge variant="secondary" className="text-xs">v0.1.0</Badge> 20 <Badge variant="outline" className="text-xs">Alpha</Badge> 21 </div> 22 <CardDescription> ··· 32 </div> 33 34 <div className="space-y-3"> 35 - <h3 className="text-sm font-semibold">Download CLI</h3> 36 <div className="grid gap-2"> 37 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 38 <a 39 - href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64" 40 target="_blank" 41 rel="noopener noreferrer" 42 className="flex items-center justify-between mb-2" ··· 45 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 46 </a> 47 <div className="text-xs text-muted-foreground"> 48 - <span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span> 49 </div> 50 </div> 51 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> ··· 59 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 60 </a> 61 <div className="text-xs text-muted-foreground"> 62 - <span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span> 63 </div> 64 </div> 65 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> ··· 73 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 74 </a> 75 <div className="text-xs text-muted-foreground"> 76 - <span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span> 77 </div> 78 </div> 79 </div> 80 </div> 81 82 <div className="space-y-3"> 83 - <h3 className="text-sm font-semibold">Basic Usage</h3> 84 <CodeBlock 85 code={`# Download and make executable 86 - curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64 87 - chmod +x wisp-cli-macos-arm64 88 89 - # Deploy your site (will use OAuth) 90 - ./wisp-cli-macos-arm64 your-handle.bsky.social \\ 91 --path ./dist \\ 92 - --site my-site 93 94 # Your site will be available at: 95 # https://sites.wisp.place/your-handle/my-site`} ··· 98 </div> 99 100 <div className="space-y-3"> 101 <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 102 <p className="text-xs text-muted-foreground"> 103 Deploy automatically on every push using{' '} ··· 147 chmod +x wisp-cli 148 149 # Deploy to Wisp 150 - ./wisp-cli \\ 151 "$WISP_HANDLE" \\ 152 --path "$SITE_PATH" \\ 153 --site "$SITE_NAME" \\ ··· 210 chmod +x wisp-cli 211 212 # Deploy to Wisp 213 - ./wisp-cli \\ 214 "$WISP_HANDLE" \\ 215 --path "$SITE_PATH" \\ 216 --site "$SITE_NAME" \\
··· 16 <CardHeader> 17 <div className="flex items-center gap-2 mb-2"> 18 <CardTitle>Wisp CLI Tool</CardTitle> 19 + <Badge variant="secondary" className="text-xs">v0.2.0</Badge> 20 <Badge variant="outline" className="text-xs">Alpha</Badge> 21 </div> 22 <CardDescription> ··· 32 </div> 33 34 <div className="space-y-3"> 35 + <h3 className="text-sm font-semibold">Features</h3> 36 + <ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside"> 37 + <li><strong>Deploy:</strong> Push static sites directly from your terminal</li> 38 + <li><strong>Pull:</strong> Download sites from the PDS for development or backup</li> 39 + <li><strong>Serve:</strong> Run a local server with real-time firehose updates</li> 40 + </ul> 41 + </div> 42 + 43 + <div className="space-y-3"> 44 + <h3 className="text-sm font-semibold">Download v0.2.0</h3> 45 <div className="grid gap-2"> 46 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 47 <a 48 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin" 49 target="_blank" 50 rel="noopener noreferrer" 51 className="flex items-center justify-between mb-2" ··· 54 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 55 </a> 56 <div className="text-xs text-muted-foreground"> 57 + <span className="font-mono">SHA-1: a8c27ea41c5e2672bfecb3476ece1c801741d759</span> 58 </div> 59 </div> 60 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> ··· 68 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 69 </a> 70 <div className="text-xs text-muted-foreground"> 71 + <span className="font-mono">SHA-1: fd7ee689c7600fc953179ea755b0357c8481a622</span> 72 </div> 73 </div> 74 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> ··· 82 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 83 </a> 84 <div className="text-xs text-muted-foreground"> 85 + <span className="font-mono">SHA-1: 8bca6992559e19e1d29ab3d2fcc6d09b28e5a485</span> 86 + </div> 87 + </div> 88 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 89 + <a 90 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-windows.exe" 91 + target="_blank" 92 + rel="noopener noreferrer" 93 + className="flex items-center justify-between mb-2" 94 + > 95 + <span className="font-mono text-sm">Windows (x86_64)</span> 96 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 97 + </a> 98 + <div className="text-xs text-muted-foreground"> 99 + <span className="font-mono">SHA-1: 90ea3987a06597fa6c42e1df9009e9758e92dd54</span> 100 </div> 101 </div> 102 </div> 103 </div> 104 105 <div className="space-y-3"> 106 + <h3 className="text-sm font-semibold">Deploy a Site</h3> 107 <CodeBlock 108 code={`# Download and make executable 109 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-darwin 110 + chmod +x wisp-cli-aarch64-darwin 111 112 + # Deploy your site 113 + ./wisp-cli-aarch64-darwin deploy your-handle.bsky.social \\ 114 --path ./dist \\ 115 + --site my-site \\ 116 + --password your-app-password 117 118 # Your site will be available at: 119 # https://sites.wisp.place/your-handle/my-site`} ··· 122 </div> 123 124 <div className="space-y-3"> 125 + <h3 className="text-sm font-semibold">Pull a Site from PDS</h3> 126 + <p className="text-xs text-muted-foreground"> 127 + Download a site from the PDS to your local machine (uses OAuth authentication): 128 + </p> 129 + <CodeBlock 130 + code={`# Pull a site to a specific directory 131 + wisp-cli pull your-handle.bsky.social \\ 132 + --site my-site \\ 133 + --output ./my-site 134 + 135 + # Pull to current directory 136 + wisp-cli pull your-handle.bsky.social \\ 137 + --site my-site 138 + 139 + # Opens browser for OAuth authentication on first run`} 140 + language="bash" 141 + /> 142 + </div> 143 + 144 + <div className="space-y-3"> 145 + <h3 className="text-sm font-semibold">Serve a Site Locally with Real-Time Updates</h3> 146 + <p className="text-xs text-muted-foreground"> 147 + Run a local server that monitors the firehose for real-time updates (uses OAuth authentication): 148 + </p> 149 + <CodeBlock 150 + code={`# Serve on http://localhost:8080 (default) 151 + wisp-cli serve your-handle.bsky.social \\ 152 + --site my-site 153 + 154 + # Serve on a custom port 155 + wisp-cli serve your-handle.bsky.social \\ 156 + --site my-site \\ 157 + --port 3000 158 + 159 + # Downloads site, serves it, and watches firehose for live updates!`} 160 + language="bash" 161 + /> 162 + </div> 163 + 164 + <div className="space-y-3"> 165 <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 166 <p className="text-xs text-muted-foreground"> 167 Deploy automatically on every push using{' '} ··· 211 chmod +x wisp-cli 212 213 # Deploy to Wisp 214 + ./wisp-cli deploy \\ 215 "$WISP_HANDLE" \\ 216 --path "$SITE_PATH" \\ 217 --site "$SITE_NAME" \\ ··· 274 chmod +x wisp-cli 275 276 # Deploy to Wisp 277 + ./wisp-cli deploy \\ 278 "$WISP_HANDLE" \\ 279 --path "$SITE_PATH" \\ 280 --site "$SITE_NAME" \\
+110 -85
public/editor/tabs/DomainsTab.tsx
··· 28 import type { UserInfo } from '../hooks/useUserInfo' 29 30 interface DomainsTabProps { 31 - wispDomain: WispDomain | null 32 customDomains: CustomDomain[] 33 domainsLoading: boolean 34 verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' } ··· 36 onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }> 37 onVerifyDomain: (id: string) => Promise<void> 38 onDeleteCustomDomain: (id: string) => Promise<boolean> 39 onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }> 40 onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }> 41 } 42 43 export function DomainsTab({ 44 - wispDomain, 45 customDomains, 46 domainsLoading, 47 verificationStatus, ··· 49 onAddCustomDomain, 50 onVerifyDomain, 51 onDeleteCustomDomain, 52 onClaimWispDomain, 53 onCheckWispAvailability 54 }: DomainsTabProps) { ··· 119 <div className="space-y-4 min-h-[400px]"> 120 <Card> 121 <CardHeader> 122 - <CardTitle>wisp.place Subdomain</CardTitle> 123 <CardDescription> 124 - Your free subdomain on the wisp.place network 125 </CardDescription> 126 </CardHeader> 127 <CardContent> ··· 129 <div className="flex items-center justify-center py-4"> 130 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 131 </div> 132 - ) : wispDomain ? ( 133 - <> 134 - <div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg"> 135 - <div className="flex items-center gap-2"> 136 - <CheckCircle2 className="w-5 h-5 text-green-500" /> 137 - <span className="font-mono text-lg"> 138 - {wispDomain.domain} 139 - </span> 140 - </div> 141 - {wispDomain.rkey && ( 142 - <p className="text-xs text-muted-foreground ml-7"> 143 - โ†’ Mapped to site: {wispDomain.rkey} 144 - </p> 145 - )} 146 - </div> 147 - <p className="text-sm text-muted-foreground mt-3"> 148 - {wispDomain.rkey 149 - ? 'This domain is mapped to a specific site' 150 - : 'This domain is not mapped to any site yet. Configure it from the Sites tab.'} 151 - </p> 152 - </> 153 ) : ( 154 <div className="space-y-4"> 155 - <div className="p-4 bg-muted/30 rounded-lg"> 156 - <p className="text-sm text-muted-foreground mb-4"> 157 - Claim your free wisp.place subdomain 158 - </p> 159 - <div className="space-y-3"> 160 - <div className="space-y-2"> 161 - <Label htmlFor="wisp-handle">Choose your handle</Label> 162 - <div className="flex gap-2"> 163 - <div className="flex-1 relative"> 164 - <Input 165 - id="wisp-handle" 166 - placeholder="mysite" 167 - value={wispHandle} 168 - onChange={(e) => { 169 - setWispHandle(e.target.value) 170 - if (e.target.value.trim()) { 171 - checkWispAvailability(e.target.value) 172 - } else { 173 - setWispAvailability({ available: null, checking: false }) 174 - } 175 - }} 176 - disabled={isClaimingWisp} 177 - className="pr-24" 178 - /> 179 - <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 180 - .wisp.place 181 - </span> 182 </div> 183 </div> 184 - {wispAvailability.checking && ( 185 - <p className="text-xs text-muted-foreground flex items-center gap-1"> 186 - <Loader2 className="w-3 h-3 animate-spin" /> 187 - Checking availability... 188 - </p> 189 - )} 190 - {!wispAvailability.checking && wispAvailability.available === true && ( 191 - <p className="text-xs text-green-600 flex items-center gap-1"> 192 - <CheckCircle2 className="w-3 h-3" /> 193 - Available 194 - </p> 195 - )} 196 - {!wispAvailability.checking && wispAvailability.available === false && ( 197 - <p className="text-xs text-red-600 flex items-center gap-1"> 198 - <XCircle className="w-3 h-3" /> 199 - Not available 200 - </p> 201 - )} 202 </div> 203 - <Button 204 - onClick={handleClaimWispDomain} 205 - disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 206 - className="w-full" 207 - > 208 - {isClaimingWisp ? ( 209 - <> 210 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 211 - Claiming... 212 - </> 213 - ) : ( 214 - 'Claim Subdomain' 215 - )} 216 - </Button> 217 </div> 218 - </div> 219 </div> 220 )} 221 </CardContent>
··· 28 import type { UserInfo } from '../hooks/useUserInfo' 29 30 interface DomainsTabProps { 31 + wispDomains: WispDomain[] 32 customDomains: CustomDomain[] 33 domainsLoading: boolean 34 verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' } ··· 36 onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }> 37 onVerifyDomain: (id: string) => Promise<void> 38 onDeleteCustomDomain: (id: string) => Promise<boolean> 39 + onDeleteWispDomain: (domain: string) => Promise<boolean> 40 onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }> 41 onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }> 42 } 43 44 export function DomainsTab({ 45 + wispDomains, 46 customDomains, 47 domainsLoading, 48 verificationStatus, ··· 50 onAddCustomDomain, 51 onVerifyDomain, 52 onDeleteCustomDomain, 53 + onDeleteWispDomain, 54 onClaimWispDomain, 55 onCheckWispAvailability 56 }: DomainsTabProps) { ··· 121 <div className="space-y-4 min-h-[400px]"> 122 <Card> 123 <CardHeader> 124 + <CardTitle>wisp.place Subdomains</CardTitle> 125 <CardDescription> 126 + Your free subdomains on the wisp.place network (up to 3) 127 </CardDescription> 128 </CardHeader> 129 <CardContent> ··· 131 <div className="flex items-center justify-center py-4"> 132 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 133 </div> 134 ) : ( 135 <div className="space-y-4"> 136 + {wispDomains.length > 0 && ( 137 + <div className="space-y-2"> 138 + {wispDomains.map((domain) => ( 139 + <div 140 + key={domain.domain} 141 + className="flex items-center justify-between p-3 border border-border rounded-lg" 142 + > 143 + <div className="flex flex-col gap-1 flex-1"> 144 + <div className="flex items-center gap-2"> 145 + <CheckCircle2 className="w-4 h-4 text-green-500" /> 146 + <span className="font-mono"> 147 + {domain.domain} 148 + </span> 149 + </div> 150 + {domain.rkey && ( 151 + <p className="text-xs text-muted-foreground ml-6"> 152 + โ†’ Mapped to site: {domain.rkey} 153 + </p> 154 + )} 155 + </div> 156 + <Button 157 + variant="ghost" 158 + size="sm" 159 + onClick={() => onDeleteWispDomain(domain.domain)} 160 + > 161 + <Trash2 className="w-4 h-4" /> 162 + </Button> 163 + </div> 164 + ))} 165 + </div> 166 + )} 167 + 168 + {wispDomains.length < 3 && ( 169 + <div className="p-4 bg-muted/30 rounded-lg"> 170 + <p className="text-sm text-muted-foreground mb-4"> 171 + {wispDomains.length === 0 172 + ? 'Claim your free wisp.place subdomain' 173 + : `Claim another wisp.place subdomain (${wispDomains.length}/3)`} 174 + </p> 175 + <div className="space-y-3"> 176 + <div className="space-y-2"> 177 + <Label htmlFor="wisp-handle">Choose your handle</Label> 178 + <div className="flex gap-2"> 179 + <div className="flex-1 relative"> 180 + <Input 181 + id="wisp-handle" 182 + placeholder="mysite" 183 + value={wispHandle} 184 + onChange={(e) => { 185 + setWispHandle(e.target.value) 186 + if (e.target.value.trim()) { 187 + checkWispAvailability(e.target.value) 188 + } else { 189 + setWispAvailability({ available: null, checking: false }) 190 + } 191 + }} 192 + disabled={isClaimingWisp} 193 + className="pr-24" 194 + /> 195 + <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 196 + .wisp.place 197 + </span> 198 + </div> 199 </div> 200 + {wispAvailability.checking && ( 201 + <p className="text-xs text-muted-foreground flex items-center gap-1"> 202 + <Loader2 className="w-3 h-3 animate-spin" /> 203 + Checking availability... 204 + </p> 205 + )} 206 + {!wispAvailability.checking && wispAvailability.available === true && ( 207 + <p className="text-xs text-green-600 flex items-center gap-1"> 208 + <CheckCircle2 className="w-3 h-3" /> 209 + Available 210 + </p> 211 + )} 212 + {!wispAvailability.checking && wispAvailability.available === false && ( 213 + <p className="text-xs text-red-600 flex items-center gap-1"> 214 + <XCircle className="w-3 h-3" /> 215 + Not available 216 + </p> 217 + )} 218 </div> 219 + <Button 220 + onClick={handleClaimWispDomain} 221 + disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 222 + className="w-full" 223 + > 224 + {isClaimingWisp ? ( 225 + <> 226 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 227 + Claiming... 228 + </> 229 + ) : ( 230 + 'Claim Subdomain' 231 + )} 232 + </Button> 233 </div> 234 </div> 235 + )} 236 + 237 + {wispDomains.length === 3 && ( 238 + <div className="p-3 bg-muted/30 rounded-lg text-center"> 239 + <p className="text-sm text-muted-foreground"> 240 + You have claimed the maximum of 3 wisp.place subdomains 241 + </p> 242 + </div> 243 + )} 244 </div> 245 )} 246 </CardContent>
+395 -15
public/index.tsx
··· 1 - import { useState, useRef, useEffect } from 'react' 2 import { createRoot } from 'react-dom/client' 3 import { 4 ArrowRight, ··· 9 Code, 10 Server 11 } from 'lucide-react' 12 - 13 import Layout from '@public/layouts' 14 import { Button } from '@public/components/ui/button' 15 import { Card } from '@public/components/ui/card' 16 17 function App() { 18 const [showForm, setShowForm] = useState(false) ··· 32 window.location.href = '/editor' 33 return 34 } 35 } catch (error) { 36 console.error('Auth check failed:', error) 37 } finally { 38 setCheckingAuth(false) 39 } ··· 63 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 64 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 65 <div className="flex items-center gap-2"> 66 - <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 67 - <Globe className="w-5 h-5 text-primary-foreground" /> 68 - </div> 69 <span className="text-xl font-semibold text-foreground"> 70 wisp.place 71 </span> ··· 81 <Button 82 size="sm" 83 className="bg-accent text-accent-foreground hover:bg-accent/90" 84 > 85 Get Started 86 </Button> ··· 167 'Login failed:', 168 error 169 ) 170 alert('Authentication failed') 171 } 172 }} 173 className="space-y-3" 174 > 175 - <input 176 - ref={inputRef} 177 - type="text" 178 - name="handle" 179 - placeholder="Enter your handle (e.g., alice.bsky.social)" 180 - className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 181 - /> 182 <button 183 type="submit" 184 className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors" ··· 318 319 {/* CTA Section */} 320 <section className="container mx-auto px-4 py-20"> 321 <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 322 <h2 className="text-3xl md:text-4xl font-bold mb-4"> 323 Ready to deploy? ··· 351 > 352 @nekomimi.pet 353 </a> 354 </p> 355 </div> 356 </div> ··· 362 363 const root = createRoot(document.getElementById('elysia')!) 364 root.render( 365 - <Layout className="gap-6"> 366 - <App /> 367 - </Layout> 368 )
··· 1 + import React, { useState, useRef, useEffect } from 'react' 2 import { createRoot } from 'react-dom/client' 3 import { 4 ArrowRight, ··· 9 Code, 10 Server 11 } from 'lucide-react' 12 import Layout from '@public/layouts' 13 import { Button } from '@public/components/ui/button' 14 import { Card } from '@public/components/ui/card' 15 + import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui' 16 + 17 + //Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead 18 + interface Actor { 19 + handle: string 20 + avatar?: string 21 + displayName?: string 22 + } 23 + 24 + interface ActorTypeaheadProps { 25 + children: React.ReactElement<React.InputHTMLAttributes<HTMLInputElement>> 26 + host?: string 27 + rows?: number 28 + onSelect?: (handle: string) => void 29 + autoSubmit?: boolean 30 + } 31 + 32 + const ActorTypeahead: React.FC<ActorTypeaheadProps> = ({ 33 + children, 34 + host = 'https://public.api.bsky.app', 35 + rows = 5, 36 + onSelect, 37 + autoSubmit = false 38 + }) => { 39 + const [actors, setActors] = useState<Actor[]>([]) 40 + const [index, setIndex] = useState(-1) 41 + const [pressed, setPressed] = useState(false) 42 + const [isOpen, setIsOpen] = useState(false) 43 + const containerRef = useRef<HTMLDivElement>(null) 44 + const inputRef = useRef<HTMLInputElement>(null) 45 + const lastQueryRef = useRef<string>('') 46 + const previousValueRef = useRef<string>('') 47 + const preserveIndexRef = useRef(false) 48 + 49 + const handleInput = async (e: React.FormEvent<HTMLInputElement>) => { 50 + const query = e.currentTarget.value 51 + 52 + // Check if the value actually changed (filter out arrow key events) 53 + if (query === previousValueRef.current) { 54 + return 55 + } 56 + previousValueRef.current = query 57 + 58 + if (!query) { 59 + setActors([]) 60 + setIndex(-1) 61 + setIsOpen(false) 62 + lastQueryRef.current = '' 63 + return 64 + } 65 + 66 + // Store the query for this request 67 + const currentQuery = query 68 + lastQueryRef.current = currentQuery 69 + 70 + try { 71 + const url = new URL('xrpc/app.bsky.actor.searchActorsTypeahead', host) 72 + url.searchParams.set('q', query) 73 + url.searchParams.set('limit', `${rows}`) 74 + 75 + const res = await fetch(url) 76 + const json = await res.json() 77 + 78 + // Only update if this is still the latest query 79 + if (lastQueryRef.current === currentQuery) { 80 + setActors(json.actors || []) 81 + // Only reset index if we're not preserving it 82 + if (!preserveIndexRef.current) { 83 + setIndex(-1) 84 + } 85 + preserveIndexRef.current = false 86 + setIsOpen(true) 87 + } 88 + } catch (error) { 89 + console.error('Failed to fetch actors:', error) 90 + if (lastQueryRef.current === currentQuery) { 91 + setActors([]) 92 + setIsOpen(false) 93 + } 94 + } 95 + } 96 + 97 + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 98 + const navigationKeys = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Enter', 'Escape'] 99 + 100 + // Mark that we should preserve the index for navigation keys 101 + if (navigationKeys.includes(e.key)) { 102 + preserveIndexRef.current = true 103 + } 104 + 105 + if (!isOpen || actors.length === 0) return 106 + 107 + switch (e.key) { 108 + case 'ArrowDown': 109 + e.preventDefault() 110 + setIndex((prev) => { 111 + const newIndex = prev < 0 ? 0 : Math.min(prev + 1, actors.length - 1) 112 + return newIndex 113 + }) 114 + break 115 + case 'PageDown': 116 + e.preventDefault() 117 + setIndex(actors.length - 1) 118 + break 119 + case 'ArrowUp': 120 + e.preventDefault() 121 + setIndex((prev) => { 122 + const newIndex = prev < 0 ? 0 : Math.max(prev - 1, 0) 123 + return newIndex 124 + }) 125 + break 126 + case 'PageUp': 127 + e.preventDefault() 128 + setIndex(0) 129 + break 130 + case 'Escape': 131 + e.preventDefault() 132 + setActors([]) 133 + setIndex(-1) 134 + setIsOpen(false) 135 + break 136 + case 'Enter': 137 + if (index >= 0 && index < actors.length) { 138 + e.preventDefault() 139 + selectActor(actors[index].handle) 140 + } 141 + break 142 + } 143 + } 144 + 145 + const selectActor = (handle: string) => { 146 + if (inputRef.current) { 147 + inputRef.current.value = handle 148 + } 149 + setActors([]) 150 + setIndex(-1) 151 + setIsOpen(false) 152 + onSelect?.(handle) 153 + 154 + // Auto-submit the form if enabled 155 + if (autoSubmit && inputRef.current) { 156 + const form = inputRef.current.closest('form') 157 + if (form) { 158 + // Use setTimeout to ensure the value is set before submission 159 + setTimeout(() => { 160 + form.requestSubmit() 161 + }, 0) 162 + } 163 + } 164 + } 165 + 166 + const handleFocusOut = (e: React.FocusEvent) => { 167 + if (pressed) return 168 + setActors([]) 169 + setIndex(-1) 170 + setIsOpen(false) 171 + } 172 + 173 + // Clone the input element and add our event handlers 174 + const input = React.cloneElement(children, { 175 + ref: (el: HTMLInputElement) => { 176 + inputRef.current = el 177 + // Preserve the original ref if it exists 178 + const originalRef = (children as any).ref 179 + if (typeof originalRef === 'function') { 180 + originalRef(el) 181 + } else if (originalRef) { 182 + originalRef.current = el 183 + } 184 + }, 185 + onInput: (e: React.FormEvent<HTMLInputElement>) => { 186 + handleInput(e) 187 + children.props.onInput?.(e) 188 + }, 189 + onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => { 190 + handleKeyDown(e) 191 + children.props.onKeyDown?.(e) 192 + }, 193 + onBlur: (e: React.FocusEvent<HTMLInputElement>) => { 194 + handleFocusOut(e) 195 + children.props.onBlur?.(e) 196 + }, 197 + autoComplete: 'off' 198 + } as any) 199 + 200 + return ( 201 + <div ref={containerRef} style={{ position: 'relative', display: 'block' }}> 202 + {input} 203 + {isOpen && actors.length > 0 && ( 204 + <ul 205 + style={{ 206 + display: 'flex', 207 + flexDirection: 'column', 208 + position: 'absolute', 209 + left: 0, 210 + marginTop: '4px', 211 + width: '100%', 212 + listStyle: 'none', 213 + overflow: 'hidden', 214 + backgroundColor: 'rgba(255, 255, 255, 0.8)', 215 + backgroundClip: 'padding-box', 216 + backdropFilter: 'blur(12px)', 217 + WebkitBackdropFilter: 'blur(12px)', 218 + border: '1px solid rgba(0, 0, 0, 0.1)', 219 + borderRadius: '8px', 220 + boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)', 221 + padding: '4px', 222 + margin: 0, 223 + zIndex: 1000 224 + }} 225 + onMouseDown={() => setPressed(true)} 226 + onMouseUp={() => { 227 + setPressed(false) 228 + inputRef.current?.focus() 229 + }} 230 + > 231 + {actors.map((actor, i) => ( 232 + <li key={actor.handle}> 233 + <button 234 + type="button" 235 + onClick={() => selectActor(actor.handle)} 236 + style={{ 237 + all: 'unset', 238 + boxSizing: 'border-box', 239 + display: 'flex', 240 + alignItems: 'center', 241 + gap: '8px', 242 + padding: '6px 8px', 243 + width: '100%', 244 + height: 'calc(1.5rem + 12px)', 245 + borderRadius: '4px', 246 + cursor: 'pointer', 247 + backgroundColor: i === index ? 'hsl(var(--accent) / 0.5)' : 'transparent', 248 + transition: 'background-color 0.1s' 249 + }} 250 + onMouseEnter={() => setIndex(i)} 251 + > 252 + <div 253 + style={{ 254 + width: '1.5rem', 255 + height: '1.5rem', 256 + borderRadius: '50%', 257 + backgroundColor: 'hsl(var(--muted))', 258 + overflow: 'hidden', 259 + flexShrink: 0 260 + }} 261 + > 262 + {actor.avatar && ( 263 + <img 264 + src={actor.avatar} 265 + alt="" 266 + style={{ 267 + display: 'block', 268 + width: '100%', 269 + height: '100%', 270 + objectFit: 'cover' 271 + }} 272 + /> 273 + )} 274 + </div> 275 + <span 276 + style={{ 277 + whiteSpace: 'nowrap', 278 + overflow: 'hidden', 279 + textOverflow: 'ellipsis', 280 + color: '#000000' 281 + }} 282 + > 283 + {actor.handle} 284 + </span> 285 + </button> 286 + </li> 287 + ))} 288 + </ul> 289 + )} 290 + </div> 291 + ) 292 + } 293 + 294 + const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => { 295 + const { record, rkey, loading } = useLatestRecord<FeedPostRecord>( 296 + did, 297 + 'app.bsky.feed.post' 298 + ) 299 + 300 + if (loading) return <span>Loadingโ€ฆ</span> 301 + if (!record || !rkey) return <span>No posts yet.</span> 302 + 303 + return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} /> 304 + } 305 306 function App() { 307 const [showForm, setShowForm] = useState(false) ··· 321 window.location.href = '/editor' 322 return 323 } 324 + // If not authenticated, clear any stale cookies 325 + document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 326 } catch (error) { 327 console.error('Auth check failed:', error) 328 + // Clear cookies on error as well 329 + document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 330 } finally { 331 setCheckingAuth(false) 332 } ··· 356 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 357 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 358 <div className="flex items-center gap-2"> 359 + <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 360 <span className="text-xl font-semibold text-foreground"> 361 wisp.place 362 </span> ··· 372 <Button 373 size="sm" 374 className="bg-accent text-accent-foreground hover:bg-accent/90" 375 + onClick={() => setShowForm(true)} 376 > 377 Get Started 378 </Button> ··· 459 'Login failed:', 460 error 461 ) 462 + // Clear any invalid cookies 463 + document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 464 alert('Authentication failed') 465 } 466 }} 467 className="space-y-3" 468 > 469 + <ActorTypeahead 470 + autoSubmit={true} 471 + onSelect={(handle) => { 472 + if (inputRef.current) { 473 + inputRef.current.value = handle 474 + } 475 + }} 476 + > 477 + <input 478 + ref={inputRef} 479 + type="text" 480 + name="handle" 481 + placeholder="Enter your handle (e.g., alice.bsky.social)" 482 + className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 483 + /> 484 + </ActorTypeahead> 485 <button 486 type="submit" 487 className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors" ··· 621 622 {/* CTA Section */} 623 <section className="container mx-auto px-4 py-20"> 624 + <div className="max-w-6xl mx-auto"> 625 + <div className="text-center mb-12"> 626 + <h2 className="text-3xl md:text-4xl font-bold"> 627 + Follow on Bluesky for updates 628 + </h2> 629 + </div> 630 + <div className="grid md:grid-cols-2 gap-8 items-center"> 631 + <Card 632 + className="shadow-lg border-2 border-border overflow-hidden !py-3" 633 + style={{ 634 + '--atproto-color-bg': 'var(--card)', 635 + '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 636 + '--atproto-color-text': 'hsl(var(--foreground))', 637 + '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 638 + '--atproto-color-link': 'hsl(var(--accent))', 639 + '--atproto-color-link-hover': 'hsl(var(--accent))', 640 + '--atproto-color-border': 'transparent', 641 + } as AtProtoStyles} 642 + > 643 + <BlueskyPostList did="wisp.place" /> 644 + </Card> 645 + <div className="space-y-6 w-full max-w-md mx-auto"> 646 + <Card 647 + className="shadow-lg border-2 overflow-hidden relative !py-3" 648 + style={{ 649 + '--atproto-color-bg': 'var(--card)', 650 + '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 651 + '--atproto-color-text': 'hsl(var(--foreground))', 652 + '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 653 + } as AtProtoStyles} 654 + > 655 + <BlueskyProfile did="wisp.place" /> 656 + </Card> 657 + <Card 658 + className="shadow-lg border-2 overflow-hidden relative !py-3" 659 + style={{ 660 + '--atproto-color-bg': 'var(--card)', 661 + '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 662 + '--atproto-color-text': 'hsl(var(--foreground))', 663 + '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 664 + } as AtProtoStyles} 665 + > 666 + <LatestPostWithPrefetch did="wisp.place" /> 667 + </Card> 668 + </div> 669 + </div> 670 + </div> 671 + </section> 672 + 673 + {/* Ready to Deploy CTA */} 674 + <section className="container mx-auto px-4 py-20"> 675 <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 676 <h2 className="text-3xl md:text-4xl font-bold mb-4"> 677 Ready to deploy? ··· 705 > 706 @nekomimi.pet 707 </a> 708 + {' โ€ข '} 709 + Contact:{' '} 710 + <a 711 + href="mailto:contact@wisp.place" 712 + className="text-accent hover:text-accent/80 transition-colors font-medium" 713 + > 714 + contact@wisp.place 715 + </a> 716 + {' โ€ข '} 717 + Legal/DMCA:{' '} 718 + <a 719 + href="mailto:legal@wisp.place" 720 + className="text-accent hover:text-accent/80 transition-colors font-medium" 721 + > 722 + legal@wisp.place 723 + </a> 724 + </p> 725 + <p className="mt-2"> 726 + <a 727 + href="/acceptable-use" 728 + className="text-accent hover:text-accent/80 transition-colors font-medium" 729 + > 730 + Acceptable Use Policy 731 + </a> 732 </p> 733 </div> 734 </div> ··· 740 741 const root = createRoot(document.getElementById('elysia')!) 742 root.render( 743 + <AtProtoProvider> 744 + <Layout className="gap-6"> 745 + <App /> 746 + </Layout> 747 + </AtProtoProvider> 748 )
public/transparent-full-size-ico.png

This is a binary file and will not be displayed.

+2 -2
scripts/change-admin-password.ts
··· 1 // Change admin password 2 - import { adminAuth } from './src/lib/admin-auth' 3 - import { db } from './src/lib/db' 4 import { randomBytes, createHash } from 'crypto' 5 6 // Get username and new password from command line
··· 1 // Change admin password 2 + import { adminAuth } from '../src/lib/admin-auth' 3 + import { db } from '../src/lib/db' 4 import { randomBytes, createHash } from 'crypto' 5 6 // Get username and new password from command line
+20 -7
src/index.ts
··· 12 cleanupExpiredSessions, 13 rotateKeysIfNeeded 14 } from './lib/oauth-client' 15 import { authRoutes } from './routes/auth' 16 import { wispRoutes } from './routes/wisp' 17 import { domainRoutes } from './routes/domain' ··· 30 31 // Initialize admin setup (prompt if no admin exists) 32 await promptAdminSetup() 33 34 const client = await getOAuthClient(config) 35 ··· 63 maxRequestBodySize: 1024 * 1024 * 128 * 3, 64 development: Bun.env.NODE_ENV !== 'production' ? true : false, 65 id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 66 } 67 }) 68 // Observability middleware ··· 96 }) 97 .onError(observabilityMiddleware('main-app').onError) 98 .use(csrfProtection()) 99 - .use(authRoutes(client)) 100 - .use(wispRoutes(client)) 101 - .use(domainRoutes(client)) 102 - .use(userRoutes(client)) 103 - .use(siteRoutes(client)) 104 - .use(adminRoutes()) 105 .use( 106 await staticPlugin({ 107 prefix: '/' ··· 110 .get('/client-metadata.json', () => { 111 return createClientMetadata(config) 112 }) 113 - .get('/jwks.json', async () => { 114 const keys = await getCurrentKeys() 115 if (!keys.length) return { keys: [] } 116
··· 12 cleanupExpiredSessions, 13 rotateKeysIfNeeded 14 } from './lib/oauth-client' 15 + import { getCookieSecret } from './lib/db' 16 import { authRoutes } from './routes/auth' 17 import { wispRoutes } from './routes/wisp' 18 import { domainRoutes } from './routes/domain' ··· 31 32 // Initialize admin setup (prompt if no admin exists) 33 await promptAdminSetup() 34 + 35 + // Get or generate cookie signing secret 36 + const cookieSecret = await getCookieSecret() 37 38 const client = await getOAuthClient(config) 39 ··· 67 maxRequestBodySize: 1024 * 1024 * 128 * 3, 68 development: Bun.env.NODE_ENV !== 'production' ? true : false, 69 id: Bun.env.NODE_ENV !== 'production' ? undefined : null, 70 + }, 71 + cookie: { 72 + secrets: cookieSecret, 73 + sign: ['did'] 74 } 75 }) 76 // Observability middleware ··· 104 }) 105 .onError(observabilityMiddleware('main-app').onError) 106 .use(csrfProtection()) 107 + .use(authRoutes(client, cookieSecret)) 108 + .use(wispRoutes(client, cookieSecret)) 109 + .use(domainRoutes(client, cookieSecret)) 110 + .use(userRoutes(client, cookieSecret)) 111 + .use(siteRoutes(client, cookieSecret)) 112 + .use(adminRoutes(cookieSecret)) 113 .use( 114 await staticPlugin({ 115 prefix: '/' ··· 118 .get('/client-metadata.json', () => { 119 return createClientMetadata(config) 120 }) 121 + .get('/jwks.json', async ({ set }) => { 122 + // Prevent caching to ensure clients always get fresh keys after rotation 123 + set.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' 124 + set.headers['Pragma'] = 'no-cache' 125 + set.headers['Expires'] = '0' 126 + 127 const keys = await getCurrentKeys() 128 if (!keys.length) return { keys: [] } 129
+65 -8
src/lib/db.ts
··· 36 ) 37 `; 38 39 - // Domains table maps subdomain -> DID 40 await db` 41 CREATE TABLE IF NOT EXISTS domains ( 42 domain TEXT PRIMARY KEY, 43 - did TEXT UNIQUE NOT NULL, 44 rkey TEXT, 45 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 46 ) ··· 69 await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`; 70 } catch (err) { 71 // Column might already exist, ignore 72 } 73 74 // Custom domains table for BYOD (bring your own domain) ··· 189 export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`; 190 191 export const getDomainByDid = async (did: string): Promise<string | null> => { 192 - const rows = await db`SELECT domain FROM domains WHERE did = ${did}`; 193 return rows[0]?.domain ?? null; 194 }; 195 196 export const getWispDomainInfo = async (did: string) => { 197 - const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`; 198 return rows[0] ?? null; 199 }; 200 201 export const getDidByDomain = async (domain: string): Promise<string | null> => { 202 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`; 203 return rows[0]?.did ?? null; ··· 251 export const claimDomain = async (did: string, handle: string): Promise<string> => { 252 const h = handle.trim().toLowerCase(); 253 if (!isValidHandle(h)) throw new Error('invalid_handle'); 254 const domain = toDomain(h); 255 try { 256 await db` ··· 258 VALUES (${domain}, ${did}) 259 `; 260 } catch (err) { 261 - // Unique constraint violations -> already taken or DID already claimed 262 throw new Error('conflict'); 263 } 264 return domain; ··· 283 } 284 }; 285 286 - export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => { 287 await db` 288 UPDATE domains 289 SET rkey = ${siteRkey} 290 - WHERE did = ${did} 291 `; 292 }; 293 294 export const getWispDomainSite = async (did: string): Promise<string | null> => { 295 - const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`; 296 return rows[0]?.rkey ?? null; 297 }; 298 299 // Session timeout configuration (30 days in seconds) ··· 688 total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0), 689 }; 690 };
··· 36 ) 37 `; 38 39 + // Cookie secrets table for signed cookies 40 + await db` 41 + CREATE TABLE IF NOT EXISTS cookie_secrets ( 42 + id TEXT PRIMARY KEY DEFAULT 'default', 43 + secret TEXT NOT NULL, 44 + created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 45 + ) 46 + `; 47 + 48 + // Domains table maps subdomain -> DID (now supports up to 3 domains per user) 49 await db` 50 CREATE TABLE IF NOT EXISTS domains ( 51 domain TEXT PRIMARY KEY, 52 + did TEXT NOT NULL, 53 rkey TEXT, 54 created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 55 ) ··· 78 await db`ALTER TABLE oauth_states ADD COLUMN IF NOT EXISTS expires_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) + 3600`; 79 } catch (err) { 80 // Column might already exist, ignore 81 + } 82 + 83 + // Remove the unique constraint on domains.did to allow multiple domains per user 84 + try { 85 + await db`ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_did_key`; 86 + } catch (err) { 87 + // Constraint might already be removed, ignore 88 } 89 90 // Custom domains table for BYOD (bring your own domain) ··· 205 export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`; 206 207 export const getDomainByDid = async (did: string): Promise<string | null> => { 208 + const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 209 return rows[0]?.domain ?? null; 210 }; 211 212 export const getWispDomainInfo = async (did: string) => { 213 + const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 214 return rows[0] ?? null; 215 }; 216 217 + export const getAllWispDomains = async (did: string) => { 218 + const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC`; 219 + return rows; 220 + }; 221 + 222 + export const countWispDomains = async (did: string): Promise<number> => { 223 + const rows = await db`SELECT COUNT(*) as count FROM domains WHERE did = ${did}`; 224 + return Number(rows[0]?.count ?? 0); 225 + }; 226 + 227 export const getDidByDomain = async (domain: string): Promise<string | null> => { 228 const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`; 229 return rows[0]?.did ?? null; ··· 277 export const claimDomain = async (did: string, handle: string): Promise<string> => { 278 const h = handle.trim().toLowerCase(); 279 if (!isValidHandle(h)) throw new Error('invalid_handle'); 280 + 281 + // Check if user already has 3 domains 282 + const existingCount = await countWispDomains(did); 283 + if (existingCount >= 3) { 284 + throw new Error('domain_limit_reached'); 285 + } 286 + 287 const domain = toDomain(h); 288 try { 289 await db` ··· 291 VALUES (${domain}, ${did}) 292 `; 293 } catch (err) { 294 + // Unique constraint violations -> already taken 295 throw new Error('conflict'); 296 } 297 return domain; ··· 316 } 317 }; 318 319 + export const updateWispDomainSite = async (domain: string, siteRkey: string | null): Promise<void> => { 320 await db` 321 UPDATE domains 322 SET rkey = ${siteRkey} 323 + WHERE domain = ${domain} 324 `; 325 }; 326 327 export const getWispDomainSite = async (did: string): Promise<string | null> => { 328 + const rows = await db`SELECT rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`; 329 return rows[0]?.rkey ?? null; 330 + }; 331 + 332 + export const deleteWispDomain = async (domain: string): Promise<void> => { 333 + await db`DELETE FROM domains WHERE domain = ${domain}`; 334 }; 335 336 // Session timeout configuration (30 days in seconds) ··· 725 total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0), 726 }; 727 }; 728 + 729 + // Cookie secret management - ensure we have a secret for signing cookies 730 + export const getCookieSecret = async (): Promise<string> => { 731 + // Check if secret already exists 732 + const rows = await db`SELECT secret FROM cookie_secrets WHERE id = 'default' LIMIT 1`; 733 + 734 + if (rows.length > 0) { 735 + return rows[0].secret as string; 736 + } 737 + 738 + // Generate new secret if none exists 739 + const secret = crypto.randomUUID() + crypto.randomUUID(); // 72 character random string 740 + await db` 741 + INSERT INTO cookie_secrets (id, secret, created_at) 742 + VALUES ('default', ${secret}, EXTRACT(EPOCH FROM NOW())) 743 + `; 744 + 745 + console.log('[CookieSecret] Generated new cookie signing secret'); 746 + return secret; 747 + };
+106 -9
src/routes/admin.ts
··· 4 import { logCollector, errorTracker, metricsCollector } from '../lib/observability' 5 import { db } from '../lib/db' 6 7 - export const adminRoutes = () => 8 new Elysia({ prefix: '/api/admin' }) 9 // Login 10 .post( ··· 35 body: t.Object({ 36 username: t.String(), 37 password: t.String() 38 }) 39 } 40 ) ··· 47 } 48 cookie.admin_session.remove() 49 return { success: true } 50 }) 51 52 // Check auth status ··· 65 authenticated: true, 66 username: session.username 67 } 68 }) 69 70 // Get logs (protected) ··· 86 // Get logs from hosting service 87 let hostingLogs: any[] = [] 88 try { 89 - const hostingPort = process.env.HOSTING_PORT || '3001' 90 const params = new URLSearchParams() 91 if (query.level) params.append('level', query.level as string) 92 if (query.service) params.append('service', query.service as string) ··· 94 if (query.eventType) params.append('eventType', query.eventType as string) 95 params.append('limit', String(filter.limit || 100)) 96 97 - const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`) 98 if (response.ok) { 99 const data = await response.json() 100 hostingLogs = data.logs ··· 109 ) 110 111 return { logs: allLogs.slice(0, filter.limit || 100) } 112 }) 113 114 // Get errors (protected) ··· 127 // Get errors from hosting service 128 let hostingErrors: any[] = [] 129 try { 130 - const hostingPort = process.env.HOSTING_PORT || '3001' 131 const params = new URLSearchParams() 132 if (query.service) params.append('service', query.service as string) 133 params.append('limit', String(filter.limit || 100)) 134 135 - const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`) 136 if (response.ok) { 137 const data = await response.json() 138 hostingErrors = data.errors ··· 147 ) 148 149 return { errors: allErrors.slice(0, filter.limit || 100) } 150 }) 151 152 // Get metrics (protected) ··· 173 } 174 175 try { 176 - const hostingPort = process.env.HOSTING_PORT || '3001' 177 - const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`) 178 if (response.ok) { 179 const data = await response.json() 180 hostingServiceStats = data.stats ··· 189 hostingService: hostingServiceStats, 190 timeWindow 191 } 192 }) 193 194 // Get database stats (protected) ··· 204 205 // Get recent sites (including those without domains) 206 const recentSites = await db` 207 - SELECT 208 s.did, 209 s.rkey, 210 s.display_name, ··· 235 message: error instanceof Error ? error.message : String(error) 236 } 237 } 238 }) 239 240 // Get sites listing (protected) ··· 247 248 try { 249 const sites = await db` 250 - SELECT 251 s.did, 252 s.rkey, 253 s.display_name, ··· 282 message: error instanceof Error ? error.message : String(error) 283 } 284 } 285 }) 286 287 // Get system health (protected) ··· 301 }, 302 timestamp: new Date().toISOString() 303 } 304 }) 305
··· 4 import { logCollector, errorTracker, metricsCollector } from '../lib/observability' 5 import { db } from '../lib/db' 6 7 + export const adminRoutes = (cookieSecret: string) => 8 new Elysia({ prefix: '/api/admin' }) 9 // Login 10 .post( ··· 35 body: t.Object({ 36 username: t.String(), 37 password: t.String() 38 + }), 39 + cookie: t.Cookie({ 40 + admin_session: t.Optional(t.String()) 41 + }, { 42 + secrets: cookieSecret, 43 + sign: ['admin_session'] 44 }) 45 } 46 ) ··· 53 } 54 cookie.admin_session.remove() 55 return { success: true } 56 + }, { 57 + cookie: t.Cookie({ 58 + admin_session: t.Optional(t.String()) 59 + }, { 60 + secrets: cookieSecret, 61 + sign: ['admin_session'] 62 + }) 63 }) 64 65 // Check auth status ··· 78 authenticated: true, 79 username: session.username 80 } 81 + }, { 82 + cookie: t.Cookie({ 83 + admin_session: t.Optional(t.String()) 84 + }, { 85 + secrets: cookieSecret, 86 + sign: ['admin_session'] 87 + }) 88 }) 89 90 // Get logs (protected) ··· 106 // Get logs from hosting service 107 let hostingLogs: any[] = [] 108 try { 109 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 110 const params = new URLSearchParams() 111 if (query.level) params.append('level', query.level as string) 112 if (query.service) params.append('service', query.service as string) ··· 114 if (query.eventType) params.append('eventType', query.eventType as string) 115 params.append('limit', String(filter.limit || 100)) 116 117 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/logs?${params}`) 118 if (response.ok) { 119 const data = await response.json() 120 hostingLogs = data.logs ··· 129 ) 130 131 return { logs: allLogs.slice(0, filter.limit || 100) } 132 + }, { 133 + cookie: t.Cookie({ 134 + admin_session: t.Optional(t.String()) 135 + }, { 136 + secrets: cookieSecret, 137 + sign: ['admin_session'] 138 + }) 139 }) 140 141 // Get errors (protected) ··· 154 // Get errors from hosting service 155 let hostingErrors: any[] = [] 156 try { 157 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 158 const params = new URLSearchParams() 159 if (query.service) params.append('service', query.service as string) 160 params.append('limit', String(filter.limit || 100)) 161 162 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/errors?${params}`) 163 if (response.ok) { 164 const data = await response.json() 165 hostingErrors = data.errors ··· 174 ) 175 176 return { errors: allErrors.slice(0, filter.limit || 100) } 177 + }, { 178 + cookie: t.Cookie({ 179 + admin_session: t.Optional(t.String()) 180 + }, { 181 + secrets: cookieSecret, 182 + sign: ['admin_session'] 183 + }) 184 }) 185 186 // Get metrics (protected) ··· 207 } 208 209 try { 210 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 211 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/metrics?timeWindow=${timeWindow}`) 212 if (response.ok) { 213 const data = await response.json() 214 hostingServiceStats = data.stats ··· 223 hostingService: hostingServiceStats, 224 timeWindow 225 } 226 + }, { 227 + cookie: t.Cookie({ 228 + admin_session: t.Optional(t.String()) 229 + }, { 230 + secrets: cookieSecret, 231 + sign: ['admin_session'] 232 + }) 233 }) 234 235 // Get database stats (protected) ··· 245 246 // Get recent sites (including those without domains) 247 const recentSites = await db` 248 + SELECT 249 s.did, 250 s.rkey, 251 s.display_name, ··· 276 message: error instanceof Error ? error.message : String(error) 277 } 278 } 279 + }, { 280 + cookie: t.Cookie({ 281 + admin_session: t.Optional(t.String()) 282 + }, { 283 + secrets: cookieSecret, 284 + sign: ['admin_session'] 285 + }) 286 + }) 287 + 288 + // Get cache stats (protected) 289 + .get('/cache', async ({ cookie, set }) => { 290 + const check = requireAdmin({ cookie, set }) 291 + if (check) return check 292 + 293 + try { 294 + const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}` 295 + const response = await fetch(`${hostingServiceUrl}/__internal__/observability/cache`) 296 + 297 + if (response.ok) { 298 + const data = await response.json() 299 + return data 300 + } else { 301 + set.status = 503 302 + return { 303 + error: 'Failed to fetch cache stats from hosting service', 304 + message: 'Hosting service unavailable' 305 + } 306 + } 307 + } catch (error) { 308 + set.status = 500 309 + return { 310 + error: 'Failed to fetch cache stats', 311 + message: error instanceof Error ? error.message : String(error) 312 + } 313 + } 314 + }, { 315 + cookie: t.Cookie({ 316 + admin_session: t.Optional(t.String()) 317 + }, { 318 + secrets: cookieSecret, 319 + sign: ['admin_session'] 320 + }) 321 }) 322 323 // Get sites listing (protected) ··· 330 331 try { 332 const sites = await db` 333 + SELECT 334 s.did, 335 s.rkey, 336 s.display_name, ··· 365 message: error instanceof Error ? error.message : String(error) 366 } 367 } 368 + }, { 369 + cookie: t.Cookie({ 370 + admin_session: t.Optional(t.String()) 371 + }, { 372 + secrets: cookieSecret, 373 + sign: ['admin_session'] 374 + }) 375 }) 376 377 // Get system health (protected) ··· 391 }, 392 timestamp: new Date().toISOString() 393 } 394 + }, { 395 + cookie: t.Cookie({ 396 + admin_session: t.Optional(t.String()) 397 + }, { 398 + secrets: cookieSecret, 399 + sign: ['admin_session'] 400 + }) 401 }) 402
+20 -6
src/routes/auth.ts
··· 1 - import { Elysia } from 'elysia' 2 import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 - import { getSitesByDid, getDomainByDid } from '../lib/db' 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 import { authenticateRequest } from '../lib/wisp-auth' 6 import { logger } from '../lib/observability' 7 8 - export const authRoutes = (client: NodeOAuthClient) => new Elysia() 9 .post('/api/auth/signin', async (c) => { 10 let handle = 'unknown' 11 try { ··· 32 33 if (!session) { 34 logger.error('[Auth] OAuth callback failed: no session returned') 35 return c.redirect('/?error=auth_failed') 36 } 37 38 const cookieSession = c.cookie 39 - cookieSession.did.value = session.did 40 41 // Sync sites from PDS to database cache 42 logger.debug('[Auth] Syncing sites from PDS for', session.did) ··· 64 } catch (err) { 65 // This catches state validation failures and other OAuth errors 66 logger.error('[Auth] OAuth callback error', err) 67 return c.redirect('/?error=auth_failed') 68 } 69 }) ··· 73 const did = cookieSession.did?.value 74 75 // Clear the session cookie 76 - cookieSession.did.value = '' 77 - cookieSession.did.maxAge = 0 78 79 // If we have a DID, try to revoke the OAuth session 80 if (did && typeof did === 'string') { ··· 98 const auth = await authenticateRequest(client, c.cookie) 99 100 if (!auth) { 101 return { authenticated: false } 102 } 103 ··· 107 } 108 } catch (err) { 109 logger.error('[Auth] Status check error', err) 110 return { authenticated: false } 111 } 112 })
··· 1 + import { Elysia, t } from 'elysia' 2 import { NodeOAuthClient } from '@atproto/oauth-client-node' 3 + import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db' 4 import { syncSitesFromPDS } from '../lib/sync-sites' 5 import { authenticateRequest } from '../lib/wisp-auth' 6 import { logger } from '../lib/observability' 7 8 + export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({ 9 + cookie: { 10 + secrets: cookieSecret, 11 + sign: ['did'] 12 + } 13 + }) 14 .post('/api/auth/signin', async (c) => { 15 let handle = 'unknown' 16 try { ··· 37 38 if (!session) { 39 logger.error('[Auth] OAuth callback failed: no session returned') 40 + c.cookie.did.remove() 41 return c.redirect('/?error=auth_failed') 42 } 43 44 const cookieSession = c.cookie 45 + cookieSession.did.set({ 46 + value: session.did, 47 + httpOnly: true, 48 + secure: process.env.NODE_ENV === 'production', 49 + sameSite: 'lax', 50 + maxAge: 30 * 24 * 60 * 60 // 30 days 51 + }) 52 53 // Sync sites from PDS to database cache 54 logger.debug('[Auth] Syncing sites from PDS for', session.did) ··· 76 } catch (err) { 77 // This catches state validation failures and other OAuth errors 78 logger.error('[Auth] OAuth callback error', err) 79 + c.cookie.did.remove() 80 return c.redirect('/?error=auth_failed') 81 } 82 }) ··· 86 const did = cookieSession.did?.value 87 88 // Clear the session cookie 89 + cookieSession.did.remove() 90 91 // If we have a DID, try to revoke the OAuth session 92 if (did && typeof did === 'string') { ··· 110 const auth = await authenticateRequest(client, c.cookie) 111 112 if (!auth) { 113 + c.cookie.did.remove() 114 return { authenticated: false } 115 } 116 ··· 120 } 121 } catch (err) { 122 logger.error('[Auth] Status check error', err) 123 + c.cookie.did.remove() 124 return { authenticated: false } 125 } 126 })
+65 -14
src/routes/domain.ts
··· 10 isValidHandle, 11 toDomain, 12 updateDomain, 13 getCustomDomainInfo, 14 getCustomDomainById, 15 claimCustomDomain, ··· 22 import { verifyCustomDomain } from '../lib/dns-verify' 23 import { logger } from '../lib/logger' 24 25 - export const domainRoutes = (client: NodeOAuthClient) => 26 - new Elysia({ prefix: '/api/domain' }) 27 // Public endpoints (no auth required) 28 .get('/check', async ({ query }) => { 29 try { ··· 84 try { 85 const { handle } = body as { handle?: string }; 86 const normalizedHandle = (handle || "").trim().toLowerCase(); 87 - 88 if (!isValidHandle(normalizedHandle)) { 89 throw new Error("Invalid handle"); 90 } 91 92 - // ensure user hasn't already claimed 93 - const existing = await getDomainByDid(auth.did); 94 - if (existing) { 95 - throw new Error("Already claimed"); 96 - } 97 - 98 // claim in DB 99 let domain: string; 100 try { 101 domain = await claimDomain(auth.did, normalizedHandle); 102 } catch (err) { 103 - throw new Error("Handle taken"); 104 } 105 106 - // write place.wisp.domain record rkey = self 107 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 108 await agent.com.atproto.repo.putRecord({ 109 repo: auth.did, 110 collection: "place.wisp.domain", 111 - rkey: "self", 112 record: { 113 $type: "place.wisp.domain", 114 domain, ··· 309 }) 310 .post('/wisp/map-site', async ({ body, auth }) => { 311 try { 312 - const { siteRkey } = body as { siteRkey: string | null }; 313 314 // Update wisp.place domain to point to this site 315 - await updateWispDomainSite(auth.did, siteRkey); 316 317 return { success: true }; 318 } catch (err) { 319 logger.error('[Domain] Wisp domain map error', err); 320 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 321 } 322 }) 323 .post('/custom/:id/map-site', async ({ params, body, auth }) => {
··· 10 isValidHandle, 11 toDomain, 12 updateDomain, 13 + countWispDomains, 14 + deleteWispDomain, 15 getCustomDomainInfo, 16 getCustomDomainById, 17 claimCustomDomain, ··· 24 import { verifyCustomDomain } from '../lib/dns-verify' 25 import { logger } from '../lib/logger' 26 27 + export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) => 28 + new Elysia({ 29 + prefix: '/api/domain', 30 + cookie: { 31 + secrets: cookieSecret, 32 + sign: ['did'] 33 + } 34 + }) 35 // Public endpoints (no auth required) 36 .get('/check', async ({ query }) => { 37 try { ··· 92 try { 93 const { handle } = body as { handle?: string }; 94 const normalizedHandle = (handle || "").trim().toLowerCase(); 95 + 96 if (!isValidHandle(normalizedHandle)) { 97 throw new Error("Invalid handle"); 98 } 99 100 + // Check if user already has 3 domains (handled in claimDomain) 101 // claim in DB 102 let domain: string; 103 try { 104 domain = await claimDomain(auth.did, normalizedHandle); 105 } catch (err) { 106 + const message = err instanceof Error ? err.message : 'Unknown error'; 107 + if (message === 'domain_limit_reached') { 108 + throw new Error("Domain limit reached: You can only claim up to 3 wisp.place domains"); 109 + } 110 + throw new Error("Handle taken or error claiming domain"); 111 } 112 113 + // write place.wisp.domain record with unique rkey 114 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 115 + const rkey = normalizedHandle; // Use handle as rkey for uniqueness 116 await agent.com.atproto.repo.putRecord({ 117 repo: auth.did, 118 collection: "place.wisp.domain", 119 + rkey, 120 record: { 121 $type: "place.wisp.domain", 122 domain, ··· 317 }) 318 .post('/wisp/map-site', async ({ body, auth }) => { 319 try { 320 + const { domain, siteRkey } = body as { domain: string; siteRkey: string | null }; 321 + 322 + if (!domain) { 323 + throw new Error('Domain parameter required'); 324 + } 325 326 // Update wisp.place domain to point to this site 327 + await updateWispDomainSite(domain, siteRkey); 328 329 return { success: true }; 330 } catch (err) { 331 logger.error('[Domain] Wisp domain map error', err); 332 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 333 + } 334 + }) 335 + .delete('/wisp/:domain', async ({ params, auth }) => { 336 + try { 337 + const { domain } = params; 338 + 339 + // Verify domain belongs to user 340 + const domainLower = domain.toLowerCase().trim(); 341 + const info = await isDomainRegistered(domainLower); 342 + 343 + if (!info.registered || info.type !== 'wisp') { 344 + throw new Error('Domain not found'); 345 + } 346 + 347 + if (info.did !== auth.did) { 348 + throw new Error('Unauthorized: You do not own this domain'); 349 + } 350 + 351 + // Delete from database 352 + await deleteWispDomain(domainLower); 353 + 354 + // Delete from PDS 355 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 356 + const handle = domainLower.replace(`.${process.env.BASE_DOMAIN || 'wisp.place'}`, ''); 357 + try { 358 + await agent.com.atproto.repo.deleteRecord({ 359 + repo: auth.did, 360 + collection: "place.wisp.domain", 361 + rkey: handle, 362 + }); 363 + } catch (err) { 364 + // Record might not exist in PDS, continue anyway 365 + logger.warn('[Domain] Could not delete wisp domain from PDS', err); 366 + } 367 + 368 + return { success: true }; 369 + } catch (err) { 370 + logger.error('[Domain] Wisp domain delete error', err); 371 + throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 372 } 373 }) 374 .post('/custom/:id/map-site', async ({ params, body, auth }) => {
+8 -2
src/routes/site.ts
··· 5 import { deleteSite } from '../lib/db' 6 import { logger } from '../lib/logger' 7 8 - export const siteRoutes = (client: NodeOAuthClient) => 9 - new Elysia({ prefix: '/api/site' }) 10 .derive(async ({ cookie }) => { 11 const auth = await requireAuth(client, cookie) 12 return { auth }
··· 5 import { deleteSite } from '../lib/db' 6 import { logger } from '../lib/logger' 7 8 + export const siteRoutes = (client: NodeOAuthClient, cookieSecret: string) => 9 + new Elysia({ 10 + prefix: '/api/site', 11 + cookie: { 12 + secrets: cookieSecret, 13 + sign: ['did'] 14 + } 15 + }) 16 .derive(async ({ cookie }) => { 17 const auth = await requireAuth(client, cookie) 18 return { auth }
+16 -10
src/routes/user.ts
··· 1 - import { Elysia } from 'elysia' 2 import { requireAuth } from '../lib/wisp-auth' 3 import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 import { Agent } from '@atproto/api' 5 - import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite } from '../lib/db' 6 import { syncSitesFromPDS } from '../lib/sync-sites' 7 import { logger } from '../lib/logger' 8 9 - export const userRoutes = (client: NodeOAuthClient) => 10 - new Elysia({ prefix: '/api/user' }) 11 .derive(async ({ cookie }) => { 12 const auth = await requireAuth(client, cookie) 13 return { auth } ··· 65 }) 66 .get('/domains', async ({ auth }) => { 67 try { 68 - // Get wisp.place subdomain with mapping 69 - const wispDomainInfo = await getWispDomainInfo(auth.did) 70 71 // Get custom domains 72 const customDomains = await getCustomDomainsByDid(auth.did) 73 74 return { 75 - wispDomain: wispDomainInfo ? { 76 - domain: wispDomainInfo.domain, 77 - rkey: wispDomainInfo.rkey || null 78 - } : null, 79 customDomains 80 } 81 } catch (err) {
··· 1 + import { Elysia, t } from 'elysia' 2 import { requireAuth } from '../lib/wisp-auth' 3 import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 import { Agent } from '@atproto/api' 5 + import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db' 6 import { syncSitesFromPDS } from '../lib/sync-sites' 7 import { logger } from '../lib/logger' 8 9 + export const userRoutes = (client: NodeOAuthClient, cookieSecret: string) => 10 + new Elysia({ 11 + prefix: '/api/user', 12 + cookie: { 13 + secrets: cookieSecret, 14 + sign: ['did'] 15 + } 16 + }) 17 .derive(async ({ cookie }) => { 18 const auth = await requireAuth(client, cookie) 19 return { auth } ··· 71 }) 72 .get('/domains', async ({ auth }) => { 73 try { 74 + // Get all wisp.place subdomains with mappings (up to 3) 75 + const wispDomains = await getAllWispDomains(auth.did) 76 77 // Get custom domains 78 const customDomains = await getCustomDomainsByDid(auth.did) 79 80 return { 81 + wispDomains: wispDomains.map(d => ({ 82 + domain: d.domain, 83 + rkey: d.rkey || null 84 + })), 85 customDomains 86 } 87 } catch (err) {
+8 -2
src/routes/wisp.ts
··· 37 return true; 38 } 39 40 - export const wispRoutes = (client: NodeOAuthClient) => 41 - new Elysia({ prefix: '/wisp' }) 42 .derive(async ({ cookie }) => { 43 const auth = await requireAuth(client, cookie) 44 return { auth }
··· 37 return true; 38 } 39 40 + export const wispRoutes = (client: NodeOAuthClient, cookieSecret: string) => 41 + new Elysia({ 42 + prefix: '/wisp', 43 + cookie: { 44 + secrets: cookieSecret, 45 + sign: ['did'] 46 + } 47 + }) 48 .derive(async ({ cookie }) => { 49 const auth = await requireAuth(client, cookie) 50 return { auth }