+6
.dockerignore
+6
.dockerignore
+1
.gitignore
+1
.gitignore
+49
.tangled/workflows/deploy-wisp.yml
+49
.tangled/workflows/deploy-wisp.yml
···
1
+
# Deploy to Wisp.place
2
+
# This workflow builds your site and deploys it to Wisp.place using the wisp-cli
3
+
when:
4
+
- event: ['push']
5
+
branch: ['main']
6
+
- event: ['manual']
7
+
engine: 'nixery'
8
+
clone:
9
+
skip: false
10
+
depth: 1
11
+
submodules: true
12
+
dependencies:
13
+
nixpkgs:
14
+
- git
15
+
- gcc
16
+
github:NixOS/nixpkgs/nixpkgs-unstable:
17
+
- rustc
18
+
- cargo
19
+
environment:
20
+
# Customize these for your project
21
+
SITE_PATH: 'testDeploy'
22
+
SITE_NAME: 'wispPlaceDocs'
23
+
steps:
24
+
- name: 'Initialize submodules'
25
+
command: |
26
+
git submodule update --init --recursive
27
+
28
+
- name: 'Build wisp-cli'
29
+
command: |
30
+
cd cli
31
+
export PATH="$HOME/.nix-profile/bin:$PATH"
32
+
nix-channel --add https://nixos.org/channels/nixpkgs-unstable nixpkgs
33
+
nix-channel --update
34
+
nix-shell -p pkg-config openssl --run '
35
+
export PKG_CONFIG_PATH="$(pkg-config --variable pc_path pkg-config)"
36
+
export OPENSSL_DIR="$(nix-build --no-out-link "<nixpkgs>" -A openssl.dev)"
37
+
export OPENSSL_NO_VENDOR=1
38
+
export OPENSSL_LIB_DIR="$(nix-build --no-out-link "<nixpkgs>" -A openssl.out)/lib"
39
+
cargo build --release
40
+
'
41
+
cd ..
42
+
43
+
- name: 'Deploy to Wisp.place'
44
+
command: |
45
+
./cli/target/release/wisp-cli \
46
+
"$WISP_HANDLE" \
47
+
--path "$SITE_PATH" \
48
+
--site "$SITE_NAME" \
49
+
--password "$WISP_APP_PASSWORD"
+26
.tangled/workflows/test.yml
+26
.tangled/workflows/test.yml
···
1
+
when:
2
+
- event: ["push", "pull_request"]
3
+
branch: main
4
+
5
+
engine: nixery
6
+
7
+
dependencies:
8
+
nixpkgs:
9
+
- git
10
+
github:NixOS/nixpkgs/nixpkgs-unstable:
11
+
- bun
12
+
13
+
steps:
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
24
+
command: |
25
+
export PATH="$HOME/.nix-profile/bin:$PATH"
26
+
bun test
+10
-15
Dockerfile
+10
-15
Dockerfile
···
5
5
WORKDIR /app
6
6
7
7
# Copy package files
8
-
COPY package.json bun.lock* ./
8
+
COPY package.json ./
9
+
10
+
# Copy Bun configuration
11
+
COPY bunfig.toml ./
12
+
13
+
COPY tsconfig.json ./
9
14
10
15
# Install dependencies
11
-
RUN bun install --frozen-lockfile
16
+
RUN bun install
12
17
13
18
# Copy source code
14
19
COPY src ./src
15
20
COPY public ./public
16
21
17
-
# Build the application (if needed)
18
-
# RUN bun run build
19
-
20
-
# Set environment variables (can be overridden at runtime)
21
-
ENV PORT=3000
22
+
ENV PORT=8000
22
23
ENV NODE_ENV=production
23
24
24
-
# Expose the application port
25
-
EXPOSE 3000
26
-
27
-
# Health check
28
-
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
29
-
CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
25
+
EXPOSE 8000
30
26
31
-
# Start the application
32
-
CMD ["bun", "src/index.ts"]
27
+
CMD ["bun", "start"]
+96
-7
README.md
+96
-7
README.md
···
1
-
# Elysia with Bun runtime
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.
2
8
3
-
## Getting Started
4
-
To get started with this template, simply paste this command into your terminal:
9
+
## Quick Start
10
+
5
11
```bash
6
-
bun create elysia ./elysia-example
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
7
19
```
8
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
+
9
37
## Development
10
-
To start the development server run:
38
+
11
39
```bash
12
-
bun run dev
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
13
82
```
14
83
15
-
Open http://localhost:3000/ with your browser to see the result.
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)
-41
api.md
-41
api.md
···
1
-
/**
2
-
* AUTHENTICATION ROUTES
3
-
*
4
-
* Handles OAuth authentication flow for Bluesky/ATProto accounts
5
-
* All routes are on the editor.wisp.place subdomain
6
-
*
7
-
* Routes:
8
-
* POST /api/auth/signin - Initiate OAuth sign-in flow
9
-
* GET /api/auth/callback - OAuth callback handler (redirect from PDS)
10
-
* GET /api/auth/status - Check current authentication status
11
-
* POST /api/auth/logout - Sign out and clear session
12
-
*/
13
-
14
-
/**
15
-
* CUSTOM DOMAIN ROUTES
16
-
*
17
-
* Handles custom domain (BYOD - Bring Your Own Domain) management
18
-
* Users can claim custom domains with DNS verification (TXT + CNAME)
19
-
* and map them to their sites
20
-
*
21
-
* Routes:
22
-
* GET /api/check-domain - Fast verification check for routing (public)
23
-
* GET /api/custom-domains - List user's custom domains
24
-
* POST /api/custom-domains/check - Check domain availability and DNS config
25
-
* POST /api/custom-domains/claim - Claim a custom domain
26
-
* PUT /api/custom-domains/:id/site - Update site mapping
27
-
* DELETE /api/custom-domains/:id - Remove a custom domain
28
-
* POST /api/custom-domains/:id/verify - Manually trigger verification
29
-
*/
30
-
31
-
/**
32
-
* WISP SITE MANAGEMENT ROUTES
33
-
*
34
-
* API endpoints for managing user's Wisp sites stored in ATProto repos
35
-
* Handles reading site metadata, fetching content, updating sites, and uploads
36
-
* All routes are on the editor.wisp.place subdomain
37
-
*
38
-
* Routes:
39
-
* GET /wisp/sites - List all sites for authenticated user
40
-
* POST /wisp/upload-files - Upload and deploy files as a site
41
-
*/
+154
-35
bun.lock
+154
-35
bun.lock
···
1
1
{
2
2
"lockfileVersion": 1,
3
+
"configVersion": 0,
3
4
"workspaces": {
4
5
"": {
5
6
"name": "elysia-static",
···
13
14
"@elysiajs/openapi": "^1.4.11",
14
15
"@elysiajs/opentelemetry": "^1.4.6",
15
16
"@elysiajs/static": "^1.4.2",
17
+
"@radix-ui/react-checkbox": "^1.3.3",
16
18
"@radix-ui/react-dialog": "^1.1.15",
17
19
"@radix-ui/react-label": "^2.1.7",
18
20
"@radix-ui/react-radio-group": "^1.3.8",
19
21
"@radix-ui/react-slot": "^1.2.3",
20
22
"@radix-ui/react-tabs": "^1.1.13",
21
23
"@tanstack/react-query": "^5.90.2",
24
+
"actor-typeahead": "^0.1.1",
25
+
"atproto-ui": "^0.11.3",
22
26
"class-variance-authority": "^0.7.1",
23
27
"clsx": "^2.1.1",
24
28
"elysia": "latest",
25
29
"iron-session": "^8.0.4",
26
30
"lucide-react": "^0.546.0",
31
+
"multiformats": "^13.4.1",
32
+
"prismjs": "^1.30.0",
27
33
"react": "^19.2.0",
28
34
"react-dom": "^19.2.0",
29
35
"tailwind-merge": "^3.3.1",
···
37
43
"@types/react-dom": "^19.2.1",
38
44
"bun-plugin-tailwind": "^0.1.2",
39
45
"bun-types": "latest",
46
+
"esbuild": "0.26.0",
40
47
},
41
48
},
42
49
},
43
50
"trustedDependencies": [
44
51
"core-js",
52
+
"cbor-extract",
53
+
"bun",
45
54
"protobufjs",
46
55
],
47
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
+
48
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=="],
49
74
50
75
"@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="],
51
76
52
-
"@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.1.10", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-o7hGaonA71A6p7O107VhM6UBUN/g9tTyYohMp1q0Kf6xQ4npnuZYRSHSf2g6reSfGQJ1GoFNjBObETTT1ge/jQ=="],
77
+
"@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="],
53
78
54
79
"@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.2", "", { "dependencies": { "@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-KIerCzh3qb+zZoqWbIvTlvBY0XPq0r56kwViaJY/LTe/3oPO2JaqlYKS/F4dByWBhHK6YoUOJ0sWrh6PMJl40A=="],
55
80
56
-
"@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.20", "", { "dependencies": { "@atproto-labs/fetch-node": "0.1.10", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-094EL61XN9M7vm22cloSOxk/gcTRaCK52vN7BYgXgdoEI8uJJMTFXenQqu+LRGwiCcjvyclcBqbaz0DzJep50Q=="],
81
+
"@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.21", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.2", "@atproto/did": "0.2.1" } }, "sha512-fuJy5Px5pGF3lJX/ATdurbT8tbmaFWtf+PPxAQDFy7ot2no3t+iaAgymhyxYymrssOuWs6BwOP8tyF3VrfdwtQ=="],
57
82
58
83
"@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.2", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver": "0.3.2" } }, "sha512-MYxO9pe0WsFyi5HFdKAwqIqHfiF2kBPoVhAIuH/4PYHzGr799ED47xLhNMxR3ZUYrJm5+TQzWXypGZ0Btw1Ffw=="],
59
84
···
63
88
64
89
"@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="],
65
90
66
-
"@atproto/api": ["@atproto/api@0.17.3", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-pdQXhUAapNPdmN00W0vX5ta/aMkHqfgBHATt20X02XwxQpY2AnrPm2Iog4FyjsZqoHooAtCNV/NWJ4xfddJzsg=="],
91
+
"@atproto/api": ["@atproto/api@0.17.7", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "@atproto/xrpc": "^0.7.5", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw=="],
67
92
68
93
"@atproto/common": ["@atproto/common@0.4.12", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@ipld/dag-cbor": "^7.0.3", "cbor-x": "^1.5.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "pino": "^8.21.0" } }, "sha512-NC+TULLQiqs6MvNymhQS5WDms3SlbIKGLf4n33tpftRJcalh507rI+snbcUb7TLIkKw7VO17qMqxEXtIdd5auQ=="],
69
94
···
79
104
80
105
"@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="],
81
106
82
-
"@atproto/lex-cli": ["@atproto/lex-cli@0.9.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-zun4jhD1dbjD7IHtLIjh/1UsMx+6E8+OyOT2GXYAKIj9N6wmLKM/v2OeQBKxcyqUmtRi57lxWnGikWjjU7pplQ=="],
107
+
"@atproto/lex-cli": ["@atproto/lex-cli@0.9.6", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "@atproto/syntax": "^0.4.1", "chalk": "^4.1.2", "commander": "^9.4.0", "prettier": "^3.2.5", "ts-morph": "^24.0.0", "yesno": "^0.4.0", "zod": "^3.23.8" }, "bin": { "lex": "dist/index.js" } }, "sha512-EedEKmURoSP735YwSDHsFrLOhZ4P2it8goCHv5ApWi/R9DFpOKOpmYfIXJ9MAprK8cw+yBnjDJbzpLJy7UXlTg=="],
83
108
84
109
"@atproto/lexicon": ["@atproto/lexicon@0.5.1", "", { "dependencies": { "@atproto/common-web": "^0.4.3", "@atproto/syntax": "^0.4.1", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A=="],
85
110
86
-
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.7", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.4.2", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-pDvbvy9DCxrAJv7bAbBUzWrHZKhFy091HvEMZhr+EyZA6gSCGYmmQJG/coDj0oICSVQeafAZd+IxR0YUCWwmEg=="],
111
+
"@atproto/oauth-client": ["@atproto/oauth-client@0.5.8", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.2", "@atproto-labs/identity-resolver": "0.3.2", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.5.0", "@atproto/xrpc": "0.7.5", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-7YEym6d97+Dd73qGdkQTXi5La8xvCQxwRUDzzlR/NVAARa9a4YP7MCmqBJVeP2anT0By+DSAPyPDLTsxcjIcCg=="],
87
112
88
-
"@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.9", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.20", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.7", "@atproto/oauth-types": "0.4.2" } }, "sha512-JdzwDQ8Gczl0lgfJNm7lG7omkJ4yu99IuGkkRWixpEvKY/jY/mDZaho+3pfd29SrUvwQOOx4Bm4l7DGeYwxxyA=="],
113
+
"@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.10", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.2", "@atproto-labs/handle-resolver-node": "0.1.21", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.8", "@atproto/oauth-types": "0.5.0" } }, "sha512-6khKlJqu1Ed5rt3rzcTD5hymB6JUjKdOHWYXwiphw4inkAIo6GxLCighI4eGOqZorYk2j8ueeTNB6KsgH0kcRw=="],
89
114
90
-
"@atproto/oauth-types": ["@atproto/oauth-types@0.4.2", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-gcfNTyFsPJcYDf79M0iKHykWqzxloscioKoerdIN3MTS3htiNOSgZjm2p8ho7pdrElLzea3qktuhTQI39j1XFQ=="],
115
+
"@atproto/oauth-types": ["@atproto/oauth-types@0.5.0", "", { "dependencies": { "@atproto/did": "0.2.1", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-33xz7HcXhbl+XRqbIMVu3GE02iK1nKe2oMWENASsfZEYbCz2b9ZOarOFuwi7g4LKqpGowGp0iRKsQHFcq4SDaQ=="],
91
116
92
117
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
93
118
94
119
"@atproto/xrpc": ["@atproto/xrpc@0.7.5", "", { "dependencies": { "@atproto/lexicon": "^0.5.1", "zod": "^3.23.8" } }, "sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA=="],
95
120
96
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=="],
97
124
98
125
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
99
126
···
111
138
112
139
"@elysiajs/cors": ["@elysiajs/cors@1.4.0", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-pb0SCzBfFbFSYA/U40HHO7R+YrcXBJXOWgL20eSViK33ol1e20ru2/KUaZYo5IMUn63yaTJI/bQERuQ+77ND8g=="],
113
140
114
-
"@elysiajs/eden": ["@elysiajs/eden@1.4.3", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-mX0v5cTvTJiDsDWNEEyuoqudOvW5J+tXsvp/ZOJXJF3iCIEJI0Brvm78ymPrvwiOG4nUr3lS8BxUfbNf32DSXA=="],
141
+
"@elysiajs/eden": ["@elysiajs/eden@1.4.4", "", { "peerDependencies": { "elysia": ">= 1.4.0-exp.0" } }, "sha512-/LVqflmgUcCiXb8rz1iRq9Rx3SWfIV/EkoNqDFGMx+TvOyo8QHAygFXAVQz7RHs+jk6n6mEgpI6KlKBANoErsQ=="],
115
142
116
143
"@elysiajs/openapi": ["@elysiajs/openapi@1.4.11", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-d75bMxYJpN6qSDi/z9L1S7SLk1S/8Px+cTb3W2lrYzU8uQ5E0kXdy1oOMJEfTyVsz3OA19NP9KNxE7ztSbLBLg=="],
117
144
118
145
"@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="],
119
146
120
-
"@elysiajs/static": ["@elysiajs/static@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lAEvdxeBhU/jX/hTzfoP+1AtqhsKNCwW4Q+tfNwAShWU6s4ZPQxR1hLoHBveeApofJt4HWEq/tBGvfFz3ODuKg=="],
147
+
"@elysiajs/static": ["@elysiajs/static@1.4.6", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-cd61aY/DHOVhlnBjzTBX8E1XANIrsCH8MwEGHeLMaZzNrz0gD4Q8Qsde2dFMzu81I7ZDaaZ2Rim9blSLtUrYBg=="],
148
+
149
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.26.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-hj0sKNCQOOo2fgyII3clmJXP28VhgDfU5iy3GNHlWO76KG6N7x4D9ezH5lJtQTG+1J6MFDAJXC1qsI+W+LvZoA=="],
150
+
151
+
"@esbuild/android-arm": ["@esbuild/android-arm@0.26.0", "", { "os": "android", "cpu": "arm" }, "sha512-C0hkDsYNHZkBtPxxDx177JN90/1MiCpvBNjz1f5yWJo1+5+c5zr8apjastpEG+wtPjo9FFtGG7owSsAxyKiHxA=="],
152
+
153
+
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.26.0", "", { "os": "android", "cpu": "arm64" }, "sha512-DDnoJ5eoa13L8zPh87PUlRd/IyFaIKOlRbxiwcSbeumcJ7UZKdtuMCHa1Q27LWQggug6W4m28i4/O2qiQQ5NZQ=="],
154
+
155
+
"@esbuild/android-x64": ["@esbuild/android-x64@0.26.0", "", { "os": "android", "cpu": "x64" }, "sha512-bKDkGXGZnj0T70cRpgmv549x38Vr2O3UWLbjT2qmIkdIWcmlg8yebcFWoT9Dku7b5OV3UqPEuNKRzlNhjwUJ9A=="],
156
+
157
+
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.26.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6Z3naJgOuAIB0RLlJkYc81An3rTlQ/IeRdrU3dOea8h/PvZSgitZV+thNuIccw0MuK1GmIAnAmd5TrMZad8FTQ=="],
158
+
159
+
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.26.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-OPnYj0zpYW0tHusMefyaMvNYQX5pNQuSsHFTHUBNp3vVXupwqpxofcjVsUx11CQhGVkGeXjC3WLjh91hgBG2xw=="],
160
+
161
+
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.26.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jix2fa6GQeZhO1sCKNaNMjfj5hbOvoL2F5t+w6gEPxALumkpOV/wq7oUBMHBn2hY2dOm+mEV/K+xfZy3mrsxNQ=="],
162
+
163
+
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.26.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tccJaH5xHJD/239LjbVvJwf6T4kSzbk6wPFerF0uwWlkw/u7HL+wnAzAH5GB2irGhYemDgiNTp8wJzhAHQ64oA=="],
164
+
165
+
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.26.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JY8NyU31SyRmRpuc5W8PQarAx4TvuYbyxbPIpHAZdr/0g4iBr8KwQBS4kiiamGl2f42BBecHusYCsyxi7Kn8UQ=="],
166
+
167
+
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.26.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IMJYN7FSkLttYyTbsbme0Ra14cBO5z47kpamo16IwggzzATFY2lcZAwkbcNkWiAduKrTgFJP7fW5cBI7FzcuNQ=="],
168
+
169
+
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.26.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-XITaGqGVLgk8WOHw8We9Z1L0lbLFip8LyQzKYFKO4zFo1PFaaSKsbNjvkb7O8kEXytmSGRkYpE8LLVpPJpsSlw=="],
170
+
171
+
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-MkggfbDIczStUJwq9wU7gQ7kO33d8j9lWuOCDifN9t47+PeI+9m2QVh51EI/zZQ1spZtFMC1nzBJ+qNGCjJnsg=="],
172
+
173
+
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-fUYup12HZWAeccNLhQ5HwNBPr4zXCPgUWzEq2Rfw7UwqwfQrFZ0SR/JljaURR8xIh9t+o1lNUFTECUTmaP7yKA=="],
174
+
175
+
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.26.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-MzRKhM0Ip+//VYwC8tialCiwUQ4G65WfALtJEFyU0GKJzfTYoPBw5XNWf0SLbCUYQbxTKamlVwPmcw4DgZzFxg=="],
176
+
177
+
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.26.0", "", { "os": "linux", "cpu": "none" }, "sha512-QhCc32CwI1I4Jrg1enCv292sm3YJprW8WHHlyxJhae/dVs+KRWkbvz2Nynl5HmZDW/m9ZxrXayHzjzVNvQMGQA=="],
178
+
179
+
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.26.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-1D6vi6lfI18aNT1aTf2HV+RIlm6fxtlAp8eOJ4mmnbYmZ4boz8zYDar86sIYNh0wmiLJEbW/EocaKAX6Yso2fw=="],
180
+
181
+
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.26.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rnDcepj7LjrKFvZkx+WrBv6wECeYACcFjdNPvVPojCPJD8nHpb3pv3AuR9CXgdnjH1O23btICj0rsp0L9wAnHA=="],
182
+
183
+
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.26.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FSWmgGp0mDNjEXXFcsf12BmVrb+sZBBBlyh3LwB/B9ac3Kkc8x5D2WimYW9N7SUkolui8JzVnVlWh7ZmjCpnxw=="],
184
+
185
+
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.26.0", "", { "os": "none", "cpu": "x64" }, "sha512-0QfciUDFryD39QoSPUDshj4uNEjQhp73+3pbSAaxjV2qGOEDsM67P7KbJq7LzHoVl46oqhIhJ1S+skKGR7lMXA=="],
186
+
187
+
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.26.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-vmAK+nHhIZWImwJ3RNw9hX3fU4UGN/OqbSE0imqljNbUQC3GvVJ1jpwYoTfD6mmXmQaxdJY6Hn4jQbLGJKg5Yw=="],
188
+
189
+
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.26.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-GPXF7RMkJ7o9bTyUsnyNtrFMqgM3X+uM/LWw4CeHIjqc32fm0Ir6jKDnWHpj8xHFstgWDUYseSABK9KCkHGnpg=="],
190
+
191
+
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.26.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nUHZ5jEYqbBthbiBksbmHTlbb5eElyVfs/s1iHQ8rLBq1eWsd5maOnDpCocw1OM8kFK747d1Xms8dXJHtduxSw=="],
192
+
193
+
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.26.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-TMg3KCTCYYaVO+R6P5mSORhcNDDlemUVnUbb8QkboUtOhb5JWKAzd5uMIMECJQOxHZ/R+N8HHtDF5ylzLfMiLw=="],
194
+
195
+
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.26.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-apqYgoAUd6ZCb9Phcs8zN32q6l0ZQzQBdVXOofa6WvHDlSOhwCWgSfVQabGViThS40Y1NA4SCvQickgZMFZRlA=="],
196
+
197
+
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.26.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-FGJAcImbJNZzLWu7U6WB0iKHl4RuY4TsXEwxJPl9UZLS47agIZuILZEX3Pagfw7I4J3ddflomt9f0apfaJSbaw=="],
198
+
199
+
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.26.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WAckBKaVnmFqbEhbymrPK7M086DQMpL1XoRbpmN0iW8k5JSXjDRQBhcZNa0VweItknLq9eAeCL34jK7/CDcw7A=="],
121
200
122
-
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="],
201
+
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
123
202
124
203
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
125
204
···
185
264
186
265
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="],
187
266
188
-
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
267
+
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="],
189
268
190
-
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WeXSaL29ylJEZMYHHW28QZ6rgAbxQ1KuNSZD9gvd3fPlo0s6s2PglvPArjjP07nmvIK9m4OffN0k4M98O7WmAg=="],
269
+
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-licBDIbbLP5L5/S0+bwtJynso94XD3KyqSP48K59Sq7Mude6C7dR5ZujZm4Ut4BwZqUFfNOfYNMWBU5nlL7t1A=="],
191
270
192
-
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CFKjoUWQH0Oz3UHYfKbdKLq0wGryrFsTJEYq839qAwHQSECvVZYAnxVVDYUDa0yQFonhO2qSHY41f6HK+b7xtw=="],
271
+
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-hn8lLzsYyyh6ULo2E8v2SqtrWOkdQKJwapeVy1rDw7juTTeHY3KDudGWf4mVYteC9riZU6HD88Fn3nGwyX0eIg=="],
193
272
194
-
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+FSr/ub5vA/EkD3fMhHJUzYioSf/sXd50OGxNDAntVxcDu4tXL/81Ka3R/gkZmjznpLFIzovU/1Ts+b7dlkrfw=="],
273
+
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UHxdtbyxdtNJUNcXtIrjx3Lmq8ji3KywlXtIHV/0vn9A8W5mulqOcryqUWMFVH9JTIIzmNn6Q/qVmXHTME63Ww=="],
195
274
196
-
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WHthS/eLkCNcp9pk4W8aubRl9fIUgt2XhHyLrP0GClB1FVvmodu/zIOtG0NXNpzlzB8+gglOkGo4dPjfVf4Z+g=="],
275
+
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5uZzxzvHU/z+3cZwN/A0H8G+enQ+9FkeJVZkE2fwK2XhiJZFUGAuWajCpy7GepvOWlqV7VjPaKi2+Qmr4IX7nQ=="],
197
276
198
-
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HT5sr7N8NDYbQRjAnT7ISpx64y+ewZZRQozOJb0+KQObKvg4UUNXGm4Pn1xA4/WPMZDDazjO8E2vtOQw1nJlAQ=="],
277
+
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OD9DYkjes7WXieBn4zQZGXWhRVZhIEWMDGCetZ3H4vxIuweZ++iul/CNX5jdpNXaJ17myb1ROMvmRbrqW44j3w=="],
199
278
200
-
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sGEWoJQXO4GDr0x4t/yJQ/Bq1yNkOdX9tHbZZ+DBGJt3z3r7jeb4Digv8xQUk6gdTFC9vnGHuin+KW3/yD1Aww=="],
279
+
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EoEuRP9bxAxVKuvi6tZ0ZENjueP4lvjz0mKsMzdG0kwg/2apGKiirH1l0RIcdmvfDGGuDmNiv/XBpkoXq1x8ug=="],
201
280
202
-
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OmlEH3nlxQyv7HOvTH21vyNAZGv9DIPnrTznzvKiOQxkOphhCyKvPTlF13ydw4s/i18iwaUrhHy+YG9HSSxa4Q=="],
281
+
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m9Ov9YH8KjRLui87eNtQQFKVnjGsNk3xgbrR9c8d2FS3NfZSxmVjSeBvEsDjzNf1TXLDriHb/NYOlpiMf/QzDg=="],
203
282
204
-
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rtzUEzCynl3Rhgn/iR9DQezSFiZMcAXAbU+xfROqsweMGKwvwIA2ckyyckO08psEP8XcUZTs3LT9CH7PnaMiEA=="],
283
+
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-3TuOsRVoG8K+soQWRo+Cp5ACpRs6rTFSu5tAqc/6WrqwbNWmqjov/eWJPTgz3gPXnC7uNKVG7RxxAmV8r2EYTQ=="],
205
284
206
-
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hrr7mDvUjMX1tuJaXz448tMsgKIqGJBY8+rJqztKOw1U5+a/v2w5HuIIW1ce7ut0ZwEn+KIDvAujlPvpH33vpQ=="],
285
+
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-q8Hto8hcpofPJjvuvjuwyYvhOaAzPw1F5vRUUeOJDmDwZ4lZhANFM0rUwchMzfWUJCD6jg8/EVQ8MiixnZWU0A=="],
207
286
208
-
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xXwtpZVVP7T+vkxcF/TUVVOGRjEfkByO4mKveKYb4xnHWV4u4NnV0oNmzyMKkvmj10to5j2h0oZxA4ZVVv4gfA=="],
287
+
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nZJUa5NprPYQ4Ii4cMwtP9PzlJJTp1XhxJ+A9eSn1Jfr6YygVWyN2KLjenyI93IcuBouBAaepDAVZZjH2lFBhg=="],
209
288
210
-
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/jVZ8eYjpYHLDFNoT86cP+AjuWvpkzFY+0R0a1bdeu0sQ6ILuy1FV6hz1hUAP390E09VCo5oP76fnx29giHTtA=="],
289
+
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-s00T99MjB+xLOWq+t+wVaVBrry+oBOZNiTJijt+bmkp/MJptYS3FGvs7a+nkjLNzoNDoWQcXgKew6AaHES37Bg=="],
211
290
212
291
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
213
292
···
230
309
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
231
310
232
311
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
312
+
313
+
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
233
314
234
315
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
235
316
···
249
330
250
331
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
251
332
252
-
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
333
+
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
253
334
254
335
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
255
336
···
261
342
262
343
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
263
344
264
-
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
345
+
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
265
346
266
347
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
267
348
···
281
362
282
363
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
283
364
284
-
"@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="],
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=="],
285
368
286
-
"@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="],
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=="],
287
370
288
371
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
289
372
···
291
374
292
375
"@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="],
293
376
294
-
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
377
+
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
295
378
296
379
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
297
380
298
-
"@types/react-dom": ["@types/react-dom@19.2.1", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A=="],
381
+
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
299
382
300
383
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
301
384
···
307
390
308
391
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
309
392
393
+
"actor-typeahead": ["actor-typeahead@0.1.1", "", {}, "sha512-ilsBwzplKwMSBiO6Tg6RdaZ5xxqgXds5jCQuHV+ib9Aq3ja9g0T7u2Y1PmihotmS7l5RxhpGI/tPm3ljoRDRwg=="],
394
+
310
395
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
311
396
312
397
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
···
317
402
318
403
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
319
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
+
320
407
"await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="],
321
408
322
409
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
···
329
416
330
417
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
331
418
332
-
"bun": ["bun@1.3.0", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.0", "@oven/bun-darwin-x64": "1.3.0", "@oven/bun-darwin-x64-baseline": "1.3.0", "@oven/bun-linux-aarch64": "1.3.0", "@oven/bun-linux-aarch64-musl": "1.3.0", "@oven/bun-linux-x64": "1.3.0", "@oven/bun-linux-x64-baseline": "1.3.0", "@oven/bun-linux-x64-musl": "1.3.0", "@oven/bun-linux-x64-musl-baseline": "1.3.0", "@oven/bun-windows-x64": "1.3.0", "@oven/bun-windows-x64-baseline": "1.3.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-YI7mFs7iWc/VsGsh2aw6eAPD2cjzn1j+LKdYVk09x1CrdTWKYIHyd+dG5iQoN9//3hCDoZj8U6vKpZzEf5UARA=="],
419
+
"bun": ["bun@1.3.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.2", "@oven/bun-darwin-x64": "1.3.2", "@oven/bun-darwin-x64-baseline": "1.3.2", "@oven/bun-linux-aarch64": "1.3.2", "@oven/bun-linux-aarch64-musl": "1.3.2", "@oven/bun-linux-x64": "1.3.2", "@oven/bun-linux-x64-baseline": "1.3.2", "@oven/bun-linux-x64-musl": "1.3.2", "@oven/bun-linux-x64-musl-baseline": "1.3.2", "@oven/bun-windows-x64": "1.3.2", "@oven/bun-windows-x64-baseline": "1.3.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-x75mPJiEfhO1j4Tfc65+PtW6ZyrAB6yTZInydnjDZXF9u9PRAnr6OK3v0Q9dpDl0dxRHkXlYvJ8tteJxc8t4Sw=="],
333
420
334
421
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
335
422
336
-
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
423
+
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
337
424
338
425
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
339
426
···
391
478
392
479
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
393
480
394
-
"elysia": ["elysia@1.4.11", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-cphuzQj0fRw1ICRvwHy2H3xQio9bycaZUVHnDHJQnKqBfMNlZ+Hzj6TMmt9lc0Az0mvbCnPXWVF7y1MCRhUuOA=="],
481
+
"elysia": ["elysia@1.4.15", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-RaDqqZdLuC4UJetfVRQ4Z5aVpGgEtQ+pZnsbI4ZzEaf3l/MzuHcqSVoL/Fue3d6qE4RV9HMB2rAZaHyPIxkyzg=="],
395
482
396
483
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
397
484
···
403
490
404
491
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
405
492
493
+
"esbuild": ["esbuild@0.26.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.26.0", "@esbuild/android-arm": "0.26.0", "@esbuild/android-arm64": "0.26.0", "@esbuild/android-x64": "0.26.0", "@esbuild/darwin-arm64": "0.26.0", "@esbuild/darwin-x64": "0.26.0", "@esbuild/freebsd-arm64": "0.26.0", "@esbuild/freebsd-x64": "0.26.0", "@esbuild/linux-arm": "0.26.0", "@esbuild/linux-arm64": "0.26.0", "@esbuild/linux-ia32": "0.26.0", "@esbuild/linux-loong64": "0.26.0", "@esbuild/linux-mips64el": "0.26.0", "@esbuild/linux-ppc64": "0.26.0", "@esbuild/linux-riscv64": "0.26.0", "@esbuild/linux-s390x": "0.26.0", "@esbuild/linux-x64": "0.26.0", "@esbuild/netbsd-arm64": "0.26.0", "@esbuild/netbsd-x64": "0.26.0", "@esbuild/openbsd-arm64": "0.26.0", "@esbuild/openbsd-x64": "0.26.0", "@esbuild/openharmony-arm64": "0.26.0", "@esbuild/sunos-x64": "0.26.0", "@esbuild/win32-arm64": "0.26.0", "@esbuild/win32-ia32": "0.26.0", "@esbuild/win32-x64": "0.26.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-3Hq7jri+tRrVWha+ZeIVhl4qJRha/XjRNSopvTsOaCvfPHrflTYTcUFcEjMKdxofsXXsdc4zjg5NOTnL4Gl57Q=="],
494
+
406
495
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
407
496
408
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=="],
409
500
410
501
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
411
502
···
489
580
490
581
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
491
582
583
+
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
584
+
492
585
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
493
586
494
587
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
···
505
598
506
599
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
507
600
508
-
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
601
+
"multiformats": ["multiformats@13.4.1", "", {}, "sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q=="],
509
602
510
603
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
511
604
···
536
629
"pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="],
537
630
538
631
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
632
+
633
+
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
539
634
540
635
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
541
636
···
619
714
620
715
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
621
716
622
-
"tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="],
717
+
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
623
718
624
719
"thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw=="],
625
720
626
721
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
627
722
628
-
"tlds": ["tlds@1.260.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ=="],
723
+
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
629
724
630
725
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
631
726
···
649
744
650
745
"undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
651
746
652
-
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
747
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
653
748
654
749
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
655
750
···
677
772
678
773
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
679
774
775
+
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
776
+
777
+
"@atproto/common/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
778
+
779
+
"@atproto/common-web/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
780
+
781
+
"@atproto/jwk/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
782
+
783
+
"@atproto/lexicon/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
784
+
785
+
"@atproto/oauth-client/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
786
+
787
+
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
788
+
789
+
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
790
+
791
+
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
792
+
793
+
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
794
+
795
+
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
796
+
680
797
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
681
798
682
799
"express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="],
···
690
807
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
691
808
692
809
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
810
+
811
+
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
693
812
694
813
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
695
814
+420
-25
claude.md
+420
-25
claude.md
···
1
-
Wisp.place - Decentralized Static Site Hosting
1
+
# Wisp.place - Codebase Overview
2
+
3
+
**Project URL**: https://wisp.place
4
+
5
+
A decentralized static site hosting service built on the AT Protocol (Bluesky). Users can host static websites directly in their AT Protocol accounts, keeping full control and ownership while benefiting from fast CDN distribution.
6
+
7
+
---
8
+
9
+
## ๐๏ธ Architecture Overview
10
+
11
+
### Multi-Part System
12
+
1. **Main Backend** (`/src`) - OAuth, site management, custom domains
13
+
2. **Hosting Service** (`/hosting-service`) - Microservice that serves cached sites
14
+
3. **CLI Tool** (`/cli`) - Rust CLI for direct site uploads to PDS
15
+
4. **Frontend** (`/public`) - React UI for onboarding, editor, admin
16
+
17
+
### Tech Stack
18
+
- **Backend**: Elysia (Bun) + TypeScript + PostgreSQL
19
+
- **Frontend**: React 19 + Tailwind CSS 4 + Radix UI
20
+
- **CLI**: Rust with Jacquard (AT Protocol library)
21
+
- **Database**: PostgreSQL for session/domain/site caching
22
+
- **AT Protocol**: OAuth 2.0 + custom lexicons for storage
23
+
24
+
---
25
+
26
+
## ๐ Directory Structure
27
+
28
+
### `/src` - Main Backend Server
29
+
**Purpose**: Core server handling OAuth, site management, custom domains, admin features
30
+
31
+
**Key Routes**:
32
+
- `/api/auth/*` - OAuth signin/callback/logout/status
33
+
- `/api/domain/*` - Custom domain management (BYOD)
34
+
- `/wisp/*` - Site upload and management
35
+
- `/api/user/*` - User info and site listing
36
+
- `/api/admin/*` - Admin console (logs, metrics, DNS verification)
37
+
38
+
**Key Files**:
39
+
- `index.ts` - Express-like Elysia app setup with middleware (CORS, CSP, security headers)
40
+
- `lib/oauth-client.ts` - OAuth client setup with session/state persistence
41
+
- `lib/db.ts` - PostgreSQL schema and queries for all tables
42
+
- `lib/wisp-auth.ts` - Cookie-based authentication middleware
43
+
- `lib/wisp-utils.ts` - File compression (gzip), manifest creation, blob handling
44
+
- `lib/sync-sites.ts` - Syncs user's place.wisp.fs records from PDS to database cache
45
+
- `lib/dns-verify.ts` - DNS verification for custom domains (TXT + CNAME)
46
+
- `lib/dns-verification-worker.ts` - Background worker that checks domain verification every 10 minutes
47
+
- `lib/admin-auth.ts` - Simple username/password admin authentication
48
+
- `lib/observability.ts` - Logging, error tracking, metrics collection
49
+
- `routes/auth.ts` - OAuth flow handlers
50
+
- `routes/wisp.ts` - File upload and site creation (/wisp/upload-files)
51
+
- `routes/domain.ts` - Domain claiming/verification API
52
+
- `routes/user.ts` - User status/info/sites listing
53
+
- `routes/site.ts` - Site metadata and file retrieval
54
+
- `routes/admin.ts` - Admin dashboard API (logs, system health, manual DNS trigger)
55
+
56
+
### `/lexicons` & `src/lexicons/`
57
+
**Purpose**: AT Protocol Lexicon definitions for custom data types
58
+
59
+
**Key File**: `fs.json` - Defines `place.wisp.fs` record format
60
+
- **structure**: Virtual filesystem manifest with tree structure
61
+
- **site**: string identifier
62
+
- **root**: directory object containing entries
63
+
- **file**: blob reference + metadata (encoding, mimeType, base64 flag)
64
+
- **directory**: array of entries (recursive)
65
+
- **entry**: name + node (file or directory)
66
+
67
+
**Important**: Files are gzip-compressed and base64-encoded before upload to bypass PDS content sniffing
68
+
69
+
### `/hosting-service`
70
+
**Purpose**: Lightweight microservice that serves cached sites from disk
71
+
72
+
**Architecture**:
73
+
- Routes by domain lookup in PostgreSQL
74
+
- Caches site content locally on first access or firehose event
75
+
- Listens to AT Protocol firehose for new site records
76
+
- Automatically downloads and caches files from PDS
77
+
- SSRF-protected fetch (timeout, size limits, private IP blocking)
78
+
79
+
**Routes**:
80
+
1. Custom domains (`/*`) โ lookup custom_domains table
81
+
2. Wisp subdomains (`/*.wisp.place/*`) โ lookup domains table
82
+
3. DNS hash routing (`/hash.dns.wisp.place/*`) โ lookup custom_domains by hash
83
+
4. Direct serving (`/s.wisp.place/:identifier/:site/*`) โ fetch from PDS if not cached
84
+
85
+
**HTML Path Rewriting**: Absolute paths in HTML (`/style.css`) automatically rewritten to relative (`/:identifier/:site/style.css`)
86
+
87
+
### `/cli`
88
+
**Purpose**: Rust CLI tool for direct site uploads using app password or OAuth
89
+
90
+
**Flow**:
91
+
1. Authenticate with handle + app password or OAuth
92
+
2. Walk directory tree, compress files
93
+
3. Upload blobs to PDS via agent
94
+
4. Create place.wisp.fs record with manifest
95
+
5. Store site in database cache
96
+
97
+
**Auth Methods**:
98
+
- `--password` flag for app password auth
99
+
- OAuth loopback server for browser-based auth
100
+
- Supports both (password preferred if provided)
101
+
102
+
---
103
+
104
+
## ๐ Key Concepts
105
+
106
+
### Custom Domains (BYOD - Bring Your Own Domain)
107
+
**Process**:
108
+
1. User claims custom domain via API
109
+
2. System generates hash (SHA256(domain + secret))
110
+
3. User adds DNS records:
111
+
- TXT at `_wisp.example.com` = their DID
112
+
- CNAME at `example.com` = `{hash}.dns.wisp.place`
113
+
4. Background worker checks verification every 10 minutes
114
+
5. Once verified, custom domain routes to their hosted sites
115
+
116
+
**Tables**: `custom_domains` (id, domain, did, rkey, verified, last_verified_at)
117
+
118
+
### Wisp Subdomains
119
+
**Process**:
120
+
1. Handle claimed on first signup (e.g., alice โ alice.wisp.place)
121
+
2. Stored in `domains` table mapping domain โ DID
122
+
3. Served by hosting service
123
+
124
+
### Site Storage
125
+
**Locations**:
126
+
- **Authoritative**: PDS (AT Protocol repo) as `place.wisp.fs` record
127
+
- **Cache**: PostgreSQL `sites` table (rkey, did, site_name, created_at)
128
+
- **File Cache**: Hosting service caches downloaded files on disk
129
+
130
+
**Limits**:
131
+
- MAX_SITE_SIZE: 300MB total
132
+
- MAX_FILE_SIZE: 100MB per file
133
+
- MAX_FILE_COUNT: 2000 files
134
+
135
+
### File Compression Strategy
136
+
**Why**: Bypass PDS content sniffing issues (was treating HTML as images)
137
+
138
+
**Process**:
139
+
1. All files gzip-compressed (level 9)
140
+
2. Compressed content base64-encoded
141
+
3. Uploaded as `application/octet-stream` MIME type
142
+
4. Blob metadata stores original MIME type + encoding flag
143
+
5. Hosting service decompresses on serve
144
+
145
+
---
146
+
147
+
## ๐ Data Flow
148
+
149
+
### User Registration โ Site Upload
150
+
```
151
+
1. OAuth signin โ state/session stored in DB
152
+
2. Cookie set with DID
153
+
3. Sync sites from PDS to cache DB
154
+
4. If no sites/domain โ redirect to onboarding
155
+
5. User creates site โ POST /wisp/upload-files
156
+
6. Files compressed, uploaded as blobs
157
+
7. place.wisp.fs record created
158
+
8. Site cached in DB
159
+
9. Hosting service notified via firehose
160
+
```
161
+
162
+
### Custom Domain Setup
163
+
```
164
+
1. User claims domain (DB check + allocation)
165
+
2. System generates hash
166
+
3. User adds DNS records (_wisp.domain TXT + CNAME)
167
+
4. Background worker verifies every 10 min
168
+
5. Hosting service routes based on verification status
169
+
```
170
+
171
+
### Site Access
172
+
```
173
+
Hosting Service:
174
+
1. Request arrives at custom domain or *.wisp.place
175
+
2. Domain lookup in PostgreSQL
176
+
3. Check cache for site files
177
+
4. If not cached:
178
+
- Fetch from PDS using DID + rkey
179
+
- Decompress files
180
+
- Save to disk cache
181
+
5. Serve files (with HTML path rewriting)
182
+
```
183
+
184
+
---
185
+
186
+
## ๐ ๏ธ Important Implementation Details
187
+
188
+
### OAuth Implementation
189
+
- **State & Session Storage**: PostgreSQL (with expiration)
190
+
- **Key Rotation**: Periodic rotation + expiration cleanup (hourly)
191
+
- **OAuth Flow**: Redirects to PDS, returns to /api/auth/callback
192
+
- **Session Timeout**: 30 days
193
+
- **State Timeout**: 1 hour
194
+
195
+
### Security Headers
196
+
- X-Frame-Options: DENY
197
+
- X-Content-Type-Options: nosniff
198
+
- Strict-Transport-Security: max-age=31536000
199
+
- Content-Security-Policy (configured for Elysia + React)
200
+
- X-XSS-Protection: 1; mode=block
201
+
- Referrer-Policy: strict-origin-when-cross-origin
202
+
203
+
### Admin Authentication
204
+
- Simple username/password (hashed with bcrypt)
205
+
- Session-based cookie auth (24hr expiration)
206
+
- Separate `admin_session` cookie
207
+
- Initial setup prompted on startup
208
+
209
+
### Observability
210
+
- **Logging**: Structured logging with service tags + event types
211
+
- **Error Tracking**: Captures error context (message, stack, etc.)
212
+
- **Metrics**: Request counts, latencies, error rates
213
+
- **Log Levels**: debug, info, warn, error
214
+
- **Collection**: Centralized log collector with in-memory buffer
215
+
216
+
---
217
+
218
+
## ๐ Database Schema
219
+
220
+
### oauth_states
221
+
- key (primary key)
222
+
- data (JSON)
223
+
- created_at, expires_at (timestamps)
2
224
3
-
Architecture Overview
225
+
### oauth_sessions
226
+
- sub (primary key - subject/DID)
227
+
- data (JSON with OAuth session)
228
+
- updated_at, expires_at
4
229
5
-
Wisp.Place a two-service application that provides static site hosting on the AT
6
-
Protocol. Wisp aims to be a CDN for static sites where the content is ultimately owned by the user at their repo. The microservice is responsbile for injesting firehose events and serving a on-disk cache of the latest site files.
230
+
### oauth_keys
231
+
- kid (primary key - key ID)
232
+
- jwk (JSON Web Key)
233
+
- created_at
234
+
235
+
### domains
236
+
- domain (primary key - e.g., alice.wisp.place)
237
+
- did (unique - user's DID)
238
+
- rkey (optional - record key)
239
+
- created_at
240
+
241
+
### custom_domains
242
+
- id (primary key - UUID)
243
+
- domain (unique - e.g., example.com)
244
+
- did (user's DID)
245
+
- rkey (optional)
246
+
- verified (boolean)
247
+
- last_verified_at (timestamp)
248
+
- created_at
249
+
250
+
### sites
251
+
- id, did, rkey, site_name
252
+
- created_at, updated_at
253
+
- Indexes on (did), (did, rkey), (rkey)
254
+
255
+
### admin_users
256
+
- username (primary key)
257
+
- password_hash (bcrypt)
258
+
- created_at
259
+
260
+
---
261
+
262
+
## ๐ Key Workflows
263
+
264
+
### Sign In Flow
265
+
1. POST /api/auth/signin with handle
266
+
2. System generates state token
267
+
3. Redirects to PDS OAuth endpoint
268
+
4. PDS redirects back to /api/auth/callback?code=X&state=Y
269
+
5. Validate state (CSRF protection)
270
+
6. Exchange code for session
271
+
7. Store session in DB, set DID cookie
272
+
8. Sync sites from PDS
273
+
9. Redirect to /editor or /onboarding
274
+
275
+
### File Upload Flow
276
+
1. POST /wisp/upload-files with siteName + files
277
+
2. Validate site name (rkey format rules)
278
+
3. For each file:
279
+
- Check size limits
280
+
- Read as ArrayBuffer
281
+
- Gzip compress
282
+
- Base64 encode
283
+
4. Upload all blobs in parallel via agent.com.atproto.repo.uploadBlob()
284
+
5. Create manifest with all blob refs
285
+
6. putRecord() for place.wisp.fs with manifest
286
+
7. Upsert to sites table
287
+
8. Return URI + CID
7
288
8
-
Service 1: Main App (Port 8000, Bun runtime, elysia.js)
9
-
- User-facing editor and API
10
-
- OAuth authentication (AT Protocol)
11
-
- File upload processing (gzip + base64 encoding)
12
-
- Domain management (subdomains + custom domains)
13
-
- DNS verification worker
14
-
- React frontend
289
+
### Domain Verification Flow
290
+
1. POST /api/custom-domains/claim
291
+
2. Generate hash = SHA256(domain + secret)
292
+
3. Store in custom_domains with verified=false
293
+
4. Return hash for user to configure DNS
294
+
5. Background worker periodically:
295
+
- Query custom_domains where verified=false
296
+
- Verify TXT record at _wisp.domain
297
+
- Verify CNAME points to hash.dns.wisp.place
298
+
- Update verified flag + last_verified_at
299
+
6. Hosting service routes when verified=true
15
300
16
-
Service 2: Hosting Service (Port 3001, Node.js runtime, hono.js)
17
-
- AT Protocol Firehose listener for real-time updates
18
-
- Serves hosted websites from local cache
19
-
- Multi-domain routing (custom domains, wisp.place subdomains, sites subdomain)
20
-
- Distributed locking for multi-instance coordination
301
+
---
21
302
22
-
Tech Stack
303
+
## ๐จ Frontend Structure
23
304
24
-
- Backend: Bun/Node.js, Elysia.js, PostgreSQL, AT Protocol SDK
25
-
- Frontend: React 19, Tailwind CSS v4, Shadcn UI
305
+
### `/public`
306
+
- **index.tsx** - Landing page with sign-in form
307
+
- **editor/editor.tsx** - Site editor/management UI
308
+
- **admin/admin.tsx** - Admin dashboard
309
+
- **components/ui/** - Reusable components (Button, Card, Dialog, etc.)
310
+
- **styles/global.css** - Tailwind + custom styles
26
311
27
-
Key Features
312
+
### Page Flow
313
+
1. `/` - Landing page (sign in / get started)
314
+
2. `/editor` - Main app (requires auth)
315
+
3. `/admin` - Admin console (requires admin auth)
316
+
4. `/onboarding` - First-time user setup
28
317
29
-
- AT Protocol Integration: Sites stored as place.wisp.fs records in user repos
30
-
- File Processing: Validates, compresses (gzip), encodes (base64), uploads to user's PDS
31
-
- Domain Management: wisp.place subdomains + custom BYOD domains with DNS verification
32
-
- Real-time Sync: Firehose worker listens for site updates and caches files locally
33
-
- Atomic Updates: Safe cache swapping without downtime
318
+
---
319
+
320
+
## ๐ Notable Implementation Patterns
321
+
322
+
### File Handling
323
+
- Files stored as base64-encoded gzip in PDS blobs
324
+
- Metadata preserves original MIME type
325
+
- Hosting service decompresses on serve
326
+
- Workaround for PDS image pipeline issues with HTML
327
+
328
+
### Error Handling
329
+
- Comprehensive logging with context
330
+
- Graceful degradation (e.g., site sync failure doesn't break auth)
331
+
- Structured error responses with details
332
+
333
+
### Performance
334
+
- Site sync: Batch fetch up to 100 records per request
335
+
- Blob upload: Parallel promises for all files
336
+
- DNS verification: Batched background worker (10 min intervals)
337
+
- Caching: Two-tier (DB + disk in hosting service)
338
+
339
+
### Validation
340
+
- Lexicon validation on manifest creation
341
+
- Record type checking
342
+
- Domain format validation
343
+
- Site name format validation (AT Protocol rkey rules)
344
+
- File size limits enforced before upload
345
+
346
+
---
347
+
348
+
## ๐ Known Quirks & Workarounds
349
+
350
+
1. **PDS Content Sniffing**: Files must be uploaded as `application/octet-stream` (even HTML) and base64-encoded to prevent PDS from misinterpreting content
351
+
352
+
2. **Max URL Query Size**: DNS verification worker queries in batch; may need pagination for users with many custom domains
353
+
354
+
3. **File Count Limits**: Max 500 entries per directory (Lexicon constraint); large sites split across multiple directories
355
+
356
+
4. **Blob Size Limits**: Individual blobs limited to 100MB by Lexicon; large files handled differently if needed
357
+
358
+
5. **HTML Path Rewriting**: Only in hosting service for `/s.wisp.place/:identifier/:site/*` routes; custom domains handled differently
359
+
360
+
---
361
+
362
+
## ๐ Environment Variables
363
+
364
+
- `DOMAIN` - Base domain with protocol (default: `https://wisp.place`)
365
+
- `CLIENT_NAME` - OAuth client name (default: `PDS-View`)
366
+
- `DATABASE_URL` - PostgreSQL connection (default: `postgres://postgres:postgres@localhost:5432/wisp`)
367
+
- `NODE_ENV` - production/development
368
+
- `HOSTING_PORT` - Hosting service port (default: 3001)
369
+
- `BASE_DOMAIN` - Domain for URLs (default: wisp.place)
370
+
371
+
---
372
+
373
+
## ๐งโ๐ป Development Notes
374
+
375
+
### Adding New Features
376
+
1. **New routes**: Add to `/src/routes/*.ts`, import in index.ts
377
+
2. **DB changes**: Add migration in db.ts
378
+
3. **New lexicons**: Update `/lexicons/*.json`, regenerate types
379
+
4. **Admin features**: Add to /api/admin endpoints
380
+
381
+
### Testing
382
+
- Run with `bun test`
383
+
- CSRF tests in lib/csrf.test.ts
384
+
- Utility tests in lib/wisp-utils.test.ts
385
+
386
+
### Debugging
387
+
- Check logs via `/api/admin/logs` (requires admin auth)
388
+
- DNS verification manual trigger: POST /api/admin/verify-dns
389
+
- Health check: GET /api/health (includes DNS verifier status)
390
+
391
+
---
392
+
393
+
## ๐ Deployment Considerations
394
+
395
+
1. **Secrets**: Admin password, OAuth keys, database credentials
396
+
2. **HTTPS**: Required (HSTS header enforces it)
397
+
3. **CDN**: Custom domains require DNS configuration
398
+
4. **Scaling**:
399
+
- Main server: Horizontal scaling with session DB
400
+
- Hosting service: Independent scaling, disk cache per instance
401
+
5. **Backups**: PostgreSQL database critical; firehose provides recovery
402
+
403
+
---
404
+
405
+
## ๐ Related Technologies
406
+
407
+
- **AT Protocol**: Decentralized identity, OAuth 2.0
408
+
- **Jacquard**: Rust library for AT Protocol interactions
409
+
- **Elysia**: Bun web framework (similar to Express/Hono)
410
+
- **Lexicon**: AT Protocol's schema definition language
411
+
- **Firehose**: Real-time event stream of repo changes
412
+
- **PDS**: Personal Data Server (where users' data stored)
413
+
414
+
---
415
+
416
+
## ๐ฏ Project Goals
417
+
418
+
โ
Decentralized site hosting (data owned by users)
419
+
โ
Custom domain support with DNS verification
420
+
โ
Fast CDN distribution via hosting service
421
+
โ
Developer tools (CLI + API)
422
+
โ
Admin dashboard for monitoring
423
+
โ
Zero user data retention (sites in PDS, sessions in DB only)
424
+
425
+
---
426
+
427
+
**Last Updated**: November 2025
428
+
**Status**: Active development
+25
cli/.gitignore
+25
cli/.gitignore
···
1
+
test/
2
+
.DS_STORE
3
+
jacquard/
4
+
binaries/
5
+
# Generated by Cargo
6
+
# will have compiled files and executables
7
+
debug
8
+
target
9
+
10
+
# These are backup files generated by rustfmt
11
+
**/*.rs.bk
12
+
13
+
# MSVC Windows builds of rustc generate these, which store debugging information
14
+
*.pdb
15
+
16
+
# Generated by cargo mutants
17
+
# Contains mutation testing data
18
+
**/mutants.out*/
19
+
20
+
# RustRover
21
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
22
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
23
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
24
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
25
+
#.idea/
+663
-311
cli/Cargo.lock
+663
-311
cli/Cargo.lock
···
139
139
140
140
[[package]]
141
141
name = "async-compression"
142
-
version = "0.4.32"
142
+
version = "0.4.33"
143
143
source = "registry+https://github.com/rust-lang/crates.io-index"
144
-
checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0"
144
+
checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2"
145
145
dependencies = [
146
146
"compression-codecs",
147
147
"compression-core",
···
151
151
]
152
152
153
153
[[package]]
154
-
name = "async-lock"
155
-
version = "3.4.1"
156
-
source = "registry+https://github.com/rust-lang/crates.io-index"
157
-
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
158
-
dependencies = [
159
-
"event-listener",
160
-
"event-listener-strategy",
161
-
"pin-project-lite",
162
-
]
163
-
164
-
[[package]]
165
154
name = "async-trait"
166
155
version = "0.1.89"
167
156
source = "registry+https://github.com/rust-lang/crates.io-index"
···
169
158
dependencies = [
170
159
"proc-macro2",
171
160
"quote",
172
-
"syn 2.0.108",
161
+
"syn 2.0.110",
173
162
]
174
163
175
164
[[package]]
···
185
174
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
186
175
187
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]]
188
232
name = "backtrace"
189
233
version = "0.3.76"
190
234
source = "registry+https://github.com/rust-lang/crates.io-index"
···
285
329
"proc-macro2",
286
330
"quote",
287
331
"rustversion",
288
-
"syn 2.0.108",
332
+
"syn 2.0.110",
289
333
]
290
334
291
335
[[package]]
···
359
403
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
360
404
361
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]]
362
412
name = "bytes"
363
413
version = "1.10.1"
364
414
source = "registry+https://github.com/rust-lang/crates.io-index"
···
378
428
379
429
[[package]]
380
430
name = "cc"
381
-
version = "1.2.44"
431
+
version = "1.2.45"
382
432
source = "registry+https://github.com/rust-lang/crates.io-index"
383
-
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
433
+
checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
384
434
dependencies = [
385
435
"find-msvc-tools",
386
436
"shlex",
···
505
555
"heck 0.5.0",
506
556
"proc-macro2",
507
557
"quote",
508
-
"syn 2.0.108",
558
+
"syn 2.0.110",
509
559
]
510
560
511
561
[[package]]
···
532
582
533
583
[[package]]
534
584
name = "compression-codecs"
535
-
version = "0.4.31"
585
+
version = "0.4.32"
536
586
source = "registry+https://github.com/rust-lang/crates.io-index"
537
-
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
587
+
checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b"
538
588
dependencies = [
539
589
"compression-core",
540
590
"flate2",
···
543
593
544
594
[[package]]
545
595
name = "compression-core"
546
-
version = "0.4.29"
596
+
version = "0.4.30"
547
597
source = "registry+https://github.com/rust-lang/crates.io-index"
548
-
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
549
-
550
-
[[package]]
551
-
name = "concurrent-queue"
552
-
version = "2.5.0"
553
-
source = "registry+https://github.com/rust-lang/crates.io-index"
554
-
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
555
-
dependencies = [
556
-
"crossbeam-utils",
557
-
]
598
+
checksum = "3a9b614a5787ef0c8802a55766480563cb3a93b435898c422ed2a359cf811582"
558
599
559
600
[[package]]
560
601
name = "const-oid"
···
569
610
checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
570
611
571
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]]
572
623
name = "core-foundation"
573
624
version = "0.9.4"
574
625
source = "registry+https://github.com/rust-lang/crates.io-index"
···
579
630
]
580
631
581
632
[[package]]
633
+
name = "core-foundation"
634
+
version = "0.10.1"
635
+
source = "registry+https://github.com/rust-lang/crates.io-index"
636
+
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
637
+
dependencies = [
638
+
"core-foundation-sys",
639
+
"libc",
640
+
]
641
+
642
+
[[package]]
582
643
name = "core-foundation-sys"
583
644
version = "0.8.7"
584
645
source = "registry+https://github.com/rust-lang/crates.io-index"
···
621
682
]
622
683
623
684
[[package]]
624
-
name = "crossbeam-epoch"
625
-
version = "0.9.18"
626
-
source = "registry+https://github.com/rust-lang/crates.io-index"
627
-
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
628
-
dependencies = [
629
-
"crossbeam-utils",
630
-
]
631
-
632
-
[[package]]
633
685
name = "crossbeam-utils"
634
686
version = "0.8.21"
635
687
source = "registry+https://github.com/rust-lang/crates.io-index"
···
684
736
"proc-macro2",
685
737
"quote",
686
738
"strsim",
687
-
"syn 2.0.108",
739
+
"syn 2.0.110",
688
740
]
689
741
690
742
[[package]]
···
695
747
dependencies = [
696
748
"darling_core",
697
749
"quote",
698
-
"syn 2.0.108",
750
+
"syn 2.0.110",
699
751
]
700
752
701
753
[[package]]
···
735
787
checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976"
736
788
dependencies = [
737
789
"data-encoding",
738
-
"syn 2.0.108",
790
+
"syn 2.0.110",
739
791
]
740
792
741
793
[[package]]
···
770
822
]
771
823
772
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]]
773
852
name = "digest"
774
853
version = "0.10.7"
775
854
source = "registry+https://github.com/rust-lang/crates.io-index"
···
810
889
dependencies = [
811
890
"proc-macro2",
812
891
"quote",
813
-
"syn 2.0.108",
892
+
"syn 2.0.110",
814
893
]
815
894
816
895
[[package]]
···
871
950
"heck 0.5.0",
872
951
"proc-macro2",
873
952
"quote",
874
-
"syn 2.0.108",
953
+
"syn 2.0.110",
875
954
]
876
955
877
956
[[package]]
···
891
970
]
892
971
893
972
[[package]]
894
-
name = "event-listener"
895
-
version = "5.4.1"
896
-
source = "registry+https://github.com/rust-lang/crates.io-index"
897
-
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
898
-
dependencies = [
899
-
"concurrent-queue",
900
-
"parking",
901
-
"pin-project-lite",
902
-
]
903
-
904
-
[[package]]
905
-
name = "event-listener-strategy"
906
-
version = "0.5.4"
907
-
source = "registry+https://github.com/rust-lang/crates.io-index"
908
-
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
909
-
dependencies = [
910
-
"event-listener",
911
-
"pin-project-lite",
912
-
]
913
-
914
-
[[package]]
915
973
name = "fastrand"
916
974
version = "2.3.0"
917
975
source = "registry+https://github.com/rust-lang/crates.io-index"
···
962
1020
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
963
1021
964
1022
[[package]]
965
-
name = "foreign-types"
966
-
version = "0.3.2"
967
-
source = "registry+https://github.com/rust-lang/crates.io-index"
968
-
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
969
-
dependencies = [
970
-
"foreign-types-shared",
971
-
]
972
-
973
-
[[package]]
974
-
name = "foreign-types-shared"
975
-
version = "0.1.1"
976
-
source = "registry+https://github.com/rust-lang/crates.io-index"
977
-
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
978
-
979
-
[[package]]
980
1023
name = "form_urlencoded"
981
1024
version = "1.2.2"
982
1025
source = "registry+https://github.com/rust-lang/crates.io-index"
···
996
1039
]
997
1040
998
1041
[[package]]
1042
+
name = "futures"
1043
+
version = "0.3.31"
1044
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1045
+
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
1046
+
dependencies = [
1047
+
"futures-channel",
1048
+
"futures-core",
1049
+
"futures-executor",
1050
+
"futures-io",
1051
+
"futures-sink",
1052
+
"futures-task",
1053
+
"futures-util",
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]]
999
1070
name = "futures-channel"
1000
1071
version = "0.3.31"
1001
1072
source = "registry+https://github.com/rust-lang/crates.io-index"
1002
1073
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
1003
1074
dependencies = [
1004
1075
"futures-core",
1076
+
"futures-sink",
1005
1077
]
1006
1078
1007
1079
[[package]]
···
1011
1083
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
1012
1084
1013
1085
[[package]]
1086
+
name = "futures-executor"
1087
+
version = "0.3.31"
1088
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1089
+
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
1090
+
dependencies = [
1091
+
"futures-core",
1092
+
"futures-task",
1093
+
"futures-util",
1094
+
]
1095
+
1096
+
[[package]]
1014
1097
name = "futures-io"
1015
1098
version = "0.3.31"
1016
1099
source = "registry+https://github.com/rust-lang/crates.io-index"
1017
1100
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
1018
1101
1019
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]]
1020
1116
name = "futures-macro"
1021
1117
version = "0.3.31"
1022
1118
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1024
1120
dependencies = [
1025
1121
"proc-macro2",
1026
1122
"quote",
1027
-
"syn 2.0.108",
1123
+
"syn 2.0.110",
1028
1124
]
1029
1125
1030
1126
[[package]]
···
1045
1141
source = "registry+https://github.com/rust-lang/crates.io-index"
1046
1142
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
1047
1143
dependencies = [
1144
+
"futures-channel",
1048
1145
"futures-core",
1049
1146
"futures-io",
1050
1147
"futures-macro",
···
1054
1151
"pin-project-lite",
1055
1152
"pin-utils",
1056
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",
1057
1168
]
1058
1169
1059
1170
[[package]]
···
1253
1364
]
1254
1365
1255
1366
[[package]]
1256
-
name = "home"
1257
-
version = "0.5.12"
1258
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1259
-
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
1260
-
dependencies = [
1261
-
"windows-sys 0.61.2",
1262
-
]
1263
-
1264
-
[[package]]
1265
1367
name = "html5ever"
1266
1368
version = "0.27.0"
1267
1369
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1272
1374
"markup5ever",
1273
1375
"proc-macro2",
1274
1376
"quote",
1275
-
"syn 2.0.108",
1377
+
"syn 2.0.110",
1276
1378
]
1277
1379
1278
1380
[[package]]
···
1310
1412
]
1311
1413
1312
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]]
1313
1421
name = "httparse"
1314
1422
version = "1.10.1"
1315
1423
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1323
1431
1324
1432
[[package]]
1325
1433
name = "hyper"
1326
-
version = "1.7.0"
1434
+
version = "1.8.0"
1327
1435
source = "registry+https://github.com/rust-lang/crates.io-index"
1328
-
checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e"
1436
+
checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f"
1329
1437
dependencies = [
1330
1438
"atomic-waker",
1331
1439
"bytes",
···
1335
1443
"http",
1336
1444
"http-body",
1337
1445
"httparse",
1446
+
"httpdate",
1338
1447
"itoa",
1339
1448
"pin-project-lite",
1340
1449
"pin-utils",
···
1361
1470
]
1362
1471
1363
1472
[[package]]
1364
-
name = "hyper-tls"
1365
-
version = "0.6.0"
1366
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1367
-
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
1368
-
dependencies = [
1369
-
"bytes",
1370
-
"http-body-util",
1371
-
"hyper",
1372
-
"hyper-util",
1373
-
"native-tls",
1374
-
"tokio",
1375
-
"tokio-native-tls",
1376
-
"tower-service",
1377
-
]
1378
-
1379
-
[[package]]
1380
1473
name = "hyper-util"
1381
1474
version = "0.1.17"
1382
1475
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1414
1507
"js-sys",
1415
1508
"log",
1416
1509
"wasm-bindgen",
1417
-
"windows-core",
1510
+
"windows-core 0.62.2",
1418
1511
]
1419
1512
1420
1513
[[package]]
···
1606
1699
1607
1700
[[package]]
1608
1701
name = "iri-string"
1609
-
version = "0.7.8"
1702
+
version = "0.7.9"
1610
1703
source = "registry+https://github.com/rust-lang/crates.io-index"
1611
-
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
1704
+
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
1612
1705
dependencies = [
1613
1706
"memchr",
1614
1707
"serde",
···
1634
1727
1635
1728
[[package]]
1636
1729
name = "jacquard"
1637
-
version = "0.8.0"
1730
+
version = "0.9.0"
1731
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1638
1732
dependencies = [
1639
1733
"bytes",
1640
1734
"getrandom 0.2.16",
···
1661
1755
1662
1756
[[package]]
1663
1757
name = "jacquard-api"
1664
-
version = "0.8.0"
1758
+
version = "0.9.0"
1759
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1665
1760
dependencies = [
1666
1761
"bon",
1667
1762
"bytes",
···
1678
1773
1679
1774
[[package]]
1680
1775
name = "jacquard-common"
1681
-
version = "0.8.0"
1776
+
version = "0.9.0"
1777
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1682
1778
dependencies = [
1683
1779
"base64 0.22.1",
1684
1780
"bon",
1685
1781
"bytes",
1686
1782
"chrono",
1783
+
"ciborium",
1687
1784
"cid",
1785
+
"futures",
1688
1786
"getrandom 0.2.16",
1689
1787
"getrandom 0.3.4",
1690
1788
"http",
···
1694
1792
"miette",
1695
1793
"multibase",
1696
1794
"multihash",
1795
+
"n0-future",
1697
1796
"ouroboros",
1698
1797
"p256",
1699
1798
"rand 0.9.2",
···
1707
1806
"smol_str",
1708
1807
"thiserror 2.0.17",
1709
1808
"tokio",
1809
+
"tokio-tungstenite-wasm",
1710
1810
"tokio-util",
1711
1811
"trait-variant",
1712
1812
"url",
···
1714
1814
1715
1815
[[package]]
1716
1816
name = "jacquard-derive"
1717
-
version = "0.8.0"
1817
+
version = "0.9.0"
1818
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1718
1819
dependencies = [
1719
1820
"heck 0.5.0",
1720
1821
"jacquard-lexicon",
1721
1822
"proc-macro2",
1722
1823
"quote",
1723
-
"syn 2.0.108",
1824
+
"syn 2.0.110",
1724
1825
]
1725
1826
1726
1827
[[package]]
1727
1828
name = "jacquard-identity"
1728
-
version = "0.8.0"
1829
+
version = "0.9.1"
1830
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1729
1831
dependencies = [
1730
1832
"bon",
1731
1833
"bytes",
···
1735
1837
"jacquard-common",
1736
1838
"jacquard-lexicon",
1737
1839
"miette",
1738
-
"moka",
1840
+
"mini-moka",
1739
1841
"percent-encoding",
1740
1842
"reqwest",
1741
1843
"serde",
···
1750
1852
1751
1853
[[package]]
1752
1854
name = "jacquard-lexicon"
1753
-
version = "0.8.0"
1855
+
version = "0.9.1"
1856
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1754
1857
dependencies = [
1755
1858
"cid",
1756
1859
"dashmap",
···
1768
1871
"serde_repr",
1769
1872
"serde_with",
1770
1873
"sha2",
1771
-
"syn 2.0.108",
1874
+
"syn 2.0.110",
1772
1875
"thiserror 2.0.17",
1773
1876
"unicode-segmentation",
1774
1877
]
1775
1878
1776
1879
[[package]]
1777
1880
name = "jacquard-oauth"
1778
-
version = "0.8.0"
1881
+
version = "0.9.0"
1882
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#5c79bb76de544cbd4fa8d5d8b01ba6e828f8ba65"
1779
1883
dependencies = [
1780
1884
"base64 0.22.1",
1781
1885
"bytes",
···
1901
2005
source = "registry+https://github.com/rust-lang/crates.io-index"
1902
2006
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1903
2007
dependencies = [
1904
-
"spin",
2008
+
"spin 0.9.8",
1905
2009
]
1906
2010
1907
2011
[[package]]
···
1961
2065
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
1962
2066
1963
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]]
1964
2081
name = "lru-cache"
1965
2082
version = "0.1.2"
1966
2083
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1982
2099
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
1983
2100
1984
2101
[[package]]
1985
-
name = "malloc_buf"
1986
-
version = "0.0.6"
1987
-
source = "registry+https://github.com/rust-lang/crates.io-index"
1988
-
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
1989
-
dependencies = [
1990
-
"libc",
1991
-
]
1992
-
1993
-
[[package]]
1994
2102
name = "markup5ever"
1995
2103
version = "0.12.1"
1996
2104
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2028
2136
]
2029
2137
2030
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]]
2031
2154
name = "memchr"
2032
2155
version = "2.7.6"
2033
2156
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2060
2183
dependencies = [
2061
2184
"proc-macro2",
2062
2185
"quote",
2063
-
"syn 2.0.108",
2186
+
"syn 2.0.110",
2064
2187
]
2065
2188
2066
2189
[[package]]
···
2080
2203
]
2081
2204
2082
2205
[[package]]
2206
+
name = "mini-moka"
2207
+
version = "0.11.0"
2208
+
source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d"
2209
+
dependencies = [
2210
+
"crossbeam-channel",
2211
+
"crossbeam-utils",
2212
+
"dashmap",
2213
+
"smallvec",
2214
+
"tagptr",
2215
+
"triomphe",
2216
+
"web-time",
2217
+
]
2218
+
2219
+
[[package]]
2083
2220
name = "minimal-lexical"
2084
2221
version = "0.2.1"
2085
2222
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2107
2244
]
2108
2245
2109
2246
[[package]]
2110
-
name = "moka"
2111
-
version = "0.12.11"
2112
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2113
-
checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077"
2114
-
dependencies = [
2115
-
"async-lock",
2116
-
"crossbeam-channel",
2117
-
"crossbeam-epoch",
2118
-
"crossbeam-utils",
2119
-
"equivalent",
2120
-
"event-listener",
2121
-
"futures-util",
2122
-
"parking_lot",
2123
-
"portable-atomic",
2124
-
"rustc_version",
2125
-
"smallvec",
2126
-
"tagptr",
2127
-
"uuid",
2128
-
]
2129
-
2130
-
[[package]]
2131
2247
name = "multibase"
2132
2248
version = "0.9.2"
2133
2249
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2169
2285
]
2170
2286
2171
2287
[[package]]
2172
-
name = "native-tls"
2173
-
version = "0.2.14"
2288
+
name = "n0-future"
2289
+
version = "0.1.3"
2174
2290
source = "registry+https://github.com/rust-lang/crates.io-index"
2175
-
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
2291
+
checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794"
2176
2292
dependencies = [
2177
-
"libc",
2178
-
"log",
2179
-
"openssl",
2180
-
"openssl-probe",
2181
-
"openssl-sys",
2182
-
"schannel",
2183
-
"security-framework",
2184
-
"security-framework-sys",
2185
-
"tempfile",
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",
2186
2306
]
2187
2307
2188
2308
[[package]]
···
2208
2328
]
2209
2329
2210
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]]
2211
2340
name = "num-bigint-dig"
2212
-
version = "0.8.5"
2341
+
version = "0.8.6"
2213
2342
source = "registry+https://github.com/rust-lang/crates.io-index"
2214
-
checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b"
2343
+
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
2215
2344
dependencies = [
2216
2345
"lazy_static",
2217
2346
"libm",
···
2279
2408
]
2280
2409
2281
2410
[[package]]
2282
-
name = "objc"
2283
-
version = "0.2.7"
2411
+
name = "objc2"
2412
+
version = "0.6.3"
2284
2413
source = "registry+https://github.com/rust-lang/crates.io-index"
2285
-
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
2414
+
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
2286
2415
dependencies = [
2287
-
"malloc_buf",
2416
+
"objc2-encode",
2417
+
]
2418
+
2419
+
[[package]]
2420
+
name = "objc2-encode"
2421
+
version = "4.1.0"
2422
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2423
+
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
2424
+
2425
+
[[package]]
2426
+
name = "objc2-foundation"
2427
+
version = "0.3.2"
2428
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2429
+
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
2430
+
dependencies = [
2431
+
"bitflags",
2432
+
"objc2",
2288
2433
]
2289
2434
2290
2435
[[package]]
···
2309
2454
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
2310
2455
2311
2456
[[package]]
2312
-
name = "openssl"
2313
-
version = "0.10.74"
2314
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2315
-
checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
2316
-
dependencies = [
2317
-
"bitflags",
2318
-
"cfg-if",
2319
-
"foreign-types",
2320
-
"libc",
2321
-
"once_cell",
2322
-
"openssl-macros",
2323
-
"openssl-sys",
2324
-
]
2325
-
2326
-
[[package]]
2327
-
name = "openssl-macros"
2328
-
version = "0.1.1"
2329
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2330
-
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
2331
-
dependencies = [
2332
-
"proc-macro2",
2333
-
"quote",
2334
-
"syn 2.0.108",
2335
-
]
2336
-
2337
-
[[package]]
2338
2457
name = "openssl-probe"
2339
2458
version = "0.1.6"
2340
2459
source = "registry+https://github.com/rust-lang/crates.io-index"
2341
2460
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
2342
-
2343
-
[[package]]
2344
-
name = "openssl-sys"
2345
-
version = "0.9.110"
2346
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2347
-
checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
2348
-
dependencies = [
2349
-
"cc",
2350
-
"libc",
2351
-
"pkg-config",
2352
-
"vcpkg",
2353
-
]
2354
2461
2355
2462
[[package]]
2356
2463
name = "option-ext"
···
2379
2486
"proc-macro2",
2380
2487
"proc-macro2-diagnostics",
2381
2488
"quote",
2382
-
"syn 2.0.108",
2489
+
"syn 2.0.110",
2383
2490
]
2384
2491
2385
2492
[[package]]
···
2493
2600
]
2494
2601
2495
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]]
2496
2623
name = "pin-project-lite"
2497
2624
version = "0.2.16"
2498
2625
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2526
2653
]
2527
2654
2528
2655
[[package]]
2529
-
name = "pkg-config"
2530
-
version = "0.3.32"
2531
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2532
-
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
2533
-
2534
-
[[package]]
2535
-
name = "portable-atomic"
2536
-
version = "1.11.1"
2537
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2538
-
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
2539
-
2540
-
[[package]]
2541
2656
name = "potential_utf"
2542
2657
version = "0.1.4"
2543
2658
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2574
2689
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
2575
2690
dependencies = [
2576
2691
"proc-macro2",
2577
-
"syn 2.0.108",
2692
+
"syn 2.0.110",
2578
2693
]
2579
2694
2580
2695
[[package]]
···
2627
2742
dependencies = [
2628
2743
"proc-macro2",
2629
2744
"quote",
2630
-
"syn 2.0.108",
2745
+
"syn 2.0.110",
2631
2746
"version_check",
2632
2747
"yansi",
2633
2748
]
···
2695
2810
2696
2811
[[package]]
2697
2812
name = "quote"
2698
-
version = "1.0.41"
2813
+
version = "1.0.42"
2699
2814
source = "registry+https://github.com/rust-lang/crates.io-index"
2700
-
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
2815
+
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
2701
2816
dependencies = [
2702
2817
"proc-macro2",
2703
2818
]
···
2774
2889
checksum = "d20581732dd76fa913c7dff1a2412b714afe3573e94d41c34719de73337cc8ab"
2775
2890
2776
2891
[[package]]
2777
-
name = "raw-window-handle"
2778
-
version = "0.5.2"
2779
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2780
-
checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9"
2781
-
2782
-
[[package]]
2783
2892
name = "redox_syscall"
2784
2893
version = "0.5.18"
2785
2894
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2816
2925
dependencies = [
2817
2926
"proc-macro2",
2818
2927
"quote",
2819
-
"syn 2.0.108",
2928
+
"syn 2.0.110",
2820
2929
]
2821
2930
2822
2931
[[package]]
···
2866
2975
"http-body-util",
2867
2976
"hyper",
2868
2977
"hyper-rustls",
2869
-
"hyper-tls",
2870
2978
"hyper-util",
2871
2979
"js-sys",
2872
2980
"log",
2873
2981
"mime",
2874
-
"native-tls",
2875
2982
"percent-encoding",
2876
2983
"pin-project-lite",
2877
2984
"quinn",
···
2882
2989
"serde_urlencoded",
2883
2990
"sync_wrapper",
2884
2991
"tokio",
2885
-
"tokio-native-tls",
2886
2992
"tokio-rustls",
2887
2993
"tokio-util",
2888
-
"tower",
2889
-
"tower-http",
2994
+
"tower 0.5.2",
2995
+
"tower-http 0.6.6",
2890
2996
"tower-service",
2891
2997
"url",
2892
2998
"wasm-bindgen",
···
2983
3089
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
2984
3090
2985
3091
[[package]]
2986
-
name = "rustc_version"
2987
-
version = "0.4.1"
2988
-
source = "registry+https://github.com/rust-lang/crates.io-index"
2989
-
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2990
-
dependencies = [
2991
-
"semver",
2992
-
]
2993
-
2994
-
[[package]]
2995
3092
name = "rustix"
2996
3093
version = "1.1.2"
2997
3094
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3006
3103
3007
3104
[[package]]
3008
3105
name = "rustls"
3009
-
version = "0.23.34"
3106
+
version = "0.23.35"
3010
3107
source = "registry+https://github.com/rust-lang/crates.io-index"
3011
-
checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
3108
+
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
3012
3109
dependencies = [
3013
3110
"once_cell",
3014
3111
"ring",
···
3019
3116
]
3020
3117
3021
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]]
3022
3131
name = "rustls-pki-types"
3023
3132
version = "1.13.0"
3024
3133
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3089
3198
3090
3199
[[package]]
3091
3200
name = "schemars"
3092
-
version = "1.0.4"
3201
+
version = "1.1.0"
3093
3202
source = "registry+https://github.com/rust-lang/crates.io-index"
3094
-
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
3203
+
checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289"
3095
3204
dependencies = [
3096
3205
"dyn-clone",
3097
3206
"ref-cast",
3098
3207
"serde",
3099
3208
"serde_json",
3100
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"
3101
3216
3102
3217
[[package]]
3103
3218
name = "scopeguard"
···
3121
3236
3122
3237
[[package]]
3123
3238
name = "security-framework"
3124
-
version = "2.11.1"
3239
+
version = "3.5.1"
3125
3240
source = "registry+https://github.com/rust-lang/crates.io-index"
3126
-
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
3241
+
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
3127
3242
dependencies = [
3128
3243
"bitflags",
3129
-
"core-foundation",
3244
+
"core-foundation 0.10.1",
3130
3245
"core-foundation-sys",
3131
3246
"libc",
3132
3247
"security-framework-sys",
···
3143
3258
]
3144
3259
3145
3260
[[package]]
3146
-
name = "semver"
3147
-
version = "1.0.27"
3261
+
name = "send_wrapper"
3262
+
version = "0.6.0"
3148
3263
source = "registry+https://github.com/rust-lang/crates.io-index"
3149
-
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
3264
+
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
3150
3265
3151
3266
[[package]]
3152
3267
name = "serde"
···
3185
3300
dependencies = [
3186
3301
"proc-macro2",
3187
3302
"quote",
3188
-
"syn 2.0.108",
3303
+
"syn 2.0.110",
3189
3304
]
3190
3305
3191
3306
[[package]]
···
3227
3342
]
3228
3343
3229
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]]
3230
3356
name = "serde_repr"
3231
3357
version = "0.1.20"
3232
3358
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3234
3360
dependencies = [
3235
3361
"proc-macro2",
3236
3362
"quote",
3237
-
"syn 2.0.108",
3363
+
"syn 2.0.110",
3238
3364
]
3239
3365
3240
3366
[[package]]
···
3261
3387
"indexmap 1.9.3",
3262
3388
"indexmap 2.12.0",
3263
3389
"schemars 0.9.0",
3264
-
"schemars 1.0.4",
3390
+
"schemars 1.1.0",
3265
3391
"serde_core",
3266
3392
"serde_json",
3267
3393
"serde_with_macros",
···
3277
3403
"darling",
3278
3404
"proc-macro2",
3279
3405
"quote",
3280
-
"syn 2.0.108",
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",
3281
3418
]
3282
3419
3283
3420
[[package]]
···
3298
3435
]
3299
3436
3300
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]]
3301
3447
name = "shellexpand"
3302
3448
version = "3.1.1"
3303
3449
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3390
3536
version = "0.9.8"
3391
3537
source = "registry+https://github.com/rust-lang/crates.io-index"
3392
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"
3393
3545
3394
3546
[[package]]
3395
3547
name = "spki"
···
3423
3575
"quote",
3424
3576
"serde",
3425
3577
"sha2",
3426
-
"syn 2.0.108",
3578
+
"syn 2.0.110",
3427
3579
"thiserror 1.0.69",
3428
3580
]
3429
3581
···
3504
3656
3505
3657
[[package]]
3506
3658
name = "syn"
3507
-
version = "2.0.108"
3659
+
version = "2.0.110"
3508
3660
source = "registry+https://github.com/rust-lang/crates.io-index"
3509
-
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
3661
+
checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
3510
3662
dependencies = [
3511
3663
"proc-macro2",
3512
3664
"quote",
···
3530
3682
dependencies = [
3531
3683
"proc-macro2",
3532
3684
"quote",
3533
-
"syn 2.0.108",
3685
+
"syn 2.0.110",
3534
3686
]
3535
3687
3536
3688
[[package]]
···
3540
3692
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
3541
3693
dependencies = [
3542
3694
"bitflags",
3543
-
"core-foundation",
3695
+
"core-foundation 0.9.4",
3544
3696
"system-configuration-sys",
3545
3697
]
3546
3698
···
3630
3782
dependencies = [
3631
3783
"proc-macro2",
3632
3784
"quote",
3633
-
"syn 2.0.108",
3785
+
"syn 2.0.110",
3634
3786
]
3635
3787
3636
3788
[[package]]
···
3641
3793
dependencies = [
3642
3794
"proc-macro2",
3643
3795
"quote",
3644
-
"syn 2.0.108",
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",
3645
3806
]
3646
3807
3647
3808
[[package]]
···
3748
3909
dependencies = [
3749
3910
"proc-macro2",
3750
3911
"quote",
3751
-
"syn 2.0.108",
3912
+
"syn 2.0.110",
3752
3913
]
3753
3914
3754
3915
[[package]]
3755
-
name = "tokio-native-tls"
3756
-
version = "0.3.1"
3916
+
name = "tokio-rustls"
3917
+
version = "0.26.4"
3757
3918
source = "registry+https://github.com/rust-lang/crates.io-index"
3758
-
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
3919
+
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
3759
3920
dependencies = [
3760
-
"native-tls",
3921
+
"rustls",
3761
3922
"tokio",
3762
3923
]
3763
3924
3764
3925
[[package]]
3765
-
name = "tokio-rustls"
3766
-
version = "0.26.4"
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"
3767
3944
source = "registry+https://github.com/rust-lang/crates.io-index"
3768
-
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
3945
+
checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae"
3769
3946
dependencies = [
3947
+
"futures-channel",
3948
+
"futures-util",
3949
+
"http",
3950
+
"httparse",
3951
+
"js-sys",
3770
3952
"rustls",
3953
+
"thiserror 1.0.69",
3771
3954
"tokio",
3955
+
"tokio-tungstenite",
3956
+
"wasm-bindgen",
3957
+
"web-sys",
3772
3958
]
3773
3959
3774
3960
[[package]]
3775
3961
name = "tokio-util"
3776
-
version = "0.7.16"
3962
+
version = "0.7.17"
3777
3963
source = "registry+https://github.com/rust-lang/crates.io-index"
3778
-
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
3964
+
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
3779
3965
dependencies = [
3780
3966
"bytes",
3781
3967
"futures-core",
3782
3968
"futures-sink",
3969
+
"futures-util",
3783
3970
"pin-project-lite",
3784
3971
"tokio",
3785
3972
]
3786
3973
3787
3974
[[package]]
3788
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"
3789
3987
version = "0.5.2"
3790
3988
source = "registry+https://github.com/rust-lang/crates.io-index"
3791
3989
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
···
3797
3995
"tokio",
3798
3996
"tower-layer",
3799
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",
3800
4026
]
3801
4027
3802
4028
[[package]]
···
3812
4038
"http-body",
3813
4039
"iri-string",
3814
4040
"pin-project-lite",
3815
-
"tower",
4041
+
"tower 0.5.2",
3816
4042
"tower-layer",
3817
4043
"tower-service",
3818
4044
]
···
3835
4061
source = "registry+https://github.com/rust-lang/crates.io-index"
3836
4062
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
3837
4063
dependencies = [
4064
+
"log",
3838
4065
"pin-project-lite",
3839
4066
"tracing-attributes",
3840
4067
"tracing-core",
···
3848
4075
dependencies = [
3849
4076
"proc-macro2",
3850
4077
"quote",
3851
-
"syn 2.0.108",
4078
+
"syn 2.0.110",
3852
4079
]
3853
4080
3854
4081
[[package]]
···
3858
4085
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
3859
4086
dependencies = [
3860
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",
3861
4118
]
3862
4119
3863
4120
[[package]]
···
3868
4125
dependencies = [
3869
4126
"proc-macro2",
3870
4127
"quote",
3871
-
"syn 2.0.108",
4128
+
"syn 2.0.110",
3872
4129
]
3873
4130
3874
4131
[[package]]
4132
+
name = "triomphe"
4133
+
version = "0.1.15"
4134
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4135
+
checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
4136
+
4137
+
[[package]]
3875
4138
name = "try-lock"
3876
4139
version = "0.2.5"
3877
4140
source = "registry+https://github.com/rust-lang/crates.io-index"
3878
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
+
]
3879
4162
3880
4163
[[package]]
3881
4164
name = "twoway"
···
3929
4212
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
3930
4213
3931
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]]
3932
4221
name = "unsigned-varint"
3933
4222
version = "0.8.0"
3934
4223
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3977
4266
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
3978
4267
3979
4268
[[package]]
3980
-
name = "uuid"
3981
-
version = "1.18.1"
4269
+
name = "valuable"
4270
+
version = "0.1.1"
3982
4271
source = "registry+https://github.com/rust-lang/crates.io-index"
3983
-
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
3984
-
dependencies = [
3985
-
"getrandom 0.3.4",
3986
-
"js-sys",
3987
-
"wasm-bindgen",
3988
-
]
3989
-
3990
-
[[package]]
3991
-
name = "vcpkg"
3992
-
version = "0.2.15"
3993
-
source = "registry+https://github.com/rust-lang/crates.io-index"
3994
-
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
4272
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
3995
4273
3996
4274
[[package]]
3997
4275
name = "version_check"
···
4078
4356
"bumpalo",
4079
4357
"proc-macro2",
4080
4358
"quote",
4081
-
"syn 2.0.108",
4359
+
"syn 2.0.110",
4082
4360
"wasm-bindgen-shared",
4083
4361
]
4084
4362
···
4126
4404
4127
4405
[[package]]
4128
4406
name = "webbrowser"
4129
-
version = "0.8.15"
4407
+
version = "1.0.6"
4130
4408
source = "registry+https://github.com/rust-lang/crates.io-index"
4131
-
checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b"
4409
+
checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97"
4132
4410
dependencies = [
4133
-
"core-foundation",
4134
-
"home",
4411
+
"core-foundation 0.10.1",
4135
4412
"jni",
4136
4413
"log",
4137
4414
"ndk-context",
4138
-
"objc",
4139
-
"raw-window-handle",
4415
+
"objc2",
4416
+
"objc2-foundation",
4140
4417
"url",
4141
4418
"web-sys",
4142
4419
]
···
4178
4455
]
4179
4456
4180
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]]
4181
4493
name = "windows-core"
4182
4494
version = "0.62.2"
4183
4495
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4191
4503
]
4192
4504
4193
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]]
4194
4517
name = "windows-implement"
4195
4518
version = "0.60.2"
4196
4519
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4198
4521
dependencies = [
4199
4522
"proc-macro2",
4200
4523
"quote",
4201
-
"syn 2.0.108",
4524
+
"syn 2.0.110",
4202
4525
]
4203
4526
4204
4527
[[package]]
···
4209
4532
dependencies = [
4210
4533
"proc-macro2",
4211
4534
"quote",
4212
-
"syn 2.0.108",
4535
+
"syn 2.0.110",
4213
4536
]
4214
4537
4215
4538
[[package]]
···
4225
4548
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
4226
4549
4227
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]]
4228
4561
name = "windows-registry"
4229
4562
version = "0.5.3"
4230
4563
source = "registry+https://github.com/rust-lang/crates.io-index"
···
4377
4710
"windows_x86_64_gnu 0.53.1",
4378
4711
"windows_x86_64_gnullvm 0.53.1",
4379
4712
"windows_x86_64_msvc 0.53.1",
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",
4380
4722
]
4381
4723
4382
4724
[[package]]
···
4571
4913
4572
4914
[[package]]
4573
4915
name = "wisp-cli"
4574
-
version = "0.1.0"
4916
+
version = "0.2.0"
4575
4917
dependencies = [
4918
+
"axum",
4576
4919
"base64 0.22.1",
4577
4920
"bytes",
4921
+
"chrono",
4578
4922
"clap",
4579
4923
"flate2",
4924
+
"futures",
4580
4925
"jacquard",
4581
4926
"jacquard-api",
4582
4927
"jacquard-common",
···
4586
4931
"jacquard-oauth",
4587
4932
"miette",
4588
4933
"mime_guess",
4934
+
"multibase",
4935
+
"multihash",
4936
+
"n0-future",
4589
4937
"reqwest",
4590
4938
"rustversion",
4591
4939
"serde",
4592
4940
"serde_json",
4941
+
"sha2",
4593
4942
"shellexpand",
4594
4943
"tokio",
4944
+
"tower 0.4.13",
4945
+
"tower-http 0.5.2",
4946
+
"url",
4595
4947
"walkdir",
4596
4948
]
4597
4949
···
4643
4995
dependencies = [
4644
4996
"proc-macro2",
4645
4997
"quote",
4646
-
"syn 2.0.108",
4998
+
"syn 2.0.110",
4647
4999
"synstructure",
4648
5000
]
4649
5001
···
4664
5016
dependencies = [
4665
5017
"proc-macro2",
4666
5018
"quote",
4667
-
"syn 2.0.108",
5019
+
"syn 2.0.110",
4668
5020
]
4669
5021
4670
5022
[[package]]
···
4684
5036
dependencies = [
4685
5037
"proc-macro2",
4686
5038
"quote",
4687
-
"syn 2.0.108",
5039
+
"syn 2.0.110",
4688
5040
"synstructure",
4689
5041
]
4690
5042
···
4727
5079
dependencies = [
4728
5080
"proc-macro2",
4729
5081
"quote",
4730
-
"syn 2.0.108",
5082
+
"syn 2.0.110",
4731
5083
]
+20
-9
cli/Cargo.toml
+20
-9
cli/Cargo.toml
···
1
1
[package]
2
2
name = "wisp-cli"
3
-
version = "0.1.0"
3
+
version = "0.2.0"
4
4
edition = "2024"
5
5
6
6
[features]
···
8
8
place_wisp = []
9
9
10
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" }
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
18
clap = { version = "4.5.51", features = ["derive"] }
19
19
tokio = { version = "1.48", features = ["full"] }
20
20
miette = { version = "7.6.0", features = ["fancy"] }
21
21
serde_json = "1.0.145"
22
22
serde = { version = "1.0", features = ["derive"] }
23
23
shellexpand = "3.1.1"
24
-
reqwest = "0.12"
24
+
#reqwest = "0.12"
25
+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
25
26
rustversion = "1.0"
26
27
flate2 = "1.0"
27
28
base64 = "0.22"
28
29
walkdir = "2.5"
29
30
mime_guess = "2.0"
30
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
+23
cli/build-linux.sh
+23
cli/build-linux.sh
···
1
+
#!/usr/bin/env bash
2
+
# Build Linux binaries (statically linked)
3
+
set -e
4
+
mkdir -p binaries
5
+
6
+
# Build Linux binaries
7
+
echo "Building Linux binaries..."
8
+
9
+
echo "Building Linux ARM64 (static)..."
10
+
nix-shell -p rustup --run '
11
+
rustup target add aarch64-unknown-linux-musl
12
+
RUSTFLAGS="-C target-feature=+crt-static" cargo zigbuild --release --target aarch64-unknown-linux-musl
13
+
'
14
+
cp target/aarch64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-aarch64-linux
15
+
16
+
echo "Building Linux x86_64 (static)..."
17
+
nix-shell -p rustup --run '
18
+
rustup target add x86_64-unknown-linux-musl
19
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target x86_64-unknown-linux-musl
20
+
'
21
+
cp target/x86_64-unknown-linux-musl/release/wisp-cli binaries/wisp-cli-x86_64-linux
22
+
23
+
echo "Done! Binaries in ./binaries/"
+15
cli/build-macos.sh
+15
cli/build-macos.sh
···
1
+
#!/bin/bash
2
+
# Build Linux and macOS binaries
3
+
4
+
set -e
5
+
6
+
mkdir -p binaries
7
+
rm -rf target
8
+
9
+
# Build macOS binaries natively
10
+
echo "Building macOS binaries..."
11
+
rustup target add aarch64-apple-darwin
12
+
13
+
echo "Building macOS arm64 binary."
14
+
RUSTFLAGS="-C target-feature=+crt-static" cargo build --release --target aarch64-apple-darwin
15
+
cp target/aarch64-apple-darwin/release/wisp-cli binaries/wisp-cli-macos-arm64
+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
+
+321
-62
cli/src/main.rs
+321
-62
cli/src/main.rs
···
1
1
mod builder_types;
2
2
mod place_wisp;
3
+
mod cid;
4
+
mod blob_map;
5
+
mod metadata;
6
+
mod download;
7
+
mod pull;
8
+
mod serve;
3
9
4
-
use clap::Parser;
10
+
use clap::{Parser, Subcommand};
5
11
use jacquard::CowStr;
6
-
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt};
12
+
use jacquard::client::{Agent, FileAuthStore, AgentSessionExt, MemoryCredentialSession, AgentSession};
7
13
use jacquard::oauth::client::OAuthClient;
8
14
use jacquard::oauth::loopback::LoopbackConfig;
9
15
use jacquard::prelude::IdentityResolver;
···
11
17
use jacquard_common::types::blob::MimeType;
12
18
use miette::IntoDiagnostic;
13
19
use std::path::{Path, PathBuf};
20
+
use std::collections::HashMap;
14
21
use flate2::Compression;
15
22
use flate2::write::GzEncoder;
16
23
use std::io::Write;
17
24
use base64::Engine;
25
+
use futures::stream::{self, StreamExt};
18
26
19
27
use place_wisp::fs::*;
20
28
21
29
#[derive(Parser, Debug)]
22
-
#[command(author, version, about = "Deploy a static site to wisp.place")]
30
+
#[command(author, version, about = "wisp.place CLI tool")]
23
31
struct Args {
32
+
#[command(subcommand)]
33
+
command: Option<Commands>,
34
+
35
+
// Deploy arguments (when no subcommand is specified)
24
36
/// Handle (e.g., alice.bsky.social), DID, or PDS URL
25
-
input: CowStr<'static>,
37
+
#[arg(global = true, conflicts_with = "command")]
38
+
input: Option<CowStr<'static>>,
26
39
27
40
/// Path to the directory containing your static site
28
-
#[arg(short, long, default_value = ".")]
29
-
path: PathBuf,
41
+
#[arg(short, long, global = true, conflicts_with = "command")]
42
+
path: Option<PathBuf>,
30
43
31
44
/// Site name (defaults to directory name)
32
-
#[arg(short, long)]
45
+
#[arg(short, long, global = true, conflicts_with = "command")]
33
46
site: Option<String>,
34
47
35
-
/// Path to auth store file (will be created if missing)
36
-
#[arg(long, default_value = "/tmp/wisp-oauth-session.json")]
37
-
store: String,
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
+
},
38
110
}
39
111
40
112
#[tokio::main]
41
113
async fn main() -> miette::Result<()> {
42
114
let args = Args::parse();
43
115
44
-
let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
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
+
153
+
/// Run deployment with app password authentication
154
+
async fn run_with_app_password(
155
+
input: CowStr<'static>,
156
+
password: CowStr<'static>,
157
+
path: PathBuf,
158
+
site: Option<String>,
159
+
) -> miette::Result<()> {
160
+
let (session, auth) =
161
+
MemoryCredentialSession::authenticated(input, password, None).await?;
162
+
println!("Signed in as {}", auth.handle);
163
+
164
+
let agent: Agent<_> = Agent::from(session);
165
+
deploy_site(&agent, path, site).await
166
+
}
167
+
168
+
/// Run deployment with OAuth authentication
169
+
async fn run_with_oauth(
170
+
input: CowStr<'static>,
171
+
store: String,
172
+
path: PathBuf,
173
+
site: Option<String>,
174
+
) -> miette::Result<()> {
175
+
let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store));
45
176
let session = oauth
46
-
.login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
177
+
.login_with_local_server(input, Default::default(), LoopbackConfig::default())
47
178
.await?;
48
179
49
180
let agent: Agent<_> = Agent::from(session);
181
+
deploy_site(&agent, path, site).await
182
+
}
50
183
184
+
/// Deploy the site using the provided agent
185
+
async fn deploy_site(
186
+
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
187
+
path: PathBuf,
188
+
site: Option<String>,
189
+
) -> miette::Result<()> {
51
190
// Verify the path exists
52
-
if !args.path.exists() {
53
-
return Err(miette::miette!("Path does not exist: {}", args.path.display()));
191
+
if !path.exists() {
192
+
return Err(miette::miette!("Path does not exist: {}", path.display()));
54
193
}
55
194
56
195
// Get site name
57
-
let site_name = args.site.unwrap_or_else(|| {
58
-
args.path
196
+
let site_name = site.unwrap_or_else(|| {
197
+
path
59
198
.file_name()
60
199
.and_then(|n| n.to_str())
61
200
.unwrap_or("site")
···
64
203
65
204
println!("Deploying site '{}'...", site_name);
66
205
67
-
// Build directory tree
68
-
let root_dir = build_directory(&agent, &args.path).await?;
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
+
};
69
246
70
-
// Count total files
71
-
let file_count = count_files(&root_dir);
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;
72
250
73
251
// Create the Fs record
74
252
let fs_record = Fs::new()
75
253
.site(CowStr::from(site_name.clone()))
76
254
.root(root_dir)
77
-
.file_count(file_count as i64)
255
+
.file_count(total_files as i64)
78
256
.created_at(Datetime::now())
79
257
.build();
80
258
···
89
267
.and_then(|s| s.split('/').next())
90
268
.ok_or_else(|| miette::miette!("Failed to parse DID from URI"))?;
91
269
92
-
println!("Deployed site '{}': {}", site_name, output.uri);
93
-
println!("Available at: https://sites.wisp.place/{}/{}", did, site_name);
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);
94
273
95
274
Ok(())
96
275
}
97
276
98
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)
99
279
fn build_directory<'a>(
100
280
agent: &'a Agent<impl jacquard::client::AgentSession + IdentityResolver + 'a>,
101
281
dir_path: &'a Path,
102
-
) -> std::pin::Pin<Box<dyn std::future::Future<Output = miette::Result<Directory<'static>>> + 'a>>
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>>
103
285
{
104
286
Box::pin(async move {
105
-
let mut entries = Vec::new();
287
+
// Collect all directory entries first
288
+
let dir_entries: Vec<_> = std::fs::read_dir(dir_path)
289
+
.into_diagnostic()?
290
+
.collect::<Result<Vec<_>, _>>()
291
+
.into_diagnostic()?;
106
292
107
-
for entry in std::fs::read_dir(dir_path).into_diagnostic()? {
108
-
let entry = entry.into_diagnostic()?;
293
+
// Separate files and directories
294
+
let mut file_tasks = Vec::new();
295
+
let mut dir_tasks = Vec::new();
296
+
297
+
for entry in dir_entries {
109
298
let path = entry.path();
110
299
let name = entry.file_name();
111
300
let name_str = name.to_str()
112
-
.ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))?;
301
+
.ok_or_else(|| miette::miette!("Invalid filename: {:?}", name))?
302
+
.to_string();
113
303
114
304
// Skip hidden files
115
305
if name_str.starts_with('.') {
···
119
309
let metadata = entry.metadata().into_diagnostic()?;
120
310
121
311
if metadata.is_file() {
122
-
let file_node = process_file(agent, &path).await?;
123
-
entries.push(Entry::new()
124
-
.name(CowStr::from(name_str.to_string()))
125
-
.node(EntryNode::File(Box::new(file_node)))
126
-
.build());
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));
127
319
} else if metadata.is_dir() {
128
-
let subdir = build_directory(agent, &path).await?;
129
-
entries.push(Entry::new()
130
-
.name(CowStr::from(name_str.to_string()))
131
-
.node(EntryNode::Directory(Box::new(subdir)))
132
-
.build());
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;
133
349
}
134
350
}
135
351
136
-
Ok(Directory::new()
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()
137
375
.r#type(CowStr::from("directory"))
138
376
.entries(entries)
139
-
.build())
377
+
.build();
378
+
379
+
Ok((directory, total_files, reused_count))
140
380
})
141
381
}
142
382
143
-
/// Process a single file: gzip -> base64 -> upload blob
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
144
386
async fn process_file(
145
387
agent: &Agent<impl jacquard::client::AgentSession + IdentityResolver>,
146
388
file_path: &Path,
147
-
) -> miette::Result<File<'static>>
389
+
file_path_key: &str,
390
+
existing_blobs: &HashMap<String, (jacquard_common::types::blob::BlobRef<'static>, String)>,
391
+
) -> miette::Result<(File<'static>, bool)>
148
392
{
149
393
// Read file
150
394
let file_data = std::fs::read(file_path).into_diagnostic()?;
···
162
406
// Base64 encode the gzipped data
163
407
let base64_bytes = base64::prelude::BASE64_STANDARD.encode(&gzipped).into_bytes();
164
408
165
-
// Upload blob as octet-stream
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);
166
434
let blob = agent.upload_blob(
167
435
base64_bytes,
168
436
MimeType::new_static("application/octet-stream"),
169
437
).await?;
170
438
171
-
Ok(File::new()
172
-
.r#type(CowStr::from("file"))
173
-
.blob(blob)
174
-
.encoding(CowStr::from("gzip"))
175
-
.mime_type(CowStr::from(original_mime))
176
-
.base64(true)
177
-
.build())
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
+
))
178
449
}
179
450
180
-
/// Count total files in a directory tree
181
-
fn count_files(dir: &Directory) -> usize {
182
-
let mut count = 0;
183
-
for entry in &dir.entries {
184
-
match &entry.node {
185
-
EntryNode::File(_) => count += 1,
186
-
EntryNode::Directory(subdir) => count += count_files(subdir),
187
-
_ => {} // Unknown variants
188
-
}
189
-
}
190
-
count
191
-
}
+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
+
-14
cli/test_headers.rs
-14
cli/test_headers.rs
···
1
-
use http::Request;
2
-
3
-
fn main() {
4
-
let builder = Request::builder()
5
-
.header(http::header::CONTENT_TYPE, "*/*")
6
-
.header(http::header::CONTENT_TYPE, "application/octet-stream");
7
-
8
-
let req = builder.body(()).unwrap();
9
-
10
-
println!("Content-Type headers:");
11
-
for value in req.headers().get_all(http::header::CONTENT_TYPE) {
12
-
println!(" {:?}", value);
13
-
}
14
-
}
+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
+
}
+7
-7
hosting-service/Dockerfile
+7
-7
hosting-service/Dockerfile
···
1
-
# Use official Bun image
2
-
FROM oven/bun:1.3 AS base
1
+
# Use official Node.js Alpine image
2
+
FROM node:alpine AS base
3
3
4
4
# Set working directory
5
5
WORKDIR /app
6
6
7
7
# Copy package files
8
-
COPY package.json bun.lock ./
8
+
COPY package.json ./
9
9
10
10
# Install dependencies
11
-
RUN bun install --frozen-lockfile --production
11
+
RUN npm install
12
12
13
13
# Copy source code
14
14
COPY src ./src
···
25
25
26
26
# Health check
27
27
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
28
-
CMD bun -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
28
+
CMD node -e "fetch('http://localhost:3001/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
29
29
30
-
# Start the application
31
-
CMD ["bun", "src/index.ts"]
30
+
# Start the application (can override with 'npm run backfill' in compose)
31
+
CMD ["npm", "run", "start"]
-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.
+14
-192
hosting-service/bun.lock
+14
-192
hosting-service/bun.lock
···
7
7
"@atproto/api": "^0.17.4",
8
8
"@atproto/identity": "^0.4.9",
9
9
"@atproto/lexicon": "^0.5.1",
10
-
"@atproto/sync": "^0.1.35",
10
+
"@atproto/sync": "^0.1.36",
11
11
"@atproto/xrpc": "^0.7.5",
12
-
"@elysiajs/node": "^1.4.1",
13
-
"@elysiajs/opentelemetry": "latest",
14
-
"elysia": "latest",
12
+
"@hono/node-server": "^1.19.6",
13
+
"hono": "^4.10.4",
15
14
"mime-types": "^2.1.35",
16
15
"multiformats": "^13.4.1",
17
16
"postgres": "^3.4.5",
18
17
},
19
18
"devDependencies": {
19
+
"@types/bun": "^1.3.1",
20
20
"@types/mime-types": "^2.1.4",
21
21
"@types/node": "^22.10.5",
22
22
"tsx": "^4.19.2",
···
38
38
39
39
"@atproto/repo": ["@atproto/repo@0.8.10", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/common-web": "^0.4.3", "@atproto/crypto": "^0.4.4", "@atproto/lexicon": "^0.5.1", "@ipld/dag-cbor": "^7.0.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "varint": "^6.0.0", "zod": "^3.23.8" } }, ""],
40
40
41
-
"@atproto/sync": ["@atproto/sync@0.1.35", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, ""],
41
+
"@atproto/sync": ["@atproto/sync@0.1.36", "", { "dependencies": { "@atproto/common": "^0.4.12", "@atproto/identity": "^0.4.9", "@atproto/lexicon": "^0.5.1", "@atproto/repo": "^0.8.10", "@atproto/syntax": "^0.4.1", "@atproto/xrpc-server": "^0.9.5", "multiformats": "^9.9.0", "p-queue": "^6.6.2", "ws": "^8.12.0" } }, "sha512-HyF835Bmn8ps9BuXkmGjRrbgfv4K3fJdfEvXimEhTCntqIxQg0ttmOYDg/WBBmIRfkCB5ab+wS1PCGN8trr+FQ=="],
42
42
43
43
"@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, ""],
44
44
···
46
46
47
47
"@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" } }, ""],
48
48
49
-
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
50
-
51
49
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, ""],
52
50
53
-
"@elysiajs/node": ["@elysiajs/node@1.4.1", "", { "dependencies": { "crossws": "^0.4.1", "srvx": "^0.8.9" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-2wAALwHK3IYi1XJPnxfp1xJsvps5FqqcQqe+QXjYlGQvsmSG+vI5wNDIuvIlB+6p9NE/laLbqV0aFromf3X7yg=="],
54
-
55
-
"@elysiajs/opentelemetry": ["@elysiajs/opentelemetry@1.4.6", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.200.0", "@opentelemetry/sdk-node": "^0.200.0" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-jR7t4M6ZvMnBqzzHsNTL6y3sNq9jbGi2vKxbkizi/OO5tlvlKl/rnBGyFjZUjQ1Hte7rCz+2kfmgOQMhkjk+Og=="],
56
-
57
51
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
58
52
59
53
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
···
106
100
107
101
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
108
102
109
-
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="],
110
-
111
-
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
103
+
"@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="],
112
104
113
105
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, ""],
114
106
115
-
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
116
-
117
107
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, ""],
118
108
119
109
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, ""],
120
110
121
-
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
122
-
123
-
"@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="],
124
-
125
-
"@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.0.0", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA=="],
126
-
127
-
"@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="],
128
-
129
-
"@opentelemetry/exporter-logs-otlp-grpc": ["@opentelemetry/exporter-logs-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+3MDfa5YQPGM3WXxW9kqGD85Q7s9wlEMVNhXXG7tYFLnIeaseUt9YtCeFhEDFzfEktacdFpOtXmJuNW8cHbU5A=="],
130
-
131
-
"@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/sdk-logs": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g=="],
132
-
133
-
"@opentelemetry/exporter-logs-otlp-proto": ["@opentelemetry/exporter-logs-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA=="],
134
-
135
-
"@opentelemetry/exporter-metrics-otlp-grpc": ["@opentelemetry/exporter-metrics-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew=="],
136
-
137
-
"@opentelemetry/exporter-metrics-otlp-http": ["@opentelemetry/exporter-metrics-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-5BiR6i8yHc9+qW7F6LqkuUnIzVNA7lt0qRxIKcKT+gq3eGUPHZ3DY29sfxI3tkvnwMgtnHDMNze5DdxW39HsAw=="],
138
-
139
-
"@opentelemetry/exporter-metrics-otlp-proto": ["@opentelemetry/exporter-metrics-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-E+uPj0yyvz81U9pvLZp3oHtFrEzNSqKGVkIViTQY1rH3TOobeJPSpLnTVXACnCwkPR5XeTvPnK3pZ2Kni8AFMg=="],
140
-
141
-
"@opentelemetry/exporter-prometheus": ["@opentelemetry/exporter-prometheus@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-metrics": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-ZYdlU9r0USuuYppiDyU2VFRA0kFl855ylnb3N/2aOlXrbA4PMCznen7gmPbetGQu7pz8Jbaf4fwvrDnVdQQXSw=="],
142
-
143
-
"@opentelemetry/exporter-trace-otlp-grpc": ["@opentelemetry/exporter-trace-otlp-grpc@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-grpc-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-hmeZrUkFl1YMsgukSuHCFPYeF9df0hHoKeHUthRKFCxiURs+GwF1VuabuHmBMZnjTbsuvNjOB+JSs37Csem/5Q=="],
144
-
145
-
"@opentelemetry/exporter-trace-otlp-http": ["@opentelemetry/exporter-trace-otlp-http@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA=="],
146
-
147
-
"@opentelemetry/exporter-trace-otlp-proto": ["@opentelemetry/exporter-trace-otlp-proto@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-V9TDSD3PjK1OREw2iT9TUTzNYEVWJk4Nhodzhp9eiz4onDMYmPy3LaGbPv81yIR6dUb/hNp/SIhpiCHwFUq2Vg=="],
148
-
149
-
"@opentelemetry/exporter-zipkin": ["@opentelemetry/exporter-zipkin@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0" } }, "sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg=="],
150
-
151
-
"@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@types/shimmer": "^1.2.0", "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "shimmer": "^1.2.1" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-pmPlzfJd+vvgaZd/reMsC8RWgTXn2WY1OWT5RT42m3aOn5532TozwXNDhg1vzqJ+jnvmkREcdLr27ebJEQt0Jg=="],
152
-
153
-
"@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.200.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IxJgA3FD7q4V6gGq4bnmQM5nTIyMDkoGFGrBrrDjB6onEiq1pafma55V+bHvGYLWvcqbBbRfezr1GED88lacEQ=="],
154
-
155
-
"@opentelemetry/otlp-grpc-exporter-base": ["@opentelemetry/otlp-grpc-exporter-base@0.200.0", "", { "dependencies": { "@grpc/grpc-js": "^1.7.1", "@opentelemetry/core": "2.0.0", "@opentelemetry/otlp-exporter-base": "0.200.0", "@opentelemetry/otlp-transformer": "0.200.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CK2S+bFgOZ66Bsu5hlDeOX6cvW5FVtVjFFbWuaJP0ELxJKBB6HlbLZQ2phqz/uLj1cWap5xJr/PsR3iGoB7Vqw=="],
156
-
157
-
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw=="],
158
-
159
-
"@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-blx9S2EI49Ycuw6VZq+bkpaIoiJFhsDuvFGhBIoH3vJ5oYjJ2U0s3fAM5jYft99xVIAv6HqoPtlP9gpVA2IZtA=="],
160
-
161
-
"@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Mbm/LSFyAtQKP0AQah4AfGgsD+vsZcyreZoQ5okFBk33hU7AquU4TltgyL9dvaO8/Zkoud8/0gEvwfOZ5d7EPA=="],
162
-
163
-
"@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="],
164
-
165
-
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-VZG870063NLfObmQQNtCVcdXXLzI3vOjjrRENmU37HYiPFa0ZXpXVDsTD02Nh3AT3xYJzQaWKl2X2lQ2l7TWJA=="],
166
-
167
-
"@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="],
168
-
169
-
"@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.200.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.200.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-logs-otlp-grpc": "0.200.0", "@opentelemetry/exporter-logs-otlp-http": "0.200.0", "@opentelemetry/exporter-logs-otlp-proto": "0.200.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "0.200.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.200.0", "@opentelemetry/exporter-prometheus": "0.200.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.200.0", "@opentelemetry/exporter-trace-otlp-http": "0.200.0", "@opentelemetry/exporter-trace-otlp-proto": "0.200.0", "@opentelemetry/exporter-zipkin": "2.0.0", "@opentelemetry/instrumentation": "0.200.0", "@opentelemetry/propagator-b3": "2.0.0", "@opentelemetry/propagator-jaeger": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/sdk-logs": "0.200.0", "@opentelemetry/sdk-metrics": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0", "@opentelemetry/sdk-trace-node": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A=="],
170
-
171
-
"@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-qQnYdX+ZCkonM7tA5iU4fSRsVxbFGml8jbxOgipRGMFHKaXKHQ30js03rTobYjKjIfnOsZSbHKWF0/0v0OQGfw=="],
172
-
173
-
"@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.0.0", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.0.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/sdk-trace-base": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-omdilCZozUjQwY3uZRBwbaRMJ3p09l4t187Lsdf0dGMye9WKD4NGcpgZRvqhI1dwcH6og+YXQEtoO9Wx3ykilg=="],
174
-
175
-
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
176
-
177
-
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
178
-
179
-
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
180
-
181
-
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
182
-
183
-
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
184
-
185
-
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
186
-
187
-
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
188
-
189
-
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
190
-
191
-
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
192
-
193
-
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
194
-
195
-
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
196
-
197
-
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
198
-
199
-
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
200
-
201
-
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
111
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
202
112
203
113
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
204
114
205
115
"@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="],
206
116
207
-
"@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="],
117
+
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
208
118
209
119
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, ""],
210
120
211
121
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, ""],
212
122
213
-
"acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
214
-
215
-
"acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="],
216
-
217
-
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
218
-
219
-
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
220
-
221
123
"array-flatten": ["array-flatten@1.1.1", "", {}, ""],
222
124
223
125
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""],
···
229
131
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, ""],
230
132
231
133
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, ""],
134
+
135
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
232
136
233
137
"bytes": ["bytes@3.1.2", "", {}, ""],
234
138
···
242
146
243
147
"cborg": ["cborg@1.10.2", "", { "bin": "cli.js" }, ""],
244
148
245
-
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
246
-
247
-
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
248
-
249
-
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
250
-
251
-
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
252
-
253
149
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, ""],
254
150
255
151
"content-type": ["content-type@1.0.5", "", {}, ""],
256
152
257
-
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
153
+
"cookie": ["cookie@0.7.1", "", {}, ""],
258
154
259
155
"cookie-signature": ["cookie-signature@1.0.6", "", {}, ""],
260
156
261
-
"crossws": ["crossws@0.4.1", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w=="],
157
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
262
158
263
159
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, ""],
264
160
···
272
168
273
169
"ee-first": ["ee-first@1.1.1", "", {}, ""],
274
170
275
-
"elysia": ["elysia@1.4.13", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "exact-mirror": ">= 0.0.9", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-6QaWQEm7QN1UCo1TPpEjaRJPHUmnM7R29y6LY224frDGk5PrpAnWmdHkoZxkcv+JRWp1j2ROr2IHbxHbG/jRjw=="],
276
-
277
-
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
278
-
279
171
"encodeurl": ["encodeurl@2.0.0", "", {}, ""],
280
172
281
173
"es-define-property": ["es-define-property@1.0.1", "", {}, ""],
···
286
178
287
179
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": "bin/esbuild" }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
288
180
289
-
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
290
-
291
181
"escape-html": ["escape-html@1.0.3", "", {}, ""],
292
182
293
183
"etag": ["etag@1.8.1", "", {}, ""],
···
297
187
"eventemitter3": ["eventemitter3@4.0.7", "", {}, ""],
298
188
299
189
"events": ["events@3.3.0", "", {}, ""],
300
-
301
-
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" } }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
302
190
303
191
"express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, ""],
304
192
305
-
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
306
-
307
193
"fast-redact": ["fast-redact@3.5.0", "", {}, ""],
308
-
309
-
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
310
-
311
-
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
312
194
313
195
"finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, ""],
314
196
···
320
202
321
203
"function-bind": ["function-bind@1.1.2", "", {}, ""],
322
204
323
-
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
324
-
325
205
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, ""],
326
206
327
207
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, ""],
···
335
215
"has-symbols": ["has-symbols@1.1.0", "", {}, ""],
336
216
337
217
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, ""],
218
+
219
+
"hono": ["hono@4.10.4", "", {}, "sha512-YG/fo7zlU3KwrBL5vDpWKisLYiM+nVstBQqfr7gCPbSYURnNEP9BDxEMz8KfsDR9JX0lJWDRNc6nXX31v7ZEyg=="],
338
220
339
221
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, ""],
340
222
···
342
224
343
225
"ieee754": ["ieee754@1.2.1", "", {}, ""],
344
226
345
-
"import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="],
346
-
347
227
"inherits": ["inherits@2.0.4", "", {}, ""],
348
228
349
229
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, ""],
350
230
351
-
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
352
-
353
-
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
354
-
355
231
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, ""],
356
232
357
-
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
358
-
359
-
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
360
-
361
233
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, ""],
362
234
363
235
"media-typer": ["media-typer@0.3.0", "", {}, ""],
364
-
365
-
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
366
236
367
237
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, ""],
368
238
···
374
244
375
245
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""],
376
246
377
-
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
378
-
379
247
"ms": ["ms@2.0.0", "", {}, ""],
380
248
381
249
"multiformats": ["multiformats@13.4.1", "", {}, ""],
···
389
257
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""],
390
258
391
259
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, ""],
392
-
393
-
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
394
260
395
261
"p-finally": ["p-finally@1.0.0", "", {}, ""],
396
262
···
400
266
401
267
"parseurl": ["parseurl@1.3.3", "", {}, ""],
402
268
403
-
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
404
-
405
269
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, ""],
406
270
407
271
"pino": ["pino@8.21.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^6.0.0", "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^3.7.0", "thread-stream": "^2.6.0" }, "bin": "bin.js" }, ""],
···
416
280
417
281
"process-warning": ["process-warning@3.0.0", "", {}, ""],
418
282
419
-
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
420
-
421
283
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, ""],
422
284
423
285
"qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, ""],
···
434
296
435
297
"real-require": ["real-require@0.2.0", "", {}, ""],
436
298
437
-
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
438
-
439
-
"require-in-the-middle": ["require-in-the-middle@7.5.2", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3", "resolve": "^1.22.8" } }, "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ=="],
440
-
441
-
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
442
-
443
299
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
444
300
445
301
"safe-buffer": ["safe-buffer@5.2.1", "", {}, ""],
···
454
310
455
311
"setprototypeof": ["setprototypeof@1.2.0", "", {}, ""],
456
312
457
-
"shimmer": ["shimmer@1.2.1", "", {}, "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw=="],
458
-
459
313
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, ""],
460
314
461
315
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, ""],
···
468
322
469
323
"split2": ["split2@4.2.0", "", {}, ""],
470
324
471
-
"srvx": ["srvx@0.8.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-hmcGW4CgroeSmzgF1Ihwgl+Ths0JqAJ7HwjP2X7e3JzY7u4IydLMcdnlqGQiQGUswz+PO9oh/KtCpOISIvs9QQ=="],
472
-
473
325
"statuses": ["statuses@2.0.1", "", {}, ""],
474
-
475
-
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
476
326
477
327
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""],
478
328
479
-
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
480
-
481
-
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
482
-
483
-
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
484
-
485
329
"thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""],
486
330
487
331
"tlds": ["tlds@1.261.0", "", { "bin": "bin.js" }, ""],
488
332
489
333
"toidentifier": ["toidentifier@1.0.1", "", {}, ""],
490
334
491
-
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
492
-
493
335
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
494
336
495
337
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, ""],
496
-
497
-
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
498
338
499
339
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, ""],
500
340
···
508
348
509
349
"vary": ["vary@1.1.2", "", {}, ""],
510
350
511
-
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
512
-
513
351
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, ""],
514
352
515
-
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
516
-
517
-
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
518
-
519
-
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
520
-
521
353
"zod": ["zod@3.25.76", "", {}, ""],
522
354
523
355
"@atproto/api/multiformats": ["multiformats@9.9.0", "", {}, ""],
···
534
366
535
367
"@ipld/dag-cbor/multiformats": ["multiformats@9.9.0", "", {}, ""],
536
368
537
-
"@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
538
-
539
-
"express/cookie": ["cookie@0.7.1", "", {}, ""],
540
-
541
-
"require-in-the-middle/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
542
-
543
369
"send/encodeurl": ["encodeurl@1.0.2", "", {}, ""],
544
370
545
371
"send/ms": ["ms@2.1.3", "", {}, ""],
546
372
547
373
"uint8arrays/multiformats": ["multiformats@9.9.0", "", {}, ""],
548
-
549
-
"@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
550
-
551
-
"require-in-the-middle/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
552
374
}
553
375
}
+32
hosting-service/docker-entrypoint.sh
+32
hosting-service/docker-entrypoint.sh
···
1
+
#!/bin/sh
2
+
set -e
3
+
4
+
# Run different modes based on MODE environment variable
5
+
# Modes:
6
+
# - server (default): Start the hosting service
7
+
# - backfill: Run cache backfill and exit
8
+
# - backfill-server: Run cache backfill, then start the server
9
+
10
+
MODE="${MODE:-server}"
11
+
12
+
case "$MODE" in
13
+
backfill)
14
+
echo "๐ Running in backfill-only mode..."
15
+
exec npm run backfill
16
+
;;
17
+
backfill-server)
18
+
echo "๐ Running backfill, then starting server..."
19
+
npm run backfill
20
+
echo "โ
Backfill complete, starting server..."
21
+
exec npm run start
22
+
;;
23
+
server)
24
+
echo "๐ Starting server..."
25
+
exec npm run start
26
+
;;
27
+
*)
28
+
echo "โ Unknown MODE: $MODE"
29
+
echo "Valid modes: server, backfill, backfill-server"
30
+
exit 1
31
+
;;
32
+
esac
+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
+
+8
-5
hosting-service/package.json
+8
-5
hosting-service/package.json
···
3
3
"version": "1.0.0",
4
4
"type": "module",
5
5
"scripts": {
6
-
"dev": "tsx watch src/index.ts",
7
-
"start": "node --loader tsx src/index.ts"
6
+
"dev": "tsx --env-file=.env watch src/index.ts",
7
+
"build": "tsc",
8
+
"start": "tsx src/index.ts",
9
+
"backfill": "tsx src/index.ts --backfill"
8
10
},
9
11
"dependencies": {
10
12
"@atproto/api": "^0.17.4",
11
13
"@atproto/identity": "^0.4.9",
12
14
"@atproto/lexicon": "^0.5.1",
13
-
"@atproto/sync": "^0.1.35",
15
+
"@atproto/sync": "^0.1.36",
14
16
"@atproto/xrpc": "^0.7.5",
15
-
"@elysiajs/opentelemetry": "latest",
16
-
"elysia": "latest",
17
+
"@hono/node-server": "^1.19.6",
18
+
"hono": "^4.10.4",
17
19
"mime-types": "^2.1.35",
18
20
"multiformats": "^13.4.1",
19
21
"postgres": "^3.4.5"
20
22
},
21
23
"devDependencies": {
24
+
"@types/bun": "^1.3.1",
22
25
"@types/mime-types": "^2.1.4",
23
26
"@types/node": "^22.10.5",
24
27
"tsx": "^4.19.2"
+49
-10
hosting-service/src/index.ts
+49
-10
hosting-service/src/index.ts
···
1
1
import app from './server';
2
+
import { serve } from '@hono/node-server';
2
3
import { FirehoseWorker } from './lib/firehose';
3
4
import { logger } from './lib/observability';
4
5
import { mkdirSync, existsSync } from 'fs';
6
+
import { backfillCache } from './lib/backfill';
7
+
import { startDomainCacheCleanup, stopDomainCacheCleanup, setCacheOnlyMode } from './lib/db';
5
8
6
9
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
7
-
const CACHE_DIR = './cache/sites';
10
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
11
+
12
+
// Parse CLI arguments
13
+
const args = process.argv.slice(2);
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
+
}
8
25
9
26
// Ensure cache directory exists
10
27
if (!existsSync(CACHE_DIR)) {
···
12
29
console.log('Created cache directory:', CACHE_DIR);
13
30
}
14
31
32
+
// Start domain cache cleanup
33
+
startDomainCacheCleanup();
34
+
15
35
// Start firehose worker with observability logger
16
36
const firehose = new FirehoseWorker((msg, data) => {
17
37
logger.info(msg, data);
···
19
39
20
40
firehose.start();
21
41
42
+
// Run backfill if requested
43
+
if (backfillOnStartup) {
44
+
console.log('๐ Backfill requested, starting cache backfill...');
45
+
backfillCache({
46
+
skipExisting: true,
47
+
concurrency: 3,
48
+
}).then((stats) => {
49
+
console.log('โ
Cache backfill completed');
50
+
}).catch((err) => {
51
+
console.error('โ Cache backfill error:', err);
52
+
});
53
+
}
54
+
22
55
// Add health check endpoint
23
-
app.get('/health', () => {
56
+
app.get('/health', (c) => {
24
57
const firehoseHealth = firehose.getHealth();
25
-
return {
58
+
return c.json({
26
59
status: 'ok',
27
60
firehose: firehoseHealth,
28
-
};
61
+
});
62
+
});
63
+
64
+
// Start HTTP server with Node.js adapter
65
+
const server = serve({
66
+
fetch: app.fetch,
67
+
port: PORT,
29
68
});
30
69
31
-
// Start HTTP server
32
-
app.listen(PORT, () => {
33
-
console.log(`
70
+
console.log(`
34
71
Wisp Hosting Service
35
72
36
73
Server: http://localhost:${PORT}
37
74
Health: http://localhost:${PORT}/health
38
75
Cache: ${CACHE_DIR}
39
76
Firehose: Connected to Firehose
77
+
Cache-Only: ${CACHE_ONLY_MODE ? 'ENABLED (no DB writes)' : 'DISABLED'}
40
78
`);
41
-
});
42
79
43
80
// Graceful shutdown
44
81
process.on('SIGINT', async () => {
45
82
console.log('\n๐ Shutting down...');
46
83
firehose.stop();
47
-
app.stop();
84
+
stopDomainCacheCleanup();
85
+
server.close();
48
86
process.exit(0);
49
87
});
50
88
51
89
process.on('SIGTERM', async () => {
52
90
console.log('\n๐ Shutting down...');
53
91
firehose.stop();
54
-
app.stop();
92
+
stopDomainCacheCleanup();
93
+
server.close();
55
94
process.exit(0);
56
95
});
+1
-1
hosting-service/src/lexicon/types/place/wisp/fs.ts
+1
-1
hosting-service/src/lexicon/types/place/wisp/fs.ts
···
2
2
* GENERATED CODE - DO NOT MODIFY
3
3
*/
4
4
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
-
import { CID } from 'multiformats/cid'
5
+
import { CID } from 'multiformats'
6
6
import { validate as _validate } from '../../../lexicons'
7
7
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
8
8
+136
hosting-service/src/lib/backfill.ts
+136
hosting-service/src/lib/backfill.ts
···
1
+
import { getAllSites } from './db';
2
+
import { fetchSiteRecord, getPdsForDid, downloadAndCacheSite, isCached } from './utils';
3
+
import { logger } from './observability';
4
+
5
+
export interface BackfillOptions {
6
+
skipExisting?: boolean; // Skip sites already in cache
7
+
concurrency?: number; // Number of sites to cache concurrently
8
+
maxSites?: number; // Maximum number of sites to backfill (for testing)
9
+
}
10
+
11
+
export interface BackfillStats {
12
+
total: number;
13
+
cached: number;
14
+
skipped: number;
15
+
failed: number;
16
+
duration: number;
17
+
}
18
+
19
+
/**
20
+
* Backfill all sites from the database into the local cache
21
+
*/
22
+
export async function backfillCache(options: BackfillOptions = {}): Promise<BackfillStats> {
23
+
const {
24
+
skipExisting = true,
25
+
concurrency = 3,
26
+
maxSites,
27
+
} = options;
28
+
29
+
const startTime = Date.now();
30
+
const stats: BackfillStats = {
31
+
total: 0,
32
+
cached: 0,
33
+
skipped: 0,
34
+
failed: 0,
35
+
duration: 0,
36
+
};
37
+
38
+
logger.info('Starting cache backfill', { skipExisting, concurrency, maxSites });
39
+
console.log(`
40
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
41
+
โ CACHE BACKFILL STARTING โ
42
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
43
+
`);
44
+
45
+
try {
46
+
// Get all sites from database
47
+
let sites = await getAllSites();
48
+
stats.total = sites.length;
49
+
50
+
logger.info(`Found ${sites.length} sites in database`);
51
+
console.log(`๐ Found ${sites.length} sites in database`);
52
+
53
+
// Limit if specified
54
+
if (maxSites && maxSites > 0) {
55
+
sites = sites.slice(0, maxSites);
56
+
console.log(`โ๏ธ Limited to ${maxSites} sites for backfill`);
57
+
}
58
+
59
+
// Process sites in batches
60
+
const batches: typeof sites[] = [];
61
+
for (let i = 0; i < sites.length; i += concurrency) {
62
+
batches.push(sites.slice(i, i + concurrency));
63
+
}
64
+
65
+
let processed = 0;
66
+
for (const batch of batches) {
67
+
await Promise.all(
68
+
batch.map(async (site) => {
69
+
try {
70
+
// Check if already cached
71
+
if (skipExisting && isCached(site.did, site.rkey)) {
72
+
stats.skipped++;
73
+
processed++;
74
+
logger.debug(`Skipping already cached site`, { did: site.did, rkey: site.rkey });
75
+
console.log(`โญ๏ธ [${processed}/${sites.length}] Skipped (cached): ${site.display_name || site.rkey}`);
76
+
return;
77
+
}
78
+
79
+
// Fetch site record
80
+
const siteData = await fetchSiteRecord(site.did, site.rkey);
81
+
if (!siteData) {
82
+
stats.failed++;
83
+
processed++;
84
+
logger.error('Site record not found during backfill', null, { did: site.did, rkey: site.rkey });
85
+
console.log(`โ [${processed}/${sites.length}] Failed (not found): ${site.display_name || site.rkey}`);
86
+
return;
87
+
}
88
+
89
+
// Get PDS endpoint
90
+
const pdsEndpoint = await getPdsForDid(site.did);
91
+
if (!pdsEndpoint) {
92
+
stats.failed++;
93
+
processed++;
94
+
logger.error('PDS not found during backfill', null, { did: site.did });
95
+
console.log(`โ [${processed}/${sites.length}] Failed (no PDS): ${site.display_name || site.rkey}`);
96
+
return;
97
+
}
98
+
99
+
// Download and cache site
100
+
await downloadAndCacheSite(site.did, site.rkey, siteData.record, pdsEndpoint, siteData.cid);
101
+
stats.cached++;
102
+
processed++;
103
+
logger.info('Successfully cached site during backfill', { did: site.did, rkey: site.rkey });
104
+
console.log(`โ
[${processed}/${sites.length}] Cached: ${site.display_name || site.rkey}`);
105
+
} catch (err) {
106
+
stats.failed++;
107
+
processed++;
108
+
logger.error('Failed to cache site during backfill', err, { did: site.did, rkey: site.rkey });
109
+
console.log(`โ [${processed}/${sites.length}] Failed: ${site.display_name || site.rkey}`);
110
+
}
111
+
})
112
+
);
113
+
}
114
+
115
+
stats.duration = Date.now() - startTime;
116
+
117
+
console.log(`
118
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
119
+
โ CACHE BACKFILL COMPLETED โ
120
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
121
+
122
+
๐ Total Sites: ${stats.total}
123
+
โ
Cached: ${stats.cached}
124
+
โญ๏ธ Skipped: ${stats.skipped}
125
+
โ Failed: ${stats.failed}
126
+
โฑ๏ธ Duration: ${(stats.duration / 1000).toFixed(2)}s
127
+
`);
128
+
129
+
logger.info('Cache backfill completed', stats);
130
+
} catch (err) {
131
+
logger.error('Cache backfill failed', err);
132
+
console.error('โ Cache backfill failed:', err);
133
+
}
134
+
135
+
return stats;
136
+
}
+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
+
}
+98
-72
hosting-service/src/lib/db.ts
+98
-72
hosting-service/src/lib/db.ts
···
1
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
+
}
2
13
3
14
const sql = postgres(
4
15
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
···
8
19
}
9
20
);
10
21
11
-
export interface DomainLookup {
12
-
did: string;
13
-
rkey: string | null;
14
-
}
22
+
// Domain lookup cache with TTL
23
+
const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
15
24
16
-
export interface CustomDomainLookup {
17
-
id: string;
18
-
domain: string;
19
-
did: string;
20
-
rkey: string;
21
-
verified: boolean;
25
+
interface CachedDomain<T> {
26
+
value: T;
27
+
timestamp: number;
22
28
}
23
29
24
-
// In-memory cache with TTL
25
-
interface CacheEntry<T> {
26
-
data: T;
27
-
expiry: number;
28
-
}
30
+
const domainCache = new Map<string, CachedDomain<DomainLookup | null>>();
31
+
const customDomainCache = new Map<string, CachedDomain<CustomDomainLookup | null>>();
29
32
30
-
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
33
+
let cleanupInterval: NodeJS.Timeout | null = null;
31
34
32
-
class SimpleCache<T> {
33
-
private cache = new Map<string, CacheEntry<T>>();
35
+
export function startDomainCacheCleanup() {
36
+
if (cleanupInterval) return;
34
37
35
-
get(key: string): T | null {
36
-
const entry = this.cache.get(key);
37
-
if (!entry) return null;
38
+
cleanupInterval = setInterval(() => {
39
+
const now = Date.now();
38
40
39
-
if (Date.now() > entry.expiry) {
40
-
this.cache.delete(key);
41
-
return null;
41
+
for (const [key, entry] of domainCache.entries()) {
42
+
if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
43
+
domainCache.delete(key);
44
+
}
42
45
}
43
46
44
-
return entry.data;
45
-
}
46
-
47
-
set(key: string, data: T): void {
48
-
this.cache.set(key, {
49
-
data,
50
-
expiry: Date.now() + CACHE_TTL_MS,
51
-
});
52
-
}
53
-
54
-
// Periodic cleanup to prevent memory leaks
55
-
cleanup(): void {
56
-
const now = Date.now();
57
-
for (const [key, entry] of this.cache.entries()) {
58
-
if (now > entry.expiry) {
59
-
this.cache.delete(key);
47
+
for (const [key, entry] of customDomainCache.entries()) {
48
+
if (now - entry.timestamp > DOMAIN_CACHE_TTL) {
49
+
customDomainCache.delete(key);
60
50
}
61
51
}
52
+
}, 30 * 60 * 1000); // Run every 30 minutes
53
+
}
54
+
55
+
export function stopDomainCacheCleanup() {
56
+
if (cleanupInterval) {
57
+
clearInterval(cleanupInterval);
58
+
cleanupInterval = null;
62
59
}
63
60
}
64
61
65
-
// Create cache instances
66
-
const wispDomainCache = new SimpleCache<DomainLookup | null>();
67
-
const customDomainCache = new SimpleCache<CustomDomainLookup | null>();
68
-
const customDomainHashCache = new SimpleCache<CustomDomainLookup | null>();
62
+
export interface DomainLookup {
63
+
did: string;
64
+
rkey: string | null;
65
+
}
69
66
70
-
// Run cleanup every 5 minutes
71
-
setInterval(() => {
72
-
wispDomainCache.cleanup();
73
-
customDomainCache.cleanup();
74
-
customDomainHashCache.cleanup();
75
-
}, 5 * 60 * 1000);
67
+
export interface CustomDomainLookup {
68
+
id: string;
69
+
domain: string;
70
+
did: string;
71
+
rkey: string | null;
72
+
verified: boolean;
73
+
}
74
+
75
+
76
76
77
77
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
78
78
const key = domain.toLowerCase();
79
79
80
80
// Check cache first
81
-
const cached = wispDomainCache.get(key);
82
-
if (cached !== null) {
83
-
return cached;
81
+
const cached = domainCache.get(key);
82
+
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
83
+
return cached.value;
84
84
}
85
85
86
86
// Query database
···
89
89
`;
90
90
const data = result[0] || null;
91
91
92
-
// Store in cache
93
-
wispDomainCache.set(key, data);
92
+
// Cache the result
93
+
domainCache.set(key, { value: data, timestamp: Date.now() });
94
94
95
95
return data;
96
96
}
···
100
100
101
101
// Check cache first
102
102
const cached = customDomainCache.get(key);
103
-
if (cached !== null) {
104
-
return cached;
103
+
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
104
+
return cached.value;
105
105
}
106
106
107
107
// Query database
···
111
111
`;
112
112
const data = result[0] || null;
113
113
114
-
// Store in cache
115
-
customDomainCache.set(key, data);
114
+
// Cache the result
115
+
customDomainCache.set(key, { value: data, timestamp: Date.now() });
116
116
117
117
return data;
118
118
}
119
119
120
120
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
121
+
const key = `hash:${hash}`;
122
+
121
123
// Check cache first
122
-
const cached = customDomainHashCache.get(hash);
123
-
if (cached !== null) {
124
-
return cached;
124
+
const cached = customDomainCache.get(key);
125
+
if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) {
126
+
return cached.value;
125
127
}
126
128
127
129
// Query database
···
131
133
`;
132
134
const data = result[0] || null;
133
135
134
-
// Store in cache
135
-
customDomainHashCache.set(hash, data);
136
+
// Cache the result
137
+
customDomainCache.set(key, { value: data, timestamp: Date.now() });
136
138
137
139
return data;
138
140
}
139
141
140
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
+
141
149
try {
142
150
// Only set display_name if provided (not undefined/null/empty)
143
151
const cleanDisplayName = displayName && displayName.trim() ? displayName.trim() : null;
···
158
166
}
159
167
}
160
168
169
+
export interface SiteRecord {
170
+
did: string;
171
+
rkey: string;
172
+
display_name?: string;
173
+
}
174
+
175
+
export async function getAllSites(): Promise<SiteRecord[]> {
176
+
try {
177
+
const result = await sql<SiteRecord[]>`
178
+
SELECT did, rkey, display_name FROM sites
179
+
ORDER BY created_at DESC
180
+
`;
181
+
return result;
182
+
} catch (err) {
183
+
console.error('Failed to get all sites', err);
184
+
return [];
185
+
}
186
+
}
187
+
161
188
/**
162
189
* Generate a numeric lock ID from a string key
163
190
* PostgreSQL advisory locks use bigint (64-bit signed integer)
164
191
*/
165
192
function stringToLockId(key: string): bigint {
166
-
let hash = 0n;
167
-
for (let i = 0; i < key.length; i++) {
168
-
const char = BigInt(key.charCodeAt(i));
169
-
hash = ((hash << 5n) - hash + char) & 0x7FFFFFFFFFFFFFFFn; // Keep within signed int64 range
170
-
}
171
-
return hash;
193
+
const hash = createHash('sha256').update(key).digest('hex');
194
+
// Take first 16 hex characters (64 bits) and convert to bigint
195
+
const hashNum = BigInt('0x' + hash.substring(0, 16));
196
+
// Keep within signed int64 range
197
+
return hashNum & 0x7FFFFFFFFFFFFFFFn;
172
198
}
173
199
174
200
/**
···
180
206
const lockId = stringToLockId(key);
181
207
182
208
try {
183
-
const result = await sql`SELECT pg_try_advisory_lock(${lockId}) as acquired`;
209
+
const result = await sql`SELECT pg_try_advisory_lock(${Number(lockId)}) as acquired`;
184
210
return result[0]?.acquired === true;
185
211
} catch (err) {
186
212
console.error('Failed to acquire lock', { key, error: err });
···
195
221
const lockId = stringToLockId(key);
196
222
197
223
try {
198
-
await sql`SELECT pg_advisory_unlock(${lockId})`;
224
+
await sql`SELECT pg_advisory_unlock(${Number(lockId)})`;
199
225
} catch (err) {
200
226
console.error('Failed to release lock', { key, error: err });
201
227
}
+268
-219
hosting-service/src/lib/firehose.ts
+268
-219
hosting-service/src/lib/firehose.ts
···
1
-
import { existsSync, rmSync } from 'fs';
2
-
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
3
-
import { upsertSite, tryAcquireLock, releaseLock } from './db';
4
-
import { safeFetch } from './safe-fetch';
5
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
6
-
import { Firehose } from '@atproto/sync';
7
-
import { IdResolver } from '@atproto/identity';
1
+
import { existsSync, rmSync } from 'fs'
2
+
import {
3
+
getPdsForDid,
4
+
downloadAndCacheSite,
5
+
extractBlobCid,
6
+
fetchSiteRecord
7
+
} from './utils'
8
+
import { upsertSite, tryAcquireLock, releaseLock } from './db'
9
+
import { safeFetch } from './safe-fetch'
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'
8
14
9
-
const CACHE_DIR = './cache/sites';
15
+
const CACHE_DIR = './cache/sites'
10
16
11
17
export class FirehoseWorker {
12
-
private firehose: Firehose | null = null;
13
-
private idResolver: IdResolver;
14
-
private isShuttingDown = false;
15
-
private lastEventTime = Date.now();
18
+
private firehose: Firehose | null = null
19
+
private idResolver: IdResolver
20
+
private isShuttingDown = false
21
+
private lastEventTime = Date.now()
16
22
17
-
constructor(
18
-
private logger?: (msg: string, data?: Record<string, unknown>) => void,
19
-
) {
20
-
this.idResolver = new IdResolver();
21
-
}
23
+
constructor(
24
+
private logger?: (msg: string, data?: Record<string, unknown>) => void
25
+
) {
26
+
this.idResolver = new IdResolver()
27
+
}
22
28
23
-
private log(msg: string, data?: Record<string, unknown>) {
24
-
const log = this.logger || console.log;
25
-
log(`[FirehoseWorker] ${msg}`, data || {});
26
-
}
29
+
private log(msg: string, data?: Record<string, unknown>) {
30
+
const log = this.logger || console.log
31
+
log(`[FirehoseWorker] ${msg}`, data || {})
32
+
}
27
33
28
-
start() {
29
-
this.log('Starting firehose worker');
30
-
this.connect();
31
-
}
34
+
start() {
35
+
this.log('Starting firehose worker')
36
+
this.connect()
37
+
}
32
38
33
-
stop() {
34
-
this.log('Stopping firehose worker');
35
-
this.isShuttingDown = true;
39
+
stop() {
40
+
this.log('Stopping firehose worker')
41
+
this.isShuttingDown = true
36
42
37
-
if (this.firehose) {
38
-
this.firehose.destroy();
39
-
this.firehose = null;
40
-
}
41
-
}
43
+
if (this.firehose) {
44
+
this.firehose.destroy()
45
+
this.firehose = null
46
+
}
47
+
}
42
48
43
-
private connect() {
44
-
if (this.isShuttingDown) return;
49
+
private connect() {
50
+
if (this.isShuttingDown) return
45
51
46
-
this.log('Connecting to AT Protocol firehose');
52
+
this.log('Connecting to AT Protocol firehose')
47
53
48
-
this.firehose = new Firehose({
49
-
idResolver: this.idResolver,
50
-
service: 'wss://bsky.network',
51
-
filterCollections: ['place.wisp.fs'],
52
-
handleEvent: async (evt) => {
53
-
this.lastEventTime = Date.now();
54
+
this.firehose = new Firehose({
55
+
idResolver: this.idResolver,
56
+
service: 'wss://bsky.network',
57
+
filterCollections: ['place.wisp.fs'],
58
+
handleEvent: async (evt: any) => {
59
+
this.lastEventTime = Date.now()
54
60
55
-
// Watch for write events
56
-
if (evt.event === 'create' || evt.event === 'update') {
57
-
const record = evt.record;
61
+
// Watch for write events
62
+
if (evt.event === 'create' || evt.event === 'update') {
63
+
const record = evt.record
58
64
59
-
// If the write is a valid place.wisp.fs record
60
-
if (
61
-
evt.collection === 'place.wisp.fs' &&
62
-
isRecord(record) &&
63
-
validateRecord(record).success
64
-
) {
65
-
this.log('Received place.wisp.fs event', {
66
-
did: evt.did,
67
-
event: evt.event,
68
-
rkey: evt.rkey,
69
-
});
65
+
// If the write is a valid place.wisp.fs record
66
+
if (
67
+
evt.collection === 'place.wisp.fs' &&
68
+
isRecord(record) &&
69
+
validateRecord(record).success
70
+
) {
71
+
this.log('Received place.wisp.fs event', {
72
+
did: evt.did,
73
+
event: evt.event,
74
+
rkey: evt.rkey
75
+
})
70
76
71
-
try {
72
-
await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString());
73
-
} catch (err) {
74
-
this.log('Error handling event', {
75
-
did: evt.did,
76
-
event: evt.event,
77
-
rkey: evt.rkey,
78
-
error: err instanceof Error ? err.message : String(err),
79
-
});
80
-
}
81
-
}
82
-
} else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') {
83
-
this.log('Received delete event', {
84
-
did: evt.did,
85
-
rkey: evt.rkey,
86
-
});
77
+
try {
78
+
await this.handleCreateOrUpdate(
79
+
evt.did,
80
+
evt.rkey,
81
+
record,
82
+
evt.cid?.toString()
83
+
)
84
+
} catch (err) {
85
+
this.log('Error handling event', {
86
+
did: evt.did,
87
+
event: evt.event,
88
+
rkey: evt.rkey,
89
+
error:
90
+
err instanceof Error
91
+
? err.message
92
+
: String(err)
93
+
})
94
+
}
95
+
}
96
+
} else if (
97
+
evt.event === 'delete' &&
98
+
evt.collection === 'place.wisp.fs'
99
+
) {
100
+
this.log('Received delete event', {
101
+
did: evt.did,
102
+
rkey: evt.rkey
103
+
})
87
104
88
-
try {
89
-
await this.handleDelete(evt.did, evt.rkey);
90
-
} catch (err) {
91
-
this.log('Error handling delete', {
92
-
did: evt.did,
93
-
rkey: evt.rkey,
94
-
error: err instanceof Error ? err.message : String(err),
95
-
});
96
-
}
97
-
}
98
-
},
99
-
onError: (err) => {
100
-
this.log('Firehose error', {
101
-
error: err instanceof Error ? err.message : String(err),
102
-
stack: err instanceof Error ? err.stack : undefined,
103
-
fullError: err,
104
-
});
105
-
console.error('Full firehose error:', err);
106
-
},
107
-
});
105
+
try {
106
+
await this.handleDelete(evt.did, evt.rkey)
107
+
} catch (err) {
108
+
this.log('Error handling delete', {
109
+
did: evt.did,
110
+
rkey: evt.rkey,
111
+
error:
112
+
err instanceof Error ? err.message : String(err)
113
+
})
114
+
}
115
+
}
116
+
},
117
+
onError: (err: any) => {
118
+
this.log('Firehose error', {
119
+
error: err instanceof Error ? err.message : String(err),
120
+
stack: err instanceof Error ? err.stack : undefined,
121
+
fullError: err
122
+
})
123
+
console.error('Full firehose error:', err)
124
+
}
125
+
})
126
+
127
+
this.firehose.start()
128
+
this.log('Firehose started')
129
+
}
130
+
131
+
private async handleCreateOrUpdate(
132
+
did: string,
133
+
site: string,
134
+
record: any,
135
+
eventCid?: string
136
+
) {
137
+
this.log('Processing create/update', { did, site })
108
138
109
-
this.firehose.start();
110
-
this.log('Firehose started');
111
-
}
139
+
// Record is already validated in handleEvent
140
+
const fsRecord = record
112
141
113
-
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
114
-
this.log('Processing create/update', { did, site });
142
+
const pdsEndpoint = await getPdsForDid(did)
143
+
if (!pdsEndpoint) {
144
+
this.log('Could not resolve PDS for DID', { did })
145
+
return
146
+
}
115
147
116
-
// Record is already validated in handleEvent
117
-
const fsRecord = record;
148
+
this.log('Resolved PDS', { did, pdsEndpoint })
118
149
119
-
const pdsEndpoint = await getPdsForDid(did);
120
-
if (!pdsEndpoint) {
121
-
this.log('Could not resolve PDS for DID', { did });
122
-
return;
123
-
}
150
+
// Verify record exists on PDS and fetch its CID
151
+
let verifiedCid: string
152
+
try {
153
+
const result = await fetchSiteRecord(did, site)
124
154
125
-
this.log('Resolved PDS', { did, pdsEndpoint });
155
+
if (!result) {
156
+
this.log('Record not found on PDS, skipping cache', {
157
+
did,
158
+
site
159
+
})
160
+
return
161
+
}
126
162
127
-
// Verify record exists on PDS and fetch its CID
128
-
let verifiedCid: string;
129
-
try {
130
-
const result = await fetchSiteRecord(did, site);
163
+
verifiedCid = result.cid
131
164
132
-
if (!result) {
133
-
this.log('Record not found on PDS, skipping cache', { did, site });
134
-
return;
135
-
}
165
+
// Verify event CID matches PDS CID (prevent cache poisoning)
166
+
if (eventCid && eventCid !== verifiedCid) {
167
+
this.log('CID mismatch detected - potential spoofed event', {
168
+
did,
169
+
site,
170
+
eventCid,
171
+
verifiedCid
172
+
})
173
+
return
174
+
}
136
175
137
-
verifiedCid = result.cid;
176
+
this.log('Record verified on PDS', { did, site, cid: verifiedCid })
177
+
} catch (err) {
178
+
this.log('Failed to verify record on PDS', {
179
+
did,
180
+
site,
181
+
error: err instanceof Error ? err.message : String(err)
182
+
})
183
+
return
184
+
}
138
185
139
-
// Verify event CID matches PDS CID (prevent cache poisoning)
140
-
if (eventCid && eventCid !== verifiedCid) {
141
-
this.log('CID mismatch detected - potential spoofed event', {
142
-
did,
143
-
site,
144
-
eventCid,
145
-
verifiedCid
146
-
});
147
-
return;
148
-
}
186
+
// Invalidate in-memory caches before updating
187
+
invalidateSiteCache(did, site)
149
188
150
-
this.log('Record verified on PDS', { did, site, cid: verifiedCid });
151
-
} catch (err) {
152
-
this.log('Failed to verify record on PDS', {
153
-
did,
154
-
site,
155
-
error: err instanceof Error ? err.message : String(err),
156
-
});
157
-
return;
158
-
}
189
+
// Cache the record with verified CID (uses atomic swap internally)
190
+
// All instances cache locally for edge serving
191
+
await downloadAndCacheSite(
192
+
did,
193
+
site,
194
+
fsRecord,
195
+
pdsEndpoint,
196
+
verifiedCid
197
+
)
159
198
160
-
// Cache the record with verified CID (uses atomic swap internally)
161
-
// All instances cache locally for edge serving
162
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
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)
163
203
164
-
// Acquire distributed lock only for database write to prevent duplicate writes
165
-
const lockKey = `db:upsert:${did}:${site}`;
166
-
const lockAcquired = await tryAcquireLock(lockKey);
204
+
if (!lockAcquired) {
205
+
this.log('Another instance is writing to DB, skipping upsert', {
206
+
did,
207
+
site
208
+
})
209
+
this.log('Successfully processed create/update (cached locally)', {
210
+
did,
211
+
site
212
+
})
213
+
return
214
+
}
167
215
168
-
if (!lockAcquired) {
169
-
this.log('Another instance is writing to DB, skipping upsert', { did, site });
170
-
this.log('Successfully processed create/update (cached locally)', { did, site });
171
-
return;
172
-
}
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)',
222
+
{ did, site }
223
+
)
224
+
} finally {
225
+
// Always release lock, even if DB write fails
226
+
await releaseLock(lockKey)
227
+
}
228
+
}
173
229
174
-
try {
175
-
// Upsert site to database (only one instance does this)
176
-
await upsertSite(did, site, fsRecord.site);
177
-
this.log('Successfully processed create/update (cached + DB updated)', { did, site });
178
-
} finally {
179
-
// Always release lock, even if DB write fails
180
-
await releaseLock(lockKey);
181
-
}
182
-
}
230
+
private async handleDelete(did: string, site: string) {
231
+
this.log('Processing delete', { did, site })
183
232
184
-
private async handleDelete(did: string, site: string) {
185
-
this.log('Processing delete', { did, site });
233
+
// All instances should delete their local cache (no lock needed)
234
+
const pdsEndpoint = await getPdsForDid(did)
235
+
if (!pdsEndpoint) {
236
+
this.log('Could not resolve PDS for DID', { did })
237
+
return
238
+
}
186
239
187
-
// All instances should delete their local cache (no lock needed)
188
-
const pdsEndpoint = await getPdsForDid(did);
189
-
if (!pdsEndpoint) {
190
-
this.log('Could not resolve PDS for DID', { did });
191
-
return;
192
-
}
240
+
// Verify record is actually deleted from PDS
241
+
try {
242
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`
243
+
const recordRes = await safeFetch(recordUrl)
193
244
194
-
// Verify record is actually deleted from PDS
195
-
try {
196
-
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
197
-
const recordRes = await safeFetch(recordUrl);
245
+
if (recordRes.ok) {
246
+
this.log('Record still exists on PDS, not deleting cache', {
247
+
did,
248
+
site
249
+
})
250
+
return
251
+
}
198
252
199
-
if (recordRes.ok) {
200
-
this.log('Record still exists on PDS, not deleting cache', {
201
-
did,
202
-
site,
203
-
});
204
-
return;
205
-
}
253
+
this.log('Verified record is deleted from PDS', {
254
+
did,
255
+
site,
256
+
status: recordRes.status
257
+
})
258
+
} catch (err) {
259
+
this.log('Error verifying deletion on PDS', {
260
+
did,
261
+
site,
262
+
error: err instanceof Error ? err.message : String(err)
263
+
})
264
+
}
206
265
207
-
this.log('Verified record is deleted from PDS', {
208
-
did,
209
-
site,
210
-
status: recordRes.status,
211
-
});
212
-
} catch (err) {
213
-
this.log('Error verifying deletion on PDS', {
214
-
did,
215
-
site,
216
-
error: err instanceof Error ? err.message : String(err),
217
-
});
218
-
}
266
+
// Invalidate in-memory caches
267
+
invalidateSiteCache(did, site)
219
268
220
-
// Delete cache
221
-
this.deleteCache(did, site);
269
+
// Delete disk cache
270
+
this.deleteCache(did, site)
222
271
223
-
this.log('Successfully processed delete', { did, site });
224
-
}
272
+
this.log('Successfully processed delete', { did, site })
273
+
}
225
274
226
-
private deleteCache(did: string, site: string) {
227
-
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
275
+
private deleteCache(did: string, site: string) {
276
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`
228
277
229
-
if (!existsSync(cacheDir)) {
230
-
this.log('Cache directory does not exist, nothing to delete', {
231
-
did,
232
-
site,
233
-
});
234
-
return;
235
-
}
278
+
if (!existsSync(cacheDir)) {
279
+
this.log('Cache directory does not exist, nothing to delete', {
280
+
did,
281
+
site
282
+
})
283
+
return
284
+
}
236
285
237
-
try {
238
-
rmSync(cacheDir, { recursive: true, force: true });
239
-
this.log('Cache deleted', { did, site, path: cacheDir });
240
-
} catch (err) {
241
-
this.log('Failed to delete cache', {
242
-
did,
243
-
site,
244
-
path: cacheDir,
245
-
error: err instanceof Error ? err.message : String(err),
246
-
});
247
-
}
248
-
}
286
+
try {
287
+
rmSync(cacheDir, { recursive: true, force: true })
288
+
this.log('Cache deleted', { did, site, path: cacheDir })
289
+
} catch (err) {
290
+
this.log('Failed to delete cache', {
291
+
did,
292
+
site,
293
+
path: cacheDir,
294
+
error: err instanceof Error ? err.message : String(err)
295
+
})
296
+
}
297
+
}
249
298
250
-
getHealth() {
251
-
const isConnected = this.firehose !== null;
252
-
const timeSinceLastEvent = Date.now() - this.lastEventTime;
299
+
getHealth() {
300
+
const isConnected = this.firehose !== null
301
+
const timeSinceLastEvent = Date.now() - this.lastEventTime
253
302
254
-
return {
255
-
connected: isConnected,
256
-
lastEventTime: this.lastEventTime,
257
-
timeSinceLastEvent,
258
-
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
259
-
};
260
-
}
303
+
return {
304
+
connected: isConnected,
305
+
lastEventTime: this.lastEventTime,
306
+
timeSinceLastEvent,
307
+
healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes
308
+
}
309
+
}
261
310
}
+434
-84
hosting-service/src/lib/html-rewriter.test.ts
+434
-84
hosting-service/src/lib/html-rewriter.test.ts
···
1
-
/**
2
-
* Simple tests for HTML path rewriter
3
-
* Run with: bun test
4
-
*/
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'
3
+
4
+
describe('rewriteHtmlPaths', () => {
5
+
const basePath = '/identifier/site/'
6
+
7
+
describe('absolute paths', () => {
8
+
test('rewrites absolute paths with leading slash', () => {
9
+
const html = '<img src="/image.png">'
10
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
11
+
expect(result).toBe('<img src="/identifier/site/image.png">')
12
+
})
13
+
14
+
test('rewrites nested absolute paths', () => {
15
+
const html = '<link href="/css/style.css">'
16
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
17
+
expect(result).toBe('<link href="/identifier/site/css/style.css">')
18
+
})
19
+
})
20
+
21
+
describe('relative paths from root document', () => {
22
+
test('rewrites relative paths with ./ prefix', () => {
23
+
const html = '<img src="./image.png">'
24
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
25
+
expect(result).toBe('<img src="/identifier/site/image.png">')
26
+
})
27
+
28
+
test('rewrites relative paths without prefix', () => {
29
+
const html = '<img src="image.png">'
30
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
31
+
expect(result).toBe('<img src="/identifier/site/image.png">')
32
+
})
33
+
34
+
test('rewrites relative paths with ../ (should stay at root)', () => {
35
+
const html = '<img src="../image.png">'
36
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
37
+
expect(result).toBe('<img src="/identifier/site/image.png">')
38
+
})
39
+
})
40
+
41
+
describe('relative paths from nested documents', () => {
42
+
test('rewrites relative path from nested document', () => {
43
+
const html = '<img src="./photo.jpg">'
44
+
const result = rewriteHtmlPaths(
45
+
html,
46
+
basePath,
47
+
'folder1/folder2/index.html'
48
+
)
49
+
expect(result).toBe(
50
+
'<img src="/identifier/site/folder1/folder2/photo.jpg">'
51
+
)
52
+
})
53
+
54
+
test('rewrites plain filename from nested document', () => {
55
+
const html = '<script src="app.js"></script>'
56
+
const result = rewriteHtmlPaths(
57
+
html,
58
+
basePath,
59
+
'folder1/folder2/index.html'
60
+
)
61
+
expect(result).toBe(
62
+
'<script src="/identifier/site/folder1/folder2/app.js"></script>'
63
+
)
64
+
})
65
+
66
+
test('rewrites ../ to go up one level', () => {
67
+
const html = '<img src="../image.png">'
68
+
const result = rewriteHtmlPaths(
69
+
html,
70
+
basePath,
71
+
'folder1/folder2/folder3/index.html'
72
+
)
73
+
expect(result).toBe(
74
+
'<img src="/identifier/site/folder1/folder2/image.png">'
75
+
)
76
+
})
77
+
78
+
test('rewrites multiple ../ to go up multiple levels', () => {
79
+
const html = '<link href="../../css/style.css">'
80
+
const result = rewriteHtmlPaths(
81
+
html,
82
+
basePath,
83
+
'folder1/folder2/folder3/index.html'
84
+
)
85
+
expect(result).toBe(
86
+
'<link href="/identifier/site/folder1/css/style.css">'
87
+
)
88
+
})
89
+
90
+
test('rewrites ../ with additional path segments', () => {
91
+
const html = '<img src="../assets/logo.png">'
92
+
const result = rewriteHtmlPaths(
93
+
html,
94
+
basePath,
95
+
'pages/about/index.html'
96
+
)
97
+
expect(result).toBe(
98
+
'<img src="/identifier/site/pages/assets/logo.png">'
99
+
)
100
+
})
101
+
102
+
test('handles complex nested relative paths', () => {
103
+
const html = '<script src="../../lib/vendor/jquery.js"></script>'
104
+
const result = rewriteHtmlPaths(
105
+
html,
106
+
basePath,
107
+
'pages/blog/post/index.html'
108
+
)
109
+
expect(result).toBe(
110
+
'<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>'
111
+
)
112
+
})
113
+
114
+
test('handles ../ going past root (stays at root)', () => {
115
+
const html = '<img src="../../../image.png">'
116
+
const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html')
117
+
expect(result).toBe('<img src="/identifier/site/image.png">')
118
+
})
119
+
})
120
+
121
+
describe('external URLs and special schemes', () => {
122
+
test('does not rewrite http URLs', () => {
123
+
const html = '<img src="http://example.com/image.png">'
124
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
125
+
expect(result).toBe('<img src="http://example.com/image.png">')
126
+
})
5
127
6
-
import { test, expect } from 'bun:test';
7
-
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
128
+
test('does not rewrite https URLs', () => {
129
+
const html = '<link href="https://cdn.example.com/style.css">'
130
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
131
+
expect(result).toBe(
132
+
'<link href="https://cdn.example.com/style.css">'
133
+
)
134
+
})
8
135
9
-
test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => {
10
-
const html = '<img src="/logo.png">';
11
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
12
-
expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">');
13
-
});
136
+
test('does not rewrite protocol-relative URLs', () => {
137
+
const html = '<script src="//cdn.example.com/script.js"></script>'
138
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
139
+
expect(result).toBe(
140
+
'<script src="//cdn.example.com/script.js"></script>'
141
+
)
142
+
})
14
143
15
-
test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => {
16
-
const html = '<link rel="stylesheet" href="/style.css">';
17
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
18
-
expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">');
19
-
});
144
+
test('does not rewrite data URIs', () => {
145
+
const html =
146
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
147
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
148
+
expect(result).toBe(
149
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
150
+
)
151
+
})
20
152
21
-
test('rewriteHtmlPaths - preserves external URLs', () => {
22
-
const html = '<img src="https://example.com/logo.png">';
23
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
24
-
expect(result).toBe('<img src="https://example.com/logo.png">');
25
-
});
153
+
test('does not rewrite mailto links', () => {
154
+
const html = '<a href="mailto:test@example.com">Email</a>'
155
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
156
+
expect(result).toBe('<a href="mailto:test@example.com">Email</a>')
157
+
})
26
158
27
-
test('rewriteHtmlPaths - preserves protocol-relative URLs', () => {
28
-
const html = '<script src="//cdn.example.com/script.js"></script>';
29
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
30
-
expect(result).toBe('<script src="//cdn.example.com/script.js"></script>');
31
-
});
159
+
test('does not rewrite tel links', () => {
160
+
const html = '<a href="tel:+1234567890">Call</a>'
161
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
162
+
expect(result).toBe('<a href="tel:+1234567890">Call</a>')
163
+
})
164
+
})
32
165
33
-
test('rewriteHtmlPaths - preserves data URIs', () => {
34
-
const html = '<img src="data:image/png;base64,abc123">';
35
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
36
-
expect(result).toBe('<img src="data:image/png;base64,abc123">');
37
-
});
166
+
describe('different HTML attributes', () => {
167
+
test('rewrites src attribute', () => {
168
+
const html = '<img src="/image.png">'
169
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
170
+
expect(result).toBe('<img src="/identifier/site/image.png">')
171
+
})
38
172
39
-
test('rewriteHtmlPaths - preserves anchors', () => {
40
-
const html = '<a href="/#section">Jump</a>';
41
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
42
-
expect(result).toBe('<a href="/#section">Jump</a>');
43
-
});
173
+
test('rewrites href attribute', () => {
174
+
const html = '<a href="/page.html">Link</a>'
175
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
176
+
expect(result).toBe('<a href="/identifier/site/page.html">Link</a>')
177
+
})
44
178
45
-
test('rewriteHtmlPaths - preserves relative paths', () => {
46
-
const html = '<img src="./logo.png">';
47
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
48
-
expect(result).toBe('<img src="./logo.png">');
49
-
});
179
+
test('rewrites action attribute', () => {
180
+
const html = '<form action="/submit"></form>'
181
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
182
+
expect(result).toBe('<form action="/identifier/site/submit"></form>')
183
+
})
50
184
51
-
test('rewriteHtmlPaths - handles single quotes', () => {
52
-
const html = "<img src='/logo.png'>";
53
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
54
-
expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>");
55
-
});
185
+
test('rewrites data attribute', () => {
186
+
const html = '<object data="/document.pdf"></object>'
187
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
188
+
expect(result).toBe(
189
+
'<object data="/identifier/site/document.pdf"></object>'
190
+
)
191
+
})
56
192
57
-
test('rewriteHtmlPaths - handles srcset', () => {
58
-
const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">';
59
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
60
-
expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">');
61
-
});
193
+
test('rewrites poster attribute', () => {
194
+
const html = '<video poster="/thumbnail.jpg"></video>'
195
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
196
+
expect(result).toBe(
197
+
'<video poster="/identifier/site/thumbnail.jpg"></video>'
198
+
)
199
+
})
62
200
63
-
test('rewriteHtmlPaths - handles form actions', () => {
64
-
const html = '<form action="/submit"></form>';
65
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
66
-
expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>');
67
-
});
201
+
test('rewrites srcset attribute with single URL', () => {
202
+
const html = '<img srcset="/image.png 1x">'
203
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
204
+
expect(result).toBe(
205
+
'<img srcset="/identifier/site/image.png 1x">'
206
+
)
207
+
})
68
208
69
-
test('rewriteHtmlPaths - handles complex HTML', () => {
70
-
const html = `
209
+
test('rewrites srcset attribute with multiple URLs', () => {
210
+
const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">'
211
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
212
+
expect(result).toBe(
213
+
'<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">'
214
+
)
215
+
})
216
+
217
+
test('rewrites srcset with width descriptors', () => {
218
+
const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">'
219
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
220
+
expect(result).toBe(
221
+
'<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">'
222
+
)
223
+
})
224
+
225
+
test('rewrites srcset with relative paths from nested document', () => {
226
+
const html = '<img srcset="../img1.png 1x, ../img2.png 2x">'
227
+
const result = rewriteHtmlPaths(
228
+
html,
229
+
basePath,
230
+
'folder1/folder2/index.html'
231
+
)
232
+
expect(result).toBe(
233
+
'<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">'
234
+
)
235
+
})
236
+
})
237
+
238
+
describe('quote handling', () => {
239
+
test('handles double quotes', () => {
240
+
const html = '<img src="/image.png">'
241
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
242
+
expect(result).toBe('<img src="/identifier/site/image.png">')
243
+
})
244
+
245
+
test('handles single quotes', () => {
246
+
const html = "<img src='/image.png'>"
247
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
248
+
expect(result).toBe("<img src='/identifier/site/image.png'>")
249
+
})
250
+
251
+
test('handles mixed quotes in same document', () => {
252
+
const html = '<img src="/img1.png"><link href=\'/style.css\'>'
253
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
254
+
expect(result).toBe(
255
+
'<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>'
256
+
)
257
+
})
258
+
})
259
+
260
+
describe('multiple rewrites in same document', () => {
261
+
test('rewrites multiple attributes in complex HTML', () => {
262
+
const html = `
71
263
<!DOCTYPE html>
72
264
<html>
73
265
<head>
74
-
<link rel="stylesheet" href="/style.css">
75
-
<script src="/app.js"></script>
266
+
<link href="/css/style.css" rel="stylesheet">
267
+
<script src="/js/app.js"></script>
268
+
</head>
269
+
<body>
270
+
<img src="/images/logo.png" alt="Logo">
271
+
<a href="/about.html">About</a>
272
+
<form action="/submit">
273
+
<button type="submit">Submit</button>
274
+
</form>
275
+
</body>
276
+
</html>
277
+
`
278
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
279
+
expect(result).toContain('href="/identifier/site/css/style.css"')
280
+
expect(result).toContain('src="/identifier/site/js/app.js"')
281
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
282
+
expect(result).toContain('href="/identifier/site/about.html"')
283
+
expect(result).toContain('action="/identifier/site/submit"')
284
+
})
285
+
286
+
test('handles mix of relative and absolute paths', () => {
287
+
const html = `
288
+
<img src="/abs/image.png">
289
+
<img src="./rel/image.png">
290
+
<img src="../parent/image.png">
291
+
<img src="https://external.com/image.png">
292
+
`
293
+
const result = rewriteHtmlPaths(
294
+
html,
295
+
basePath,
296
+
'folder1/folder2/page.html'
297
+
)
298
+
expect(result).toContain('src="/identifier/site/abs/image.png"')
299
+
expect(result).toContain(
300
+
'src="/identifier/site/folder1/folder2/rel/image.png"'
301
+
)
302
+
expect(result).toContain(
303
+
'src="/identifier/site/folder1/parent/image.png"'
304
+
)
305
+
expect(result).toContain('src="https://external.com/image.png"')
306
+
})
307
+
})
308
+
309
+
describe('edge cases', () => {
310
+
test('handles empty src attribute', () => {
311
+
const html = '<img src="">'
312
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
313
+
expect(result).toBe('<img src="">')
314
+
})
315
+
316
+
test('handles basePath without trailing slash', () => {
317
+
const html = '<img src="/image.png">'
318
+
const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html')
319
+
expect(result).toBe('<img src="/identifier/site/image.png">')
320
+
})
321
+
322
+
test('handles basePath with trailing slash', () => {
323
+
const html = '<img src="/image.png">'
324
+
const result = rewriteHtmlPaths(
325
+
html,
326
+
'/identifier/site/',
327
+
'index.html'
328
+
)
329
+
expect(result).toBe('<img src="/identifier/site/image.png">')
330
+
})
331
+
332
+
test('handles whitespace around equals sign', () => {
333
+
const html = '<img src = "/image.png">'
334
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
335
+
expect(result).toBe('<img src="/identifier/site/image.png">')
336
+
})
337
+
338
+
test('preserves query strings in URLs', () => {
339
+
const html = '<img src="/image.png?v=123">'
340
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
341
+
expect(result).toBe('<img src="/identifier/site/image.png?v=123">')
342
+
})
343
+
344
+
test('preserves hash fragments in URLs', () => {
345
+
const html = '<a href="/page.html#section">Link</a>'
346
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
347
+
expect(result).toBe(
348
+
'<a href="/identifier/site/page.html#section">Link</a>'
349
+
)
350
+
})
351
+
352
+
test('handles paths with special characters', () => {
353
+
const html = '<img src="/folder-name/file_name.png">'
354
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
355
+
expect(result).toBe(
356
+
'<img src="/identifier/site/folder-name/file_name.png">'
357
+
)
358
+
})
359
+
})
360
+
361
+
describe('real-world scenario', () => {
362
+
test('handles the example from the bug report', () => {
363
+
// HTML file at: /folder1/folder2/folder3/index.html
364
+
// Image at: /folder1/folder2/img.png
365
+
// Reference: src="../img.png"
366
+
const html = '<img src="../img.png">'
367
+
const result = rewriteHtmlPaths(
368
+
html,
369
+
basePath,
370
+
'folder1/folder2/folder3/index.html'
371
+
)
372
+
expect(result).toBe(
373
+
'<img src="/identifier/site/folder1/folder2/img.png">'
374
+
)
375
+
})
376
+
377
+
test('handles deeply nested static site structure', () => {
378
+
// A typical static site with nested pages and shared assets
379
+
const html = `
380
+
<!DOCTYPE html>
381
+
<html>
382
+
<head>
383
+
<link href="../../css/style.css" rel="stylesheet">
384
+
<link href="../../css/theme.css" rel="stylesheet">
385
+
<script src="../../js/main.js"></script>
76
386
</head>
77
387
<body>
78
-
<img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x">
79
-
<a href="/about">About</a>
80
-
<a href="https://example.com">External</a>
81
-
<a href="#section">Anchor</a>
388
+
<img src="../../images/logo.png" alt="Logo">
389
+
<img src="./post-image.jpg" alt="Post">
390
+
<a href="../index.html">Back to Blog</a>
391
+
<a href="../../index.html">Home</a>
82
392
</body>
83
393
</html>
84
-
`.trim();
394
+
`
395
+
const result = rewriteHtmlPaths(
396
+
html,
397
+
basePath,
398
+
'blog/posts/my-post.html'
399
+
)
400
+
401
+
// Assets two levels up
402
+
expect(result).toContain('href="/identifier/site/css/style.css"')
403
+
expect(result).toContain('href="/identifier/site/css/theme.css"')
404
+
expect(result).toContain('src="/identifier/site/js/main.js"')
405
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
406
+
407
+
// Same directory
408
+
expect(result).toContain(
409
+
'src="/identifier/site/blog/posts/post-image.jpg"'
410
+
)
85
411
86
-
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
412
+
// One level up
413
+
expect(result).toContain('href="/identifier/site/blog/index.html"')
87
414
88
-
expect(result).toContain('href="/did:plc:123/mysite/style.css"');
89
-
expect(result).toContain('src="/did:plc:123/mysite/app.js"');
90
-
expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"');
91
-
expect(result).toContain('href="/did:plc:123/mysite/about"');
92
-
expect(result).toContain('href="https://example.com"'); // External preserved
93
-
expect(result).toContain('href="#section"'); // Anchor preserved
94
-
});
415
+
// Two levels up
416
+
expect(result).toContain('href="/identifier/site/index.html"')
417
+
})
418
+
})
419
+
})
95
420
96
-
test('isHtmlContent - detects HTML by extension', () => {
97
-
expect(isHtmlContent('index.html')).toBe(true);
98
-
expect(isHtmlContent('page.htm')).toBe(true);
99
-
expect(isHtmlContent('style.css')).toBe(false);
100
-
expect(isHtmlContent('script.js')).toBe(false);
101
-
});
421
+
describe('isHtmlContent', () => {
422
+
test('identifies HTML by content type', () => {
423
+
expect(isHtmlContent('file.txt', 'text/html')).toBe(true)
424
+
expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe(
425
+
true
426
+
)
427
+
})
102
428
103
-
test('isHtmlContent - detects HTML by content type', () => {
104
-
expect(isHtmlContent('index', 'text/html')).toBe(true);
105
-
expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true);
106
-
expect(isHtmlContent('index', 'application/json')).toBe(false);
107
-
});
429
+
test('identifies HTML by .html extension', () => {
430
+
expect(isHtmlContent('index.html')).toBe(true)
431
+
expect(isHtmlContent('page.html', undefined)).toBe(true)
432
+
expect(isHtmlContent('/path/to/file.html')).toBe(true)
433
+
})
434
+
435
+
test('identifies HTML by .htm extension', () => {
436
+
expect(isHtmlContent('index.htm')).toBe(true)
437
+
expect(isHtmlContent('page.htm', undefined)).toBe(true)
438
+
})
439
+
440
+
test('handles case-insensitive extensions', () => {
441
+
expect(isHtmlContent('INDEX.HTML')).toBe(true)
442
+
expect(isHtmlContent('page.HTM')).toBe(true)
443
+
expect(isHtmlContent('File.HtMl')).toBe(true)
444
+
})
445
+
446
+
test('returns false for non-HTML files', () => {
447
+
expect(isHtmlContent('script.js')).toBe(false)
448
+
expect(isHtmlContent('style.css')).toBe(false)
449
+
expect(isHtmlContent('image.png')).toBe(false)
450
+
expect(isHtmlContent('data.json')).toBe(false)
451
+
})
452
+
453
+
test('returns false for files with no extension', () => {
454
+
expect(isHtmlContent('README')).toBe(false)
455
+
expect(isHtmlContent('Makefile')).toBe(false)
456
+
})
457
+
})
+178
-104
hosting-service/src/lib/html-rewriter.ts
+178
-104
hosting-service/src/lib/html-rewriter.ts
···
4
4
*/
5
5
6
6
const REWRITABLE_ATTRIBUTES = [
7
-
'src',
8
-
'href',
9
-
'action',
10
-
'data',
11
-
'poster',
12
-
'srcset',
13
-
] as const;
7
+
'src',
8
+
'href',
9
+
'action',
10
+
'data',
11
+
'poster',
12
+
'srcset'
13
+
] as const
14
14
15
15
/**
16
16
* Check if a path should be rewritten
17
17
*/
18
18
function shouldRewritePath(path: string): boolean {
19
-
// Don't rewrite empty paths
20
-
if (!path) return false;
19
+
// Don't rewrite empty paths
20
+
if (!path) return false
21
+
22
+
// Don't rewrite external URLs (http://, https://, //)
23
+
if (
24
+
path.startsWith('http://') ||
25
+
path.startsWith('https://') ||
26
+
path.startsWith('//')
27
+
) {
28
+
return false
29
+
}
30
+
31
+
// Don't rewrite data URIs or other schemes (except file paths)
32
+
if (
33
+
path.includes(':') &&
34
+
!path.startsWith('./') &&
35
+
!path.startsWith('../')
36
+
) {
37
+
return false
38
+
}
39
+
40
+
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
41
+
return true
42
+
}
21
43
22
-
// Don't rewrite external URLs (http://, https://, //)
23
-
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
24
-
return false;
25
-
}
44
+
/**
45
+
* Normalize a path by resolving . and .. segments
46
+
*/
47
+
function normalizePath(path: string): string {
48
+
const parts = path.split('/')
49
+
const result: string[] = []
26
50
27
-
// Don't rewrite data URIs or other schemes (except file paths)
28
-
if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) {
29
-
return false;
30
-
}
51
+
for (const part of parts) {
52
+
if (part === '.' || part === '') {
53
+
// Skip current directory and empty parts (but keep leading empty for absolute paths)
54
+
if (part === '' && result.length === 0) {
55
+
result.push(part)
56
+
}
57
+
continue
58
+
}
59
+
if (part === '..') {
60
+
// Go up one directory (but not past root)
61
+
if (result.length > 0 && result[result.length - 1] !== '..') {
62
+
result.pop()
63
+
}
64
+
continue
65
+
}
66
+
result.push(part)
67
+
}
31
68
32
-
// Don't rewrite pure anchors
33
-
if (path.startsWith('#')) return false;
69
+
return result.join('/')
70
+
}
34
71
35
-
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
36
-
return true;
72
+
/**
73
+
* Get the directory path from a file path
74
+
* e.g., "folder1/folder2/file.html" -> "folder1/folder2/"
75
+
*/
76
+
function getDirectory(filepath: string): string {
77
+
const lastSlash = filepath.lastIndexOf('/')
78
+
if (lastSlash === -1) {
79
+
return ''
80
+
}
81
+
return filepath.substring(0, lastSlash + 1)
37
82
}
38
83
39
84
/**
40
85
* Rewrite a single path
41
86
*/
42
-
function rewritePath(path: string, basePath: string): string {
43
-
if (!shouldRewritePath(path)) {
44
-
return path;
45
-
}
87
+
function rewritePath(
88
+
path: string,
89
+
basePath: string,
90
+
documentPath: string
91
+
): string {
92
+
if (!shouldRewritePath(path)) {
93
+
return path
94
+
}
46
95
47
-
// Handle absolute paths: /file.js -> /base/file.js
48
-
if (path.startsWith('/')) {
49
-
return basePath + path.slice(1);
50
-
}
96
+
// Handle absolute paths: /file.js -> /base/file.js
97
+
if (path.startsWith('/')) {
98
+
return basePath + path.slice(1)
99
+
}
51
100
52
-
// Handle relative paths: ./file.js or ../file.js or file.js -> /base/file.js
53
-
// Strip leading ./ or ../ and just use the base path
54
-
let cleanPath = path;
55
-
if (cleanPath.startsWith('./')) {
56
-
cleanPath = cleanPath.slice(2);
57
-
} else if (cleanPath.startsWith('../')) {
58
-
// For sites.wisp.place, we can't go up from the site root, so just use base path
59
-
cleanPath = cleanPath.replace(/^(\.\.\/)+/, '');
60
-
}
101
+
// Handle relative paths by resolving against document directory
102
+
const documentDir = getDirectory(documentPath)
103
+
let resolvedPath: string
104
+
105
+
if (path.startsWith('./')) {
106
+
// ./file.js relative to current directory
107
+
resolvedPath = documentDir + path.slice(2)
108
+
} else if (path.startsWith('../')) {
109
+
// ../file.js relative to parent directory
110
+
resolvedPath = documentDir + path
111
+
} else {
112
+
// file.js (no prefix) - treat as relative to current directory
113
+
resolvedPath = documentDir + path
114
+
}
115
+
116
+
// Normalize the path to resolve .. and .
117
+
resolvedPath = normalizePath(resolvedPath)
61
118
62
-
return basePath + cleanPath;
119
+
return basePath + resolvedPath
63
120
}
64
121
65
122
/**
66
123
* Rewrite srcset attribute (can contain multiple URLs)
67
124
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
68
125
*/
69
-
function rewriteSrcset(srcset: string, basePath: string): string {
70
-
return srcset
71
-
.split(',')
72
-
.map(part => {
73
-
const trimmed = part.trim();
74
-
const spaceIndex = trimmed.indexOf(' ');
126
+
function rewriteSrcset(
127
+
srcset: string,
128
+
basePath: string,
129
+
documentPath: string
130
+
): string {
131
+
return srcset
132
+
.split(',')
133
+
.map((part) => {
134
+
const trimmed = part.trim()
135
+
const spaceIndex = trimmed.indexOf(' ')
75
136
76
-
if (spaceIndex === -1) {
77
-
// No descriptor, just URL
78
-
return rewritePath(trimmed, basePath);
79
-
}
137
+
if (spaceIndex === -1) {
138
+
// No descriptor, just URL
139
+
return rewritePath(trimmed, basePath, documentPath)
140
+
}
80
141
81
-
const url = trimmed.substring(0, spaceIndex);
82
-
const descriptor = trimmed.substring(spaceIndex);
83
-
return rewritePath(url, basePath) + descriptor;
84
-
})
85
-
.join(', ');
142
+
const url = trimmed.substring(0, spaceIndex)
143
+
const descriptor = trimmed.substring(spaceIndex)
144
+
return rewritePath(url, basePath, documentPath) + descriptor
145
+
})
146
+
.join(', ')
86
147
}
87
148
88
149
/**
89
-
* Rewrite absolute paths in HTML content
150
+
* Rewrite absolute and relative paths in HTML content
90
151
* Uses simple regex matching for safety (no full HTML parsing)
91
152
*/
92
-
export function rewriteHtmlPaths(html: string, basePath: string): string {
93
-
// Ensure base path ends with /
94
-
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
153
+
export function rewriteHtmlPaths(
154
+
html: string,
155
+
basePath: string,
156
+
documentPath: string
157
+
): string {
158
+
// Ensure base path ends with /
159
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'
95
160
96
-
let rewritten = html;
161
+
let rewritten = html
97
162
98
-
// Rewrite each attribute type
99
-
// Use more specific patterns to prevent ReDoS attacks
100
-
for (const attr of REWRITABLE_ATTRIBUTES) {
101
-
if (attr === 'srcset') {
102
-
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
103
-
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
104
-
const srcsetRegex = new RegExp(
105
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
106
-
'gi'
107
-
);
108
-
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
109
-
const rewrittenValue = rewriteSrcset(value, normalizedBase);
110
-
return `${attr}="${rewrittenValue}"`;
111
-
});
112
-
} else {
113
-
// Regular attributes with quoted values
114
-
// Limit whitespace to prevent catastrophic backtracking
115
-
const doubleQuoteRegex = new RegExp(
116
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
117
-
'gi'
118
-
);
119
-
const singleQuoteRegex = new RegExp(
120
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
121
-
'gi'
122
-
);
163
+
// Rewrite each attribute type
164
+
// Use more specific patterns to prevent ReDoS attacks
165
+
for (const attr of REWRITABLE_ATTRIBUTES) {
166
+
if (attr === 'srcset') {
167
+
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
168
+
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
169
+
const srcsetRegex = new RegExp(
170
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
171
+
'gi'
172
+
)
173
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
174
+
const rewrittenValue = rewriteSrcset(
175
+
value,
176
+
normalizedBase,
177
+
documentPath
178
+
)
179
+
return `${attr}="${rewrittenValue}"`
180
+
})
181
+
} else {
182
+
// Regular attributes with quoted values
183
+
// Limit whitespace to prevent catastrophic backtracking
184
+
const doubleQuoteRegex = new RegExp(
185
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
186
+
'gi'
187
+
)
188
+
const singleQuoteRegex = new RegExp(
189
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
190
+
'gi'
191
+
)
123
192
124
-
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
125
-
const rewrittenValue = rewritePath(value, normalizedBase);
126
-
return `${attr}="${rewrittenValue}"`;
127
-
});
193
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
194
+
const rewrittenValue = rewritePath(
195
+
value,
196
+
normalizedBase,
197
+
documentPath
198
+
)
199
+
return `${attr}="${rewrittenValue}"`
200
+
})
128
201
129
-
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
130
-
const rewrittenValue = rewritePath(value, normalizedBase);
131
-
return `${attr}='${rewrittenValue}'`;
132
-
});
133
-
}
134
-
}
202
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
203
+
const rewrittenValue = rewritePath(
204
+
value,
205
+
normalizedBase,
206
+
documentPath
207
+
)
208
+
return `${attr}='${rewrittenValue}'`
209
+
})
210
+
}
211
+
}
135
212
136
-
return rewritten;
213
+
return rewritten
137
214
}
138
215
139
216
/**
140
217
* Check if content is HTML based on content or filename
141
218
*/
142
-
export function isHtmlContent(
143
-
filepath: string,
144
-
contentType?: string
145
-
): boolean {
146
-
if (contentType && contentType.includes('text/html')) {
147
-
return true;
148
-
}
219
+
export function isHtmlContent(filepath: string, contentType?: string): boolean {
220
+
if (contentType && contentType.includes('text/html')) {
221
+
return true
222
+
}
149
223
150
-
const ext = filepath.toLowerCase().split('.').pop();
151
-
return ext === 'html' || ext === 'htm';
224
+
const ext = filepath.toLowerCase().split('.').pop()
225
+
return ext === 'html' || ext === 'htm'
152
226
}
+36
-38
hosting-service/src/lib/observability.ts
+36
-38
hosting-service/src/lib/observability.ts
···
1
1
// DIY Observability for Hosting Service
2
-
import type { Context } from 'elysia'
2
+
import type { Context } from 'hono'
3
3
4
4
// Types
5
5
export interface LogEntry {
···
175
175
// Rotate if needed
176
176
if (errors.size > MAX_ERRORS) {
177
177
const oldest = Array.from(errors.keys())[0]
178
-
errors.delete(oldest)
178
+
if (oldest !== undefined) {
179
+
errors.delete(oldest)
180
+
}
179
181
}
180
182
}
181
183
},
···
262
264
return {
263
265
totalRequests: filtered.length,
264
266
avgDuration: Math.round(totalDuration / filtered.length),
265
-
p50Duration: Math.round(p50),
266
-
p95Duration: Math.round(p95),
267
-
p99Duration: Math.round(p99),
267
+
p50Duration: Math.round(p50 ?? 0),
268
+
p95Duration: Math.round(p95 ?? 0),
269
+
p99Duration: Math.round(p99 ?? 0),
268
270
errorRate: (errors / filtered.length) * 100,
269
271
requestsPerMinute: Math.round(filtered.length / timeWindowMinutes)
270
272
}
···
275
277
}
276
278
}
277
279
278
-
// Elysia middleware for request timing
280
+
// Hono middleware for request timing
279
281
export function observabilityMiddleware(service: string) {
280
-
return {
281
-
beforeHandle: ({ request }: any) => {
282
-
(request as any).__startTime = Date.now()
283
-
},
284
-
afterHandle: ({ request, set }: any) => {
285
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
286
-
const url = new URL(request.url)
282
+
return async (c: Context, next: () => Promise<void>) => {
283
+
const startTime = Date.now()
284
+
285
+
await next()
286
+
287
+
const duration = Date.now() - startTime
288
+
const { pathname } = new URL(c.req.url)
287
289
288
-
metricsCollector.recordRequest(
289
-
url.pathname,
290
-
request.method,
291
-
set.status || 200,
292
-
duration,
293
-
service
294
-
)
295
-
},
296
-
onError: ({ request, error, set }: any) => {
297
-
const duration = Date.now() - ((request as any).__startTime || Date.now())
298
-
const url = new URL(request.url)
290
+
metricsCollector.recordRequest(
291
+
pathname,
292
+
c.req.method,
293
+
c.res.status,
294
+
duration,
295
+
service
296
+
)
297
+
}
298
+
}
299
299
300
-
metricsCollector.recordRequest(
301
-
url.pathname,
302
-
request.method,
303
-
set.status || 500,
304
-
duration,
305
-
service
306
-
)
300
+
// Hono error handler
301
+
export function observabilityErrorHandler(service: string) {
302
+
return (err: Error, c: Context) => {
303
+
const { pathname } = new URL(c.req.url)
304
+
305
+
logCollector.error(
306
+
`Request failed: ${c.req.method} ${pathname}`,
307
+
service,
308
+
err,
309
+
{ statusCode: c.res.status || 500 }
310
+
)
307
311
308
-
logCollector.error(
309
-
`Request failed: ${request.method} ${url.pathname}`,
310
-
service,
311
-
error,
312
-
{ statusCode: set.status || 500 }
313
-
)
314
-
}
312
+
return c.text('Internal Server Error', 500)
315
313
}
316
314
}
317
315
+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
+
+10
-4
hosting-service/src/lib/safe-fetch.ts
+10
-4
hosting-service/src/lib/safe-fetch.ts
···
21
21
'169.254.169.254',
22
22
];
23
23
24
-
const FETCH_TIMEOUT = 5000; // 5 seconds
24
+
const FETCH_TIMEOUT = 120000; // 120 seconds
25
+
const FETCH_TIMEOUT_BLOB = 120000; // 2 minutes for blob downloads
25
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;
26
30
27
31
function isBlockedHost(hostname: string): boolean {
28
32
const lowerHost = hostname.toLowerCase();
···
71
75
const response = await fetch(url, {
72
76
...options,
73
77
signal: controller.signal,
78
+
redirect: 'follow',
74
79
});
75
80
76
81
const contentLength = response.headers.get('content-length');
···
93
98
url: string,
94
99
options?: RequestInit & { maxSize?: number; timeout?: number }
95
100
): Promise<T> {
96
-
const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON
101
+
const maxJsonSize = options?.maxSize ?? MAX_JSON_SIZE;
97
102
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
98
103
99
104
if (!response.ok) {
···
139
144
url: string,
140
145
options?: RequestInit & { maxSize?: number; timeout?: number }
141
146
): Promise<Uint8Array> {
142
-
const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
143
-
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize });
147
+
const maxBlobSize = options?.maxSize ?? MAX_BLOB_SIZE;
148
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT_BLOB;
149
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize, timeout: timeoutMs });
144
150
145
151
if (!response.ok) {
146
152
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+169
hosting-service/src/lib/utils.test.ts
+169
hosting-service/src/lib/utils.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { sanitizePath, extractBlobCid } from './utils'
3
+
import { CID } from 'multiformats'
4
+
5
+
describe('sanitizePath', () => {
6
+
test('allows normal file paths', () => {
7
+
expect(sanitizePath('index.html')).toBe('index.html')
8
+
expect(sanitizePath('css/styles.css')).toBe('css/styles.css')
9
+
expect(sanitizePath('images/logo.png')).toBe('images/logo.png')
10
+
expect(sanitizePath('js/app.js')).toBe('js/app.js')
11
+
})
12
+
13
+
test('allows deeply nested paths', () => {
14
+
expect(sanitizePath('assets/images/icons/favicon.ico')).toBe('assets/images/icons/favicon.ico')
15
+
expect(sanitizePath('a/b/c/d/e/f.txt')).toBe('a/b/c/d/e/f.txt')
16
+
})
17
+
18
+
test('removes leading slashes', () => {
19
+
expect(sanitizePath('/index.html')).toBe('index.html')
20
+
expect(sanitizePath('//index.html')).toBe('index.html')
21
+
expect(sanitizePath('///index.html')).toBe('index.html')
22
+
expect(sanitizePath('/css/styles.css')).toBe('css/styles.css')
23
+
})
24
+
25
+
test('blocks parent directory traversal', () => {
26
+
expect(sanitizePath('../etc/passwd')).toBe('etc/passwd')
27
+
expect(sanitizePath('../../etc/passwd')).toBe('etc/passwd')
28
+
expect(sanitizePath('../../../etc/passwd')).toBe('etc/passwd')
29
+
expect(sanitizePath('css/../../../etc/passwd')).toBe('css/etc/passwd')
30
+
})
31
+
32
+
test('blocks directory traversal in middle of path', () => {
33
+
expect(sanitizePath('images/../../../etc/passwd')).toBe('images/etc/passwd')
34
+
// Note: sanitizePath only filters out ".." segments, doesn't resolve paths
35
+
expect(sanitizePath('a/b/../c')).toBe('a/b/c')
36
+
expect(sanitizePath('a/../b/../c')).toBe('a/b/c')
37
+
})
38
+
39
+
test('removes current directory references', () => {
40
+
expect(sanitizePath('./index.html')).toBe('index.html')
41
+
expect(sanitizePath('././index.html')).toBe('index.html')
42
+
expect(sanitizePath('css/./styles.css')).toBe('css/styles.css')
43
+
expect(sanitizePath('./css/./styles.css')).toBe('css/styles.css')
44
+
})
45
+
46
+
test('removes empty path segments', () => {
47
+
expect(sanitizePath('css//styles.css')).toBe('css/styles.css')
48
+
expect(sanitizePath('css///styles.css')).toBe('css/styles.css')
49
+
expect(sanitizePath('a//b//c')).toBe('a/b/c')
50
+
})
51
+
52
+
test('blocks null bytes', () => {
53
+
// Null bytes cause the entire segment to be filtered out
54
+
expect(sanitizePath('index.html\0.txt')).toBe('')
55
+
expect(sanitizePath('test\0')).toBe('')
56
+
// Null byte in middle segment
57
+
expect(sanitizePath('css/bad\0name/styles.css')).toBe('css/styles.css')
58
+
})
59
+
60
+
test('handles mixed attacks', () => {
61
+
expect(sanitizePath('/../../../etc/passwd')).toBe('etc/passwd')
62
+
expect(sanitizePath('/./././../etc/passwd')).toBe('etc/passwd')
63
+
expect(sanitizePath('//../../.\0./etc/passwd')).toBe('etc/passwd')
64
+
})
65
+
66
+
test('handles edge cases', () => {
67
+
expect(sanitizePath('')).toBe('')
68
+
expect(sanitizePath('/')).toBe('')
69
+
expect(sanitizePath('//')).toBe('')
70
+
expect(sanitizePath('.')).toBe('')
71
+
expect(sanitizePath('..')).toBe('')
72
+
expect(sanitizePath('../..')).toBe('')
73
+
})
74
+
75
+
test('preserves valid special characters in filenames', () => {
76
+
expect(sanitizePath('file-name.html')).toBe('file-name.html')
77
+
expect(sanitizePath('file_name.html')).toBe('file_name.html')
78
+
expect(sanitizePath('file.name.html')).toBe('file.name.html')
79
+
expect(sanitizePath('file (1).html')).toBe('file (1).html')
80
+
expect(sanitizePath('file@2x.png')).toBe('file@2x.png')
81
+
})
82
+
83
+
test('handles Unicode characters', () => {
84
+
expect(sanitizePath('ๆไปถ.html')).toBe('ๆไปถ.html')
85
+
expect(sanitizePath('ัะฐะนะป.html')).toBe('ัะฐะนะป.html')
86
+
expect(sanitizePath('ใใกใคใซ.html')).toBe('ใใกใคใซ.html')
87
+
})
88
+
})
89
+
90
+
describe('extractBlobCid', () => {
91
+
const TEST_CID = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
92
+
93
+
test('extracts CID from IPLD link', () => {
94
+
const blobRef = { $link: TEST_CID }
95
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
96
+
})
97
+
98
+
test('extracts CID from typed BlobRef with CID object', () => {
99
+
const cid = CID.parse(TEST_CID)
100
+
const blobRef = { ref: cid }
101
+
const result = extractBlobCid(blobRef)
102
+
expect(result).toBe(TEST_CID)
103
+
})
104
+
105
+
test('extracts CID from typed BlobRef with IPLD link', () => {
106
+
const blobRef = {
107
+
ref: { $link: TEST_CID }
108
+
}
109
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
110
+
})
111
+
112
+
test('extracts CID from untyped BlobRef', () => {
113
+
const blobRef = { cid: TEST_CID }
114
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
115
+
})
116
+
117
+
test('returns null for invalid blob ref', () => {
118
+
expect(extractBlobCid(null)).toBe(null)
119
+
expect(extractBlobCid(undefined)).toBe(null)
120
+
expect(extractBlobCid({})).toBe(null)
121
+
expect(extractBlobCid('not-an-object')).toBe(null)
122
+
expect(extractBlobCid(123)).toBe(null)
123
+
})
124
+
125
+
test('returns null for malformed objects', () => {
126
+
expect(extractBlobCid({ wrongKey: 'value' })).toBe(null)
127
+
expect(extractBlobCid({ ref: 'not-a-cid' })).toBe(null)
128
+
expect(extractBlobCid({ ref: {} })).toBe(null)
129
+
})
130
+
131
+
test('handles nested structures from AT Proto API', () => {
132
+
// Real structure from AT Proto
133
+
const blobRef = {
134
+
$type: 'blob',
135
+
ref: CID.parse(TEST_CID),
136
+
mimeType: 'text/html',
137
+
size: 1234
138
+
}
139
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
140
+
})
141
+
142
+
test('handles BlobRef with additional properties', () => {
143
+
const blobRef = {
144
+
ref: { $link: TEST_CID },
145
+
mimeType: 'image/png',
146
+
size: 5678,
147
+
someOtherField: 'value'
148
+
}
149
+
expect(extractBlobCid(blobRef)).toBe(TEST_CID)
150
+
})
151
+
152
+
test('prioritizes checking IPLD link first', () => {
153
+
// Direct $link takes precedence
154
+
const directLink = { $link: TEST_CID }
155
+
expect(extractBlobCid(directLink)).toBe(TEST_CID)
156
+
})
157
+
158
+
test('handles CID v0 format', () => {
159
+
const cidV0 = 'QmZ4tDuvesekSs4qM5ZBKpXiZGun7S2CYtEZRB3DYXkjGx'
160
+
const blobRef = { $link: cidV0 }
161
+
expect(extractBlobCid(blobRef)).toBe(cidV0)
162
+
})
163
+
164
+
test('handles CID v1 format', () => {
165
+
const cidV1 = 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'
166
+
const blobRef = { $link: cidV1 }
167
+
expect(extractBlobCid(blobRef)).toBe(cidV1)
168
+
})
169
+
})
+207
-33
hosting-service/src/lib/utils.ts
+207
-33
hosting-service/src/lib/utils.ts
···
3
3
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
4
4
import { writeFile, readFile, rename } from 'fs/promises';
5
5
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
-
import { CID } from 'multiformats/cid';
6
+
import { CID } from 'multiformats';
7
7
8
-
const CACHE_DIR = './cache/sites';
8
+
const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
9
9
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
10
10
11
11
interface CacheMetadata {
···
13
13
cachedAt: number;
14
14
did: string;
15
15
rkey: string;
16
+
// Map of file path to blob CID for incremental updates
17
+
fileCids?: Record<string, string>;
18
+
}
19
+
20
+
/**
21
+
* Determines if a MIME type should benefit from gzip compression.
22
+
* Returns true for text-based web assets (HTML, CSS, JS, JSON, XML, SVG).
23
+
* Returns false for already-compressed formats (images, video, audio, PDFs).
24
+
*
25
+
*/
26
+
export function shouldCompressMimeType(mimeType: string | undefined): boolean {
27
+
if (!mimeType) return false;
28
+
29
+
const mime = mimeType.toLowerCase();
30
+
31
+
// Text-based web assets that benefit from compression
32
+
const compressibleTypes = [
33
+
'text/html',
34
+
'text/css',
35
+
'text/javascript',
36
+
'application/javascript',
37
+
'application/x-javascript',
38
+
'text/xml',
39
+
'application/xml',
40
+
'application/json',
41
+
'text/plain',
42
+
'image/svg+xml',
43
+
];
44
+
45
+
if (compressibleTypes.some(type => mime === type || mime.startsWith(type))) {
46
+
return true;
47
+
}
48
+
49
+
// Already-compressed formats that should NOT be double-compressed
50
+
const alreadyCompressedPrefixes = [
51
+
'video/',
52
+
'audio/',
53
+
'image/',
54
+
'application/pdf',
55
+
'application/zip',
56
+
'application/gzip',
57
+
];
58
+
59
+
if (alreadyCompressedPrefixes.some(prefix => mime.startsWith(prefix))) {
60
+
return false;
61
+
}
62
+
63
+
// Default to not compressing for unknown types
64
+
return false;
16
65
}
17
66
18
67
interface IpldLink {
···
153
202
throw new Error('Invalid record structure: root missing entries array');
154
203
}
155
204
205
+
// Get existing cache metadata to check for incremental updates
206
+
const existingMetadata = await getCacheMetadata(did, rkey);
207
+
const existingFileCids = existingMetadata?.fileCids || {};
208
+
156
209
// Use a temporary directory with timestamp to avoid collisions
157
210
const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
158
211
const tempDir = `${CACHE_DIR}/${did}/${rkey}${tempSuffix}`;
159
212
const finalDir = `${CACHE_DIR}/${did}/${rkey}`;
160
213
161
214
try {
162
-
// Download to temporary directory
163
-
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix);
164
-
await saveCacheMetadata(did, rkey, recordCid, tempSuffix);
215
+
// Collect file CIDs from the new record
216
+
const newFileCids: Record<string, string> = {};
217
+
collectFileCidsFromEntries(record.root.entries, '', newFileCids);
218
+
219
+
// Download/copy files to temporary directory (with incremental logic)
220
+
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir);
221
+
await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids);
165
222
166
223
// Atomically replace old cache with new cache
167
224
// On POSIX systems (Linux/macOS), rename is atomic
···
198
255
}
199
256
}
200
257
258
+
/**
259
+
* Recursively collect file CIDs from entries for incremental update tracking
260
+
*/
261
+
function collectFileCidsFromEntries(entries: Entry[], pathPrefix: string, fileCids: Record<string, string>): void {
262
+
for (const entry of entries) {
263
+
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
264
+
const node = entry.node;
265
+
266
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
267
+
collectFileCidsFromEntries(node.entries, currentPath, fileCids);
268
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
269
+
const fileNode = node as File;
270
+
const cid = extractBlobCid(fileNode.blob);
271
+
if (cid) {
272
+
fileCids[currentPath] = cid;
273
+
}
274
+
}
275
+
}
276
+
}
277
+
201
278
async function cacheFiles(
202
279
did: string,
203
280
site: string,
204
281
entries: Entry[],
205
282
pdsEndpoint: string,
206
283
pathPrefix: string,
207
-
dirSuffix: string = ''
284
+
dirSuffix: string = '',
285
+
existingFileCids: Record<string, string> = {},
286
+
existingCacheDir?: string
208
287
): Promise<void> {
209
-
// Collect all file blob download tasks first
288
+
// Collect file tasks, separating unchanged files from new/changed files
210
289
const downloadTasks: Array<() => Promise<void>> = [];
211
-
290
+
const copyTasks: Array<() => Promise<void>> = [];
291
+
212
292
function collectFileTasks(
213
293
entries: Entry[],
214
294
currentPathPrefix: string
···
221
301
collectFileTasks(node.entries, currentPath);
222
302
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
223
303
const fileNode = node as File;
224
-
downloadTasks.push(() => cacheFileBlob(
225
-
did,
226
-
site,
227
-
currentPath,
228
-
fileNode.blob,
229
-
pdsEndpoint,
230
-
fileNode.encoding,
231
-
fileNode.mimeType,
232
-
fileNode.base64,
233
-
dirSuffix
234
-
));
304
+
const cid = extractBlobCid(fileNode.blob);
305
+
306
+
// Check if file is unchanged (same CID as existing cache)
307
+
if (cid && existingFileCids[currentPath] === cid && existingCacheDir) {
308
+
// File unchanged - copy from existing cache instead of downloading
309
+
copyTasks.push(() => copyExistingFile(
310
+
did,
311
+
site,
312
+
currentPath,
313
+
dirSuffix,
314
+
existingCacheDir
315
+
));
316
+
} else {
317
+
// File new or changed - download it
318
+
downloadTasks.push(() => cacheFileBlob(
319
+
did,
320
+
site,
321
+
currentPath,
322
+
fileNode.blob,
323
+
pdsEndpoint,
324
+
fileNode.encoding,
325
+
fileNode.mimeType,
326
+
fileNode.base64,
327
+
dirSuffix
328
+
));
329
+
}
235
330
}
236
331
}
237
332
}
238
333
239
334
collectFileTasks(entries, pathPrefix);
240
335
241
-
// Execute downloads concurrently with a limit of 3 at a time
242
-
const concurrencyLimit = 3;
243
-
for (let i = 0; i < downloadTasks.length; i += concurrencyLimit) {
244
-
const batch = downloadTasks.slice(i, i + concurrencyLimit);
336
+
console.log(`[Incremental Update] Files to copy: ${copyTasks.length}, Files to download: ${downloadTasks.length}`);
337
+
338
+
// Copy unchanged files in parallel (fast local operations)
339
+
const copyLimit = 10;
340
+
for (let i = 0; i < copyTasks.length; i += copyLimit) {
341
+
const batch = copyTasks.slice(i, i + copyLimit);
342
+
await Promise.all(batch.map(task => task()));
343
+
}
344
+
345
+
// Download new/changed files concurrently with a limit of 3 at a time
346
+
const downloadLimit = 3;
347
+
for (let i = 0; i < downloadTasks.length; i += downloadLimit) {
348
+
const batch = downloadTasks.slice(i, i + downloadLimit);
245
349
await Promise.all(batch.map(task => task()));
246
350
}
247
351
}
248
352
353
+
/**
354
+
* Copy an unchanged file from existing cache to new cache location
355
+
*/
356
+
async function copyExistingFile(
357
+
did: string,
358
+
site: string,
359
+
filePath: string,
360
+
dirSuffix: string,
361
+
existingCacheDir: string
362
+
): Promise<void> {
363
+
const { copyFile } = await import('fs/promises');
364
+
365
+
const sourceFile = `${existingCacheDir}/${filePath}`;
366
+
const destFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
367
+
const destDir = destFile.substring(0, destFile.lastIndexOf('/'));
368
+
369
+
// Create destination directory if needed
370
+
if (destDir && !existsSync(destDir)) {
371
+
mkdirSync(destDir, { recursive: true });
372
+
}
373
+
374
+
try {
375
+
// Copy the file
376
+
await copyFile(sourceFile, destFile);
377
+
378
+
// Copy metadata file if it exists
379
+
const sourceMetaFile = `${sourceFile}.meta`;
380
+
const destMetaFile = `${destFile}.meta`;
381
+
if (existsSync(sourceMetaFile)) {
382
+
await copyFile(sourceMetaFile, destMetaFile);
383
+
}
384
+
385
+
console.log(`[Incremental] Copied unchanged file: ${filePath}`);
386
+
} catch (err) {
387
+
console.error(`[Incremental] Failed to copy file ${filePath}, will attempt download:`, err);
388
+
throw err;
389
+
}
390
+
}
391
+
249
392
async function cacheFileBlob(
250
393
did: string,
251
394
site: string,
···
265
408
266
409
const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
267
410
268
-
// Allow up to 100MB per file blob
269
-
let content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024 });
411
+
// Allow up to 500MB per file blob, with 5 minute timeout
412
+
let content = await safeFetchBlob(blobUrl, { maxSize: 500 * 1024 * 1024, timeout: 300000 });
270
413
271
-
// If content is base64-encoded, decode it back to gzipped binary
272
-
if (base64 && encoding === 'gzip') {
273
-
// Convert Uint8Array to Buffer for proper string conversion
274
-
const buffer = Buffer.from(content);
275
-
const base64String = buffer.toString('utf-8');
414
+
console.log(`[DEBUG] ${filePath}: fetched ${content.length} bytes, base64=${base64}, encoding=${encoding}, mimeType=${mimeType}`);
415
+
416
+
// If content is base64-encoded, decode it back to raw binary (gzipped or not)
417
+
if (base64) {
418
+
const originalSize = content.length;
419
+
// Decode base64 directly from raw bytes - no string conversion
420
+
// The blob contains base64-encoded text as raw bytes, decode it in-place
421
+
const textDecoder = new TextDecoder();
422
+
const base64String = textDecoder.decode(content);
276
423
content = Buffer.from(base64String, 'base64');
424
+
console.log(`[DEBUG] ${filePath}: decoded base64 from ${originalSize} bytes to ${content.length} bytes`);
425
+
426
+
// Check if it's actually gzipped by looking at magic bytes
427
+
if (content.length >= 2) {
428
+
const hasGzipMagic = content[0] === 0x1f && content[1] === 0x8b;
429
+
console.log(`[DEBUG] ${filePath}: has gzip magic bytes: ${hasGzipMagic}`);
430
+
}
277
431
}
278
432
279
433
const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
···
283
437
mkdirSync(fileDir, { recursive: true });
284
438
}
285
439
440
+
// Use the shared function to determine if this should remain compressed
441
+
const shouldStayCompressed = shouldCompressMimeType(mimeType);
442
+
443
+
// Decompress files that shouldn't be stored compressed
444
+
if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 &&
445
+
content[0] === 0x1f && content[1] === 0x8b) {
446
+
console.log(`[DEBUG] ${filePath}: decompressing non-compressible type (${mimeType}) before caching`);
447
+
try {
448
+
const { gunzipSync } = await import('zlib');
449
+
const decompressed = gunzipSync(content);
450
+
console.log(`[DEBUG] ${filePath}: decompressed from ${content.length} to ${decompressed.length} bytes`);
451
+
content = decompressed;
452
+
// Clear the encoding flag since we're storing decompressed
453
+
encoding = undefined;
454
+
} catch (error) {
455
+
console.log(`[DEBUG] ${filePath}: failed to decompress, storing original gzipped content. Error:`, error);
456
+
}
457
+
}
458
+
286
459
await writeFile(cacheFile, content);
287
460
288
-
// Store metadata if file is compressed
461
+
// Store metadata only if file is still compressed
289
462
if (encoding === 'gzip' && mimeType) {
290
463
const metaFile = `${cacheFile}.meta`;
291
464
await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
···
327
500
return existsSync(`${CACHE_DIR}/${did}/${site}`);
328
501
}
329
502
330
-
async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = ''): Promise<void> {
503
+
async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>): Promise<void> {
331
504
const metadata: CacheMetadata = {
332
505
recordCid,
333
506
cachedAt: Date.now(),
334
507
did,
335
-
rkey
508
+
rkey,
509
+
fileCids
336
510
};
337
511
338
512
const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`;
+505
-219
hosting-service/src/server.ts
+505
-219
hosting-service/src/server.ts
···
1
-
import { Elysia } from 'elysia';
2
-
import { node } from '@elysiajs/node'
3
-
import { opentelemetry } from '@elysiajs/opentelemetry';
1
+
import { Hono } from 'hono';
4
2
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
5
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath } from './lib/utils';
3
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
6
4
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
7
-
import { existsSync, readFileSync } from 'fs';
5
+
import { existsSync } from 'fs';
6
+
import { readFile, access } from 'fs/promises';
8
7
import { lookup } from 'mime-types';
9
-
import { logger, observabilityMiddleware, logCollector, errorTracker, metricsCollector } from './lib/observability';
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';
10
11
11
12
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
12
13
···
23
24
return validRkeyPattern.test(rkey);
24
25
}
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
+
26
51
// Helper to serve files from cache
27
-
async function serveFromCache(did: string, rkey: string, filePath: string) {
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) {
28
118
// Default to index.html if path is empty or ends with /
29
119
let requestPath = filePath || 'index.html';
30
120
if (requestPath.endsWith('/')) {
31
121
requestPath += 'index.html';
32
122
}
33
123
124
+
const cacheKey = getCacheKey(did, rkey, requestPath);
34
125
const cachedFile = getCachedFilePath(did, rkey, requestPath);
35
126
36
-
if (existsSync(cachedFile)) {
37
-
const content = readFileSync(cachedFile);
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
+
38
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
+
}
39
143
40
-
// Check if file has compression metadata
41
-
if (existsSync(metaFile)) {
42
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
43
-
if (meta.encoding === 'gzip' && meta.mimeType) {
44
-
// Serve gzipped content with proper headers
45
-
return new Response(content, {
46
-
headers: {
47
-
'Content-Type': meta.mimeType,
48
-
'Content-Encoding': 'gzip',
49
-
},
50
-
});
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 });
51
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 });
52
165
}
53
166
54
-
// Serve non-compressed files normally
167
+
// Non-compressed files
55
168
const mimeType = lookup(cachedFile) || 'application/octet-stream';
56
-
return new Response(content, {
57
-
headers: {
58
-
'Content-Type': mimeType,
59
-
},
60
-
});
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 });
61
174
}
62
175
63
176
// Try index.html for directory-like paths
64
177
if (!requestPath.includes('.')) {
65
-
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
66
-
if (existsSync(indexFile)) {
67
-
const content = readFileSync(indexFile);
68
-
const metaFile = `${indexFile}.meta`;
178
+
const indexPath = `${requestPath}/index.html`;
179
+
const indexCacheKey = getCacheKey(did, rkey, indexPath);
180
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
69
181
70
-
// Check if file has compression metadata
71
-
if (existsSync(metaFile)) {
72
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
73
-
if (meta.encoding === 'gzip' && meta.mimeType) {
74
-
return new Response(content, {
75
-
headers: {
76
-
'Content-Type': meta.mimeType,
77
-
'Content-Encoding': 'gzip',
78
-
},
79
-
});
80
-
}
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);
81
194
}
195
+
}
82
196
83
-
return new Response(content, {
84
-
headers: {
85
-
'Content-Type': 'text/html; charset=utf-8',
86
-
},
87
-
});
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 });
88
208
}
89
209
}
90
210
···
96
216
did: string,
97
217
rkey: string,
98
218
filePath: string,
99
-
basePath: string
219
+
basePath: string,
220
+
fullUrl?: string,
221
+
headers?: Record<string, string>
100
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) {
101
287
// Default to index.html if path is empty or ends with /
102
288
let requestPath = filePath || 'index.html';
103
289
if (requestPath.endsWith('/')) {
104
290
requestPath += 'index.html';
105
291
}
106
292
293
+
const cacheKey = getCacheKey(did, rkey, requestPath);
107
294
const cachedFile = getCachedFilePath(did, rkey, requestPath);
108
295
109
-
if (existsSync(cachedFile)) {
110
-
const metaFile = `${cachedFile}.meta`;
111
-
let mimeType = lookup(cachedFile) || 'application/octet-stream';
112
-
let isGzipped = false;
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
+
}
113
311
114
-
// Check if file has compression metadata
115
-
if (existsSync(metaFile)) {
116
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
117
-
if (meta.encoding === 'gzip' && meta.mimeType) {
118
-
mimeType = meta.mimeType;
119
-
isGzipped = true;
120
-
}
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);
121
326
}
327
+
}
328
+
329
+
if (content) {
330
+
const mimeType = meta?.mimeType || lookup(cachedFile) || 'application/octet-stream';
331
+
const isGzipped = meta?.encoding === 'gzip';
122
332
123
333
// Check if this is HTML content that needs rewriting
124
-
// Note: For gzipped HTML with path rewriting, we need to decompress, rewrite, and serve uncompressed
125
-
// This is a trade-off for the sites.wisp.place domain which needs path rewriting
126
334
if (isHtmlContent(requestPath, mimeType)) {
127
-
let content: string;
335
+
let htmlContent: string;
128
336
if (isGzipped) {
129
337
const { gunzipSync } = await import('zlib');
130
-
const compressed = readFileSync(cachedFile);
131
-
content = gunzipSync(compressed).toString('utf-8');
338
+
htmlContent = gunzipSync(content).toString('utf-8');
132
339
} else {
133
-
content = readFileSync(cachedFile, 'utf-8');
340
+
htmlContent = content.toString('utf-8');
134
341
}
135
-
const rewritten = rewriteHtmlPaths(content, basePath);
136
-
return new Response(rewritten, {
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, {
137
352
headers: {
138
353
'Content-Type': 'text/html; charset=utf-8',
354
+
'Content-Encoding': 'gzip',
355
+
'Cache-Control': 'public, max-age=300',
139
356
},
140
357
});
141
358
}
142
359
143
-
// Non-HTML files: serve gzipped content as-is with proper headers
144
-
const content = readFileSync(cachedFile);
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
+
145
366
if (isGzipped) {
146
-
return new Response(content, {
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, {
147
390
headers: {
148
-
'Content-Type': mimeType,
391
+
'Content-Type': 'text/html; charset=utf-8',
149
392
'Content-Encoding': 'gzip',
393
+
'Cache-Control': 'public, max-age=300',
150
394
},
151
395
});
152
396
}
153
-
return new Response(content, {
154
-
headers: {
155
-
'Content-Type': mimeType,
156
-
},
157
-
});
158
-
}
159
397
160
-
// Try index.html for directory-like paths
161
-
if (!requestPath.includes('.')) {
162
-
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
163
-
if (existsSync(indexFile)) {
164
-
const metaFile = `${indexFile}.meta`;
165
-
let isGzipped = false;
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);
166
404
167
-
if (existsSync(metaFile)) {
168
-
const meta = JSON.parse(readFileSync(metaFile, 'utf-8'));
169
-
if (meta.encoding === 'gzip') {
170
-
isGzipped = true;
171
-
}
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);
172
410
}
411
+
}
173
412
174
-
// HTML needs path rewriting, so decompress if needed
175
-
let content: string;
413
+
if (indexContent) {
414
+
const isGzipped = indexMeta?.encoding === 'gzip';
415
+
416
+
let htmlContent: string;
176
417
if (isGzipped) {
177
418
const { gunzipSync } = await import('zlib');
178
-
const compressed = readFileSync(indexFile);
179
-
content = gunzipSync(compressed).toString('utf-8');
419
+
htmlContent = gunzipSync(indexContent).toString('utf-8');
180
420
} else {
181
-
content = readFileSync(indexFile, 'utf-8');
421
+
htmlContent = indexContent.toString('utf-8');
182
422
}
183
-
const rewritten = rewriteHtmlPaths(content, basePath);
184
-
return new Response(rewritten, {
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, {
185
431
headers: {
186
432
'Content-Type': 'text/html; charset=utf-8',
433
+
'Content-Encoding': 'gzip',
434
+
'Cache-Control': 'public, max-age=300',
187
435
},
188
436
});
189
437
}
···
213
461
214
462
try {
215
463
await downloadAndCacheSite(did, rkey, siteData.record, pdsEndpoint, siteData.cid);
464
+
// Clear redirect rules cache since the site was updated
465
+
clearRedirectRulesCache(did, rkey);
216
466
logger.info('Site cached successfully', { did, rkey });
217
467
return true;
218
468
} catch (err) {
···
221
471
}
222
472
}
223
473
224
-
const app = new Elysia({ adapter: node() })
225
-
.use(opentelemetry())
226
-
.onBeforeHandle(observabilityMiddleware('hosting-service').beforeHandle)
227
-
.onAfterHandle(observabilityMiddleware('hosting-service').afterHandle)
228
-
.onError(observabilityMiddleware('hosting-service').onError)
229
-
.get('/*', async ({ request, set }) => {
230
-
const url = new URL(request.url);
231
-
const hostname = request.headers.get('host') || '';
232
-
const rawPath = url.pathname.replace(/^\//, '');
233
-
const path = sanitizePath(rawPath);
474
+
const app = new Hono();
234
475
235
-
// Check if this is sites.wisp.place subdomain
236
-
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
237
-
// Sanitize the path FIRST to prevent path traversal
238
-
const sanitizedFullPath = sanitizePath(rawPath);
476
+
// Add observability middleware
477
+
app.use('*', observabilityMiddleware('hosting-service'));
239
478
240
-
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
241
-
const pathParts = sanitizedFullPath.split('/');
242
-
if (pathParts.length < 2) {
243
-
set.status = 400;
244
-
return 'Invalid path format. Expected: /identifier/sitename/path';
245
-
}
479
+
// Error handler
480
+
app.onError(observabilityErrorHandler('hosting-service'));
246
481
247
-
const identifier = pathParts[0];
248
-
const site = pathParts[1];
249
-
const filePath = pathParts.slice(2).join('/');
482
+
// Main site serving route
483
+
app.get('/*', async (c) => {
484
+
const url = new URL(c.req.url);
485
+
const hostname = c.req.header('host') || '';
486
+
const rawPath = url.pathname.replace(/^\//, '');
487
+
const path = sanitizePath(rawPath);
250
488
251
-
// Additional validation: identifier must be a valid DID or handle format
252
-
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
253
-
set.status = 400;
254
-
return 'Invalid identifier';
255
-
}
489
+
// Check if this is sites.wisp.place subdomain
490
+
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
491
+
// Sanitize the path FIRST to prevent path traversal
492
+
const sanitizedFullPath = sanitizePath(rawPath);
256
493
257
-
// Validate site name (rkey)
258
-
if (!isValidRkey(site)) {
259
-
set.status = 400;
260
-
return 'Invalid site name';
261
-
}
494
+
// Extract identifier and site from sanitized path: did:plc:123abc/sitename/file.html
495
+
const pathParts = sanitizedFullPath.split('/');
496
+
if (pathParts.length < 2) {
497
+
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
498
+
}
262
499
263
-
// Resolve identifier to DID
264
-
const did = await resolveDid(identifier);
265
-
if (!did) {
266
-
set.status = 400;
267
-
return 'Invalid identifier';
268
-
}
500
+
const identifier = pathParts[0];
501
+
const site = pathParts[1];
502
+
const filePath = pathParts.slice(2).join('/');
269
503
270
-
// Ensure site is cached
271
-
const cached = await ensureSiteCached(did, site);
272
-
if (!cached) {
273
-
set.status = 404;
274
-
return 'Site not found';
275
-
}
504
+
// Additional validation: identifier must be a valid DID or handle format
505
+
if (!identifier || identifier.length < 3 || identifier.includes('..') || identifier.includes('\0')) {
506
+
return c.text('Invalid identifier', 400);
507
+
}
276
508
277
-
// Serve with HTML path rewriting to handle absolute paths
278
-
const basePath = `/${identifier}/${site}/`;
279
-
return serveFromCacheWithRewrite(did, site, filePath, basePath);
509
+
// Validate site parameter exists
510
+
if (!site) {
511
+
return c.text('Site name required', 400);
280
512
}
281
513
282
-
// Check if this is a DNS hash subdomain
283
-
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
284
-
if (dnsMatch) {
285
-
const hash = dnsMatch[1];
286
-
const baseDomain = dnsMatch[2];
514
+
// Validate site name (rkey)
515
+
if (!isValidRkey(site)) {
516
+
return c.text('Invalid site name', 400);
517
+
}
287
518
288
-
if (baseDomain !== BASE_HOST) {
289
-
set.status = 400;
290
-
return 'Invalid base domain';
291
-
}
519
+
// Resolve identifier to DID
520
+
const did = await resolveDid(identifier);
521
+
if (!did) {
522
+
return c.text('Invalid identifier', 400);
523
+
}
292
524
293
-
const customDomain = await getCustomDomainByHash(hash);
294
-
if (!customDomain) {
295
-
set.status = 404;
296
-
return 'Custom domain not found or not verified';
297
-
}
525
+
// Ensure site is cached
526
+
const cached = await ensureSiteCached(did, site);
527
+
if (!cached) {
528
+
return c.text('Site not found', 404);
529
+
}
298
530
299
-
const rkey = customDomain.rkey || 'self';
300
-
if (!isValidRkey(rkey)) {
301
-
set.status = 500;
302
-
return 'Invalid site configuration';
303
-
}
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
+
}
304
539
305
-
const cached = await ensureSiteCached(customDomain.did, rkey);
306
-
if (!cached) {
307
-
set.status = 404;
308
-
return 'Site not found';
309
-
}
540
+
// Check if this is a DNS hash subdomain
541
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
542
+
if (dnsMatch) {
543
+
const hash = dnsMatch[1];
544
+
const baseDomain = dnsMatch[2];
310
545
311
-
return serveFromCache(customDomain.did, rkey, path);
546
+
if (!hash) {
547
+
return c.text('Invalid DNS hash', 400);
548
+
}
549
+
550
+
if (baseDomain !== BASE_HOST) {
551
+
return c.text('Invalid base domain', 400);
552
+
}
553
+
554
+
const customDomain = await getCustomDomainByHash(hash);
555
+
if (!customDomain) {
556
+
return c.text('Custom domain not found or not verified', 404);
312
557
}
313
558
314
-
// Route 2: Registered subdomains - /*.wisp.place/*
315
-
if (hostname.endsWith(`.${BASE_HOST}`)) {
316
-
const subdomain = hostname.replace(`.${BASE_HOST}`, '');
559
+
if (!customDomain.rkey) {
560
+
return c.text('Domain not mapped to a site', 404);
561
+
}
317
562
318
-
const domainInfo = await getWispDomain(hostname);
319
-
if (!domainInfo) {
320
-
set.status = 404;
321
-
return 'Subdomain not registered';
322
-
}
563
+
const rkey = customDomain.rkey;
564
+
if (!isValidRkey(rkey)) {
565
+
return c.text('Invalid site configuration', 500);
566
+
}
323
567
324
-
const rkey = domainInfo.rkey || 'self';
325
-
if (!isValidRkey(rkey)) {
326
-
set.status = 500;
327
-
return 'Invalid site configuration';
328
-
}
568
+
const cached = await ensureSiteCached(customDomain.did, rkey);
569
+
if (!cached) {
570
+
return c.text('Site not found', 404);
571
+
}
329
572
330
-
const cached = await ensureSiteCached(domainInfo.did, rkey);
331
-
if (!cached) {
332
-
set.status = 404;
333
-
return 'Site not found';
334
-
}
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
+
}
335
579
336
-
return serveFromCache(domainInfo.did, rkey, path);
580
+
// Route 2: Registered subdomains - /*.wisp.place/*
581
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
582
+
const domainInfo = await getWispDomain(hostname);
583
+
if (!domainInfo) {
584
+
return c.text('Subdomain not registered', 404);
337
585
}
338
586
339
-
// Route 1: Custom domains - /*
340
-
const customDomain = await getCustomDomain(hostname);
341
-
if (!customDomain) {
342
-
set.status = 404;
343
-
return 'Custom domain not found or not verified';
587
+
if (!domainInfo.rkey) {
588
+
return c.text('Domain not mapped to a site', 404);
344
589
}
345
590
346
-
const rkey = customDomain.rkey || 'self';
591
+
const rkey = domainInfo.rkey;
347
592
if (!isValidRkey(rkey)) {
348
-
set.status = 500;
349
-
return 'Invalid site configuration';
593
+
return c.text('Invalid site configuration', 500);
350
594
}
351
595
352
-
const cached = await ensureSiteCached(customDomain.did, rkey);
596
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
353
597
if (!cached) {
354
-
set.status = 404;
355
-
return 'Site not found';
598
+
return c.text('Site not found', 404);
356
599
}
357
600
358
-
return serveFromCache(customDomain.did, rkey, path);
359
-
})
360
-
// Internal observability endpoints (for admin panel)
361
-
.get('/__internal__/observability/logs', ({ query }) => {
362
-
const filter: any = {};
363
-
if (query.level) filter.level = query.level;
364
-
if (query.service) filter.service = query.service;
365
-
if (query.search) filter.search = query.search;
366
-
if (query.eventType) filter.eventType = query.eventType;
367
-
if (query.limit) filter.limit = parseInt(query.limit as string);
368
-
return { logs: logCollector.getLogs(filter) };
369
-
})
370
-
.get('/__internal__/observability/errors', ({ query }) => {
371
-
const filter: any = {};
372
-
if (query.service) filter.service = query.service;
373
-
if (query.limit) filter.limit = parseInt(query.limit as string);
374
-
return { errors: errorTracker.getErrors(filter) };
375
-
})
376
-
.get('/__internal__/observability/metrics', ({ query }) => {
377
-
const timeWindow = query.timeWindow ? parseInt(query.timeWindow as string) : 3600000;
378
-
const stats = metricsCollector.getStats('hosting-service', timeWindow);
379
-
return { stats, timeWindow };
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 - /*
609
+
const customDomain = await getCustomDomain(hostname);
610
+
if (!customDomain) {
611
+
return c.text('Custom domain not found or not verified', 404);
612
+
}
613
+
614
+
if (!customDomain.rkey) {
615
+
return c.text('Domain not mapped to a site', 404);
616
+
}
617
+
618
+
const rkey = customDomain.rkey;
619
+
if (!isValidRkey(rkey)) {
620
+
return c.text('Invalid site configuration', 500);
621
+
}
622
+
623
+
const cached = await ensureSiteCached(customDomain.did, rkey);
624
+
if (!cached) {
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;
380
631
});
632
+
return serveFromCache(customDomain.did, rkey, path, c.req.url, headers);
633
+
});
634
+
635
+
// Internal observability endpoints (for admin panel)
636
+
app.get('/__internal__/observability/logs', (c) => {
637
+
const query = c.req.query();
638
+
const filter: any = {};
639
+
if (query.level) filter.level = query.level;
640
+
if (query.service) filter.service = query.service;
641
+
if (query.search) filter.search = query.search;
642
+
if (query.eventType) filter.eventType = query.eventType;
643
+
if (query.limit) filter.limit = parseInt(query.limit as string);
644
+
return c.json({ logs: logCollector.getLogs(filter) });
645
+
});
646
+
647
+
app.get('/__internal__/observability/errors', (c) => {
648
+
const query = c.req.query();
649
+
const filter: any = {};
650
+
if (query.service) filter.service = query.service;
651
+
if (query.limit) filter.limit = parseInt(query.limit as string);
652
+
return c.json({ errors: errorTracker.getErrors(filter) });
653
+
});
654
+
655
+
app.get('/__internal__/observability/metrics', (c) => {
656
+
const query = c.req.query();
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
+
});
381
667
382
668
export default app;
+30
hosting-service/tsconfig.json
+30
hosting-service/tsconfig.json
···
1
+
{
2
+
"compilerOptions": {
3
+
/* Base Options */
4
+
"esModuleInterop": true,
5
+
"skipLibCheck": true,
6
+
"target": "es2022",
7
+
"allowJs": true,
8
+
"resolveJsonModule": true,
9
+
"moduleDetection": "force",
10
+
"isolatedModules": true,
11
+
"verbatimModuleSyntax": true,
12
+
13
+
/* Strictness */
14
+
"strict": true,
15
+
"noUncheckedIndexedAccess": true,
16
+
"noImplicitOverride": true,
17
+
"forceConsistentCasingInFileNames": true,
18
+
19
+
/* Transpiling with TypeScript */
20
+
"module": "ESNext",
21
+
"moduleResolution": "bundler",
22
+
"outDir": "dist",
23
+
"sourceMap": true,
24
+
25
+
/* Code doesn't run in DOM */
26
+
"lib": ["es2022"],
27
+
},
28
+
"include": ["src/**/*"],
29
+
"exclude": ["node_modules", "cache", "dist"]
30
+
}
+10
-2
package.json
+10
-2
package.json
···
2
2
"name": "elysia-static",
3
3
"version": "1.0.50",
4
4
"scripts": {
5
-
"test": "echo \"Error: no test specified\" && exit 1",
5
+
"test": "bun test",
6
6
"dev": "bun run --watch src/index.ts",
7
7
"start": "bun run src/index.ts",
8
8
"build": "bun build --compile --target bun --outfile server src/index.ts"
···
17
17
"@elysiajs/openapi": "^1.4.11",
18
18
"@elysiajs/opentelemetry": "^1.4.6",
19
19
"@elysiajs/static": "^1.4.2",
20
+
"@radix-ui/react-checkbox": "^1.3.3",
20
21
"@radix-ui/react-dialog": "^1.1.15",
21
22
"@radix-ui/react-label": "^2.1.7",
22
23
"@radix-ui/react-radio-group": "^1.3.8",
23
24
"@radix-ui/react-slot": "^1.2.3",
24
25
"@radix-ui/react-tabs": "^1.1.13",
25
26
"@tanstack/react-query": "^5.90.2",
27
+
"actor-typeahead": "^0.1.1",
28
+
"atproto-ui": "^0.11.3",
26
29
"class-variance-authority": "^0.7.1",
27
30
"clsx": "^2.1.1",
28
31
"elysia": "latest",
29
32
"iron-session": "^8.0.4",
30
33
"lucide-react": "^0.546.0",
34
+
"multiformats": "^13.4.1",
35
+
"prismjs": "^1.30.0",
31
36
"react": "^19.2.0",
32
37
"react-dom": "^19.2.0",
33
38
"tailwind-merge": "^3.3.1",
···
40
45
"@types/react": "^19.2.2",
41
46
"@types/react-dom": "^19.2.1",
42
47
"bun-plugin-tailwind": "^0.1.2",
43
-
"bun-types": "latest"
48
+
"bun-types": "latest",
49
+
"esbuild": "0.26.0"
44
50
},
45
51
"module": "src/index.js",
46
52
"trustedDependencies": [
53
+
"bun",
54
+
"cbor-extract",
47
55
"core-js",
48
56
"protobufjs"
49
57
]
+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>
+7
-1
public/admin/index.html
+7
-1
public/admin/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Admin Dashboard - Wisp.place</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Admin dashboard for wisp.place decentralized static site hosting." />
8
+
<meta name="robots" content="noindex, nofollow" />
9
+
10
+
<!-- Theme -->
11
+
<meta name="theme-color" content="#7c3aed" />
12
+
7
13
<link rel="stylesheet" href="./styles.css" />
8
14
</head>
9
15
<body>
public/android-chrome-192x192.png
public/android-chrome-192x192.png
This is a binary file and will not be displayed.
public/android-chrome-512x512.png
public/android-chrome-512x512.png
This is a binary file and will not be displayed.
public/apple-touch-icon.png
public/apple-touch-icon.png
This is a binary file and will not be displayed.
+30
public/components/ui/checkbox.tsx
+30
public/components/ui/checkbox.tsx
···
1
+
import * as React from "react"
2
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3
+
import { CheckIcon } from "lucide-react"
4
+
5
+
import { cn } from "@public/lib/utils"
6
+
7
+
function Checkbox({
8
+
className,
9
+
...props
10
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
11
+
return (
12
+
<CheckboxPrimitive.Root
13
+
data-slot="checkbox"
14
+
className={cn(
15
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
16
+
className
17
+
)}
18
+
{...props}
19
+
>
20
+
<CheckboxPrimitive.Indicator
21
+
data-slot="checkbox-indicator"
22
+
className="grid place-content-center text-current transition-none"
23
+
>
24
+
<CheckIcon className="size-3.5" />
25
+
</CheckboxPrimitive.Indicator>
26
+
</CheckboxPrimitive.Root>
27
+
)
28
+
}
29
+
30
+
export { Checkbox }
+104
public/components/ui/code-block.tsx
+104
public/components/ui/code-block.tsx
···
1
+
import { useEffect, useRef, useState } from 'react'
2
+
3
+
declare global {
4
+
interface Window {
5
+
Prism: {
6
+
languages: Record<string, any>
7
+
highlightElement: (element: HTMLElement) => void
8
+
highlightAll: () => void
9
+
}
10
+
}
11
+
}
12
+
13
+
interface CodeBlockProps {
14
+
code: string
15
+
language?: 'bash' | 'yaml'
16
+
className?: string
17
+
}
18
+
19
+
export function CodeBlock({ code, language = 'bash', className = '' }: CodeBlockProps) {
20
+
const [isThemeLoaded, setIsThemeLoaded] = useState(false)
21
+
const codeRef = useRef<HTMLElement>(null)
22
+
23
+
useEffect(() => {
24
+
// Load Catppuccin theme CSS
25
+
const loadTheme = async () => {
26
+
// Detect if user prefers dark mode
27
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
28
+
const theme = prefersDark ? 'mocha' : 'latte'
29
+
30
+
// Remove any existing theme CSS
31
+
const existingTheme = document.querySelector('link[data-prism-theme]')
32
+
if (existingTheme) {
33
+
existingTheme.remove()
34
+
}
35
+
36
+
// Load the appropriate Catppuccin theme
37
+
const link = document.createElement('link')
38
+
link.rel = 'stylesheet'
39
+
link.href = `https://prismjs.catppuccin.com/${theme}.css`
40
+
link.setAttribute('data-prism-theme', theme)
41
+
document.head.appendChild(link)
42
+
43
+
// Load PrismJS if not already loaded
44
+
if (!window.Prism) {
45
+
const script = document.createElement('script')
46
+
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/prism.min.js'
47
+
script.onload = () => {
48
+
// Load language support if needed
49
+
if (language === 'yaml' && !window.Prism.languages.yaml) {
50
+
const yamlScript = document.createElement('script')
51
+
yamlScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.30.0/components/prism-yaml.min.js'
52
+
yamlScript.onload = () => setIsThemeLoaded(true)
53
+
document.head.appendChild(yamlScript)
54
+
} else {
55
+
setIsThemeLoaded(true)
56
+
}
57
+
}
58
+
document.head.appendChild(script)
59
+
} else {
60
+
setIsThemeLoaded(true)
61
+
}
62
+
}
63
+
64
+
loadTheme()
65
+
66
+
// Listen for theme changes
67
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
68
+
const handleThemeChange = () => loadTheme()
69
+
mediaQuery.addEventListener('change', handleThemeChange)
70
+
71
+
return () => {
72
+
mediaQuery.removeEventListener('change', handleThemeChange)
73
+
}
74
+
}, [language])
75
+
76
+
// Highlight code when Prism is loaded and component is mounted
77
+
useEffect(() => {
78
+
if (isThemeLoaded && codeRef.current && window.Prism) {
79
+
window.Prism.highlightElement(codeRef.current)
80
+
}
81
+
}, [isThemeLoaded, code])
82
+
83
+
if (!isThemeLoaded) {
84
+
return (
85
+
<pre className={`p-4 bg-muted rounded-lg overflow-x-auto ${className}`}>
86
+
<code>{code.trim()}</code>
87
+
</pre>
88
+
)
89
+
}
90
+
91
+
// Map language to Prism language class
92
+
const languageMap = {
93
+
'bash': 'language-bash',
94
+
'yaml': 'language-yaml'
95
+
}
96
+
97
+
const prismLanguage = languageMap[language] || 'language-bash'
98
+
99
+
return (
100
+
<pre className={`p-4 rounded-lg overflow-x-auto ${className}`}>
101
+
<code ref={codeRef} className={prismLanguage}>{code.trim()}</code>
102
+
</pre>
103
+
)
104
+
}
+1
-1
public/components/ui/radio-group.tsx
+1
-1
public/components/ui/radio-group.tsx
···
27
27
<RadioGroupPrimitive.Item
28
28
data-slot="radio-group-item"
29
29
className={cn(
30
-
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
30
+
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/50 aspect-square size-4 shrink-0 rounded-full border border-black/30 dark:border-white/30 shadow-inner transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
31
31
className
32
32
)}
33
33
{...props}
+2
-2
public/components/ui/tabs.tsx
+2
-2
public/components/ui/tabs.tsx
···
24
24
<TabsPrimitive.List
25
25
data-slot="tabs-list"
26
26
className={cn(
27
-
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
27
+
"bg-muted dark:bg-muted/80 text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
28
28
className
29
29
)}
30
30
{...props}
···
40
40
<TabsPrimitive.Trigger
41
41
data-slot="tabs-trigger"
42
42
className={cn(
43
-
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
43
+
"data-[state=active]:bg-background dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-border dark:data-[state=active]:shadow-sm text-foreground dark:text-muted-foreground/70 inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
44
44
className
45
45
)}
46
46
{...props}
+65
public/editor/components/TabSkeleton.tsx
+65
public/editor/components/TabSkeleton.tsx
···
1
+
import {
2
+
Card,
3
+
CardContent,
4
+
CardDescription,
5
+
CardHeader,
6
+
CardTitle
7
+
} from '@public/components/ui/card'
8
+
9
+
// Shimmer animation for skeleton loading
10
+
const Shimmer = () => (
11
+
<div className="animate-pulse">
12
+
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
13
+
<div className="h-4 bg-muted rounded w-1/2"></div>
14
+
</div>
15
+
)
16
+
17
+
const SkeletonLine = ({ className = '' }: { className?: string }) => (
18
+
<div className={`animate-pulse bg-muted rounded ${className}`}></div>
19
+
)
20
+
21
+
export function TabSkeleton() {
22
+
return (
23
+
<div className="space-y-4 min-h-[400px]">
24
+
<Card>
25
+
<CardHeader>
26
+
<div className="space-y-2">
27
+
<SkeletonLine className="h-6 w-1/3" />
28
+
<SkeletonLine className="h-4 w-2/3" />
29
+
</div>
30
+
</CardHeader>
31
+
<CardContent className="space-y-4">
32
+
{/* Skeleton content items */}
33
+
<div className="p-4 border border-border rounded-lg">
34
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
35
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
36
+
<SkeletonLine className="h-4 w-2/3" />
37
+
</div>
38
+
<div className="p-4 border border-border rounded-lg">
39
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
40
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
41
+
<SkeletonLine className="h-4 w-2/3" />
42
+
</div>
43
+
<div className="p-4 border border-border rounded-lg">
44
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
45
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
46
+
<SkeletonLine className="h-4 w-2/3" />
47
+
</div>
48
+
</CardContent>
49
+
</Card>
50
+
51
+
<Card>
52
+
<CardHeader>
53
+
<div className="space-y-2">
54
+
<SkeletonLine className="h-6 w-1/4" />
55
+
<SkeletonLine className="h-4 w-1/2" />
56
+
</div>
57
+
</CardHeader>
58
+
<CardContent className="space-y-3">
59
+
<SkeletonLine className="h-10 w-full" />
60
+
<SkeletonLine className="h-4 w-3/4" />
61
+
</CardContent>
62
+
</Card>
63
+
</div>
64
+
)
65
+
}
+249
-1144
public/editor/editor.tsx
+249
-1144
public/editor/editor.tsx
···
2
2
import { createRoot } from 'react-dom/client'
3
3
import { Button } from '@public/components/ui/button'
4
4
import {
5
-
Card,
6
-
CardContent,
7
-
CardDescription,
8
-
CardHeader,
9
-
CardTitle
10
-
} from '@public/components/ui/card'
11
-
import { Input } from '@public/components/ui/input'
12
-
import { Label } from '@public/components/ui/label'
13
-
import {
14
5
Tabs,
15
6
TabsContent,
16
7
TabsList,
17
8
TabsTrigger
18
9
} from '@public/components/ui/tabs'
19
-
import { Badge } from '@public/components/ui/badge'
20
10
import {
21
11
Dialog,
22
12
DialogContent,
···
25
15
DialogTitle,
26
16
DialogFooter
27
17
} from '@public/components/ui/dialog'
18
+
import { Checkbox } from '@public/components/ui/checkbox'
19
+
import { Label } from '@public/components/ui/label'
20
+
import { Badge } from '@public/components/ui/badge'
28
21
import {
29
-
Globe,
30
-
Upload,
31
-
ExternalLink,
32
-
CheckCircle2,
33
-
XCircle,
34
-
AlertCircle,
35
22
Loader2,
36
23
Trash2,
37
-
RefreshCw,
38
-
Settings
24
+
LogOut
39
25
} from 'lucide-react'
40
-
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
-
42
26
import Layout from '@public/layouts'
43
-
44
-
interface UserInfo {
45
-
did: string
46
-
handle: string
47
-
}
48
-
49
-
interface Site {
50
-
did: string
51
-
rkey: string
52
-
display_name: string | null
53
-
created_at: number
54
-
updated_at: number
55
-
}
56
-
57
-
interface CustomDomain {
58
-
id: string
59
-
domain: string
60
-
did: string
61
-
rkey: string
62
-
verified: boolean
63
-
last_verified_at: number | null
64
-
created_at: number
65
-
}
66
-
67
-
interface WispDomain {
68
-
domain: string
69
-
rkey: string | null
70
-
}
27
+
import { useUserInfo } from './hooks/useUserInfo'
28
+
import { useSiteData, type SiteWithDomains } from './hooks/useSiteData'
29
+
import { useDomainData } from './hooks/useDomainData'
30
+
import { SitesTab } from './tabs/SitesTab'
31
+
import { DomainsTab } from './tabs/DomainsTab'
32
+
import { UploadTab } from './tabs/UploadTab'
33
+
import { CLITab } from './tabs/CLITab'
71
34
72
35
function Dashboard() {
73
-
// User state
74
-
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
75
-
const [loading, setLoading] = useState(true)
76
-
77
-
// Sites state
78
-
const [sites, setSites] = useState<Site[]>([])
79
-
const [sitesLoading, setSitesLoading] = useState(true)
80
-
const [isSyncing, setIsSyncing] = useState(false)
36
+
// Use custom hooks
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,
44
+
fetchDomains,
45
+
addCustomDomain,
46
+
verifyDomain,
47
+
deleteCustomDomain,
48
+
mapWispDomain,
49
+
deleteWispDomain,
50
+
mapCustomDomain,
51
+
claimWispDomain,
52
+
checkWispAvailability
53
+
} = useDomainData()
81
54
82
-
// Domains state
83
-
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
84
-
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
85
-
const [domainsLoading, setDomainsLoading] = useState(true)
86
-
87
-
// Site configuration state
88
-
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
89
-
const [selectedDomain, setSelectedDomain] = useState<string>('')
55
+
// Site configuration modal state (shared across components)
56
+
const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
57
+
const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
90
58
const [isSavingConfig, setIsSavingConfig] = useState(false)
91
59
const [isDeletingSite, setIsDeletingSite] = useState(false)
92
60
93
-
// Upload state
94
-
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
95
-
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
96
-
const [newSiteName, setNewSiteName] = useState('')
97
-
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
98
-
const [isUploading, setIsUploading] = useState(false)
99
-
const [uploadProgress, setUploadProgress] = useState('')
100
-
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
101
-
const [uploadedCount, setUploadedCount] = useState(0)
102
-
103
-
// Custom domain modal state
104
-
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
105
-
const [customDomain, setCustomDomain] = useState('')
106
-
const [isAddingDomain, setIsAddingDomain] = useState(false)
107
-
const [verificationStatus, setVerificationStatus] = useState<{
108
-
[id: string]: 'idle' | 'verifying' | 'success' | 'error'
109
-
}>({})
110
-
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
111
-
112
-
// Wisp domain claim state
113
-
const [wispHandle, setWispHandle] = useState('')
114
-
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
115
-
const [wispAvailability, setWispAvailability] = useState<{
116
-
available: boolean | null
117
-
checking: boolean
118
-
}>({ available: null, checking: false })
119
-
120
-
// Fetch user info on mount
61
+
// Fetch initial data on mount
121
62
useEffect(() => {
122
63
fetchUserInfo()
123
64
fetchSites()
124
65
fetchDomains()
125
66
}, [])
126
67
127
-
// Auto-switch to 'new' mode if no sites exist
128
-
useEffect(() => {
129
-
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
130
-
setSiteMode('new')
131
-
}
132
-
}, [sites, sitesLoading, siteMode])
68
+
// Handle site configuration modal
69
+
const handleConfigureSite = (site: SiteWithDomains) => {
70
+
setConfiguringSite(site)
133
71
134
-
const fetchUserInfo = async () => {
135
-
try {
136
-
const response = await fetch('/api/user/info')
137
-
const data = await response.json()
138
-
setUserInfo(data)
139
-
} catch (err) {
140
-
console.error('Failed to fetch user info:', err)
141
-
} finally {
142
-
setLoading(false)
143
-
}
144
-
}
72
+
// Build set of currently mapped domains
73
+
const mappedDomains = new Set<string>()
145
74
146
-
const fetchSites = async () => {
147
-
try {
148
-
const response = await fetch('/api/user/sites')
149
-
const data = await response.json()
150
-
setSites(data.sites || [])
151
-
} catch (err) {
152
-
console.error('Failed to fetch sites:', err)
153
-
} finally {
154
-
setSitesLoading(false)
155
-
}
156
-
}
157
-
158
-
const syncSites = async () => {
159
-
setIsSyncing(true)
160
-
try {
161
-
const response = await fetch('/api/user/sync', {
162
-
method: 'POST'
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
+
}
163
83
})
164
-
const data = await response.json()
165
-
if (data.success) {
166
-
console.log(`Synced ${data.synced} sites from PDS`)
167
-
// Refresh sites list
168
-
await fetchSites()
169
-
}
170
-
} catch (err) {
171
-
console.error('Failed to sync sites:', err)
172
-
alert('Failed to sync sites from PDS')
173
-
} finally {
174
-
setIsSyncing(false)
175
84
}
176
-
}
177
85
178
-
const fetchDomains = async () => {
179
-
try {
180
-
const response = await fetch('/api/user/domains')
181
-
const data = await response.json()
182
-
setWispDomain(data.wispDomain)
183
-
setCustomDomains(data.customDomains || [])
184
-
} catch (err) {
185
-
console.error('Failed to fetch domains:', err)
186
-
} finally {
187
-
setDomainsLoading(false)
188
-
}
86
+
setSelectedDomains(mappedDomains)
189
87
}
190
88
191
-
const getSiteUrl = (site: Site) => {
192
-
// Check if this site is mapped to the wisp.place domain
193
-
if (wispDomain && wispDomain.rkey === site.rkey) {
194
-
return `https://${wispDomain.domain}`
195
-
}
196
-
197
-
// Check if this site is mapped to any custom domain
198
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
199
-
if (customDomain) {
200
-
return `https://${customDomain.domain}`
201
-
}
202
-
203
-
// Default fallback URL
204
-
if (!userInfo) return '#'
205
-
return `https://sites.wisp.place/${site.did}/${site.rkey}`
206
-
}
89
+
const handleSaveSiteConfig = async () => {
90
+
if (!configuringSite) return
207
91
208
-
const getSiteDomainName = (site: Site) => {
209
-
if (wispDomain && wispDomain.rkey === site.rkey) {
210
-
return wispDomain.domain
211
-
}
212
-
213
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
214
-
if (customDomain) {
215
-
return customDomain.domain
216
-
}
217
-
218
-
return `sites.wisp.place/${site.did}/${site.rkey}`
219
-
}
220
-
221
-
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
222
-
if (e.target.files && e.target.files.length > 0) {
223
-
setSelectedFiles(e.target.files)
224
-
}
225
-
}
226
-
227
-
const handleUpload = async () => {
228
-
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
229
-
230
-
if (!siteName) {
231
-
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
232
-
return
233
-
}
234
-
235
-
setIsUploading(true)
236
-
setUploadProgress('Preparing files...')
237
-
92
+
setIsSavingConfig(true)
238
93
try {
239
-
const formData = new FormData()
240
-
formData.append('siteName', siteName)
241
-
242
-
if (selectedFiles) {
243
-
for (let i = 0; i < selectedFiles.length; i++) {
244
-
formData.append('files', selectedFiles[i])
245
-
}
246
-
}
247
-
248
-
setUploadProgress('Uploading to AT Protocol...')
249
-
const response = await fetch('/wisp/upload-files', {
250
-
method: 'POST',
251
-
body: formData
252
-
})
253
-
254
-
const data = await response.json()
255
-
if (data.success) {
256
-
setUploadProgress('Upload complete!')
257
-
setSkippedFiles(data.skippedFiles || [])
258
-
setUploadedCount(data.uploadedCount || data.fileCount || 0)
259
-
setSelectedSiteRkey('')
260
-
setNewSiteName('')
261
-
setSelectedFiles(null)
262
-
263
-
// Refresh sites list
264
-
await fetchSites()
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:', ''))
265
97
266
-
// Reset form - give more time if there are skipped files
267
-
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
268
-
setTimeout(() => {
269
-
setUploadProgress('')
270
-
setSkippedFiles([])
271
-
setUploadedCount(0)
272
-
setIsUploading(false)
273
-
}, resetDelay)
274
-
} else {
275
-
throw new Error(data.error || 'Upload failed')
276
-
}
277
-
} catch (err) {
278
-
console.error('Upload error:', err)
279
-
alert(
280
-
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
98
+
// Get currently mapped wisp domains
99
+
const currentlyMappedWispDomains = wispDomains.filter(
100
+
d => d.rkey === configuringSite.rkey
281
101
)
282
-
setIsUploading(false)
283
-
setUploadProgress('')
284
-
}
285
-
}
286
102
287
-
const handleAddCustomDomain = async () => {
288
-
if (!customDomain) {
289
-
alert('Please enter a domain')
290
-
return
291
-
}
292
-
293
-
setIsAddingDomain(true)
294
-
try {
295
-
const response = await fetch('/api/domain/custom/add', {
296
-
method: 'POST',
297
-
headers: { 'Content-Type': 'application/json' },
298
-
body: JSON.stringify({ domain: customDomain })
299
-
})
300
-
301
-
const data = await response.json()
302
-
if (data.success) {
303
-
setCustomDomain('')
304
-
setAddDomainModalOpen(false)
305
-
await fetchDomains()
306
-
307
-
// Automatically show DNS configuration for the newly added domain
308
-
setViewDomainDNS(data.id)
309
-
} else {
310
-
throw new Error(data.error || 'Failed to add domain')
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
+
}
311
108
}
312
-
} catch (err) {
313
-
console.error('Add domain error:', err)
314
-
alert(
315
-
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
316
-
)
317
-
} finally {
318
-
setIsAddingDomain(false)
319
-
}
320
-
}
321
109
322
-
const handleVerifyDomain = async (id: string) => {
323
-
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
324
-
325
-
try {
326
-
const response = await fetch('/api/domain/custom/verify', {
327
-
method: 'POST',
328
-
headers: { 'Content-Type': 'application/json' },
329
-
body: JSON.stringify({ id })
330
-
})
331
-
332
-
const data = await response.json()
333
-
if (data.success && data.verified) {
334
-
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
335
-
await fetchDomains()
336
-
} else {
337
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
338
-
if (data.error) {
339
-
alert(`Verification failed: ${data.error}`)
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)
340
115
}
341
116
}
342
-
} catch (err) {
343
-
console.error('Verify domain error:', err)
344
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
345
-
alert(
346
-
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
347
-
)
348
-
}
349
-
}
350
117
351
-
const handleDeleteCustomDomain = async (id: string) => {
352
-
if (!confirm('Are you sure you want to remove this custom domain?')) {
353
-
return
354
-
}
355
-
356
-
try {
357
-
const response = await fetch(`/api/domain/custom/${id}`, {
358
-
method: 'DELETE'
359
-
})
360
-
361
-
const data = await response.json()
362
-
if (data.success) {
363
-
await fetchDomains()
364
-
} else {
365
-
throw new Error('Failed to delete domain')
366
-
}
367
-
} catch (err) {
368
-
console.error('Delete domain error:', err)
369
-
alert(
370
-
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
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
371
122
)
372
-
}
373
-
}
374
123
375
-
const handleConfigureSite = (site: Site) => {
376
-
setConfiguringSite(site)
377
-
378
-
// Determine current domain mapping
379
-
if (wispDomain && wispDomain.rkey === site.rkey) {
380
-
setSelectedDomain('wisp')
381
-
} else {
382
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
383
-
if (customDomain) {
384
-
setSelectedDomain(customDomain.id)
385
-
} else {
386
-
setSelectedDomain('none')
124
+
// Unmap domains that are no longer selected
125
+
for (const domain of currentlyMappedCustomDomains) {
126
+
if (!selectedCustomDomainIds.includes(domain.id)) {
127
+
await mapCustomDomain(domain.id, null)
128
+
}
387
129
}
388
-
}
389
-
}
390
130
391
-
const handleSaveSiteConfig = async () => {
392
-
if (!configuringSite) return
393
-
394
-
setIsSavingConfig(true)
395
-
try {
396
-
if (selectedDomain === 'wisp') {
397
-
// Map to wisp.place domain
398
-
const response = await fetch('/api/domain/wisp/map-site', {
399
-
method: 'POST',
400
-
headers: { 'Content-Type': 'application/json' },
401
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
402
-
})
403
-
const data = await response.json()
404
-
if (!data.success) throw new Error('Failed to map site')
405
-
} else if (selectedDomain === 'none') {
406
-
// Unmap from all domains
407
-
// Unmap wisp domain if this site was mapped to it
408
-
if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
409
-
await fetch('/api/domain/wisp/map-site', {
410
-
method: 'POST',
411
-
headers: { 'Content-Type': 'application/json' },
412
-
body: JSON.stringify({ siteRkey: null })
413
-
})
414
-
}
415
-
416
-
// Unmap from custom domains
417
-
const mappedCustom = customDomains.find(
418
-
(d) => d.rkey === configuringSite.rkey
419
-
)
420
-
if (mappedCustom) {
421
-
await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
422
-
method: 'POST',
423
-
headers: { 'Content-Type': 'application/json' },
424
-
body: JSON.stringify({ siteRkey: null })
425
-
})
131
+
// Map newly selected domains
132
+
for (const domainId of selectedCustomDomainIds) {
133
+
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
134
+
if (!isAlreadyMapped) {
135
+
await mapCustomDomain(domainId, configuringSite.rkey)
426
136
}
427
-
} else {
428
-
// Map to a custom domain
429
-
const response = await fetch(
430
-
`/api/domain/custom/${selectedDomain}/map-site`,
431
-
{
432
-
method: 'POST',
433
-
headers: { 'Content-Type': 'application/json' },
434
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
435
-
}
436
-
)
437
-
const data = await response.json()
438
-
if (!data.success) throw new Error('Failed to map site')
439
137
}
440
138
441
-
// Refresh domains to get updated mappings
139
+
// Refresh both domains and sites to get updated mappings
442
140
await fetchDomains()
141
+
await fetchSites()
443
142
setConfiguringSite(null)
444
143
} catch (err) {
445
144
console.error('Save config error:', err)
···
459
158
}
460
159
461
160
setIsDeletingSite(true)
462
-
try {
463
-
const response = await fetch(`/api/site/${configuringSite.rkey}`, {
464
-
method: 'DELETE'
465
-
})
466
-
467
-
const data = await response.json()
468
-
if (data.success) {
469
-
// Refresh sites list
470
-
await fetchSites()
471
-
// Refresh domains in case this site was mapped
472
-
await fetchDomains()
473
-
setConfiguringSite(null)
474
-
} else {
475
-
throw new Error(data.error || 'Failed to delete site')
476
-
}
477
-
} catch (err) {
478
-
console.error('Delete site error:', err)
479
-
alert(
480
-
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
481
-
)
482
-
} finally {
483
-
setIsDeletingSite(false)
161
+
const success = await deleteSite(configuringSite.rkey)
162
+
if (success) {
163
+
// Refresh domains in case this site was mapped
164
+
await fetchDomains()
165
+
setConfiguringSite(null)
484
166
}
167
+
setIsDeletingSite(false)
485
168
}
486
169
487
-
const checkWispAvailability = async (handle: string) => {
488
-
const trimmedHandle = handle.trim().toLowerCase()
489
-
if (!trimmedHandle) {
490
-
setWispAvailability({ available: null, checking: false })
491
-
return
492
-
}
493
-
494
-
setWispAvailability({ available: null, checking: true })
495
-
try {
496
-
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
497
-
const data = await response.json()
498
-
setWispAvailability({ available: data.available, checking: false })
499
-
} catch (err) {
500
-
console.error('Check availability error:', err)
501
-
setWispAvailability({ available: false, checking: false })
502
-
}
170
+
const handleUploadComplete = async () => {
171
+
await fetchSites()
503
172
}
504
173
505
-
const handleClaimWispDomain = async () => {
506
-
const trimmedHandle = wispHandle.trim().toLowerCase()
507
-
if (!trimmedHandle) {
508
-
alert('Please enter a handle')
509
-
return
510
-
}
511
-
512
-
setIsClaimingWisp(true)
174
+
const handleLogout = async () => {
513
175
try {
514
-
const response = await fetch('/api/domain/claim', {
176
+
const response = await fetch('/api/auth/logout', {
515
177
method: 'POST',
516
-
headers: { 'Content-Type': 'application/json' },
517
-
body: JSON.stringify({ handle: trimmedHandle })
178
+
credentials: 'include'
518
179
})
519
-
520
-
const data = await response.json()
521
-
if (data.success) {
522
-
setWispHandle('')
523
-
setWispAvailability({ available: null, checking: false })
524
-
await fetchDomains()
180
+
const result = await response.json()
181
+
if (result.success) {
182
+
// Redirect to home page after successful logout
183
+
window.location.href = '/'
525
184
} else {
526
-
throw new Error(data.error || 'Failed to claim domain')
185
+
alert('Logout failed: ' + (result.error || 'Unknown error'))
527
186
}
528
187
} catch (err) {
529
-
console.error('Claim domain error:', err)
530
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
531
-
532
-
// Handle "Already claimed" error more gracefully
533
-
if (errorMessage.includes('Already claimed')) {
534
-
alert('You have already claimed a wisp.place subdomain. Please refresh the page.')
535
-
await fetchDomains()
536
-
} else {
537
-
alert(`Failed to claim domain: ${errorMessage}`)
538
-
}
539
-
} finally {
540
-
setIsClaimingWisp(false)
188
+
alert('Logout failed: ' + (err instanceof Error ? err.message : 'Unknown error'))
541
189
}
542
190
}
543
191
···
555
203
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
556
204
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
557
205
<div className="flex items-center gap-2">
558
-
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
559
-
<Globe className="w-5 h-5 text-primary-foreground" />
560
-
</div>
206
+
<img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
561
207
<span className="text-xl font-semibold text-foreground">
562
208
wisp.place
563
209
</span>
···
566
212
<span className="text-sm text-muted-foreground">
567
213
{userInfo?.handle || 'Loading...'}
568
214
</span>
215
+
<Button
216
+
variant="ghost"
217
+
size="sm"
218
+
onClick={handleLogout}
219
+
className="h-8 px-2"
220
+
>
221
+
<LogOut className="w-4 h-4" />
222
+
</Button>
569
223
</div>
570
224
</div>
571
225
</header>
···
579
233
</div>
580
234
581
235
<Tabs defaultValue="sites" className="space-y-6 w-full">
582
-
<TabsList className="grid w-full grid-cols-3 max-w-md">
236
+
<TabsList className="grid w-full grid-cols-4">
583
237
<TabsTrigger value="sites">Sites</TabsTrigger>
584
238
<TabsTrigger value="domains">Domains</TabsTrigger>
585
239
<TabsTrigger value="upload">Upload</TabsTrigger>
240
+
<TabsTrigger value="cli">CLI</TabsTrigger>
586
241
</TabsList>
587
242
588
243
{/* Sites Tab */}
589
-
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
590
-
<Card>
591
-
<CardHeader>
592
-
<div className="flex items-center justify-between">
593
-
<div>
594
-
<CardTitle>Your Sites</CardTitle>
595
-
<CardDescription>
596
-
View and manage all your deployed sites
597
-
</CardDescription>
598
-
</div>
599
-
<Button
600
-
variant="outline"
601
-
size="sm"
602
-
onClick={syncSites}
603
-
disabled={isSyncing || sitesLoading}
604
-
>
605
-
<RefreshCw
606
-
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
607
-
/>
608
-
Sync from PDS
609
-
</Button>
610
-
</div>
611
-
</CardHeader>
612
-
<CardContent className="space-y-4">
613
-
{sitesLoading ? (
614
-
<div className="flex items-center justify-center py-8">
615
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
616
-
</div>
617
-
) : sites.length === 0 ? (
618
-
<div className="text-center py-8 text-muted-foreground">
619
-
<p>No sites yet. Upload your first site!</p>
620
-
</div>
621
-
) : (
622
-
sites.map((site) => (
623
-
<div
624
-
key={`${site.did}-${site.rkey}`}
625
-
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
626
-
>
627
-
<div className="flex-1">
628
-
<div className="flex items-center gap-3 mb-2">
629
-
<h3 className="font-semibold text-lg">
630
-
{site.display_name || site.rkey}
631
-
</h3>
632
-
<Badge
633
-
variant="secondary"
634
-
className="text-xs"
635
-
>
636
-
active
637
-
</Badge>
638
-
</div>
639
-
<a
640
-
href={getSiteUrl(site)}
641
-
target="_blank"
642
-
rel="noopener noreferrer"
643
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
644
-
>
645
-
{getSiteDomainName(site)}
646
-
<ExternalLink className="w-3 h-3" />
647
-
</a>
648
-
</div>
649
-
<Button
650
-
variant="outline"
651
-
size="sm"
652
-
onClick={() => handleConfigureSite(site)}
653
-
>
654
-
<Settings className="w-4 h-4 mr-2" />
655
-
Configure
656
-
</Button>
657
-
</div>
658
-
))
659
-
)}
660
-
</CardContent>
661
-
</Card>
244
+
<TabsContent value="sites">
245
+
<SitesTab
246
+
sites={sites}
247
+
sitesLoading={sitesLoading}
248
+
isSyncing={isSyncing}
249
+
userInfo={userInfo}
250
+
onSyncSites={syncSites}
251
+
onConfigureSite={handleConfigureSite}
252
+
/>
662
253
</TabsContent>
663
254
664
255
{/* Domains Tab */}
665
-
<TabsContent value="domains" className="space-y-4 min-h-[400px]">
666
-
<Card>
667
-
<CardHeader>
668
-
<CardTitle>wisp.place Subdomain</CardTitle>
669
-
<CardDescription>
670
-
Your free subdomain on the wisp.place network
671
-
</CardDescription>
672
-
</CardHeader>
673
-
<CardContent>
674
-
{domainsLoading ? (
675
-
<div className="flex items-center justify-center py-4">
676
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
677
-
</div>
678
-
) : wispDomain ? (
679
-
<>
680
-
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
681
-
<div className="flex items-center gap-2">
682
-
<CheckCircle2 className="w-5 h-5 text-green-500" />
683
-
<span className="font-mono text-lg">
684
-
{wispDomain.domain}
685
-
</span>
686
-
</div>
687
-
{wispDomain.rkey && (
688
-
<p className="text-xs text-muted-foreground ml-7">
689
-
โ Mapped to site: {wispDomain.rkey}
690
-
</p>
691
-
)}
692
-
</div>
693
-
<p className="text-sm text-muted-foreground mt-3">
694
-
{wispDomain.rkey
695
-
? 'This domain is mapped to a specific site'
696
-
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
697
-
</p>
698
-
</>
699
-
) : (
700
-
<div className="space-y-4">
701
-
<div className="p-4 bg-muted/30 rounded-lg">
702
-
<p className="text-sm text-muted-foreground mb-4">
703
-
Claim your free wisp.place subdomain
704
-
</p>
705
-
<div className="space-y-3">
706
-
<div className="space-y-2">
707
-
<Label htmlFor="wisp-handle">Choose your handle</Label>
708
-
<div className="flex gap-2">
709
-
<div className="flex-1 relative">
710
-
<Input
711
-
id="wisp-handle"
712
-
placeholder="mysite"
713
-
value={wispHandle}
714
-
onChange={(e) => {
715
-
setWispHandle(e.target.value)
716
-
if (e.target.value.trim()) {
717
-
checkWispAvailability(e.target.value)
718
-
} else {
719
-
setWispAvailability({ available: null, checking: false })
720
-
}
721
-
}}
722
-
disabled={isClaimingWisp}
723
-
className="pr-24"
724
-
/>
725
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
726
-
.wisp.place
727
-
</span>
728
-
</div>
729
-
</div>
730
-
{wispAvailability.checking && (
731
-
<p className="text-xs text-muted-foreground flex items-center gap-1">
732
-
<Loader2 className="w-3 h-3 animate-spin" />
733
-
Checking availability...
734
-
</p>
735
-
)}
736
-
{!wispAvailability.checking && wispAvailability.available === true && (
737
-
<p className="text-xs text-green-600 flex items-center gap-1">
738
-
<CheckCircle2 className="w-3 h-3" />
739
-
Available
740
-
</p>
741
-
)}
742
-
{!wispAvailability.checking && wispAvailability.available === false && (
743
-
<p className="text-xs text-red-600 flex items-center gap-1">
744
-
<XCircle className="w-3 h-3" />
745
-
Not available
746
-
</p>
747
-
)}
748
-
</div>
749
-
<Button
750
-
onClick={handleClaimWispDomain}
751
-
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
752
-
className="w-full"
753
-
>
754
-
{isClaimingWisp ? (
755
-
<>
756
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
757
-
Claiming...
758
-
</>
759
-
) : (
760
-
'Claim Subdomain'
761
-
)}
762
-
</Button>
763
-
</div>
764
-
</div>
765
-
</div>
766
-
)}
767
-
</CardContent>
768
-
</Card>
769
-
770
-
<Card>
771
-
<CardHeader>
772
-
<CardTitle>Custom Domains</CardTitle>
773
-
<CardDescription>
774
-
Bring your own domain with DNS verification
775
-
</CardDescription>
776
-
</CardHeader>
777
-
<CardContent className="space-y-4">
778
-
<Button
779
-
onClick={() => setAddDomainModalOpen(true)}
780
-
className="w-full"
781
-
>
782
-
Add Custom Domain
783
-
</Button>
784
-
785
-
{domainsLoading ? (
786
-
<div className="flex items-center justify-center py-4">
787
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
788
-
</div>
789
-
) : customDomains.length === 0 ? (
790
-
<div className="text-center py-4 text-muted-foreground text-sm">
791
-
No custom domains added yet
792
-
</div>
793
-
) : (
794
-
<div className="space-y-2">
795
-
{customDomains.map((domain) => (
796
-
<div
797
-
key={domain.id}
798
-
className="flex items-center justify-between p-3 border border-border rounded-lg"
799
-
>
800
-
<div className="flex flex-col gap-1 flex-1">
801
-
<div className="flex items-center gap-2">
802
-
{domain.verified ? (
803
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
804
-
) : (
805
-
<XCircle className="w-4 h-4 text-red-500" />
806
-
)}
807
-
<span className="font-mono">
808
-
{domain.domain}
809
-
</span>
810
-
</div>
811
-
{domain.rkey && domain.rkey !== 'self' && (
812
-
<p className="text-xs text-muted-foreground ml-6">
813
-
โ Mapped to site: {domain.rkey}
814
-
</p>
815
-
)}
816
-
</div>
817
-
<div className="flex items-center gap-2">
818
-
<Button
819
-
variant="outline"
820
-
size="sm"
821
-
onClick={() =>
822
-
setViewDomainDNS(domain.id)
823
-
}
824
-
>
825
-
View DNS
826
-
</Button>
827
-
{domain.verified ? (
828
-
<Badge variant="secondary">
829
-
Verified
830
-
</Badge>
831
-
) : (
832
-
<Button
833
-
variant="outline"
834
-
size="sm"
835
-
onClick={() =>
836
-
handleVerifyDomain(domain.id)
837
-
}
838
-
disabled={
839
-
verificationStatus[
840
-
domain.id
841
-
] === 'verifying'
842
-
}
843
-
>
844
-
{verificationStatus[
845
-
domain.id
846
-
] === 'verifying' ? (
847
-
<>
848
-
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
849
-
Verifying...
850
-
</>
851
-
) : (
852
-
'Verify DNS'
853
-
)}
854
-
</Button>
855
-
)}
856
-
<Button
857
-
variant="ghost"
858
-
size="sm"
859
-
onClick={() =>
860
-
handleDeleteCustomDomain(
861
-
domain.id
862
-
)
863
-
}
864
-
>
865
-
<Trash2 className="w-4 h-4" />
866
-
</Button>
867
-
</div>
868
-
</div>
869
-
))}
870
-
</div>
871
-
)}
872
-
</CardContent>
873
-
</Card>
256
+
<TabsContent value="domains">
257
+
<DomainsTab
258
+
wispDomains={wispDomains}
259
+
customDomains={customDomains}
260
+
domainsLoading={domainsLoading}
261
+
verificationStatus={verificationStatus}
262
+
userInfo={userInfo}
263
+
onAddCustomDomain={addCustomDomain}
264
+
onVerifyDomain={verifyDomain}
265
+
onDeleteCustomDomain={deleteCustomDomain}
266
+
onDeleteWispDomain={deleteWispDomain}
267
+
onClaimWispDomain={claimWispDomain}
268
+
onCheckWispAvailability={checkWispAvailability}
269
+
/>
874
270
</TabsContent>
875
271
876
272
{/* Upload Tab */}
877
-
<TabsContent value="upload" className="space-y-4 min-h-[400px]">
878
-
<Card>
879
-
<CardHeader>
880
-
<CardTitle>Upload Site</CardTitle>
881
-
<CardDescription>
882
-
Deploy a new site from a folder or Git repository
883
-
</CardDescription>
884
-
</CardHeader>
885
-
<CardContent className="space-y-6">
886
-
<div className="space-y-4">
887
-
<RadioGroup
888
-
value={siteMode}
889
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
890
-
disabled={isUploading}
891
-
>
892
-
<div className="flex items-center space-x-2">
893
-
<RadioGroupItem value="existing" id="existing" />
894
-
<Label htmlFor="existing" className="cursor-pointer">
895
-
Update existing site
896
-
</Label>
897
-
</div>
898
-
<div className="flex items-center space-x-2">
899
-
<RadioGroupItem value="new" id="new" />
900
-
<Label htmlFor="new" className="cursor-pointer">
901
-
Create new site
902
-
</Label>
903
-
</div>
904
-
</RadioGroup>
905
-
906
-
{siteMode === 'existing' ? (
907
-
<div className="space-y-2">
908
-
<Label htmlFor="site-select">Select Site</Label>
909
-
{sitesLoading ? (
910
-
<div className="flex items-center justify-center py-4">
911
-
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
912
-
</div>
913
-
) : sites.length === 0 ? (
914
-
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
915
-
No sites available. Create a new site instead.
916
-
</div>
917
-
) : (
918
-
<select
919
-
id="site-select"
920
-
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
921
-
value={selectedSiteRkey}
922
-
onChange={(e) => setSelectedSiteRkey(e.target.value)}
923
-
disabled={isUploading}
924
-
>
925
-
<option value="">Select a site...</option>
926
-
{sites.map((site) => (
927
-
<option key={site.rkey} value={site.rkey}>
928
-
{site.display_name || site.rkey}
929
-
</option>
930
-
))}
931
-
</select>
932
-
)}
933
-
</div>
934
-
) : (
935
-
<div className="space-y-2">
936
-
<Label htmlFor="new-site-name">New Site Name</Label>
937
-
<Input
938
-
id="new-site-name"
939
-
placeholder="my-awesome-site"
940
-
value={newSiteName}
941
-
onChange={(e) => setNewSiteName(e.target.value)}
942
-
disabled={isUploading}
943
-
/>
944
-
</div>
945
-
)}
946
-
947
-
<p className="text-xs text-muted-foreground">
948
-
File limits: 100MB per file, 300MB total
949
-
</p>
950
-
</div>
951
-
952
-
<div className="grid md:grid-cols-2 gap-4">
953
-
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
954
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
955
-
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
956
-
<h3 className="font-semibold mb-2">
957
-
Upload Folder
958
-
</h3>
959
-
<p className="text-sm text-muted-foreground mb-4">
960
-
Drag and drop or click to upload your
961
-
static site files
962
-
</p>
963
-
<input
964
-
type="file"
965
-
id="file-upload"
966
-
multiple
967
-
onChange={handleFileSelect}
968
-
className="hidden"
969
-
{...(({ webkitdirectory: '', directory: '' } as any))}
970
-
disabled={isUploading}
971
-
/>
972
-
<label htmlFor="file-upload">
973
-
<Button
974
-
variant="outline"
975
-
type="button"
976
-
onClick={() =>
977
-
document
978
-
.getElementById('file-upload')
979
-
?.click()
980
-
}
981
-
disabled={isUploading}
982
-
>
983
-
Choose Folder
984
-
</Button>
985
-
</label>
986
-
{selectedFiles && selectedFiles.length > 0 && (
987
-
<p className="text-sm text-muted-foreground mt-3">
988
-
{selectedFiles.length} files selected
989
-
</p>
990
-
)}
991
-
</CardContent>
992
-
</Card>
993
-
994
-
<Card className="border-2 border-dashed opacity-50">
995
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
996
-
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
997
-
<h3 className="font-semibold mb-2">
998
-
Connect Git Repository
999
-
</h3>
1000
-
<p className="text-sm text-muted-foreground mb-4">
1001
-
Link your GitHub, GitLab, or any Git
1002
-
repository
1003
-
</p>
1004
-
<Badge variant="secondary">Coming soon!</Badge>
1005
-
</CardContent>
1006
-
</Card>
1007
-
</div>
1008
-
1009
-
{uploadProgress && (
1010
-
<div className="space-y-3">
1011
-
<div className="p-4 bg-muted rounded-lg">
1012
-
<div className="flex items-center gap-2">
1013
-
<Loader2 className="w-4 h-4 animate-spin" />
1014
-
<span className="text-sm">{uploadProgress}</span>
1015
-
</div>
1016
-
</div>
1017
-
1018
-
{skippedFiles.length > 0 && (
1019
-
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
1020
-
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
1021
-
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
1022
-
<div className="flex-1">
1023
-
<span className="font-medium">
1024
-
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
1025
-
</span>
1026
-
{uploadedCount > 0 && (
1027
-
<span className="text-sm ml-2">
1028
-
({uploadedCount} uploaded successfully)
1029
-
</span>
1030
-
)}
1031
-
</div>
1032
-
</div>
1033
-
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
1034
-
{skippedFiles.slice(0, 5).map((file, idx) => (
1035
-
<div key={idx} className="text-xs">
1036
-
<span className="font-mono">{file.name}</span>
1037
-
<span className="text-muted-foreground"> - {file.reason}</span>
1038
-
</div>
1039
-
))}
1040
-
{skippedFiles.length > 5 && (
1041
-
<div className="text-xs text-muted-foreground">
1042
-
...and {skippedFiles.length - 5} more
1043
-
</div>
1044
-
)}
1045
-
</div>
1046
-
</div>
1047
-
)}
1048
-
</div>
1049
-
)}
273
+
<TabsContent value="upload">
274
+
<UploadTab
275
+
sites={sites}
276
+
sitesLoading={sitesLoading}
277
+
onUploadComplete={handleUploadComplete}
278
+
/>
279
+
</TabsContent>
1050
280
1051
-
<Button
1052
-
onClick={handleUpload}
1053
-
className="w-full"
1054
-
disabled={
1055
-
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
1056
-
isUploading ||
1057
-
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
1058
-
}
1059
-
>
1060
-
{isUploading ? (
1061
-
<>
1062
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
1063
-
Uploading...
1064
-
</>
1065
-
) : (
1066
-
<>
1067
-
{siteMode === 'existing' ? (
1068
-
'Update Site'
1069
-
) : (
1070
-
selectedFiles && selectedFiles.length > 0
1071
-
? 'Upload & Deploy'
1072
-
: 'Create Empty Site'
1073
-
)}
1074
-
</>
1075
-
)}
1076
-
</Button>
1077
-
</CardContent>
1078
-
</Card>
281
+
{/* CLI Tab */}
282
+
<TabsContent value="cli">
283
+
<CLITab />
1079
284
</TabsContent>
1080
285
</Tabs>
1081
286
</div>
1082
287
1083
-
{/* Add Custom Domain Modal */}
1084
-
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
1085
-
<DialogContent className="sm:max-w-lg">
1086
-
<DialogHeader>
1087
-
<DialogTitle>Add Custom Domain</DialogTitle>
1088
-
<DialogDescription>
1089
-
Enter your domain name. After adding, you'll see the DNS
1090
-
records to configure.
1091
-
</DialogDescription>
1092
-
</DialogHeader>
1093
-
<div className="space-y-4 py-4">
1094
-
<div className="space-y-2">
1095
-
<Label htmlFor="new-domain">Domain Name</Label>
1096
-
<Input
1097
-
id="new-domain"
1098
-
placeholder="example.com"
1099
-
value={customDomain}
1100
-
onChange={(e) => setCustomDomain(e.target.value)}
1101
-
/>
1102
-
<p className="text-xs text-muted-foreground">
1103
-
After adding, click "View DNS" to see the records you
1104
-
need to configure.
1105
-
</p>
1106
-
</div>
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>
1107
327
</div>
1108
-
<DialogFooter className="flex-col sm:flex-row gap-2">
1109
-
<Button
1110
-
variant="outline"
1111
-
onClick={() => {
1112
-
setAddDomainModalOpen(false)
1113
-
setCustomDomain('')
1114
-
}}
1115
-
className="w-full sm:w-auto"
1116
-
disabled={isAddingDomain}
1117
-
>
1118
-
Cancel
1119
-
</Button>
1120
-
<Button
1121
-
onClick={handleAddCustomDomain}
1122
-
disabled={!customDomain || isAddingDomain}
1123
-
className="w-full sm:w-auto"
1124
-
>
1125
-
{isAddingDomain ? (
1126
-
<>
1127
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
1128
-
Adding...
1129
-
</>
1130
-
) : (
1131
-
'Add Domain'
1132
-
)}
1133
-
</Button>
1134
-
</DialogFooter>
1135
-
</DialogContent>
1136
-
</Dialog>
328
+
</div>
329
+
</footer>
1137
330
1138
331
{/* Site Configuration Modal */}
1139
332
<Dialog
···
1142
335
>
1143
336
<DialogContent className="sm:max-w-lg">
1144
337
<DialogHeader>
1145
-
<DialogTitle>Configure Site Domain</DialogTitle>
338
+
<DialogTitle>Configure Site Domains</DialogTitle>
1146
339
<DialogDescription>
1147
-
Choose which domain this site should use
340
+
Select which domains should be mapped to this site. You can select multiple domains.
1148
341
</DialogDescription>
1149
342
</DialogHeader>
1150
343
{configuringSite && (
···
1157
350
</p>
1158
351
</div>
1159
352
1160
-
<RadioGroup
1161
-
value={selectedDomain}
1162
-
onValueChange={setSelectedDomain}
1163
-
>
1164
-
{wispDomain && (
1165
-
<div className="flex items-center space-x-2">
1166
-
<RadioGroupItem value="wisp" id="wisp" />
1167
-
<Label
1168
-
htmlFor="wisp"
1169
-
className="flex-1 cursor-pointer"
1170
-
>
1171
-
<div className="flex items-center justify-between">
1172
-
<span className="font-mono text-sm">
1173
-
{wispDomain.domain}
1174
-
</span>
1175
-
<Badge variant="secondary" className="text-xs ml-2">
1176
-
Free
1177
-
</Badge>
1178
-
</div>
1179
-
</Label>
1180
-
</div>
1181
-
)}
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
+
})}
1182
389
1183
390
{customDomains
1184
391
.filter((d) => d.verified)
1185
392
.map((domain) => (
1186
393
<div
1187
394
key={domain.id}
1188
-
className="flex items-center space-x-2"
395
+
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
1189
396
>
1190
-
<RadioGroupItem
1191
-
value={domain.id}
397
+
<Checkbox
1192
398
id={domain.id}
399
+
checked={selectedDomains.has(domain.id)}
400
+
onCheckedChange={(checked) => {
401
+
const newSelected = new Set(selectedDomains)
402
+
if (checked) {
403
+
newSelected.add(domain.id)
404
+
} else {
405
+
newSelected.delete(domain.id)
406
+
}
407
+
setSelectedDomains(newSelected)
408
+
}}
1193
409
/>
1194
410
<Label
1195
411
htmlFor={domain.id}
···
1210
426
</div>
1211
427
))}
1212
428
1213
-
<div className="flex items-center space-x-2">
1214
-
<RadioGroupItem value="none" id="none" />
1215
-
<Label htmlFor="none" className="flex-1 cursor-pointer">
1216
-
<div className="flex flex-col">
1217
-
<span className="text-sm">Default URL</span>
1218
-
<span className="text-xs text-muted-foreground font-mono break-all">
1219
-
sites.wisp.place/{configuringSite.did}/
1220
-
{configuringSite.rkey}
1221
-
</span>
1222
-
</div>
1223
-
</Label>
1224
-
</div>
1225
-
</RadioGroup>
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>
435
+
436
+
<div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
437
+
<p className="text-xs text-muted-foreground">
438
+
<strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
439
+
<span className="font-mono">
440
+
sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
441
+
</span>
442
+
</p>
443
+
</div>
1226
444
</div>
1227
445
)}
1228
446
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
···
1268
486
)}
1269
487
</Button>
1270
488
</div>
1271
-
</DialogFooter>
1272
-
</DialogContent>
1273
-
</Dialog>
1274
-
1275
-
{/* View DNS Records Modal */}
1276
-
<Dialog
1277
-
open={viewDomainDNS !== null}
1278
-
onOpenChange={(open) => !open && setViewDomainDNS(null)}
1279
-
>
1280
-
<DialogContent className="sm:max-w-lg">
1281
-
<DialogHeader>
1282
-
<DialogTitle>DNS Configuration</DialogTitle>
1283
-
<DialogDescription>
1284
-
Add these DNS records to your domain provider
1285
-
</DialogDescription>
1286
-
</DialogHeader>
1287
-
{viewDomainDNS && userInfo && (
1288
-
<>
1289
-
{(() => {
1290
-
const domain = customDomains.find(
1291
-
(d) => d.id === viewDomainDNS
1292
-
)
1293
-
if (!domain) return null
1294
-
1295
-
return (
1296
-
<div className="space-y-4 py-4">
1297
-
<div className="p-3 bg-muted/30 rounded-lg">
1298
-
<p className="text-sm font-medium mb-1">
1299
-
Domain:
1300
-
</p>
1301
-
<p className="font-mono text-sm">
1302
-
{domain.domain}
1303
-
</p>
1304
-
</div>
1305
-
1306
-
<div className="space-y-3">
1307
-
<div className="p-3 bg-background rounded border border-border">
1308
-
<div className="flex justify-between items-start mb-2">
1309
-
<span className="text-xs font-semibold text-muted-foreground">
1310
-
TXT Record (Verification)
1311
-
</span>
1312
-
</div>
1313
-
<div className="font-mono text-xs space-y-2">
1314
-
<div>
1315
-
<span className="text-muted-foreground">
1316
-
Name:
1317
-
</span>{' '}
1318
-
<span className="select-all">
1319
-
_wisp.{domain.domain}
1320
-
</span>
1321
-
</div>
1322
-
<div>
1323
-
<span className="text-muted-foreground">
1324
-
Value:
1325
-
</span>{' '}
1326
-
<span className="select-all break-all">
1327
-
{userInfo.did}
1328
-
</span>
1329
-
</div>
1330
-
</div>
1331
-
</div>
1332
-
1333
-
<div className="p-3 bg-background rounded border border-border">
1334
-
<div className="flex justify-between items-start mb-2">
1335
-
<span className="text-xs font-semibold text-muted-foreground">
1336
-
CNAME Record (Pointing)
1337
-
</span>
1338
-
</div>
1339
-
<div className="font-mono text-xs space-y-2">
1340
-
<div>
1341
-
<span className="text-muted-foreground">
1342
-
Name:
1343
-
</span>{' '}
1344
-
<span className="select-all">
1345
-
{domain.domain}
1346
-
</span>
1347
-
</div>
1348
-
<div>
1349
-
<span className="text-muted-foreground">
1350
-
Value:
1351
-
</span>{' '}
1352
-
<span className="select-all">
1353
-
{domain.id}.dns.wisp.place
1354
-
</span>
1355
-
</div>
1356
-
</div>
1357
-
<p className="text-xs text-muted-foreground mt-2">
1358
-
Some DNS providers may require you to use @ or leave it blank for the root domain
1359
-
</p>
1360
-
</div>
1361
-
</div>
1362
-
1363
-
<div className="p-3 bg-muted/30 rounded-lg">
1364
-
<p className="text-xs text-muted-foreground">
1365
-
๐ก After configuring DNS, click "Verify DNS"
1366
-
to check if everything is set up correctly.
1367
-
DNS changes can take a few minutes to
1368
-
propagate.
1369
-
</p>
1370
-
</div>
1371
-
</div>
1372
-
)
1373
-
})()}
1374
-
</>
1375
-
)}
1376
-
<DialogFooter>
1377
-
<Button
1378
-
variant="outline"
1379
-
onClick={() => setViewDomainDNS(null)}
1380
-
className="w-full sm:w-auto"
1381
-
>
1382
-
Close
1383
-
</Button>
1384
489
</DialogFooter>
1385
490
</DialogContent>
1386
491
</Dialog>
+239
public/editor/hooks/useDomainData.ts
+239
public/editor/hooks/useDomainData.ts
···
1
+
import { useState } from 'react'
2
+
3
+
export interface CustomDomain {
4
+
id: string
5
+
domain: string
6
+
did: string
7
+
rkey: string
8
+
verified: boolean
9
+
last_verified_at: number | null
10
+
created_at: number
11
+
}
12
+
13
+
export interface WispDomain {
14
+
domain: string
15
+
rkey: string | null
16
+
}
17
+
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<{
25
+
[id: string]: VerificationStatus
26
+
}>({})
27
+
28
+
const fetchDomains = async () => {
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)
36
+
} finally {
37
+
setDomainsLoading(false)
38
+
}
39
+
}
40
+
41
+
const addCustomDomain = async (domain: string) => {
42
+
try {
43
+
const response = await fetch('/api/domain/custom/add', {
44
+
method: 'POST',
45
+
headers: { 'Content-Type': 'application/json' },
46
+
body: JSON.stringify({ domain })
47
+
})
48
+
49
+
const data = await response.json()
50
+
if (data.success) {
51
+
await fetchDomains()
52
+
return { success: true, id: data.id }
53
+
} else {
54
+
throw new Error(data.error || 'Failed to add domain')
55
+
}
56
+
} catch (err) {
57
+
console.error('Add domain error:', err)
58
+
alert(
59
+
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
60
+
)
61
+
return { success: false }
62
+
}
63
+
}
64
+
65
+
const verifyDomain = async (id: string) => {
66
+
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
67
+
68
+
try {
69
+
const response = await fetch('/api/domain/custom/verify', {
70
+
method: 'POST',
71
+
headers: { 'Content-Type': 'application/json' },
72
+
body: JSON.stringify({ id })
73
+
})
74
+
75
+
const data = await response.json()
76
+
if (data.success && data.verified) {
77
+
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
78
+
await fetchDomains()
79
+
} else {
80
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
81
+
if (data.error) {
82
+
alert(`Verification failed: ${data.error}`)
83
+
}
84
+
}
85
+
} catch (err) {
86
+
console.error('Verify domain error:', err)
87
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
88
+
alert(
89
+
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
90
+
)
91
+
}
92
+
}
93
+
94
+
const deleteCustomDomain = async (id: string) => {
95
+
if (!confirm('Are you sure you want to remove this custom domain?')) {
96
+
return false
97
+
}
98
+
99
+
try {
100
+
const response = await fetch(`/api/domain/custom/${id}`, {
101
+
method: 'DELETE'
102
+
})
103
+
104
+
const data = await response.json()
105
+
if (data.success) {
106
+
await fetchDomains()
107
+
return true
108
+
} else {
109
+
throw new Error('Failed to delete domain')
110
+
}
111
+
} catch (err) {
112
+
console.error('Delete domain error:', err)
113
+
alert(
114
+
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
115
+
)
116
+
return false
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')
129
+
return true
130
+
} catch (err) {
131
+
console.error('Map wisp domain error:', err)
132
+
throw err
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`, {
165
+
method: 'POST',
166
+
headers: { 'Content-Type': 'application/json' },
167
+
body: JSON.stringify({ siteRkey })
168
+
})
169
+
const data = await response.json()
170
+
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
171
+
return true
172
+
} catch (err) {
173
+
console.error('Map custom domain error:', err)
174
+
throw err
175
+
}
176
+
}
177
+
178
+
const claimWispDomain = async (handle: string) => {
179
+
try {
180
+
const response = await fetch('/api/domain/claim', {
181
+
method: 'POST',
182
+
headers: { 'Content-Type': 'application/json' },
183
+
body: JSON.stringify({ handle })
184
+
})
185
+
186
+
const data = await response.json()
187
+
if (data.success) {
188
+
await fetchDomains()
189
+
return { success: true }
190
+
} else {
191
+
throw new Error(data.error || 'Failed to claim domain')
192
+
}
193
+
} catch (err) {
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}`)
203
+
}
204
+
return { success: false, error: errorMessage }
205
+
}
206
+
}
207
+
208
+
const checkWispAvailability = async (handle: string) => {
209
+
const trimmedHandle = handle.trim().toLowerCase()
210
+
if (!trimmedHandle) {
211
+
return { available: null }
212
+
}
213
+
214
+
try {
215
+
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
216
+
const data = await response.json()
217
+
return { available: data.available }
218
+
} catch (err) {
219
+
console.error('Check availability error:', err)
220
+
return { available: false }
221
+
}
222
+
}
223
+
224
+
return {
225
+
wispDomains,
226
+
customDomains,
227
+
domainsLoading,
228
+
verificationStatus,
229
+
fetchDomains,
230
+
addCustomDomain,
231
+
verifyDomain,
232
+
deleteCustomDomain,
233
+
mapWispDomain,
234
+
deleteWispDomain,
235
+
mapCustomDomain,
236
+
claimWispDomain,
237
+
checkWispAvailability
238
+
}
239
+
}
+112
public/editor/hooks/useSiteData.ts
+112
public/editor/hooks/useSiteData.ts
···
1
+
import { useState } from 'react'
2
+
3
+
export interface Site {
4
+
did: string
5
+
rkey: string
6
+
display_name: string | null
7
+
created_at: number
8
+
updated_at: number
9
+
}
10
+
11
+
export interface DomainInfo {
12
+
type: 'wisp' | 'custom'
13
+
domain: string
14
+
verified?: boolean
15
+
id?: string
16
+
}
17
+
18
+
export interface SiteWithDomains extends Site {
19
+
domains?: DomainInfo[]
20
+
}
21
+
22
+
export function useSiteData() {
23
+
const [sites, setSites] = useState<SiteWithDomains[]>([])
24
+
const [sitesLoading, setSitesLoading] = useState(true)
25
+
const [isSyncing, setIsSyncing] = useState(false)
26
+
27
+
const fetchSites = async () => {
28
+
try {
29
+
const response = await fetch('/api/user/sites')
30
+
const data = await response.json()
31
+
const sitesData: Site[] = data.sites || []
32
+
33
+
// Fetch domain info for each site
34
+
const sitesWithDomains = await Promise.all(
35
+
sitesData.map(async (site) => {
36
+
try {
37
+
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
38
+
const domainsData = await domainsResponse.json()
39
+
return {
40
+
...site,
41
+
domains: domainsData.domains || []
42
+
}
43
+
} catch (err) {
44
+
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
45
+
return {
46
+
...site,
47
+
domains: []
48
+
}
49
+
}
50
+
})
51
+
)
52
+
53
+
setSites(sitesWithDomains)
54
+
} catch (err) {
55
+
console.error('Failed to fetch sites:', err)
56
+
} finally {
57
+
setSitesLoading(false)
58
+
}
59
+
}
60
+
61
+
const syncSites = async () => {
62
+
setIsSyncing(true)
63
+
try {
64
+
const response = await fetch('/api/user/sync', {
65
+
method: 'POST'
66
+
})
67
+
const data = await response.json()
68
+
if (data.success) {
69
+
console.log(`Synced ${data.synced} sites from PDS`)
70
+
// Refresh sites list
71
+
await fetchSites()
72
+
}
73
+
} catch (err) {
74
+
console.error('Failed to sync sites:', err)
75
+
alert('Failed to sync sites from PDS')
76
+
} finally {
77
+
setIsSyncing(false)
78
+
}
79
+
}
80
+
81
+
const deleteSite = async (rkey: string) => {
82
+
try {
83
+
const response = await fetch(`/api/site/${rkey}`, {
84
+
method: 'DELETE'
85
+
})
86
+
87
+
const data = await response.json()
88
+
if (data.success) {
89
+
// Refresh sites list
90
+
await fetchSites()
91
+
return true
92
+
} else {
93
+
throw new Error(data.error || 'Failed to delete site')
94
+
}
95
+
} catch (err) {
96
+
console.error('Delete site error:', err)
97
+
alert(
98
+
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
99
+
)
100
+
return false
101
+
}
102
+
}
103
+
104
+
return {
105
+
sites,
106
+
sitesLoading,
107
+
isSyncing,
108
+
fetchSites,
109
+
syncSites,
110
+
deleteSite
111
+
}
112
+
}
+29
public/editor/hooks/useUserInfo.ts
+29
public/editor/hooks/useUserInfo.ts
···
1
+
import { useState } from 'react'
2
+
3
+
export interface UserInfo {
4
+
did: string
5
+
handle: string
6
+
}
7
+
8
+
export function useUserInfo() {
9
+
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
10
+
const [loading, setLoading] = useState(true)
11
+
12
+
const fetchUserInfo = async () => {
13
+
try {
14
+
const response = await fetch('/api/user/info')
15
+
const data = await response.json()
16
+
setUserInfo(data)
17
+
} catch (err) {
18
+
console.error('Failed to fetch user info:', err)
19
+
} finally {
20
+
setLoading(false)
21
+
}
22
+
}
23
+
24
+
return {
25
+
userInfo,
26
+
loading,
27
+
fetchUserInfo
28
+
}
29
+
}
+42
-1
public/editor/index.html
+42
-1
public/editor/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Elysia Static</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Manage your decentralized static sites hosted on AT Protocol." />
8
+
9
+
<!-- Open Graph / Facebook -->
10
+
<meta property="og:type" content="website" />
11
+
<meta property="og:url" content="https://wisp.place/editor" />
12
+
<meta property="og:title" content="Editor - wisp.place" />
13
+
<meta property="og:description" content="Manage your decentralized static sites hosted on AT Protocol." />
14
+
<meta property="og:site_name" content="wisp.place" />
15
+
16
+
<!-- Twitter -->
17
+
<meta name="twitter:card" content="summary" />
18
+
<meta name="twitter:url" content="https://wisp.place/editor" />
19
+
<meta name="twitter:title" content="Editor - wisp.place" />
20
+
<meta name="twitter:description" content="Manage your decentralized static sites hosted on AT Protocol." />
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
+
<style>
31
+
/* Dark theme fallback styles for before JS loads */
32
+
@media (prefers-color-scheme: dark) {
33
+
body {
34
+
background-color: oklch(0.23 0.015 285);
35
+
color: oklch(0.90 0.005 285);
36
+
}
37
+
38
+
pre {
39
+
background-color: oklch(0.33 0.015 285) !important;
40
+
color: oklch(0.90 0.005 285) !important;
41
+
}
42
+
43
+
.bg-muted {
44
+
background-color: oklch(0.33 0.015 285) !important;
45
+
}
46
+
}
47
+
</style>
7
48
</head>
8
49
<body>
9
50
<div id="elysia"></div>
+322
public/editor/tabs/CLITab.tsx
+322
public/editor/tabs/CLITab.tsx
···
1
+
import {
2
+
Card,
3
+
CardContent,
4
+
CardDescription,
5
+
CardHeader,
6
+
CardTitle
7
+
} from '@public/components/ui/card'
8
+
import { Badge } from '@public/components/ui/badge'
9
+
import { ExternalLink } from 'lucide-react'
10
+
import { CodeBlock } from '@public/components/ui/code-block'
11
+
12
+
export function CLITab() {
13
+
return (
14
+
<div className="space-y-4 min-h-[400px]">
15
+
<Card>
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>
23
+
Deploy static sites directly from your terminal
24
+
</CardDescription>
25
+
</CardHeader>
26
+
<CardContent className="space-y-6">
27
+
<div className="prose prose-sm max-w-none dark:prose-invert">
28
+
<p className="text-sm text-muted-foreground">
29
+
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
30
+
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
31
+
</p>
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"
52
+
>
53
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
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">
61
+
<a
62
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
63
+
target="_blank"
64
+
rel="noopener noreferrer"
65
+
className="flex items-center justify-between mb-2"
66
+
>
67
+
<span className="font-mono text-sm">Linux (ARM64)</span>
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">
75
+
<a
76
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
77
+
target="_blank"
78
+
rel="noopener noreferrer"
79
+
className="flex items-center justify-between mb-2"
80
+
>
81
+
<span className="font-mono text-sm">Linux (x86_64)</span>
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`}
120
+
language="bash"
121
+
/>
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{' '}
168
+
<a
169
+
href="https://blog.tangled.org/ci"
170
+
target="_blank"
171
+
rel="noopener noreferrer"
172
+
className="text-accent hover:underline"
173
+
>
174
+
Tangled Spindle
175
+
</a>
176
+
</p>
177
+
178
+
<div className="space-y-4">
179
+
<div>
180
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
181
+
<span>Example 1: Simple Asset Publishing</span>
182
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
183
+
</h4>
184
+
<CodeBlock
185
+
code={`when:
186
+
- event: ['push']
187
+
branch: ['main']
188
+
- event: ['manual']
189
+
190
+
engine: 'nixery'
191
+
192
+
clone:
193
+
skip: false
194
+
depth: 1
195
+
196
+
dependencies:
197
+
nixpkgs:
198
+
- coreutils
199
+
- curl
200
+
201
+
environment:
202
+
SITE_PATH: '.' # Copy entire repo
203
+
SITE_NAME: 'myWebbedSite'
204
+
WISP_HANDLE: 'your-handle.bsky.social'
205
+
206
+
steps:
207
+
- name: deploy assets to wisp
208
+
command: |
209
+
# Download Wisp CLI
210
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
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" \\
218
+
--password "$WISP_APP_PASSWORD"
219
+
220
+
# Output
221
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
222
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
223
+
`}
224
+
language="yaml"
225
+
/>
226
+
</div>
227
+
228
+
<div>
229
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
230
+
<span>Example 2: React/Vite Build & Deploy</span>
231
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
232
+
</h4>
233
+
<CodeBlock
234
+
code={`when:
235
+
- event: ['push']
236
+
branch: ['main']
237
+
- event: ['manual']
238
+
239
+
engine: 'nixery'
240
+
241
+
clone:
242
+
skip: false
243
+
depth: 1
244
+
submodules: false
245
+
246
+
dependencies:
247
+
nixpkgs:
248
+
- nodejs
249
+
- coreutils
250
+
- curl
251
+
github:NixOS/nixpkgs/nixpkgs-unstable:
252
+
- bun
253
+
254
+
environment:
255
+
SITE_PATH: 'dist'
256
+
SITE_NAME: 'my-react-site'
257
+
WISP_HANDLE: 'your-handle.bsky.social'
258
+
259
+
steps:
260
+
- name: build site
261
+
command: |
262
+
# necessary to ensure bun is in PATH
263
+
export PATH="$HOME/.nix-profile/bin:$PATH"
264
+
265
+
bun install --frozen-lockfile
266
+
267
+
# build with vite, run directly to get around env issues
268
+
bun node_modules/.bin/vite build
269
+
270
+
- name: deploy to wisp
271
+
command: |
272
+
# Download Wisp CLI
273
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
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" \\
281
+
--password "$WISP_APP_PASSWORD"`}
282
+
language="yaml"
283
+
/>
284
+
</div>
285
+
</div>
286
+
287
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
288
+
<p className="text-xs text-muted-foreground">
289
+
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
290
+
Generate an app password from your AT Protocol account settings.
291
+
</p>
292
+
</div>
293
+
</div>
294
+
295
+
<div className="space-y-3">
296
+
<h3 className="text-sm font-semibold">Learn More</h3>
297
+
<div className="grid gap-2">
298
+
<a
299
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
300
+
target="_blank"
301
+
rel="noopener noreferrer"
302
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
303
+
>
304
+
<span className="text-sm">Source Code</span>
305
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
306
+
</a>
307
+
<a
308
+
href="https://blog.tangled.org/ci"
309
+
target="_blank"
310
+
rel="noopener noreferrer"
311
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
312
+
>
313
+
<span className="text-sm">Tangled Spindle CI/CD</span>
314
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
315
+
</a>
316
+
</div>
317
+
</div>
318
+
</CardContent>
319
+
</Card>
320
+
</div>
321
+
)
322
+
}
+524
public/editor/tabs/DomainsTab.tsx
+524
public/editor/tabs/DomainsTab.tsx
···
1
+
import { useState } from 'react'
2
+
import {
3
+
Card,
4
+
CardContent,
5
+
CardDescription,
6
+
CardHeader,
7
+
CardTitle
8
+
} from '@public/components/ui/card'
9
+
import { Button } from '@public/components/ui/button'
10
+
import { Input } from '@public/components/ui/input'
11
+
import { Label } from '@public/components/ui/label'
12
+
import { Badge } from '@public/components/ui/badge'
13
+
import {
14
+
Dialog,
15
+
DialogContent,
16
+
DialogDescription,
17
+
DialogHeader,
18
+
DialogTitle,
19
+
DialogFooter
20
+
} from '@public/components/ui/dialog'
21
+
import {
22
+
CheckCircle2,
23
+
XCircle,
24
+
Loader2,
25
+
Trash2
26
+
} from 'lucide-react'
27
+
import type { WispDomain, CustomDomain } from '../hooks/useDomainData'
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' }
35
+
userInfo: UserInfo | null
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,
49
+
userInfo,
50
+
onAddCustomDomain,
51
+
onVerifyDomain,
52
+
onDeleteCustomDomain,
53
+
onDeleteWispDomain,
54
+
onClaimWispDomain,
55
+
onCheckWispAvailability
56
+
}: DomainsTabProps) {
57
+
// Wisp domain claim state
58
+
const [wispHandle, setWispHandle] = useState('')
59
+
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
60
+
const [wispAvailability, setWispAvailability] = useState<{
61
+
available: boolean | null
62
+
checking: boolean
63
+
}>({ available: null, checking: false })
64
+
65
+
// Custom domain modal state
66
+
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
67
+
const [customDomain, setCustomDomain] = useState('')
68
+
const [isAddingDomain, setIsAddingDomain] = useState(false)
69
+
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
70
+
71
+
const checkWispAvailability = async (handle: string) => {
72
+
const trimmedHandle = handle.trim().toLowerCase()
73
+
if (!trimmedHandle) {
74
+
setWispAvailability({ available: null, checking: false })
75
+
return
76
+
}
77
+
78
+
setWispAvailability({ available: null, checking: true })
79
+
const result = await onCheckWispAvailability(trimmedHandle)
80
+
setWispAvailability({ available: result.available, checking: false })
81
+
}
82
+
83
+
const handleClaimWispDomain = async () => {
84
+
const trimmedHandle = wispHandle.trim().toLowerCase()
85
+
if (!trimmedHandle) {
86
+
alert('Please enter a handle')
87
+
return
88
+
}
89
+
90
+
setIsClaimingWisp(true)
91
+
const result = await onClaimWispDomain(trimmedHandle)
92
+
if (result.success) {
93
+
setWispHandle('')
94
+
setWispAvailability({ available: null, checking: false })
95
+
}
96
+
setIsClaimingWisp(false)
97
+
}
98
+
99
+
const handleAddCustomDomain = async () => {
100
+
if (!customDomain) {
101
+
alert('Please enter a domain')
102
+
return
103
+
}
104
+
105
+
setIsAddingDomain(true)
106
+
const result = await onAddCustomDomain(customDomain)
107
+
setIsAddingDomain(false)
108
+
109
+
if (result.success) {
110
+
setCustomDomain('')
111
+
setAddDomainModalOpen(false)
112
+
// Automatically show DNS configuration for the newly added domain
113
+
if (result.id) {
114
+
setViewDomainDNS(result.id)
115
+
}
116
+
}
117
+
}
118
+
119
+
return (
120
+
<>
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>
130
+
{domainsLoading ? (
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>
247
+
</Card>
248
+
249
+
<Card>
250
+
<CardHeader>
251
+
<CardTitle>Custom Domains</CardTitle>
252
+
<CardDescription>
253
+
Bring your own domain with DNS verification
254
+
</CardDescription>
255
+
</CardHeader>
256
+
<CardContent className="space-y-4">
257
+
<Button
258
+
onClick={() => setAddDomainModalOpen(true)}
259
+
className="w-full"
260
+
>
261
+
Add Custom Domain
262
+
</Button>
263
+
264
+
{domainsLoading ? (
265
+
<div className="flex items-center justify-center py-4">
266
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
267
+
</div>
268
+
) : customDomains.length === 0 ? (
269
+
<div className="text-center py-4 text-muted-foreground text-sm">
270
+
No custom domains added yet
271
+
</div>
272
+
) : (
273
+
<div className="space-y-2">
274
+
{customDomains.map((domain) => (
275
+
<div
276
+
key={domain.id}
277
+
className="flex items-center justify-between p-3 border border-border rounded-lg"
278
+
>
279
+
<div className="flex flex-col gap-1 flex-1">
280
+
<div className="flex items-center gap-2">
281
+
{domain.verified ? (
282
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
283
+
) : (
284
+
<XCircle className="w-4 h-4 text-red-500" />
285
+
)}
286
+
<span className="font-mono">
287
+
{domain.domain}
288
+
</span>
289
+
</div>
290
+
{domain.rkey && domain.rkey !== 'self' && (
291
+
<p className="text-xs text-muted-foreground ml-6">
292
+
โ Mapped to site: {domain.rkey}
293
+
</p>
294
+
)}
295
+
</div>
296
+
<div className="flex items-center gap-2">
297
+
<Button
298
+
variant="outline"
299
+
size="sm"
300
+
onClick={() =>
301
+
setViewDomainDNS(domain.id)
302
+
}
303
+
>
304
+
View DNS
305
+
</Button>
306
+
{domain.verified ? (
307
+
<Badge variant="secondary">
308
+
Verified
309
+
</Badge>
310
+
) : (
311
+
<Button
312
+
variant="outline"
313
+
size="sm"
314
+
onClick={() =>
315
+
onVerifyDomain(domain.id)
316
+
}
317
+
disabled={
318
+
verificationStatus[
319
+
domain.id
320
+
] === 'verifying'
321
+
}
322
+
>
323
+
{verificationStatus[
324
+
domain.id
325
+
] === 'verifying' ? (
326
+
<>
327
+
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
328
+
Verifying...
329
+
</>
330
+
) : (
331
+
'Verify DNS'
332
+
)}
333
+
</Button>
334
+
)}
335
+
<Button
336
+
variant="ghost"
337
+
size="sm"
338
+
onClick={() =>
339
+
onDeleteCustomDomain(
340
+
domain.id
341
+
)
342
+
}
343
+
>
344
+
<Trash2 className="w-4 h-4" />
345
+
</Button>
346
+
</div>
347
+
</div>
348
+
))}
349
+
</div>
350
+
)}
351
+
</CardContent>
352
+
</Card>
353
+
</div>
354
+
355
+
{/* Add Custom Domain Modal */}
356
+
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
357
+
<DialogContent className="sm:max-w-lg">
358
+
<DialogHeader>
359
+
<DialogTitle>Add Custom Domain</DialogTitle>
360
+
<DialogDescription>
361
+
Enter your domain name. After adding, you'll see the DNS
362
+
records to configure.
363
+
</DialogDescription>
364
+
</DialogHeader>
365
+
<div className="space-y-4 py-4">
366
+
<div className="space-y-2">
367
+
<Label htmlFor="new-domain">Domain Name</Label>
368
+
<Input
369
+
id="new-domain"
370
+
placeholder="example.com"
371
+
value={customDomain}
372
+
onChange={(e) => setCustomDomain(e.target.value)}
373
+
/>
374
+
<p className="text-xs text-muted-foreground">
375
+
After adding, click "View DNS" to see the records you
376
+
need to configure.
377
+
</p>
378
+
</div>
379
+
</div>
380
+
<DialogFooter className="flex-col sm:flex-row gap-2">
381
+
<Button
382
+
variant="outline"
383
+
onClick={() => {
384
+
setAddDomainModalOpen(false)
385
+
setCustomDomain('')
386
+
}}
387
+
className="w-full sm:w-auto"
388
+
disabled={isAddingDomain}
389
+
>
390
+
Cancel
391
+
</Button>
392
+
<Button
393
+
onClick={handleAddCustomDomain}
394
+
disabled={!customDomain || isAddingDomain}
395
+
className="w-full sm:w-auto"
396
+
>
397
+
{isAddingDomain ? (
398
+
<>
399
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
400
+
Adding...
401
+
</>
402
+
) : (
403
+
'Add Domain'
404
+
)}
405
+
</Button>
406
+
</DialogFooter>
407
+
</DialogContent>
408
+
</Dialog>
409
+
410
+
{/* View DNS Records Modal */}
411
+
<Dialog
412
+
open={viewDomainDNS !== null}
413
+
onOpenChange={(open) => !open && setViewDomainDNS(null)}
414
+
>
415
+
<DialogContent className="sm:max-w-lg">
416
+
<DialogHeader>
417
+
<DialogTitle>DNS Configuration</DialogTitle>
418
+
<DialogDescription>
419
+
Add these DNS records to your domain provider
420
+
</DialogDescription>
421
+
</DialogHeader>
422
+
{viewDomainDNS && userInfo && (
423
+
<>
424
+
{(() => {
425
+
const domain = customDomains.find(
426
+
(d) => d.id === viewDomainDNS
427
+
)
428
+
if (!domain) return null
429
+
430
+
return (
431
+
<div className="space-y-4 py-4">
432
+
<div className="p-3 bg-muted/30 rounded-lg">
433
+
<p className="text-sm font-medium mb-1">
434
+
Domain:
435
+
</p>
436
+
<p className="font-mono text-sm">
437
+
{domain.domain}
438
+
</p>
439
+
</div>
440
+
441
+
<div className="space-y-3">
442
+
<div className="p-3 bg-background rounded border border-border">
443
+
<div className="flex justify-between items-start mb-2">
444
+
<span className="text-xs font-semibold text-muted-foreground">
445
+
TXT Record (Verification)
446
+
</span>
447
+
</div>
448
+
<div className="font-mono text-xs space-y-2">
449
+
<div>
450
+
<span className="text-muted-foreground">
451
+
Name:
452
+
</span>{' '}
453
+
<span className="select-all">
454
+
_wisp.{domain.domain}
455
+
</span>
456
+
</div>
457
+
<div>
458
+
<span className="text-muted-foreground">
459
+
Value:
460
+
</span>{' '}
461
+
<span className="select-all break-all">
462
+
{userInfo.did}
463
+
</span>
464
+
</div>
465
+
</div>
466
+
</div>
467
+
468
+
<div className="p-3 bg-background rounded border border-border">
469
+
<div className="flex justify-between items-start mb-2">
470
+
<span className="text-xs font-semibold text-muted-foreground">
471
+
CNAME Record (Pointing)
472
+
</span>
473
+
</div>
474
+
<div className="font-mono text-xs space-y-2">
475
+
<div>
476
+
<span className="text-muted-foreground">
477
+
Name:
478
+
</span>{' '}
479
+
<span className="select-all">
480
+
{domain.domain}
481
+
</span>
482
+
</div>
483
+
<div>
484
+
<span className="text-muted-foreground">
485
+
Value:
486
+
</span>{' '}
487
+
<span className="select-all">
488
+
{domain.id}.dns.wisp.place
489
+
</span>
490
+
</div>
491
+
</div>
492
+
<p className="text-xs text-muted-foreground mt-2">
493
+
Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification.
494
+
</p>
495
+
</div>
496
+
</div>
497
+
498
+
<div className="p-3 bg-muted/30 rounded-lg">
499
+
<p className="text-xs text-muted-foreground">
500
+
๐ก After configuring DNS, click "Verify DNS"
501
+
to check if everything is set up correctly.
502
+
DNS changes can take a few minutes to
503
+
propagate.
504
+
</p>
505
+
</div>
506
+
</div>
507
+
)
508
+
})()}
509
+
</>
510
+
)}
511
+
<DialogFooter>
512
+
<Button
513
+
variant="outline"
514
+
onClick={() => setViewDomainDNS(null)}
515
+
className="w-full sm:w-auto"
516
+
>
517
+
Close
518
+
</Button>
519
+
</DialogFooter>
520
+
</DialogContent>
521
+
</Dialog>
522
+
</>
523
+
)
524
+
}
+196
public/editor/tabs/SitesTab.tsx
+196
public/editor/tabs/SitesTab.tsx
···
1
+
import {
2
+
Card,
3
+
CardContent,
4
+
CardDescription,
5
+
CardHeader,
6
+
CardTitle
7
+
} from '@public/components/ui/card'
8
+
import { Button } from '@public/components/ui/button'
9
+
import { Badge } from '@public/components/ui/badge'
10
+
import {
11
+
Globe,
12
+
ExternalLink,
13
+
CheckCircle2,
14
+
AlertCircle,
15
+
Loader2,
16
+
RefreshCw,
17
+
Settings
18
+
} from 'lucide-react'
19
+
import type { SiteWithDomains } from '../hooks/useSiteData'
20
+
import type { UserInfo } from '../hooks/useUserInfo'
21
+
22
+
interface SitesTabProps {
23
+
sites: SiteWithDomains[]
24
+
sitesLoading: boolean
25
+
isSyncing: boolean
26
+
userInfo: UserInfo | null
27
+
onSyncSites: () => Promise<void>
28
+
onConfigureSite: (site: SiteWithDomains) => void
29
+
}
30
+
31
+
export function SitesTab({
32
+
sites,
33
+
sitesLoading,
34
+
isSyncing,
35
+
userInfo,
36
+
onSyncSites,
37
+
onConfigureSite
38
+
}: SitesTabProps) {
39
+
const getSiteUrl = (site: SiteWithDomains) => {
40
+
// Use the first mapped domain if available
41
+
if (site.domains && site.domains.length > 0) {
42
+
return `https://${site.domains[0].domain}`
43
+
}
44
+
45
+
// Default fallback URL - use handle instead of DID
46
+
if (!userInfo) return '#'
47
+
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
48
+
}
49
+
50
+
const getSiteDomainName = (site: SiteWithDomains) => {
51
+
// Return the first domain if available
52
+
if (site.domains && site.domains.length > 0) {
53
+
return site.domains[0].domain
54
+
}
55
+
56
+
// Use handle instead of DID for display
57
+
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
58
+
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
59
+
}
60
+
61
+
return (
62
+
<div className="space-y-4 min-h-[400px]">
63
+
<Card>
64
+
<CardHeader>
65
+
<div className="flex items-center justify-between">
66
+
<div>
67
+
<CardTitle>Your Sites</CardTitle>
68
+
<CardDescription>
69
+
View and manage all your deployed sites
70
+
</CardDescription>
71
+
</div>
72
+
<Button
73
+
variant="outline"
74
+
size="sm"
75
+
onClick={onSyncSites}
76
+
disabled={isSyncing || sitesLoading}
77
+
>
78
+
<RefreshCw
79
+
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
80
+
/>
81
+
Sync from PDS
82
+
</Button>
83
+
</div>
84
+
</CardHeader>
85
+
<CardContent className="space-y-4">
86
+
{sitesLoading ? (
87
+
<div className="flex items-center justify-center py-8">
88
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
89
+
</div>
90
+
) : sites.length === 0 ? (
91
+
<div className="text-center py-8 text-muted-foreground">
92
+
<p>No sites yet. Upload your first site!</p>
93
+
</div>
94
+
) : (
95
+
sites.map((site) => (
96
+
<div
97
+
key={`${site.did}-${site.rkey}`}
98
+
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
99
+
>
100
+
<div className="flex-1">
101
+
<div className="flex items-center gap-3 mb-2">
102
+
<h3 className="font-semibold text-lg">
103
+
{site.display_name || site.rkey}
104
+
</h3>
105
+
<Badge
106
+
variant="secondary"
107
+
className="text-xs"
108
+
>
109
+
active
110
+
</Badge>
111
+
</div>
112
+
113
+
{/* Display all mapped domains */}
114
+
{site.domains && site.domains.length > 0 ? (
115
+
<div className="space-y-1">
116
+
{site.domains.map((domainInfo, idx) => (
117
+
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
118
+
<a
119
+
href={`https://${domainInfo.domain}`}
120
+
target="_blank"
121
+
rel="noopener noreferrer"
122
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
123
+
>
124
+
<Globe className="w-3 h-3" />
125
+
{domainInfo.domain}
126
+
<ExternalLink className="w-3 h-3" />
127
+
</a>
128
+
<Badge
129
+
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
130
+
className="text-xs"
131
+
>
132
+
{domainInfo.type}
133
+
</Badge>
134
+
{domainInfo.type === 'custom' && (
135
+
<Badge
136
+
variant={domainInfo.verified ? 'default' : 'secondary'}
137
+
className="text-xs"
138
+
>
139
+
{domainInfo.verified ? (
140
+
<>
141
+
<CheckCircle2 className="w-3 h-3 mr-1" />
142
+
verified
143
+
</>
144
+
) : (
145
+
<>
146
+
<AlertCircle className="w-3 h-3 mr-1" />
147
+
pending
148
+
</>
149
+
)}
150
+
</Badge>
151
+
)}
152
+
</div>
153
+
))}
154
+
</div>
155
+
) : (
156
+
<a
157
+
href={getSiteUrl(site)}
158
+
target="_blank"
159
+
rel="noopener noreferrer"
160
+
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
161
+
>
162
+
{getSiteDomainName(site)}
163
+
<ExternalLink className="w-3 h-3" />
164
+
</a>
165
+
)}
166
+
</div>
167
+
<Button
168
+
variant="outline"
169
+
size="sm"
170
+
onClick={() => onConfigureSite(site)}
171
+
>
172
+
<Settings className="w-4 h-4 mr-2" />
173
+
Configure
174
+
</Button>
175
+
</div>
176
+
))
177
+
)}
178
+
</CardContent>
179
+
</Card>
180
+
181
+
<div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
182
+
<div className="flex items-start gap-2">
183
+
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
184
+
<div className="flex-1 space-y-1">
185
+
<p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
186
+
Note about sites.wisp.place URLs
187
+
</p>
188
+
<p className="text-xs text-muted-foreground">
189
+
Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths.
190
+
</p>
191
+
</div>
192
+
</div>
193
+
</div>
194
+
</div>
195
+
)
196
+
}
+323
public/editor/tabs/UploadTab.tsx
+323
public/editor/tabs/UploadTab.tsx
···
1
+
import { useState, useEffect } from 'react'
2
+
import {
3
+
Card,
4
+
CardContent,
5
+
CardDescription,
6
+
CardHeader,
7
+
CardTitle
8
+
} from '@public/components/ui/card'
9
+
import { Button } from '@public/components/ui/button'
10
+
import { Input } from '@public/components/ui/input'
11
+
import { Label } from '@public/components/ui/label'
12
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
13
+
import { Badge } from '@public/components/ui/badge'
14
+
import {
15
+
Globe,
16
+
Upload,
17
+
AlertCircle,
18
+
Loader2
19
+
} from 'lucide-react'
20
+
import type { SiteWithDomains } from '../hooks/useSiteData'
21
+
22
+
interface UploadTabProps {
23
+
sites: SiteWithDomains[]
24
+
sitesLoading: boolean
25
+
onUploadComplete: () => Promise<void>
26
+
}
27
+
28
+
export function UploadTab({
29
+
sites,
30
+
sitesLoading,
31
+
onUploadComplete
32
+
}: UploadTabProps) {
33
+
// Upload state
34
+
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
35
+
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
36
+
const [newSiteName, setNewSiteName] = useState('')
37
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
38
+
const [isUploading, setIsUploading] = useState(false)
39
+
const [uploadProgress, setUploadProgress] = useState('')
40
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
41
+
const [uploadedCount, setUploadedCount] = useState(0)
42
+
43
+
// Auto-switch to 'new' mode if no sites exist
44
+
useEffect(() => {
45
+
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
46
+
setSiteMode('new')
47
+
}
48
+
}, [sites, sitesLoading, siteMode])
49
+
50
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
51
+
if (e.target.files && e.target.files.length > 0) {
52
+
setSelectedFiles(e.target.files)
53
+
}
54
+
}
55
+
56
+
const handleUpload = async () => {
57
+
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
58
+
59
+
if (!siteName) {
60
+
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
61
+
return
62
+
}
63
+
64
+
setIsUploading(true)
65
+
setUploadProgress('Preparing files...')
66
+
67
+
try {
68
+
const formData = new FormData()
69
+
formData.append('siteName', siteName)
70
+
71
+
if (selectedFiles) {
72
+
for (let i = 0; i < selectedFiles.length; i++) {
73
+
formData.append('files', selectedFiles[i])
74
+
}
75
+
}
76
+
77
+
setUploadProgress('Uploading to AT Protocol...')
78
+
const response = await fetch('/wisp/upload-files', {
79
+
method: 'POST',
80
+
body: formData
81
+
})
82
+
83
+
const data = await response.json()
84
+
if (data.success) {
85
+
setUploadProgress('Upload complete!')
86
+
setSkippedFiles(data.skippedFiles || [])
87
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
88
+
setSelectedSiteRkey('')
89
+
setNewSiteName('')
90
+
setSelectedFiles(null)
91
+
92
+
// Refresh sites list
93
+
await onUploadComplete()
94
+
95
+
// Reset form - give more time if there are skipped files
96
+
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
97
+
setTimeout(() => {
98
+
setUploadProgress('')
99
+
setSkippedFiles([])
100
+
setUploadedCount(0)
101
+
setIsUploading(false)
102
+
}, resetDelay)
103
+
} else {
104
+
throw new Error(data.error || 'Upload failed')
105
+
}
106
+
} catch (err) {
107
+
console.error('Upload error:', err)
108
+
alert(
109
+
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
110
+
)
111
+
setIsUploading(false)
112
+
setUploadProgress('')
113
+
}
114
+
}
115
+
116
+
return (
117
+
<div className="space-y-4 min-h-[400px]">
118
+
<Card>
119
+
<CardHeader>
120
+
<CardTitle>Upload Site</CardTitle>
121
+
<CardDescription>
122
+
Deploy a new site from a folder or Git repository
123
+
</CardDescription>
124
+
</CardHeader>
125
+
<CardContent className="space-y-6">
126
+
<div className="space-y-4">
127
+
<div className="p-4 bg-muted/50 rounded-lg">
128
+
<RadioGroup
129
+
value={siteMode}
130
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
131
+
disabled={isUploading}
132
+
>
133
+
<div className="flex items-center space-x-2">
134
+
<RadioGroupItem value="existing" id="existing" />
135
+
<Label htmlFor="existing" className="cursor-pointer">
136
+
Update existing site
137
+
</Label>
138
+
</div>
139
+
<div className="flex items-center space-x-2">
140
+
<RadioGroupItem value="new" id="new" />
141
+
<Label htmlFor="new" className="cursor-pointer">
142
+
Create new site
143
+
</Label>
144
+
</div>
145
+
</RadioGroup>
146
+
</div>
147
+
148
+
{siteMode === 'existing' ? (
149
+
<div className="space-y-2">
150
+
<Label htmlFor="site-select">Select Site</Label>
151
+
{sitesLoading ? (
152
+
<div className="flex items-center justify-center py-4">
153
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
154
+
</div>
155
+
) : sites.length === 0 ? (
156
+
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
157
+
No sites available. Create a new site instead.
158
+
</div>
159
+
) : (
160
+
<select
161
+
id="site-select"
162
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
163
+
value={selectedSiteRkey}
164
+
onChange={(e) => setSelectedSiteRkey(e.target.value)}
165
+
disabled={isUploading}
166
+
>
167
+
<option value="">Select a site...</option>
168
+
{sites.map((site) => (
169
+
<option key={site.rkey} value={site.rkey}>
170
+
{site.display_name || site.rkey}
171
+
</option>
172
+
))}
173
+
</select>
174
+
)}
175
+
</div>
176
+
) : (
177
+
<div className="space-y-2">
178
+
<Label htmlFor="new-site-name">New Site Name</Label>
179
+
<Input
180
+
id="new-site-name"
181
+
placeholder="my-awesome-site"
182
+
value={newSiteName}
183
+
onChange={(e) => setNewSiteName(e.target.value)}
184
+
disabled={isUploading}
185
+
/>
186
+
</div>
187
+
)}
188
+
189
+
<p className="text-xs text-muted-foreground">
190
+
File limits: 100MB per file, 300MB total
191
+
</p>
192
+
</div>
193
+
194
+
<div className="grid md:grid-cols-2 gap-4">
195
+
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
196
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
197
+
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
198
+
<h3 className="font-semibold mb-2">
199
+
Upload Folder
200
+
</h3>
201
+
<p className="text-sm text-muted-foreground mb-4">
202
+
Drag and drop or click to upload your
203
+
static site files
204
+
</p>
205
+
<input
206
+
type="file"
207
+
id="file-upload"
208
+
multiple
209
+
onChange={handleFileSelect}
210
+
className="hidden"
211
+
{...(({ webkitdirectory: '', directory: '' } as any))}
212
+
disabled={isUploading}
213
+
/>
214
+
<label htmlFor="file-upload">
215
+
<Button
216
+
variant="outline"
217
+
type="button"
218
+
onClick={() =>
219
+
document
220
+
.getElementById('file-upload')
221
+
?.click()
222
+
}
223
+
disabled={isUploading}
224
+
>
225
+
Choose Folder
226
+
</Button>
227
+
</label>
228
+
{selectedFiles && selectedFiles.length > 0 && (
229
+
<p className="text-sm text-muted-foreground mt-3">
230
+
{selectedFiles.length} files selected
231
+
</p>
232
+
)}
233
+
</CardContent>
234
+
</Card>
235
+
236
+
<Card className="border-2 border-dashed opacity-50">
237
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
238
+
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
239
+
<h3 className="font-semibold mb-2">
240
+
Connect Git Repository
241
+
</h3>
242
+
<p className="text-sm text-muted-foreground mb-4">
243
+
Link your GitHub, GitLab, or any Git
244
+
repository
245
+
</p>
246
+
<Badge variant="secondary">Coming soon!</Badge>
247
+
</CardContent>
248
+
</Card>
249
+
</div>
250
+
251
+
{uploadProgress && (
252
+
<div className="space-y-3">
253
+
<div className="p-4 bg-muted rounded-lg">
254
+
<div className="flex items-center gap-2">
255
+
<Loader2 className="w-4 h-4 animate-spin" />
256
+
<span className="text-sm">{uploadProgress}</span>
257
+
</div>
258
+
</div>
259
+
260
+
{skippedFiles.length > 0 && (
261
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
262
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
263
+
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
264
+
<div className="flex-1">
265
+
<span className="font-medium">
266
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
267
+
</span>
268
+
{uploadedCount > 0 && (
269
+
<span className="text-sm ml-2">
270
+
({uploadedCount} uploaded successfully)
271
+
</span>
272
+
)}
273
+
</div>
274
+
</div>
275
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
276
+
{skippedFiles.slice(0, 5).map((file, idx) => (
277
+
<div key={idx} className="text-xs">
278
+
<span className="font-mono">{file.name}</span>
279
+
<span className="text-muted-foreground"> - {file.reason}</span>
280
+
</div>
281
+
))}
282
+
{skippedFiles.length > 5 && (
283
+
<div className="text-xs text-muted-foreground">
284
+
...and {skippedFiles.length - 5} more
285
+
</div>
286
+
)}
287
+
</div>
288
+
</div>
289
+
)}
290
+
</div>
291
+
)}
292
+
293
+
<Button
294
+
onClick={handleUpload}
295
+
className="w-full"
296
+
disabled={
297
+
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
298
+
isUploading ||
299
+
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
300
+
}
301
+
>
302
+
{isUploading ? (
303
+
<>
304
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
305
+
Uploading...
306
+
</>
307
+
) : (
308
+
<>
309
+
{siteMode === 'existing' ? (
310
+
'Update Site'
311
+
) : (
312
+
selectedFiles && selectedFiles.length > 0
313
+
? 'Upload & Deploy'
314
+
: 'Create Empty Site'
315
+
)}
316
+
</>
317
+
)}
318
+
</Button>
319
+
</CardContent>
320
+
</Card>
321
+
</div>
322
+
)
323
+
}
public/favicon-16x16.png
public/favicon-16x16.png
This is a binary file and will not be displayed.
public/favicon-32x32.png
public/favicon-32x32.png
This is a binary file and will not be displayed.
public/favicon.ico
public/favicon.ico
This is a binary file and will not be displayed.
+24
-1
public/index.html
+24
-1
public/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Elysia Static</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution. Built on Bluesky's decentralized network." />
8
+
9
+
<!-- Open Graph / Facebook -->
10
+
<meta property="og:type" content="website" />
11
+
<meta property="og:url" content="https://wisp.place/" />
12
+
<meta property="og:title" content="wisp.place - Decentralized Static Site Hosting" />
13
+
<meta property="og:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." />
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/" />
19
+
<meta name="twitter:title" content="wisp.place - Decentralized Static Site Hosting" />
20
+
<meta name="twitter:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." />
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">
7
30
</head>
8
31
<body>
9
32
<div id="elysia"></div>
+428
-16
public/index.tsx
+428
-16
public/index.tsx
···
1
-
import { useState, useRef, useEffect } from 'react'
1
+
import React, { useState, useRef, useEffect } from 'react'
2
2
import { createRoot } from 'react-dom/client'
3
3
import {
4
4
ArrowRight,
···
9
9
Code,
10
10
Server
11
11
} from 'lucide-react'
12
-
13
12
import Layout from '@public/layouts'
14
13
import { Button } from '@public/components/ui/button'
15
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
+
}
16
305
17
306
function App() {
18
307
const [showForm, setShowForm] = useState(false)
308
+
const [checkingAuth, setCheckingAuth] = useState(true)
19
309
const inputRef = useRef<HTMLInputElement>(null)
20
310
21
311
useEffect(() => {
312
+
// Check authentication status on mount
313
+
const checkAuth = async () => {
314
+
try {
315
+
const response = await fetch('/api/auth/status', {
316
+
credentials: 'include'
317
+
})
318
+
const data = await response.json()
319
+
if (data.authenticated) {
320
+
// User is already authenticated, redirect to editor
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
+
}
333
+
}
334
+
335
+
checkAuth()
336
+
}, [])
337
+
338
+
useEffect(() => {
22
339
if (showForm) {
23
340
setTimeout(() => inputRef.current?.focus(), 500)
24
341
}
25
342
}, [showForm])
26
343
344
+
if (checkingAuth) {
345
+
return (
346
+
<div className="min-h-screen bg-background flex items-center justify-center">
347
+
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
348
+
</div>
349
+
)
350
+
}
351
+
27
352
return (
28
353
<>
29
354
<div className="min-h-screen">
···
31
356
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
32
357
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
33
358
<div className="flex items-center gap-2">
34
-
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
35
-
<Globe className="w-5 h-5 text-primary-foreground" />
36
-
</div>
359
+
<img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
37
360
<span className="text-xl font-semibold text-foreground">
38
361
wisp.place
39
362
</span>
···
49
372
<Button
50
373
size="sm"
51
374
className="bg-accent text-accent-foreground hover:bg-accent/90"
375
+
onClick={() => setShowForm(true)}
52
376
>
53
377
Get Started
54
378
</Button>
···
61
385
<div className="max-w-4xl mx-auto text-center">
62
386
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
63
387
<span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
64
-
<span className="text-sm text-accent-foreground">
388
+
<span className="text-sm text-foreground">
65
389
Built on AT Protocol
66
390
</span>
67
391
</div>
···
135
459
'Login failed:',
136
460
error
137
461
)
462
+
// Clear any invalid cookies
463
+
document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax'
138
464
alert('Authentication failed')
139
465
}
140
466
}}
141
467
className="space-y-3"
142
468
>
143
-
<input
144
-
ref={inputRef}
145
-
type="text"
146
-
name="handle"
147
-
placeholder="Enter your handle (e.g., alice.bsky.social)"
148
-
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"
149
-
/>
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>
150
485
<button
151
486
type="submit"
152
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"
···
286
621
287
622
{/* CTA Section */}
288
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">
289
675
<div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12">
290
676
<h2 className="text-3xl md:text-4xl font-bold mb-4">
291
677
Ready to deploy?
···
319
705
>
320
706
@nekomimi.pet
321
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>
322
732
</p>
323
733
</div>
324
734
</div>
···
330
740
331
741
const root = createRoot(document.getElementById('elysia')!)
332
742
root.render(
333
-
<Layout className="gap-6">
334
-
<App />
335
-
</Layout>
743
+
<AtProtoProvider>
744
+
<Layout className="gap-6">
745
+
<App />
746
+
</Layout>
747
+
</AtProtoProvider>
336
748
)
+24
public/layouts/index.tsx
+24
public/layouts/index.tsx
···
1
1
import type { PropsWithChildren } from 'react'
2
+
import { useEffect } from 'react'
2
3
3
4
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
4
5
import clsx from 'clsx'
···
12
13
}
13
14
14
15
export default function Layout({ children, className }: LayoutProps) {
16
+
useEffect(() => {
17
+
// Function to update dark mode based on system preference
18
+
const updateDarkMode = (e: MediaQueryList | MediaQueryListEvent) => {
19
+
if (e.matches) {
20
+
document.documentElement.classList.add('dark')
21
+
} else {
22
+
document.documentElement.classList.remove('dark')
23
+
}
24
+
}
25
+
26
+
// Create media query
27
+
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
28
+
29
+
// Set initial value
30
+
updateDarkMode(darkModeQuery)
31
+
32
+
// Listen for changes
33
+
darkModeQuery.addEventListener('change', updateDarkMode)
34
+
35
+
// Cleanup
36
+
return () => darkModeQuery.removeEventListener('change', updateDarkMode)
37
+
}, [])
38
+
15
39
return (
16
40
<QueryClientProvider client={client}>
17
41
<div
+18
-1
public/onboarding/index.html
+18
-1
public/onboarding/index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Get Started - wisp.place</title>
6
+
<title>wisp.place</title>
7
+
<meta name="description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." />
8
+
9
+
<!-- Open Graph / Facebook -->
10
+
<meta property="og:type" content="website" />
11
+
<meta property="og:url" content="https://wisp.place/onboarding" />
12
+
<meta property="og:title" content="Get Started - wisp.place" />
13
+
<meta property="og:description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." />
14
+
<meta property="og:site_name" content="wisp.place" />
15
+
16
+
<!-- Twitter -->
17
+
<meta name="twitter:card" content="summary" />
18
+
<meta name="twitter:url" content="https://wisp.place/onboarding" />
19
+
<meta name="twitter:title" content="Get Started - wisp.place" />
20
+
<meta name="twitter:description" content="Get started with wisp.place and host your first decentralized static site on AT Protocol." />
21
+
22
+
<!-- Theme -->
23
+
<meta name="theme-color" content="#7c3aed" />
7
24
</head>
8
25
<body>
9
26
<div id="elysia"></div>
+21
public/robots.txt
+21
public/robots.txt
···
1
+
# robots.txt for wisp.place
2
+
3
+
User-agent: *
4
+
5
+
# Allow indexing of landing page
6
+
Allow: /$
7
+
8
+
# Disallow application pages
9
+
Disallow: /editor
10
+
Disallow: /admin
11
+
Disallow: /onboarding
12
+
13
+
# Disallow API routes
14
+
Disallow: /api/
15
+
Disallow: /wisp/
16
+
17
+
# Allow static assets
18
+
Allow: /favicon.ico
19
+
Allow: /favicon-*.png
20
+
Allow: /apple-touch-icon.png
21
+
Allow: /site.webmanifest
+1
public/site.webmanifest
+1
public/site.webmanifest
···
1
+
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+98
-58
public/styles/global.css
+98
-58
public/styles/global.css
···
1
1
@import "tailwindcss";
2
2
@import "tw-animate-css";
3
3
4
-
@custom-variant dark (&:is(.dark *));
4
+
@custom-variant dark (@media (prefers-color-scheme: dark));
5
5
6
6
:root {
7
-
/* #F2E7C9 - parchment background */
8
-
--background: oklch(0.93 0.03 85);
9
-
/* #413C58 - violet for text */
10
-
--foreground: oklch(0.32 0.04 285);
7
+
color-scheme: light;
11
8
12
-
--card: oklch(0.98 0.01 85);
13
-
--card-foreground: oklch(0.32 0.04 285);
9
+
/* Warm beige background inspired by Sunset design #E9DDD8 */
10
+
--background: oklch(0.90 0.012 35);
11
+
/* Very dark brown text for strong contrast #2A2420 */
12
+
--foreground: oklch(0.18 0.01 30);
14
13
15
-
--popover: oklch(0.98 0.01 85);
16
-
--popover-foreground: oklch(0.32 0.04 285);
14
+
/* Slightly lighter card background */
15
+
--card: oklch(0.93 0.01 35);
16
+
--card-foreground: oklch(0.18 0.01 30);
17
+
18
+
--popover: oklch(0.93 0.01 35);
19
+
--popover-foreground: oklch(0.18 0.01 30);
17
20
18
-
/* #413C58 - violet primary */
19
-
--primary: oklch(0.32 0.04 285);
20
-
--primary-foreground: oklch(0.98 0.01 85);
21
+
/* Dark brown primary inspired by #645343 */
22
+
--primary: oklch(0.35 0.02 35);
23
+
--primary-foreground: oklch(0.95 0.01 35);
21
24
22
-
/* #FFAAD2 - pink accent */
25
+
/* Bright pink accent for links #FFAAD2 */
23
26
--accent: oklch(0.78 0.15 345);
24
-
--accent-foreground: oklch(0.32 0.04 285);
27
+
--accent-foreground: oklch(0.18 0.01 30);
25
28
26
-
/* #348AA7 - blue secondary */
27
-
--secondary: oklch(0.56 0.08 220);
28
-
--secondary-foreground: oklch(0.98 0.01 85);
29
+
/* Medium taupe secondary inspired by #867D76 */
30
+
--secondary: oklch(0.52 0.015 30);
31
+
--secondary-foreground: oklch(0.95 0.01 35);
29
32
30
-
/* #CCD7C5 - ash muted */
31
-
--muted: oklch(0.85 0.02 130);
32
-
--muted-foreground: oklch(0.45 0.03 285);
33
+
/* Light warm muted background */
34
+
--muted: oklch(0.88 0.01 35);
35
+
--muted-foreground: oklch(0.42 0.015 30);
33
36
34
-
--border: oklch(0.75 0.02 285);
35
-
--input: oklch(0.75 0.02 285);
36
-
--ring: oklch(0.78 0.15 345);
37
+
--border: oklch(0.75 0.015 30);
38
+
--input: oklch(0.92 0.01 35);
39
+
--ring: oklch(0.72 0.08 15);
37
40
38
41
--destructive: oklch(0.577 0.245 27.325);
39
42
--destructive-foreground: oklch(0.985 0 0);
···
56
59
}
57
60
58
61
.dark {
59
-
/* #413C58 - violet background for dark mode */
60
-
--background: oklch(0.28 0.04 285);
61
-
/* #F2E7C9 - parchment text */
62
-
--foreground: oklch(0.93 0.03 85);
62
+
color-scheme: dark;
63
63
64
-
--card: oklch(0.32 0.04 285);
65
-
--card-foreground: oklch(0.93 0.03 85);
64
+
/* Slate violet background - #2C2C2C with violet tint */
65
+
--background: oklch(0.23 0.015 285);
66
+
/* Light gray text - #E4E4E4 */
67
+
--foreground: oklch(0.90 0.005 285);
66
68
67
-
--popover: oklch(0.32 0.04 285);
68
-
--popover-foreground: oklch(0.93 0.03 85);
69
+
/* Slightly lighter slate for cards */
70
+
--card: oklch(0.28 0.015 285);
71
+
--card-foreground: oklch(0.90 0.005 285);
69
72
70
-
/* #FFAAD2 - pink primary in dark mode */
71
-
--primary: oklch(0.78 0.15 345);
72
-
--primary-foreground: oklch(0.32 0.04 285);
73
+
--popover: oklch(0.28 0.015 285);
74
+
--popover-foreground: oklch(0.90 0.005 285);
73
75
74
-
--accent: oklch(0.78 0.15 345);
75
-
--accent-foreground: oklch(0.32 0.04 285);
76
+
/* Lavender buttons - #B39CD0 */
77
+
--primary: oklch(0.70 0.10 295);
78
+
--primary-foreground: oklch(0.23 0.015 285);
76
79
77
-
--secondary: oklch(0.56 0.08 220);
78
-
--secondary-foreground: oklch(0.93 0.03 85);
80
+
/* Soft pink accent - #FFC1CC */
81
+
--accent: oklch(0.85 0.08 5);
82
+
--accent-foreground: oklch(0.23 0.015 285);
79
83
80
-
--muted: oklch(0.38 0.03 285);
81
-
--muted-foreground: oklch(0.75 0.02 85);
84
+
/* Light cyan secondary - #A8DADC */
85
+
--secondary: oklch(0.82 0.05 200);
86
+
--secondary-foreground: oklch(0.23 0.015 285);
82
87
83
-
--border: oklch(0.42 0.03 285);
84
-
--input: oklch(0.42 0.03 285);
85
-
--ring: oklch(0.78 0.15 345);
88
+
/* Muted slate areas */
89
+
--muted: oklch(0.33 0.015 285);
90
+
--muted-foreground: oklch(0.72 0.01 285);
86
91
87
-
--destructive: oklch(0.577 0.245 27.325);
88
-
--destructive-foreground: oklch(0.985 0 0);
92
+
/* Subtle borders */
93
+
--border: oklch(0.38 0.02 285);
94
+
--input: oklch(0.30 0.015 285);
95
+
--ring: oklch(0.70 0.10 295);
96
+
97
+
/* Warm destructive color */
98
+
--destructive: oklch(0.60 0.22 27);
99
+
--destructive-foreground: oklch(0.98 0.01 85);
89
100
90
-
--chart-1: oklch(0.78 0.15 345);
91
-
--chart-2: oklch(0.93 0.03 85);
92
-
--chart-3: oklch(0.56 0.08 220);
93
-
--chart-4: oklch(0.85 0.02 130);
94
-
--chart-5: oklch(0.32 0.04 285);
95
-
--sidebar: oklch(0.205 0 0);
96
-
--sidebar-foreground: oklch(0.985 0 0);
97
-
--sidebar-primary: oklch(0.488 0.243 264.376);
98
-
--sidebar-primary-foreground: oklch(0.985 0 0);
99
-
--sidebar-accent: oklch(0.269 0 0);
100
-
--sidebar-accent-foreground: oklch(0.985 0 0);
101
-
--sidebar-border: oklch(0.269 0 0);
102
-
--sidebar-ring: oklch(0.439 0 0);
101
+
/* Chart colors using the accent palette */
102
+
--chart-1: oklch(0.85 0.08 5);
103
+
--chart-2: oklch(0.82 0.05 200);
104
+
--chart-3: oklch(0.70 0.10 295);
105
+
--chart-4: oklch(0.75 0.08 340);
106
+
--chart-5: oklch(0.65 0.08 180);
107
+
108
+
/* Sidebar slate */
109
+
--sidebar: oklch(0.20 0.015 285);
110
+
--sidebar-foreground: oklch(0.90 0.005 285);
111
+
--sidebar-primary: oklch(0.70 0.10 295);
112
+
--sidebar-primary-foreground: oklch(0.20 0.015 285);
113
+
--sidebar-accent: oklch(0.28 0.015 285);
114
+
--sidebar-accent-foreground: oklch(0.90 0.005 285);
115
+
--sidebar-border: oklch(0.32 0.02 285);
116
+
--sidebar-ring: oklch(0.70 0.10 295);
103
117
}
104
118
105
119
@theme inline {
···
150
164
@apply bg-background text-foreground;
151
165
}
152
166
}
167
+
168
+
@keyframes arrow-bounce {
169
+
0%, 100% {
170
+
transform: translateX(0);
171
+
}
172
+
50% {
173
+
transform: translateX(4px);
174
+
}
175
+
}
176
+
177
+
.arrow-animate {
178
+
animation: arrow-bounce 1.5s ease-in-out infinite;
179
+
}
180
+
181
+
/* Shiki syntax highlighting styles */
182
+
.shiki-wrapper {
183
+
border-radius: 0.5rem;
184
+
padding: 1rem;
185
+
overflow-x: auto;
186
+
border: 1px solid hsl(var(--border));
187
+
}
188
+
189
+
.shiki-wrapper pre {
190
+
margin: 0 !important;
191
+
padding: 0 !important;
192
+
}
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
···
1
1
// Change admin password
2
-
import { adminAuth } from './src/lib/admin-auth'
3
-
import { db } from './src/lib/db'
2
+
import { adminAuth } from '../src/lib/admin-auth'
3
+
import { db } from '../src/lib/db'
4
4
import { randomBytes, createHash } from 'crypto'
5
5
6
6
// Get username and new password from command line
+36
-15
src/index.ts
+36
-15
src/index.ts
···
1
1
import { Elysia } from 'elysia'
2
+
import type { Context } from 'elysia'
2
3
import { cors } from '@elysiajs/cors'
3
-
import { openapi, fromTypes } from '@elysiajs/openapi'
4
4
import { staticPlugin } from '@elysiajs/static'
5
5
6
6
import type { Config } from './lib/types'
···
12
12
cleanupExpiredSessions,
13
13
rotateKeysIfNeeded
14
14
} from './lib/oauth-client'
15
+
import { getCookieSecret } from './lib/db'
15
16
import { authRoutes } from './routes/auth'
16
17
import { wispRoutes } from './routes/wisp'
17
18
import { domainRoutes } from './routes/domain'
···
24
25
import { adminRoutes } from './routes/admin'
25
26
26
27
const config: Config = {
27
-
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
28
+
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as Config['domain'],
28
29
clientName: Bun.env.CLIENT_NAME ?? 'PDS-View'
29
30
}
30
31
31
32
// Initialize admin setup (prompt if no admin exists)
32
33
await promptAdminSetup()
33
34
35
+
// Get or generate cookie signing secret
36
+
const cookieSecret = await getCookieSecret()
37
+
34
38
const client = await getOAuthClient(config)
35
39
36
40
// Periodic maintenance: cleanup expired sessions and rotate keys
···
58
62
dnsVerifier.start()
59
63
logger.info('DNS Verifier Started - checking custom domains every 10 minutes')
60
64
61
-
export const app = new Elysia()
62
-
.use(openapi({
63
-
references: fromTypes()
64
-
}))
65
+
export const app = new Elysia({
66
+
serve: {
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
+
})
65
76
// Observability middleware
66
77
.onBeforeHandle(observabilityMiddleware('main-app').beforeHandle)
67
-
.onAfterHandle((ctx) => {
78
+
.onAfterHandle((ctx: Context) => {
68
79
observabilityMiddleware('main-app').afterHandle(ctx)
69
80
// Security headers middleware
70
81
const { set } = ctx
···
93
104
})
94
105
.onError(observabilityMiddleware('main-app').onError)
95
106
.use(csrfProtection())
96
-
.use(authRoutes(client))
97
-
.use(wispRoutes(client))
98
-
.use(domainRoutes(client))
99
-
.use(userRoutes(client))
100
-
.use(siteRoutes(client))
101
-
.use(adminRoutes())
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))
102
113
.use(
103
114
await staticPlugin({
104
115
prefix: '/'
105
116
})
106
117
)
107
-
.get('/client-metadata.json', (c) => {
118
+
.get('/client-metadata.json', () => {
108
119
return createClientMetadata(config)
109
120
})
110
-
.get('/jwks.json', async (c) => {
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
+
111
127
const keys = await getCurrentKeys()
112
128
if (!keys.length) return { keys: [] }
113
129
···
143
159
error: error instanceof Error ? error.message : String(error)
144
160
}
145
161
}
162
+
})
163
+
.get('/.well-known/atproto-did', ({ set }) => {
164
+
// Return plain text DID for AT Protocol domain verification
165
+
set.headers['Content-Type'] = 'text/plain'
166
+
return 'did:plc:7puq73yz2hkvbcpdhnsze2qw'
146
167
})
147
168
.use(cors({
148
169
origin: config.domain,
+81
src/lib/csrf.test.ts
+81
src/lib/csrf.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { verifyRequestOrigin } from './csrf'
3
+
4
+
describe('verifyRequestOrigin', () => {
5
+
test('should accept matching origin and host', () => {
6
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
7
+
expect(verifyRequestOrigin('http://localhost:8000', ['localhost:8000'])).toBe(true)
8
+
expect(verifyRequestOrigin('https://app.example.com', ['app.example.com'])).toBe(true)
9
+
})
10
+
11
+
test('should accept origin matching one of multiple allowed hosts', () => {
12
+
const allowedHosts = ['example.com', 'app.example.com', 'localhost:8000']
13
+
expect(verifyRequestOrigin('https://example.com', allowedHosts)).toBe(true)
14
+
expect(verifyRequestOrigin('https://app.example.com', allowedHosts)).toBe(true)
15
+
expect(verifyRequestOrigin('http://localhost:8000', allowedHosts)).toBe(true)
16
+
})
17
+
18
+
test('should reject non-matching origin', () => {
19
+
expect(verifyRequestOrigin('https://evil.com', ['example.com'])).toBe(false)
20
+
expect(verifyRequestOrigin('https://fake-example.com', ['example.com'])).toBe(false)
21
+
expect(verifyRequestOrigin('https://example.com.evil.com', ['example.com'])).toBe(false)
22
+
})
23
+
24
+
test('should reject empty origin', () => {
25
+
expect(verifyRequestOrigin('', ['example.com'])).toBe(false)
26
+
})
27
+
28
+
test('should reject invalid URL format', () => {
29
+
expect(verifyRequestOrigin('not-a-url', ['example.com'])).toBe(false)
30
+
expect(verifyRequestOrigin('javascript:alert(1)', ['example.com'])).toBe(false)
31
+
expect(verifyRequestOrigin('file:///etc/passwd', ['example.com'])).toBe(false)
32
+
})
33
+
34
+
test('should handle different protocols correctly', () => {
35
+
// Same host, different protocols should match (we only check host)
36
+
expect(verifyRequestOrigin('http://example.com', ['example.com'])).toBe(true)
37
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
38
+
})
39
+
40
+
test('should handle port numbers correctly', () => {
41
+
expect(verifyRequestOrigin('http://localhost:3000', ['localhost:3000'])).toBe(true)
42
+
expect(verifyRequestOrigin('http://localhost:3000', ['localhost:8000'])).toBe(false)
43
+
expect(verifyRequestOrigin('http://localhost', ['localhost'])).toBe(true)
44
+
})
45
+
46
+
test('should handle subdomains correctly', () => {
47
+
expect(verifyRequestOrigin('https://sub.example.com', ['sub.example.com'])).toBe(true)
48
+
expect(verifyRequestOrigin('https://sub.example.com', ['example.com'])).toBe(false)
49
+
})
50
+
51
+
test('should handle case sensitivity (exact match required)', () => {
52
+
// URL host is automatically lowercased by URL parser
53
+
expect(verifyRequestOrigin('https://EXAMPLE.COM', ['example.com'])).toBe(true)
54
+
expect(verifyRequestOrigin('https://example.com', ['example.com'])).toBe(true)
55
+
// But allowed hosts are case-sensitive
56
+
expect(verifyRequestOrigin('https://example.com', ['EXAMPLE.COM'])).toBe(false)
57
+
})
58
+
59
+
test('should handle trailing slashes in origin', () => {
60
+
expect(verifyRequestOrigin('https://example.com/', ['example.com'])).toBe(true)
61
+
})
62
+
63
+
test('should handle paths in origin (host extraction)', () => {
64
+
expect(verifyRequestOrigin('https://example.com/path/to/page', ['example.com'])).toBe(true)
65
+
expect(verifyRequestOrigin('https://evil.com/example.com', ['example.com'])).toBe(false)
66
+
})
67
+
68
+
test('should reject when allowed hosts is empty', () => {
69
+
expect(verifyRequestOrigin('https://example.com', [])).toBe(false)
70
+
})
71
+
72
+
test('should handle IPv4 addresses', () => {
73
+
expect(verifyRequestOrigin('http://127.0.0.1:8000', ['127.0.0.1:8000'])).toBe(true)
74
+
expect(verifyRequestOrigin('http://192.168.1.1', ['192.168.1.1'])).toBe(true)
75
+
})
76
+
77
+
test('should handle IPv6 addresses', () => {
78
+
expect(verifyRequestOrigin('http://[::1]:8000', ['[::1]:8000'])).toBe(true)
79
+
expect(verifyRequestOrigin('http://[2001:db8::1]', ['[2001:db8::1]'])).toBe(true)
80
+
})
81
+
})
+246
-38
src/lib/db.ts
+246
-38
src/lib/db.ts
···
36
36
)
37
37
`;
38
38
39
-
// Domains table maps subdomain -> DID
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)
40
49
await db`
41
50
CREATE TABLE IF NOT EXISTS domains (
42
51
domain TEXT PRIMARY KEY,
43
-
did TEXT UNIQUE NOT NULL,
52
+
did TEXT NOT NULL,
44
53
rkey TEXT,
45
54
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
46
55
)
···
71
80
// Column might already exist, ignore
72
81
}
73
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
+
74
90
// Custom domains table for BYOD (bring your own domain)
75
91
await db`
76
92
CREATE TABLE IF NOT EXISTS custom_domains (
77
93
id TEXT PRIMARY KEY,
78
94
domain TEXT UNIQUE NOT NULL,
79
95
did TEXT NOT NULL,
80
-
rkey TEXT NOT NULL DEFAULT 'self',
96
+
rkey TEXT,
81
97
verified BOOLEAN DEFAULT false,
82
98
last_verified_at BIGINT,
83
99
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
84
100
)
85
101
`;
86
102
103
+
// Migrate existing tables to make rkey nullable and remove default
104
+
try {
105
+
await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP NOT NULL`;
106
+
} catch (err) {
107
+
// Column might already be nullable, ignore
108
+
}
109
+
try {
110
+
await db`ALTER TABLE custom_domains ALTER COLUMN rkey DROP DEFAULT`;
111
+
} catch (err) {
112
+
// Default might already be removed, ignore
113
+
}
114
+
87
115
// Sites table - cache of place.wisp.fs records from PDS
88
116
await db`
89
117
CREATE TABLE IF NOT EXISTS sites (
···
96
124
)
97
125
`;
98
126
127
+
// Create indexes for common query patterns
128
+
await Promise.all([
129
+
// oauth_states cleanup queries
130
+
db`CREATE INDEX IF NOT EXISTS idx_oauth_states_expires_at ON oauth_states(expires_at)`.catch(err => {
131
+
if (!err.message?.includes('already exists')) {
132
+
console.error('Failed to create idx_oauth_states_expires_at:', err);
133
+
}
134
+
}),
135
+
136
+
// oauth_sessions cleanup queries
137
+
db`CREATE INDEX IF NOT EXISTS idx_oauth_sessions_expires_at ON oauth_sessions(expires_at)`.catch(err => {
138
+
if (!err.message?.includes('already exists')) {
139
+
console.error('Failed to create idx_oauth_sessions_expires_at:', err);
140
+
}
141
+
}),
142
+
143
+
// oauth_keys key rotation queries
144
+
db`CREATE INDEX IF NOT EXISTS idx_oauth_keys_created_at ON oauth_keys(created_at)`.catch(err => {
145
+
if (!err.message?.includes('already exists')) {
146
+
console.error('Failed to create idx_oauth_keys_created_at:', err);
147
+
}
148
+
}),
149
+
150
+
// domains queries by (did, rkey)
151
+
db`CREATE INDEX IF NOT EXISTS idx_domains_did_rkey ON domains(did, rkey)`.catch(err => {
152
+
if (!err.message?.includes('already exists')) {
153
+
console.error('Failed to create idx_domains_did_rkey:', err);
154
+
}
155
+
}),
156
+
157
+
// custom_domains queries by did
158
+
db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did ON custom_domains(did)`.catch(err => {
159
+
if (!err.message?.includes('already exists')) {
160
+
console.error('Failed to create idx_custom_domains_did:', err);
161
+
}
162
+
}),
163
+
164
+
// custom_domains queries by (did, rkey)
165
+
db`CREATE INDEX IF NOT EXISTS idx_custom_domains_did_rkey ON custom_domains(did, rkey)`.catch(err => {
166
+
if (!err.message?.includes('already exists')) {
167
+
console.error('Failed to create idx_custom_domains_did_rkey:', err);
168
+
}
169
+
}),
170
+
171
+
// custom_domains DNS verification worker queries
172
+
db`CREATE INDEX IF NOT EXISTS idx_custom_domains_verified ON custom_domains(verified)`.catch(err => {
173
+
if (!err.message?.includes('already exists')) {
174
+
console.error('Failed to create idx_custom_domains_verified:', err);
175
+
}
176
+
}),
177
+
178
+
// sites queries by did
179
+
db`CREATE INDEX IF NOT EXISTS idx_sites_did ON sites(did)`.catch(err => {
180
+
if (!err.message?.includes('already exists')) {
181
+
console.error('Failed to create idx_sites_did:', err);
182
+
}
183
+
})
184
+
]);
185
+
99
186
const RESERVED_HANDLES = new Set([
100
187
"www",
101
188
"api",
···
118
205
export const toDomain = (handle: string): string => `${handle.toLowerCase()}.${BASE_HOST}`;
119
206
120
207
export const getDomainByDid = async (did: string): Promise<string | null> => {
121
-
const rows = await db`SELECT domain FROM domains WHERE did = ${did}`;
208
+
const rows = await db`SELECT domain FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
122
209
return rows[0]?.domain ?? null;
123
210
};
124
211
125
212
export const getWispDomainInfo = async (did: string) => {
126
-
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
213
+
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
127
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);
128
225
};
129
226
130
227
export const getDidByDomain = async (domain: string): Promise<string | null> => {
···
180
277
export const claimDomain = async (did: string, handle: string): Promise<string> => {
181
278
const h = handle.trim().toLowerCase();
182
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
+
183
287
const domain = toDomain(h);
184
288
try {
185
289
await db`
···
187
291
VALUES (${domain}, ${did})
188
292
`;
189
293
} catch (err) {
190
-
// Unique constraint violations -> already taken or DID already claimed
294
+
// Unique constraint violations -> already taken
191
295
throw new Error('conflict');
192
296
}
193
297
return domain;
···
212
316
}
213
317
};
214
318
215
-
export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
319
+
export const updateWispDomainSite = async (domain: string, siteRkey: string | null): Promise<void> => {
216
320
await db`
217
321
UPDATE domains
218
322
SET rkey = ${siteRkey}
219
-
WHERE did = ${did}
323
+
WHERE domain = ${domain}
220
324
`;
221
325
};
222
326
223
327
export const getWispDomainSite = async (did: string): Promise<string | null> => {
224
-
const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
328
+
const rows = await db`SELECT rkey FROM domains WHERE did = ${did} ORDER BY created_at ASC LIMIT 1`;
225
329
return rows[0]?.rkey ?? null;
226
330
};
227
331
332
+
export const deleteWispDomain = async (domain: string): Promise<void> => {
333
+
await db`DELETE FROM domains WHERE domain = ${domain}`;
334
+
};
335
+
228
336
// Session timeout configuration (30 days in seconds)
229
337
const SESSION_TIMEOUT = 30 * 24 * 60 * 60; // 2592000 seconds
230
338
// OAuth state timeout (1 hour in seconds)
···
232
340
233
341
const stateStore = {
234
342
async set(key: string, data: any) {
235
-
console.debug('[stateStore] set', key)
236
343
const expiresAt = Math.floor(Date.now() / 1000) + STATE_TIMEOUT;
237
344
await db`
238
345
INSERT INTO oauth_states (key, data, created_at, expires_at)
···
241
348
`;
242
349
},
243
350
async get(key: string) {
244
-
console.debug('[stateStore] get', key)
245
351
const now = Math.floor(Date.now() / 1000);
246
352
const result = await db`
247
353
SELECT data, expires_at
···
253
359
// Check if expired
254
360
const expiresAt = Number(result[0].expires_at);
255
361
if (expiresAt && now > expiresAt) {
256
-
console.debug('[stateStore] State expired, deleting', key);
257
362
await db`DELETE FROM oauth_states WHERE key = ${key}`;
258
363
return undefined;
259
364
}
···
261
366
return JSON.parse(result[0].data);
262
367
},
263
368
async del(key: string) {
264
-
console.debug('[stateStore] del', key)
265
369
await db`DELETE FROM oauth_states WHERE key = ${key}`;
266
370
}
267
371
};
268
372
269
373
const sessionStore = {
270
374
async set(sub: string, data: any) {
271
-
console.debug('[sessionStore] set', sub)
272
375
const expiresAt = Math.floor(Date.now() / 1000) + SESSION_TIMEOUT;
273
376
await db`
274
377
INSERT INTO oauth_sessions (sub, data, updated_at, expires_at)
···
280
383
`;
281
384
},
282
385
async get(sub: string) {
283
-
console.debug('[sessionStore] get', sub)
284
386
const now = Math.floor(Date.now() / 1000);
285
387
const result = await db`
286
388
SELECT data, expires_at
···
300
402
return JSON.parse(result[0].data);
301
403
},
302
404
async del(sub: string) {
303
-
console.debug('[sessionStore] del', sub)
304
405
await db`DELETE FROM oauth_sessions WHERE sub = ${sub}`;
305
406
}
306
407
};
···
325
426
}
326
427
};
327
428
328
-
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => ({
329
-
client_id: `${config.domain}/client-metadata.json`,
330
-
client_name: config.clientName,
331
-
client_uri: config.domain,
332
-
logo_uri: `${config.domain}/logo.png`,
333
-
tos_uri: `${config.domain}/tos`,
334
-
policy_uri: `${config.domain}/policy`,
335
-
redirect_uris: [`${config.domain}/api/auth/callback`],
336
-
grant_types: ['authorization_code', 'refresh_token'],
337
-
response_types: ['code'],
338
-
application_type: 'web',
339
-
token_endpoint_auth_method: 'private_key_jwt',
340
-
token_endpoint_auth_signing_alg: "ES256",
341
-
scope: "atproto transition:generic",
342
-
dpop_bound_access_tokens: true,
343
-
jwks_uri: `${config.domain}/jwks.json`,
344
-
subject_type: 'public',
345
-
authorization_signed_response_alg: 'ES256'
346
-
});
429
+
export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
430
+
const isLocalDev = process.env.LOCAL_DEV === 'true';
431
+
432
+
if (isLocalDev) {
433
+
// Loopback client for local development
434
+
// For loopback, scopes and redirect_uri must be in client_id query string
435
+
const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
436
+
const scope = 'atproto transition:generic';
437
+
const params = new URLSearchParams();
438
+
params.append('redirect_uri', redirectUri);
439
+
params.append('scope', scope);
440
+
441
+
return {
442
+
client_id: `http://localhost?${params.toString()}`,
443
+
client_name: config.clientName,
444
+
client_uri: config.domain,
445
+
redirect_uris: [redirectUri],
446
+
grant_types: ['authorization_code', 'refresh_token'],
447
+
response_types: ['code'],
448
+
application_type: 'web',
449
+
token_endpoint_auth_method: 'none',
450
+
scope: scope,
451
+
dpop_bound_access_tokens: false,
452
+
subject_type: 'public'
453
+
};
454
+
}
455
+
456
+
// Production client with private_key_jwt
457
+
return {
458
+
client_id: `${config.domain}/client-metadata.json`,
459
+
client_name: config.clientName,
460
+
client_uri: config.domain,
461
+
logo_uri: `${config.domain}/logo.png`,
462
+
tos_uri: `${config.domain}/tos`,
463
+
policy_uri: `${config.domain}/policy`,
464
+
redirect_uris: [`${config.domain}/api/auth/callback`],
465
+
grant_types: ['authorization_code', 'refresh_token'],
466
+
response_types: ['code'],
467
+
application_type: 'web',
468
+
token_endpoint_auth_method: 'private_key_jwt',
469
+
token_endpoint_auth_signing_alg: "ES256",
470
+
scope: "atproto transition:generic",
471
+
dpop_bound_access_tokens: true,
472
+
jwks_uri: `${config.domain}/jwks.json`,
473
+
subject_type: 'public',
474
+
authorization_signed_response_alg: 'ES256'
475
+
};
476
+
};
347
477
348
478
const persistKey = async (key: JoseKey) => {
349
479
const priv = key.privateJwk;
···
431
561
}
432
562
};
433
563
434
-
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
564
+
export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
435
565
const keys = await ensureKeys();
436
566
437
567
return new NodeOAuthClient({
···
462
592
return rows[0] ?? null;
463
593
};
464
594
465
-
export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => {
595
+
export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string | null = null) => {
466
596
const domainLower = domain.toLowerCase();
467
597
try {
468
598
await db`
···
476
606
}
477
607
};
478
608
479
-
export const updateCustomDomainRkey = async (id: string, rkey: string) => {
609
+
export const updateCustomDomainRkey = async (id: string, rkey: string | null) => {
480
610
const rows = await db`
481
611
UPDATE custom_domains
482
612
SET rkey = ${rkey}
···
537
667
return { success: false, error: err };
538
668
}
539
669
};
670
+
671
+
// Get all domains (wisp + custom) mapped to a specific site
672
+
export const getDomainsBySite = async (did: string, rkey: string) => {
673
+
const domains: Array<{
674
+
type: 'wisp' | 'custom';
675
+
domain: string;
676
+
verified?: boolean;
677
+
id?: string;
678
+
}> = [];
679
+
680
+
// Check wisp domain
681
+
const wispDomain = await db`
682
+
SELECT domain, rkey FROM domains
683
+
WHERE did = ${did} AND rkey = ${rkey}
684
+
`;
685
+
if (wispDomain.length > 0) {
686
+
domains.push({
687
+
type: 'wisp',
688
+
domain: wispDomain[0].domain,
689
+
});
690
+
}
691
+
692
+
// Check custom domains
693
+
const customDomains = await db`
694
+
SELECT id, domain, verified FROM custom_domains
695
+
WHERE did = ${did} AND rkey = ${rkey}
696
+
ORDER BY created_at DESC
697
+
`;
698
+
for (const cd of customDomains) {
699
+
domains.push({
700
+
type: 'custom',
701
+
domain: cd.domain,
702
+
verified: cd.verified,
703
+
id: cd.id,
704
+
});
705
+
}
706
+
707
+
return domains;
708
+
};
709
+
710
+
// Get count of domains mapped to a specific site
711
+
export const getDomainCountBySite = async (did: string, rkey: string) => {
712
+
const wispCount = await db`
713
+
SELECT COUNT(*) as count FROM domains
714
+
WHERE did = ${did} AND rkey = ${rkey}
715
+
`;
716
+
717
+
const customCount = await db`
718
+
SELECT COUNT(*) as count FROM custom_domains
719
+
WHERE did = ${did} AND rkey = ${rkey}
720
+
`;
721
+
722
+
return {
723
+
wisp: Number(wispCount[0]?.count || 0),
724
+
custom: Number(customCount[0]?.count || 0),
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
+
};
+35
-15
src/lib/dns-verification-worker.ts
+35
-15
src/lib/dns-verification-worker.ts
···
71
71
};
72
72
73
73
try {
74
-
// Get all verified custom domains
75
-
const domains = await db`
76
-
SELECT id, domain, did FROM custom_domains WHERE verified = true
74
+
// Get all custom domains (both verified and pending)
75
+
const domains = await db<Array<{
76
+
id: string;
77
+
domain: string;
78
+
did: string;
79
+
verified: boolean;
80
+
}>>`
81
+
SELECT id, domain, did, verified FROM custom_domains
77
82
`;
78
83
79
84
if (!domains || domains.length === 0) {
80
-
this.log('No verified custom domains to check');
85
+
this.log('No custom domains to check');
81
86
this.lastRunTime = Date.now();
82
87
return;
83
88
}
84
89
85
-
this.log(`Checking ${domains.length} verified custom domains`);
90
+
const verifiedCount = domains.filter(d => d.verified).length;
91
+
const pendingCount = domains.filter(d => !d.verified).length;
92
+
this.log(`Checking ${domains.length} custom domains (${verifiedCount} verified, ${pendingCount} pending)`);
86
93
87
94
// Verify each domain
88
95
for (const row of domains) {
89
96
runStats.totalChecked++;
90
-
const { id, domain, did } = row;
97
+
const { id, domain, did, verified: wasVerified } = row;
91
98
92
99
try {
93
100
// Extract hash from id (SHA256 of did:domain)
···
97
104
const result = await verifyCustomDomain(domain, did, expectedHash);
98
105
99
106
if (result.verified) {
100
-
// Update last_verified_at timestamp
107
+
// Update verified status and last_verified_at timestamp
101
108
await db`
102
109
UPDATE custom_domains
103
-
SET last_verified_at = EXTRACT(EPOCH FROM NOW())
110
+
SET verified = true,
111
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
104
112
WHERE id = ${id}
105
113
`;
106
114
runStats.verified++;
107
-
this.log(`Domain verified: ${domain}`, { did });
115
+
if (!wasVerified) {
116
+
this.log(`Domain newly verified: ${domain}`, { did });
117
+
} else {
118
+
this.log(`Domain re-verified: ${domain}`, { did });
119
+
}
108
120
} else {
109
-
// Mark domain as unverified
121
+
// Mark domain as unverified or keep it pending
110
122
await db`
111
123
UPDATE custom_domains
112
124
SET verified = false,
···
114
126
WHERE id = ${id}
115
127
`;
116
128
runStats.failed++;
117
-
this.log(`Domain verification failed: ${domain}`, {
118
-
did,
119
-
error: result.error,
120
-
found: result.found,
121
-
});
129
+
if (wasVerified) {
130
+
this.log(`Domain verification failed (was verified): ${domain}`, {
131
+
did,
132
+
error: result.error,
133
+
found: result.found,
134
+
});
135
+
} else {
136
+
this.log(`Domain still pending: ${domain}`, {
137
+
did,
138
+
error: result.error,
139
+
found: result.found,
140
+
});
141
+
}
122
142
}
123
143
} catch (error) {
124
144
runStats.errors++;
+19
-3
src/lib/dns-verify.ts
+19
-3
src/lib/dns-verify.ts
···
135
135
}
136
136
137
137
/**
138
-
* Verify both TXT and CNAME records for a custom domain
138
+
* Verify custom domain using TXT record as authoritative proof
139
+
* CNAME check is optional/advisory - TXT record is sufficient for verification
140
+
*
141
+
* This approach works with CNAME flattening (e.g., Cloudflare) where the CNAME
142
+
* is resolved to A/AAAA records and won't be visible in DNS queries.
139
143
*/
140
144
export const verifyCustomDomain = async (
141
145
domain: string,
142
146
expectedDid: string,
143
147
expectedHash: string
144
148
): Promise<VerificationResult> => {
149
+
// TXT record is authoritative - it proves ownership
145
150
const txtResult = await verifyDomainOwnership(domain, expectedDid)
146
151
if (!txtResult.verified) {
147
152
return txtResult
148
153
}
149
154
155
+
// CNAME check is advisory only - we still check it for logging/debugging
156
+
// but don't fail verification if it's missing (could be flattened)
150
157
const cnameResult = await verifyCNAME(domain, expectedHash)
158
+
159
+
// Log CNAME status for debugging, but don't fail on it
151
160
if (!cnameResult.verified) {
152
-
return cnameResult
161
+
console.log(`[DNS Verify] โ ๏ธ CNAME verification failed (may be flattened):`, cnameResult.error)
153
162
}
154
163
155
-
return { verified: true }
164
+
// TXT verification is sufficient
165
+
return {
166
+
verified: true,
167
+
found: {
168
+
txt: txtResult.found?.txt,
169
+
cname: cnameResult.found?.cname
170
+
}
171
+
}
156
172
}
+30
-5
src/lib/oauth-client.ts
+30
-5
src/lib/oauth-client.ts
···
58
58
`;
59
59
},
60
60
async get(sub: string) {
61
-
console.debug('[sessionStore] get', sub)
62
61
const now = Math.floor(Date.now() / 1000);
63
62
const result = await db`
64
63
SELECT data, expires_at
···
103
102
}
104
103
};
105
104
106
-
export const createClientMetadata = (config: { domain: `https://${string}`, clientName: string }): ClientMetadata => {
107
-
// Use editor.wisp.place for OAuth endpoints since that's where the API routes live
105
+
export const createClientMetadata = (config: { domain: `http://${string}` | `https://${string}`, clientName: string }): ClientMetadata => {
106
+
const isLocalDev = Bun.env.LOCAL_DEV === 'true';
107
+
108
+
if (isLocalDev) {
109
+
// Loopback client for local development
110
+
// For loopback, scopes and redirect_uri must be in client_id query string
111
+
const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
112
+
const scope = 'atproto transition:generic';
113
+
const params = new URLSearchParams();
114
+
params.append('redirect_uri', redirectUri);
115
+
params.append('scope', scope);
116
+
117
+
return {
118
+
client_id: `http://localhost?${params.toString()}`,
119
+
client_name: config.clientName,
120
+
client_uri: `https://wisp.place`,
121
+
redirect_uris: [redirectUri],
122
+
grant_types: ['authorization_code', 'refresh_token'],
123
+
response_types: ['code'],
124
+
application_type: 'web',
125
+
token_endpoint_auth_method: 'none',
126
+
scope: scope,
127
+
dpop_bound_access_tokens: false,
128
+
subject_type: 'public'
129
+
};
130
+
}
131
+
132
+
// Production client with private_key_jwt
108
133
return {
109
134
client_id: `${config.domain}/client-metadata.json`,
110
135
client_name: config.clientName,
111
-
client_uri: `https://wisp.place`,
136
+
client_uri: `https://wisp.place`,
112
137
logo_uri: `${config.domain}/logo.png`,
113
138
tos_uri: `${config.domain}/tos`,
114
139
policy_uri: `${config.domain}/policy`,
···
212
237
}
213
238
};
214
239
215
-
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
240
+
export const getOAuthClient = async (config: { domain: `http://${string}` | `https://${string}`, clientName: string }) => {
216
241
const keys = await ensureKeys();
217
242
218
243
return new NodeOAuthClient({
+10
-6
src/lib/observability.ts
+10
-6
src/lib/observability.ts
···
312
312
service
313
313
)
314
314
315
-
logCollector.error(
316
-
`Request failed: ${request.method} ${url.pathname}`,
317
-
service,
318
-
error,
319
-
{ statusCode: set.status || 500 }
320
-
)
315
+
// Don't log 404 errors
316
+
const statusCode = set.status || 500
317
+
if (statusCode !== 404) {
318
+
logCollector.error(
319
+
`Request failed: ${request.method} ${url.pathname}`,
320
+
service,
321
+
error,
322
+
{ statusCode }
323
+
)
324
+
}
321
325
}
322
326
}
323
327
}
+2
-2
src/lib/types.ts
+2
-2
src/lib/types.ts
···
3
3
* @typeParam Config
4
4
*/
5
5
export type Config = {
6
-
/** The base domain URL with HTTPS protocol */
7
-
domain: `https://${string}`,
6
+
/** The base domain URL with HTTP or HTTPS protocol */
7
+
domain: `http://${string}` | `https://${string}`,
8
8
/** Name of the client application */
9
9
clientName: string
10
10
};
+999
src/lib/wisp-utils.test.ts
+999
src/lib/wisp-utils.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import {
3
+
shouldCompressFile,
4
+
compressFile,
5
+
processUploadedFiles,
6
+
createManifest,
7
+
updateFileBlobs,
8
+
computeCID,
9
+
extractBlobMap,
10
+
type UploadedFile,
11
+
type FileUploadResult,
12
+
} from './wisp-utils'
13
+
import type { Directory } from '../lexicons/types/place/wisp/fs'
14
+
import { gunzipSync } from 'zlib'
15
+
import { BlobRef } from '@atproto/api'
16
+
import { CID } from 'multiformats/cid'
17
+
18
+
// Helper function to create a valid CID for testing
19
+
// Using a real valid CID from actual AT Protocol usage
20
+
const TEST_CID_STRING = 'bafkreid7ybejd5s2vv2j7d4aajjlmdgazguemcnuliiyfn6coxpwp2mi6y'
21
+
22
+
function createMockBlobRef(mimeType: string, size: number): BlobRef {
23
+
// Create a properly formatted CID
24
+
const cid = CID.parse(TEST_CID_STRING)
25
+
return new BlobRef(cid, mimeType, size)
26
+
}
27
+
28
+
describe('shouldCompressFile', () => {
29
+
test('should compress HTML files', () => {
30
+
expect(shouldCompressFile('text/html')).toBe(true)
31
+
expect(shouldCompressFile('text/html; charset=utf-8')).toBe(true)
32
+
})
33
+
34
+
test('should compress CSS files', () => {
35
+
expect(shouldCompressFile('text/css')).toBe(true)
36
+
})
37
+
38
+
test('should compress JavaScript files', () => {
39
+
expect(shouldCompressFile('text/javascript')).toBe(true)
40
+
expect(shouldCompressFile('application/javascript')).toBe(true)
41
+
expect(shouldCompressFile('application/x-javascript')).toBe(true)
42
+
})
43
+
44
+
test('should compress JSON files', () => {
45
+
expect(shouldCompressFile('application/json')).toBe(true)
46
+
})
47
+
48
+
test('should compress SVG files', () => {
49
+
expect(shouldCompressFile('image/svg+xml')).toBe(true)
50
+
})
51
+
52
+
test('should compress XML files', () => {
53
+
expect(shouldCompressFile('text/xml')).toBe(true)
54
+
expect(shouldCompressFile('application/xml')).toBe(true)
55
+
})
56
+
57
+
test('should compress plain text files', () => {
58
+
expect(shouldCompressFile('text/plain')).toBe(true)
59
+
})
60
+
61
+
test('should NOT compress images', () => {
62
+
expect(shouldCompressFile('image/png')).toBe(false)
63
+
expect(shouldCompressFile('image/jpeg')).toBe(false)
64
+
expect(shouldCompressFile('image/jpg')).toBe(false)
65
+
expect(shouldCompressFile('image/gif')).toBe(false)
66
+
expect(shouldCompressFile('image/webp')).toBe(false)
67
+
})
68
+
69
+
test('should NOT compress videos', () => {
70
+
expect(shouldCompressFile('video/mp4')).toBe(false)
71
+
expect(shouldCompressFile('video/webm')).toBe(false)
72
+
})
73
+
74
+
test('should NOT compress already compressed formats', () => {
75
+
expect(shouldCompressFile('application/zip')).toBe(false)
76
+
expect(shouldCompressFile('application/gzip')).toBe(false)
77
+
expect(shouldCompressFile('application/pdf')).toBe(false)
78
+
})
79
+
80
+
test('should NOT compress fonts', () => {
81
+
expect(shouldCompressFile('font/woff')).toBe(false)
82
+
expect(shouldCompressFile('font/woff2')).toBe(false)
83
+
expect(shouldCompressFile('font/ttf')).toBe(false)
84
+
})
85
+
})
86
+
87
+
describe('compressFile', () => {
88
+
test('should compress text content', () => {
89
+
const content = Buffer.from('Hello, World! '.repeat(100))
90
+
const compressed = compressFile(content)
91
+
92
+
expect(compressed.length).toBeLessThan(content.length)
93
+
94
+
// Verify we can decompress it back
95
+
const decompressed = gunzipSync(compressed)
96
+
expect(decompressed.toString()).toBe(content.toString())
97
+
})
98
+
99
+
test('should compress HTML content significantly', () => {
100
+
const html = `
101
+
<!DOCTYPE html>
102
+
<html>
103
+
<head><title>Test</title></head>
104
+
<body>
105
+
${'<p>Hello World!</p>\n'.repeat(50)}
106
+
</body>
107
+
</html>
108
+
`
109
+
const content = Buffer.from(html)
110
+
const compressed = compressFile(content)
111
+
112
+
expect(compressed.length).toBeLessThan(content.length)
113
+
114
+
// Verify decompression
115
+
const decompressed = gunzipSync(compressed)
116
+
expect(decompressed.toString()).toBe(html)
117
+
})
118
+
119
+
test('should handle empty content', () => {
120
+
const content = Buffer.from('')
121
+
const compressed = compressFile(content)
122
+
const decompressed = gunzipSync(compressed)
123
+
expect(decompressed.toString()).toBe('')
124
+
})
125
+
126
+
test('should produce deterministic compression', () => {
127
+
const content = Buffer.from('Test content')
128
+
const compressed1 = compressFile(content)
129
+
const compressed2 = compressFile(content)
130
+
131
+
expect(compressed1.toString('base64')).toBe(compressed2.toString('base64'))
132
+
})
133
+
})
134
+
135
+
describe('processUploadedFiles', () => {
136
+
test('should process single root-level file', () => {
137
+
const files: UploadedFile[] = [
138
+
{
139
+
name: 'index.html',
140
+
content: Buffer.from('<html></html>'),
141
+
mimeType: 'text/html',
142
+
size: 13,
143
+
},
144
+
]
145
+
146
+
const result = processUploadedFiles(files)
147
+
148
+
expect(result.fileCount).toBe(1)
149
+
expect(result.directory.type).toBe('directory')
150
+
expect(result.directory.entries).toHaveLength(1)
151
+
expect(result.directory.entries[0].name).toBe('index.html')
152
+
153
+
const node = result.directory.entries[0].node
154
+
expect('blob' in node).toBe(true) // It's a file node
155
+
})
156
+
157
+
test('should process multiple root-level files', () => {
158
+
const files: UploadedFile[] = [
159
+
{
160
+
name: 'index.html',
161
+
content: Buffer.from('<html></html>'),
162
+
mimeType: 'text/html',
163
+
size: 13,
164
+
},
165
+
{
166
+
name: 'styles.css',
167
+
content: Buffer.from('body {}'),
168
+
mimeType: 'text/css',
169
+
size: 7,
170
+
},
171
+
{
172
+
name: 'script.js',
173
+
content: Buffer.from('console.log("hi")'),
174
+
mimeType: 'application/javascript',
175
+
size: 17,
176
+
},
177
+
]
178
+
179
+
const result = processUploadedFiles(files)
180
+
181
+
expect(result.fileCount).toBe(3)
182
+
expect(result.directory.entries).toHaveLength(3)
183
+
184
+
const names = result.directory.entries.map(e => e.name)
185
+
expect(names).toContain('index.html')
186
+
expect(names).toContain('styles.css')
187
+
expect(names).toContain('script.js')
188
+
})
189
+
190
+
test('should process files with subdirectories', () => {
191
+
const files: UploadedFile[] = [
192
+
{
193
+
name: 'dist/index.html',
194
+
content: Buffer.from('<html></html>'),
195
+
mimeType: 'text/html',
196
+
size: 13,
197
+
},
198
+
{
199
+
name: 'dist/css/styles.css',
200
+
content: Buffer.from('body {}'),
201
+
mimeType: 'text/css',
202
+
size: 7,
203
+
},
204
+
{
205
+
name: 'dist/js/app.js',
206
+
content: Buffer.from('console.log()'),
207
+
mimeType: 'application/javascript',
208
+
size: 13,
209
+
},
210
+
]
211
+
212
+
const result = processUploadedFiles(files)
213
+
214
+
expect(result.fileCount).toBe(3)
215
+
expect(result.directory.entries).toHaveLength(3) // index.html, css/, js/
216
+
217
+
// Check root has index.html (after base folder removal)
218
+
const indexEntry = result.directory.entries.find(e => e.name === 'index.html')
219
+
expect(indexEntry).toBeDefined()
220
+
221
+
// Check css directory exists
222
+
const cssDir = result.directory.entries.find(e => e.name === 'css')
223
+
expect(cssDir).toBeDefined()
224
+
expect('entries' in cssDir!.node).toBe(true)
225
+
226
+
if ('entries' in cssDir!.node) {
227
+
expect(cssDir!.node.entries).toHaveLength(1)
228
+
expect(cssDir!.node.entries[0].name).toBe('styles.css')
229
+
}
230
+
231
+
// Check js directory exists
232
+
const jsDir = result.directory.entries.find(e => e.name === 'js')
233
+
expect(jsDir).toBeDefined()
234
+
expect('entries' in jsDir!.node).toBe(true)
235
+
})
236
+
237
+
test('should handle deeply nested subdirectories', () => {
238
+
const files: UploadedFile[] = [
239
+
{
240
+
name: 'dist/deep/nested/folder/file.txt',
241
+
content: Buffer.from('content'),
242
+
mimeType: 'text/plain',
243
+
size: 7,
244
+
},
245
+
]
246
+
247
+
const result = processUploadedFiles(files)
248
+
249
+
expect(result.fileCount).toBe(1)
250
+
251
+
// Navigate through the directory structure (base folder removed)
252
+
const deepDir = result.directory.entries.find(e => e.name === 'deep')
253
+
expect(deepDir).toBeDefined()
254
+
expect('entries' in deepDir!.node).toBe(true)
255
+
256
+
if ('entries' in deepDir!.node) {
257
+
const nestedDir = deepDir!.node.entries.find(e => e.name === 'nested')
258
+
expect(nestedDir).toBeDefined()
259
+
260
+
if (nestedDir && 'entries' in nestedDir.node) {
261
+
const folderDir = nestedDir.node.entries.find(e => e.name === 'folder')
262
+
expect(folderDir).toBeDefined()
263
+
264
+
if (folderDir && 'entries' in folderDir.node) {
265
+
expect(folderDir.node.entries).toHaveLength(1)
266
+
expect(folderDir.node.entries[0].name).toBe('file.txt')
267
+
}
268
+
}
269
+
}
270
+
})
271
+
272
+
test('should remove base folder name from paths', () => {
273
+
const files: UploadedFile[] = [
274
+
{
275
+
name: 'dist/index.html',
276
+
content: Buffer.from('<html></html>'),
277
+
mimeType: 'text/html',
278
+
size: 13,
279
+
},
280
+
{
281
+
name: 'dist/css/styles.css',
282
+
content: Buffer.from('body {}'),
283
+
mimeType: 'text/css',
284
+
size: 7,
285
+
},
286
+
]
287
+
288
+
const result = processUploadedFiles(files)
289
+
290
+
// After removing 'dist/', we should have index.html and css/ at root
291
+
expect(result.directory.entries.find(e => e.name === 'index.html')).toBeDefined()
292
+
expect(result.directory.entries.find(e => e.name === 'css')).toBeDefined()
293
+
expect(result.directory.entries.find(e => e.name === 'dist')).toBeUndefined()
294
+
})
295
+
296
+
test('should handle empty file list', () => {
297
+
const files: UploadedFile[] = []
298
+
const result = processUploadedFiles(files)
299
+
300
+
expect(result.fileCount).toBe(0)
301
+
expect(result.directory.entries).toHaveLength(0)
302
+
})
303
+
304
+
test('should handle multiple files in same subdirectory', () => {
305
+
const files: UploadedFile[] = [
306
+
{
307
+
name: 'dist/assets/image1.png',
308
+
content: Buffer.from('png1'),
309
+
mimeType: 'image/png',
310
+
size: 4,
311
+
},
312
+
{
313
+
name: 'dist/assets/image2.png',
314
+
content: Buffer.from('png2'),
315
+
mimeType: 'image/png',
316
+
size: 4,
317
+
},
318
+
]
319
+
320
+
const result = processUploadedFiles(files)
321
+
322
+
expect(result.fileCount).toBe(2)
323
+
324
+
const assetsDir = result.directory.entries.find(e => e.name === 'assets')
325
+
expect(assetsDir).toBeDefined()
326
+
327
+
if ('entries' in assetsDir!.node) {
328
+
expect(assetsDir!.node.entries).toHaveLength(2)
329
+
const names = assetsDir!.node.entries.map(e => e.name)
330
+
expect(names).toContain('image1.png')
331
+
expect(names).toContain('image2.png')
332
+
}
333
+
})
334
+
})
335
+
336
+
describe('createManifest', () => {
337
+
test('should create valid manifest', () => {
338
+
const root: Directory = {
339
+
$type: 'place.wisp.fs#directory',
340
+
type: 'directory',
341
+
entries: [],
342
+
}
343
+
344
+
const manifest = createManifest('example.com', root, 0)
345
+
346
+
expect(manifest.$type).toBe('place.wisp.fs')
347
+
expect(manifest.site).toBe('example.com')
348
+
expect(manifest.root).toBe(root)
349
+
expect(manifest.fileCount).toBe(0)
350
+
expect(manifest.createdAt).toBeDefined()
351
+
352
+
// Verify it's a valid ISO date string
353
+
const date = new Date(manifest.createdAt)
354
+
expect(date.toISOString()).toBe(manifest.createdAt)
355
+
})
356
+
357
+
test('should create manifest with file count', () => {
358
+
const root: Directory = {
359
+
$type: 'place.wisp.fs#directory',
360
+
type: 'directory',
361
+
entries: [],
362
+
}
363
+
364
+
const manifest = createManifest('test-site', root, 42)
365
+
366
+
expect(manifest.fileCount).toBe(42)
367
+
expect(manifest.site).toBe('test-site')
368
+
})
369
+
370
+
test('should create manifest with populated directory', () => {
371
+
const mockBlob = createMockBlobRef('text/html', 100)
372
+
373
+
const root: Directory = {
374
+
$type: 'place.wisp.fs#directory',
375
+
type: 'directory',
376
+
entries: [
377
+
{
378
+
name: 'index.html',
379
+
node: {
380
+
$type: 'place.wisp.fs#file',
381
+
type: 'file',
382
+
blob: mockBlob,
383
+
},
384
+
},
385
+
],
386
+
}
387
+
388
+
const manifest = createManifest('populated-site', root, 1)
389
+
390
+
expect(manifest).toBeDefined()
391
+
expect(manifest.site).toBe('populated-site')
392
+
expect(manifest.root.entries).toHaveLength(1)
393
+
})
394
+
})
395
+
396
+
describe('updateFileBlobs', () => {
397
+
test('should update single file blob at root', () => {
398
+
const directory: Directory = {
399
+
$type: 'place.wisp.fs#directory',
400
+
type: 'directory',
401
+
entries: [
402
+
{
403
+
name: 'index.html',
404
+
node: {
405
+
$type: 'place.wisp.fs#file',
406
+
type: 'file',
407
+
blob: undefined as any,
408
+
},
409
+
},
410
+
],
411
+
}
412
+
413
+
const mockBlob = createMockBlobRef('text/html', 100)
414
+
const uploadResults: FileUploadResult[] = [
415
+
{
416
+
hash: TEST_CID_STRING,
417
+
blobRef: mockBlob,
418
+
mimeType: 'text/html',
419
+
},
420
+
]
421
+
422
+
const filePaths = ['index.html']
423
+
424
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
425
+
426
+
expect(updated.entries).toHaveLength(1)
427
+
const fileNode = updated.entries[0].node
428
+
429
+
if ('blob' in fileNode) {
430
+
expect(fileNode.blob).toBeDefined()
431
+
expect(fileNode.blob.mimeType).toBe('text/html')
432
+
expect(fileNode.blob.size).toBe(100)
433
+
} else {
434
+
throw new Error('Expected file node')
435
+
}
436
+
})
437
+
438
+
test('should update files in nested directories', () => {
439
+
const directory: Directory = {
440
+
$type: 'place.wisp.fs#directory',
441
+
type: 'directory',
442
+
entries: [
443
+
{
444
+
name: 'css',
445
+
node: {
446
+
$type: 'place.wisp.fs#directory',
447
+
type: 'directory',
448
+
entries: [
449
+
{
450
+
name: 'styles.css',
451
+
node: {
452
+
$type: 'place.wisp.fs#file',
453
+
type: 'file',
454
+
blob: undefined as any,
455
+
},
456
+
},
457
+
],
458
+
},
459
+
},
460
+
],
461
+
}
462
+
463
+
const mockBlob = createMockBlobRef('text/css', 50)
464
+
const uploadResults: FileUploadResult[] = [
465
+
{
466
+
hash: TEST_CID_STRING,
467
+
blobRef: mockBlob,
468
+
mimeType: 'text/css',
469
+
encoding: 'gzip',
470
+
},
471
+
]
472
+
473
+
const filePaths = ['css/styles.css']
474
+
475
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
476
+
477
+
const cssDir = updated.entries[0]
478
+
expect(cssDir.name).toBe('css')
479
+
480
+
if ('entries' in cssDir.node) {
481
+
const cssFile = cssDir.node.entries[0]
482
+
expect(cssFile.name).toBe('styles.css')
483
+
484
+
if ('blob' in cssFile.node) {
485
+
expect(cssFile.node.blob.mimeType).toBe('text/css')
486
+
if ('encoding' in cssFile.node) {
487
+
expect(cssFile.node.encoding).toBe('gzip')
488
+
}
489
+
} else {
490
+
throw new Error('Expected file node')
491
+
}
492
+
} else {
493
+
throw new Error('Expected directory node')
494
+
}
495
+
})
496
+
497
+
test('should handle normalized paths with base folder removed', () => {
498
+
const directory: Directory = {
499
+
$type: 'place.wisp.fs#directory',
500
+
type: 'directory',
501
+
entries: [
502
+
{
503
+
name: 'index.html',
504
+
node: {
505
+
$type: 'place.wisp.fs#file',
506
+
type: 'file',
507
+
blob: undefined as any,
508
+
},
509
+
},
510
+
],
511
+
}
512
+
513
+
const mockBlob = createMockBlobRef('text/html', 100)
514
+
const uploadResults: FileUploadResult[] = [
515
+
{
516
+
hash: TEST_CID_STRING,
517
+
blobRef: mockBlob,
518
+
},
519
+
]
520
+
521
+
// Path includes base folder that should be normalized
522
+
const filePaths = ['dist/index.html']
523
+
524
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
525
+
526
+
const fileNode = updated.entries[0].node
527
+
if ('blob' in fileNode) {
528
+
expect(fileNode.blob).toBeDefined()
529
+
} else {
530
+
throw new Error('Expected file node')
531
+
}
532
+
})
533
+
534
+
test('should preserve file metadata (encoding, mimeType, base64)', () => {
535
+
const directory: Directory = {
536
+
$type: 'place.wisp.fs#directory',
537
+
type: 'directory',
538
+
entries: [
539
+
{
540
+
name: 'data.json',
541
+
node: {
542
+
$type: 'place.wisp.fs#file',
543
+
type: 'file',
544
+
blob: undefined as any,
545
+
},
546
+
},
547
+
],
548
+
}
549
+
550
+
const mockBlob = createMockBlobRef('application/json', 200)
551
+
const uploadResults: FileUploadResult[] = [
552
+
{
553
+
hash: TEST_CID_STRING,
554
+
blobRef: mockBlob,
555
+
mimeType: 'application/json',
556
+
encoding: 'gzip',
557
+
base64: true,
558
+
},
559
+
]
560
+
561
+
const filePaths = ['data.json']
562
+
563
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
564
+
565
+
const fileNode = updated.entries[0].node
566
+
if ('blob' in fileNode && 'mimeType' in fileNode && 'encoding' in fileNode && 'base64' in fileNode) {
567
+
expect(fileNode.mimeType).toBe('application/json')
568
+
expect(fileNode.encoding).toBe('gzip')
569
+
expect(fileNode.base64).toBe(true)
570
+
} else {
571
+
throw new Error('Expected file node with metadata')
572
+
}
573
+
})
574
+
575
+
test('should handle multiple files at different directory levels', () => {
576
+
const directory: Directory = {
577
+
$type: 'place.wisp.fs#directory',
578
+
type: 'directory',
579
+
entries: [
580
+
{
581
+
name: 'index.html',
582
+
node: {
583
+
$type: 'place.wisp.fs#file',
584
+
type: 'file',
585
+
blob: undefined as any,
586
+
},
587
+
},
588
+
{
589
+
name: 'assets',
590
+
node: {
591
+
$type: 'place.wisp.fs#directory',
592
+
type: 'directory',
593
+
entries: [
594
+
{
595
+
name: 'logo.svg',
596
+
node: {
597
+
$type: 'place.wisp.fs#file',
598
+
type: 'file',
599
+
blob: undefined as any,
600
+
},
601
+
},
602
+
],
603
+
},
604
+
},
605
+
],
606
+
}
607
+
608
+
const htmlBlob = createMockBlobRef('text/html', 100)
609
+
const svgBlob = createMockBlobRef('image/svg+xml', 500)
610
+
611
+
const uploadResults: FileUploadResult[] = [
612
+
{
613
+
hash: TEST_CID_STRING,
614
+
blobRef: htmlBlob,
615
+
},
616
+
{
617
+
hash: TEST_CID_STRING,
618
+
blobRef: svgBlob,
619
+
},
620
+
]
621
+
622
+
const filePaths = ['index.html', 'assets/logo.svg']
623
+
624
+
const updated = updateFileBlobs(directory, uploadResults, filePaths)
625
+
626
+
// Check root file
627
+
const indexNode = updated.entries[0].node
628
+
if ('blob' in indexNode) {
629
+
expect(indexNode.blob.mimeType).toBe('text/html')
630
+
}
631
+
632
+
// Check nested file
633
+
const assetsDir = updated.entries[1]
634
+
if ('entries' in assetsDir.node) {
635
+
const logoNode = assetsDir.node.entries[0].node
636
+
if ('blob' in logoNode) {
637
+
expect(logoNode.blob.mimeType).toBe('image/svg+xml')
638
+
}
639
+
}
640
+
})
641
+
})
642
+
643
+
describe('computeCID', () => {
644
+
test('should compute CID for gzipped+base64 encoded content', () => {
645
+
// This simulates the actual flow: gzip -> base64 -> compute CID
646
+
const originalContent = Buffer.from('Hello, World!')
647
+
const gzipped = compressFile(originalContent)
648
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
649
+
650
+
const cid = computeCID(base64Content)
651
+
652
+
// CID should be a valid CIDv1 string starting with 'bafkrei'
653
+
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
654
+
expect(cid.length).toBeGreaterThan(10)
655
+
})
656
+
657
+
test('should compute deterministic CIDs for identical content', () => {
658
+
const content = Buffer.from('Test content for CID calculation')
659
+
const gzipped = compressFile(content)
660
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
661
+
662
+
const cid1 = computeCID(base64Content)
663
+
const cid2 = computeCID(base64Content)
664
+
665
+
expect(cid1).toBe(cid2)
666
+
})
667
+
668
+
test('should compute different CIDs for different content', () => {
669
+
const content1 = Buffer.from('Content A')
670
+
const content2 = Buffer.from('Content B')
671
+
672
+
const gzipped1 = compressFile(content1)
673
+
const gzipped2 = compressFile(content2)
674
+
675
+
const base64Content1 = Buffer.from(gzipped1.toString('base64'), 'binary')
676
+
const base64Content2 = Buffer.from(gzipped2.toString('base64'), 'binary')
677
+
678
+
const cid1 = computeCID(base64Content1)
679
+
const cid2 = computeCID(base64Content2)
680
+
681
+
expect(cid1).not.toBe(cid2)
682
+
})
683
+
684
+
test('should handle empty content', () => {
685
+
const emptyContent = Buffer.from('')
686
+
const gzipped = compressFile(emptyContent)
687
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
688
+
689
+
const cid = computeCID(base64Content)
690
+
691
+
expect(cid).toMatch(/^bafkrei[a-z0-9]+$/)
692
+
})
693
+
694
+
test('should compute same CID as PDS for base64-encoded content', () => {
695
+
// Test that binary encoding produces correct bytes for CID calculation
696
+
const testContent = Buffer.from('<!DOCTYPE html><html><body>Hello</body></html>')
697
+
const gzipped = compressFile(testContent)
698
+
const base64Content = Buffer.from(gzipped.toString('base64'), 'binary')
699
+
700
+
// Compute CID twice to ensure consistency
701
+
const cid1 = computeCID(base64Content)
702
+
const cid2 = computeCID(base64Content)
703
+
704
+
expect(cid1).toBe(cid2)
705
+
expect(cid1).toMatch(/^bafkrei/)
706
+
})
707
+
708
+
test('should use binary encoding for base64 strings', () => {
709
+
// This test verifies we're using the correct encoding method
710
+
// For base64 strings, 'binary' encoding ensures each character becomes exactly one byte
711
+
const content = Buffer.from('Test content')
712
+
const gzipped = compressFile(content)
713
+
const base64String = gzipped.toString('base64')
714
+
715
+
// Using binary encoding (what we use in production)
716
+
const base64Content = Buffer.from(base64String, 'binary')
717
+
718
+
// Verify the length matches the base64 string length
719
+
expect(base64Content.length).toBe(base64String.length)
720
+
721
+
// Verify CID is computed correctly
722
+
const cid = computeCID(base64Content)
723
+
expect(cid).toMatch(/^bafkrei/)
724
+
})
725
+
})
726
+
727
+
describe('extractBlobMap', () => {
728
+
test('should extract blob map from flat directory structure', () => {
729
+
const mockCid = CID.parse(TEST_CID_STRING)
730
+
const mockBlob = new BlobRef(mockCid, 'text/html', 100)
731
+
732
+
const directory: Directory = {
733
+
$type: 'place.wisp.fs#directory',
734
+
type: 'directory',
735
+
entries: [
736
+
{
737
+
name: 'index.html',
738
+
node: {
739
+
$type: 'place.wisp.fs#file',
740
+
type: 'file',
741
+
blob: mockBlob,
742
+
},
743
+
},
744
+
],
745
+
}
746
+
747
+
const blobMap = extractBlobMap(directory)
748
+
749
+
expect(blobMap.size).toBe(1)
750
+
expect(blobMap.has('index.html')).toBe(true)
751
+
752
+
const entry = blobMap.get('index.html')
753
+
expect(entry?.cid).toBe(TEST_CID_STRING)
754
+
expect(entry?.blobRef).toBe(mockBlob)
755
+
})
756
+
757
+
test('should extract blob map from nested directory structure', () => {
758
+
const mockCid1 = CID.parse(TEST_CID_STRING)
759
+
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
760
+
761
+
const mockBlob1 = new BlobRef(mockCid1, 'text/html', 100)
762
+
const mockBlob2 = new BlobRef(mockCid2, 'text/css', 50)
763
+
764
+
const directory: Directory = {
765
+
$type: 'place.wisp.fs#directory',
766
+
type: 'directory',
767
+
entries: [
768
+
{
769
+
name: 'index.html',
770
+
node: {
771
+
$type: 'place.wisp.fs#file',
772
+
type: 'file',
773
+
blob: mockBlob1,
774
+
},
775
+
},
776
+
{
777
+
name: 'assets',
778
+
node: {
779
+
$type: 'place.wisp.fs#directory',
780
+
type: 'directory',
781
+
entries: [
782
+
{
783
+
name: 'styles.css',
784
+
node: {
785
+
$type: 'place.wisp.fs#file',
786
+
type: 'file',
787
+
blob: mockBlob2,
788
+
},
789
+
},
790
+
],
791
+
},
792
+
},
793
+
],
794
+
}
795
+
796
+
const blobMap = extractBlobMap(directory)
797
+
798
+
expect(blobMap.size).toBe(2)
799
+
expect(blobMap.has('index.html')).toBe(true)
800
+
expect(blobMap.has('assets/styles.css')).toBe(true)
801
+
802
+
expect(blobMap.get('index.html')?.cid).toBe(TEST_CID_STRING)
803
+
expect(blobMap.get('assets/styles.css')?.cid).toBe('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
804
+
})
805
+
806
+
test('should handle deeply nested directory structures', () => {
807
+
const mockCid = CID.parse(TEST_CID_STRING)
808
+
const mockBlob = new BlobRef(mockCid, 'text/javascript', 200)
809
+
810
+
const directory: Directory = {
811
+
$type: 'place.wisp.fs#directory',
812
+
type: 'directory',
813
+
entries: [
814
+
{
815
+
name: 'src',
816
+
node: {
817
+
$type: 'place.wisp.fs#directory',
818
+
type: 'directory',
819
+
entries: [
820
+
{
821
+
name: 'lib',
822
+
node: {
823
+
$type: 'place.wisp.fs#directory',
824
+
type: 'directory',
825
+
entries: [
826
+
{
827
+
name: 'utils.js',
828
+
node: {
829
+
$type: 'place.wisp.fs#file',
830
+
type: 'file',
831
+
blob: mockBlob,
832
+
},
833
+
},
834
+
],
835
+
},
836
+
},
837
+
],
838
+
},
839
+
},
840
+
],
841
+
}
842
+
843
+
const blobMap = extractBlobMap(directory)
844
+
845
+
expect(blobMap.size).toBe(1)
846
+
expect(blobMap.has('src/lib/utils.js')).toBe(true)
847
+
expect(blobMap.get('src/lib/utils.js')?.cid).toBe(TEST_CID_STRING)
848
+
})
849
+
850
+
test('should handle empty directory', () => {
851
+
const directory: Directory = {
852
+
$type: 'place.wisp.fs#directory',
853
+
type: 'directory',
854
+
entries: [],
855
+
}
856
+
857
+
const blobMap = extractBlobMap(directory)
858
+
859
+
expect(blobMap.size).toBe(0)
860
+
})
861
+
862
+
test('should correctly extract CID from BlobRef instances (not plain objects)', () => {
863
+
// This test verifies the fix: AT Protocol SDK returns BlobRef instances,
864
+
// not plain objects with $type and $link properties
865
+
const mockCid = CID.parse(TEST_CID_STRING)
866
+
const mockBlob = new BlobRef(mockCid, 'application/octet-stream', 500)
867
+
868
+
const directory: Directory = {
869
+
$type: 'place.wisp.fs#directory',
870
+
type: 'directory',
871
+
entries: [
872
+
{
873
+
name: 'test.bin',
874
+
node: {
875
+
$type: 'place.wisp.fs#file',
876
+
type: 'file',
877
+
blob: mockBlob,
878
+
},
879
+
},
880
+
],
881
+
}
882
+
883
+
const blobMap = extractBlobMap(directory)
884
+
885
+
// The fix: we call .toString() on the CID instance instead of accessing $link
886
+
expect(blobMap.get('test.bin')?.cid).toBe(TEST_CID_STRING)
887
+
expect(blobMap.get('test.bin')?.blobRef.ref.toString()).toBe(TEST_CID_STRING)
888
+
})
889
+
890
+
test('should handle multiple files in same directory', () => {
891
+
const mockCid1 = CID.parse(TEST_CID_STRING)
892
+
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
893
+
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
894
+
895
+
const mockBlob1 = new BlobRef(mockCid1, 'image/png', 1000)
896
+
const mockBlob2 = new BlobRef(mockCid2, 'image/png', 2000)
897
+
const mockBlob3 = new BlobRef(mockCid3, 'image/png', 3000)
898
+
899
+
const directory: Directory = {
900
+
$type: 'place.wisp.fs#directory',
901
+
type: 'directory',
902
+
entries: [
903
+
{
904
+
name: 'images',
905
+
node: {
906
+
$type: 'place.wisp.fs#directory',
907
+
type: 'directory',
908
+
entries: [
909
+
{
910
+
name: 'logo.png',
911
+
node: {
912
+
$type: 'place.wisp.fs#file',
913
+
type: 'file',
914
+
blob: mockBlob1,
915
+
},
916
+
},
917
+
{
918
+
name: 'banner.png',
919
+
node: {
920
+
$type: 'place.wisp.fs#file',
921
+
type: 'file',
922
+
blob: mockBlob2,
923
+
},
924
+
},
925
+
{
926
+
name: 'icon.png',
927
+
node: {
928
+
$type: 'place.wisp.fs#file',
929
+
type: 'file',
930
+
blob: mockBlob3,
931
+
},
932
+
},
933
+
],
934
+
},
935
+
},
936
+
],
937
+
}
938
+
939
+
const blobMap = extractBlobMap(directory)
940
+
941
+
expect(blobMap.size).toBe(3)
942
+
expect(blobMap.has('images/logo.png')).toBe(true)
943
+
expect(blobMap.has('images/banner.png')).toBe(true)
944
+
expect(blobMap.has('images/icon.png')).toBe(true)
945
+
})
946
+
947
+
test('should handle mixed directory and file structure', () => {
948
+
const mockCid1 = CID.parse(TEST_CID_STRING)
949
+
const mockCid2 = CID.parse('bafkreiabaduc3573q6snt2xgxzpglwuaojkzflocncrh2vj5j3jykdpqhi')
950
+
const mockCid3 = CID.parse('bafkreieb3ixgchss44kw7xiavnkns47emdfsqbhcdfluo3p6n3o53fl3vq')
951
+
952
+
const directory: Directory = {
953
+
$type: 'place.wisp.fs#directory',
954
+
type: 'directory',
955
+
entries: [
956
+
{
957
+
name: 'index.html',
958
+
node: {
959
+
$type: 'place.wisp.fs#file',
960
+
type: 'file',
961
+
blob: new BlobRef(mockCid1, 'text/html', 100),
962
+
},
963
+
},
964
+
{
965
+
name: 'assets',
966
+
node: {
967
+
$type: 'place.wisp.fs#directory',
968
+
type: 'directory',
969
+
entries: [
970
+
{
971
+
name: 'styles.css',
972
+
node: {
973
+
$type: 'place.wisp.fs#file',
974
+
type: 'file',
975
+
blob: new BlobRef(mockCid2, 'text/css', 50),
976
+
},
977
+
},
978
+
],
979
+
},
980
+
},
981
+
{
982
+
name: 'README.md',
983
+
node: {
984
+
$type: 'place.wisp.fs#file',
985
+
type: 'file',
986
+
blob: new BlobRef(mockCid3, 'text/markdown', 200),
987
+
},
988
+
},
989
+
],
990
+
}
991
+
992
+
const blobMap = extractBlobMap(directory)
993
+
994
+
expect(blobMap.size).toBe(3)
995
+
expect(blobMap.has('index.html')).toBe(true)
996
+
expect(blobMap.has('assets/styles.css')).toBe(true)
997
+
expect(blobMap.has('README.md')).toBe(true)
998
+
})
999
+
})
+63
-2
src/lib/wisp-utils.ts
+63
-2
src/lib/wisp-utils.ts
···
2
2
import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs";
3
3
import { validateRecord } from "../lexicons/types/place/wisp/fs";
4
4
import { gzipSync } from 'zlib';
5
+
import { CID } from 'multiformats/cid';
6
+
import { sha256 } from 'multiformats/hashes/sha2';
7
+
import * as raw from 'multiformats/codecs/raw';
8
+
import { createHash } from 'crypto';
9
+
import * as mf from 'multiformats';
5
10
6
11
export interface UploadedFile {
7
12
name: string;
···
48
53
}
49
54
50
55
/**
51
-
* Compress a file using gzip
56
+
* Compress a file using gzip with deterministic output
52
57
*/
53
58
export function compressFile(content: Buffer): Buffer {
54
-
return gzipSync(content, { level: 9 });
59
+
return gzipSync(content, {
60
+
level: 9
61
+
});
55
62
}
56
63
57
64
/**
···
65
72
const directoryMap = new Map<string, UploadedFile[]>();
66
73
67
74
for (const file of files) {
75
+
// Skip undefined/null files (defensive)
76
+
if (!file || !file.name) {
77
+
console.error('Skipping undefined or invalid file in processUploadedFiles');
78
+
continue;
79
+
}
80
+
68
81
// Remove any base folder name from the path
69
82
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
70
83
const parts = normalizedPath.split('/');
···
239
252
240
253
return result;
241
254
}
255
+
256
+
/**
257
+
* Compute CID (Content Identifier) for blob content
258
+
* Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256
259
+
* Based on @atproto/common/src/ipld.ts sha256RawToCid implementation
260
+
*/
261
+
export function computeCID(content: Buffer): string {
262
+
// Use node crypto to compute sha256 hash (same as AT Protocol)
263
+
const hash = createHash('sha256').update(content).digest();
264
+
// Create digest object from hash bytes
265
+
const digest = mf.digest.create(sha256.code, hash);
266
+
// Create CIDv1 with raw codec
267
+
const cid = CID.createV1(raw.code, digest);
268
+
return cid.toString();
269
+
}
270
+
271
+
/**
272
+
* Extract blob information from a directory tree
273
+
* Returns a map of file paths to their blob refs and CIDs
274
+
*/
275
+
export function extractBlobMap(
276
+
directory: Directory,
277
+
currentPath: string = ''
278
+
): Map<string, { blobRef: BlobRef; cid: string }> {
279
+
const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>();
280
+
281
+
for (const entry of directory.entries) {
282
+
const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
283
+
284
+
if ('type' in entry.node && entry.node.type === 'file') {
285
+
const fileNode = entry.node as File;
286
+
// AT Protocol SDK returns BlobRef class instances, not plain objects
287
+
// The ref is a CID instance that can be converted to string
288
+
if (fileNode.blob && fileNode.blob.ref) {
289
+
const cidString = fileNode.blob.ref.toString();
290
+
blobMap.set(fullPath, {
291
+
blobRef: fileNode.blob,
292
+
cid: cidString
293
+
});
294
+
}
295
+
} else if ('type' in entry.node && entry.node.type === 'directory') {
296
+
const subMap = extractBlobMap(entry.node as Directory, fullPath);
297
+
subMap.forEach((value, key) => blobMap.set(key, value));
298
+
}
299
+
}
300
+
301
+
return blobMap;
302
+
}
+106
-9
src/routes/admin.ts
+106
-9
src/routes/admin.ts
···
4
4
import { logCollector, errorTracker, metricsCollector } from '../lib/observability'
5
5
import { db } from '../lib/db'
6
6
7
-
export const adminRoutes = () =>
7
+
export const adminRoutes = (cookieSecret: string) =>
8
8
new Elysia({ prefix: '/api/admin' })
9
9
// Login
10
10
.post(
···
35
35
body: t.Object({
36
36
username: t.String(),
37
37
password: t.String()
38
+
}),
39
+
cookie: t.Cookie({
40
+
admin_session: t.Optional(t.String())
41
+
}, {
42
+
secrets: cookieSecret,
43
+
sign: ['admin_session']
38
44
})
39
45
}
40
46
)
···
47
53
}
48
54
cookie.admin_session.remove()
49
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
+
})
50
63
})
51
64
52
65
// Check auth status
···
65
78
authenticated: true,
66
79
username: session.username
67
80
}
81
+
}, {
82
+
cookie: t.Cookie({
83
+
admin_session: t.Optional(t.String())
84
+
}, {
85
+
secrets: cookieSecret,
86
+
sign: ['admin_session']
87
+
})
68
88
})
69
89
70
90
// Get logs (protected)
···
86
106
// Get logs from hosting service
87
107
let hostingLogs: any[] = []
88
108
try {
89
-
const hostingPort = process.env.HOSTING_PORT || '3001'
109
+
const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}`
90
110
const params = new URLSearchParams()
91
111
if (query.level) params.append('level', query.level as string)
92
112
if (query.service) params.append('service', query.service as string)
···
94
114
if (query.eventType) params.append('eventType', query.eventType as string)
95
115
params.append('limit', String(filter.limit || 100))
96
116
97
-
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/logs?${params}`)
117
+
const response = await fetch(`${hostingServiceUrl}/__internal__/observability/logs?${params}`)
98
118
if (response.ok) {
99
119
const data = await response.json()
100
120
hostingLogs = data.logs
···
109
129
)
110
130
111
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
+
})
112
139
})
113
140
114
141
// Get errors (protected)
···
127
154
// Get errors from hosting service
128
155
let hostingErrors: any[] = []
129
156
try {
130
-
const hostingPort = process.env.HOSTING_PORT || '3001'
157
+
const hostingServiceUrl = process.env.HOSTING_SERVICE_URL || `http://localhost:${process.env.HOSTING_PORT || '3001'}`
131
158
const params = new URLSearchParams()
132
159
if (query.service) params.append('service', query.service as string)
133
160
params.append('limit', String(filter.limit || 100))
134
161
135
-
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/errors?${params}`)
162
+
const response = await fetch(`${hostingServiceUrl}/__internal__/observability/errors?${params}`)
136
163
if (response.ok) {
137
164
const data = await response.json()
138
165
hostingErrors = data.errors
···
147
174
)
148
175
149
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
+
})
150
184
})
151
185
152
186
// Get metrics (protected)
···
173
207
}
174
208
175
209
try {
176
-
const hostingPort = process.env.HOSTING_PORT || '3001'
177
-
const response = await fetch(`http://localhost:${hostingPort}/__internal__/observability/metrics?timeWindow=${timeWindow}`)
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}`)
178
212
if (response.ok) {
179
213
const data = await response.json()
180
214
hostingServiceStats = data.stats
···
189
223
hostingService: hostingServiceStats,
190
224
timeWindow
191
225
}
226
+
}, {
227
+
cookie: t.Cookie({
228
+
admin_session: t.Optional(t.String())
229
+
}, {
230
+
secrets: cookieSecret,
231
+
sign: ['admin_session']
232
+
})
192
233
})
193
234
194
235
// Get database stats (protected)
···
204
245
205
246
// Get recent sites (including those without domains)
206
247
const recentSites = await db`
207
-
SELECT
248
+
SELECT
208
249
s.did,
209
250
s.rkey,
210
251
s.display_name,
···
235
276
message: error instanceof Error ? error.message : String(error)
236
277
}
237
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
+
})
238
321
})
239
322
240
323
// Get sites listing (protected)
···
247
330
248
331
try {
249
332
const sites = await db`
250
-
SELECT
333
+
SELECT
251
334
s.did,
252
335
s.rkey,
253
336
s.display_name,
···
282
365
message: error instanceof Error ? error.message : String(error)
283
366
}
284
367
}
368
+
}, {
369
+
cookie: t.Cookie({
370
+
admin_session: t.Optional(t.String())
371
+
}, {
372
+
secrets: cookieSecret,
373
+
sign: ['admin_session']
374
+
})
285
375
})
286
376
287
377
// Get system health (protected)
···
301
391
},
302
392
timestamp: new Date().toISOString()
303
393
}
394
+
}, {
395
+
cookie: t.Cookie({
396
+
admin_session: t.Optional(t.String())
397
+
}, {
398
+
secrets: cookieSecret,
399
+
sign: ['admin_session']
400
+
})
304
401
})
305
402
+20
-6
src/routes/auth.ts
+20
-6
src/routes/auth.ts
···
1
-
import { Elysia } from 'elysia'
1
+
import { Elysia, t } from 'elysia'
2
2
import { NodeOAuthClient } from '@atproto/oauth-client-node'
3
-
import { getSitesByDid, getDomainByDid } from '../lib/db'
3
+
import { getSitesByDid, getDomainByDid, getCookieSecret } from '../lib/db'
4
4
import { syncSitesFromPDS } from '../lib/sync-sites'
5
5
import { authenticateRequest } from '../lib/wisp-auth'
6
6
import { logger } from '../lib/observability'
7
7
8
-
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
8
+
export const authRoutes = (client: NodeOAuthClient, cookieSecret: string) => new Elysia({
9
+
cookie: {
10
+
secrets: cookieSecret,
11
+
sign: ['did']
12
+
}
13
+
})
9
14
.post('/api/auth/signin', async (c) => {
10
15
let handle = 'unknown'
11
16
try {
···
32
37
33
38
if (!session) {
34
39
logger.error('[Auth] OAuth callback failed: no session returned')
40
+
c.cookie.did.remove()
35
41
return c.redirect('/?error=auth_failed')
36
42
}
37
43
38
44
const cookieSession = c.cookie
39
-
cookieSession.did.value = session.did
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
+
})
40
52
41
53
// Sync sites from PDS to database cache
42
54
logger.debug('[Auth] Syncing sites from PDS for', session.did)
···
64
76
} catch (err) {
65
77
// This catches state validation failures and other OAuth errors
66
78
logger.error('[Auth] OAuth callback error', err)
79
+
c.cookie.did.remove()
67
80
return c.redirect('/?error=auth_failed')
68
81
}
69
82
})
···
73
86
const did = cookieSession.did?.value
74
87
75
88
// Clear the session cookie
76
-
cookieSession.did.value = ''
77
-
cookieSession.did.maxAge = 0
89
+
cookieSession.did.remove()
78
90
79
91
// If we have a DID, try to revoke the OAuth session
80
92
if (did && typeof did === 'string') {
···
98
110
const auth = await authenticateRequest(client, c.cookie)
99
111
100
112
if (!auth) {
113
+
c.cookie.did.remove()
101
114
return { authenticated: false }
102
115
}
103
116
···
107
120
}
108
121
} catch (err) {
109
122
logger.error('[Auth] Status check error', err)
123
+
c.cookie.did.remove()
110
124
return { authenticated: false }
111
125
}
112
126
})
+66
-15
src/routes/domain.ts
+66
-15
src/routes/domain.ts
···
10
10
isValidHandle,
11
11
toDomain,
12
12
updateDomain,
13
+
countWispDomains,
14
+
deleteWispDomain,
13
15
getCustomDomainInfo,
14
16
getCustomDomainById,
15
17
claimCustomDomain,
···
22
24
import { verifyCustomDomain } from '../lib/dns-verify'
23
25
import { logger } from '../lib/logger'
24
26
25
-
export const domainRoutes = (client: NodeOAuthClient) =>
26
-
new Elysia({ prefix: '/api/domain' })
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
+
})
27
35
// Public endpoints (no auth required)
28
36
.get('/check', async ({ query }) => {
29
37
try {
···
84
92
try {
85
93
const { handle } = body as { handle?: string };
86
94
const normalizedHandle = (handle || "").trim().toLowerCase();
87
-
95
+
88
96
if (!isValidHandle(normalizedHandle)) {
89
97
throw new Error("Invalid handle");
90
98
}
91
99
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
-
100
+
// Check if user already has 3 domains (handled in claimDomain)
98
101
// claim in DB
99
102
let domain: string;
100
103
try {
101
104
domain = await claimDomain(auth.did, normalizedHandle);
102
105
} catch (err) {
103
-
throw new Error("Handle taken");
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");
104
111
}
105
112
106
-
// write place.wisp.domain record rkey = self
113
+
// write place.wisp.domain record with unique rkey
107
114
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init));
115
+
const rkey = normalizedHandle; // Use handle as rkey for uniqueness
108
116
await agent.com.atproto.repo.putRecord({
109
117
repo: auth.did,
110
118
collection: "place.wisp.domain",
111
-
rkey: "self",
119
+
rkey,
112
120
record: {
113
121
$type: "place.wisp.domain",
114
122
domain,
···
309
317
})
310
318
.post('/wisp/map-site', async ({ body, auth }) => {
311
319
try {
312
-
const { siteRkey } = body as { siteRkey: string | null };
320
+
const { domain, siteRkey } = body as { domain: string; siteRkey: string | null };
321
+
322
+
if (!domain) {
323
+
throw new Error('Domain parameter required');
324
+
}
313
325
314
326
// Update wisp.place domain to point to this site
315
-
await updateWispDomainSite(auth.did, siteRkey);
327
+
await updateWispDomainSite(domain, siteRkey);
316
328
317
329
return { success: true };
318
330
} catch (err) {
···
320
332
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
321
333
}
322
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
+
})
323
374
.post('/custom/:id/map-site', async ({ params, body, auth }) => {
324
375
try {
325
376
const { id } = params;
···
336
387
}
337
388
338
389
// Update custom domain to point to this site
339
-
await updateCustomDomainRkey(id, siteRkey || 'self');
390
+
await updateCustomDomainRkey(id, siteRkey);
340
391
341
392
return { success: true };
342
393
} catch (err) {
+8
-2
src/routes/site.ts
+8
-2
src/routes/site.ts
···
5
5
import { deleteSite } from '../lib/db'
6
6
import { logger } from '../lib/logger'
7
7
8
-
export const siteRoutes = (client: NodeOAuthClient) =>
9
-
new Elysia({ prefix: '/api/site' })
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
+
})
10
16
.derive(async ({ cookie }) => {
11
17
const auth = await requireAuth(client, cookie)
12
18
return { auth }
+30
-10
src/routes/user.ts
+30
-10
src/routes/user.ts
···
1
-
import { Elysia } from 'elysia'
1
+
import { Elysia, t } from 'elysia'
2
2
import { requireAuth } from '../lib/wisp-auth'
3
3
import { NodeOAuthClient } from '@atproto/oauth-client-node'
4
4
import { Agent } from '@atproto/api'
5
-
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db'
5
+
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite, getAllWispDomains } from '../lib/db'
6
6
import { syncSitesFromPDS } from '../lib/sync-sites'
7
7
import { logger } from '../lib/logger'
8
8
9
-
export const userRoutes = (client: NodeOAuthClient) =>
10
-
new Elysia({ prefix: '/api/user' })
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
+
})
11
17
.derive(async ({ cookie }) => {
12
18
const auth = await requireAuth(client, cookie)
13
19
return { auth }
···
65
71
})
66
72
.get('/domains', async ({ auth }) => {
67
73
try {
68
-
// Get wisp.place subdomain with mapping
69
-
const wispDomainInfo = await getWispDomainInfo(auth.did)
74
+
// Get all wisp.place subdomains with mappings (up to 3)
75
+
const wispDomains = await getAllWispDomains(auth.did)
70
76
71
77
// Get custom domains
72
78
const customDomains = await getCustomDomainsByDid(auth.did)
73
79
74
80
return {
75
-
wispDomain: wispDomainInfo ? {
76
-
domain: wispDomainInfo.domain,
77
-
rkey: wispDomainInfo.rkey || null
78
-
} : null,
81
+
wispDomains: wispDomains.map(d => ({
82
+
domain: d.domain,
83
+
rkey: d.rkey || null
84
+
})),
79
85
customDomains
80
86
}
81
87
} catch (err) {
···
98
104
throw new Error('Failed to sync sites')
99
105
}
100
106
})
107
+
.get('/site/:rkey/domains', async ({ auth, params }) => {
108
+
try {
109
+
const { rkey } = params
110
+
const domains = await getDomainsBySite(auth.did, rkey)
111
+
112
+
return {
113
+
rkey,
114
+
domains
115
+
}
116
+
} catch (err) {
117
+
logger.error('[User] Site domains error', err)
118
+
throw new Error('Failed to get domains for site')
119
+
}
120
+
})
+138
-12
src/routes/wisp.ts
+138
-12
src/routes/wisp.ts
···
9
9
createManifest,
10
10
updateFileBlobs,
11
11
shouldCompressFile,
12
-
compressFile
12
+
compressFile,
13
+
computeCID,
14
+
extractBlobMap
13
15
} from '../lib/wisp-utils'
14
16
import { upsertSite } from '../lib/db'
15
17
import { logger } from '../lib/observability'
···
35
37
return true;
36
38
}
37
39
38
-
export const wispRoutes = (client: NodeOAuthClient) =>
39
-
new Elysia({ prefix: '/wisp' })
40
+
export const wispRoutes = (client: NodeOAuthClient, cookieSecret: string) =>
41
+
new Elysia({
42
+
prefix: '/wisp',
43
+
cookie: {
44
+
secrets: cookieSecret,
45
+
sign: ['did']
46
+
}
47
+
})
40
48
.derive(async ({ cookie }) => {
41
49
const auth = await requireAuth(client, cookie)
42
50
return { auth }
···
48
56
siteName: string;
49
57
files: File | File[]
50
58
};
59
+
60
+
console.log('=== UPLOAD FILES START ===');
61
+
console.log('Site name:', siteName);
62
+
console.log('Files received:', Array.isArray(files) ? files.length : 'single file');
51
63
52
64
try {
53
65
if (!siteName) {
···
106
118
107
119
// Create agent with OAuth session
108
120
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
121
+
console.log('Agent created for DID:', auth.did);
122
+
123
+
// Try to fetch existing record to enable incremental updates
124
+
let existingBlobMap = new Map<string, { blobRef: any; cid: string }>();
125
+
console.log('Attempting to fetch existing record...');
126
+
try {
127
+
const rkey = siteName;
128
+
const existingRecord = await agent.com.atproto.repo.getRecord({
129
+
repo: auth.did,
130
+
collection: 'place.wisp.fs',
131
+
rkey: rkey
132
+
});
133
+
console.log('Existing record found!');
134
+
135
+
if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) {
136
+
const manifest = existingRecord.data.value as any;
137
+
existingBlobMap = extractBlobMap(manifest.root);
138
+
console.log(`Found existing manifest with ${existingBlobMap.size} files for incremental update`);
139
+
logger.info(`Found existing manifest with ${existingBlobMap.size} files for incremental update`);
140
+
}
141
+
} catch (error: any) {
142
+
console.log('No existing record found or error:', error?.message || error);
143
+
// Record doesn't exist yet, this is a new site
144
+
if (error?.status !== 400 && error?.error !== 'RecordNotFound') {
145
+
logger.warn('Failed to fetch existing record, proceeding with full upload', error);
146
+
}
147
+
}
109
148
110
149
// Convert File objects to UploadedFile format
111
150
// Elysia gives us File objects directly, handle both single file and array
···
113
152
const uploadedFiles: UploadedFile[] = [];
114
153
const skippedFiles: Array<{ name: string; reason: string }> = [];
115
154
116
-
155
+
console.log('Processing files, count:', fileArray.length);
117
156
118
157
for (let i = 0; i < fileArray.length; i++) {
119
158
const file = fileArray[i];
159
+
console.log(`Processing file ${i + 1}/${fileArray.length}:`, file.name, file.size, 'bytes');
120
160
121
161
// Skip files that are too large (limit to 100MB per file)
122
162
const maxSize = MAX_FILE_SIZE; // 100MB
···
135
175
// Compress and base64 encode ALL files
136
176
const compressedContent = compressFile(originalContent);
137
177
// Base64 encode the gzipped content to prevent PDS content sniffing
138
-
const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8');
178
+
// Convert base64 string to bytes using binary encoding (each char becomes exactly one byte)
179
+
// This is what PDS receives and computes CID on
180
+
const base64Content = Buffer.from(compressedContent.toString('base64'), 'binary');
139
181
const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1);
182
+
console.log(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
140
183
logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
141
184
142
185
uploadedFiles.push({
143
186
name: file.name,
144
-
content: base64Content,
187
+
content: base64Content, // This is the gzipped+base64 content that will be uploaded and CID-computed
145
188
mimeType: originalMimeType,
146
189
size: base64Content.length,
147
190
compressed: true,
···
206
249
}
207
250
208
251
// Process files into directory structure
209
-
const { directory, fileCount } = processUploadedFiles(uploadedFiles);
252
+
console.log('Processing uploaded files into directory structure...');
253
+
console.log('uploadedFiles array length:', uploadedFiles.length);
254
+
console.log('uploadedFiles contents:', uploadedFiles.map((f, i) => `${i}: ${f?.name || 'UNDEFINED'}`));
210
255
211
-
// Upload files as blobs in parallel
256
+
// Filter out any undefined/null/invalid entries (defensive)
257
+
const validUploadedFiles = uploadedFiles.filter((f, i) => {
258
+
if (!f) {
259
+
console.error(`Filtering out undefined/null file at index ${i}`);
260
+
return false;
261
+
}
262
+
if (!f.name) {
263
+
console.error(`Filtering out file with no name at index ${i}:`, f);
264
+
return false;
265
+
}
266
+
if (!f.content) {
267
+
console.error(`Filtering out file with no content at index ${i}:`, f.name);
268
+
return false;
269
+
}
270
+
return true;
271
+
});
272
+
if (validUploadedFiles.length !== uploadedFiles.length) {
273
+
console.warn(`Filtered out ${uploadedFiles.length - validUploadedFiles.length} invalid files`);
274
+
}
275
+
console.log('validUploadedFiles length:', validUploadedFiles.length);
276
+
277
+
const { directory, fileCount } = processUploadedFiles(validUploadedFiles);
278
+
console.log('Directory structure created, file count:', fileCount);
279
+
280
+
// Upload files as blobs in parallel (or reuse existing blobs with matching CIDs)
281
+
console.log('Starting blob upload/reuse phase...');
212
282
// For compressed files, we upload as octet-stream and store the original MIME type in metadata
213
283
// For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues
214
-
const uploadPromises = uploadedFiles.map(async (file, i) => {
284
+
const uploadPromises = validUploadedFiles.map(async (file, i) => {
215
285
try {
286
+
// Skip undefined files (shouldn't happen after filter, but defensive)
287
+
if (!file || !file.name) {
288
+
console.error(`ERROR: Undefined file at index ${i} in validUploadedFiles!`);
289
+
throw new Error(`Undefined file at index ${i}`);
290
+
}
291
+
292
+
// Compute CID for this file to check if it already exists
293
+
// Note: file.content is already gzipped+base64 encoded
294
+
const fileCID = computeCID(file.content);
295
+
296
+
// Normalize the file path for comparison (remove base folder prefix like "cobblemon/")
297
+
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
298
+
299
+
// Check if we have an existing blob with the same CID
300
+
// Try both the normalized path and the full path
301
+
const existingBlob = existingBlobMap.get(normalizedPath) || existingBlobMap.get(file.name);
302
+
303
+
if (existingBlob && existingBlob.cid === fileCID) {
304
+
// Reuse existing blob - no need to upload
305
+
logger.info(`[File Upload] Reusing existing blob for: ${file.name} (CID: ${fileCID})`);
306
+
307
+
return {
308
+
result: {
309
+
hash: existingBlob.cid,
310
+
blobRef: existingBlob.blobRef,
311
+
...(file.compressed && {
312
+
encoding: 'gzip' as const,
313
+
mimeType: file.originalMimeType || file.mimeType,
314
+
base64: true
315
+
})
316
+
},
317
+
filePath: file.name,
318
+
sentMimeType: file.mimeType,
319
+
returnedMimeType: existingBlob.blobRef.mimeType,
320
+
reused: true
321
+
};
322
+
}
323
+
324
+
// File is new or changed - upload it
216
325
// If compressed, always upload as octet-stream
217
326
// Otherwise, workaround: PDS incorrectly processes text/html through image pipeline
218
327
const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html')
···
220
329
: file.mimeType;
221
330
222
331
const compressionInfo = file.compressed ? ' (gzipped)' : '';
223
-
logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`);
332
+
logger.info(`[File Upload] Uploading new/changed file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo}, CID: ${fileCID})`);
224
333
225
334
const uploadResult = await agent.com.atproto.repo.uploadBlob(
226
335
file.content,
···
244
353
},
245
354
filePath: file.name,
246
355
sentMimeType: file.mimeType,
247
-
returnedMimeType: returnedBlobRef.mimeType
356
+
returnedMimeType: returnedBlobRef.mimeType,
357
+
reused: false
248
358
};
249
359
} catch (uploadError) {
250
360
logger.error('Upload failed for file', uploadError);
···
255
365
// Wait for all uploads to complete
256
366
const uploadedBlobs = await Promise.all(uploadPromises);
257
367
368
+
// Count reused vs uploaded blobs
369
+
const reusedCount = uploadedBlobs.filter(b => (b as any).reused).length;
370
+
const uploadedCount = uploadedBlobs.filter(b => !(b as any).reused).length;
371
+
console.log(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`);
372
+
logger.info(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`);
373
+
258
374
// Extract results and file paths in correct order
259
375
const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result);
260
376
const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath);
261
377
262
378
// Update directory with file blobs
379
+
console.log('Updating directory with blob references...');
263
380
const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths);
264
381
265
382
// Create manifest
383
+
console.log('Creating manifest...');
266
384
const manifest = createManifest(siteName, updatedDirectory, fileCount);
385
+
console.log('Manifest created successfully');
267
386
268
387
// Use site name as rkey
269
388
const rkey = siteName;
270
389
271
390
let record;
272
391
try {
392
+
console.log('Putting record to PDS with rkey:', rkey);
273
393
record = await agent.com.atproto.repo.putRecord({
274
394
repo: auth.did,
275
395
collection: 'place.wisp.fs',
276
396
rkey: rkey,
277
397
record: manifest
278
398
});
399
+
console.log('Record successfully created on PDS:', record.data.uri);
279
400
} catch (putRecordError: any) {
401
+
console.error('FAILED to create record on PDS:', putRecordError);
280
402
logger.error('Failed to create record on PDS', putRecordError);
281
403
282
404
throw putRecordError;
···
292
414
fileCount,
293
415
siteName,
294
416
skippedFiles,
295
-
uploadedCount: uploadedFiles.length
417
+
uploadedCount: validUploadedFiles.length
296
418
};
297
419
420
+
console.log('=== UPLOAD FILES COMPLETE ===');
298
421
return result;
299
422
} catch (error) {
423
+
console.error('=== UPLOAD ERROR ===');
424
+
console.error('Error details:', error);
425
+
console.error('Stack trace:', error instanceof Error ? error.stack : 'N/A');
300
426
logger.error('Upload error', error, {
301
427
message: error instanceof Error ? error.message : 'Unknown error',
302
428
name: error instanceof Error ? error.name : undefined
+40
testDeploy/index.html
+40
testDeploy/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>Wisp.place Test Site</title>
7
+
<style>
8
+
body {
9
+
font-family: system-ui, -apple-system, sans-serif;
10
+
max-width: 800px;
11
+
margin: 4rem auto;
12
+
padding: 0 2rem;
13
+
line-height: 1.6;
14
+
}
15
+
h1 {
16
+
color: #333;
17
+
}
18
+
.info {
19
+
background: #f0f0f0;
20
+
padding: 1rem;
21
+
border-radius: 8px;
22
+
margin: 2rem 0;
23
+
}
24
+
</style>
25
+
</head>
26
+
<body>
27
+
<h1>Hello from Wisp.place!</h1>
28
+
<p>This is a test deployment using the wisp-cli and Tangled Spindles CI/CD.</p>
29
+
30
+
<div class="info">
31
+
<h2>About this deployment</h2>
32
+
<p>This site was deployed to the AT Protocol using:</p>
33
+
<ul>
34
+
<li>Wisp.place CLI (Rust)</li>
35
+
<li>Tangled Spindles CI/CD</li>
36
+
<li>AT Protocol for decentralized hosting</li>
37
+
</ul>
38
+
</div>
39
+
</body>
40
+
</html>