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

wire skeleton, domain management, hosting microservice

+6
hosting-service/.env.example
···
+
# Database
+
DATABASE_URL=postgres://postgres:postgres@localhost:5432/wisp
+
+
# Server
+
PORT=3001
+
BASE_HOST=wisp.place
+8
hosting-service/.gitignore
···
+
node_modules/
+
cache/
+
.env
+
.env.local
+
*.log
+
dist/
+
build/
+
.DS_Store
+123
hosting-service/EXAMPLE.md
···
+
# HTML Path Rewriting Example
+
+
This document demonstrates how HTML path rewriting works when serving sites via the `/s/:identifier/:site/*` route.
+
+
## Problem
+
+
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.
+
+
## Solution
+
+
The hosting service automatically rewrites absolute paths in HTML files to work correctly in the subdirectory context.
+
+
## Example
+
+
**Original HTML file (index.html):**
+
```html
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="UTF-8">
+
<title>My Site</title>
+
<link rel="stylesheet" href="/style.css">
+
<link rel="icon" href="/favicon.ico">
+
<script src="/app.js"></script>
+
</head>
+
<body>
+
<header>
+
<img src="/images/logo.png" alt="Logo">
+
<nav>
+
<a href="/">Home</a>
+
<a href="/about">About</a>
+
<a href="/contact">Contact</a>
+
</nav>
+
</header>
+
+
<main>
+
<h1>Welcome</h1>
+
<img src="/images/hero.jpg"
+
srcset="/images/hero.jpg 1x, /images/hero@2x.jpg 2x"
+
alt="Hero">
+
+
<form action="/submit" method="post">
+
<input type="text" name="email">
+
<button>Submit</button>
+
</form>
+
</main>
+
+
<footer>
+
<a href="https://example.com">External Link</a>
+
<a href="#top">Back to Top</a>
+
</footer>
+
</body>
+
</html>
+
```
+
+
**When accessed via `/s/alice.bsky.social/mysite/`, the HTML is rewritten to:**
+
```html
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="UTF-8">
+
<title>My Site</title>
+
<link rel="stylesheet" href="/s/alice.bsky.social/mysite/style.css">
+
<link rel="icon" href="/s/alice.bsky.social/mysite/favicon.ico">
+
<script src="/s/alice.bsky.social/mysite/app.js"></script>
+
</head>
+
<body>
+
<header>
+
<img src="/s/alice.bsky.social/mysite/images/logo.png" alt="Logo">
+
<nav>
+
<a href="/s/alice.bsky.social/mysite/">Home</a>
+
<a href="/s/alice.bsky.social/mysite/about">About</a>
+
<a href="/s/alice.bsky.social/mysite/contact">Contact</a>
+
</nav>
+
</header>
+
+
<main>
+
<h1>Welcome</h1>
+
<img src="/s/alice.bsky.social/mysite/images/hero.jpg"
+
srcset="/s/alice.bsky.social/mysite/images/hero.jpg 1x, /s/alice.bsky.social/mysite/images/hero@2x.jpg 2x"
+
alt="Hero">
+
+
<form action="/s/alice.bsky.social/mysite/submit" method="post">
+
<input type="text" name="email">
+
<button>Submit</button>
+
</form>
+
</main>
+
+
<footer>
+
<a href="https://example.com">External Link</a>
+
<a href="#top">Back to Top</a>
+
</footer>
+
</body>
+
</html>
+
```
+
+
## What's Preserved
+
+
Notice that:
+
- ✅ Absolute paths are rewritten: `/style.css` → `/s/alice.bsky.social/mysite/style.css`
+
- ✅ External URLs are preserved: `https://example.com` stays the same
+
- ✅ Anchors are preserved: `#top` stays the same
+
- ✅ The rewriting is safe and won't break your site
+
+
## Supported Attributes
+
+
The rewriter handles these HTML attributes:
+
- `src` - images, scripts, iframes, videos, audio
+
- `href` - links, stylesheets
+
- `action` - forms
+
- `data` - objects
+
- `poster` - video posters
+
- `srcset` - responsive images
+
+
## Testing Your Site
+
+
To test if your site works with path rewriting:
+
+
1. Upload your site to your PDS as a `place.wisp.fs` record
+
2. Access it via: `https://hosting.wisp.place/s/YOUR_HANDLE/SITE_NAME/`
+
3. Check that all resources load correctly
+
+
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
···
+
# Wisp Hosting Service
+
+
Minimal microservice for hosting static sites from the AT Protocol. Built with Hono and Bun.
+
+
## Features
+
+
- **Custom Domain Hosting**: Serve verified custom domains
+
- **Wisp.place Subdomains**: Serve registered `*.wisp.place` subdomains
+
- **DNS Hash Routing**: Support DNS verification via `hash.dns.wisp.place`
+
- **Direct File Serving**: Access sites via `/s/:identifier/:site/*` (no DB lookup)
+
- **Firehose Worker**: Listens to AT Protocol firehose for new `place.wisp.fs` records
+
- **Automatic Caching**: Downloads and caches sites locally on first access or firehose event
+
- **SSRF Protection**: Hardened fetch with timeout, size limits, and private IP blocking
+
+
## Routes
+
+
1. **Custom Domains** (`/*`)
+
- Serves verified custom domains (example.com)
+
- DB lookup: `custom_domains` table
+
+
2. **Wisp Subdomains** (`/*.wisp.place/*`)
+
- Serves registered subdomains (alice.wisp.place)
+
- DB lookup: `domains` table
+
+
3. **DNS Hash Routing** (`/hash.dns.wisp.place/*`)
+
- DNS verification routing for custom domains
+
- DB lookup: `custom_domains` by hash
+
+
4. **Direct Serving** (`/s.wisp.place/:identifier/:site/*`)
+
- Direct access without DB lookup
+
- `:identifier` can be DID or handle
+
- Fetches from PDS if not cached
+
- **Automatic HTML path rewriting**: Absolute paths (`/style.css`) are rewritten to relative paths (`/s/:identifier/:site/style.css`)
+
+
## Setup
+
+
```bash
+
# Install dependencies
+
bun install
+
+
# Copy environment file
+
cp .env.example .env
+
+
# Run in development
+
bun run dev
+
+
# Run in production
+
bun run start
+
```
+
+
## Environment Variables
+
+
- `DATABASE_URL` - PostgreSQL connection string
+
- `PORT` - HTTP server port (default: 3001)
+
- `BASE_HOST` - Base domain (default: wisp.place)
+
+
## Architecture
+
+
- **Hono**: Minimal web framework
+
- **Postgres**: Database for domain/site lookups
+
- **AT Protocol**: Decentralized storage
+
- **Jetstream**: Firehose consumer for real-time updates
+
- **Bun**: Runtime and file serving
+
+
## Cache Structure
+
+
```
+
cache/sites/
+
did:plc:abc123/
+
sitename/
+
index.html
+
style.css
+
assets/
+
logo.png
+
```
+
+
## Health Check
+
+
```bash
+
curl http://localhost:3001/health
+
```
+
+
Returns firehose connection status and last event time.
+
+
## HTML Path Rewriting
+
+
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.
+
+
**What gets rewritten:**
+
- `src` attributes (images, scripts, iframes)
+
- `href` attributes (links, stylesheets)
+
- `action` attributes (forms)
+
- `poster`, `data` attributes (media)
+
- `srcset` attributes (responsive images)
+
+
**What's preserved:**
+
- External URLs (`https://example.com/style.css`)
+
- Protocol-relative URLs (`//cdn.example.com/script.js`)
+
- Data URIs (`data:image/png;base64,...`)
+
- Anchors (`/#section`)
+
- Already relative paths (`./style.css`, `../images/logo.png`)
+
+
**Example:**
+
```html
+
<!-- Original HTML -->
+
<link rel="stylesheet" href="/style.css">
+
<img src="/images/logo.png">
+
+
<!-- Served at /s/did:plc:abc123/mysite/ becomes -->
+
<link rel="stylesheet" href="/s/did:plc:abc123/mysite/style.css">
+
<img src="/s/did:plc:abc123/mysite/images/logo.png">
+
```
+
+
This ensures sites work correctly when served from subdirectories without requiring manual path adjustments.
+
+
## Security
+
+
### SSRF Protection
+
+
All external HTTP requests are protected against Server-Side Request Forgery (SSRF) attacks:
+
+
- **5-second timeout** on all requests
+
- **Size limits**: 1MB for JSON, 10MB default, 100MB for file blobs
+
- **Blocked private IP ranges**:
+
- Loopback (127.0.0.0/8, ::1)
+
- Private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
+
- Link-local (169.254.0.0/16, fe80::/10)
+
- Cloud metadata endpoints (169.254.169.254)
+
- **Protocol validation**: Only HTTP/HTTPS allowed
+
- **Streaming with size enforcement**: Prevents memory exhaustion from large responses
+60
hosting-service/bun.lock
···
+
{
+
"lockfileVersion": 1,
+
"workspaces": {
+
"": {
+
"name": "wisp-hosting-service",
+
"dependencies": {
+
"@atproto/api": "^0.13.20",
+
"@atproto/xrpc": "^0.6.4",
+
"hono": "^4.6.14",
+
"postgres": "^3.4.5",
+
},
+
"devDependencies": {
+
"@types/bun": "latest",
+
},
+
},
+
},
+
"packages": {
+
"@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=="],
+
+
"@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=="],
+
+
"@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=="],
+
+
"@atproto/syntax": ["@atproto/syntax@0.3.4", "", {}, "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg=="],
+
+
"@atproto/xrpc": ["@atproto/xrpc@0.6.12", "", { "dependencies": { "@atproto/lexicon": "^0.4.10", "zod": "^3.23.8" } }, "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w=="],
+
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
+
+
"@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="],
+
+
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
+
+
"await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="],
+
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
+
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
+
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
+
+
"hono": ["hono@4.10.2", "", {}, "sha512-p6fyzl+mQo6uhESLxbF5WlBOAJMDh36PljwlKtP5V1v09NxlqGru3ShK+4wKhSuhuYf8qxMmrivHOa/M7q0sMg=="],
+
+
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
+
+
"multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="],
+
+
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
+
+
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
+
+
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
+
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+
"@atproto/lexicon/@atproto/syntax": ["@atproto/syntax@0.4.1", "", {}, "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw=="],
+
}
+
}
+18
hosting-service/package.json
···
+
{
+
"name": "wisp-hosting-service",
+
"version": "1.0.0",
+
"type": "module",
+
"scripts": {
+
"dev": "bun --watch src/index.ts",
+
"start": "bun src/index.ts"
+
},
+
"dependencies": {
+
"hono": "^4.6.14",
+
"@atproto/api": "^0.13.20",
+
"@atproto/xrpc": "^0.6.4",
+
"postgres": "^3.4.5"
+
},
+
"devDependencies": {
+
"@types/bun": "latest"
+
}
+
}
+59
hosting-service/src/index.ts
···
+
import { serve } from 'bun';
+
import app from './server';
+
import { FirehoseWorker } from './lib/firehose';
+
import { mkdirSync, existsSync } from 'fs';
+
+
const PORT = process.env.PORT || 3001;
+
const CACHE_DIR = './cache/sites';
+
+
// Ensure cache directory exists
+
if (!existsSync(CACHE_DIR)) {
+
mkdirSync(CACHE_DIR, { recursive: true });
+
console.log('Created cache directory:', CACHE_DIR);
+
}
+
+
// Start firehose worker
+
const firehose = new FirehoseWorker((msg, data) => {
+
console.log(msg, data);
+
});
+
+
firehose.start();
+
+
// Add health check endpoint
+
app.get('/health', (c) => {
+
const firehoseHealth = firehose.getHealth();
+
return c.json({
+
status: 'ok',
+
firehose: firehoseHealth,
+
});
+
});
+
+
// Start HTTP server
+
const server = serve({
+
port: PORT,
+
fetch: app.fetch,
+
});
+
+
console.log(`
+
Wisp Hosting Service
+
+
Server: http://localhost:${PORT}
+
Health: http://localhost:${PORT}/health
+
Cache: ${CACHE_DIR}
+
Firehose: Connected to Jetstream
+
`);
+
+
// Graceful shutdown
+
process.on('SIGINT', () => {
+
console.log('\n🛑 Shutting down...');
+
firehose.stop();
+
server.stop();
+
process.exit(0);
+
});
+
+
process.on('SIGTERM', () => {
+
console.log('\n🛑 Shutting down...');
+
firehose.stop();
+
server.stop();
+
process.exit(0);
+
});
+62
hosting-service/src/lib/db.ts
···
+
import postgres from 'postgres';
+
+
const sql = postgres(
+
process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/wisp',
+
{
+
max: 10,
+
idle_timeout: 20,
+
}
+
);
+
+
export interface DomainLookup {
+
did: string;
+
rkey: string | null;
+
}
+
+
export interface CustomDomainLookup {
+
id: string;
+
domain: string;
+
did: string;
+
rkey: string;
+
verified: boolean;
+
}
+
+
export async function getWispDomain(domain: string): Promise<DomainLookup | null> {
+
const result = await sql<DomainLookup[]>`
+
SELECT did, rkey FROM domains WHERE domain = ${domain.toLowerCase()} LIMIT 1
+
`;
+
return result[0] || null;
+
}
+
+
export async function getCustomDomain(domain: string): Promise<CustomDomainLookup | null> {
+
const result = await sql<CustomDomainLookup[]>`
+
SELECT id, domain, did, rkey, verified FROM custom_domains
+
WHERE domain = ${domain.toLowerCase()} AND verified = true LIMIT 1
+
`;
+
return result[0] || null;
+
}
+
+
export async function getCustomDomainByHash(hash: string): Promise<CustomDomainLookup | null> {
+
const result = await sql<CustomDomainLookup[]>`
+
SELECT id, domain, did, rkey, verified FROM custom_domains
+
WHERE id = ${hash} AND verified = true LIMIT 1
+
`;
+
return result[0] || null;
+
}
+
+
export async function upsertSite(did: string, rkey: string, displayName?: string) {
+
try {
+
await sql`
+
INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
+
VALUES (${did}, ${rkey}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
+
ON CONFLICT (did, rkey)
+
DO UPDATE SET
+
display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
+
updated_at = EXTRACT(EPOCH FROM NOW())
+
`;
+
} catch (err) {
+
console.error('Failed to upsert site', err);
+
}
+
}
+
+
export { sql };
+328
hosting-service/src/lib/firehose.ts
···
+
import { existsSync, rmSync } from 'fs';
+
import type { WispFsRecord } from './types';
+
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
+
import { upsertSite } from './db';
+
import { safeFetch } from './safe-fetch';
+
+
const CACHE_DIR = './cache/sites';
+
const JETSTREAM_URL = 'wss://jetstream2.us-west.bsky.network/subscribe';
+
const RECONNECT_DELAY = 5000; // 5 seconds
+
const MAX_RECONNECT_DELAY = 60000; // 1 minute
+
+
interface JetstreamCommitEvent {
+
did: string;
+
time_us: number;
+
type: 'com' | 'identity' | 'account';
+
kind: 'commit';
+
commit: {
+
rev: string;
+
operation: 'create' | 'update' | 'delete';
+
collection: string;
+
rkey: string;
+
record?: any;
+
cid?: string;
+
};
+
}
+
+
interface JetstreamIdentityEvent {
+
did: string;
+
time_us: number;
+
type: 'identity';
+
kind: 'update';
+
identity: {
+
did: string;
+
handle: string;
+
seq: number;
+
time: string;
+
};
+
}
+
+
interface JetstreamAccountEvent {
+
did: string;
+
time_us: number;
+
type: 'account';
+
kind: 'update' | 'delete';
+
account: {
+
active: boolean;
+
did: string;
+
seq: number;
+
time: string;
+
};
+
}
+
+
type JetstreamEvent =
+
| JetstreamCommitEvent
+
| JetstreamIdentityEvent
+
| JetstreamAccountEvent;
+
+
export class FirehoseWorker {
+
private ws: WebSocket | null = null;
+
private reconnectAttempts = 0;
+
private reconnectTimeout: Timer | null = null;
+
private isShuttingDown = false;
+
private lastEventTime = Date.now();
+
+
constructor(
+
private logger?: (msg: string, data?: Record<string, unknown>) => void,
+
) {}
+
+
private log(msg: string, data?: Record<string, unknown>) {
+
const log = this.logger || console.log;
+
log(`[FirehoseWorker] ${msg}`, data || {});
+
}
+
+
start() {
+
this.log('Starting firehose worker');
+
this.connect();
+
}
+
+
stop() {
+
this.log('Stopping firehose worker');
+
this.isShuttingDown = true;
+
+
if (this.reconnectTimeout) {
+
clearTimeout(this.reconnectTimeout);
+
this.reconnectTimeout = null;
+
}
+
+
if (this.ws) {
+
this.ws.close();
+
this.ws = null;
+
}
+
}
+
+
private connect() {
+
if (this.isShuttingDown) return;
+
+
const url = new URL(JETSTREAM_URL);
+
url.searchParams.set('wantedCollections', 'place.wisp.fs');
+
+
this.log('Connecting to Jetstream', { url: url.toString() });
+
+
try {
+
this.ws = new WebSocket(url.toString());
+
+
this.ws.onopen = () => {
+
this.log('Connected to Jetstream');
+
this.reconnectAttempts = 0;
+
this.lastEventTime = Date.now();
+
};
+
+
this.ws.onmessage = async (event) => {
+
this.lastEventTime = Date.now();
+
+
try {
+
const data = JSON.parse(event.data as string) as JetstreamEvent;
+
await this.handleEvent(data);
+
} catch (err) {
+
this.log('Error processing event', {
+
error: err instanceof Error ? err.message : String(err),
+
});
+
}
+
};
+
+
this.ws.onerror = (error) => {
+
this.log('WebSocket error', { error: String(error) });
+
};
+
+
this.ws.onclose = () => {
+
this.log('WebSocket closed');
+
this.ws = null;
+
+
if (!this.isShuttingDown) {
+
this.scheduleReconnect();
+
}
+
};
+
} catch (err) {
+
this.log('Failed to create WebSocket', {
+
error: err instanceof Error ? err.message : String(err),
+
});
+
this.scheduleReconnect();
+
}
+
}
+
+
private scheduleReconnect() {
+
if (this.isShuttingDown) return;
+
+
this.reconnectAttempts++;
+
const delay = Math.min(
+
RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1),
+
MAX_RECONNECT_DELAY,
+
);
+
+
this.log(`Scheduling reconnect attempt ${this.reconnectAttempts}`, {
+
delay: `${delay}ms`,
+
});
+
+
this.reconnectTimeout = setTimeout(() => {
+
this.connect();
+
}, delay);
+
}
+
+
private async handleEvent(event: JetstreamEvent) {
+
if (event.kind !== 'commit') return;
+
+
const commitEvent = event as JetstreamCommitEvent;
+
const { commit, did } = commitEvent;
+
+
if (commit.collection !== 'place.wisp.fs') return;
+
+
this.log('Received place.wisp.fs event', {
+
did,
+
operation: commit.operation,
+
rkey: commit.rkey,
+
});
+
+
try {
+
if (commit.operation === 'create' || commit.operation === 'update') {
+
await this.handleCreateOrUpdate(did, commit.rkey, commit.record);
+
} else if (commit.operation === 'delete') {
+
await this.handleDelete(did, commit.rkey);
+
}
+
} catch (err) {
+
this.log('Error handling event', {
+
did,
+
operation: commit.operation,
+
rkey: commit.rkey,
+
error: err instanceof Error ? err.message : String(err),
+
});
+
}
+
}
+
+
private async handleCreateOrUpdate(did: string, site: string, record: any) {
+
this.log('Processing create/update', { did, site });
+
+
if (!this.validateRecord(record)) {
+
this.log('Invalid record structure, skipping', { did, site });
+
return;
+
}
+
+
const fsRecord = record as WispFsRecord;
+
+
const pdsEndpoint = await getPdsForDid(did);
+
if (!pdsEndpoint) {
+
this.log('Could not resolve PDS for DID', { did });
+
return;
+
}
+
+
this.log('Resolved PDS', { did, pdsEndpoint });
+
+
// Verify record exists on PDS
+
try {
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
+
const recordRes = await safeFetch(recordUrl);
+
+
if (!recordRes.ok) {
+
this.log('Record not found on PDS, skipping cache', {
+
did,
+
site,
+
status: recordRes.status,
+
});
+
return;
+
}
+
+
this.log('Record verified on PDS', { did, site });
+
} catch (err) {
+
this.log('Failed to verify record on PDS', {
+
did,
+
site,
+
error: err instanceof Error ? err.message : String(err),
+
});
+
return;
+
}
+
+
// Cache the record
+
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint);
+
+
// Upsert site to database
+
await upsertSite(did, site, fsRecord.site);
+
+
this.log('Successfully processed create/update', { did, site });
+
}
+
+
private async handleDelete(did: string, site: string) {
+
this.log('Processing delete', { did, site });
+
+
const pdsEndpoint = await getPdsForDid(did);
+
if (!pdsEndpoint) {
+
this.log('Could not resolve PDS for DID', { did });
+
return;
+
}
+
+
// Verify record is actually deleted from PDS
+
try {
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
+
const recordRes = await safeFetch(recordUrl);
+
+
if (recordRes.ok) {
+
this.log('Record still exists on PDS, not deleting cache', {
+
did,
+
site,
+
});
+
return;
+
}
+
+
this.log('Verified record is deleted from PDS', {
+
did,
+
site,
+
status: recordRes.status,
+
});
+
} catch (err) {
+
this.log('Error verifying deletion on PDS', {
+
did,
+
site,
+
error: err instanceof Error ? err.message : String(err),
+
});
+
}
+
+
// Delete cache
+
this.deleteCache(did, site);
+
+
this.log('Successfully processed delete', { did, site });
+
}
+
+
private validateRecord(record: any): boolean {
+
if (!record || typeof record !== 'object') return false;
+
if (record.$type !== 'place.wisp.fs') return false;
+
if (!record.root || typeof record.root !== 'object') return false;
+
if (!record.site || typeof record.site !== 'string') return false;
+
return true;
+
}
+
+
private deleteCache(did: string, site: string) {
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
+
+
if (!existsSync(cacheDir)) {
+
this.log('Cache directory does not exist, nothing to delete', {
+
did,
+
site,
+
});
+
return;
+
}
+
+
try {
+
rmSync(cacheDir, { recursive: true, force: true });
+
this.log('Cache deleted', { did, site, path: cacheDir });
+
} catch (err) {
+
this.log('Failed to delete cache', {
+
did,
+
site,
+
path: cacheDir,
+
error: err instanceof Error ? err.message : String(err),
+
});
+
}
+
}
+
+
getHealth() {
+
const isConnected = this.ws !== null && this.ws.readyState === WebSocket.OPEN;
+
const timeSinceLastEvent = Date.now() - this.lastEventTime;
+
+
return {
+
connected: isConnected,
+
reconnectAttempts: this.reconnectAttempts,
+
lastEventTime: this.lastEventTime,
+
timeSinceLastEvent,
+
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
+
};
+
}
+
}
+107
hosting-service/src/lib/html-rewriter.test.ts
···
+
/**
+
* Simple tests for HTML path rewriter
+
* Run with: bun test
+
*/
+
+
import { test, expect } from 'bun:test';
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter';
+
+
test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => {
+
const html = '<img src="/logo.png">';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<img src="/s/did:plc:123/mysite/logo.png">');
+
});
+
+
test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => {
+
const html = '<link rel="stylesheet" href="/style.css">';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<link rel="stylesheet" href="/s/did:plc:123/mysite/style.css">');
+
});
+
+
test('rewriteHtmlPaths - preserves external URLs', () => {
+
const html = '<img src="https://example.com/logo.png">';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<img src="https://example.com/logo.png">');
+
});
+
+
test('rewriteHtmlPaths - preserves protocol-relative URLs', () => {
+
const html = '<script src="//cdn.example.com/script.js"></script>';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<script src="//cdn.example.com/script.js"></script>');
+
});
+
+
test('rewriteHtmlPaths - preserves data URIs', () => {
+
const html = '<img src="data:image/png;base64,abc123">';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<img src="data:image/png;base64,abc123">');
+
});
+
+
test('rewriteHtmlPaths - preserves anchors', () => {
+
const html = '<a href="/#section">Jump</a>';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<a href="/#section">Jump</a>');
+
});
+
+
test('rewriteHtmlPaths - preserves relative paths', () => {
+
const html = '<img src="./logo.png">';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<img src="./logo.png">');
+
});
+
+
test('rewriteHtmlPaths - handles single quotes', () => {
+
const html = "<img src='/logo.png'>";
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe("<img src='/s/did:plc:123/mysite/logo.png'>");
+
});
+
+
test('rewriteHtmlPaths - handles srcset', () => {
+
const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<img srcset="/s/did:plc:123/mysite/logo.png 1x, /s/did:plc:123/mysite/logo@2x.png 2x">');
+
});
+
+
test('rewriteHtmlPaths - handles form actions', () => {
+
const html = '<form action="/submit"></form>';
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
expect(result).toBe('<form action="/s/did:plc:123/mysite/submit"></form>');
+
});
+
+
test('rewriteHtmlPaths - handles complex HTML', () => {
+
const html = `
+
<!DOCTYPE html>
+
<html>
+
<head>
+
<link rel="stylesheet" href="/style.css">
+
<script src="/app.js"></script>
+
</head>
+
<body>
+
<img src="/images/logo.png" srcset="/images/logo.png 1x, /images/logo@2x.png 2x">
+
<a href="/about">About</a>
+
<a href="https://example.com">External</a>
+
<a href="#section">Anchor</a>
+
</body>
+
</html>
+
`.trim();
+
+
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
+
expect(result).toContain('href="/s/did:plc:123/mysite/style.css"');
+
expect(result).toContain('src="/s/did:plc:123/mysite/app.js"');
+
expect(result).toContain('src="/s/did:plc:123/mysite/images/logo.png"');
+
expect(result).toContain('href="/s/did:plc:123/mysite/about"');
+
expect(result).toContain('href="https://example.com"'); // External preserved
+
expect(result).toContain('href="#section"'); // Anchor preserved
+
});
+
+
test('isHtmlContent - detects HTML by extension', () => {
+
expect(isHtmlContent('index.html')).toBe(true);
+
expect(isHtmlContent('page.htm')).toBe(true);
+
expect(isHtmlContent('style.css')).toBe(false);
+
expect(isHtmlContent('script.js')).toBe(false);
+
});
+
+
test('isHtmlContent - detects HTML by content type', () => {
+
expect(isHtmlContent('index', 'text/html')).toBe(true);
+
expect(isHtmlContent('index', 'text/html; charset=utf-8')).toBe(true);
+
expect(isHtmlContent('index', 'application/json')).toBe(false);
+
});
+130
hosting-service/src/lib/html-rewriter.ts
···
+
/**
+
* Safely rewrites absolute paths in HTML to be relative to a base path
+
* Only processes common HTML attributes and preserves external URLs, data URIs, etc.
+
*/
+
+
const REWRITABLE_ATTRIBUTES = [
+
'src',
+
'href',
+
'action',
+
'data',
+
'poster',
+
'srcset',
+
] as const;
+
+
/**
+
* Check if a path should be rewritten
+
*/
+
function shouldRewritePath(path: string): boolean {
+
// Must start with /
+
if (!path.startsWith('/')) return false;
+
+
// Don't rewrite protocol-relative URLs
+
if (path.startsWith('//')) return false;
+
+
// Don't rewrite anchors
+
if (path.startsWith('/#')) return false;
+
+
// Don't rewrite data URIs or other schemes
+
if (path.includes(':')) return false;
+
+
return true;
+
}
+
+
/**
+
* Rewrite a single path
+
*/
+
function rewritePath(path: string, basePath: string): string {
+
if (!shouldRewritePath(path)) {
+
return path;
+
}
+
+
// Remove leading slash and prepend base path
+
return basePath + path.slice(1);
+
}
+
+
/**
+
* Rewrite srcset attribute (can contain multiple URLs)
+
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
+
*/
+
function rewriteSrcset(srcset: string, basePath: string): string {
+
return srcset
+
.split(',')
+
.map(part => {
+
const trimmed = part.trim();
+
const spaceIndex = trimmed.indexOf(' ');
+
+
if (spaceIndex === -1) {
+
// No descriptor, just URL
+
return rewritePath(trimmed, basePath);
+
}
+
+
const url = trimmed.substring(0, spaceIndex);
+
const descriptor = trimmed.substring(spaceIndex);
+
return rewritePath(url, basePath) + descriptor;
+
})
+
.join(', ');
+
}
+
+
/**
+
* Rewrite absolute paths in HTML content
+
* Uses simple regex matching for safety (no full HTML parsing)
+
*/
+
export function rewriteHtmlPaths(html: string, basePath: string): string {
+
// Ensure base path ends with /
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
+
+
let rewritten = html;
+
+
// Rewrite each attribute type
+
for (const attr of REWRITABLE_ATTRIBUTES) {
+
if (attr === 'srcset') {
+
// Special handling for srcset
+
const srcsetRegex = new RegExp(
+
`\\b${attr}\\s*=\\s*"([^"]*)"`,
+
'gi'
+
);
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
+
const rewrittenValue = rewriteSrcset(value, normalizedBase);
+
return `${attr}="${rewrittenValue}"`;
+
});
+
} else {
+
// Regular attributes with quoted values
+
const doubleQuoteRegex = new RegExp(
+
`\\b${attr}\\s*=\\s*"([^"]*)"`,
+
'gi'
+
);
+
const singleQuoteRegex = new RegExp(
+
`\\b${attr}\\s*=\\s*'([^']*)'`,
+
'gi'
+
);
+
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
+
const rewrittenValue = rewritePath(value, normalizedBase);
+
return `${attr}="${rewrittenValue}"`;
+
});
+
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
+
const rewrittenValue = rewritePath(value, normalizedBase);
+
return `${attr}='${rewrittenValue}'`;
+
});
+
}
+
}
+
+
return rewritten;
+
}
+
+
/**
+
* Check if content is HTML based on content or filename
+
*/
+
export function isHtmlContent(
+
filepath: string,
+
contentType?: string
+
): boolean {
+
if (contentType && contentType.includes('text/html')) {
+
return true;
+
}
+
+
const ext = filepath.toLowerCase().split('.').pop();
+
return ext === 'html' || ext === 'htm';
+
}
+181
hosting-service/src/lib/safe-fetch.ts
···
+
/**
+
* SSRF-hardened fetch utility
+
* Prevents requests to private networks, localhost, and enforces timeouts/size limits
+
*/
+
+
const BLOCKED_IP_RANGES = [
+
/^127\./, // 127.0.0.0/8 - Loopback
+
/^10\./, // 10.0.0.0/8 - Private
+
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 - Private
+
/^192\.168\./, // 192.168.0.0/16 - Private
+
/^169\.254\./, // 169.254.0.0/16 - Link-local
+
/^::1$/, // IPv6 loopback
+
/^fe80:/, // IPv6 link-local
+
/^fc00:/, // IPv6 unique local
+
/^fd00:/, // IPv6 unique local
+
];
+
+
const BLOCKED_HOSTS = [
+
'localhost',
+
'metadata.google.internal',
+
'169.254.169.254',
+
];
+
+
const FETCH_TIMEOUT = 5000; // 5 seconds
+
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB
+
+
function isBlockedHost(hostname: string): boolean {
+
const lowerHost = hostname.toLowerCase();
+
+
if (BLOCKED_HOSTS.includes(lowerHost)) {
+
return true;
+
}
+
+
for (const pattern of BLOCKED_IP_RANGES) {
+
if (pattern.test(lowerHost)) {
+
return true;
+
}
+
}
+
+
return false;
+
}
+
+
export async function safeFetch(
+
url: string,
+
options?: RequestInit & { maxSize?: number; timeout?: number }
+
): Promise<Response> {
+
const timeoutMs = options?.timeout ?? FETCH_TIMEOUT;
+
const maxSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
+
+
// Parse and validate URL
+
let parsedUrl: URL;
+
try {
+
parsedUrl = new URL(url);
+
} catch (err) {
+
throw new Error(`Invalid URL: ${url}`);
+
}
+
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
+
throw new Error(`Blocked protocol: ${parsedUrl.protocol}`);
+
}
+
+
const hostname = parsedUrl.hostname;
+
if (isBlockedHost(hostname)) {
+
throw new Error(`Blocked host: ${hostname}`);
+
}
+
+
const controller = new AbortController();
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
+
try {
+
const response = await fetch(url, {
+
...options,
+
signal: controller.signal,
+
});
+
+
const contentLength = response.headers.get('content-length');
+
if (contentLength && parseInt(contentLength, 10) > maxSize) {
+
throw new Error(`Response too large: ${contentLength} bytes`);
+
}
+
+
return response;
+
} catch (err) {
+
if (err instanceof Error && err.name === 'AbortError') {
+
throw new Error(`Request timeout after ${timeoutMs}ms`);
+
}
+
throw err;
+
} finally {
+
clearTimeout(timeoutId);
+
}
+
}
+
+
export async function safeFetchJson<T = any>(
+
url: string,
+
options?: RequestInit & { maxSize?: number; timeout?: number }
+
): Promise<T> {
+
const maxJsonSize = options?.maxSize ?? 1024 * 1024; // 1MB default for JSON
+
const response = await safeFetch(url, { ...options, maxSize: maxJsonSize });
+
+
if (!response.ok) {
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+
}
+
+
const reader = response.body?.getReader();
+
if (!reader) {
+
throw new Error('No response body');
+
}
+
+
const chunks: Uint8Array[] = [];
+
let totalSize = 0;
+
+
try {
+
while (true) {
+
const { done, value } = await reader.read();
+
if (done) break;
+
+
totalSize += value.length;
+
if (totalSize > maxJsonSize) {
+
throw new Error(`Response exceeds max size: ${maxJsonSize} bytes`);
+
}
+
+
chunks.push(value);
+
}
+
} finally {
+
reader.releaseLock();
+
}
+
+
const combined = new Uint8Array(totalSize);
+
let offset = 0;
+
for (const chunk of chunks) {
+
combined.set(chunk, offset);
+
offset += chunk.length;
+
}
+
+
const text = new TextDecoder().decode(combined);
+
return JSON.parse(text);
+
}
+
+
export async function safeFetchBlob(
+
url: string,
+
options?: RequestInit & { maxSize?: number; timeout?: number }
+
): Promise<Uint8Array> {
+
const maxBlobSize = options?.maxSize ?? MAX_RESPONSE_SIZE;
+
const response = await safeFetch(url, { ...options, maxSize: maxBlobSize });
+
+
if (!response.ok) {
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+
}
+
+
const reader = response.body?.getReader();
+
if (!reader) {
+
throw new Error('No response body');
+
}
+
+
const chunks: Uint8Array[] = [];
+
let totalSize = 0;
+
+
try {
+
while (true) {
+
const { done, value } = await reader.read();
+
if (done) break;
+
+
totalSize += value.length;
+
if (totalSize > maxBlobSize) {
+
throw new Error(`Blob exceeds max size: ${maxBlobSize} bytes`);
+
}
+
+
chunks.push(value);
+
}
+
} finally {
+
reader.releaseLock();
+
}
+
+
const combined = new Uint8Array(totalSize);
+
let offset = 0;
+
for (const chunk of chunks) {
+
combined.set(chunk, offset);
+
offset += chunk.length;
+
}
+
+
return combined;
+
}
+27
hosting-service/src/lib/types.ts
···
+
import type { BlobRef } from '@atproto/api';
+
+
export interface WispFsRecord {
+
$type: 'place.wisp.fs';
+
site: string;
+
root: Directory;
+
fileCount?: number;
+
createdAt: string;
+
}
+
+
export interface File {
+
$type?: 'place.wisp.fs#file';
+
type: 'file';
+
blob: BlobRef;
+
}
+
+
export interface Directory {
+
$type?: 'place.wisp.fs#directory';
+
type: 'directory';
+
entries: Entry[];
+
}
+
+
export interface Entry {
+
$type?: 'place.wisp.fs#entry';
+
name: string;
+
node: File | Directory | { $type: string };
+
}
+162
hosting-service/src/lib/utils.ts
···
+
import { AtpAgent } from '@atproto/api';
+
import type { WispFsRecord, Directory, Entry, File } from './types';
+
import { existsSync, mkdirSync } from 'fs';
+
import { writeFile } from 'fs/promises';
+
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
+
+
const CACHE_DIR = './cache/sites';
+
+
export async function resolveDid(identifier: string): Promise<string | null> {
+
try {
+
// If it's already a DID, return it
+
if (identifier.startsWith('did:')) {
+
return identifier;
+
}
+
+
// Otherwise, resolve the handle using agent's built-in method
+
const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
+
const response = await agent.resolveHandle({ handle: identifier });
+
return response.data.did;
+
} catch (err) {
+
console.error('Failed to resolve identifier', identifier, err);
+
return null;
+
}
+
}
+
+
export async function getPdsForDid(did: string): Promise<string | null> {
+
try {
+
let doc;
+
+
if (did.startsWith('did:plc:')) {
+
// Resolve did:plc from plc.directory
+
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
+
} else if (did.startsWith('did:web:')) {
+
// Resolve did:web from the domain
+
const didUrl = didWebToHttps(did);
+
doc = await safeFetchJson(didUrl);
+
} else {
+
console.error('Unsupported DID method', did);
+
return null;
+
}
+
+
const services = doc.service || [];
+
const pdsService = services.find((s: any) => s.id === '#atproto_pds');
+
+
return pdsService?.serviceEndpoint || null;
+
} catch (err) {
+
console.error('Failed to get PDS for DID', did, err);
+
return null;
+
}
+
}
+
+
function didWebToHttps(did: string): string {
+
// did:web:example.com -> https://example.com/.well-known/did.json
+
// did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json
+
+
const didParts = did.split(':');
+
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
+
throw new Error('Invalid did:web format');
+
}
+
+
const domain = didParts[2];
+
const pathParts = didParts.slice(3);
+
+
if (pathParts.length === 0) {
+
// No path, use .well-known
+
return `https://${domain}/.well-known/did.json`;
+
} else {
+
// Has path
+
const path = pathParts.join('/');
+
return `https://${domain}/${path}/did.json`;
+
}
+
}
+
+
export async function fetchSiteRecord(did: string, rkey: string): Promise<WispFsRecord | null> {
+
try {
+
const pdsEndpoint = await getPdsForDid(did);
+
if (!pdsEndpoint) return null;
+
+
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
+
const data = await safeFetchJson(url);
+
return data.value as WispFsRecord;
+
} catch (err) {
+
console.error('Failed to fetch site record', did, rkey, err);
+
return null;
+
}
+
}
+
+
export function extractBlobCid(blobRef: any): string | null {
+
if (typeof blobRef === 'object' && blobRef !== null) {
+
if ('ref' in blobRef && blobRef.ref?.$link) {
+
return blobRef.ref.$link;
+
}
+
if ('cid' in blobRef && typeof blobRef.cid === 'string') {
+
return blobRef.cid;
+
}
+
if ('$link' in blobRef && typeof blobRef.$link === 'string') {
+
return blobRef.$link;
+
}
+
}
+
return null;
+
}
+
+
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string): Promise<void> {
+
console.log('Caching site', did, rkey);
+
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
+
}
+
+
async function cacheFiles(
+
did: string,
+
site: string,
+
entries: Entry[],
+
pdsEndpoint: string,
+
pathPrefix: string
+
): Promise<void> {
+
for (const entry of entries) {
+
const currentPath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
+
const node = entry.node;
+
+
if ('type' in node && node.type === 'directory' && 'entries' in node) {
+
await cacheFiles(did, site, node.entries, pdsEndpoint, currentPath);
+
} else if ('type' in node && node.type === 'file' && 'blob' in node) {
+
await cacheFileBlob(did, site, currentPath, node.blob, pdsEndpoint);
+
}
+
}
+
}
+
+
async function cacheFileBlob(
+
did: string,
+
site: string,
+
filePath: string,
+
blobRef: any,
+
pdsEndpoint: string
+
): Promise<void> {
+
const cid = extractBlobCid(blobRef);
+
if (!cid) {
+
console.error('Could not extract CID from blob', blobRef);
+
return;
+
}
+
+
const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
+
+
// Allow up to 100MB per file blob
+
const content = await safeFetchBlob(blobUrl, { maxSize: 100 * 1024 * 1024 });
+
+
const cacheFile = `${CACHE_DIR}/${did}/${site}/${filePath}`;
+
const fileDir = cacheFile.substring(0, cacheFile.lastIndexOf('/'));
+
+
if (fileDir && !existsSync(fileDir)) {
+
mkdirSync(fileDir, { recursive: true });
+
}
+
+
await writeFile(cacheFile, content);
+
console.log('Cached file', filePath, content.length, 'bytes');
+
}
+
+
export function getCachedFilePath(did: string, site: string, filePath: string): string {
+
return `${CACHE_DIR}/${did}/${site}/${filePath}`;
+
}
+
+
export function isCached(did: string, site: string): boolean {
+
return existsSync(`${CACHE_DIR}/${did}/${site}`);
+
}
+213
hosting-service/src/server.ts
···
+
import { Hono } from 'hono';
+
import { serveStatic } from 'hono/bun';
+
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached } from './lib/utils';
+
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
+
import { existsSync } from 'fs';
+
+
const app = new Hono();
+
+
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
+
+
// Helper to serve files from cache
+
async function serveFromCache(did: string, rkey: string, filePath: string) {
+
// Default to index.html if path is empty or ends with /
+
let requestPath = filePath || 'index.html';
+
if (requestPath.endsWith('/')) {
+
requestPath += 'index.html';
+
}
+
+
const cachedFile = getCachedFilePath(did, rkey, requestPath);
+
+
if (existsSync(cachedFile)) {
+
const file = Bun.file(cachedFile);
+
return new Response(file);
+
}
+
+
// Try index.html for directory-like paths
+
if (!requestPath.includes('.')) {
+
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
+
if (existsSync(indexFile)) {
+
const file = Bun.file(indexFile);
+
return new Response(file);
+
}
+
}
+
+
return new Response('Not Found', { status: 404 });
+
}
+
+
// Helper to serve files from cache with HTML path rewriting for /s/ routes
+
async function serveFromCacheWithRewrite(
+
did: string,
+
rkey: string,
+
filePath: string,
+
basePath: string
+
) {
+
// Default to index.html if path is empty or ends with /
+
let requestPath = filePath || 'index.html';
+
if (requestPath.endsWith('/')) {
+
requestPath += 'index.html';
+
}
+
+
const cachedFile = getCachedFilePath(did, rkey, requestPath);
+
+
if (existsSync(cachedFile)) {
+
const file = Bun.file(cachedFile);
+
+
// Check if this is HTML content that needs rewriting
+
if (isHtmlContent(requestPath, file.type)) {
+
const content = await file.text();
+
const rewritten = rewriteHtmlPaths(content, basePath);
+
return new Response(rewritten, {
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
},
+
});
+
}
+
+
// Non-HTML files served as-is
+
return new Response(file);
+
}
+
+
// Try index.html for directory-like paths
+
if (!requestPath.includes('.')) {
+
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
+
if (existsSync(indexFile)) {
+
const file = Bun.file(indexFile);
+
const content = await file.text();
+
const rewritten = rewriteHtmlPaths(content, basePath);
+
return new Response(rewritten, {
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
},
+
});
+
}
+
}
+
+
return new Response('Not Found', { status: 404 });
+
}
+
+
// Helper to ensure site is cached
+
async function ensureSiteCached(did: string, rkey: string): Promise<boolean> {
+
if (isCached(did, rkey)) {
+
return true;
+
}
+
+
// Fetch and cache the site
+
const record = await fetchSiteRecord(did, rkey);
+
if (!record) {
+
console.error('Site record not found', did, rkey);
+
return false;
+
}
+
+
const pdsEndpoint = await getPdsForDid(did);
+
if (!pdsEndpoint) {
+
console.error('PDS not found for DID', did);
+
return false;
+
}
+
+
try {
+
await downloadAndCacheSite(did, rkey, record, pdsEndpoint);
+
return true;
+
} catch (err) {
+
console.error('Failed to cache site', did, rkey, err);
+
return false;
+
}
+
}
+
+
// Route 4: Direct file serving (no DB) - /s.wisp.place/:identifier/:site/*
+
app.get('/s/:identifier/:site/*', async (c) => {
+
const identifier = c.req.param('identifier');
+
const site = c.req.param('site');
+
const filePath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
+
+
console.log('[Direct] Serving', { identifier, site, filePath });
+
+
// Resolve identifier to DID
+
const did = await resolveDid(identifier);
+
if (!did) {
+
return c.text('Invalid identifier', 400);
+
}
+
+
// Ensure site is cached
+
const cached = await ensureSiteCached(did, site);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
// Serve with HTML path rewriting to handle absolute paths
+
const basePath = `/s/${identifier}/${site}/`;
+
return serveFromCacheWithRewrite(did, site, filePath, basePath);
+
});
+
+
// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
+
app.get('/*', async (c) => {
+
const hostname = c.req.header('host') || '';
+
const path = c.req.path.replace(/^\//, '');
+
+
console.log('[Request]', { hostname, path });
+
+
// Check if this is a DNS hash subdomain
+
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
+
if (dnsMatch) {
+
const hash = dnsMatch[1];
+
const baseDomain = dnsMatch[2];
+
+
console.log('[DNS Hash] Looking up', { hash, baseDomain });
+
+
if (baseDomain !== BASE_HOST) {
+
return c.text('Invalid base domain', 400);
+
}
+
+
const customDomain = await getCustomDomainByHash(hash);
+
if (!customDomain) {
+
return c.text('Custom domain not found or not verified', 404);
+
}
+
+
const rkey = customDomain.rkey || 'self';
+
const cached = await ensureSiteCached(customDomain.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
return serveFromCache(customDomain.did, rkey, path);
+
}
+
+
// Route 2: Registered subdomains - /*.wisp.place/*
+
if (hostname.endsWith(`.${BASE_HOST}`)) {
+
const subdomain = hostname.replace(`.${BASE_HOST}`, '');
+
+
console.log('[Subdomain] Looking up', { subdomain, fullDomain: hostname });
+
+
const domainInfo = await getWispDomain(hostname);
+
if (!domainInfo) {
+
return c.text('Subdomain not registered', 404);
+
}
+
+
const rkey = domainInfo.rkey || 'self';
+
const cached = await ensureSiteCached(domainInfo.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
return serveFromCache(domainInfo.did, rkey, path);
+
}
+
+
// Route 1: Custom domains - /*
+
console.log('[Custom Domain] Looking up', { hostname });
+
+
const customDomain = await getCustomDomain(hostname);
+
if (!customDomain) {
+
return c.text('Custom domain not found or not verified', 404);
+
}
+
+
const rkey = customDomain.rkey || 'self';
+
const cached = await ensureSiteCached(customDomain.did, rkey);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
return serveFromCache(customDomain.did, rkey, path);
+
});
+
+
export default app;
+864 -298
public/editor/editor.tsx
···
-
import { useState } from 'react'
+
import { useState, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { Button } from '@public/components/ui/button'
import {
···
DialogTitle,
DialogFooter
} from '@public/components/ui/dialog'
-
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
import {
Globe,
Upload,
-
Settings,
ExternalLink,
CheckCircle2,
XCircle,
-
AlertCircle
+
AlertCircle,
+
Loader2,
+
Trash2,
+
RefreshCw,
+
Settings
} from 'lucide-react'
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
import Layout from '@public/layouts'
-
// Mock user data - replace with actual auth
-
const mockUser = {
-
did: 'did:plc:abc123xyz',
-
handle: 'alice.bsky.social',
-
wispSubdomain: 'alice'
+
interface UserInfo {
+
did: string
+
handle: string
+
}
+
+
interface Site {
+
did: string
+
rkey: string
+
display_name: string | null
+
created_at: number
+
updated_at: number
+
}
+
+
interface CustomDomain {
+
id: string
+
domain: string
+
did: string
+
rkey: string
+
verified: boolean
+
last_verified_at: number | null
+
created_at: number
+
}
+
+
interface WispDomain {
+
domain: string
+
rkey: string | null
}
function Dashboard() {
+
// User state
+
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
+
const [loading, setLoading] = useState(true)
+
+
// Sites state
+
const [sites, setSites] = useState<Site[]>([])
+
const [sitesLoading, setSitesLoading] = useState(true)
+
const [isSyncing, setIsSyncing] = useState(false)
+
+
// Domains state
+
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
+
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
+
const [domainsLoading, setDomainsLoading] = useState(true)
+
+
// Site configuration state
+
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
+
const [selectedDomain, setSelectedDomain] = useState<string>('')
+
const [isSavingConfig, setIsSavingConfig] = useState(false)
+
+
// Upload state
+
const [siteName, setSiteName] = useState('')
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
+
const [isUploading, setIsUploading] = useState(false)
+
const [uploadProgress, setUploadProgress] = useState('')
+
+
// Custom domain modal state
+
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
const [customDomain, setCustomDomain] = useState('')
-
const [verificationStatus, setVerificationStatus] = useState<
-
'idle' | 'verifying' | 'success' | 'error'
-
>('idle')
-
const [selectedSite, setSelectedSite] = useState('')
+
const [isAddingDomain, setIsAddingDomain] = useState(false)
+
const [verificationStatus, setVerificationStatus] = useState<{
+
[id: string]: 'idle' | 'verifying' | 'success' | 'error'
+
}>({})
+
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
+
+
// Fetch user info on mount
+
useEffect(() => {
+
fetchUserInfo()
+
fetchSites()
+
fetchDomains()
+
}, [])
+
+
const fetchUserInfo = async () => {
+
try {
+
const response = await fetch('/api/user/info')
+
const data = await response.json()
+
setUserInfo(data)
+
} catch (err) {
+
console.error('Failed to fetch user info:', err)
+
} finally {
+
setLoading(false)
+
}
+
}
+
+
const fetchSites = async () => {
+
try {
+
const response = await fetch('/api/user/sites')
+
const data = await response.json()
+
setSites(data.sites || [])
+
} catch (err) {
+
console.error('Failed to fetch sites:', err)
+
} finally {
+
setSitesLoading(false)
+
}
+
}
+
+
const syncSites = async () => {
+
setIsSyncing(true)
+
try {
+
const response = await fetch('/api/user/sync', {
+
method: 'POST'
+
})
+
const data = await response.json()
+
if (data.success) {
+
console.log(`Synced ${data.synced} sites from PDS`)
+
// Refresh sites list
+
await fetchSites()
+
}
+
} catch (err) {
+
console.error('Failed to sync sites:', err)
+
alert('Failed to sync sites from PDS')
+
} finally {
+
setIsSyncing(false)
+
}
+
}
+
+
const fetchDomains = async () => {
+
try {
+
const response = await fetch('/api/user/domains')
+
const data = await response.json()
+
setWispDomain(data.wispDomain)
+
setCustomDomains(data.customDomains || [])
+
} catch (err) {
+
console.error('Failed to fetch domains:', err)
+
} finally {
+
setDomainsLoading(false)
+
}
+
}
+
+
const getSiteUrl = (site: Site) => {
+
// Check if this site is mapped to the wisp.place domain
+
if (wispDomain && wispDomain.rkey === site.rkey) {
+
return `https://${wispDomain.domain}`
+
}
+
+
// Check if this site is mapped to any custom domain
+
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
+
if (customDomain) {
+
return `https://${customDomain.domain}`
+
}
+
+
// Default fallback URL
+
if (!userInfo) return '#'
+
return `https://sites.wisp.place/${site.did}/${site.rkey}`
+
}
+
+
const getSiteDomainName = (site: Site) => {
+
if (wispDomain && wispDomain.rkey === site.rkey) {
+
return wispDomain.domain
+
}
+
+
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
+
if (customDomain) {
+
return customDomain.domain
+
}
+
+
return `sites.wisp.place/${site.did}/${site.rkey}`
+
}
+
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+
if (e.target.files && e.target.files.length > 0) {
+
setSelectedFiles(e.target.files)
+
}
+
}
+
+
const handleUpload = async () => {
+
if (!siteName) {
+
alert('Please enter a site name')
+
return
+
}
+
+
setIsUploading(true)
+
setUploadProgress('Preparing files...')
+
+
try {
+
const formData = new FormData()
+
formData.append('siteName', siteName)
+
+
if (selectedFiles) {
+
for (let i = 0; i < selectedFiles.length; i++) {
+
formData.append('files', selectedFiles[i])
+
}
+
}
+
+
setUploadProgress('Uploading to AT Protocol...')
+
const response = await fetch('/wisp/upload-files', {
+
method: 'POST',
+
body: formData
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
setUploadProgress('Upload complete!')
+
setSiteName('')
+
setSelectedFiles(null)
+
+
// Refresh sites list
+
await fetchSites()
+
+
// Reset form
+
setTimeout(() => {
+
setUploadProgress('')
+
setIsUploading(false)
+
}, 1500)
+
} else {
+
throw new Error(data.error || 'Upload failed')
+
}
+
} catch (err) {
+
console.error('Upload error:', err)
+
alert(
+
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
setIsUploading(false)
+
setUploadProgress('')
+
}
+
}
-
const [configureModalOpen, setConfigureModalOpen] = useState(false)
-
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
-
const [currentSite, setCurrentSite] = useState<{
-
id: string
-
name: string
-
domain: string | null
-
} | null>(null)
-
const [selectedDomain, setSelectedDomain] = useState<string>('')
+
const handleAddCustomDomain = async () => {
+
if (!customDomain) {
+
alert('Please enter a domain')
+
return
+
}
-
// Mock sites data
-
const [sites] = useState([
-
{
-
id: '1',
-
name: 'my-blog',
-
domain: 'alice.wisp.place',
-
status: 'active'
-
},
-
{ id: '2', name: 'portfolio', domain: null, status: 'active' },
-
{
-
id: '3',
-
name: 'docs-site',
-
domain: 'docs.example.com',
-
status: 'active'
+
setIsAddingDomain(true)
+
try {
+
const response = await fetch('/api/domain/custom/add', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ domain: customDomain })
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
setCustomDomain('')
+
setAddDomainModalOpen(false)
+
await fetchDomains()
+
+
// Automatically show DNS configuration for the newly added domain
+
setViewDomainDNS(data.id)
+
} else {
+
throw new Error(data.error || 'Failed to add domain')
+
}
+
} catch (err) {
+
console.error('Add domain error:', err)
+
alert(
+
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
} finally {
+
setIsAddingDomain(false)
}
-
])
+
}
+
+
const handleVerifyDomain = async (id: string) => {
+
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
-
const availableDomains = [
-
{ value: 'alice.wisp.place', label: 'alice.wisp.place', type: 'wisp' },
-
{
-
value: 'docs.example.com',
-
label: 'docs.example.com',
-
type: 'custom'
-
},
-
{ value: 'none', label: 'No domain (use default URL)', type: 'none' }
-
]
+
try {
+
const response = await fetch('/api/domain/custom/verify', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ id })
+
})
-
const handleVerifyDNS = async () => {
-
setVerificationStatus('verifying')
-
// Simulate DNS verification
-
setTimeout(() => {
-
setVerificationStatus('success')
-
}, 2000)
+
const data = await response.json()
+
if (data.success && data.verified) {
+
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
+
await fetchDomains()
+
} else {
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
+
if (data.error) {
+
alert(`Verification failed: ${data.error}`)
+
}
+
}
+
} catch (err) {
+
console.error('Verify domain error:', err)
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
+
alert(
+
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
}
}
-
const handleConfigureSite = (site: {
-
id: string
-
name: string
-
domain: string | null
-
}) => {
-
setCurrentSite(site)
-
setSelectedDomain(site.domain || 'none')
-
setConfigureModalOpen(true)
+
const handleDeleteCustomDomain = async (id: string) => {
+
if (!confirm('Are you sure you want to remove this custom domain?')) {
+
return
+
}
+
+
try {
+
const response = await fetch(`/api/domain/custom/${id}`, {
+
method: 'DELETE'
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
await fetchDomains()
+
} else {
+
throw new Error('Failed to delete domain')
+
}
+
} catch (err) {
+
console.error('Delete domain error:', err)
+
alert(
+
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
}
}
-
const handleSaveConfiguration = () => {
-
console.log(
-
'[v0] Saving configuration for site:',
-
currentSite?.name,
-
'with domain:',
-
selectedDomain
-
)
-
// TODO: Implement actual save logic
-
setConfigureModalOpen(false)
+
const handleConfigureSite = (site: Site) => {
+
setConfiguringSite(site)
+
+
// Determine current domain mapping
+
if (wispDomain && wispDomain.rkey === site.rkey) {
+
setSelectedDomain('wisp')
+
} else {
+
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
+
if (customDomain) {
+
setSelectedDomain(customDomain.id)
+
} else {
+
setSelectedDomain('none')
+
}
+
}
}
-
const getSiteUrl = (site: { name: string; domain: string | null }) => {
-
if (site.domain) {
-
return `https://${site.domain}`
+
const handleSaveSiteConfig = async () => {
+
if (!configuringSite) return
+
+
setIsSavingConfig(true)
+
try {
+
if (selectedDomain === 'wisp') {
+
// Map to wisp.place domain
+
const response = await fetch('/api/domain/wisp/map-site', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ siteRkey: configuringSite.rkey })
+
})
+
const data = await response.json()
+
if (!data.success) throw new Error('Failed to map site')
+
} else if (selectedDomain === 'none') {
+
// Unmap from all domains
+
// Unmap wisp domain if this site was mapped to it
+
if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
+
await fetch('/api/domain/wisp/map-site', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ siteRkey: null })
+
})
+
}
+
+
// Unmap from custom domains
+
const mappedCustom = customDomains.find(
+
(d) => d.rkey === configuringSite.rkey
+
)
+
if (mappedCustom) {
+
await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ siteRkey: null })
+
})
+
}
+
} else {
+
// Map to a custom domain
+
const response = await fetch(
+
`/api/domain/custom/${selectedDomain}/map-site`,
+
{
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ siteRkey: configuringSite.rkey })
+
}
+
)
+
const data = await response.json()
+
if (!data.success) throw new Error('Failed to map site')
+
}
+
+
// Refresh domains to get updated mappings
+
await fetchDomains()
+
setConfiguringSite(null)
+
} catch (err) {
+
console.error('Save config error:', err)
+
alert(
+
`Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
} finally {
+
setIsSavingConfig(false)
}
-
return `https://sites.wisp.place/${mockUser.did}/${site.name}`
+
}
+
+
if (loading) {
+
return (
+
<div className="w-full min-h-screen bg-background flex items-center justify-center">
+
<Loader2 className="w-8 h-8 animate-spin text-primary" />
+
</div>
+
)
}
return (
···
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
-
{mockUser.handle}
+
{userInfo?.handle || 'Loading...'}
</span>
</div>
</div>
···
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
<Card>
<CardHeader>
-
<CardTitle>Your Sites</CardTitle>
-
<CardDescription>
-
View and manage all your deployed sites
-
</CardDescription>
+
<div className="flex items-center justify-between">
+
<div>
+
<CardTitle>Your Sites</CardTitle>
+
<CardDescription>
+
View and manage all your deployed sites
+
</CardDescription>
+
</div>
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={syncSites}
+
disabled={isSyncing || sitesLoading}
+
>
+
<RefreshCw
+
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
+
/>
+
Sync from PDS
+
</Button>
+
</div>
</CardHeader>
<CardContent className="space-y-4">
-
{sites.map((site) => (
-
<div
-
key={site.id}
-
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
-
>
-
<div className="flex-1">
-
<div className="flex items-center gap-3 mb-2">
-
<h3 className="font-semibold text-lg">
-
{site.name}
-
</h3>
-
<Badge
-
variant="secondary"
-
className="text-xs"
+
{sitesLoading ? (
+
<div className="flex items-center justify-center py-8">
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
+
</div>
+
) : sites.length === 0 ? (
+
<div className="text-center py-8 text-muted-foreground">
+
<p>No sites yet. Upload your first site!</p>
+
</div>
+
) : (
+
sites.map((site) => (
+
<div
+
key={`${site.did}-${site.rkey}`}
+
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
+
>
+
<div className="flex-1">
+
<div className="flex items-center gap-3 mb-2">
+
<h3 className="font-semibold text-lg">
+
{site.display_name || site.rkey}
+
</h3>
+
<Badge
+
variant="secondary"
+
className="text-xs"
+
>
+
active
+
</Badge>
+
</div>
+
<a
+
href={getSiteUrl(site)}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
>
-
{site.status}
-
</Badge>
+
{getSiteDomainName(site)}
+
<ExternalLink className="w-3 h-3" />
+
</a>
</div>
-
<a
-
href={getSiteUrl(site)}
-
target="_blank"
-
rel="noopener noreferrer"
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() => handleConfigureSite(site)}
>
-
{site.domain ||
-
`sites.wisp.place/${mockUser.did}/${site.name}`}
-
<ExternalLink className="w-3 h-3" />
-
</a>
+
<Settings className="w-4 h-4 mr-2" />
+
Configure
+
</Button>
</div>
-
<Button
-
variant="outline"
-
size="sm"
-
onClick={() =>
-
handleConfigureSite(site)
-
}
-
>
-
<Settings className="w-4 h-4 mr-2" />
-
Configure
-
</Button>
-
</div>
-
))}
+
))
+
)}
</CardContent>
</Card>
</TabsContent>
···
<CardHeader>
<CardTitle>wisp.place Subdomain</CardTitle>
<CardDescription>
-
Your free subdomain on the wisp.place
-
network
+
Your free subdomain on the wisp.place network
</CardDescription>
</CardHeader>
<CardContent>
-
<div className="flex items-center gap-2 p-4 bg-muted/50 rounded-lg">
-
<CheckCircle2 className="w-5 h-5 text-green-500" />
-
<span className="font-mono text-lg">
-
{mockUser.wispSubdomain}.wisp.place
-
</span>
-
</div>
-
<p className="text-sm text-muted-foreground mt-3">
-
Configure which site uses this domain in the
-
Sites tab
-
</p>
+
{domainsLoading ? (
+
<div className="flex items-center justify-center py-4">
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
+
</div>
+
) : wispDomain ? (
+
<>
+
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
+
<div className="flex items-center gap-2">
+
<CheckCircle2 className="w-5 h-5 text-green-500" />
+
<span className="font-mono text-lg">
+
{wispDomain.domain}
+
</span>
+
</div>
+
{wispDomain.rkey && (
+
<p className="text-xs text-muted-foreground ml-7">
+
→ Mapped to site: {wispDomain.rkey}
+
</p>
+
)}
+
</div>
+
<p className="text-sm text-muted-foreground mt-3">
+
{wispDomain.rkey
+
? 'This domain is mapped to a specific site'
+
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
+
</p>
+
</>
+
) : (
+
<div className="text-center py-4 text-muted-foreground">
+
<p>No wisp.place subdomain claimed yet.</p>
+
<p className="text-sm mt-1">
+
You should have claimed one during onboarding!
+
</p>
+
</div>
+
)}
</CardContent>
</Card>
···
Add Custom Domain
</Button>
-
<div className="space-y-2">
-
<div className="flex items-center justify-between p-3 border border-border rounded-lg">
-
<div className="flex items-center gap-2">
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
-
<span className="font-mono">
-
docs.example.com
-
</span>
-
</div>
-
<Badge variant="secondary">
-
Verified
-
</Badge>
+
{domainsLoading ? (
+
<div className="flex items-center justify-center py-4">
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
+
</div>
+
) : customDomains.length === 0 ? (
+
<div className="text-center py-4 text-muted-foreground text-sm">
+
No custom domains added yet
</div>
-
</div>
+
) : (
+
<div className="space-y-2">
+
{customDomains.map((domain) => (
+
<div
+
key={domain.id}
+
className="flex items-center justify-between p-3 border border-border rounded-lg"
+
>
+
<div className="flex flex-col gap-1 flex-1">
+
<div className="flex items-center gap-2">
+
{domain.verified ? (
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
+
) : (
+
<XCircle className="w-4 h-4 text-red-500" />
+
)}
+
<span className="font-mono">
+
{domain.domain}
+
</span>
+
</div>
+
{domain.rkey && domain.rkey !== 'self' && (
+
<p className="text-xs text-muted-foreground ml-6">
+
→ Mapped to site: {domain.rkey}
+
</p>
+
)}
+
</div>
+
<div className="flex items-center gap-2">
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() =>
+
setViewDomainDNS(domain.id)
+
}
+
>
+
View DNS
+
</Button>
+
{domain.verified ? (
+
<Badge variant="secondary">
+
Verified
+
</Badge>
+
) : (
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() =>
+
handleVerifyDomain(domain.id)
+
}
+
disabled={
+
verificationStatus[
+
domain.id
+
] === 'verifying'
+
}
+
>
+
{verificationStatus[
+
domain.id
+
] === 'verifying' ? (
+
<>
+
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
+
Verifying...
+
</>
+
) : (
+
'Verify DNS'
+
)}
+
</Button>
+
)}
+
<Button
+
variant="ghost"
+
size="sm"
+
onClick={() =>
+
handleDeleteCustomDomain(
+
domain.id
+
)
+
}
+
>
+
<Trash2 className="w-4 h-4" />
+
</Button>
+
</div>
+
</div>
+
))}
+
</div>
+
)}
</CardContent>
</Card>
</TabsContent>
···
<CardHeader>
<CardTitle>Upload Site</CardTitle>
<CardDescription>
-
Deploy a new site from a folder or Git
-
repository
+
Deploy a new site from a folder or Git repository
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
···
<Input
id="site-name"
placeholder="my-awesome-site"
+
value={siteName}
+
onChange={(e) => setSiteName(e.target.value)}
+
disabled={isUploading}
/>
</div>
···
Upload Folder
</h3>
<p className="text-sm text-muted-foreground mb-4">
-
Drag and drop or click to upload
-
your static site files
+
Drag and drop or click to upload your
+
static site files
</p>
-
<Button variant="outline">
-
Choose Folder
-
</Button>
+
<input
+
type="file"
+
id="file-upload"
+
multiple
+
onChange={handleFileSelect}
+
className="hidden"
+
{...(({ webkitdirectory: '', directory: '' } as any))}
+
disabled={isUploading}
+
/>
+
<label htmlFor="file-upload">
+
<Button
+
variant="outline"
+
type="button"
+
onClick={() =>
+
document
+
.getElementById('file-upload')
+
?.click()
+
}
+
disabled={isUploading}
+
>
+
Choose Folder
+
</Button>
+
</label>
+
{selectedFiles && selectedFiles.length > 0 && (
+
<p className="text-sm text-muted-foreground mt-3">
+
{selectedFiles.length} files selected
+
</p>
+
)}
</CardContent>
</Card>
-
<Card className="border-2 border-dashed hover:border-accent transition-colors">
+
<Card className="border-2 border-dashed opacity-50">
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="font-semibold mb-2">
Connect Git Repository
</h3>
<p className="text-sm text-muted-foreground mb-4">
-
Link your GitHub, GitLab, or any
-
Git repository
+
Link your GitHub, GitLab, or any Git
+
repository
</p>
-
<Button variant="outline">
-
Connect Git
-
</Button>
+
<Badge variant="secondary">Coming soon!</Badge>
</CardContent>
</Card>
</div>
+
+
{uploadProgress && (
+
<div className="p-4 bg-muted rounded-lg">
+
<div className="flex items-center gap-2">
+
<Loader2 className="w-4 h-4 animate-spin" />
+
<span className="text-sm">{uploadProgress}</span>
+
</div>
+
</div>
+
)}
+
+
<Button
+
onClick={handleUpload}
+
className="w-full"
+
disabled={!siteName || isUploading}
+
>
+
{isUploading ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Uploading...
+
</>
+
) : (
+
<>
+
{selectedFiles && selectedFiles.length > 0
+
? 'Upload & Deploy'
+
: 'Create Empty Site'}
+
</>
+
)}
+
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
+
{/* Add Custom Domain Modal */}
+
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
+
<DialogContent className="sm:max-w-lg">
+
<DialogHeader>
+
<DialogTitle>Add Custom Domain</DialogTitle>
+
<DialogDescription>
+
Enter your domain name. After adding, you'll see the DNS
+
records to configure.
+
</DialogDescription>
+
</DialogHeader>
+
<div className="space-y-4 py-4">
+
<div className="space-y-2">
+
<Label htmlFor="new-domain">Domain Name</Label>
+
<Input
+
id="new-domain"
+
placeholder="example.com"
+
value={customDomain}
+
onChange={(e) => setCustomDomain(e.target.value)}
+
/>
+
<p className="text-xs text-muted-foreground">
+
After adding, click "View DNS" to see the records you
+
need to configure.
+
</p>
+
</div>
+
</div>
+
<DialogFooter className="flex-col sm:flex-row gap-2">
+
<Button
+
variant="outline"
+
onClick={() => {
+
setAddDomainModalOpen(false)
+
setCustomDomain('')
+
}}
+
className="w-full sm:w-auto"
+
disabled={isAddingDomain}
+
>
+
Cancel
+
</Button>
+
<Button
+
onClick={handleAddCustomDomain}
+
disabled={!customDomain || isAddingDomain}
+
className="w-full sm:w-auto"
+
>
+
{isAddingDomain ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Adding...
+
</>
+
) : (
+
'Add Domain'
+
)}
+
</Button>
+
</DialogFooter>
+
</DialogContent>
+
</Dialog>
+
+
{/* Site Configuration Modal */}
<Dialog
-
open={configureModalOpen}
-
onOpenChange={setConfigureModalOpen}
+
open={configuringSite !== null}
+
onOpenChange={(open) => !open && setConfiguringSite(null)}
>
-
<DialogContent className="sm:max-w-md">
+
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Configure Site Domain</DialogTitle>
<DialogDescription>
-
Choose which domain {currentSite?.name} should use
+
Choose which domain this site should use
</DialogDescription>
</DialogHeader>
-
<div className="space-y-4 py-4">
-
<RadioGroup
-
value={selectedDomain}
-
onValueChange={setSelectedDomain}
-
>
-
{availableDomains.map((domain) => (
-
<div
-
key={domain.value}
-
className="flex items-center space-x-2"
-
>
-
<RadioGroupItem
-
value={domain.value}
-
id={domain.value}
-
/>
-
<Label
-
htmlFor={domain.value}
-
className="flex-1 cursor-pointer"
-
>
-
<div className="flex items-center justify-between">
-
<span className="font-mono text-sm">
-
{domain.label}
-
</span>
-
{domain.type === 'wisp' && (
-
<Badge
-
variant="secondary"
-
className="text-xs"
-
>
+
{configuringSite && (
+
<div className="space-y-4 py-4">
+
<div className="p-3 bg-muted/30 rounded-lg">
+
<p className="text-sm font-medium mb-1">Site:</p>
+
<p className="font-mono text-sm">
+
{configuringSite.display_name ||
+
configuringSite.rkey}
+
</p>
+
</div>
+
+
<RadioGroup
+
value={selectedDomain}
+
onValueChange={setSelectedDomain}
+
>
+
{wispDomain && (
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="wisp" id="wisp" />
+
<Label
+
htmlFor="wisp"
+
className="flex-1 cursor-pointer"
+
>
+
<div className="flex items-center justify-between">
+
<span className="font-mono text-sm">
+
{wispDomain.domain}
+
</span>
+
<Badge variant="secondary" className="text-xs ml-2">
Free
</Badge>
-
)}
-
{domain.type === 'custom' && (
-
<Badge
-
variant="outline"
-
className="text-xs"
-
>
-
Custom
-
</Badge>
-
)}
+
</div>
+
</Label>
+
</div>
+
)}
+
+
{customDomains
+
.filter((d) => d.verified)
+
.map((domain) => (
+
<div
+
key={domain.id}
+
className="flex items-center space-x-2"
+
>
+
<RadioGroupItem
+
value={domain.id}
+
id={domain.id}
+
/>
+
<Label
+
htmlFor={domain.id}
+
className="flex-1 cursor-pointer"
+
>
+
<div className="flex items-center justify-between">
+
<span className="font-mono text-sm">
+
{domain.domain}
+
</span>
+
<Badge
+
variant="outline"
+
className="text-xs ml-2"
+
>
+
Custom
+
</Badge>
+
</div>
+
</Label>
+
</div>
+
))}
+
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="none" id="none" />
+
<Label htmlFor="none" className="flex-1 cursor-pointer">
+
<div className="flex flex-col">
+
<span className="text-sm">Default URL</span>
+
<span className="text-xs text-muted-foreground font-mono break-all">
+
sites.wisp.place/{configuringSite.did}/
+
{configuringSite.rkey}
+
</span>
</div>
</Label>
</div>
-
))}
-
</RadioGroup>
-
</div>
+
</RadioGroup>
+
</div>
+
)}
<DialogFooter>
<Button
variant="outline"
-
onClick={() => setConfigureModalOpen(false)}
+
onClick={() => setConfiguringSite(null)}
+
disabled={isSavingConfig}
>
Cancel
</Button>
-
<Button onClick={handleSaveConfiguration}>
-
Save Configuration
+
<Button
+
onClick={handleSaveSiteConfig}
+
disabled={isSavingConfig}
+
>
+
{isSavingConfig ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Saving...
+
</>
+
) : (
+
'Save'
+
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+
{/* View DNS Records Modal */}
<Dialog
-
open={addDomainModalOpen}
-
onOpenChange={setAddDomainModalOpen}
+
open={viewDomainDNS !== null}
+
onOpenChange={(open) => !open && setViewDomainDNS(null)}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
-
<DialogTitle>Add Custom Domain</DialogTitle>
+
<DialogTitle>DNS Configuration</DialogTitle>
<DialogDescription>
-
Configure DNS records to verify your domain
-
ownership
+
Add these DNS records to your domain provider
</DialogDescription>
</DialogHeader>
-
<div className="space-y-4 py-4">
-
<div className="space-y-2">
-
<Label htmlFor="new-domain">Domain Name</Label>
-
<Input
-
id="new-domain"
-
placeholder="example.com"
-
value={customDomain}
-
onChange={(e) =>
-
setCustomDomain(e.target.value)
-
}
-
/>
-
</div>
-
-
{customDomain && (
-
<div className="space-y-4 p-4 bg-muted/30 rounded-lg border border-border">
-
<div>
-
<h4 className="font-semibold mb-2 flex items-center gap-2">
-
<AlertCircle className="w-4 h-4 text-accent" />
-
DNS Configuration Required
-
</h4>
-
<p className="text-sm text-muted-foreground mb-4">
-
Add these DNS records to your domain
-
provider:
-
</p>
-
</div>
+
{viewDomainDNS && userInfo && (
+
<>
+
{(() => {
+
const domain = customDomains.find(
+
(d) => d.id === viewDomainDNS
+
)
+
if (!domain) return null
-
<div className="space-y-3">
-
<div className="p-3 bg-background rounded border border-border">
-
<div className="flex justify-between items-start mb-1">
-
<span className="text-xs font-semibold text-muted-foreground">
-
TXT Record
-
</span>
+
return (
+
<div className="space-y-4 py-4">
+
<div className="p-3 bg-muted/30 rounded-lg">
+
<p className="text-sm font-medium mb-1">
+
Domain:
+
</p>
+
<p className="font-mono text-sm">
+
{domain.domain}
+
</p>
</div>
-
<div className="font-mono text-sm space-y-1">
-
<div>
-
<span className="text-muted-foreground">
-
Name:
-
</span>{' '}
-
_wisp
+
+
<div className="space-y-3">
+
<div className="p-3 bg-background rounded border border-border">
+
<div className="flex justify-between items-start mb-2">
+
<span className="text-xs font-semibold text-muted-foreground">
+
TXT Record (Verification)
+
</span>
+
</div>
+
<div className="font-mono text-xs space-y-2">
+
<div>
+
<span className="text-muted-foreground">
+
Name:
+
</span>{' '}
+
<span className="select-all">
+
_wisp.{domain.domain}
+
</span>
+
</div>
+
<div>
+
<span className="text-muted-foreground">
+
Value:
+
</span>{' '}
+
<span className="select-all break-all">
+
{userInfo.did}
+
</span>
+
</div>
+
</div>
</div>
-
<div>
-
<span className="text-muted-foreground">
-
Value:
-
</span>{' '}
-
{mockUser.did}
+
+
<div className="p-3 bg-background rounded border border-border">
+
<div className="flex justify-between items-start mb-2">
+
<span className="text-xs font-semibold text-muted-foreground">
+
CNAME Record (Pointing)
+
</span>
+
</div>
+
<div className="font-mono text-xs space-y-2">
+
<div>
+
<span className="text-muted-foreground">
+
Name:
+
</span>{' '}
+
<span className="select-all">
+
{domain.domain}
+
</span>
+
</div>
+
<div>
+
<span className="text-muted-foreground">
+
Value:
+
</span>{' '}
+
<span className="select-all">
+
{domain.id}.dns.wisp.place
+
</span>
+
</div>
+
</div>
+
<p className="text-xs text-muted-foreground mt-2">
+
Some DNS providers may require you to use @ or leave it blank for the root domain
+
</p>
</div>
</div>
-
</div>
-
<div className="p-3 bg-background rounded border border-border">
-
<div className="flex justify-between items-start mb-1">
-
<span className="text-xs font-semibold text-muted-foreground">
-
CNAME Record
-
</span>
-
</div>
-
<div className="font-mono text-sm space-y-1">
-
<div>
-
<span className="text-muted-foreground">
-
Name:
-
</span>{' '}
-
@ or {customDomain}
-
</div>
-
<div>
-
<span className="text-muted-foreground">
-
Value:
-
</span>{' '}
-
abc123.dns.wisp.place
-
</div>
+
<div className="p-3 bg-muted/30 rounded-lg">
+
<p className="text-xs text-muted-foreground">
+
💡 After configuring DNS, click "Verify DNS"
+
to check if everything is set up correctly.
+
DNS changes can take a few minutes to
+
propagate.
+
</p>
</div>
</div>
-
</div>
-
</div>
-
)}
-
</div>
-
<DialogFooter className="flex-col sm:flex-row gap-2">
+
)
+
})()}
+
</>
+
)}
+
<DialogFooter>
<Button
variant="outline"
-
onClick={() => {
-
setAddDomainModalOpen(false)
-
setCustomDomain('')
-
setVerificationStatus('idle')
-
}}
-
className="w-full sm:w-auto"
-
>
-
Cancel
-
</Button>
-
<Button
-
onClick={handleVerifyDNS}
-
disabled={
-
!customDomain ||
-
verificationStatus === 'verifying'
-
}
+
onClick={() => setViewDomainDNS(null)}
className="w-full sm:w-auto"
>
-
{verificationStatus === 'verifying' ? (
-
<>Verifying DNS...</>
-
) : verificationStatus === 'success' ? (
-
<>
-
<CheckCircle2 className="w-4 h-4 mr-2" />
-
Verified
-
</>
-
) : verificationStatus === 'error' ? (
-
<>
-
<XCircle className="w-4 h-4 mr-2" />
-
Verification Failed
-
</>
-
) : (
-
<>Verify DNS Records</>
-
)}
+
Close
</Button>
</DialogFooter>
</DialogContent>
+4 -1
public/lib/api.ts
···
import type { app } from '@server'
-
export const api = treaty<typeof app>('localhost:3000')
+
// Use the current host instead of hardcoded localhost
+
const apiHost = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8000'
+
+
export const api = treaty<typeof app>(apiHost)
+12
public/onboarding/index.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<title>Get Started - wisp.place</title>
+
</head>
+
<body>
+
<div id="elysia"></div>
+
<script type="module" src="./onboarding.tsx"></script>
+
</body>
+
</html>
+412
public/onboarding/onboarding.tsx
···
+
import { useState, useEffect } from 'react'
+
import { createRoot } from 'react-dom/client'
+
import { Button } from '@public/components/ui/button'
+
import {
+
Card,
+
CardContent,
+
CardDescription,
+
CardHeader,
+
CardTitle
+
} from '@public/components/ui/card'
+
import { Input } from '@public/components/ui/input'
+
import { Label } from '@public/components/ui/label'
+
import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react'
+
import Layout from '@public/layouts'
+
+
type OnboardingStep = 'domain' | 'upload' | 'complete'
+
+
function Onboarding() {
+
const [step, setStep] = useState<OnboardingStep>('domain')
+
const [handle, setHandle] = useState('')
+
const [isCheckingAvailability, setIsCheckingAvailability] = useState(false)
+
const [isAvailable, setIsAvailable] = useState<boolean | null>(null)
+
const [domain, setDomain] = useState('')
+
const [isClaimingDomain, setIsClaimingDomain] = useState(false)
+
const [claimedDomain, setClaimedDomain] = useState('')
+
+
const [siteName, setSiteName] = useState('')
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
+
const [isUploading, setIsUploading] = useState(false)
+
const [uploadProgress, setUploadProgress] = useState('')
+
+
// Check domain availability as user types
+
useEffect(() => {
+
if (!handle || handle.length < 3) {
+
setIsAvailable(null)
+
setDomain('')
+
return
+
}
+
+
const timeoutId = setTimeout(async () => {
+
setIsCheckingAvailability(true)
+
try {
+
const response = await fetch(
+
`/api/domain/check?handle=${encodeURIComponent(handle)}`
+
)
+
const data = await response.json()
+
setIsAvailable(data.available)
+
setDomain(data.domain || '')
+
} catch (err) {
+
console.error('Error checking availability:', err)
+
setIsAvailable(false)
+
} finally {
+
setIsCheckingAvailability(false)
+
}
+
}, 500)
+
+
return () => clearTimeout(timeoutId)
+
}, [handle])
+
+
const handleClaimDomain = async () => {
+
if (!handle || !isAvailable) return
+
+
setIsClaimingDomain(true)
+
try {
+
const response = await fetch('/api/domain/claim', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ handle })
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
setClaimedDomain(data.domain)
+
setStep('upload')
+
} else {
+
alert('Failed to claim domain. Please try again.')
+
}
+
} catch (err) {
+
console.error('Error claiming domain:', err)
+
alert('Failed to claim domain. Please try again.')
+
} finally {
+
setIsClaimingDomain(false)
+
}
+
}
+
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+
if (e.target.files && e.target.files.length > 0) {
+
setSelectedFiles(e.target.files)
+
}
+
}
+
+
const handleUpload = async () => {
+
if (!siteName) {
+
alert('Please enter a site name')
+
return
+
}
+
+
setIsUploading(true)
+
setUploadProgress('Preparing files...')
+
+
try {
+
const formData = new FormData()
+
formData.append('siteName', siteName)
+
+
if (selectedFiles) {
+
for (let i = 0; i < selectedFiles.length; i++) {
+
formData.append('files', selectedFiles[i])
+
}
+
}
+
+
setUploadProgress('Uploading to AT Protocol...')
+
const response = await fetch('/wisp/upload-files', {
+
method: 'POST',
+
body: formData
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
setUploadProgress('Upload complete!')
+
// Redirect to the claimed domain
+
setTimeout(() => {
+
window.location.href = `https://${claimedDomain}`
+
}, 1500)
+
} else {
+
throw new Error(data.error || 'Upload failed')
+
}
+
} catch (err) {
+
console.error('Upload error:', err)
+
alert(
+
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
+
)
+
setIsUploading(false)
+
setUploadProgress('')
+
}
+
}
+
+
const handleSkipUpload = () => {
+
// Redirect to editor without uploading
+
window.location.href = '/editor'
+
}
+
+
return (
+
<div className="w-full min-h-screen bg-background">
+
{/* Header */}
+
<header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
+
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
+
<div className="flex items-center gap-2">
+
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
+
<Globe className="w-5 h-5 text-primary-foreground" />
+
</div>
+
<span className="text-xl font-semibold text-foreground">
+
wisp.place
+
</span>
+
</div>
+
</div>
+
</header>
+
+
<div className="container mx-auto px-4 py-12 max-w-2xl">
+
{/* Progress indicator */}
+
<div className="mb-8">
+
<div className="flex items-center justify-center gap-2 mb-4">
+
<div
+
className={`w-8 h-8 rounded-full flex items-center justify-center ${
+
step === 'domain'
+
? 'bg-primary text-primary-foreground'
+
: 'bg-green-500 text-white'
+
}`}
+
>
+
{step === 'domain' ? (
+
'1'
+
) : (
+
<CheckCircle2 className="w-5 h-5" />
+
)}
+
</div>
+
<div className="w-16 h-0.5 bg-border"></div>
+
<div
+
className={`w-8 h-8 rounded-full flex items-center justify-center ${
+
step === 'upload'
+
? 'bg-primary text-primary-foreground'
+
: step === 'domain'
+
? 'bg-muted text-muted-foreground'
+
: 'bg-green-500 text-white'
+
}`}
+
>
+
{step === 'complete' ? (
+
<CheckCircle2 className="w-5 h-5" />
+
) : (
+
'2'
+
)}
+
</div>
+
</div>
+
<div className="text-center">
+
<h1 className="text-2xl font-bold mb-2">
+
{step === 'domain' && 'Claim Your Free Domain'}
+
{step === 'upload' && 'Deploy Your First Site'}
+
{step === 'complete' && 'All Set!'}
+
</h1>
+
<p className="text-muted-foreground">
+
{step === 'domain' &&
+
'Choose a subdomain on wisp.place'}
+
{step === 'upload' &&
+
'Upload your site or start with an empty one'}
+
{step === 'complete' && 'Redirecting to your site...'}
+
</p>
+
</div>
+
</div>
+
+
{/* Domain registration step */}
+
{step === 'domain' && (
+
<Card>
+
<CardHeader>
+
<CardTitle>Choose Your Domain</CardTitle>
+
<CardDescription>
+
Pick a unique handle for your free *.wisp.place
+
subdomain
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-4">
+
<div className="space-y-2">
+
<Label htmlFor="handle">Your Handle</Label>
+
<div className="flex gap-2">
+
<div className="relative flex-1">
+
<Input
+
id="handle"
+
placeholder="my-awesome-site"
+
value={handle}
+
onChange={(e) =>
+
setHandle(
+
e.target.value
+
.toLowerCase()
+
.replace(/[^a-z0-9-]/g, '')
+
)
+
}
+
className="pr-10"
+
/>
+
{isCheckingAvailability && (
+
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
+
)}
+
{!isCheckingAvailability &&
+
isAvailable !== null && (
+
<div
+
className={`absolute right-3 top-1/2 -translate-y-1/2 ${
+
isAvailable
+
? 'text-green-500'
+
: 'text-red-500'
+
}`}
+
>
+
{isAvailable ? '✓' : '✗'}
+
</div>
+
)}
+
</div>
+
</div>
+
{domain && (
+
<p className="text-sm text-muted-foreground">
+
Your domain will be:{' '}
+
<span className="font-mono">{domain}</span>
+
</p>
+
)}
+
{isAvailable === false && handle.length >= 3 && (
+
<p className="text-sm text-red-500">
+
This handle is not available or invalid
+
</p>
+
)}
+
</div>
+
+
<Button
+
onClick={handleClaimDomain}
+
disabled={
+
!isAvailable ||
+
isClaimingDomain ||
+
isCheckingAvailability
+
}
+
className="w-full"
+
>
+
{isClaimingDomain ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Claiming Domain...
+
</>
+
) : (
+
<>Claim Domain</>
+
)}
+
</Button>
+
</CardContent>
+
</Card>
+
)}
+
+
{/* Upload step */}
+
{step === 'upload' && (
+
<Card>
+
<CardHeader>
+
<CardTitle>Deploy Your Site</CardTitle>
+
<CardDescription>
+
Upload your static site files or start with an empty
+
site (you can upload later)
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-6">
+
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
+
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
+
<CheckCircle2 className="w-4 h-4" />
+
<span className="font-medium">
+
Domain claimed: {claimedDomain}
+
</span>
+
</div>
+
</div>
+
+
<div className="space-y-2">
+
<Label htmlFor="site-name">Site Name</Label>
+
<Input
+
id="site-name"
+
placeholder="my-site"
+
value={siteName}
+
onChange={(e) => setSiteName(e.target.value)}
+
/>
+
<p className="text-xs text-muted-foreground">
+
A unique identifier for this site in your account
+
</p>
+
</div>
+
+
<div className="space-y-2">
+
<Label>Upload Files (Optional)</Label>
+
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-accent transition-colors">
+
<Upload className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
+
<input
+
type="file"
+
id="file-upload"
+
multiple
+
onChange={handleFileSelect}
+
className="hidden"
+
{...(({ webkitdirectory: '', directory: '' } as any))}
+
/>
+
<label
+
htmlFor="file-upload"
+
className="cursor-pointer"
+
>
+
<Button
+
variant="outline"
+
type="button"
+
onClick={() =>
+
document
+
.getElementById('file-upload')
+
?.click()
+
}
+
>
+
Choose Folder
+
</Button>
+
</label>
+
{selectedFiles && selectedFiles.length > 0 && (
+
<p className="text-sm text-muted-foreground mt-3">
+
{selectedFiles.length} files selected
+
</p>
+
)}
+
</div>
+
<p className="text-xs text-muted-foreground">
+
Supported: HTML, CSS, JS, images, fonts, and more
+
</p>
+
</div>
+
+
{uploadProgress && (
+
<div className="p-4 bg-muted rounded-lg">
+
<div className="flex items-center gap-2">
+
<Loader2 className="w-4 h-4 animate-spin" />
+
<span className="text-sm">
+
{uploadProgress}
+
</span>
+
</div>
+
</div>
+
)}
+
+
<div className="flex gap-3">
+
<Button
+
onClick={handleSkipUpload}
+
variant="outline"
+
className="flex-1"
+
disabled={isUploading}
+
>
+
Skip for Now
+
</Button>
+
<Button
+
onClick={handleUpload}
+
className="flex-1"
+
disabled={!siteName || isUploading}
+
>
+
{isUploading ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Uploading...
+
</>
+
) : (
+
<>
+
{selectedFiles && selectedFiles.length > 0
+
? 'Upload & Deploy'
+
: 'Create Empty Site'}
+
</>
+
)}
+
</Button>
+
</div>
+
</CardContent>
+
</Card>
+
)}
+
</div>
+
</div>
+
)
+
}
+
+
const root = createRoot(document.getElementById('elysia')!)
+
root.render(
+
<Layout>
+
<Onboarding />
+
</Layout>
+
)
+2
src/index.ts
···
import { authRoutes } from './routes/auth'
import { wispRoutes } from './routes/wisp'
import { domainRoutes } from './routes/domain'
+
import { userRoutes } from './routes/user'
const config: Config = {
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
.use(authRoutes(client))
.use(wispRoutes(client))
.use(domainRoutes(client))
+
.use(userRoutes(client))
.get('/client-metadata.json', (c) => {
return createClientMetadata(config)
})
+35 -9
src/lib/db.ts
···
CREATE TABLE IF NOT EXISTS domains (
domain TEXT PRIMARY KEY,
did TEXT UNIQUE NOT NULL,
+
rkey TEXT,
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW())
)
`;
+
+
// Add rkey column if it doesn't exist (for existing databases)
+
try {
+
await db`ALTER TABLE domains ADD COLUMN IF NOT EXISTS rkey TEXT`;
+
} catch (err) {
+
// Column might already exist, ignore
+
}
// Custom domains table for BYOD (bring your own domain)
await db`
···
return rows[0]?.domain ?? null;
};
+
export const getWispDomainInfo = async (did: string) => {
+
const rows = await db`SELECT domain, rkey FROM domains WHERE did = ${did}`;
+
return rows[0] ?? null;
+
};
+
export const getDidByDomain = async (domain: string): Promise<string | null> => {
const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`;
return rows[0]?.did ?? null;
···
}
};
+
export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise<void> => {
+
await db`
+
UPDATE domains
+
SET rkey = ${siteRkey}
+
WHERE did = ${did}
+
`;
+
};
+
+
export const getWispDomainSite = async (did: string): Promise<string | null> => {
+
const rows = await db`SELECT rkey FROM domains WHERE did = ${did}`;
+
return rows[0]?.rkey ?? null;
+
};
+
const stateStore = {
async set(key: string, data: any) {
console.debug('[stateStore] set', key)
···
return rows[0] ?? null;
};
-
export const claimCustomDomain = async (did: string, domain: string, siteName: string, hash: string) => {
+
export const claimCustomDomain = async (did: string, domain: string, hash: string, rkey: string = 'self') => {
const domainLower = domain.toLowerCase();
try {
await db`
-
INSERT INTO custom_domains (id, domain, did, site_name, verified, created_at)
-
VALUES (${hash}, ${domainLower}, ${did}, ${siteName}, false, EXTRACT(EPOCH FROM NOW()))
+
INSERT INTO custom_domains (id, domain, did, rkey, verified, created_at)
+
VALUES (${hash}, ${domainLower}, ${did}, ${rkey}, false, EXTRACT(EPOCH FROM NOW()))
`;
return { success: true, hash };
} catch (err) {
···
}
};
-
export const updateCustomDomainSite = async (id: string, siteName: string) => {
+
export const updateCustomDomainRkey = async (id: string, rkey: string) => {
const rows = await db`
UPDATE custom_domains
-
SET site_name = ${siteName}
+
SET rkey = ${rkey}
WHERE id = ${id}
RETURNING *
`;
···
return rows;
};
-
export const upsertSite = async (did: string, siteName: string, displayName?: string) => {
+
export const upsertSite = async (did: string, rkey: string, displayName?: string) => {
try {
await db`
-
INSERT INTO sites (did, site_name, display_name, created_at, updated_at)
-
VALUES (${did}, ${siteName}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
-
ON CONFLICT (did, site_name)
+
INSERT INTO sites (did, rkey, display_name, created_at, updated_at)
+
VALUES (${did}, ${rkey}, ${displayName || null}, EXTRACT(EPOCH FROM NOW()), EXTRACT(EPOCH FROM NOW()))
+
ON CONFLICT (did, rkey)
DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, sites.display_name),
updated_at = EXTRACT(EPOCH FROM NOW())
+156
src/lib/dns-verify.ts
···
+
import { promises as dns } from 'dns'
+
+
/**
+
* Result of a domain verification process
+
*/
+
export interface VerificationResult {
+
/** Whether the verification was successful */
+
verified: boolean
+
/** Error message if verification failed */
+
error?: string
+
/** DNS records found during verification */
+
found?: {
+
/** TXT records found (used for domain verification) */
+
txt?: string[]
+
/** CNAME record found (used for domain pointing) */
+
cname?: string
+
}
+
}
+
+
/**
+
* Verify domain ownership via TXT record at _wisp.{domain}
+
* Expected format: did:plc:xxx or did:web:xxx
+
*/
+
export const verifyDomainOwnership = async (
+
domain: string,
+
expectedDid: string
+
): Promise<VerificationResult> => {
+
try {
+
const txtDomain = `_wisp.${domain}`
+
+
console.log(`[DNS Verify] Checking TXT record for ${txtDomain}`)
+
console.log(`[DNS Verify] Expected DID: ${expectedDid}`)
+
+
// Query TXT records
+
const records = await dns.resolveTxt(txtDomain)
+
+
// Log what we found
+
const foundTxtValues = records.map((record) => record.join(''))
+
console.log(`[DNS Verify] Found TXT records:`, foundTxtValues)
+
+
// TXT records come as arrays of strings (for multi-part records)
+
// We need to join them and check if any match the expected DID
+
for (const record of records) {
+
const txtValue = record.join('')
+
if (txtValue === expectedDid) {
+
console.log(`[DNS Verify] ✓ TXT record matches!`)
+
return { verified: true, found: { txt: foundTxtValues } }
+
}
+
}
+
+
console.log(`[DNS Verify] ✗ TXT record does not match`)
+
return {
+
verified: false,
+
error: `TXT record at ${txtDomain} does not match expected DID. Expected: ${expectedDid}`,
+
found: { txt: foundTxtValues }
+
}
+
} catch (err: any) {
+
console.log(`[DNS Verify] ✗ TXT lookup error:`, err.message)
+
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
+
return {
+
verified: false,
+
error: `No TXT record found at _wisp.${domain}`,
+
found: { txt: [] }
+
}
+
}
+
return {
+
verified: false,
+
error: `DNS lookup failed: ${err.message}`,
+
found: { txt: [] }
+
}
+
}
+
}
+
+
/**
+
* Verify CNAME record points to the expected hash target
+
* For custom domains, we expect: domain CNAME -> {hash}.dns.wisp.place
+
*/
+
export const verifyCNAME = async (
+
domain: string,
+
expectedHash: string
+
): Promise<VerificationResult> => {
+
try {
+
console.log(`[DNS Verify] Checking CNAME record for ${domain}`)
+
const expectedTarget = `${expectedHash}.dns.wisp.place`
+
console.log(`[DNS Verify] Expected CNAME: ${expectedTarget}`)
+
+
// Resolve CNAME for the domain
+
const cname = await dns.resolveCname(domain)
+
+
// Log what we found
+
const foundCname =
+
cname.length > 0
+
? cname[0]?.toLowerCase().replace(/\.$/, '')
+
: null
+
console.log(`[DNS Verify] Found CNAME:`, foundCname || 'none')
+
+
if (cname.length === 0 || !foundCname) {
+
console.log(`[DNS Verify] ✗ No CNAME record found`)
+
return {
+
verified: false,
+
error: `No CNAME record found for ${domain}`,
+
found: { cname: '' }
+
}
+
}
+
+
// Check if CNAME points to the expected target
+
const actualTarget = foundCname
+
+
if (actualTarget === expectedTarget.toLowerCase()) {
+
console.log(`[DNS Verify] ✓ CNAME record matches!`)
+
return { verified: true, found: { cname: actualTarget } }
+
}
+
+
console.log(`[DNS Verify] ✗ CNAME record does not match`)
+
return {
+
verified: false,
+
error: `CNAME for ${domain} points to ${actualTarget}, expected ${expectedTarget}`,
+
found: { cname: actualTarget }
+
}
+
} catch (err: any) {
+
console.log(`[DNS Verify] ✗ CNAME lookup error:`, err.message)
+
if (err.code === 'ENOTFOUND' || err.code === 'ENODATA') {
+
return {
+
verified: false,
+
error: `No CNAME record found for ${domain}`,
+
found: { cname: '' }
+
}
+
}
+
return {
+
verified: false,
+
error: `DNS lookup failed: ${err.message}`,
+
found: { cname: '' }
+
}
+
}
+
}
+
+
/**
+
* Verify both TXT and CNAME records for a custom domain
+
*/
+
export const verifyCustomDomain = async (
+
domain: string,
+
expectedDid: string,
+
expectedHash: string
+
): Promise<VerificationResult> => {
+
const txtResult = await verifyDomainOwnership(domain, expectedDid)
+
if (!txtResult.verified) {
+
return txtResult
+
}
+
+
const cnameResult = await verifyCNAME(domain, expectedHash)
+
if (!cnameResult.verified) {
+
return cnameResult
+
}
+
+
return { verified: true }
+
}
+90
src/lib/sync-sites.ts
···
+
import { Agent } from '@atproto/api'
+
import type { OAuthSession } from '@atproto/oauth-client-node'
+
import { upsertSite } from './db'
+
+
/**
+
* Sync sites from user's PDS into the database cache
+
* - Fetches all place.wisp.fs records from AT Protocol repo
+
* - Validates record structure
+
* - Backfills into sites table
+
*/
+
export async function syncSitesFromPDS(
+
did: string,
+
session: OAuthSession
+
): Promise<{ synced: number; errors: string[] }> {
+
console.log(`[Sync] Starting site sync for ${did}`)
+
+
const agent = new Agent((url, init) => session.fetchHandler(url, init))
+
const errors: string[] = []
+
let synced = 0
+
+
try {
+
// List all records in the place.wisp.fs collection
+
console.log('[Sync] Fetching place.wisp.fs records from PDS')
+
const records = await agent.com.atproto.repo.listRecords({
+
repo: did,
+
collection: 'place.wisp.fs',
+
limit: 100 // Adjust if users might have more sites
+
})
+
+
console.log(`[Sync] Found ${records.data.records.length} records`)
+
+
// Process each record
+
for (const record of records.data.records) {
+
try {
+
const { uri, value } = record
+
+
// Extract rkey from URI (at://did/collection/rkey)
+
const rkey = uri.split('/').pop()
+
if (!rkey) {
+
errors.push(`Invalid URI format: ${uri}`)
+
continue
+
}
+
+
// Validate record structure
+
if (!value || typeof value !== 'object') {
+
errors.push(`Invalid record value for ${rkey}`)
+
continue
+
}
+
+
const siteValue = value as any
+
+
// Check for required fields
+
if (siteValue.$type !== 'place.wisp.fs') {
+
errors.push(
+
`Invalid $type for ${rkey}: ${siteValue.$type}`
+
)
+
continue
+
}
+
+
if (!siteValue.site || typeof siteValue.site !== 'string') {
+
errors.push(`Missing or invalid site name for ${rkey}`)
+
continue
+
}
+
+
// Upsert into database
+
const displayName = siteValue.site
+
await upsertSite(did, rkey, displayName)
+
+
console.log(
+
`[Sync] ✓ Synced site: ${displayName} (${rkey})`
+
)
+
synced++
+
} catch (err) {
+
const errorMsg = `Error processing record: ${err instanceof Error ? err.message : 'Unknown error'}`
+
console.error(`[Sync] ${errorMsg}`)
+
errors.push(errorMsg)
+
}
+
}
+
+
console.log(
+
`[Sync] Complete: ${synced} synced, ${errors.length} errors`
+
)
+
return { synced, errors }
+
} catch (err) {
+
const errorMsg = `Failed to fetch records from PDS: ${err instanceof Error ? err.message : 'Unknown error'}`
+
console.error(`[Sync] ${errorMsg}`)
+
errors.push(errorMsg)
+
return { synced, errors }
+
}
+
}
+24
src/routes/auth.ts
···
import { Elysia } from 'elysia'
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import { getSitesByDid, getDomainByDid } from '../lib/db'
+
import { syncSitesFromPDS } from '../lib/sync-sites'
export const authRoutes = (client: NodeOAuthClient) => new Elysia()
.post('/api/auth/signin', async (c) => {
···
const cookieSession = c.cookie
cookieSession.did.value = session.did
+
+
// Sync sites from PDS to database cache
+
console.log('[Auth] Syncing sites from PDS for', session.did)
+
try {
+
const syncResult = await syncSitesFromPDS(session.did, session)
+
console.log(`[Auth] Sync complete: ${syncResult.synced} sites synced`)
+
if (syncResult.errors.length > 0) {
+
console.warn('[Auth] Sync errors:', syncResult.errors)
+
}
+
} catch (err) {
+
console.error('[Auth] Failed to sync sites:', err)
+
// Don't fail auth if sync fails, just log it
+
}
+
+
// Check if user has any sites or domain
+
const sites = await getSitesByDid(session.did)
+
const domain = await getDomainByDid(session.did)
+
+
// If no sites and no domain, redirect to onboarding
+
if (sites.length === 0 && !domain) {
+
return c.redirect('/onboarding')
+
}
return c.redirect('/editor')
})
+110
src/routes/domain.ts
···
isValidHandle,
toDomain,
updateDomain,
+
getCustomDomainInfo,
+
getCustomDomainById,
+
claimCustomDomain,
+
deleteCustomDomain,
+
updateCustomDomainVerification,
+
updateWispDomainSite,
+
updateCustomDomainRkey
} from '../lib/db'
+
import { createHash } from 'crypto'
+
import { verifyCustomDomain } from '../lib/dns-verify'
export const domainRoutes = (client: NodeOAuthClient) =>
new Elysia({ prefix: '/api/domain' })
···
} catch (err) {
console.error("domain/update error", err);
throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`);
+
}
+
})
+
.post('/custom/add', async ({ body, auth }) => {
+
try {
+
const { domain } = body as { domain: string };
+
const domainLower = domain.toLowerCase().trim();
+
+
// Basic validation
+
if (!domainLower || domainLower.length < 3) {
+
throw new Error('Invalid domain');
+
}
+
+
// Check if already exists
+
const existing = await getCustomDomainInfo(domainLower);
+
if (existing) {
+
throw new Error('Domain already claimed');
+
}
+
+
// Create hash for ID
+
const hash = createHash('sha256').update(`${auth.did}:${domainLower}`).digest('hex').substring(0, 16);
+
+
// Store in database only
+
await claimCustomDomain(auth.did, domainLower, hash);
+
+
return {
+
success: true,
+
id: hash,
+
domain: domainLower,
+
verified: false
+
};
+
} catch (err) {
+
console.error('custom domain add error', err);
+
throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
+
}
+
})
+
.post('/custom/verify', async ({ body, auth }) => {
+
try {
+
const { id } = body as { id: string };
+
+
// Get domain from database
+
const domainInfo = await getCustomDomainById(id);
+
if (!domainInfo) {
+
throw new Error('Domain not found');
+
}
+
+
// Verify DNS records (TXT + CNAME)
+
console.log(`Verifying custom domain: ${domainInfo.domain}`);
+
const result = await verifyCustomDomain(domainInfo.domain, auth.did, id);
+
+
// Update verification status in database
+
await updateCustomDomainVerification(id, result.verified);
+
+
return {
+
success: true,
+
verified: result.verified,
+
error: result.error,
+
found: result.found
+
};
+
} catch (err) {
+
console.error('custom domain verify error', err);
+
throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
+
}
+
})
+
.delete('/custom/:id', async ({ params, auth }) => {
+
try {
+
const { id } = params;
+
+
// Delete from database
+
await deleteCustomDomain(id);
+
+
return { success: true };
+
} catch (err) {
+
console.error('custom domain delete error', err);
+
throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`);
+
}
+
})
+
.post('/wisp/map-site', async ({ body, auth }) => {
+
try {
+
const { siteRkey } = body as { siteRkey: string | null };
+
+
// Update wisp.place domain to point to this site
+
await updateWispDomainSite(auth.did, siteRkey);
+
+
return { success: true };
+
} catch (err) {
+
console.error('wisp domain map error', err);
+
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
+
}
+
})
+
.post('/custom/:id/map-site', async ({ params, body, auth }) => {
+
try {
+
const { id } = params;
+
const { siteRkey } = body as { siteRkey: string | null };
+
+
// Update custom domain to point to this site
+
await updateCustomDomainRkey(id, siteRkey || 'self');
+
+
return { success: true };
+
} catch (err) {
+
console.error('custom domain map error', err);
+
throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
});
+99
src/routes/user.ts
···
+
import { Elysia } from 'elysia'
+
import { requireAuth } from '../lib/wisp-auth'
+
import { NodeOAuthClient } from '@atproto/oauth-client-node'
+
import { Agent } from '@atproto/api'
+
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db'
+
import { syncSitesFromPDS } from '../lib/sync-sites'
+
+
export const userRoutes = (client: NodeOAuthClient) =>
+
new Elysia({ prefix: '/api/user' })
+
.derive(async ({ cookie }) => {
+
const auth = await requireAuth(client, cookie)
+
return { auth }
+
})
+
.get('/status', async ({ auth }) => {
+
try {
+
// Check if user has any sites
+
const sites = await getSitesByDid(auth.did)
+
+
// Check if user has claimed a domain
+
const domain = await getDomainByDid(auth.did)
+
+
return {
+
did: auth.did,
+
hasSites: sites.length > 0,
+
hasDomain: !!domain,
+
domain: domain || null,
+
sitesCount: sites.length
+
}
+
} catch (err) {
+
console.error('user/status error', err)
+
throw new Error('Failed to get user status')
+
}
+
})
+
.get('/info', async ({ auth }) => {
+
try {
+
// Get user's handle from AT Protocol
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
+
+
let handle = 'unknown'
+
try {
+
const profile = await agent.getProfile({ actor: auth.did })
+
handle = profile.data.handle
+
} catch (err) {
+
console.error('Failed to fetch profile:', err)
+
}
+
+
return {
+
did: auth.did,
+
handle
+
}
+
} catch (err) {
+
console.error('user/info error', err)
+
throw new Error('Failed to get user info')
+
}
+
})
+
.get('/sites', async ({ auth }) => {
+
try {
+
const sites = await getSitesByDid(auth.did)
+
return { sites }
+
} catch (err) {
+
console.error('user/sites error', err)
+
throw new Error('Failed to get sites')
+
}
+
})
+
.get('/domains', async ({ auth }) => {
+
try {
+
// Get wisp.place subdomain with mapping
+
const wispDomainInfo = await getWispDomainInfo(auth.did)
+
+
// Get custom domains
+
const customDomains = await getCustomDomainsByDid(auth.did)
+
+
return {
+
wispDomain: wispDomainInfo ? {
+
domain: wispDomainInfo.domain,
+
rkey: wispDomainInfo.rkey || null
+
} : null,
+
customDomains
+
}
+
} catch (err) {
+
console.error('user/domains error', err)
+
throw new Error('Failed to get domains')
+
}
+
})
+
.post('/sync', async ({ auth }) => {
+
try {
+
console.log('[User] Manual sync requested for', auth.did)
+
const result = await syncSitesFromPDS(auth.did, auth.session)
+
+
return {
+
success: true,
+
synced: result.synced,
+
errors: result.errors
+
}
+
} catch (err) {
+
console.error('user/sync error', err)
+
throw new Error('Failed to sync sites')
+
}
+
})
+111 -9
src/routes/wisp.ts
···
createManifest,
updateFileBlobs
} from '../lib/wisp-utils'
+
import { upsertSite } from '../lib/db'
export const wispRoutes = (client: NodeOAuthClient) =>
new Elysia({ prefix: '/wisp' })
···
console.log('🚀 Starting upload process', { siteName, fileCount: Array.isArray(files) ? files.length : 1 });
try {
-
if (!files || (Array.isArray(files) ? files.length === 0 : !files)) {
-
console.error('❌ No files provided');
-
throw new Error('No files provided')
-
}
-
if (!siteName) {
console.error('❌ Site name is required');
throw new Error('Site name is required')
}
console.log('✅ Initial validation passed');
+
+
// Check if files were provided
+
const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files);
+
+
if (!hasFiles) {
+
console.log('📝 Creating empty site (no files provided)');
+
+
// Create agent with OAuth session
+
console.log('🔐 Creating agent with OAuth session');
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
+
console.log('✅ Agent created successfully');
+
+
// Create empty manifest
+
const emptyManifest = {
+
$type: 'place.wisp.fs',
+
site: siteName,
+
root: {
+
type: 'directory',
+
entries: []
+
},
+
fileCount: 0,
+
createdAt: new Date().toISOString()
+
};
+
+
// Use site name as rkey
+
const rkey = siteName;
+
+
// Create the record with explicit rkey
+
console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`);
+
const record = await agent.com.atproto.repo.putRecord({
+
repo: auth.did,
+
collection: 'place.wisp.fs',
+
rkey: rkey,
+
record: emptyManifest
+
});
+
+
console.log('✅ Empty site record created successfully:', {
+
uri: record.data.uri,
+
cid: record.data.cid
+
});
+
+
// Store site in database cache
+
console.log('💾 Storing site in database cache');
+
await upsertSite(auth.did, rkey, siteName);
+
console.log('✅ Site stored in database');
+
+
return {
+
success: true,
+
uri: record.data.uri,
+
cid: record.data.cid,
+
fileCount: 0,
+
siteName
+
};
+
}
// Create agent with OAuth session
console.log('🔐 Creating agent with OAuth session');
···
}
if (uploadedFiles.length === 0) {
-
throw new Error('No valid web files found to upload. Allowed types: HTML, CSS, JS, images, fonts, PDFs, and other web assets.');
+
console.log('⚠️ No valid web files found, creating empty site instead');
+
+
// Create empty manifest
+
const emptyManifest = {
+
$type: 'place.wisp.fs',
+
site: siteName,
+
root: {
+
type: 'directory',
+
entries: []
+
},
+
fileCount: 0,
+
createdAt: new Date().toISOString()
+
};
+
+
// Use site name as rkey
+
const rkey = siteName;
+
+
// Create the record with explicit rkey
+
console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`);
+
const record = await agent.com.atproto.repo.putRecord({
+
repo: auth.did,
+
collection: 'place.wisp.fs',
+
rkey: rkey,
+
record: emptyManifest
+
});
+
+
console.log('✅ Empty site record created successfully:', {
+
uri: record.data.uri,
+
cid: record.data.cid
+
});
+
+
// Store site in database cache
+
console.log('💾 Storing site in database cache');
+
await upsertSite(auth.did, rkey, siteName);
+
console.log('✅ Site stored in database');
+
+
return {
+
success: true,
+
uri: record.data.uri,
+
cid: record.data.cid,
+
fileCount: 0,
+
siteName,
+
message: 'Site created but no valid web files were found to upload'
+
};
}
console.log('✅ File conversion completed');
···
const manifest = createManifest(siteName, updatedDirectory, fileCount);
console.log('✅ Manifest created');
-
// Create the record
-
console.log('📝 Creating record in repo');
-
const record = await agent.com.atproto.repo.createRecord({
+
// Use site name as rkey
+
const rkey = siteName;
+
+
// Create the record with explicit rkey
+
console.log(`📝 Creating record in repo with rkey: ${rkey}`);
+
const record = await agent.com.atproto.repo.putRecord({
repo: auth.did,
collection: 'place.wisp.fs',
+
rkey: rkey,
record: manifest
});
···
uri: record.data.uri,
cid: record.data.cid
});
+
+
// Store site in database cache
+
console.log('💾 Storing site in database cache');
+
await upsertSite(auth.did, rkey, siteName);
+
console.log('✅ Site stored in database');
const result = {
success: true,