Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 12 kB view raw
1import { Elysia } from 'elysia' 2import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth' 3import { NodeOAuthClient } from '@atproto/oauth-client-node' 4import { Agent } from '@atproto/api' 5import { 6 claimDomain, 7 getDomainByDid, 8 isDomainAvailable, 9 isDomainRegistered, 10 isValidHandle, 11 toDomain, 12 updateDomain, 13 countWispDomains, 14 deleteWispDomain, 15 getCustomDomainInfo, 16 getCustomDomainById, 17 claimCustomDomain, 18 deleteCustomDomain, 19 updateCustomDomainVerification, 20 updateWispDomainSite, 21 updateCustomDomainRkey 22} from '../lib/db' 23import { createHash } from 'crypto' 24import { verifyCustomDomain } from '../lib/dns-verify' 25import { createLogger } from '@wisp/observability' 26 27const logger = createLogger('main-app') 28 29export const domainRoutes = (client: NodeOAuthClient, cookieSecret: string) => 30 new Elysia({ 31 prefix: '/api/domain', 32 cookie: { 33 secrets: cookieSecret, 34 sign: ['did'] 35 } 36 }) 37 // Public endpoints (no auth required) 38 .get('/check', async ({ query }) => { 39 try { 40 const handle = (query.handle || "") 41 .trim() 42 .toLowerCase(); 43 44 if (!isValidHandle(handle)) { 45 return { 46 available: false, 47 reason: "invalid" 48 }; 49 } 50 51 const available = await isDomainAvailable(handle); 52 return { 53 available, 54 domain: toDomain(handle) 55 }; 56 } catch (err) { 57 logger.error('[Domain] Check error', err); 58 return { 59 available: false 60 }; 61 } 62 }) 63 .get('/registered', async ({ query, set }) => { 64 try { 65 const domain = (query.domain || "").trim().toLowerCase(); 66 67 if (!domain) { 68 set.status = 400; 69 return { error: 'Domain parameter required' }; 70 } 71 72 const result = await isDomainRegistered(domain); 73 74 // For Caddy on-demand TLS: 200 = allow, 404 = deny 75 if (result.registered) { 76 set.status = 200; 77 return result; 78 } else { 79 set.status = 404; 80 return { registered: false }; 81 } 82 } catch (err) { 83 logger.error('[Domain] Registered check error', err); 84 set.status = 500; 85 return { error: 'Failed to check domain' }; 86 } 87 }) 88 // Authenticated endpoints (require auth) 89 .derive(async ({ cookie }) => { 90 const auth = await requireAuth(client, cookie) 91 return { auth } 92 }) 93 .post('/claim', async ({ body, auth }) => { 94 try { 95 const { handle } = body as { handle?: string }; 96 const normalizedHandle = (handle || "").trim().toLowerCase(); 97 98 if (!isValidHandle(normalizedHandle)) { 99 throw new Error("Invalid handle"); 100 } 101 102 // Check if user already has 3 domains (handled in claimDomain) 103 // claim in DB 104 let domain: string; 105 try { 106 domain = await claimDomain(auth.did, normalizedHandle); 107 } catch (err) { 108 const message = err instanceof Error ? err.message : 'Unknown error'; 109 if (message === 'domain_limit_reached') { 110 throw new Error("Domain limit reached: You can only claim up to 3 wisp.place domains"); 111 } 112 throw new Error("Handle taken or error claiming domain"); 113 } 114 115 // write place.wisp.domain record with unique rkey 116 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 117 const rkey = normalizedHandle; // Use handle as rkey for uniqueness 118 await agent.com.atproto.repo.putRecord({ 119 repo: auth.did, 120 collection: "place.wisp.domain", 121 rkey, 122 record: { 123 $type: "place.wisp.domain", 124 domain, 125 createdAt: new Date().toISOString(), 126 } as any, 127 validate: false, 128 }); 129 130 return { success: true, domain }; 131 } catch (err) { 132 logger.error('[Domain] Claim error', err); 133 throw new Error(`Failed to claim: ${err instanceof Error ? err.message : 'Unknown error'}`); 134 } 135 }) 136 .post('/update', async ({ body, auth }) => { 137 try { 138 const { handle } = body as { handle?: string }; 139 const normalizedHandle = (handle || "").trim().toLowerCase(); 140 141 if (!isValidHandle(normalizedHandle)) { 142 throw new Error("Invalid handle"); 143 } 144 145 const desiredDomain = toDomain(normalizedHandle); 146 const current = await getDomainByDid(auth.did); 147 148 if (current === desiredDomain) { 149 return { success: true, domain: current }; 150 } 151 152 let domain: string; 153 try { 154 domain = await updateDomain(auth.did, normalizedHandle); 155 } catch (err) { 156 throw new Error("Handle taken"); 157 } 158 159 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 160 await agent.com.atproto.repo.putRecord({ 161 repo: auth.did, 162 collection: "place.wisp.domain", 163 rkey: "self", 164 record: { 165 $type: "place.wisp.domain", 166 domain, 167 createdAt: new Date().toISOString(), 168 } as any, 169 validate: false, 170 }); 171 172 return { success: true, domain }; 173 } catch (err) { 174 logger.error('[Domain] Update error', err); 175 throw new Error(`Failed to update: ${err instanceof Error ? err.message : 'Unknown error'}`); 176 } 177 }) 178 .post('/custom/add', async ({ body, auth }) => { 179 try { 180 const { domain } = body as { domain: string }; 181 const domainLower = domain.toLowerCase().trim(); 182 183 // Enhanced domain validation 184 // 1. Length check (RFC 1035: labels 1-63 chars, total max 253) 185 if (!domainLower || domainLower.length < 3 || domainLower.length > 253) { 186 throw new Error('Invalid domain: must be 3-253 characters'); 187 } 188 189 // 2. Basic format validation 190 // - Must contain at least one dot (require TLD) 191 // - Valid characters: a-z, 0-9, hyphen, dot 192 // - No consecutive dots, no leading/trailing dots or hyphens 193 const domainPattern = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; 194 if (!domainPattern.test(domainLower)) { 195 throw new Error('Invalid domain format'); 196 } 197 198 // 3. Validate each label (part between dots) 199 const labels = domainLower.split('.'); 200 for (const label of labels) { 201 if (label.length === 0 || label.length > 63) { 202 throw new Error('Invalid domain: label length must be 1-63 characters'); 203 } 204 if (label.startsWith('-') || label.endsWith('-')) { 205 throw new Error('Invalid domain: labels cannot start or end with hyphen'); 206 } 207 } 208 209 // 4. TLD validation (require valid TLD, block single-char TLDs and numeric TLDs) 210 const tld = labels[labels.length - 1]; 211 if (tld.length < 2 || /^\d+$/.test(tld)) { 212 throw new Error('Invalid domain: TLD must be at least 2 characters and not all numeric'); 213 } 214 215 // 5. Homograph attack protection - block domains with mixed scripts or confusables 216 // Block non-ASCII characters (Punycode domains should be pre-converted) 217 if (!/^[a-z0-9.-]+$/.test(domainLower)) { 218 throw new Error('Invalid domain: only ASCII alphanumeric, dots, and hyphens allowed'); 219 } 220 221 // 6. Block localhost, internal IPs, and reserved domains 222 const blockedDomains = [ 223 'localhost', 224 'example.com', 225 'example.org', 226 'example.net', 227 'test', 228 'invalid', 229 'local' 230 ]; 231 const blockedPatterns = [ 232 /^(?:10|127|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\./, // Private IPs 233 /^(?:\d{1,3}\.){3}\d{1,3}$/, // Any IP address 234 ]; 235 236 if (blockedDomains.includes(domainLower)) { 237 throw new Error('Invalid domain: reserved or blocked domain'); 238 } 239 240 for (const pattern of blockedPatterns) { 241 if (pattern.test(domainLower)) { 242 throw new Error('Invalid domain: IP addresses not allowed'); 243 } 244 } 245 246 // Check if already exists and is verified 247 const existing = await getCustomDomainInfo(domainLower); 248 if (existing && existing.verified) { 249 throw new Error('Domain already verified and claimed'); 250 } 251 252 // Create hash for ID 253 const hash = createHash('sha256').update(`${auth.did}:${domainLower}`).digest('hex').substring(0, 16); 254 255 // Store in database only 256 await claimCustomDomain(auth.did, domainLower, hash); 257 258 return { 259 success: true, 260 id: hash, 261 domain: domainLower, 262 verified: false 263 }; 264 } catch (err) { 265 logger.error('[Domain] Custom domain add error', err); 266 throw new Error(`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 267 } 268 }) 269 .post('/custom/verify', async ({ body, auth }) => { 270 try { 271 const { id } = body as { id: string }; 272 273 // Get domain from database 274 const domainInfo = await getCustomDomainById(id); 275 if (!domainInfo) { 276 throw new Error('Domain not found'); 277 } 278 279 // Verify DNS records (TXT + CNAME) 280 logger.debug(`[Domain] Verifying custom domain: ${domainInfo.domain}`); 281 const result = await verifyCustomDomain(domainInfo.domain, auth.did, id); 282 283 // Update verification status in database 284 await updateCustomDomainVerification(id, result.verified); 285 286 return { 287 success: true, 288 verified: result.verified, 289 error: result.error, 290 found: result.found 291 }; 292 } catch (err) { 293 logger.error('[Domain] Custom domain verify error', err); 294 throw new Error(`Failed to verify domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 295 } 296 }) 297 .delete('/custom/:id', async ({ params, auth }) => { 298 try { 299 const { id } = params; 300 301 // Verify ownership before deleting 302 const domainInfo = await getCustomDomainById(id); 303 if (!domainInfo) { 304 throw new Error('Domain not found'); 305 } 306 307 if (domainInfo.did !== auth.did) { 308 throw new Error('Unauthorized: You do not own this domain'); 309 } 310 311 // Delete from database 312 await deleteCustomDomain(id); 313 314 return { success: true }; 315 } catch (err) { 316 logger.error('[Domain] Custom domain delete error', err); 317 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 318 } 319 }) 320 .post('/wisp/map-site', async ({ body, auth }) => { 321 try { 322 const { domain, siteRkey } = body as { domain: string; siteRkey: string | null }; 323 324 if (!domain) { 325 throw new Error('Domain parameter required'); 326 } 327 328 // Update wisp.place domain to point to this site 329 await updateWispDomainSite(domain, siteRkey); 330 331 return { success: true }; 332 } catch (err) { 333 logger.error('[Domain] Wisp domain map error', err); 334 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 335 } 336 }) 337 .delete('/wisp/:domain', async ({ params, auth }) => { 338 try { 339 const { domain } = params; 340 341 // Verify domain belongs to user 342 const domainLower = domain.toLowerCase().trim(); 343 const info = await isDomainRegistered(domainLower); 344 345 if (!info.registered || info.type !== 'wisp') { 346 throw new Error('Domain not found'); 347 } 348 349 if (info.did !== auth.did) { 350 throw new Error('Unauthorized: You do not own this domain'); 351 } 352 353 // Delete from database 354 await deleteWispDomain(domainLower); 355 356 // Delete from PDS 357 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)); 358 const handle = domainLower.replace(`.${process.env.BASE_DOMAIN || 'wisp.place'}`, ''); 359 try { 360 await agent.com.atproto.repo.deleteRecord({ 361 repo: auth.did, 362 collection: "place.wisp.domain", 363 rkey: handle, 364 }); 365 } catch (err) { 366 // Record might not exist in PDS, continue anyway 367 logger.warn('[Domain] Could not delete wisp domain from PDS', err as any); 368 } 369 370 return { success: true }; 371 } catch (err) { 372 logger.error('[Domain] Wisp domain delete error', err); 373 throw new Error(`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`); 374 } 375 }) 376 .post('/custom/:id/map-site', async ({ params, body, auth }) => { 377 try { 378 const { id } = params; 379 const { siteRkey } = body as { siteRkey: string | null }; 380 381 // Verify ownership before updating 382 const domainInfo = await getCustomDomainById(id); 383 if (!domainInfo) { 384 throw new Error('Domain not found'); 385 } 386 387 if (domainInfo.did !== auth.did) { 388 throw new Error('Unauthorized: You do not own this domain'); 389 } 390 391 // Update custom domain to point to this site 392 await updateCustomDomainRkey(id, siteRkey); 393 394 return { success: true }; 395 } catch (err) { 396 logger.error('[Domain] Custom domain map error', err); 397 throw new Error(`Failed to map site: ${err instanceof Error ? err.message : 'Unknown error'}`); 398 } 399 });