From 5472d4c00a9408848da28174870c31fa4ac2e825 Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Fri, 24 Oct 2025 12:41:38 -0400 Subject: [PATCH] wire skeleton, domain management, hosting microservice --- hosting-service/.env.example | 6 + hosting-service/.gitignore | 8 + hosting-service/EXAMPLE.md | 123 ++ hosting-service/README.md | 130 ++ hosting-service/bun.lock | 60 + hosting-service/package.json | 18 + hosting-service/src/index.ts | 59 + hosting-service/src/lib/db.ts | 62 + hosting-service/src/lib/firehose.ts | 328 +++++ hosting-service/src/lib/html-rewriter.test.ts | 107 ++ hosting-service/src/lib/html-rewriter.ts | 130 ++ hosting-service/src/lib/safe-fetch.ts | 181 +++ hosting-service/src/lib/types.ts | 27 + hosting-service/src/lib/utils.ts | 162 +++ hosting-service/src/server.ts | 213 +++ public/editor/editor.tsx | 1166 ++++++++++++----- public/lib/api.ts | 5 +- public/onboarding/index.html | 12 + public/onboarding/onboarding.tsx | 412 ++++++ src/index.ts | 2 + src/lib/db.ts | 44 +- src/lib/dns-verify.ts | 156 +++ src/lib/sync-sites.ts | 90 ++ src/routes/auth.ts | 24 + src/routes/domain.ts | 110 ++ src/routes/user.ts | 99 ++ src/routes/wisp.ts | 120 +- 27 files changed, 3535 insertions(+), 319 deletions(-) create mode 100644 hosting-service/.env.example create mode 100644 hosting-service/.gitignore create mode 100644 hosting-service/EXAMPLE.md create mode 100644 hosting-service/README.md create mode 100644 hosting-service/bun.lock create mode 100644 hosting-service/package.json create mode 100644 hosting-service/src/index.ts create mode 100644 hosting-service/src/lib/db.ts create mode 100644 hosting-service/src/lib/firehose.ts create mode 100644 hosting-service/src/lib/html-rewriter.test.ts create mode 100644 hosting-service/src/lib/html-rewriter.ts create mode 100644 hosting-service/src/lib/safe-fetch.ts create mode 100644 hosting-service/src/lib/types.ts create mode 100644 hosting-service/src/lib/utils.ts create mode 100644 hosting-service/src/server.ts create mode 100644 public/onboarding/index.html create mode 100644 public/onboarding/onboarding.tsx create mode 100644 src/lib/dns-verify.ts create mode 100644 src/lib/sync-sites.ts create mode 100644 src/routes/user.ts diff --git a/hosting-service/.env.example b/hosting-service/.env.example new file mode 100644 index 0000000..561fadb --- /dev/null +++ b/hosting-service/.env.example @@ -0,0 +1,6 @@ +# Database +DATABASE_URL=postgres://postgres:postgres@localhost:5432/wisp + +# Server +PORT=3001 +BASE_HOST=wisp.place diff --git a/hosting-service/.gitignore b/hosting-service/.gitignore new file mode 100644 index 0000000..e1550c5 --- /dev/null +++ b/hosting-service/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +cache/ +.env +.env.local +*.log +dist/ +build/ +.DS_Store diff --git a/hosting-service/EXAMPLE.md b/hosting-service/EXAMPLE.md new file mode 100644 index 0000000..adf7cd9 --- /dev/null +++ b/hosting-service/EXAMPLE.md @@ -0,0 +1,123 @@ +# 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 + + + + + My Site + + + + + +
+ Logo + +
+ +
+

Welcome

+ Hero + +
+ + +
+
+ + + + +``` + +**When accessed via `/s/alice.bsky.social/mysite/`, the HTML is rewritten to:** +```html + + + + + My Site + + + + + +
+ Logo + +
+ +
+

Welcome

