+6
hosting-service/.env.example
+6
hosting-service/.env.example
+8
hosting-service/.gitignore
+8
hosting-service/.gitignore
+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.
+130
hosting-service/README.md
+130
hosting-service/README.md
···
1
+
# Wisp Hosting Service
2
+
3
+
Minimal microservice for hosting static sites from the AT Protocol. Built with Hono and Bun.
4
+
5
+
## Features
6
+
7
+
- **Custom Domain Hosting**: Serve verified custom domains
8
+
- **Wisp.place Subdomains**: Serve registered `*.wisp.place` subdomains
9
+
- **DNS Hash Routing**: Support DNS verification via `hash.dns.wisp.place`
10
+
- **Direct File Serving**: Access sites via `/s/:identifier/:site/*` (no DB lookup)
11
+
- **Firehose Worker**: Listens to AT Protocol firehose for new `place.wisp.fs` records
12
+
- **Automatic Caching**: Downloads and caches sites locally on first access or firehose event
13
+
- **SSRF Protection**: Hardened fetch with timeout, size limits, and private IP blocking
14
+
15
+
## Routes
16
+
17
+
1. **Custom Domains** (`/*`)
18
+
- Serves verified custom domains (example.com)
19
+
- DB lookup: `custom_domains` table
20
+
21
+
2. **Wisp Subdomains** (`/*.wisp.place/*`)
22
+
- Serves registered subdomains (alice.wisp.place)
23
+
- DB lookup: `domains` table
24
+
25
+
3. **DNS Hash Routing** (`/hash.dns.wisp.place/*`)
26
+
- DNS verification routing for custom domains
27
+
- DB lookup: `custom_domains` by hash
28
+
29
+
4. **Direct Serving** (`/s.wisp.place/:identifier/:site/*`)
30
+
- Direct access without DB lookup
31
+
- `:identifier` can be DID or handle
32
+
- Fetches from PDS if not cached
33
+
- **Automatic HTML path rewriting**: Absolute paths (`/style.css`) are rewritten to relative paths (`/s/:identifier/:site/style.css`)
34
+
35
+
## Setup
36
+
37
+
```bash
38
+
# Install dependencies
39
+
bun install
40
+
41
+
# Copy environment file
42
+
cp .env.example .env
43
+
44
+
# Run in development
45
+
bun run dev
46
+
47
+
# Run in production
48
+
bun run start
49
+
```
50
+
51
+
## Environment Variables
52
+
53
+
- `DATABASE_URL` - PostgreSQL connection string
54
+
- `PORT` - HTTP server port (default: 3001)
55
+
- `BASE_HOST` - Base domain (default: wisp.place)
56
+
57
+
## Architecture
58
+
59
+
- **Hono**: Minimal web framework
60
+
- **Postgres**: Database for domain/site lookups
61
+
- **AT Protocol**: Decentralized storage
62
+
- **Jetstream**: Firehose consumer for real-time updates
63
+
- **Bun**: Runtime and file serving
64
+
65
+
## Cache Structure
66
+
67
+
```
68
+
cache/sites/
69
+
did:plc:abc123/
70
+
sitename/
71
+
index.html
72
+
style.css
73
+
assets/
74
+
logo.png
75
+
```
76
+
77
+
## Health Check
78
+
79
+
```bash
80
+
curl http://localhost:3001/health
81
+
```
82
+
83
+
Returns firehose connection status and last event time.
84
+
85
+
## HTML Path Rewriting
86
+
87
+
When serving sites via the `/s/:identifier/:site/*` route, HTML files are automatically processed to rewrite absolute paths to work correctly in the subdirectory context.
88
+
89
+
**What gets rewritten:**
90
+
- `src` attributes (images, scripts, iframes)
91
+
- `href` attributes (links, stylesheets)
92
+
- `action` attributes (forms)
93
+
- `poster`, `data` attributes (media)
94
+
- `srcset` attributes (responsive images)
95
+
96
+
**What's preserved:**
97
+
- External URLs (`https://example.com/style.css`)
98
+
- Protocol-relative URLs (`//cdn.example.com/script.js`)
99
+
- Data URIs (`data:image/png;base64,...`)
100
+
- Anchors (`/#section`)
101
+
- Already relative paths (`./style.css`, `../images/logo.png`)
102
+
103
+
**Example:**
104
+
```html
105
+
<!-- Original HTML -->
106
+
<link rel="stylesheet" href="/style.css">
107
+
<img src="/images/logo.png">
108
+
109
+
<!-- Served at /s/did:plc:abc123/mysite/ becomes -->
110
+
<link rel="stylesheet" href="/s/did:plc:abc123/mysite/style.css">
111
+
<img src="/s/did:plc:abc123/mysite/images/logo.png">
112
+
```
113
+
114
+
This ensures sites work correctly when served from subdirectories without requiring manual path adjustments.
115
+
116
+
## Security
117
+
118
+
### SSRF Protection
119
+
120
+
All external HTTP requests are protected against Server-Side Request Forgery (SSRF) attacks:
121
+
122
+
- **5-second timeout** on all requests
123
+
- **Size limits**: 1MB for JSON, 10MB default, 100MB for file blobs
124
+
- **Blocked private IP ranges**:
125
+
- Loopback (127.0.0.0/8, ::1)
126
+
- Private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
127
+
- Link-local (169.254.0.0/16, fe80::/10)
128
+
- Cloud metadata endpoints (169.254.169.254)
129
+
- **Protocol validation**: Only HTTP/HTTPS allowed
130
+
- **Streaming with size enforcement**: Prevents memory exhaustion from large responses
+60
hosting-service/bun.lock
+60
hosting-service/bun.lock
···
1
+
{
2
+
"lockfileVersion": 1,
3
+
"workspaces": {
4
+
"": {
5
+
"name": "wisp-hosting-service",
6
+
"dependencies": {
7
+
"@atproto/api": "^0.13.20",
8
+
"@atproto/xrpc": "^0.6.4",
9
+
"hono": "^4.6.14",
10
+
"postgres": "^3.4.5",
11
+
},
12
+
"devDependencies": {
13
+
"@types/bun": "latest",
14
+
},
15
+
},
16
+
},
17
+
"packages": {
18
+
"@atproto/api": ["@atproto/api@0.13.35", "", { "dependencies": { "@atproto/common-web": "^0.4.0", "@atproto/lexicon": "^0.4.6", "@atproto/syntax": "^0.3.2", "@atproto/xrpc": "^0.6.8", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g=="],
19
+
20
+
"@atproto/common-web": ["@atproto/common-web@0.4.3", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg=="],
21
+
22
+
"@atproto/lexicon": ["@atproto/lexicon@0.4.14", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/syntax": "^0.4.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ=="],
23
+
24
+
"@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="],
25
+
26
+
"@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="],
27
+
28
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
29
+
30
+
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
31
+
32
+
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
33
+
34
+
"await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="],
35
+
36
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
37
+
38
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
39
+
40
+
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
41
+
42
+
"hono": ["hono@4.10.2", "", {}, "sha512-p6fyzl+mQo6uhESLxbF5WlBOAJMDh36PljwlKtP5V1v09NxlqGru3ShK+4wKhSuhuYf8qxMmrivHOa/M7q0sMg=="],
43
+
44
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
45
+
46
+
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
47
+
48
+
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
49
+
50
+
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
51
+
52
+
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
53
+
54
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
55
+
56
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
57
+
58
+
"@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
59
+
}
60
+
}
+18
hosting-service/package.json
+18
hosting-service/package.json
···
1
+
{
2
+
"name": "wisp-hosting-service",
3
+
"version": "1.0.0",
4
+
"type": "module",
5
+
"scripts": {
6
+
"dev": "bun --watch src/index.ts",
7
+
"start": "bun src/index.ts"
8
+
},
9
+
"dependencies": {
10
+
"hono": "^4.6.14",
11
+
"@atproto/api": "^0.13.20",
12
+
"@atproto/xrpc": "^0.6.4",
13
+
"postgres": "^3.4.5"
14
+
},
15
+
"devDependencies": {
16
+
"@types/bun": "latest"
17
+
}
18
+
}
+59
hosting-service/src/index.ts
+59
hosting-service/src/index.ts
···
1
+
import { serve } from 'bun';
2
+
import app from './server';
3
+
import { FirehoseWorker } from './lib/firehose';
4
+
import { mkdirSync, existsSync } from 'fs';
5
+
6
+
const PORT = process.env.PORT || 3001;
7
+
const CACHE_DIR = './cache/sites';
8
+
9
+
// Ensure cache directory exists
10
+
if (!existsSync(CACHE_DIR)) {
11
+
mkdirSync(CACHE_DIR, { recursive: true });
12
+
console.log('Created cache directory:', CACHE_DIR);
13
+
}
14
+
15
+
// Start firehose worker
16
+
const firehose = new FirehoseWorker((msg, data) => {
17
+
console.log(msg, data);
18
+
});
19
+
20
+
firehose.start();
21
+
22
+
// Add health check endpoint
23
+
app.get('/health', (c) => {
24
+
const firehoseHealth = firehose.getHealth();
25
+
return c.json({
26
+
status: 'ok',
27
+
firehose: firehoseHealth,
28
+
});
29
+
});
30
+
31
+
// Start HTTP server
32
+
const server = serve({
33
+
port: PORT,
34
+
fetch: app.fetch,
35
+
});
36
+
37
+
console.log(`
38
+
Wisp Hosting Service
39
+
40
+
Server: http://localhost:${PORT}
41
+
Health: http://localhost:${PORT}/health
42
+
Cache: ${CACHE_DIR}
43
+
Firehose: Connected to Jetstream
44
+
`);
45
+
46
+
// Graceful shutdown
47
+
process.on('SIGINT', () => {
48
+
console.log('\n🛑 Shutting down...');
49
+
firehose.stop();
50
+
server.stop();
51
+
process.exit(0);
52
+
});
53
+
54
+
process.on('SIGTERM', () => {
55
+
console.log('\n🛑 Shutting down...');
56
+
firehose.stop();
57
+
server.stop();
58
+
process.exit(0);
59
+
});
+62
hosting-service/src/lib/db.ts
+62
hosting-service/src/lib/db.ts
···
1
+
import postgres from 'postgres';
2
+
3
+
const sql = postgres(
4
+
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
5
+
{
6
+
max: 10,
7
+
idle_timeout: 20,
8
+
}
9
+
);
10
+
11
+
export interface DomainLookup {
12
+
did: string;
13
+
rkey: string | null;
14
+
}
15
+
16
+
export interface CustomDomainLookup {
17
+
id: string;
18
+
domain: string;
19
+
did: string;
20
+
rkey: string;
21
+
verified: boolean;
22
+
}
23
+
24
+
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
25
+
const result = await sql<DomainLookup[]>`
26
+
SELECT did, rkey FROM domains WHERE domain = ${domain.toLowerCase()} LIMIT 1
27
+
`;
28
+
return result[0] || null;
29
+
}
30
+
31
+
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
32
+
const result = await sql<CustomDomainLookup[]>`
33
+
SELECT id, domain, did, rkey, verified FROM custom_domains
34
+
WHERE domain = ${domain.toLowerCase()} AND verified = true LIMIT 1
35
+
`;
36
+
return result[0] || null;
37
+
}
38
+
39
+
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
40
+
const result = await sql<CustomDomainLookup[]>`
41
+
SELECT id, domain, did, rkey, verified FROM custom_domains
42
+
WHERE id = ${hash} AND verified = true LIMIT 1
43
+
`;
44
+
return result[0] || null;
45
+
}
46
+
47
+
export async function upsertSite(did: string, rkey: string, displayName?: string) {
48
+
try {
49
+
await sql`
50
+
INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
51
+
VALUES (${did}, ${rkey}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
52
+
ON CONFLICT (did, rkey)
53
+
DO UPDATE SET
54
+
display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
55
+
updated_at = EXTRACT(EPOCH FROM NOW())
56
+
`;
57
+
} catch (err) {
58
+
console.error('Failed to upsert site', err);
59
+
}
60
+
}
61
+
62
+
export { sql };
+328
hosting-service/src/lib/firehose.ts
+328
hosting-service/src/lib/firehose.ts
···
1
+
import { existsSync, rmSync } from 'fs';
2
+
import type { WispFsRecord } from './types';
3
+
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
4
+
import { upsertSite } from './db';
5
+
import { safeFetch } from './safe-fetch';
6
+
7
+
const CACHE_DIR = './cache/sites';
8
+
const JETSTREAM_URL = 'wss://jetstream2.us-west.bsky.network/subscribe';
9
+
const RECONNECT_DELAY = 5000; // 5 seconds
10
+
const MAX_RECONNECT_DELAY = 60000; // 1 minute
11
+
12
+
interface JetstreamCommitEvent {
13
+
did: string;
14
+
time_us: number;
15
+
type: 'com' | 'identity' | 'account';
16
+
kind: 'commit';
17
+
commit: {
18
+
rev: string;
19
+
operation: 'create' | 'update' | 'delete';
20
+
collection: string;
21
+
rkey: string;
22
+
record?: any;
23
+
cid?: string;
24
+
};
25
+
}
26
+
27
+
interface JetstreamIdentityEvent {
28
+
did: string;
29
+
time_us: number;
30
+
type: 'identity';
31
+
kind: 'update';
32
+
identity: {
33
+
did: string;
34
+
handle: string;
35
+
seq: number;
36
+
time: string;
37
+
};
38
+
}
39
+
40
+
interface JetstreamAccountEvent {
41
+
did: string;
42
+
time_us: number;
43
+
type: 'account';
44
+
kind: 'update' | 'delete';
45
+
account: {
46
+
active: boolean;
47
+
did: string;
48
+
seq: number;
49
+
time: string;
50
+
};
51
+
}
52
+
53
+
type JetstreamEvent =
54
+
| JetstreamCommitEvent
55
+
| JetstreamIdentityEvent
56
+
| JetstreamAccountEvent;
57
+
58
+
export class FirehoseWorker {
59
+
private ws: WebSocket | null = null;
60
+
private reconnectAttempts = 0;
61
+
private reconnectTimeout: Timer | null = null;
62
+
private isShuttingDown = false;
63
+
private lastEventTime = Date.now();
64
+
65
+
constructor(
66
+
private logger?: (msg: string, data?: Record<string, unknown>) => void,
67
+
) {}
68
+
69
+
private log(msg: string, data?: Record<string, unknown>) {
70
+
const log = this.logger || console.log;
71
+
log(`[FirehoseWorker] ${msg}`, data || {});
72
+
}
73
+
74
+
start() {
75
+
this.log('Starting firehose worker');
76
+
this.connect();
77
+
}
78
+
79
+
stop() {
80
+
this.log('Stopping firehose worker');
81
+
this.isShuttingDown = true;
82
+
83
+
if (this.reconnectTimeout) {
84
+
clearTimeout(this.reconnectTimeout);
85
+
this.reconnectTimeout = null;
86
+
}
87
+
88
+
if (this.ws) {
89
+
this.ws.close();
90
+
this.ws = null;
91
+
}
92
+
}
93
+
94
+
private connect() {
95
+
if (this.isShuttingDown) return;
96
+
97
+
const url = new URL(JETSTREAM_URL);
98
+
url.searchParams.set('wantedCollections', 'place.wisp.fs');
99
+
100
+
this.log('Connecting to Jetstream', { url: url.toString() });
101
+
102
+
try {
103
+
this.ws = new WebSocket(url.toString());
104
+
105
+
this.ws.onopen = () => {
106
+
this.log('Connected to Jetstream');
107
+
this.reconnectAttempts = 0;
108
+
this.lastEventTime = Date.now();
109
+
};
110
+
111
+
this.ws.onmessage = async (event) => {
112
+
this.lastEventTime = Date.now();
113
+
114
+
try {
115
+
const data = JSON.parse(event.data as string) as JetstreamEvent;
116
+
await this.handleEvent(data);
117
+
} catch (err) {
118
+
this.log('Error processing event', {
119
+
error: err instanceof Error ? err.message : String(err),
120
+
});
121
+
}
122
+
};
123
+
124
+
this.ws.onerror = (error) => {
125
+
this.log('WebSocket error', { error: String(error) });
126
+
};
127
+
128
+
this.ws.onclose = () => {
129
+
this.log('WebSocket closed');
130
+
this.ws = null;
131
+
132
+
if (!this.isShuttingDown) {
133
+
this.scheduleReconnect();
134
+
}
135
+
};
136
+
} catch (err) {
137
+
this.log('Failed to create WebSocket', {
138
+
error: err instanceof Error ? err.message : String(err),
139
+
});
140
+
this.scheduleReconnect();
141
+
}
142
+
}
143
+
144
+
private scheduleReconnect() {
145
+
if (this.isShuttingDown) return;
146
+
147
+
this.reconnectAttempts++;
148
+
const delay = Math.min(
149
+
RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
150
+
MAX_RECONNECT_DELAY,
151
+
);
152
+
153
+
this.log(`Scheduling reconnect attempt ${this.reconnectAttempts}`, {
154
+
delay: `${delay}ms`,
155
+
});
156
+
157
+
this.reconnectTimeout = setTimeout(() => {
158
+
this.connect();
159
+
}, delay);
160
+
}
161
+
162
+
private async handleEvent(event: JetstreamEvent) {
163
+
if (event.kind !== 'commit') return;
164
+
165
+
const commitEvent = event as JetstreamCommitEvent;
166
+
const { commit, did } = commitEvent;
167
+
168
+
if (commit.collection !== 'place.wisp.fs') return;
169
+
170
+
this.log('Received place.wisp.fs event', {
171
+
did,
172
+
operation: commit.operation,
173
+
rkey: commit.rkey,
174
+
});
175
+
176
+
try {
177
+
if (commit.operation === 'create' || commit.operation === 'update') {
178
+
await this.handleCreateOrUpdate(did, commit.rkey, commit.record);
179
+
} else if (commit.operation === 'delete') {
180
+
await this.handleDelete(did, commit.rkey);
181
+
}
182
+
} catch (err) {
183
+
this.log('Error handling event', {
184
+
did,
185
+
operation: commit.operation,
186
+
rkey: commit.rkey,
187
+
error: err instanceof Error ? err.message : String(err),
188
+
});
189
+
}
190
+
}
191
+
192
+
private async handleCreateOrUpdate(did: string, site: string, record: any) {
193
+
this.log('Processing create/update', { did, site });
194
+
195
+
if (!this.validateRecord(record)) {
196
+
this.log('Invalid record structure, skipping', { did, site });
197
+
return;
198
+
}
199
+
200
+
const fsRecord = record as WispFsRecord;
201
+
202
+
const pdsEndpoint = await getPdsForDid(did);
203
+
if (!pdsEndpoint) {
204
+
this.log('Could not resolve PDS for DID', { did });
205
+
return;
206
+
}
207
+
208
+
this.log('Resolved PDS', { did, pdsEndpoint });
209
+
210
+
// Verify record exists on PDS
211
+
try {
212
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
213
+
const recordRes = await safeFetch(recordUrl);
214
+
215
+
if (!recordRes.ok) {
216
+
this.log('Record not found on PDS, skipping cache', {
217
+
did,
218
+
site,
219
+
status: recordRes.status,
220
+
});
221
+
return;
222
+
}
223
+
224
+
this.log('Record verified on PDS', { did, site });
225
+
} catch (err) {
226
+
this.log('Failed to verify record on PDS', {
227
+
did,
228
+
site,
229
+
error: err instanceof Error ? err.message : String(err),
230
+
});
231
+
return;
232
+
}
233
+
234
+
// Cache the record
235
+
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint);
236
+
237
+
// Upsert site to database
238
+
await upsertSite(did, site, fsRecord.site);
239
+
240
+
this.log('Successfully processed create/update', { did, site });
241
+
}
242
+
243
+
private async handleDelete(did: string, site: string) {
244
+
this.log('Processing delete', { did, site });
245
+
246
+
const pdsEndpoint = await getPdsForDid(did);
247
+
if (!pdsEndpoint) {
248
+
this.log('Could not resolve PDS for DID', { did });
249
+
return;
250
+
}
251
+
252
+
// Verify record is actually deleted from PDS
253
+
try {
254
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
255
+
const recordRes = await safeFetch(recordUrl);
256
+
257
+
if (recordRes.ok) {
258
+
this.log('Record still exists on PDS, not deleting cache', {
259
+
did,
260
+
site,
261
+
});
262
+
return;
263
+
}
264
+
265
+
this.log('Verified record is deleted from PDS', {
266
+
did,
267
+
site,
268
+
status: recordRes.status,
269
+
});
270
+
} catch (err) {
271
+
this.log('Error verifying deletion on PDS', {
272
+
did,
273
+
site,
274
+
error: err instanceof Error ? err.message : String(err),
275
+
});
276
+
}
277
+
278
+
// Delete cache
279
+
this.deleteCache(did, site);
280
+
281
+
this.log('Successfully processed delete', { did, site });
282
+
}
283
+
284
+
private validateRecord(record: any): boolean {
285
+
if (!record || typeof record !== 'object') return false;
286
+
if (record.$type !== 'place.wisp.fs') return false;
287
+
if (!record.root || typeof record.root !== 'object') return false;
288
+
if (!record.site || typeof record.site !== 'string') return false;
289
+
return true;
290
+
}
291
+
292
+
private deleteCache(did: string, site: string) {
293
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
294
+
295
+
if (!existsSync(cacheDir)) {
296
+
this.log('Cache directory does not exist, nothing to delete', {
297
+
did,
298
+
site,
299
+
});
300
+
return;
301
+
}
302
+
303
+
try {
304
+
rmSync(cacheDir, { recursive: true, force: true });
305
+
this.log('Cache deleted', { did, site, path: cacheDir });
306
+
} catch (err) {
307
+
this.log('Failed to delete cache', {
308
+
did,
309
+
site,
310
+
path: cacheDir,
311
+
error: err instanceof Error ? err.message : String(err),
312
+
});
313
+
}
314
+
}
315
+
316
+
getHealth() {
317
+
const isConnected = this.ws !== null && this.ws.readyState === WebSocket.OPEN;
318
+
const timeSinceLastEvent = Date.now() - this.lastEventTime;
319
+
320
+
return {
321
+
connected: isConnected,
322
+
reconnectAttempts: this.reconnectAttempts,
323
+
lastEventTime: this.lastEventTime,
324
+
timeSinceLastEvent,
325
+
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
326
+
};
327
+
}
328
+
}
+107
hosting-service/src/lib/html-rewriter.test.ts
+107
hosting-service/src/lib/html-rewriter.test.ts
···
1
+
/**
2
+
* Simple tests for HTML path rewriter
3
+
* Run with: bun test
4
+
*/
5
+
6
+
import { test, expect } from 'bun:test';
7
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
8
+
9
+
test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => {
10
+
const html = '<img src="/logo.png">';
11
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
12
+
expect(result).toBe('<img src="/s/did:plc:123/mysite/logo.png">');
13
+
});
14
+
15
+
test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => {
16
+
const html = '<link rel="stylesheet" href="/style.css">';
17
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
18
+
expect(result).toBe('<link rel="stylesheet" href="/s/did:plc:123/mysite/style.css">');
19
+
});
20
+
21
+
test('rewriteHtmlPaths - preserves external URLs', () => {
22
+
const html = '<img src="https://example.com/logo.png">';
23
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
24
+
expect(result).toBe('<img src="https://example.com/logo.png">');
25
+
});
26
+
27
+
test('rewriteHtmlPaths - preserves protocol-relative URLs', () => {
28
+
const html = '<script src="//cdn.example.com/script.js"></script>';
29
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
30
+
expect(result).toBe('<script src="//cdn.example.com/script.js"></script>');
31
+
});
32
+
33
+
test('rewriteHtmlPaths - preserves data URIs', () => {
34
+
const html = '<img src="data:image/png;base64,abc123">';
35
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
36
+
expect(result).toBe('<img src="data:image/png;base64,abc123">');
37
+
});
38
+
39
+
test('rewriteHtmlPaths - preserves anchors', () => {
40
+
const html = '<a href="/#section">Jump</a>';
41
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
42
+
expect(result).toBe('<a href="/#section">Jump</a>');
43
+
});
44
+
45
+
test('rewriteHtmlPaths - preserves relative paths', () => {
46
+
const html = '<img src="./logo.png">';
47
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
48
+
expect(result).toBe('<img src="./logo.png">');
49
+
});
50
+
51
+
test('rewriteHtmlPaths - handles single quotes', () => {
52
+
const html = "<img src='/logo.png'>";
53
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
54
+
expect(result).toBe("<img src='/s/did:plc:123/mysite/logo.png'>");
55
+
});
56
+
57
+
test('rewriteHtmlPaths - handles srcset', () => {
58
+
const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">';
59
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
60
+
expect(result).toBe('<img srcset="/s/did:plc:123/mysite/logo.png 1x, /s/did:plc:123/mysite/logo@2x.png 2x">');
61
+
});
62
+
63
+
test('rewriteHtmlPaths - handles form actions', () => {
64
+
const html = '<form action="/submit"></form>';
65
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
66
+
expect(result).toBe('<form action="/s/did:plc:123/mysite/submit"></form>');
67
+
});
68
+
69
+
test('rewriteHtmlPaths - handles complex HTML', () => {
70
+
const html = `
71
+
<!DOCTYPE html>
72
+
<html>
73
+
<head>
74
+
<link rel="stylesheet" href="/style.css">
75
+
<script src="/app.js"></script>
76
+
</head>
77
+
<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>
82
+
</body>
83
+
</html>
84
+
`.trim();
85
+
86
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
87
+
88
+
expect(result).toContain('href="/s/did:plc:123/mysite/style.css"');
89
+
expect(result).toContain('src="/s/did:plc:123/mysite/app.js"');
90
+
expect(result).toContain('src="/s/did:plc:123/mysite/images/logo.png"');
91
+
expect(result).toContain('href="/s/did:plc:123/mysite/about"');
92
+
expect(result).toContain('href="https://example.com"'); // External preserved
93
+
expect(result).toContain('href="#section"'); // Anchor preserved
94
+
});
95
+
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
+
});
102
+
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
+
});
+130
hosting-service/src/lib/html-rewriter.ts
+130
hosting-service/src/lib/html-rewriter.ts
···
1
+
/**
2
+
* Safely rewrites absolute paths in HTML to be relative to a base path
3
+
* Only processes common HTML attributes and preserves external URLs, data URIs, etc.
4
+
*/
5
+
6
+
const REWRITABLE_ATTRIBUTES = [
7
+
'src',
8
+
'href',
9
+
'action',
10
+
'data',
11
+
'poster',
12
+
'srcset',
13
+
] as const;
14
+
15
+
/**
16
+
* Check if a path should be rewritten
17
+
*/
18
+
function shouldRewritePath(path: string): boolean {
19
+
// Must start with /
20
+
if (!path.startsWith('/')) return false;
21
+
22
+
// Don't rewrite protocol-relative URLs
23
+
if (path.startsWith('//')) return false;
24
+
25
+
// Don't rewrite anchors
26
+
if (path.startsWith('/#')) return false;
27
+
28
+
// Don't rewrite data URIs or other schemes
29
+
if (path.includes(':')) return false;
30
+
31
+
return true;
32
+
}
33
+
34
+
/**
35
+
* Rewrite a single path
36
+
*/
37
+
function rewritePath(path: string, basePath: string): string {
38
+
if (!shouldRewritePath(path)) {
39
+
return path;
40
+
}
41
+
42
+
// Remove leading slash and prepend base path
43
+
return basePath + path.slice(1);
44
+
}
45
+
46
+
/**
47
+
* Rewrite srcset attribute (can contain multiple URLs)
48
+
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
49
+
*/
50
+
function rewriteSrcset(srcset: string, basePath: string): string {
51
+
return srcset
52
+
.split(',')
53
+
.map(part => {
54
+
const trimmed = part.trim();
55
+
const spaceIndex = trimmed.indexOf(' ');
56
+
57
+
if (spaceIndex === -1) {
58
+
// No descriptor, just URL
59
+
return rewritePath(trimmed, basePath);
60
+
}
61
+
62
+
const url = trimmed.substring(0, spaceIndex);
63
+
const descriptor = trimmed.substring(spaceIndex);
64
+
return rewritePath(url, basePath) + descriptor;
65
+
})
66
+
.join(', ');
67
+
}
68
+
69
+
/**
70
+
* Rewrite absolute paths in HTML content
71
+
* Uses simple regex matching for safety (no full HTML parsing)
72
+
*/
73
+
export function rewriteHtmlPaths(html: string, basePath: string): string {
74
+
// Ensure base path ends with /
75
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
76
+
77
+
let rewritten = html;
78
+
79
+
// Rewrite each attribute type
80
+
for (const attr of REWRITABLE_ATTRIBUTES) {
81
+
if (attr === 'srcset') {
82
+
// Special handling for srcset
83
+
const srcsetRegex = new RegExp(
84
+
`\\b${attr}\\s*=\\s*"([^"]*)"`,
85
+
'gi'
86
+
);
87
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
88
+
const rewrittenValue = rewriteSrcset(value, normalizedBase);
89
+
return `${attr}="${rewrittenValue}"`;
90
+
});
91
+
} else {
92
+
// Regular attributes with quoted values
93
+
const doubleQuoteRegex = new RegExp(
94
+
`\\b${attr}\\s*=\\s*"([^"]*)"`,
95
+
'gi'
96
+
);
97
+
const singleQuoteRegex = new RegExp(
98
+
`\\b${attr}\\s*=\\s*'([^']*)'`,
99
+
'gi'
100
+
);
101
+
102
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
103
+
const rewrittenValue = rewritePath(value, normalizedBase);
104
+
return `${attr}="${rewrittenValue}"`;
105
+
});
106
+
107
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
108
+
const rewrittenValue = rewritePath(value, normalizedBase);
109
+
return `${attr}='${rewrittenValue}'`;
110
+
});
111
+
}
112
+
}
113
+
114
+
return rewritten;
115
+
}
116
+
117
+
/**
118
+
* Check if content is HTML based on content or filename
119
+
*/
120
+
export function isHtmlContent(
121
+
filepath: string,
122
+
contentType?: string
123
+
): boolean {
124
+
if (contentType && contentType.includes('text/html')) {
125
+
return true;
126
+
}
127
+
128
+
const ext = filepath.toLowerCase().split('.').pop();
129
+
return ext === 'html' || ext === 'htm';
130
+
}
+181
hosting-service/src/lib/safe-fetch.ts
+181
hosting-service/src/lib/safe-fetch.ts
···
1
+
/**
2
+
* SSRF-hardened fetch utility
3
+
* Prevents requests to private networks, localhost, and enforces timeouts/size limits
4
+
*/
5
+
6
+
const BLOCKED_IP_RANGES = [
7
+
/^127\./, // 127.0.0.0/8 - Loopback
8
+
/^10\./, // 10.0.0.0/8 - Private
9
+
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 - Private
10
+
/^192\.168\./, // 192.168.0.0/16 - Private
11
+
/^169\.254\./, // 169.254.0.0/16 - Link-local
12
+
/^::1$/, // IPv6 loopback
13
+
/^fe80:/, // IPv6 link-local
14
+
/^fc00:/, // IPv6 unique local
15
+
/^fd00:/, // IPv6 unique local
16
+
];
17
+
18
+
const BLOCKED_HOSTS = [
19
+
'localhost',
20
+
'metadata.google.internal',
21
+
'169.254.169.254',
22
+
];
23
+
24
+
const FETCH_TIMEOUT = 5000; // 5 seconds
25
+
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
26
+
27
+
function isBlockedHost(hostname: string): boolean {
28
+
const lowerHost = hostname.toLowerCase();
29
+
30
+
if (BLOCKED_HOSTS.includes(lowerHost)) {
31
+
return true;
32
+
}
33
+
34
+
for (const pattern of BLOCKED_IP_RANGES) {
35
+
if (pattern.test(lowerHost)) {
36
+
return true;
37
+
}
38
+
}
39
+
40
+
return false;
41
+
}
42
+
43
+
export async function safeFetch(
44
+
url: string,
45
+
options?: RequestInit & { maxSize?: number; timeout?: number }
46
+
): Promise<Response> {
47
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
48
+
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
49
+
50
+
// Parse and validate URL
51
+
let parsedUrl: URL;
52
+
try {
53
+
parsedUrl = new URL(url);
54
+
} catch (err) {
55
+
throw new Error(`Invalid URL: ${url}`);
56
+
}
57
+
58
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
59
+
throw new Error(`Blocked protocol: ${parsedUrl.protocol}`);
60
+
}
61
+
62
+
const hostname = parsedUrl.hostname;
63
+
if (isBlockedHost(hostname)) {
64
+
throw new Error(`Blocked host: ${hostname}`);
65
+
}
66
+
67
+
const controller = new AbortController();
68
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
69
+
70
+
try {
71
+
const response = await fetch(url, {
72
+
...options,
73
+
signal: controller.signal,
74
+
});
75
+
76
+
const contentLength = response.headers.get('content-length');
77
+
if (contentLength && parseInt(contentLength, 10) > maxSize) {
78
+
throw new Error(`Response too large: ${contentLength} bytes`);
79
+
}
80
+
81
+
return response;
82
+
} catch (err) {
83
+
if (err instanceof Error && err.name === 'AbortError') {
84
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
85
+
}
86
+
throw err;
87
+
} finally {
88
+
clearTimeout(timeoutId);
89
+
}
90
+
}
91
+
92
+
export async function safeFetchJson<T = any>(
93
+
url: string,
94
+
options?: RequestInit & { maxSize?: number; timeout?: number }
95
+
): Promise<T> {
96
+
const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON
97
+
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
98
+
99
+
if (!response.ok) {
100
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
101
+
}
102
+
103
+
const reader = response.body?.getReader();
104
+
if (!reader) {
105
+
throw new Error('No response body');
106
+
}
107
+
108
+
const chunks: Uint8Array[] = [];
109
+
let totalSize = 0;
110
+
111
+
try {
112
+
while (true) {
113
+
const { done, value } = await reader.read();
114
+
if (done) break;
115
+
116
+
totalSize += value.length;
117
+
if (totalSize > maxJsonSize) {
118
+
throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`);
119
+
}
120
+
121
+
chunks.push(value);
122
+
}
123
+
} finally {
124
+
reader.releaseLock();
125
+
}
126
+
127
+
const combined = new Uint8Array(totalSize);
128
+
let offset = 0;
129
+
for (const chunk of chunks) {
130
+
combined.set(chunk, offset);
131
+
offset += chunk.length;
132
+
}
133
+
134
+
const text = new TextDecoder().decode(combined);
135
+
return JSON.parse(text);
136
+
}
137
+
138
+
export async function safeFetchBlob(
139
+
url: string,
140
+
options?: RequestInit & { maxSize?: number; timeout?: number }
141
+
): Promise<Uint8Array> {
142
+
const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
143
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize });
144
+
145
+
if (!response.ok) {
146
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
147
+
}
148
+
149
+
const reader = response.body?.getReader();
150
+
if (!reader) {
151
+
throw new Error('No response body');
152
+
}
153
+
154
+
const chunks: Uint8Array[] = [];
155
+
let totalSize = 0;
156
+
157
+
try {
158
+
while (true) {
159
+
const { done, value } = await reader.read();
160
+
if (done) break;
161
+
162
+
totalSize += value.length;
163
+
if (totalSize > maxBlobSize) {
164
+
throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`);
165
+
}
166
+
167
+
chunks.push(value);
168
+
}
169
+
} finally {
170
+
reader.releaseLock();
171
+
}
172
+
173
+
const combined = new Uint8Array(totalSize);
174
+
let offset = 0;
175
+
for (const chunk of chunks) {
176
+
combined.set(chunk, offset);
177
+
offset += chunk.length;
178
+
}
179
+
180
+
return combined;
181
+
}
+27
hosting-service/src/lib/types.ts
+27
hosting-service/src/lib/types.ts
···
1
+
import type { BlobRef } from '@atproto/api';
2
+
3
+
export interface WispFsRecord {
4
+
$type: 'place.wisp.fs';
5
+
site: string;
6
+
root: Directory;
7
+
fileCount?: number;
8
+
createdAt: string;
9
+
}
10
+
11
+
export interface File {
12
+
$type?: 'place.wisp.fs#file';
13
+
type: 'file';
14
+
blob: BlobRef;
15
+
}
16
+
17
+
export interface Directory {
18
+
$type?: 'place.wisp.fs#directory';
19
+
type: 'directory';
20
+
entries: Entry[];
21
+
}
22
+
23
+
export interface Entry {
24
+
$type?: 'place.wisp.fs#entry';
25
+
name: string;
26
+
node: File | Directory | { $type: string };
27
+
}
+162
hosting-service/src/lib/utils.ts
+162
hosting-service/src/lib/utils.ts
···
1
+
import { AtpAgent } from '@atproto/api';
2
+
import type { WispFsRecord, Directory, Entry, File } from './types';
3
+
import { existsSync, mkdirSync } from 'fs';
4
+
import { writeFile } from 'fs/promises';
5
+
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
+
7
+
const CACHE_DIR = './cache/sites';
8
+
9
+
export async function resolveDid(identifier: string): Promise<string | null> {
10
+
try {
11
+
// If it's already a DID, return it
12
+
if (identifier.startsWith('did:')) {
13
+
return identifier;
14
+
}
15
+
16
+
// Otherwise, resolve the handle using agent's built-in method
17
+
const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
18
+
const response = await agent.resolveHandle({ handle: identifier });
19
+
return response.data.did;
20
+
} catch (err) {
21
+
console.error('Failed to resolve identifier', identifier, err);
22
+
return null;
23
+
}
24
+
}
25
+
26
+
export async function getPdsForDid(did: string): Promise<string | null> {
27
+
try {
28
+
let doc;
29
+
30
+
if (did.startsWith('did:plc:')) {
31
+
// Resolve did:plc from plc.directory
32
+
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
33
+
} else if (did.startsWith('did:web:')) {
34
+
// Resolve did:web from the domain
35
+
const didUrl = didWebToHttps(did);
36
+
doc = await safeFetchJson(didUrl);
37
+
} else {
38
+
console.error('Unsupported DID method', did);
39
+
return null;
40
+
}
41
+
42
+
const services = doc.service || [];
43
+
const pdsService = services.find((s: any) => s.id === '#atproto_pds');
44
+
45
+
return pdsService?.serviceEndpoint || null;
46
+
} catch (err) {
47
+
console.error('Failed to get PDS for DID', did, err);
48
+
return null;
49
+
}
50
+
}
51
+
52
+
function didWebToHttps(did: string): string {
53
+
// did:web:example.com -> https://example.com/.well-known/did.json
54
+
// did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json
55
+
56
+
const didParts = did.split(':');
57
+
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
58
+
throw new Error('Invalid did:web format');
59
+
}
60
+
61
+
const domain = didParts[2];
62
+
const pathParts = didParts.slice(3);
63
+
64
+
if (pathParts.length === 0) {
65
+
// No path, use .well-known
66
+
return `https://${domain}/.well-known/did.json`;
67
+
} else {
68
+
// Has path
69
+
const path = pathParts.join('/');
70
+
return `https://${domain}/${path}/did.json`;
71
+
}
72
+
}
73
+
74
+
export async function fetchSiteRecord(did: string, rkey: string): Promise<WispFsRecord | null> {
75
+
try {
76
+
const pdsEndpoint = await getPdsForDid(did);
77
+
if (!pdsEndpoint) return null;
78
+
79
+
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
80
+
const data = await safeFetchJson(url);
81
+
return data.value as WispFsRecord;
82
+
} catch (err) {
83
+
console.error('Failed to fetch site record', did, rkey, err);
84
+
return null;
85
+
}
86
+
}
87
+
88
+
export function extractBlobCid(blobRef: any): string | null {
89
+
if (typeof blobRef === 'object' && blobRef !== null) {
90
+
if ('ref' in blobRef && blobRef.ref?.$link) {
91
+
return blobRef.ref.$link;
92
+
}
93
+
if ('cid' in blobRef && typeof blobRef.cid === 'string') {
94
+
return blobRef.cid;
95
+
}
96
+
if ('$link' in blobRef && typeof blobRef.$link === 'string') {
97
+
return blobRef.$link;
98
+
}
99
+
}
100
+
return null;
101
+
}
102
+
103
+
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string): Promise<void> {
104
+
console.log('Caching site', did, rkey);
105
+
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
106
+
}
107
+
108
+
async function cacheFiles(
109
+
did: string,
110
+
site: string,
111
+
entries: Entry[],
112
+
pdsEndpoint: string,
113
+
pathPrefix: string
114
+
): Promise<void> {
115
+
for (const entry of entries) {
116
+
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
117
+
const node = entry.node;
118
+
119
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
120
+
await cacheFiles(did, site, node.entries, pdsEndpoint, currentPath);
121
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
122
+
await cacheFileBlob(did, site, currentPath, node.blob, pdsEndpoint);
123
+
}
124
+
}
125
+
}
126
+
127
+
async function cacheFileBlob(
128
+
did: string,
129
+
site: string,
130
+
filePath: string,
131
+
blobRef: any,
132
+
pdsEndpoint: string
133
+
): Promise<void> {
134
+
const cid = extractBlobCid(blobRef);
135
+
if (!cid) {
136
+
console.error('Could not extract CID from blob', blobRef);
137
+
return;
138
+
}
139
+
140
+
const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
141
+
142
+
// Allow up to 100MB per file blob
143
+
const content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024 });
144
+
145
+
const cacheFile = `${CACHE_DIR}/${did}/${site}/${filePath}`;
146
+
const fileDir = cacheFile.substring(0, cacheFile.lastIndexOf('/'));
147
+
148
+
if (fileDir && !existsSync(fileDir)) {
149
+
mkdirSync(fileDir, { recursive: true });
150
+
}
151
+
152
+
await writeFile(cacheFile, content);
153
+
console.log('Cached file', filePath, content.length, 'bytes');
154
+
}
155
+
156
+
export function getCachedFilePath(did: string, site: string, filePath: string): string {
157
+
return `${CACHE_DIR}/${did}/${site}/${filePath}`;
158
+
}
159
+
160
+
export function isCached(did: string, site: string): boolean {
161
+
return existsSync(`${CACHE_DIR}/${did}/${site}`);
162
+
}
+213
hosting-service/src/server.ts
+213
hosting-service/src/server.ts
···
1
+
import { Hono } from 'hono';
2
+
import { serveStatic } from 'hono/bun';
3
+
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
4
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached } from './lib/utils';
5
+
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
6
+
import { existsSync } from 'fs';
7
+
8
+
const app = new Hono();
9
+
10
+
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
11
+
12
+
// Helper to serve files from cache
13
+
async function serveFromCache(did: string, rkey: string, filePath: string) {
14
+
// Default to index.html if path is empty or ends with /
15
+
let requestPath = filePath || 'index.html';
16
+
if (requestPath.endsWith('/')) {
17
+
requestPath += 'index.html';
18
+
}
19
+
20
+
const cachedFile = getCachedFilePath(did, rkey, requestPath);
21
+
22
+
if (existsSync(cachedFile)) {
23
+
const file = Bun.file(cachedFile);
24
+
return new Response(file);
25
+
}
26
+
27
+
// Try index.html for directory-like paths
28
+
if (!requestPath.includes('.')) {
29
+
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
30
+
if (existsSync(indexFile)) {
31
+
const file = Bun.file(indexFile);
32
+
return new Response(file);
33
+
}
34
+
}
35
+
36
+
return new Response('Not Found', { status: 404 });
37
+
}
38
+
39
+
// Helper to serve files from cache with HTML path rewriting for /s/ routes
40
+
async function serveFromCacheWithRewrite(
41
+
did: string,
42
+
rkey: string,
43
+
filePath: string,
44
+
basePath: string
45
+
) {
46
+
// Default to index.html if path is empty or ends with /
47
+
let requestPath = filePath || 'index.html';
48
+
if (requestPath.endsWith('/')) {
49
+
requestPath += 'index.html';
50
+
}
51
+
52
+
const cachedFile = getCachedFilePath(did, rkey, requestPath);
53
+
54
+
if (existsSync(cachedFile)) {
55
+
const file = Bun.file(cachedFile);
56
+
57
+
// Check if this is HTML content that needs rewriting
58
+
if (isHtmlContent(requestPath, file.type)) {
59
+
const content = await file.text();
60
+
const rewritten = rewriteHtmlPaths(content, basePath);
61
+
return new Response(rewritten, {
62
+
headers: {
63
+
'Content-Type': 'text/html; charset=utf-8',
64
+
},
65
+
});
66
+
}
67
+
68
+
// Non-HTML files served as-is
69
+
return new Response(file);
70
+
}
71
+
72
+
// Try index.html for directory-like paths
73
+
if (!requestPath.includes('.')) {
74
+
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
75
+
if (existsSync(indexFile)) {
76
+
const file = Bun.file(indexFile);
77
+
const content = await file.text();
78
+
const rewritten = rewriteHtmlPaths(content, basePath);
79
+
return new Response(rewritten, {
80
+
headers: {
81
+
'Content-Type': 'text/html; charset=utf-8',
82
+
},
83
+
});
84
+
}
85
+
}
86
+
87
+
return new Response('Not Found', { status: 404 });
88
+
}
89
+
90
+
// Helper to ensure site is cached
91
+
async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
92
+
if (isCached(did, rkey)) {
93
+
return true;
94
+
}
95
+
96
+
// Fetch and cache the site
97
+
const record = await fetchSiteRecord(did, rkey);
98
+
if (!record) {
99
+
console.error('Site record not found', did, rkey);
100
+
return false;
101
+
}
102
+
103
+
const pdsEndpoint = await getPdsForDid(did);
104
+
if (!pdsEndpoint) {
105
+
console.error('PDS not found for DID', did);
106
+
return false;
107
+
}
108
+
109
+
try {
110
+
await downloadAndCacheSite(did, rkey, record, pdsEndpoint);
111
+
return true;
112
+
} catch (err) {
113
+
console.error('Failed to cache site', did, rkey, err);
114
+
return false;
115
+
}
116
+
}
117
+
118
+
// Route 4: Direct file serving (no DB) - /s.wisp.place/:identifier/:site/*
119
+
app.get('/s/:identifier/:site/*', async (c) => {
120
+
const identifier = c.req.param('identifier');
121
+
const site = c.req.param('site');
122
+
const filePath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
123
+
124
+
console.log('[Direct] Serving', { identifier, site, filePath });
125
+
126
+
// Resolve identifier to DID
127
+
const did = await resolveDid(identifier);
128
+
if (!did) {
129
+
return c.text('Invalid identifier', 400);
130
+
}
131
+
132
+
// Ensure site is cached
133
+
const cached = await ensureSiteCached(did, site);
134
+
if (!cached) {
135
+
return c.text('Site not found', 404);
136
+
}
137
+
138
+
// Serve with HTML path rewriting to handle absolute paths
139
+
const basePath = `/s/${identifier}/${site}/`;
140
+
return serveFromCacheWithRewrite(did, site, filePath, basePath);
141
+
});
142
+
143
+
// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
144
+
app.get('/*', async (c) => {
145
+
const hostname = c.req.header('host') || '';
146
+
const path = c.req.path.replace(/^\//, '');
147
+
148
+
console.log('[Request]', { hostname, path });
149
+
150
+
// Check if this is a DNS hash subdomain
151
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
152
+
if (dnsMatch) {
153
+
const hash = dnsMatch[1];
154
+
const baseDomain = dnsMatch[2];
155
+
156
+
console.log('[DNS Hash] Looking up', { hash, baseDomain });
157
+
158
+
if (baseDomain !== BASE_HOST) {
159
+
return c.text('Invalid base domain', 400);
160
+
}
161
+
162
+
const customDomain = await getCustomDomainByHash(hash);
163
+
if (!customDomain) {
164
+
return c.text('Custom domain not found or not verified', 404);
165
+
}
166
+
167
+
const rkey = customDomain.rkey || 'self';
168
+
const cached = await ensureSiteCached(customDomain.did, rkey);
169
+
if (!cached) {
170
+
return c.text('Site not found', 404);
171
+
}
172
+
173
+
return serveFromCache(customDomain.did, rkey, path);
174
+
}
175
+
176
+
// Route 2: Registered subdomains - /*.wisp.place/*
177
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
178
+
const subdomain = hostname.replace(`.${BASE_HOST}`, '');
179
+
180
+
console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname });
181
+
182
+
const domainInfo = await getWispDomain(hostname);
183
+
if (!domainInfo) {
184
+
return c.text('Subdomain not registered', 404);
185
+
}
186
+
187
+
const rkey = domainInfo.rkey || 'self';
188
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
189
+
if (!cached) {
190
+
return c.text('Site not found', 404);
191
+
}
192
+
193
+
return serveFromCache(domainInfo.did, rkey, path);
194
+
}
195
+
196
+
// Route 1: Custom domains - /*
197
+
console.log('[Custom Domain] Looking up', { hostname });
198
+
199
+
const customDomain = await getCustomDomain(hostname);
200
+
if (!customDomain) {
201
+
return c.text('Custom domain not found or not verified', 404);
202
+
}
203
+
204
+
const rkey = customDomain.rkey || 'self';
205
+
const cached = await ensureSiteCached(customDomain.did, rkey);
206
+
if (!cached) {
207
+
return c.text('Site not found', 404);
208
+
}
209
+
210
+
return serveFromCache(customDomain.did, rkey, path);
211
+
});
212
+
213
+
export default app;
+864
-298
public/editor/editor.tsx
+864
-298
public/editor/editor.tsx
···
1
-
import { useState } from 'react'
1
+
import { useState, useEffect } from 'react'
2
2
import { createRoot } from 'react-dom/client'
3
3
import { Button } from '@public/components/ui/button'
4
4
import {
···
25
25
DialogTitle,
26
26
DialogFooter
27
27
} from '@public/components/ui/dialog'
28
-
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
29
28
import {
30
29
Globe,
31
30
Upload,
32
-
Settings,
33
31
ExternalLink,
34
32
CheckCircle2,
35
33
XCircle,
36
-
AlertCircle
34
+
AlertCircle,
35
+
Loader2,
36
+
Trash2,
37
+
RefreshCw,
38
+
Settings
37
39
} from 'lucide-react'
40
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
38
41
39
42
import Layout from '@public/layouts'
40
43
41
-
// Mock user data - replace with actual auth
42
-
const mockUser = {
43
-
did: 'did:plc:abc123xyz',
44
-
handle: 'alice.bsky.social',
45
-
wispSubdomain: 'alice'
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
46
70
}
47
71
48
72
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)
81
+
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>('')
90
+
const [isSavingConfig, setIsSavingConfig] = useState(false)
91
+
92
+
// Upload state
93
+
const [siteName, setSiteName] = useState('')
94
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
95
+
const [isUploading, setIsUploading] = useState(false)
96
+
const [uploadProgress, setUploadProgress] = useState('')
97
+
98
+
// Custom domain modal state
99
+
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
49
100
const [customDomain, setCustomDomain] = useState('')
50
-
const [verificationStatus, setVerificationStatus] = useState<
51
-
'idle' | 'verifying' | 'success' | 'error'
52
-
>('idle')
53
-
const [selectedSite, setSelectedSite] = useState('')
101
+
const [isAddingDomain, setIsAddingDomain] = useState(false)
102
+
const [verificationStatus, setVerificationStatus] = useState<{
103
+
[id: string]: 'idle' | 'verifying' | 'success' | 'error'
104
+
}>({})
105
+
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
106
+
107
+
// Fetch user info on mount
108
+
useEffect(() => {
109
+
fetchUserInfo()
110
+
fetchSites()
111
+
fetchDomains()
112
+
}, [])
113
+
114
+
const fetchUserInfo = async () => {
115
+
try {
116
+
const response = await fetch('/api/user/info')
117
+
const data = await response.json()
118
+
setUserInfo(data)
119
+
} catch (err) {
120
+
console.error('Failed to fetch user info:', err)
121
+
} finally {
122
+
setLoading(false)
123
+
}
124
+
}
125
+
126
+
const fetchSites = async () => {
127
+
try {
128
+
const response = await fetch('/api/user/sites')
129
+
const data = await response.json()
130
+
setSites(data.sites || [])
131
+
} catch (err) {
132
+
console.error('Failed to fetch sites:', err)
133
+
} finally {
134
+
setSitesLoading(false)
135
+
}
136
+
}
137
+
138
+
const syncSites = async () => {
139
+
setIsSyncing(true)
140
+
try {
141
+
const response = await fetch('/api/user/sync', {
142
+
method: 'POST'
143
+
})
144
+
const data = await response.json()
145
+
if (data.success) {
146
+
console.log(`Synced ${data.synced} sites from PDS`)
147
+
// Refresh sites list
148
+
await fetchSites()
149
+
}
150
+
} catch (err) {
151
+
console.error('Failed to sync sites:', err)
152
+
alert('Failed to sync sites from PDS')
153
+
} finally {
154
+
setIsSyncing(false)
155
+
}
156
+
}
157
+
158
+
const fetchDomains = async () => {
159
+
try {
160
+
const response = await fetch('/api/user/domains')
161
+
const data = await response.json()
162
+
setWispDomain(data.wispDomain)
163
+
setCustomDomains(data.customDomains || [])
164
+
} catch (err) {
165
+
console.error('Failed to fetch domains:', err)
166
+
} finally {
167
+
setDomainsLoading(false)
168
+
}
169
+
}
170
+
171
+
const getSiteUrl = (site: Site) => {
172
+
// Check if this site is mapped to the wisp.place domain
173
+
if (wispDomain && wispDomain.rkey === site.rkey) {
174
+
return `https://${wispDomain.domain}`
175
+
}
176
+
177
+
// Check if this site is mapped to any custom domain
178
+
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
179
+
if (customDomain) {
180
+
return `https://${customDomain.domain}`
181
+
}
182
+
183
+
// Default fallback URL
184
+
if (!userInfo) return '#'
185
+
return `https://sites.wisp.place/${site.did}/${site.rkey}`
186
+
}
187
+
188
+
const getSiteDomainName = (site: Site) => {
189
+
if (wispDomain && wispDomain.rkey === site.rkey) {
190
+
return wispDomain.domain
191
+
}
192
+
193
+
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
194
+
if (customDomain) {
195
+
return customDomain.domain
196
+
}
197
+
198
+
return `sites.wisp.place/${site.did}/${site.rkey}`
199
+
}
200
+
201
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
202
+
if (e.target.files && e.target.files.length > 0) {
203
+
setSelectedFiles(e.target.files)
204
+
}
205
+
}
206
+
207
+
const handleUpload = async () => {
208
+
if (!siteName) {
209
+
alert('Please enter a site name')
210
+
return
211
+
}
212
+
213
+
setIsUploading(true)
214
+
setUploadProgress('Preparing files...')
215
+
216
+
try {
217
+
const formData = new FormData()
218
+
formData.append('siteName', siteName)
219
+
220
+
if (selectedFiles) {
221
+
for (let i = 0; i < selectedFiles.length; i++) {
222
+
formData.append('files', selectedFiles[i])
223
+
}
224
+
}
225
+
226
+
setUploadProgress('Uploading to AT Protocol...')
227
+
const response = await fetch('/wisp/upload-files', {
228
+
method: 'POST',
229
+
body: formData
230
+
})
231
+
232
+
const data = await response.json()
233
+
if (data.success) {
234
+
setUploadProgress('Upload complete!')
235
+
setSiteName('')
236
+
setSelectedFiles(null)
237
+
238
+
// Refresh sites list
239
+
await fetchSites()
240
+
241
+
// Reset form
242
+
setTimeout(() => {
243
+
setUploadProgress('')
244
+
setIsUploading(false)
245
+
}, 1500)
246
+
} else {
247
+
throw new Error(data.error || 'Upload failed')
248
+
}
249
+
} catch (err) {
250
+
console.error('Upload error:', err)
251
+
alert(
252
+
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
253
+
)
254
+
setIsUploading(false)
255
+
setUploadProgress('')
256
+
}
257
+
}
54
258
55
-
const [configureModalOpen, setConfigureModalOpen] = useState(false)
56
-
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
57
-
const [currentSite, setCurrentSite] = useState<{
58
-
id: string
59
-
name: string
60
-
domain: string | null
61
-
} | null>(null)
62
-
const [selectedDomain, setSelectedDomain] = useState<string>('')
259
+
const handleAddCustomDomain = async () => {
260
+
if (!customDomain) {
261
+
alert('Please enter a domain')
262
+
return
263
+
}
63
264
64
-
// Mock sites data
65
-
const [sites] = useState([
66
-
{
67
-
id: '1',
68
-
name: 'my-blog',
69
-
domain: 'alice.wisp.place',
70
-
status: 'active'
71
-
},
72
-
{ id: '2', name: 'portfolio', domain: null, status: 'active' },
73
-
{
74
-
id: '3',
75
-
name: 'docs-site',
76
-
domain: 'docs.example.com',
77
-
status: 'active'
265
+
setIsAddingDomain(true)
266
+
try {
267
+
const response = await fetch('/api/domain/custom/add', {
268
+
method: 'POST',
269
+
headers: { 'Content-Type': 'application/json' },
270
+
body: JSON.stringify({ domain: customDomain })
271
+
})
272
+
273
+
const data = await response.json()
274
+
if (data.success) {
275
+
setCustomDomain('')
276
+
setAddDomainModalOpen(false)
277
+
await fetchDomains()
278
+
279
+
// Automatically show DNS configuration for the newly added domain
280
+
setViewDomainDNS(data.id)
281
+
} else {
282
+
throw new Error(data.error || 'Failed to add domain')
283
+
}
284
+
} catch (err) {
285
+
console.error('Add domain error:', err)
286
+
alert(
287
+
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
288
+
)
289
+
} finally {
290
+
setIsAddingDomain(false)
78
291
}
79
-
])
292
+
}
293
+
294
+
const handleVerifyDomain = async (id: string) => {
295
+
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
80
296
81
-
const availableDomains = [
82
-
{ value: 'alice.wisp.place', label: 'alice.wisp.place', type: 'wisp' },
83
-
{
84
-
value: 'docs.example.com',
85
-
label: 'docs.example.com',
86
-
type: 'custom'
87
-
},
88
-
{ value: 'none', label: 'No domain (use default URL)', type: 'none' }
89
-
]
297
+
try {
298
+
const response = await fetch('/api/domain/custom/verify', {
299
+
method: 'POST',
300
+
headers: { 'Content-Type': 'application/json' },
301
+
body: JSON.stringify({ id })
302
+
})
90
303
91
-
const handleVerifyDNS = async () => {
92
-
setVerificationStatus('verifying')
93
-
// Simulate DNS verification
94
-
setTimeout(() => {
95
-
setVerificationStatus('success')
96
-
}, 2000)
304
+
const data = await response.json()
305
+
if (data.success && data.verified) {
306
+
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
307
+
await fetchDomains()
308
+
} else {
309
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
310
+
if (data.error) {
311
+
alert(`Verification failed: ${data.error}`)
312
+
}
313
+
}
314
+
} catch (err) {
315
+
console.error('Verify domain error:', err)
316
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
317
+
alert(
318
+
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
319
+
)
320
+
}
97
321
}
98
322
99
-
const handleConfigureSite = (site: {
100
-
id: string
101
-
name: string
102
-
domain: string | null
103
-
}) => {
104
-
setCurrentSite(site)
105
-
setSelectedDomain(site.domain || 'none')
106
-
setConfigureModalOpen(true)
323
+
const handleDeleteCustomDomain = async (id: string) => {
324
+
if (!confirm('Are you sure you want to remove this custom domain?')) {
325
+
return
326
+
}
327
+
328
+
try {
329
+
const response = await fetch(`/api/domain/custom/${id}`, {
330
+
method: 'DELETE'
331
+
})
332
+
333
+
const data = await response.json()
334
+
if (data.success) {
335
+
await fetchDomains()
336
+
} else {
337
+
throw new Error('Failed to delete domain')
338
+
}
339
+
} catch (err) {
340
+
console.error('Delete domain error:', err)
341
+
alert(
342
+
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
343
+
)
344
+
}
107
345
}
108
346
109
-
const handleSaveConfiguration = () => {
110
-
console.log(
111
-
'[v0] Saving configuration for site:',
112
-
currentSite?.name,
113
-
'with domain:',
114
-
selectedDomain
115
-
)
116
-
// TODO: Implement actual save logic
117
-
setConfigureModalOpen(false)
347
+
const handleConfigureSite = (site: Site) => {
348
+
setConfiguringSite(site)
349
+
350
+
// Determine current domain mapping
351
+
if (wispDomain && wispDomain.rkey === site.rkey) {
352
+
setSelectedDomain('wisp')
353
+
} else {
354
+
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
355
+
if (customDomain) {
356
+
setSelectedDomain(customDomain.id)
357
+
} else {
358
+
setSelectedDomain('none')
359
+
}
360
+
}
118
361
}
119
362
120
-
const getSiteUrl = (site: { name: string; domain: string | null }) => {
121
-
if (site.domain) {
122
-
return `https://${site.domain}`
363
+
const handleSaveSiteConfig = async () => {
364
+
if (!configuringSite) return
365
+
366
+
setIsSavingConfig(true)
367
+
try {
368
+
if (selectedDomain === 'wisp') {
369
+
// Map to wisp.place domain
370
+
const response = await fetch('/api/domain/wisp/map-site', {
371
+
method: 'POST',
372
+
headers: { 'Content-Type': 'application/json' },
373
+
body: JSON.stringify({ siteRkey: configuringSite.rkey })
374
+
})
375
+
const data = await response.json()
376
+
if (!data.success) throw new Error('Failed to map site')
377
+
} else if (selectedDomain === 'none') {
378
+
// Unmap from all domains
379
+
// Unmap wisp domain if this site was mapped to it
380
+
if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
381
+
await fetch('/api/domain/wisp/map-site', {
382
+
method: 'POST',
383
+
headers: { 'Content-Type': 'application/json' },
384
+
body: JSON.stringify({ siteRkey: null })
385
+
})
386
+
}
387
+
388
+
// Unmap from custom domains
389
+
const mappedCustom = customDomains.find(
390
+
(d) => d.rkey === configuringSite.rkey
391
+
)
392
+
if (mappedCustom) {
393
+
await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
394
+
method: 'POST',
395
+
headers: { 'Content-Type': 'application/json' },
396
+
body: JSON.stringify({ siteRkey: null })
397
+
})
398
+
}
399
+
} else {
400
+
// Map to a custom domain
401
+
const response = await fetch(
402
+
`/api/domain/custom/${selectedDomain}/map-site`,
403
+
{
404
+
method: 'POST',
405
+
headers: { 'Content-Type': 'application/json' },
406
+
body: JSON.stringify({ siteRkey: configuringSite.rkey })
407
+
}
408
+
)
409
+
const data = await response.json()
410
+
if (!data.success) throw new Error('Failed to map site')
411
+
}
412
+
413
+
// Refresh domains to get updated mappings
414
+
await fetchDomains()
415
+
setConfiguringSite(null)
416
+
} catch (err) {
417
+
console.error('Save config error:', err)
418
+
alert(
419
+
`Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}`
420
+
)
421
+
} finally {
422
+
setIsSavingConfig(false)
123
423
}
124
-
return `https://sites.wisp.place/${mockUser.did}/${site.name}`
424
+
}
425
+
426
+
if (loading) {
427
+
return (
428
+
<div className="w-full min-h-screen bg-background flex items-center justify-center">
429
+
<Loader2 className="w-8 h-8 animate-spin text-primary" />
430
+
</div>
431
+
)
125
432
}
126
433
127
434
return (
···
139
446
</div>
140
447
<div className="flex items-center gap-3">
141
448
<span className="text-sm text-muted-foreground">
142
-
{mockUser.handle}
449
+
{userInfo?.handle || 'Loading...'}
143
450
</span>
144
451
</div>
145
452
</div>
···
164
471
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
165
472
<Card>
166
473
<CardHeader>
167
-
<CardTitle>Your Sites</CardTitle>
168
-
<CardDescription>
169
-
View and manage all your deployed sites
170
-
</CardDescription>
474
+
<div className="flex items-center justify-between">
475
+
<div>
476
+
<CardTitle>Your Sites</CardTitle>
477
+
<CardDescription>
478
+
View and manage all your deployed sites
479
+
</CardDescription>
480
+
</div>
481
+
<Button
482
+
variant="outline"
483
+
size="sm"
484
+
onClick={syncSites}
485
+
disabled={isSyncing || sitesLoading}
486
+
>
487
+
<RefreshCw
488
+
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
489
+
/>
490
+
Sync from PDS
491
+
</Button>
492
+
</div>
171
493
</CardHeader>
172
494
<CardContent className="space-y-4">
173
-
{sites.map((site) => (
174
-
<div
175
-
key={site.id}
176
-
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
177
-
>
178
-
<div className="flex-1">
179
-
<div className="flex items-center gap-3 mb-2">
180
-
<h3 className="font-semibold text-lg">
181
-
{site.name}
182
-
</h3>
183
-
<Badge
184
-
variant="secondary"
185
-
className="text-xs"
495
+
{sitesLoading ? (
496
+
<div className="flex items-center justify-center py-8">
497
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
498
+
</div>
499
+
) : sites.length === 0 ? (
500
+
<div className="text-center py-8 text-muted-foreground">
501
+
<p>No sites yet. Upload your first site!</p>
502
+
</div>
503
+
) : (
504
+
sites.map((site) => (
505
+
<div
506
+
key={`${site.did}-${site.rkey}`}
507
+
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
508
+
>
509
+
<div className="flex-1">
510
+
<div className="flex items-center gap-3 mb-2">
511
+
<h3 className="font-semibold text-lg">
512
+
{site.display_name || site.rkey}
513
+
</h3>
514
+
<Badge
515
+
variant="secondary"
516
+
className="text-xs"
517
+
>
518
+
active
519
+
</Badge>
520
+
</div>
521
+
<a
522
+
href={getSiteUrl(site)}
523
+
target="_blank"
524
+
rel="noopener noreferrer"
525
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
186
526
>
187
-
{site.status}
188
-
</Badge>
527
+
{getSiteDomainName(site)}
528
+
<ExternalLink className="w-3 h-3" />
529
+
</a>
189
530
</div>
190
-
<a
191
-
href={getSiteUrl(site)}
192
-
target="_blank"
193
-
rel="noopener noreferrer"
194
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
531
+
<Button
532
+
variant="outline"
533
+
size="sm"
534
+
onClick={() => handleConfigureSite(site)}
195
535
>
196
-
{site.domain ||
197
-
`sites.wisp.place/${mockUser.did}/${site.name}`}
198
-
<ExternalLink className="w-3 h-3" />
199
-
</a>
536
+
<Settings className="w-4 h-4 mr-2" />
537
+
Configure
538
+
</Button>
200
539
</div>
201
-
<Button
202
-
variant="outline"
203
-
size="sm"
204
-
onClick={() =>
205
-
handleConfigureSite(site)
206
-
}
207
-
>
208
-
<Settings className="w-4 h-4 mr-2" />
209
-
Configure
210
-
</Button>
211
-
</div>
212
-
))}
540
+
))
541
+
)}
213
542
</CardContent>
214
543
</Card>
215
544
</TabsContent>
···
220
549
<CardHeader>
221
550
<CardTitle>wisp.place Subdomain</CardTitle>
222
551
<CardDescription>
223
-
Your free subdomain on the wisp.place
224
-
network
552
+
Your free subdomain on the wisp.place network
225
553
</CardDescription>
226
554
</CardHeader>
227
555
<CardContent>
228
-
<div className="flex items-center gap-2 p-4 bg-muted/50 rounded-lg">
229
-
<CheckCircle2 className="w-5 h-5 text-green-500" />
230
-
<span className="font-mono text-lg">
231
-
{mockUser.wispSubdomain}.wisp.place
232
-
</span>
233
-
</div>
234
-
<p className="text-sm text-muted-foreground mt-3">
235
-
Configure which site uses this domain in the
236
-
Sites tab
237
-
</p>
556
+
{domainsLoading ? (
557
+
<div className="flex items-center justify-center py-4">
558
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
559
+
</div>
560
+
) : wispDomain ? (
561
+
<>
562
+
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
563
+
<div className="flex items-center gap-2">
564
+
<CheckCircle2 className="w-5 h-5 text-green-500" />
565
+
<span className="font-mono text-lg">
566
+
{wispDomain.domain}
567
+
</span>
568
+
</div>
569
+
{wispDomain.rkey && (
570
+
<p className="text-xs text-muted-foreground ml-7">
571
+
→ Mapped to site: {wispDomain.rkey}
572
+
</p>
573
+
)}
574
+
</div>
575
+
<p className="text-sm text-muted-foreground mt-3">
576
+
{wispDomain.rkey
577
+
? 'This domain is mapped to a specific site'
578
+
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
579
+
</p>
580
+
</>
581
+
) : (
582
+
<div className="text-center py-4 text-muted-foreground">
583
+
<p>No wisp.place subdomain claimed yet.</p>
584
+
<p className="text-sm mt-1">
585
+
You should have claimed one during onboarding!
586
+
</p>
587
+
</div>
588
+
)}
238
589
</CardContent>
239
590
</Card>
240
591
···
253
604
Add Custom Domain
254
605
</Button>
255
606
256
-
<div className="space-y-2">
257
-
<div className="flex items-center justify-between p-3 border border-border rounded-lg">
258
-
<div className="flex items-center gap-2">
259
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
260
-
<span className="font-mono">
261
-
docs.example.com
262
-
</span>
263
-
</div>
264
-
<Badge variant="secondary">
265
-
Verified
266
-
</Badge>
607
+
{domainsLoading ? (
608
+
<div className="flex items-center justify-center py-4">
609
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
610
+
</div>
611
+
) : customDomains.length === 0 ? (
612
+
<div className="text-center py-4 text-muted-foreground text-sm">
613
+
No custom domains added yet
267
614
</div>
268
-
</div>
615
+
) : (
616
+
<div className="space-y-2">
617
+
{customDomains.map((domain) => (
618
+
<div
619
+
key={domain.id}
620
+
className="flex items-center justify-between p-3 border border-border rounded-lg"
621
+
>
622
+
<div className="flex flex-col gap-1 flex-1">
623
+
<div className="flex items-center gap-2">
624
+
{domain.verified ? (
625
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
626
+
) : (
627
+
<XCircle className="w-4 h-4 text-red-500" />
628
+
)}
629
+
<span className="font-mono">
630
+
{domain.domain}
631
+
</span>
632
+
</div>
633
+
{domain.rkey && domain.rkey !== 'self' && (
634
+
<p className="text-xs text-muted-foreground ml-6">
635
+
→ Mapped to site: {domain.rkey}
636
+
</p>
637
+
)}
638
+
</div>
639
+
<div className="flex items-center gap-2">
640
+
<Button
641
+
variant="outline"
642
+
size="sm"
643
+
onClick={() =>
644
+
setViewDomainDNS(domain.id)
645
+
}
646
+
>
647
+
View DNS
648
+
</Button>
649
+
{domain.verified ? (
650
+
<Badge variant="secondary">
651
+
Verified
652
+
</Badge>
653
+
) : (
654
+
<Button
655
+
variant="outline"
656
+
size="sm"
657
+
onClick={() =>
658
+
handleVerifyDomain(domain.id)
659
+
}
660
+
disabled={
661
+
verificationStatus[
662
+
domain.id
663
+
] === 'verifying'
664
+
}
665
+
>
666
+
{verificationStatus[
667
+
domain.id
668
+
] === 'verifying' ? (
669
+
<>
670
+
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
671
+
Verifying...
672
+
</>
673
+
) : (
674
+
'Verify DNS'
675
+
)}
676
+
</Button>
677
+
)}
678
+
<Button
679
+
variant="ghost"
680
+
size="sm"
681
+
onClick={() =>
682
+
handleDeleteCustomDomain(
683
+
domain.id
684
+
)
685
+
}
686
+
>
687
+
<Trash2 className="w-4 h-4" />
688
+
</Button>
689
+
</div>
690
+
</div>
691
+
))}
692
+
</div>
693
+
)}
269
694
</CardContent>
270
695
</Card>
271
696
</TabsContent>
···
276
701
<CardHeader>
277
702
<CardTitle>Upload Site</CardTitle>
278
703
<CardDescription>
279
-
Deploy a new site from a folder or Git
280
-
repository
704
+
Deploy a new site from a folder or Git repository
281
705
</CardDescription>
282
706
</CardHeader>
283
707
<CardContent className="space-y-6">
···
286
710
<Input
287
711
id="site-name"
288
712
placeholder="my-awesome-site"
713
+
value={siteName}
714
+
onChange={(e) => setSiteName(e.target.value)}
715
+
disabled={isUploading}
289
716
/>
290
717
</div>
291
718
···
297
724
Upload Folder
298
725
</h3>
299
726
<p className="text-sm text-muted-foreground mb-4">
300
-
Drag and drop or click to upload
301
-
your static site files
727
+
Drag and drop or click to upload your
728
+
static site files
302
729
</p>
303
-
<Button variant="outline">
304
-
Choose Folder
305
-
</Button>
730
+
<input
731
+
type="file"
732
+
id="file-upload"
733
+
multiple
734
+
onChange={handleFileSelect}
735
+
className="hidden"
736
+
{...(({ webkitdirectory: '', directory: '' } as any))}
737
+
disabled={isUploading}
738
+
/>
739
+
<label htmlFor="file-upload">
740
+
<Button
741
+
variant="outline"
742
+
type="button"
743
+
onClick={() =>
744
+
document
745
+
.getElementById('file-upload')
746
+
?.click()
747
+
}
748
+
disabled={isUploading}
749
+
>
750
+
Choose Folder
751
+
</Button>
752
+
</label>
753
+
{selectedFiles && selectedFiles.length > 0 && (
754
+
<p className="text-sm text-muted-foreground mt-3">
755
+
{selectedFiles.length} files selected
756
+
</p>
757
+
)}
306
758
</CardContent>
307
759
</Card>
308
760
309
-
<Card className="border-2 border-dashed hover:border-accent transition-colors">
761
+
<Card className="border-2 border-dashed opacity-50">
310
762
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
311
763
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
312
764
<h3 className="font-semibold mb-2">
313
765
Connect Git Repository
314
766
</h3>
315
767
<p className="text-sm text-muted-foreground mb-4">
316
-
Link your GitHub, GitLab, or any
317
-
Git repository
768
+
Link your GitHub, GitLab, or any Git
769
+
repository
318
770
</p>
319
-
<Button variant="outline">
320
-
Connect Git
321
-
</Button>
771
+
<Badge variant="secondary">Coming soon!</Badge>
322
772
</CardContent>
323
773
</Card>
324
774
</div>
775
+
776
+
{uploadProgress && (
777
+
<div className="p-4 bg-muted rounded-lg">
778
+
<div className="flex items-center gap-2">
779
+
<Loader2 className="w-4 h-4 animate-spin" />
780
+
<span className="text-sm">{uploadProgress}</span>
781
+
</div>
782
+
</div>
783
+
)}
784
+
785
+
<Button
786
+
onClick={handleUpload}
787
+
className="w-full"
788
+
disabled={!siteName || isUploading}
789
+
>
790
+
{isUploading ? (
791
+
<>
792
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
793
+
Uploading...
794
+
</>
795
+
) : (
796
+
<>
797
+
{selectedFiles && selectedFiles.length > 0
798
+
? 'Upload & Deploy'
799
+
: 'Create Empty Site'}
800
+
</>
801
+
)}
802
+
</Button>
325
803
</CardContent>
326
804
</Card>
327
805
</TabsContent>
328
806
</Tabs>
329
807
</div>
330
808
809
+
{/* Add Custom Domain Modal */}
810
+
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
811
+
<DialogContent className="sm:max-w-lg">
812
+
<DialogHeader>
813
+
<DialogTitle>Add Custom Domain</DialogTitle>
814
+
<DialogDescription>
815
+
Enter your domain name. After adding, you'll see the DNS
816
+
records to configure.
817
+
</DialogDescription>
818
+
</DialogHeader>
819
+
<div className="space-y-4 py-4">
820
+
<div className="space-y-2">
821
+
<Label htmlFor="new-domain">Domain Name</Label>
822
+
<Input
823
+
id="new-domain"
824
+
placeholder="example.com"
825
+
value={customDomain}
826
+
onChange={(e) => setCustomDomain(e.target.value)}
827
+
/>
828
+
<p className="text-xs text-muted-foreground">
829
+
After adding, click "View DNS" to see the records you
830
+
need to configure.
831
+
</p>
832
+
</div>
833
+
</div>
834
+
<DialogFooter className="flex-col sm:flex-row gap-2">
835
+
<Button
836
+
variant="outline"
837
+
onClick={() => {
838
+
setAddDomainModalOpen(false)
839
+
setCustomDomain('')
840
+
}}
841
+
className="w-full sm:w-auto"
842
+
disabled={isAddingDomain}
843
+
>
844
+
Cancel
845
+
</Button>
846
+
<Button
847
+
onClick={handleAddCustomDomain}
848
+
disabled={!customDomain || isAddingDomain}
849
+
className="w-full sm:w-auto"
850
+
>
851
+
{isAddingDomain ? (
852
+
<>
853
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
854
+
Adding...
855
+
</>
856
+
) : (
857
+
'Add Domain'
858
+
)}
859
+
</Button>
860
+
</DialogFooter>
861
+
</DialogContent>
862
+
</Dialog>
863
+
864
+
{/* Site Configuration Modal */}
331
865
<Dialog
332
-
open={configureModalOpen}
333
-
onOpenChange={setConfigureModalOpen}
866
+
open={configuringSite !== null}
867
+
onOpenChange={(open) => !open && setConfiguringSite(null)}
334
868
>
335
-
<DialogContent className="sm:max-w-md">
869
+
<DialogContent className="sm:max-w-lg">
336
870
<DialogHeader>
337
871
<DialogTitle>Configure Site Domain</DialogTitle>
338
872
<DialogDescription>
339
-
Choose which domain {currentSite?.name} should use
873
+
Choose which domain this site should use
340
874
</DialogDescription>
341
875
</DialogHeader>
342
-
<div className="space-y-4 py-4">
343
-
<RadioGroup
344
-
value={selectedDomain}
345
-
onValueChange={setSelectedDomain}
346
-
>
347
-
{availableDomains.map((domain) => (
348
-
<div
349
-
key={domain.value}
350
-
className="flex items-center space-x-2"
351
-
>
352
-
<RadioGroupItem
353
-
value={domain.value}
354
-
id={domain.value}
355
-
/>
356
-
<Label
357
-
htmlFor={domain.value}
358
-
className="flex-1 cursor-pointer"
359
-
>
360
-
<div className="flex items-center justify-between">
361
-
<span className="font-mono text-sm">
362
-
{domain.label}
363
-
</span>
364
-
{domain.type === 'wisp' && (
365
-
<Badge
366
-
variant="secondary"
367
-
className="text-xs"
368
-
>
876
+
{configuringSite && (
877
+
<div className="space-y-4 py-4">
878
+
<div className="p-3 bg-muted/30 rounded-lg">
879
+
<p className="text-sm font-medium mb-1">Site:</p>
880
+
<p className="font-mono text-sm">
881
+
{configuringSite.display_name ||
882
+
configuringSite.rkey}
883
+
</p>
884
+
</div>
885
+
886
+
<RadioGroup
887
+
value={selectedDomain}
888
+
onValueChange={setSelectedDomain}
889
+
>
890
+
{wispDomain && (
891
+
<div className="flex items-center space-x-2">
892
+
<RadioGroupItem value="wisp" id="wisp" />
893
+
<Label
894
+
htmlFor="wisp"
895
+
className="flex-1 cursor-pointer"
896
+
>
897
+
<div className="flex items-center justify-between">
898
+
<span className="font-mono text-sm">
899
+
{wispDomain.domain}
900
+
</span>
901
+
<Badge variant="secondary" className="text-xs ml-2">
369
902
Free
370
903
</Badge>
371
-
)}
372
-
{domain.type === 'custom' && (
373
-
<Badge
374
-
variant="outline"
375
-
className="text-xs"
376
-
>
377
-
Custom
378
-
</Badge>
379
-
)}
904
+
</div>
905
+
</Label>
906
+
</div>
907
+
)}
908
+
909
+
{customDomains
910
+
.filter((d) => d.verified)
911
+
.map((domain) => (
912
+
<div
913
+
key={domain.id}
914
+
className="flex items-center space-x-2"
915
+
>
916
+
<RadioGroupItem
917
+
value={domain.id}
918
+
id={domain.id}
919
+
/>
920
+
<Label
921
+
htmlFor={domain.id}
922
+
className="flex-1 cursor-pointer"
923
+
>
924
+
<div className="flex items-center justify-between">
925
+
<span className="font-mono text-sm">
926
+
{domain.domain}
927
+
</span>
928
+
<Badge
929
+
variant="outline"
930
+
className="text-xs ml-2"
931
+
>
932
+
Custom
933
+
</Badge>
934
+
</div>
935
+
</Label>
936
+
</div>
937
+
))}
938
+
939
+
<div className="flex items-center space-x-2">
940
+
<RadioGroupItem value="none" id="none" />
941
+
<Label htmlFor="none" className="flex-1 cursor-pointer">
942
+
<div className="flex flex-col">
943
+
<span className="text-sm">Default URL</span>
944
+
<span className="text-xs text-muted-foreground font-mono break-all">
945
+
sites.wisp.place/{configuringSite.did}/
946
+
{configuringSite.rkey}
947
+
</span>
380
948
</div>
381
949
</Label>
382
950
</div>
383
-
))}
384
-
</RadioGroup>
385
-
</div>
951
+
</RadioGroup>
952
+
</div>
953
+
)}
386
954
<DialogFooter>
387
955
<Button
388
956
variant="outline"
389
-
onClick={() => setConfigureModalOpen(false)}
957
+
onClick={() => setConfiguringSite(null)}
958
+
disabled={isSavingConfig}
390
959
>
391
960
Cancel
392
961
</Button>
393
-
<Button onClick={handleSaveConfiguration}>
394
-
Save Configuration
962
+
<Button
963
+
onClick={handleSaveSiteConfig}
964
+
disabled={isSavingConfig}
965
+
>
966
+
{isSavingConfig ? (
967
+
<>
968
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
969
+
Saving...
970
+
</>
971
+
) : (
972
+
'Save'
973
+
)}
395
974
</Button>
396
975
</DialogFooter>
397
976
</DialogContent>
398
977
</Dialog>
399
978
979
+
{/* View DNS Records Modal */}
400
980
<Dialog
401
-
open={addDomainModalOpen}
402
-
onOpenChange={setAddDomainModalOpen}
981
+
open={viewDomainDNS !== null}
982
+
onOpenChange={(open) => !open && setViewDomainDNS(null)}
403
983
>
404
984
<DialogContent className="sm:max-w-lg">
405
985
<DialogHeader>
406
-
<DialogTitle>Add Custom Domain</DialogTitle>
986
+
<DialogTitle>DNS Configuration</DialogTitle>
407
987
<DialogDescription>
408
-
Configure DNS records to verify your domain
409
-
ownership
988
+
Add these DNS records to your domain provider
410
989
</DialogDescription>
411
990
</DialogHeader>
412
-
<div className="space-y-4 py-4">
413
-
<div className="space-y-2">
414
-
<Label htmlFor="new-domain">Domain Name</Label>
415
-
<Input
416
-
id="new-domain"
417
-
placeholder="example.com"
418
-
value={customDomain}
419
-
onChange={(e) =>
420
-
setCustomDomain(e.target.value)
421
-
}
422
-
/>
423
-
</div>
424
-
425
-
{customDomain && (
426
-
<div className="space-y-4 p-4 bg-muted/30 rounded-lg border border-border">
427
-
<div>
428
-
<h4 className="font-semibold mb-2 flex items-center gap-2">
429
-
<AlertCircle className="w-4 h-4 text-accent" />
430
-
DNS Configuration Required
431
-
</h4>
432
-
<p className="text-sm text-muted-foreground mb-4">
433
-
Add these DNS records to your domain
434
-
provider:
435
-
</p>
436
-
</div>
991
+
{viewDomainDNS && userInfo && (
992
+
<>
993
+
{(() => {
994
+
const domain = customDomains.find(
995
+
(d) => d.id === viewDomainDNS
996
+
)
997
+
if (!domain) return null
437
998
438
-
<div className="space-y-3">
439
-
<div className="p-3 bg-background rounded border border-border">
440
-
<div className="flex justify-between items-start mb-1">
441
-
<span className="text-xs font-semibold text-muted-foreground">
442
-
TXT Record
443
-
</span>
999
+
return (
1000
+
<div className="space-y-4 py-4">
1001
+
<div className="p-3 bg-muted/30 rounded-lg">
1002
+
<p className="text-sm font-medium mb-1">
1003
+
Domain:
1004
+
</p>
1005
+
<p className="font-mono text-sm">
1006
+
{domain.domain}
1007
+
</p>
444
1008
</div>
445
-
<div className="font-mono text-sm space-y-1">
446
-
<div>
447
-
<span className="text-muted-foreground">
448
-
Name:
449
-
</span>{' '}
450
-
_wisp
1009
+
1010
+
<div className="space-y-3">
1011
+
<div className="p-3 bg-background rounded border border-border">
1012
+
<div className="flex justify-between items-start mb-2">
1013
+
<span className="text-xs font-semibold text-muted-foreground">
1014
+
TXT Record (Verification)
1015
+
</span>
1016
+
</div>
1017
+
<div className="font-mono text-xs space-y-2">
1018
+
<div>
1019
+
<span className="text-muted-foreground">
1020
+
Name:
1021
+
</span>{' '}
1022
+
<span className="select-all">
1023
+
_wisp.{domain.domain}
1024
+
</span>
1025
+
</div>
1026
+
<div>
1027
+
<span className="text-muted-foreground">
1028
+
Value:
1029
+
</span>{' '}
1030
+
<span className="select-all break-all">
1031
+
{userInfo.did}
1032
+
</span>
1033
+
</div>
1034
+
</div>
451
1035
</div>
452
-
<div>
453
-
<span className="text-muted-foreground">
454
-
Value:
455
-
</span>{' '}
456
-
{mockUser.did}
1036
+
1037
+
<div className="p-3 bg-background rounded border border-border">
1038
+
<div className="flex justify-between items-start mb-2">
1039
+
<span className="text-xs font-semibold text-muted-foreground">
1040
+
CNAME Record (Pointing)
1041
+
</span>
1042
+
</div>
1043
+
<div className="font-mono text-xs space-y-2">
1044
+
<div>
1045
+
<span className="text-muted-foreground">
1046
+
Name:
1047
+
</span>{' '}
1048
+
<span className="select-all">
1049
+
{domain.domain}
1050
+
</span>
1051
+
</div>
1052
+
<div>
1053
+
<span className="text-muted-foreground">
1054
+
Value:
1055
+
</span>{' '}
1056
+
<span className="select-all">
1057
+
{domain.id}.dns.wisp.place
1058
+
</span>
1059
+
</div>
1060
+
</div>
1061
+
<p className="text-xs text-muted-foreground mt-2">
1062
+
Some DNS providers may require you to use @ or leave it blank for the root domain
1063
+
</p>
457
1064
</div>
458
1065
</div>
459
-
</div>
460
1066
461
-
<div className="p-3 bg-background rounded border border-border">
462
-
<div className="flex justify-between items-start mb-1">
463
-
<span className="text-xs font-semibold text-muted-foreground">
464
-
CNAME Record
465
-
</span>
466
-
</div>
467
-
<div className="font-mono text-sm space-y-1">
468
-
<div>
469
-
<span className="text-muted-foreground">
470
-
Name:
471
-
</span>{' '}
472
-
@ or {customDomain}
473
-
</div>
474
-
<div>
475
-
<span className="text-muted-foreground">
476
-
Value:
477
-
</span>{' '}
478
-
abc123.dns.wisp.place
479
-
</div>
1067
+
<div className="p-3 bg-muted/30 rounded-lg">
1068
+
<p className="text-xs text-muted-foreground">
1069
+
💡 After configuring DNS, click "Verify DNS"
1070
+
to check if everything is set up correctly.
1071
+
DNS changes can take a few minutes to
1072
+
propagate.
1073
+
</p>
480
1074
</div>
481
1075
</div>
482
-
</div>
483
-
</div>
484
-
)}
485
-
</div>
486
-
<DialogFooter className="flex-col sm:flex-row gap-2">
1076
+
)
1077
+
})()}
1078
+
</>
1079
+
)}
1080
+
<DialogFooter>
487
1081
<Button
488
1082
variant="outline"
489
-
onClick={() => {
490
-
setAddDomainModalOpen(false)
491
-
setCustomDomain('')
492
-
setVerificationStatus('idle')
493
-
}}
494
-
className="w-full sm:w-auto"
495
-
>
496
-
Cancel
497
-
</Button>
498
-
<Button
499
-
onClick={handleVerifyDNS}
500
-
disabled={
501
-
!customDomain ||
502
-
verificationStatus === 'verifying'
503
-
}
1083
+
onClick={() => setViewDomainDNS(null)}
504
1084
className="w-full sm:w-auto"
505
1085
>
506
-
{verificationStatus === 'verifying' ? (
507
-
<>Verifying DNS...</>
508
-
) : verificationStatus === 'success' ? (
509
-
<>
510
-
<CheckCircle2 className="w-4 h-4 mr-2" />
511
-
Verified
512
-
</>
513
-
) : verificationStatus === 'error' ? (
514
-
<>
515
-
<XCircle className="w-4 h-4 mr-2" />
516
-
Verification Failed
517
-
</>
518
-
) : (
519
-
<>Verify DNS Records</>
520
-
)}
1086
+
Close
521
1087
</Button>
522
1088
</DialogFooter>
523
1089
</DialogContent>
+4
-1
public/lib/api.ts
+4
-1
public/lib/api.ts
···
2
2
3
3
import type { app } from '@server'
4
4
5
-
export const api = treaty<typeof app>('localhost:3000')
5
+
// Use the current host instead of hardcoded localhost
6
+
const apiHost = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8000'
7
+
8
+
export const api = treaty<typeof app>(apiHost)
+12
public/onboarding/index.html
+12
public/onboarding/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>Get Started - wisp.place</title>
7
+
</head>
8
+
<body>
9
+
<div id="elysia"></div>
10
+
<script type="module" src="./onboarding.tsx"></script>
11
+
</body>
12
+
</html>
+412
public/onboarding/onboarding.tsx
+412
public/onboarding/onboarding.tsx
···
1
+
import { useState, useEffect } from 'react'
2
+
import { createRoot } from 'react-dom/client'
3
+
import { Button } from '@public/components/ui/button'
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 { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react'
14
+
import Layout from '@public/layouts'
15
+
16
+
type OnboardingStep = 'domain' | 'upload' | 'complete'
17
+
18
+
function Onboarding() {
19
+
const [step, setStep] = useState<OnboardingStep>('domain')
20
+
const [handle, setHandle] = useState('')
21
+
const [isCheckingAvailability, setIsCheckingAvailability] = useState(false)
22
+
const [isAvailable, setIsAvailable] = useState<boolean | null>(null)
23
+
const [domain, setDomain] = useState('')
24
+
const [isClaimingDomain, setIsClaimingDomain] = useState(false)
25
+
const [claimedDomain, setClaimedDomain] = useState('')
26
+
27
+
const [siteName, setSiteName] = useState('')
28
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
29
+
const [isUploading, setIsUploading] = useState(false)
30
+
const [uploadProgress, setUploadProgress] = useState('')
31
+
32
+
// Check domain availability as user types
33
+
useEffect(() => {
34
+
if (!handle || handle.length < 3) {
35
+
setIsAvailable(null)
36
+
setDomain('')
37
+
return
38
+
}
39
+
40
+
const timeoutId = setTimeout(async () => {
41
+
setIsCheckingAvailability(true)
42
+
try {
43
+
const response = await fetch(
44
+
`/api/domain/check?handle=${encodeURIComponent(handle)}`
45
+
)
46
+
const data = await response.json()
47
+
setIsAvailable(data.available)
48
+
setDomain(data.domain || '')
49
+
} catch (err) {
50
+
console.error('Error checking availability:', err)
51
+
setIsAvailable(false)
52
+
} finally {
53
+
setIsCheckingAvailability(false)
54
+
}
55
+
}, 500)
56
+
57
+
return () => clearTimeout(timeoutId)
58
+
}, [handle])
59
+
60
+
const handleClaimDomain = async () => {
61
+
if (!handle || !isAvailable) return
62
+
63
+
setIsClaimingDomain(true)
64
+
try {
65
+
const response = await fetch('/api/domain/claim', {
66
+
method: 'POST',
67
+
headers: { 'Content-Type': 'application/json' },
68
+
body: JSON.stringify({ handle })
69
+
})
70
+
71
+
const data = await response.json()
72
+
if (data.success) {
73
+
setClaimedDomain(data.domain)
74
+
setStep('upload')
75
+
} else {
76
+
alert('Failed to claim domain. Please try again.')
77
+
}
78
+
} catch (err) {
79
+
console.error('Error claiming domain:', err)
80
+
alert('Failed to claim domain. Please try again.')
81
+
} finally {
82
+
setIsClaimingDomain(false)
83
+
}
84
+
}
85
+
86
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
87
+
if (e.target.files && e.target.files.length > 0) {
88
+
setSelectedFiles(e.target.files)
89
+
}
90
+
}
91
+
92
+
const handleUpload = async () => {
93
+
if (!siteName) {
94
+
alert('Please enter a site name')
95
+
return
96
+
}
97
+
98
+
setIsUploading(true)
99
+
setUploadProgress('Preparing files...')
100
+
101
+
try {
102
+
const formData = new FormData()
103
+
formData.append('siteName', siteName)
104
+
105
+
if (selectedFiles) {
106
+
for (let i = 0; i < selectedFiles.length; i++) {
107
+
formData.append('files', selectedFiles[i])
108
+
}
109
+
}
110
+
111
+
setUploadProgress('Uploading to AT Protocol...')
112
+
const response = await fetch('/wisp/upload-files', {
113
+
method: 'POST',
114
+
body: formData
115
+
})
116
+
117
+
const data = await response.json()
118
+
if (data.success) {
119
+
setUploadProgress('Upload complete!')
120
+
// Redirect to the claimed domain
121
+
setTimeout(() => {
122
+
window.location.href = `https://${claimedDomain}`
123
+
}, 1500)
124
+
} else {
125
+
throw new Error(data.error || 'Upload failed')
126
+
}
127
+
} catch (err) {
128
+
console.error('Upload error:', err)
129
+
alert(
130
+
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
131
+
)
132
+
setIsUploading(false)
133
+
setUploadProgress('')
134
+
}
135
+
}
136
+
137
+
const handleSkipUpload = () => {
138
+
// Redirect to editor without uploading
139
+
window.location.href = '/editor'
140
+
}
141
+
142
+
return (
143
+
<div className="w-full min-h-screen bg-background">
144
+
{/* Header */}
145
+
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
146
+
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
147
+
<div className="flex items-center gap-2">
148
+
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
149
+
<Globe className="w-5 h-5 text-primary-foreground" />
150
+
</div>
151
+
<span className="text-xl font-semibold text-foreground">
152
+
wisp.place
153
+
</span>
154
+
</div>
155
+
</div>
156
+
</header>
157
+
158
+
<div className="container mx-auto px-4 py-12 max-w-2xl">
159
+
{/* Progress indicator */}
160
+
<div className="mb-8">
161
+
<div className="flex items-center justify-center gap-2 mb-4">
162
+
<div
163
+
className={`w-8 h-8 rounded-full flex items-center justify-center ${
164
+
step === 'domain'
165
+
? 'bg-primary text-primary-foreground'
166
+
: 'bg-green-500 text-white'
167
+
}`}
168
+
>
169
+
{step === 'domain' ? (
170
+
'1'
171
+
) : (
172
+
<CheckCircle2 className="w-5 h-5" />
173
+
)}
174
+
</div>
175
+
<div className="w-16 h-0.5 bg-border"></div>
176
+
<div
177
+
className={`w-8 h-8 rounded-full flex items-center justify-center ${
178
+
step === 'upload'
179
+
? 'bg-primary text-primary-foreground'
180
+
: step === 'domain'
181
+
? 'bg-muted text-muted-foreground'
182
+
: 'bg-green-500 text-white'
183
+
}`}
184
+
>
185
+
{step === 'complete' ? (
186
+
<CheckCircle2 className="w-5 h-5" />
187
+
) : (
188
+
'2'
189
+
)}
190
+
</div>
191
+
</div>
192
+
<div className="text-center">
193
+
<h1 className="text-2xl font-bold mb-2">
194
+
{step === 'domain' && 'Claim Your Free Domain'}
195
+
{step === 'upload' && 'Deploy Your First Site'}
196
+
{step === 'complete' && 'All Set!'}
197
+
</h1>
198
+
<p className="text-muted-foreground">
199
+
{step === 'domain' &&
200
+
'Choose a subdomain on wisp.place'}
201
+
{step === 'upload' &&
202
+
'Upload your site or start with an empty one'}
203
+
{step === 'complete' && 'Redirecting to your site...'}
204
+
</p>
205
+
</div>
206
+
</div>
207
+
208
+
{/* Domain registration step */}
209
+
{step === 'domain' && (
210
+
<Card>
211
+
<CardHeader>
212
+
<CardTitle>Choose Your Domain</CardTitle>
213
+
<CardDescription>
214
+
Pick a unique handle for your free *.wisp.place
215
+
subdomain
216
+
</CardDescription>
217
+
</CardHeader>
218
+
<CardContent className="space-y-4">
219
+
<div className="space-y-2">
220
+
<Label htmlFor="handle">Your Handle</Label>
221
+
<div className="flex gap-2">
222
+
<div className="relative flex-1">
223
+
<Input
224
+
id="handle"
225
+
placeholder="my-awesome-site"
226
+
value={handle}
227
+
onChange={(e) =>
228
+
setHandle(
229
+
e.target.value
230
+
.toLowerCase()
231
+
.replace(/[^a-z0-9-]/g, '')
232
+
)
233
+
}
234
+
className="pr-10"
235
+
/>
236
+
{isCheckingAvailability && (
237
+
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
238
+
)}
239
+
{!isCheckingAvailability &&
240
+
isAvailable !== null && (
241
+
<div
242
+
className={`absolute right-3 top-1/2 -translate-y-1/2 ${
243
+
isAvailable
244
+
? 'text-green-500'
245
+
: 'text-red-500'
246
+
}`}
247
+
>
248
+
{isAvailable ? '✓' : '✗'}
249
+
</div>
250
+
)}
251
+
</div>
252
+
</div>
253
+
{domain && (
254
+
<p className="text-sm text-muted-foreground">
255
+
Your domain will be:{' '}
256
+
<span className="font-mono">{domain}</span>
257
+
</p>
258
+
)}
259
+
{isAvailable === false && handle.length >= 3 && (
260
+
<p className="text-sm text-red-500">
261
+
This handle is not available or invalid
262
+
</p>
263
+
)}
264
+
</div>
265
+
266
+
<Button
267
+
onClick={handleClaimDomain}
268
+
disabled={
269
+
!isAvailable ||
270
+
isClaimingDomain ||
271
+
isCheckingAvailability
272
+
}
273
+
className="w-full"
274
+
>
275
+
{isClaimingDomain ? (
276
+
<>
277
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
278
+
Claiming Domain...
279
+
</>
280
+
) : (
281
+
<>Claim Domain</>
282
+
)}
283
+
</Button>
284
+
</CardContent>
285
+
</Card>
286
+
)}
287
+
288
+
{/* Upload step */}
289
+
{step === 'upload' && (
290
+
<Card>
291
+
<CardHeader>
292
+
<CardTitle>Deploy Your Site</CardTitle>
293
+
<CardDescription>
294
+
Upload your static site files or start with an empty
295
+
site (you can upload later)
296
+
</CardDescription>
297
+
</CardHeader>
298
+
<CardContent className="space-y-6">
299
+
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
300
+
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
301
+
<CheckCircle2 className="w-4 h-4" />
302
+
<span className="font-medium">
303
+
Domain claimed: {claimedDomain}
304
+
</span>
305
+
</div>
306
+
</div>
307
+
308
+
<div className="space-y-2">
309
+
<Label htmlFor="site-name">Site Name</Label>
310
+
<Input
311
+
id="site-name"
312
+
placeholder="my-site"
313
+
value={siteName}
314
+
onChange={(e) => setSiteName(e.target.value)}
315
+
/>
316
+
<p className="text-xs text-muted-foreground">
317
+
A unique identifier for this site in your account
318
+
</p>
319
+
</div>
320
+
321
+
<div className="space-y-2">
322
+
<Label>Upload Files (Optional)</Label>
323
+
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-accent transition-colors">
324
+
<Upload className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
325
+
<input
326
+
type="file"
327
+
id="file-upload"
328
+
multiple
329
+
onChange={handleFileSelect}
330
+
className="hidden"
331
+
{...(({ webkitdirectory: '', directory: '' } as any))}
332
+
/>
333
+
<label
334
+
htmlFor="file-upload"
335
+
className="cursor-pointer"
336
+
>
337
+
<Button
338
+
variant="outline"
339
+
type="button"
340
+
onClick={() =>
341
+
document
342
+
.getElementById('file-upload')
343
+
?.click()
344
+
}
345
+
>
346
+
Choose Folder
347
+
</Button>
348
+
</label>
349
+
{selectedFiles && selectedFiles.length > 0 && (
350
+
<p className="text-sm text-muted-foreground mt-3">
351
+
{selectedFiles.length} files selected
352
+
</p>
353
+
)}
354
+
</div>
355
+
<p className="text-xs text-muted-foreground">
356
+
Supported: HTML, CSS, JS, images, fonts, and more
357
+
</p>
358
+
</div>
359
+
360
+
{uploadProgress && (
361
+
<div className="p-4 bg-muted rounded-lg">
362
+
<div className="flex items-center gap-2">
363
+
<Loader2 className="w-4 h-4 animate-spin" />
364
+
<span className="text-sm">
365
+
{uploadProgress}
366
+
</span>
367
+
</div>
368
+
</div>
369
+
)}
370
+
371
+
<div className="flex gap-3">
372
+
<Button
373
+
onClick={handleSkipUpload}
374
+
variant="outline"
375
+
className="flex-1"
376
+
disabled={isUploading}
377
+
>
378
+
Skip for Now
379
+
</Button>
380
+
<Button
381
+
onClick={handleUpload}
382
+
className="flex-1"
383
+
disabled={!siteName || isUploading}
384
+
>
385
+
{isUploading ? (
386
+
<>
387
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
388
+
Uploading...
389
+
</>
390
+
) : (
391
+
<>
392
+
{selectedFiles && selectedFiles.length > 0
393
+
? 'Upload & Deploy'
394
+
: 'Create Empty Site'}
395
+
</>
396
+
)}
397
+
</Button>
398
+
</div>
399
+
</CardContent>
400
+
</Card>
401
+
)}
402
+
</div>
403
+
</div>
404
+
)
405
+
}
406
+
407
+
const root = createRoot(document.getElementById('elysia')!)
408
+
root.render(
409
+
<Layout>
410
+
<Onboarding />
411
+
</Layout>
412
+
)
+2
src/index.ts
+2
src/index.ts
···
13
13
import { authRoutes } from './routes/auth'
14
14
import { wispRoutes } from './routes/wisp'
15
15
import { domainRoutes } from './routes/domain'
16
+
import { userRoutes } from './routes/user'
16
17
17
18
const config: Config = {
18
19
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
35
36
.use(authRoutes(client))
36
37
.use(wispRoutes(client))
37
38
.use(domainRoutes(client))
39
+
.use(userRoutes(client))
38
40
.get('/client-metadata.json', (c) => {
39
41
return createClientMetadata(config)
40
42
})
+35
-9
src/lib/db.ts
+35
-9
src/lib/db.ts
···
39
39
CREATE TABLE IF NOT EXISTS domains (
40
40
domain TEXT PRIMARY KEY,
41
41
did TEXT UNIQUE NOT NULL,
42
+
rkey TEXT,
42
43
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
43
44
)
44
45
`;
46
+
47
+
// Add rkey column if it doesn't exist (for existing databases)
48
+
try {
49
+
await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`;
50
+
} catch (err) {
51
+
// Column might already exist, ignore
52
+
}
45
53
46
54
// Custom domains table for BYOD (bring your own domain)
47
55
await db`
···
94
102
return rows[0]?.domain ?? null;
95
103
};
96
104
105
+
export const getWispDomainInfo = async (did: string) => {
106
+
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
107
+
return rows[0] ?? null;
108
+
};
109
+
97
110
export const getDidByDomain = async (domain: string): Promise<string | null> => {
98
111
const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
99
112
return rows[0]?.did ?? null;
···
142
155
}
143
156
};
144
157
158
+
export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
159
+
await db`
160
+
UPDATE domains
161
+
SET rkey = ${siteRkey}
162
+
WHERE did = ${did}
163
+
`;
164
+
};
165
+
166
+
export const getWispDomainSite = async (did: string): Promise<string | null> => {
167
+
const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
168
+
return rows[0]?.rkey ?? null;
169
+
};
170
+
145
171
const stateStore = {
146
172
async set(key: string, data: any) {
147
173
console.debug('[stateStore] set', key)
···
283
309
return rows[0] ?? null;
284
310
};
285
311
286
-
export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => {
312
+
export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => {
287
313
const domainLower = domain.toLowerCase();
288
314
try {
289
315
await db`
290
-
INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at)
291
-
VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW()))
316
+
INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at)
317
+
VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW()))
292
318
`;
293
319
return { success: true, hash };
294
320
} catch (err) {
···
297
323
}
298
324
};
299
325
300
-
export const updateCustomDomainSite = async (id: string, siteName: string) => {
326
+
export const updateCustomDomainRkey = async (id: string, rkey: string) => {
301
327
const rows = await db`
302
328
UPDATE custom_domains
303
-
SET site_name = ${siteName}
329
+
SET rkey = ${rkey}
304
330
WHERE id = ${id}
305
331
RETURNING *
306
332
`;
···
326
352
return rows;
327
353
};
328
354
329
-
export const upsertSite = async (did: string, siteName: string, displayName?: string) => {
355
+
export const upsertSite = async (did: string, rkey: string, displayName?: string) => {
330
356
try {
331
357
await db`
332
-
INSERT INTO sites (did, site_name, display_name, created_at, updated_at)
333
-
VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
334
-
ON CONFLICT (did, site_name)
358
+
INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
359
+
VALUES (${did}, ${rkey}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
360
+
ON CONFLICT (did, rkey)
335
361
DO UPDATE SET
336
362
display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
337
363
updated_at = EXTRACT(EPOCH FROM NOW())
+156
src/lib/dns-verify.ts
+156
src/lib/dns-verify.ts
···
1
+
import { promises as dns } from 'dns'
2
+
3
+
/**
4
+
* Result of a domain verification process
5
+
*/
6
+
export interface VerificationResult {
7
+
/** Whether the verification was successful */
8
+
verified: boolean
9
+
/** Error message if verification failed */
10
+
error?: string
11
+
/** DNS records found during verification */
12
+
found?: {
13
+
/** TXT records found (used for domain verification) */
14
+
txt?: string[]
15
+
/** CNAME record found (used for domain pointing) */
16
+
cname?: string
17
+
}
18
+
}
19
+
20
+
/**
21
+
* Verify domain ownership via TXT record at _wisp.{domain}
22
+
* Expected format: did:plc:xxx or did:web:xxx
23
+
*/
24
+
export const verifyDomainOwnership = async (
25
+
domain: string,
26
+
expectedDid: string
27
+
): Promise<VerificationResult> => {
28
+
try {
29
+
const txtDomain = `_wisp.${domain}`
30
+
31
+
console.log(`[DNS Verify] Checking TXT record for ${txtDomain}`)
32
+
console.log(`[DNS Verify] Expected DID: ${expectedDid}`)
33
+
34
+
// Query TXT records
35
+
const records = await dns.resolveTxt(txtDomain)
36
+
37
+
// Log what we found
38
+
const foundTxtValues = records.map((record) => record.join(''))
39
+
console.log(`[DNS Verify] Found TXT records:`, foundTxtValues)
40
+
41
+
// TXT records come as arrays of strings (for multi-part records)
42
+
// We need to join them and check if any match the expected DID
43
+
for (const record of records) {
44
+
const txtValue = record.join('')
45
+
if (txtValue === expectedDid) {
46
+
console.log(`[DNS Verify] ✓ TXT record matches!`)
47
+
return { verified: true, found: { txt: foundTxtValues } }
48
+
}
49
+
}
50
+
51
+
console.log(`[DNS Verify] ✗ TXT record does not match`)
52
+
return {
53
+
verified: false,
54
+
error: `TXT record at ${txtDomain} does not match expected DID. Expected: ${expectedDid}`,
55
+
found: { txt: foundTxtValues }
56
+
}
57
+
} catch (err: any) {
58
+
console.log(`[DNS Verify] ✗ TXT lookup error:`, err.message)
59
+
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
60
+
return {
61
+
verified: false,
62
+
error: `No TXT record found at _wisp.${domain}`,
63
+
found: { txt: [] }
64
+
}
65
+
}
66
+
return {
67
+
verified: false,
68
+
error: `DNS lookup failed: ${err.message}`,
69
+
found: { txt: [] }
70
+
}
71
+
}
72
+
}
73
+
74
+
/**
75
+
* Verify CNAME record points to the expected hash target
76
+
* For custom domains, we expect: domain CNAME -> {hash}.dns.wisp.place
77
+
*/
78
+
export const verifyCNAME = async (
79
+
domain: string,
80
+
expectedHash: string
81
+
): Promise<VerificationResult> => {
82
+
try {
83
+
console.log(`[DNS Verify] Checking CNAME record for ${domain}`)
84
+
const expectedTarget = `${expectedHash}.dns.wisp.place`
85
+
console.log(`[DNS Verify] Expected CNAME: ${expectedTarget}`)
86
+
87
+
// Resolve CNAME for the domain
88
+
const cname = await dns.resolveCname(domain)
89
+
90
+
// Log what we found
91
+
const foundCname =
92
+
cname.length > 0
93
+
? cname[0]?.toLowerCase().replace(/\.$/, '')
94
+
: null
95
+
console.log(`[DNS Verify] Found CNAME:`, foundCname || 'none')
96
+
97
+
if (cname.length === 0 || !foundCname) {
98
+
console.log(`[DNS Verify] ✗ No CNAME record found`)
99
+
return {
100
+
verified: false,
101
+
error: `No CNAME record found for ${domain}`,
102
+
found: { cname: '' }
103
+
}
104
+
}
105
+
106
+
// Check if CNAME points to the expected target
107
+
const actualTarget = foundCname
108
+
109
+
if (actualTarget === expectedTarget.toLowerCase()) {
110
+
console.log(`[DNS Verify] ✓ CNAME record matches!`)
111
+
return { verified: true, found: { cname: actualTarget } }
112
+
}
113
+
114
+
console.log(`[DNS Verify] ✗ CNAME record does not match`)
115
+
return {
116
+
verified: false,
117
+
error: `CNAME for ${domain} points to ${actualTarget}, expected ${expectedTarget}`,
118
+
found: { cname: actualTarget }
119
+
}
120
+
} catch (err: any) {
121
+
console.log(`[DNS Verify] ✗ CNAME lookup error:`, err.message)
122
+
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
123
+
return {
124
+
verified: false,
125
+
error: `No CNAME record found for ${domain}`,
126
+
found: { cname: '' }
127
+
}
128
+
}
129
+
return {
130
+
verified: false,
131
+
error: `DNS lookup failed: ${err.message}`,
132
+
found: { cname: '' }
133
+
}
134
+
}
135
+
}
136
+
137
+
/**
138
+
* Verify both TXT and CNAME records for a custom domain
139
+
*/
140
+
export const verifyCustomDomain = async (
141
+
domain: string,
142
+
expectedDid: string,
143
+
expectedHash: string
144
+
): Promise<VerificationResult> => {
145
+
const txtResult = await verifyDomainOwnership(domain, expectedDid)
146
+
if (!txtResult.verified) {
147
+
return txtResult
148
+
}
149
+
150
+
const cnameResult = await verifyCNAME(domain, expectedHash)
151
+
if (!cnameResult.verified) {
152
+
return cnameResult
153
+
}
154
+
155
+
return { verified: true }
156
+
}
+90
src/lib/sync-sites.ts
+90
src/lib/sync-sites.ts
···
1
+
import { Agent } from '@atproto/api'
2
+
import type { OAuthSession } from '@atproto/oauth-client-node'
3
+
import { upsertSite } from './db'
4
+
5
+
/**
6
+
* Sync sites from user's PDS into the database cache
7
+
* - Fetches all place.wisp.fs records from AT Protocol repo
8
+
* - Validates record structure
9
+
* - Backfills into sites table
10
+
*/
11
+
export async function syncSitesFromPDS(
12
+
did: string,
13
+
session: OAuthSession
14
+
): Promise<{ synced: number; errors: string[] }> {
15
+
console.log(`[Sync] Starting site sync for ${did}`)
16
+
17
+
const agent = new Agent((url, init) => session.fetchHandler(url, init))
18
+
const errors: string[] = []
19
+
let synced = 0
20
+
21
+
try {
22
+
// List all records in the place.wisp.fs collection
23
+
console.log('[Sync] Fetching place.wisp.fs records from PDS')
24
+
const records = await agent.com.atproto.repo.listRecords({
25
+
repo: did,
26
+
collection: 'place.wisp.fs',
27
+
limit: 100 // Adjust if users might have more sites
28
+
})
29
+
30
+
console.log(`[Sync] Found ${records.data.records.length} records`)
31
+
32
+
// Process each record
33
+
for (const record of records.data.records) {
34
+
try {
35
+
const { uri, value } = record
36
+
37
+
// Extract rkey from URI (at://did/collection/rkey)
38
+
const rkey = uri.split('/').pop()
39
+
if (!rkey) {
40
+
errors.push(`Invalid URI format: ${uri}`)
41
+
continue
42
+
}
43
+
44
+
// Validate record structure
45
+
if (!value || typeof value !== 'object') {
46
+
errors.push(`Invalid record value for ${rkey}`)
47
+
continue
48
+
}
49
+
50
+
const siteValue = value as any
51
+
52
+
// Check for required fields
53
+
if (siteValue.$type !== 'place.wisp.fs') {
54
+
errors.push(
55
+
`Invalid $type for ${rkey}: ${siteValue.$type}`
56
+
)
57
+
continue
58
+
}
59
+
60
+
if (!siteValue.site || typeof siteValue.site !== 'string') {
61
+
errors.push(`Missing or invalid site name for ${rkey}`)
62
+
continue
63
+
}
64
+
65
+
// Upsert into database
66
+
const displayName = siteValue.site
67
+
await upsertSite(did, rkey, displayName)
68
+
69
+
console.log(
70
+
`[Sync] ✓ Synced site: ${displayName} (${rkey})`
71
+
)
72
+
synced++
73
+
} catch (err) {
74
+
const errorMsg = `Error processing record: ${err instanceof Error ? err.message : 'Unknown error'}`
75
+
console.error(`[Sync] ${errorMsg}`)
76
+
errors.push(errorMsg)
77
+
}
78
+
}
79
+
80
+
console.log(
81
+
`[Sync] Complete: ${synced} synced, ${errors.length} errors`
82
+
)
83
+
return { synced, errors }
84
+
} catch (err) {
85
+
const errorMsg = `Failed to fetch records from PDS: ${err instanceof Error ? err.message : 'Unknown error'}`
86
+
console.error(`[Sync] ${errorMsg}`)
87
+
errors.push(errorMsg)
88
+
return { synced, errors }
89
+
}
90
+
}
+24
src/routes/auth.ts
+24
src/routes/auth.ts
···
1
1
import { Elysia } from 'elysia'
2
2
import { NodeOAuthClient } from '@atproto/oauth-client-node'
3
+
import { getSitesByDid, getDomainByDid } from '../lib/db'
4
+
import { syncSitesFromPDS } from '../lib/sync-sites'
3
5
4
6
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
5
7
.post('/api/auth/signin', async (c) => {
···
20
22
21
23
const cookieSession = c.cookie
22
24
cookieSession.did.value = session.did
25
+
26
+
// Sync sites from PDS to database cache
27
+
console.log('[Auth] Syncing sites from PDS for', session.did)
28
+
try {
29
+
const syncResult = await syncSitesFromPDS(session.did, session)
30
+
console.log(`[Auth] Sync complete: ${syncResult.synced} sites synced`)
31
+
if (syncResult.errors.length > 0) {
32
+
console.warn('[Auth] Sync errors:', syncResult.errors)
33
+
}
34
+
} catch (err) {
35
+
console.error('[Auth] Failed to sync sites:', err)
36
+
// Don't fail auth if sync fails, just log it
37
+
}
38
+
39
+
// Check if user has any sites or domain
40
+
const sites = await getSitesByDid(session.did)
41
+
const domain = await getDomainByDid(session.did)
42
+
43
+
// If no sites and no domain, redirect to onboarding
44
+
if (sites.length === 0 && !domain) {
45
+
return c.redirect('/onboarding')
46
+
}
23
47
24
48
return c.redirect('/editor')
25
49
})
+110
src/routes/domain.ts
+110
src/routes/domain.ts
···
9
9
isValidHandle,
10
10
toDomain,
11
11
updateDomain,
12
+
getCustomDomainInfo,
13
+
getCustomDomainById,
14
+
claimCustomDomain,
15
+
deleteCustomDomain,
16
+
updateCustomDomainVerification,
17
+
updateWispDomainSite,
18
+
updateCustomDomainRkey
12
19
} from '../lib/db'
20
+
import { createHash } from 'crypto'
21
+
import { verifyCustomDomain } from '../lib/dns-verify'
13
22
14
23
export const domainRoutes = (client: NodeOAuthClient) =>
15
24
new Elysia({ prefix: '/api/domain' })
···
125
134
} catch (err) {
126
135
console.error("domain/update error", err);
127
136
throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`);
137
+
}
138
+
})
139
+
.post('/custom/add', async ({ body, auth }) => {
140
+
try {
141
+
const { domain } = body as { domain: string };
142
+
const domainLower = domain.toLowerCase().trim();
143
+
144
+
// Basic validation
145
+
if (!domainLower || domainLower.length < 3) {
146
+
throw new Error('Invalid domain');
147
+
}
148
+
149
+
// Check if already exists
150
+
const existing = await getCustomDomainInfo(domainLower);
151
+
if (existing) {
152
+
throw new Error('Domain already claimed');
153
+
}
154
+
155
+
// Create hash for ID
156
+
const hash = createHash('sha256').update(`${auth.did}:${domainLower}`).digest('hex').substring(0, 16);
157
+
158
+
// Store in database only
159
+
await claimCustomDomain(auth.did, domainLower, hash);
160
+
161
+
return {
162
+
success: true,
163
+
id: hash,
164
+
domain: domainLower,
165
+
verified: false
166
+
};
167
+
} catch (err) {
168
+
console.error('custom domain add error', err);
169
+
throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
170
+
}
171
+
})
172
+
.post('/custom/verify', async ({ body, auth }) => {
173
+
try {
174
+
const { id } = body as { id: string };
175
+
176
+
// Get domain from database
177
+
const domainInfo = await getCustomDomainById(id);
178
+
if (!domainInfo) {
179
+
throw new Error('Domain not found');
180
+
}
181
+
182
+
// Verify DNS records (TXT + CNAME)
183
+
console.log(`Verifying custom domain: ${domainInfo.domain}`);
184
+
const result = await verifyCustomDomain(domainInfo.domain, auth.did, id);
185
+
186
+
// Update verification status in database
187
+
await updateCustomDomainVerification(id, result.verified);
188
+
189
+
return {
190
+
success: true,
191
+
verified: result.verified,
192
+
error: result.error,
193
+
found: result.found
194
+
};
195
+
} catch (err) {
196
+
console.error('custom domain verify error', err);
197
+
throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
198
+
}
199
+
})
200
+
.delete('/custom/:id', async ({ params, auth }) => {
201
+
try {
202
+
const { id } = params;
203
+
204
+
// Delete from database
205
+
await deleteCustomDomain(id);
206
+
207
+
return { success: true };
208
+
} catch (err) {
209
+
console.error('custom domain delete error', err);
210
+
throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
211
+
}
212
+
})
213
+
.post('/wisp/map-site', async ({ body, auth }) => {
214
+
try {
215
+
const { siteRkey } = body as { siteRkey: string | null };
216
+
217
+
// Update wisp.place domain to point to this site
218
+
await updateWispDomainSite(auth.did, siteRkey);
219
+
220
+
return { success: true };
221
+
} catch (err) {
222
+
console.error('wisp domain map error', err);
223
+
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
224
+
}
225
+
})
226
+
.post('/custom/:id/map-site', async ({ params, body, auth }) => {
227
+
try {
228
+
const { id } = params;
229
+
const { siteRkey } = body as { siteRkey: string | null };
230
+
231
+
// Update custom domain to point to this site
232
+
await updateCustomDomainRkey(id, siteRkey || 'self');
233
+
234
+
return { success: true };
235
+
} catch (err) {
236
+
console.error('custom domain map error', err);
237
+
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
128
238
}
129
239
});
+99
src/routes/user.ts
+99
src/routes/user.ts
···
1
+
import { Elysia } from 'elysia'
2
+
import { requireAuth } from '../lib/wisp-auth'
3
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
4
+
import { Agent } from '@atproto/api'
5
+
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db'
6
+
import { syncSitesFromPDS } from '../lib/sync-sites'
7
+
8
+
export const userRoutes = (client: NodeOAuthClient) =>
9
+
new Elysia({ prefix: '/api/user' })
10
+
.derive(async ({ cookie }) => {
11
+
const auth = await requireAuth(client, cookie)
12
+
return { auth }
13
+
})
14
+
.get('/status', async ({ auth }) => {
15
+
try {
16
+
// Check if user has any sites
17
+
const sites = await getSitesByDid(auth.did)
18
+
19
+
// Check if user has claimed a domain
20
+
const domain = await getDomainByDid(auth.did)
21
+
22
+
return {
23
+
did: auth.did,
24
+
hasSites: sites.length > 0,
25
+
hasDomain: !!domain,
26
+
domain: domain || null,
27
+
sitesCount: sites.length
28
+
}
29
+
} catch (err) {
30
+
console.error('user/status error', err)
31
+
throw new Error('Failed to get user status')
32
+
}
33
+
})
34
+
.get('/info', async ({ auth }) => {
35
+
try {
36
+
// Get user's handle from AT Protocol
37
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
38
+
39
+
let handle = 'unknown'
40
+
try {
41
+
const profile = await agent.getProfile({ actor: auth.did })
42
+
handle = profile.data.handle
43
+
} catch (err) {
44
+
console.error('Failed to fetch profile:', err)
45
+
}
46
+
47
+
return {
48
+
did: auth.did,
49
+
handle
50
+
}
51
+
} catch (err) {
52
+
console.error('user/info error', err)
53
+
throw new Error('Failed to get user info')
54
+
}
55
+
})
56
+
.get('/sites', async ({ auth }) => {
57
+
try {
58
+
const sites = await getSitesByDid(auth.did)
59
+
return { sites }
60
+
} catch (err) {
61
+
console.error('user/sites error', err)
62
+
throw new Error('Failed to get sites')
63
+
}
64
+
})
65
+
.get('/domains', async ({ auth }) => {
66
+
try {
67
+
// Get wisp.place subdomain with mapping
68
+
const wispDomainInfo = await getWispDomainInfo(auth.did)
69
+
70
+
// Get custom domains
71
+
const customDomains = await getCustomDomainsByDid(auth.did)
72
+
73
+
return {
74
+
wispDomain: wispDomainInfo ? {
75
+
domain: wispDomainInfo.domain,
76
+
rkey: wispDomainInfo.rkey || null
77
+
} : null,
78
+
customDomains
79
+
}
80
+
} catch (err) {
81
+
console.error('user/domains error', err)
82
+
throw new Error('Failed to get domains')
83
+
}
84
+
})
85
+
.post('/sync', async ({ auth }) => {
86
+
try {
87
+
console.log('[User] Manual sync requested for', auth.did)
88
+
const result = await syncSitesFromPDS(auth.did, auth.session)
89
+
90
+
return {
91
+
success: true,
92
+
synced: result.synced,
93
+
errors: result.errors
94
+
}
95
+
} catch (err) {
96
+
console.error('user/sync error', err)
97
+
throw new Error('Failed to sync sites')
98
+
}
99
+
})
+111
-9
src/routes/wisp.ts
+111
-9
src/routes/wisp.ts
···
9
9
createManifest,
10
10
updateFileBlobs
11
11
} from '../lib/wisp-utils'
12
+
import { upsertSite } from '../lib/db'
12
13
13
14
export const wispRoutes = (client: NodeOAuthClient) =>
14
15
new Elysia({ prefix: '/wisp' })
···
27
28
console.log('🚀 Starting upload process', { siteName, fileCount: Array.isArray(files) ? files.length : 1 });
28
29
29
30
try {
30
-
if (!files || (Array.isArray(files) ? files.length === 0 : !files)) {
31
-
console.error('❌ No files provided');
32
-
throw new Error('No files provided')
33
-
}
34
-
35
31
if (!siteName) {
36
32
console.error('❌ Site name is required');
37
33
throw new Error('Site name is required')
38
34
}
39
35
40
36
console.log('✅ Initial validation passed');
37
+
38
+
// Check if files were provided
39
+
const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files);
40
+
41
+
if (!hasFiles) {
42
+
console.log('📝 Creating empty site (no files provided)');
43
+
44
+
// Create agent with OAuth session
45
+
console.log('🔐 Creating agent with OAuth session');
46
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
47
+
console.log('✅ Agent created successfully');
48
+
49
+
// Create empty manifest
50
+
const emptyManifest = {
51
+
$type: 'place.wisp.fs',
52
+
site: siteName,
53
+
root: {
54
+
type: 'directory',
55
+
entries: []
56
+
},
57
+
fileCount: 0,
58
+
createdAt: new Date().toISOString()
59
+
};
60
+
61
+
// Use site name as rkey
62
+
const rkey = siteName;
63
+
64
+
// Create the record with explicit rkey
65
+
console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`);
66
+
const record = await agent.com.atproto.repo.putRecord({
67
+
repo: auth.did,
68
+
collection: 'place.wisp.fs',
69
+
rkey: rkey,
70
+
record: emptyManifest
71
+
});
72
+
73
+
console.log('✅ Empty site record created successfully:', {
74
+
uri: record.data.uri,
75
+
cid: record.data.cid
76
+
});
77
+
78
+
// Store site in database cache
79
+
console.log('💾 Storing site in database cache');
80
+
await upsertSite(auth.did, rkey, siteName);
81
+
console.log('✅ Site stored in database');
82
+
83
+
return {
84
+
success: true,
85
+
uri: record.data.uri,
86
+
cid: record.data.cid,
87
+
fileCount: 0,
88
+
siteName
89
+
};
90
+
}
41
91
42
92
// Create agent with OAuth session
43
93
console.log('🔐 Creating agent with OAuth session');
···
124
174
}
125
175
126
176
if (uploadedFiles.length === 0) {
127
-
throw new Error('No valid web files found to upload. Allowed types: HTML, CSS, JS, images, fonts, PDFs, and other web assets.');
177
+
console.log('⚠️ No valid web files found, creating empty site instead');
178
+
179
+
// Create empty manifest
180
+
const emptyManifest = {
181
+
$type: 'place.wisp.fs',
182
+
site: siteName,
183
+
root: {
184
+
type: 'directory',
185
+
entries: []
186
+
},
187
+
fileCount: 0,
188
+
createdAt: new Date().toISOString()
189
+
};
190
+
191
+
// Use site name as rkey
192
+
const rkey = siteName;
193
+
194
+
// Create the record with explicit rkey
195
+
console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`);
196
+
const record = await agent.com.atproto.repo.putRecord({
197
+
repo: auth.did,
198
+
collection: 'place.wisp.fs',
199
+
rkey: rkey,
200
+
record: emptyManifest
201
+
});
202
+
203
+
console.log('✅ Empty site record created successfully:', {
204
+
uri: record.data.uri,
205
+
cid: record.data.cid
206
+
});
207
+
208
+
// Store site in database cache
209
+
console.log('💾 Storing site in database cache');
210
+
await upsertSite(auth.did, rkey, siteName);
211
+
console.log('✅ Site stored in database');
212
+
213
+
return {
214
+
success: true,
215
+
uri: record.data.uri,
216
+
cid: record.data.cid,
217
+
fileCount: 0,
218
+
siteName,
219
+
message: 'Site created but no valid web files were found to upload'
220
+
};
128
221
}
129
222
130
223
console.log('✅ File conversion completed');
···
194
287
const manifest = createManifest(siteName, updatedDirectory, fileCount);
195
288
console.log('✅ Manifest created');
196
289
197
-
// Create the record
198
-
console.log('📝 Creating record in repo');
199
-
const record = await agent.com.atproto.repo.createRecord({
290
+
// Use site name as rkey
291
+
const rkey = siteName;
292
+
293
+
// Create the record with explicit rkey
294
+
console.log(`📝 Creating record in repo with rkey: ${rkey}`);
295
+
const record = await agent.com.atproto.repo.putRecord({
200
296
repo: auth.did,
201
297
collection: 'place.wisp.fs',
298
+
rkey: rkey,
202
299
record: manifest
203
300
});
204
301
···
206
303
uri: record.data.uri,
207
304
cid: record.data.cid
208
305
});
306
+
307
+
// Store site in database cache
308
+
console.log('💾 Storing site in database cache');
309
+
await upsertSite(auth.did, rkey, siteName);
310
+
console.log('✅ Site stored in database');
209
311
210
312
const result = {
211
313
success: true,