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

wire skeleton, domain management, hosting microservice

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