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