+1
.gitignore
+1
.gitignore
-3
.gitmodules
-3
.gitmodules
-1
.tangled/workflows/deploy-wisp.yml
-1
.tangled/workflows/deploy-wisp.yml
+4
.tangled/workflows/test.yml
+4
.tangled/workflows/test.yml
···
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
+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
+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
cli/.gitignore
+627
-67
cli/Cargo.lock
+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
+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
+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
+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
+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
+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(¤t_data)
46
+
.into_diagnostic()?;
47
+
}
48
+
49
+
// Then, decompress gzip if needed
50
+
if is_gzipped {
51
+
let mut decoder = GzDecoder::new(¤t_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
+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
+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
+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(¤t_path) {
249
+
if let Some(existing_cid) = existing_file_cids.get(¤t_path) {
250
+
if existing_cid == new_cid {
251
+
// File unchanged, copy from existing directory
252
+
let existing_path = existing_output_dir.join(¤t_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
+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(¶ms).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
+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
+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
+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
-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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+2
package.json
+379
public/acceptable-use/acceptable-use.tsx
+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
+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
+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
+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
+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
+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
+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
public/transparent-full-size-ico.png
This is a binary file and will not be displayed.
+2
-2
scripts/change-admin-password.ts
+2
-2
scripts/change-admin-password.ts
+20
-7
src/index.ts
+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
+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
+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
+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
+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
+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, 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
+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
+8
-2
src/routes/wisp.ts
···
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 }