+ Hero + +
+ + +
+
+ + + + +``` + +## 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. diff --git a/hosting-service/README.md b/hosting-service/README.md new file mode 100644 index 0000000..06c5710 --- /dev/null +++ b/hosting-service/README.md @@ -0,0 +1,130 @@ +# 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 + + + + + + + +``` + +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 diff --git a/hosting-service/bun.lock b/hosting-service/bun.lock new file mode 100644 index 0000000..1f31999 --- /dev/null +++ b/hosting-service/bun.lock @@ -0,0 +1,60 @@ +{ + "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=="], + } +} diff --git a/hosting-service/package.json b/hosting-service/package.json new file mode 100644 index 0000000..073e8e6 --- /dev/null +++ b/hosting-service/package.json @@ -0,0 +1,18 @@ +{ + "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" + } +} diff --git a/hosting-service/src/index.ts b/hosting-service/src/index.ts new file mode 100644 index 0000000..e61fc46 --- /dev/null +++ b/hosting-service/src/index.ts @@ -0,0 +1,59 @@ +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); +}); diff --git a/hosting-service/src/lib/db.ts b/hosting-service/src/lib/db.ts new file mode 100644 index 0000000..0bf037d --- /dev/null +++ b/hosting-service/src/lib/db.ts @@ -0,0 +1,62 @@ +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 { + const result = await sql` + SELECT did, rkey FROM domains WHERE domain = ${domain.toLowerCase()} LIMIT 1 + `; + return result[0] || null; +} + +export async function getCustomDomain(domain: string): Promise { + const result = await sql` + 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 { + const result = await sql` + 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 }; diff --git a/hosting-service/src/lib/firehose.ts b/hosting-service/src/lib/firehose.ts new file mode 100644 index 0000000..3b35bee --- /dev/null +++ b/hosting-service/src/lib/firehose.ts @@ -0,0 +1,328 @@ +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) => void, + ) {} + + private log(msg: string, data?: Record) { + 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 + }; + } +} diff --git a/hosting-service/src/lib/html-rewriter.test.ts b/hosting-service/src/lib/html-rewriter.test.ts new file mode 100644 index 0000000..14d415f --- /dev/null +++ b/hosting-service/src/lib/html-rewriter.test.ts @@ -0,0 +1,107 @@ +/** + * 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 = ''; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(''); +}); + +test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => { + const html = ''; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(''); +}); + +test('rewriteHtmlPaths - preserves external URLs', () => { + const html = ''; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(''); +}); + +test('rewriteHtmlPaths - preserves protocol-relative URLs', () => { + const html = ''; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(''); +}); + +test('rewriteHtmlPaths - preserves data URIs', () => { + const html = ''; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(''); +}); + +test('rewriteHtmlPaths - preserves anchors', () => { + const html = 'Jump'; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe('Jump'); +}); + +test('rewriteHtmlPaths - preserves relative paths', () => { + const html = ''; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(''); +}); + +test('rewriteHtmlPaths - handles single quotes', () => { + const html = ""; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(""); +}); + +test('rewriteHtmlPaths - handles srcset', () => { + const html = ''; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe(''); +}); + +test('rewriteHtmlPaths - handles form actions', () => { + const html = '
'; + const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/'); + expect(result).toBe('
'); +}); + +test('rewriteHtmlPaths - handles complex HTML', () => { + const html = ` + + + + + + + + + About + External + Anchor + + + `.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); +}); diff --git a/hosting-service/src/lib/html-rewriter.ts b/hosting-service/src/lib/html-rewriter.ts new file mode 100644 index 0000000..9d40f6c --- /dev/null +++ b/hosting-service/src/lib/html-rewriter.ts @@ -0,0 +1,130 @@ +/** + * 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'; +} diff --git a/hosting-service/src/lib/safe-fetch.ts b/hosting-service/src/lib/safe-fetch.ts new file mode 100644 index 0000000..3191805 --- /dev/null +++ b/hosting-service/src/lib/safe-fetch.ts @@ -0,0 +1,181 @@ +/** + * 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 { + 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( + url: string, + options?: RequestInit & { maxSize?: number; timeout?: number } +): Promise { + 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 { + 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; +} diff --git a/hosting-service/src/lib/types.ts b/hosting-service/src/lib/types.ts new file mode 100644 index 0000000..ea605e7 --- /dev/null +++ b/hosting-service/src/lib/types.ts @@ -0,0 +1,27 @@ +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 }; +} diff --git a/hosting-service/src/lib/utils.ts b/hosting-service/src/lib/utils.ts new file mode 100644 index 0000000..2f68363 --- /dev/null +++ b/hosting-service/src/lib/utils.ts @@ -0,0 +1,162 @@ +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`); +} diff --git a/hosting-service/src/server.ts b/hosting-service/src/server.ts new file mode 100644 index 0000000..54f7035 --- /dev/null +++ b/hosting-service/src/server.ts @@ -0,0 +1,213 @@ +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 { + 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; diff --git a/public/editor/editor.tsx b/public/editor/editor.tsx index 847cde0..212e7da 100644 --- a/public/editor/editor.tsx +++ b/public/editor/editor.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { createRoot } from 'react-dom/client' import { Button } from '@public/components/ui/button' import { @@ -25,103 +25,410 @@ 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() { - const [customDomain, setCustomDomain] = useState('') - const [verificationStatus, setVerificationStatus] = useState< - 'idle' | 'verifying' | 'success' | 'error' - >('idle') - const [selectedSite, setSelectedSite] = useState('') + // User state + const [userInfo, setUserInfo] = useState(null) + const [loading, setLoading] = useState(true) - const [configureModalOpen, setConfigureModalOpen] = useState(false) - const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) - const [currentSite, setCurrentSite] = useState<{ - id: string - name: string - domain: string | null - } | null>(null) + // Sites state + const [sites, setSites] = useState([]) + const [sitesLoading, setSitesLoading] = useState(true) + const [isSyncing, setIsSyncing] = useState(false) + + // Domains state + const [wispDomain, setWispDomain] = useState(null) + const [customDomains, setCustomDomains] = useState([]) + const [domainsLoading, setDomainsLoading] = useState(true) + + // Site configuration state + const [configuringSite, setConfiguringSite] = useState(null) const [selectedDomain, setSelectedDomain] = useState('') + const [isSavingConfig, setIsSavingConfig] = useState(false) + + // Upload state + const [siteName, setSiteName] = useState('') + const [selectedFiles, setSelectedFiles] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState('') + + // Custom domain modal state + const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) + const [customDomain, setCustomDomain] = useState('') + const [isAddingDomain, setIsAddingDomain] = useState(false) + const [verificationStatus, setVerificationStatus] = useState<{ + [id: string]: 'idle' | 'verifying' | 'success' | 'error' + }>({}) + const [viewDomainDNS, setViewDomainDNS] = useState(null) + + // Fetch user info on mount + useEffect(() => { + fetchUserInfo() + fetchSites() + fetchDomains() + }, []) - // 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' + 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 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' } - ] - - const handleVerifyDNS = async () => { - setVerificationStatus('verifying') - // Simulate DNS verification - setTimeout(() => { - setVerificationStatus('success') - }, 2000) } - const handleConfigureSite = (site: { - id: string - name: string - domain: string | null - }) => { - setCurrentSite(site) - setSelectedDomain(site.domain || 'none') - setConfigureModalOpen(true) + 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 handleSaveConfiguration = () => { - console.log( - '[v0] Saving configuration for site:', - currentSite?.name, - 'with domain:', - selectedDomain - ) - // TODO: Implement actual save logic - setConfigureModalOpen(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) => { + 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 handleAddCustomDomain = async () => { + if (!customDomain) { + alert('Please enter a domain') + return + } + + 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' }) + + try { + const response = await fetch('/api/domain/custom/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }) + }) + + 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 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 getSiteUrl = (site: { name: string; domain: string | null }) => { - if (site.domain) { - return `https://${site.domain}` + 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 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 ( +
+ +
+ ) } return ( @@ -139,7 +446,7 @@ function Dashboard() {
- {mockUser.handle} + {userInfo?.handle || 'Loading...'}
@@ -164,52 +471,74 @@ function Dashboard() { - Your Sites - - View and manage all your deployed sites - +
+
+ Your Sites + + View and manage all your deployed sites + +
+ +
- {sites.map((site) => ( -
-
-
-

- {site.name} -

- + +
+ ) : sites.length === 0 ? ( +
+

No sites yet. Upload your first site!

+
+ ) : ( + sites.map((site) => ( + - -
- ))} + )) + )} @@ -220,21 +549,43 @@ function Dashboard() { wisp.place Subdomain - Your free subdomain on the wisp.place - network + Your free subdomain on the wisp.place network -
- - - {mockUser.wispSubdomain}.wisp.place - -
-

- Configure which site uses this domain in the - Sites tab -

+ {domainsLoading ? ( +
+ +
+ ) : wispDomain ? ( + <> +
+
+ + + {wispDomain.domain} + +
+ {wispDomain.rkey && ( +

+ → Mapped to site: {wispDomain.rkey} +

+ )} +
+

+ {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.'} +

+ + ) : ( +
+

No wisp.place subdomain claimed yet.

+

+ You should have claimed one during onboarding! +

+
+ )}
@@ -253,19 +604,93 @@ function Dashboard() { Add Custom Domain -
-
-
- - - docs.example.com - -
- - Verified - + {domainsLoading ? ( +
+
-
+ ) : customDomains.length === 0 ? ( +
+ No custom domains added yet +
+ ) : ( +
+ {customDomains.map((domain) => ( +
+
+
+ {domain.verified ? ( + + ) : ( + + )} + + {domain.domain} + +
+ {domain.rkey && domain.rkey !== 'self' && ( +

+ → Mapped to site: {domain.rkey} +

+ )} +
+
+ + {domain.verified ? ( + + Verified + + ) : ( + + )} + +
+
+ ))} +
+ )} @@ -276,8 +701,7 @@ function Dashboard() { Upload Site - Deploy a new site from a folder or Git - repository + Deploy a new site from a folder or Git repository @@ -286,6 +710,9 @@ function Dashboard() { setSiteName(e.target.value)} + disabled={isUploading} />
@@ -297,227 +724,366 @@ function Dashboard() { Upload Folder

- Drag and drop or click to upload - your static site files + Drag and drop or click to upload your + static site files

- + + + {selectedFiles && selectedFiles.length > 0 && ( +

+ {selectedFiles.length} files selected +

+ )} - +

Connect Git Repository

- Link your GitHub, GitLab, or any - Git repository + Link your GitHub, GitLab, or any Git + repository

- + Coming soon!
+ + {uploadProgress && ( +
+
+ + {uploadProgress} +
+
+ )} + +
+ {/* Add Custom Domain Modal */} + + + + Add Custom Domain + + Enter your domain name. After adding, you'll see the DNS + records to configure. + + +
+
+ + setCustomDomain(e.target.value)} + /> +

+ After adding, click "View DNS" to see the records you + need to configure. +

+
+
+ + + + +
+
+ + {/* Site Configuration Modal */} !open && setConfiguringSite(null)} > - + Configure Site Domain - Choose which domain {currentSite?.name} should use + Choose which domain this site should use -
- - {availableDomains.map((domain) => ( -
- -
+ {/* View DNS Records Modal */} !open && setViewDomainDNS(null)} > - Add Custom Domain + DNS Configuration - Configure DNS records to verify your domain - ownership + Add these DNS records to your domain provider -
-
- - - setCustomDomain(e.target.value) - } - /> -
+ {viewDomainDNS && userInfo && ( + <> + {(() => { + const domain = customDomains.find( + (d) => d.id === viewDomainDNS + ) + if (!domain) return null - {customDomain && ( -
-
-

- - DNS Configuration Required -

-

- Add these DNS records to your domain - provider: -

-
- -
-
-
- - TXT Record - + return ( +
+
+

+ Domain: +

+

+ {domain.domain} +

-
-
- - Name: - {' '} - _wisp + +
+
+
+ + TXT Record (Verification) + +
+
+
+ + Name: + {' '} + + _wisp.{domain.domain} + +
+
+ + Value: + {' '} + + {userInfo.did} + +
+
-
- - Value: - {' '} - {mockUser.did} + +
+
+ + CNAME Record (Pointing) + +
+
+
+ + Name: + {' '} + + {domain.domain} + +
+
+ + Value: + {' '} + + {domain.id}.dns.wisp.place + +
+
+

+ Some DNS providers may require you to use @ or leave it blank for the root domain +

-
-
-
- - CNAME Record - -
-
-
- - Name: - {' '} - @ or {customDomain} -
-
- - Value: - {' '} - abc123.dns.wisp.place -
+
+

+ 💡 After configuring DNS, click "Verify DNS" + to check if everything is set up correctly. + DNS changes can take a few minutes to + propagate. +

-
-
- )} -
- + ) + })()} + + )} + - diff --git a/public/lib/api.ts b/public/lib/api.ts index 203ebd2..6d1438b 100644 --- a/public/lib/api.ts +++ b/public/lib/api.ts @@ -2,4 +2,7 @@ import { treaty } from '@elysiajs/eden' import type { app } from '@server' -export const api = treaty('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(apiHost) diff --git a/public/onboarding/index.html b/public/onboarding/index.html new file mode 100644 index 0000000..a930ed4 --- /dev/null +++ b/public/onboarding/index.html @@ -0,0 +1,12 @@ + + + + + + Get Started - wisp.place + + +
+ + + diff --git a/public/onboarding/onboarding.tsx b/public/onboarding/onboarding.tsx new file mode 100644 index 0000000..d1154cc --- /dev/null +++ b/public/onboarding/onboarding.tsx @@ -0,0 +1,412 @@ +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('domain') + const [handle, setHandle] = useState('') + const [isCheckingAvailability, setIsCheckingAvailability] = useState(false) + const [isAvailable, setIsAvailable] = useState(null) + const [domain, setDomain] = useState('') + const [isClaimingDomain, setIsClaimingDomain] = useState(false) + const [claimedDomain, setClaimedDomain] = useState('') + + const [siteName, setSiteName] = useState('') + const [selectedFiles, setSelectedFiles] = useState(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) => { + 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 ( +
+ {/* Header */} +
+
+
+
+ +
+ + wisp.place + +
+
+
+ +
+ {/* Progress indicator */} +
+
+
+ {step === 'domain' ? ( + '1' + ) : ( + + )} +
+
+
+ {step === 'complete' ? ( + + ) : ( + '2' + )} +
+
+
+

+ {step === 'domain' && 'Claim Your Free Domain'} + {step === 'upload' && 'Deploy Your First Site'} + {step === 'complete' && 'All Set!'} +

+

+ {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...'} +

+
+
+ + {/* Domain registration step */} + {step === 'domain' && ( + + + Choose Your Domain + + Pick a unique handle for your free *.wisp.place + subdomain + + + +
+ +
+
+ + setHandle( + e.target.value + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + ) + } + className="pr-10" + /> + {isCheckingAvailability && ( + + )} + {!isCheckingAvailability && + isAvailable !== null && ( +
+ {isAvailable ? '✓' : '✗'} +
+ )} +
+
+ {domain && ( +

+ Your domain will be:{' '} + {domain} +

+ )} + {isAvailable === false && handle.length >= 3 && ( +

+ This handle is not available or invalid +

+ )} +
+ + +
+
+ )} + + {/* Upload step */} + {step === 'upload' && ( + + + Deploy Your Site + + Upload your static site files or start with an empty + site (you can upload later) + + + +
+
+ + + Domain claimed: {claimedDomain} + +
+
+ +
+ + setSiteName(e.target.value)} + /> +

+ A unique identifier for this site in your account +

+
+ +
+ +
+ + + + {selectedFiles && selectedFiles.length > 0 && ( +

+ {selectedFiles.length} files selected +

+ )} +
+

+ Supported: HTML, CSS, JS, images, fonts, and more +

+
+ + {uploadProgress && ( +
+
+ + + {uploadProgress} + +
+
+ )} + +
+ + +
+
+
+ )} +
+
+ ) +} + +const root = createRoot(document.getElementById('elysia')!) +root.render( + + + +) diff --git a/src/index.ts b/src/index.ts index f572ff2..cb1c0fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { 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}`, @@ -35,6 +36,7 @@ export const app = new Elysia() .use(authRoutes(client)) .use(wispRoutes(client)) .use(domainRoutes(client)) + .use(userRoutes(client)) .get('/client-metadata.json', (c) => { return createClientMetadata(config) }) diff --git a/src/lib/db.ts b/src/lib/db.ts index 3bf54b3..9d6aff6 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -39,10 +39,18 @@ await db` 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` CREATE TABLE IF NOT EXISTS custom_domains ( @@ -94,6 +102,11 @@ export const getDomainByDid = async (did: string): Promise => { 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 => { const rows = await db`SELECT did FROM domains WHERE domain = ${domain.toLowerCase()}`; return rows[0]?.did ?? null; @@ -142,6 +155,19 @@ export const updateDomain = async (did: string, handle: string): Promise } }; +export const updateWispDomainSite = async (did: string, siteRkey: string | null): Promise => { + await db` + UPDATE domains + SET rkey = ${siteRkey} + WHERE did = ${did} + `; +}; + +export const getWispDomainSite = async (did: string): Promise => { + 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) @@ -283,12 +309,12 @@ export const getCustomDomainById = async (id: string) => { 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) { @@ -297,10 +323,10 @@ export const claimCustomDomain = async (did: string, domain: string, siteName: s } }; -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 * `; @@ -326,12 +352,12 @@ export const getSitesByDid = async (did: string) => { 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()) diff --git a/src/lib/dns-verify.ts b/src/lib/dns-verify.ts new file mode 100644 index 0000000..5b66c21 --- /dev/null +++ b/src/lib/dns-verify.ts @@ -0,0 +1,156 @@ +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 => { + 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 => { + 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 => { + 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 } +} diff --git a/src/lib/sync-sites.ts b/src/lib/sync-sites.ts new file mode 100644 index 0000000..a178c91 --- /dev/null +++ b/src/lib/sync-sites.ts @@ -0,0 +1,90 @@ +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 } + } +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 6cc118c..4fa7b83 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,5 +1,7 @@ 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) => { @@ -21,5 +23,27 @@ export const authRoutes = (client: NodeOAuthClient) => new Elysia() 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') }) \ No newline at end of file diff --git a/src/routes/domain.ts b/src/routes/domain.ts index 0bee7c3..eb5bf23 100644 --- a/src/routes/domain.ts +++ b/src/routes/domain.ts @@ -9,7 +9,16 @@ import { 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' }) @@ -126,4 +135,105 @@ export const domainRoutes = (client: NodeOAuthClient) => 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'}`); + } }); \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts new file mode 100644 index 0000000..271d237 --- /dev/null +++ b/src/routes/user.ts @@ -0,0 +1,99 @@ +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') + } + }) diff --git a/src/routes/wisp.ts b/src/routes/wisp.ts index 324afc0..a3d33b0 100644 --- a/src/routes/wisp.ts +++ b/src/routes/wisp.ts @@ -9,6 +9,7 @@ import { createManifest, updateFileBlobs } from '../lib/wisp-utils' +import { upsertSite } from '../lib/db' export const wispRoutes = (client: NodeOAuthClient) => new Elysia({ prefix: '/wisp' }) @@ -27,11 +28,6 @@ export const wispRoutes = (client: NodeOAuthClient) => 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') @@ -39,6 +35,60 @@ export const wispRoutes = (client: NodeOAuthClient) => 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'); const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) @@ -124,7 +174,50 @@ export const wispRoutes = (client: NodeOAuthClient) => } 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'); @@ -194,11 +287,15 @@ export const wispRoutes = (client: NodeOAuthClient) => 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 }); @@ -207,6 +304,11 @@ export const wispRoutes = (client: NodeOAuthClient) => 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, uri: record.data.uri, -- 2.54.0