your personal website on atproto - mirror blento.app

Merge pull request #186 from flo-bit/some-fixes

improve custom domains

authored by

Florian and committed by
GitHub
d426f2df 67201b3b

+497 -144
+294
src/lib/dns.ts
··· 1 + // Authoritative DNS verification 2 + // Queries authoritative nameservers directly instead of relying on cached resolvers. 3 + // See: https://jacob.gold/posts/stop-telling-users-their-dns-is-wrong/ 4 + 5 + const TYPE_A = 1; 6 + const TYPE_NS = 2; 7 + const TYPE_CNAME = 5; 8 + 9 + interface DnsRecord { 10 + type: number; 11 + data: string; 12 + } 13 + 14 + // --- Wire format encoding --- 15 + 16 + function encodeName(name: string): Uint8Array { 17 + const n = name.endsWith('.') ? name.slice(0, -1) : name; 18 + const parts = n.split('.'); 19 + const bytes: number[] = []; 20 + for (const part of parts) { 21 + bytes.push(part.length); 22 + for (let i = 0; i < part.length; i++) { 23 + bytes.push(part.charCodeAt(i)); 24 + } 25 + } 26 + bytes.push(0); 27 + return new Uint8Array(bytes); 28 + } 29 + 30 + function buildQuery(name: string, type: number): Uint8Array { 31 + const qname = encodeName(name); 32 + const msg = new Uint8Array(12 + qname.length + 4); 33 + const id = (Math.random() * 0xffff) | 0; 34 + msg[0] = id >> 8; 35 + msg[1] = id & 0xff; 36 + msg[2] = 0x01; // RD=1 37 + msg[5] = 0x01; // QDCOUNT=1 38 + msg.set(qname, 12); 39 + const off = 12 + qname.length; 40 + msg[off] = type >> 8; 41 + msg[off + 1] = type & 0xff; 42 + msg[off + 3] = 0x01; // CLASS IN 43 + return msg; 44 + } 45 + 46 + // --- Wire format decoding --- 47 + 48 + function decodeName(data: Uint8Array, offset: number): [string, number] { 49 + const labels: string[] = []; 50 + let pos = offset; 51 + let jumped = false; 52 + let savedPos = 0; 53 + for (let safety = 0; safety < 128 && pos < data.length; safety++) { 54 + const len = data[pos]; 55 + if (len === 0) { 56 + pos++; 57 + break; 58 + } 59 + if ((len & 0xc0) === 0xc0) { 60 + if (!jumped) savedPos = pos + 2; 61 + pos = ((len & 0x3f) << 8) | data[pos + 1]; 62 + jumped = true; 63 + continue; 64 + } 65 + pos++; 66 + let label = ''; 67 + for (let i = 0; i < len && pos + i < data.length; i++) { 68 + label += String.fromCharCode(data[pos + i]); 69 + } 70 + labels.push(label); 71 + pos += len; 72 + } 73 + return [labels.join('.'), jumped ? savedPos : pos]; 74 + } 75 + 76 + function parseResponse(data: Uint8Array): DnsRecord[] { 77 + if (data.length < 12) return []; 78 + const qdcount = (data[4] << 8) | data[5]; 79 + const ancount = (data[6] << 8) | data[7]; 80 + let offset = 12; 81 + 82 + // Skip questions 83 + for (let i = 0; i < qdcount && offset < data.length; i++) { 84 + const [, off] = decodeName(data, offset); 85 + offset = off + 4; 86 + } 87 + 88 + const records: DnsRecord[] = []; 89 + for (let i = 0; i < ancount && offset + 10 < data.length; i++) { 90 + const [, nameEnd] = decodeName(data, offset); 91 + offset = nameEnd; 92 + const type = (data[offset] << 8) | data[offset + 1]; 93 + const rdlength = (data[offset + 8] << 8) | data[offset + 9]; 94 + offset += 10; 95 + if (offset + rdlength > data.length) break; 96 + 97 + let value = ''; 98 + if (type === TYPE_A && rdlength === 4) { 99 + value = `${data[offset]}.${data[offset + 1]}.${data[offset + 2]}.${data[offset + 3]}`; 100 + } else if (type === TYPE_CNAME || type === TYPE_NS) { 101 + [value] = decodeName(data, offset); 102 + } 103 + if (value) records.push({ type, data: value }); 104 + offset += rdlength; 105 + } 106 + return records; 107 + } 108 + 109 + // --- DNS-over-HTTPS (for finding NS records and as fallback) --- 110 + 111 + interface DohAnswer { 112 + type: number; 113 + data: string; 114 + } 115 + 116 + async function dohQuery(name: string, type: string): Promise<DohAnswer[]> { 117 + const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=${type}`; 118 + const res = await fetch(url, { headers: { Accept: 'application/dns-json' } }); 119 + const json: { Answer?: DohAnswer[] } = await res.json(); 120 + return json.Answer ?? []; 121 + } 122 + 123 + // --- Find authoritative nameservers (walking up the domain tree) --- 124 + 125 + async function findNameservers(domain: string): Promise<string[]> { 126 + let current = domain; 127 + while (current.includes('.')) { 128 + const answers = await dohQuery(current, 'NS'); 129 + const ns = answers.filter((a) => a.type === TYPE_NS).map((a) => a.data.replace(/\.$/, '')); 130 + if (ns.length > 0) return ns; 131 + current = current.substring(current.indexOf('.') + 1); 132 + } 133 + return []; 134 + } 135 + 136 + // --- Resolve hostname to IPs via DoH --- 137 + 138 + async function resolveToIps(hostname: string): Promise<string[]> { 139 + const answers = await dohQuery(hostname, 'A'); 140 + return answers.filter((a) => a.type === TYPE_A).map((a) => a.data); 141 + } 142 + 143 + // --- Query nameserver directly via TCP (cloudflare:sockets) --- 144 + 145 + async function queryViaTcp(serverIp: string, name: string, type: number): Promise<DnsRecord[]> { 146 + // @ts-expect-error: cloudflare:sockets is only available in Workers runtime 147 + const { connect } = await import('cloudflare:sockets'); 148 + 149 + const query = buildQuery(name, type); 150 + const tcpMsg = new Uint8Array(query.length + 2); 151 + tcpMsg[0] = (query.length >> 8) & 0xff; 152 + tcpMsg[1] = query.length & 0xff; 153 + tcpMsg.set(query, 2); 154 + 155 + const socket = connect({ hostname: serverIp, port: 53 }); 156 + const writer = socket.writable.getWriter(); 157 + await writer.write(tcpMsg); 158 + writer.releaseLock(); 159 + 160 + const reader = socket.readable.getReader(); 161 + let buffer = new Uint8Array(0); 162 + let responseLen = -1; 163 + 164 + const timeout = setTimeout(() => socket.close(), 5000); 165 + try { 166 + while (true) { 167 + const { value, done } = await reader.read(); 168 + if (done) break; 169 + const chunk = new Uint8Array(value as ArrayBuffer); 170 + const newBuf = new Uint8Array(buffer.length + chunk.length); 171 + newBuf.set(buffer); 172 + newBuf.set(chunk, buffer.length); 173 + buffer = newBuf; 174 + 175 + if (responseLen === -1 && buffer.length >= 2) { 176 + responseLen = (buffer[0] << 8) | buffer[1]; 177 + } 178 + if (responseLen >= 0 && buffer.length >= responseLen + 2) break; 179 + } 180 + } finally { 181 + clearTimeout(timeout); 182 + reader.releaseLock(); 183 + socket.close(); 184 + } 185 + 186 + if (buffer.length < 2) return []; 187 + return parseResponse(buffer.slice(2, 2 + responseLen)); 188 + } 189 + 190 + // --- Query with TCP, fall back to DoH for local dev --- 191 + 192 + async function queryAuthoritative( 193 + serverIp: string, 194 + name: string, 195 + type: number 196 + ): Promise<DnsRecord[]> { 197 + try { 198 + return await queryViaTcp(serverIp, name, type); 199 + } catch { 200 + // cloudflare:sockets not available (local dev) — fall back to DoH 201 + const typeStr = type === TYPE_A ? 'A' : type === TYPE_CNAME ? 'CNAME' : 'NS'; 202 + return (await dohQuery(name, typeStr)).map((a) => ({ type: a.type, data: a.data })); 203 + } 204 + } 205 + 206 + // --- Main verification --- 207 + 208 + export interface DnsVerifyResult { 209 + ok: boolean; 210 + error?: string; 211 + hint?: string; 212 + } 213 + 214 + function normalize(s: string): string { 215 + return s.replace(/\.$/, '').toLowerCase(); 216 + } 217 + 218 + export async function verifyDomainDns( 219 + domain: string, 220 + expectedTarget: string 221 + ): Promise<DnsVerifyResult> { 222 + // Find authoritative nameservers 223 + const nameservers = await findNameservers(domain); 224 + if (nameservers.length === 0) { 225 + return { ok: false, error: `Could not find nameservers for "${domain}".` }; 226 + } 227 + 228 + // Try each nameserver until one responds 229 + for (const ns of nameservers) { 230 + const ips = await resolveToIps(ns); 231 + if (ips.length === 0) continue; 232 + 233 + const nsIp = ips[0]; 234 + 235 + // Check CNAME records 236 + const cnameRecords = await queryAuthoritative(nsIp, domain, TYPE_CNAME); 237 + const cnames = cnameRecords.filter((r) => r.type === TYPE_CNAME); 238 + 239 + if (cnames.length > 0) { 240 + if (cnames.length > 1) { 241 + return { 242 + ok: false, 243 + error: `Multiple CNAME records found for "${domain}": ${cnames.map((r) => r.data).join(', ')}. Remove duplicate records so only one remains.` 244 + }; 245 + } 246 + const got = normalize(cnames[0].data); 247 + const want = normalize(expectedTarget); 248 + if (got !== want) { 249 + return { 250 + ok: false, 251 + error: `CNAME for "${domain}" points to "${got}" instead of "${want}".`, 252 + hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.` 253 + }; 254 + } 255 + return { ok: true }; 256 + } 257 + 258 + // No CNAME — check A records (root/apex domains with CNAME flattening or A records) 259 + const aRecords = await queryAuthoritative(nsIp, domain, TYPE_A); 260 + const aValues = aRecords.filter((r) => r.type === TYPE_A); 261 + 262 + if (aValues.length > 0) { 263 + // Resolve the expected target to get its IPs for comparison 264 + const expectedIps = await resolveToIps(expectedTarget); 265 + if (expectedIps.length === 0) { 266 + return { 267 + ok: false, 268 + error: `Could not resolve "${expectedTarget}" to verify A records.` 269 + }; 270 + } 271 + const expectedSet = new Set(expectedIps); 272 + const unexpected = aValues.filter((r) => !expectedSet.has(r.data)); 273 + if (unexpected.length > 0) { 274 + return { 275 + ok: false, 276 + error: `A record(s) for "${domain}" include unexpected IPs: ${unexpected.map((r) => r.data).join(', ')}. Expected only IPs matching "${expectedTarget}" (${expectedIps.join(', ')}).`, 277 + hint: `If you're using Cloudflare, make sure the proxy (orange cloud) is turned off for this record.` 278 + }; 279 + } 280 + return { ok: true }; 281 + } 282 + 283 + // Neither CNAME nor A found 284 + return { 285 + ok: false, 286 + error: `No CNAME or A record found for "${domain}". Please add a CNAME record pointing to "${expectedTarget}".` 287 + }; 288 + } 289 + 290 + return { 291 + ok: false, 292 + error: `Could not reach any nameserver for "${domain}".` 293 + }; 294 + }
+1 -1
src/lib/website/Account.svelte
··· 47 47 </Popover> 48 48 </div> 49 49 50 - <CustomDomainModal /> 50 + <CustomDomainModal publicationUrl={data.publication?.url} /> 51 51 {/if}
+116 -11
src/lib/website/CustomDomainModal.svelte
··· 7 7 </script> 8 8 9 9 <script lang="ts"> 10 - import { putRecord, getRecord } from '$lib/atproto/methods'; 10 + import { putRecord, getRecord, getHandleOrDid } from '$lib/atproto/methods'; 11 11 import { user } from '$lib/atproto'; 12 12 import { Button, Input } from '@foxui/core'; 13 13 import Modal from '$lib/components/modal/Modal.svelte'; 14 14 import { launchConfetti } from '@foxui/visual'; 15 15 16 - let step: 'input' | 'instructions' | 'verifying' | 'success' | 'error' = $state('input'); 16 + let { publicationUrl }: { publicationUrl?: string } = $props(); 17 + 18 + let currentDomain = $derived( 19 + publicationUrl?.startsWith('https://') && !publicationUrl.includes('blento.app') 20 + ? publicationUrl.replace('https://', '') 21 + : '' 22 + ); 23 + 24 + let step: 'current' | 'input' | 'instructions' | 'verifying' | 'removing' | 'success' | 'error' = 25 + $state('input'); 17 26 let domain = $state(''); 18 27 let errorMessage = $state(''); 28 + let errorHint = $state(''); 19 29 20 30 $effect(() => { 21 - if (!customDomainModalState.visible) { 31 + if (customDomainModalState.visible) { 32 + step = currentDomain ? 'current' : 'input'; 33 + } else { 22 34 step = 'input'; 23 35 domain = ''; 24 36 errorMessage = ''; 37 + errorHint = ''; 25 38 } 26 39 }); 27 40 41 + async function removeDomain() { 42 + step = 'removing'; 43 + try { 44 + const existing = await getRecord({ 45 + collection: 'site.standard.publication', 46 + rkey: 'blento.self' 47 + }); 48 + 49 + if (existing?.value) { 50 + const { url: _url, ...rest } = existing.value as Record<string, unknown>; 51 + await putRecord({ 52 + collection: 'site.standard.publication', 53 + rkey: 'blento.self', 54 + record: rest 55 + }); 56 + } 57 + 58 + step = 'input'; 59 + } catch (err: unknown) { 60 + errorMessage = err instanceof Error ? err.message : String(err); 61 + step = 'error'; 62 + } 63 + } 64 + 28 65 function goToInstructions() { 29 66 if (!domain.trim()) return; 30 67 step = 'instructions'; ··· 33 70 async function verify() { 34 71 step = 'verifying'; 35 72 try { 73 + // Step 1: Verify DNS records 74 + const dnsRes = await fetch('/api/verify-domain', { 75 + method: 'POST', 76 + headers: { 'Content-Type': 'application/json' }, 77 + body: JSON.stringify({ domain }) 78 + }); 79 + 80 + const dnsData = await dnsRes.json(); 81 + 82 + if (!dnsRes.ok || dnsData.error) { 83 + errorMessage = dnsData.error; 84 + errorHint = dnsData.hint || ''; 85 + step = 'error'; 86 + return; 87 + } 88 + 89 + // Step 2: Write URL to ATProto profile 36 90 const existing = await getRecord({ 37 91 collection: 'site.standard.publication', 38 92 rkey: 'blento.self' ··· 47 101 } 48 102 }); 49 103 50 - const res = await fetch('/api/verify-domain', { 104 + // Step 3: Activate domain in KV (server verifies profile has the URL) 105 + const activateRes = await fetch('/api/activate-domain', { 51 106 method: 'POST', 52 107 headers: { 'Content-Type': 'application/json' }, 53 108 body: JSON.stringify({ did: user.did, domain }) 54 109 }); 55 110 56 - const data = await res.json(); 111 + const activateData = await activateRes.json(); 57 112 58 - if (data.success) { 59 - launchConfetti(); 60 - step = 'success'; 61 - } else if (data.error) { 62 - errorMessage = data.error; 113 + if (!activateRes.ok || activateData.error) { 114 + errorMessage = activateData.error; 115 + errorHint = ''; 63 116 step = 'error'; 117 + return; 64 118 } 119 + 120 + // Refresh cached profile 121 + if (user.profile) { 122 + fetch(`/${getHandleOrDid(user.profile)}/api/refresh`).catch(() => {}); 123 + } 124 + 125 + launchConfetti(); 126 + step = 'success'; 65 127 } catch (err: unknown) { 66 128 errorMessage = err instanceof Error ? err.message : String(err); 67 129 step = 'error'; ··· 74 136 </script> 75 137 76 138 <Modal bind:open={customDomainModalState.visible}> 77 - {#if step === 'input'} 139 + {#if step === 'current'} 140 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 141 + Custom Domain 142 + </h3> 143 + 144 + <div 145 + class="bg-base-200 dark:bg-base-700 mt-2 flex items-center justify-between rounded-2xl px-3 py-2 font-mono text-sm" 146 + > 147 + <span>{currentDomain}</span> 148 + </div> 149 + 150 + <div class="mt-4 flex gap-2"> 151 + <Button variant="ghost" onclick={removeDomain}>Remove</Button> 152 + <Button variant="ghost" onclick={() => (step = 'input')}>Change</Button> 153 + <Button onclick={() => customDomainModalState.hide()}>Close</Button> 154 + </div> 155 + {:else if step === 'input'} 78 156 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 79 157 Custom Domain 80 158 </h3> ··· 150 228 </svg> 151 229 <span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span> 152 230 </div> 231 + {:else if step === 'removing'} 232 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 233 + Removing... 234 + </h3> 235 + 236 + <div class="mt-4 flex items-center gap-2"> 237 + <svg 238 + class="text-base-500 size-5 animate-spin" 239 + xmlns="http://www.w3.org/2000/svg" 240 + fill="none" 241 + viewBox="0 0 24 24" 242 + > 243 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 244 + ></circle> 245 + <path 246 + class="opacity-75" 247 + fill="currentColor" 248 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 249 + ></path> 250 + </svg> 251 + <span class="text-base-600 dark:text-base-400 text-sm">Removing domain...</span> 252 + </div> 153 253 {:else if step === 'success'} 154 254 <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 155 255 Domain verified! ··· 170 270 <p class="mt-2 text-sm text-red-500 dark:text-red-400"> 171 271 {errorMessage} 172 272 </p> 273 + {#if errorHint} 274 + <p class="mt-1 text-sm font-bold text-red-500 dark:text-red-400"> 275 + {errorHint} 276 + </p> 277 + {/if} 173 278 174 279 <div class="mt-4 flex gap-2"> 175 280 <Button variant="ghost" onclick={() => customDomainModalState.hide()}>Close</Button>
+67
src/routes/api/activate-domain/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { isDid } from '@atcute/lexicons/syntax'; 3 + import { getRecord } from '$lib/atproto/methods'; 4 + import type { Did } from '@atcute/lexicons'; 5 + 6 + export async function POST({ request, platform }) { 7 + let body: { did: string; domain: string }; 8 + try { 9 + body = await request.json(); 10 + } catch { 11 + return json({ error: 'Invalid JSON body' }, { status: 400 }); 12 + } 13 + 14 + const { did, domain } = body; 15 + 16 + if (!did || !domain) { 17 + return json({ error: 'Missing required fields: did, domain' }, { status: 400 }); 18 + } 19 + 20 + if (!isDid(did)) { 21 + return json({ error: 'Invalid DID format' }, { status: 400 }); 22 + } 23 + 24 + // Validate domain format 25 + if ( 26 + !/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test( 27 + domain 28 + ) 29 + ) { 30 + return json({ error: 'Invalid domain format' }, { status: 400 }); 31 + } 32 + 33 + // Verify the user's ATProto profile has this domain set 34 + try { 35 + const record = await getRecord({ 36 + did: did as Did, 37 + collection: 'site.standard.publication', 38 + rkey: 'blento.self' 39 + }); 40 + 41 + const url = (record?.value as Record<string, unknown>)?.url; 42 + if (url !== `https://${domain}`) { 43 + return json( 44 + { 45 + error: `Profile does not have this domain set. Expected "https://${domain}" but got "${url || '(none)'}".` 46 + }, 47 + { status: 403 } 48 + ); 49 + } 50 + } catch { 51 + return json({ error: 'Failed to verify profile record.' }, { status: 500 }); 52 + } 53 + 54 + // Write to CUSTOM_DOMAINS KV 55 + const kv = platform?.env?.CUSTOM_DOMAINS; 56 + if (!kv) { 57 + return json({ error: 'KV storage not available.' }, { status: 500 }); 58 + } 59 + 60 + try { 61 + await kv.put(domain.toLowerCase(), did); 62 + } catch { 63 + return json({ error: 'Failed to register domain.' }, { status: 500 }); 64 + } 65 + 66 + return json({ success: true }); 67 + }
-30
src/routes/api/reloadRecent/+server.ts
··· 1 - import { getDetailedProfile } from '$lib/atproto'; 2 - import { createCache } from '$lib/cache'; 3 - import { json } from '@sveltejs/kit'; 4 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 5 - 6 - export async function GET({ platform }) { 7 - const cache = createCache(platform); 8 - if (!cache) return json('no cache'); 9 - 10 - const existingUsers = await cache.get('meta', 'updatedBlentos'); 11 - 12 - const existingUsersArray: AppBskyActorDefs.ProfileViewDetailed[] = existingUsers 13 - ? JSON.parse(existingUsers) 14 - : []; 15 - 16 - const existingUsersSet = new Set(existingUsersArray.map((v) => v.did)); 17 - 18 - const newProfilesPromises: Promise<AppBskyActorDefs.ProfileViewDetailed | undefined>[] = []; 19 - for (const did of Array.from(existingUsersSet)) { 20 - const profile = getDetailedProfile({ did }); 21 - newProfilesPromises.push(profile); 22 - if (newProfilesPromises.length > 20) break; 23 - } 24 - 25 - const newProfiles = await Promise.all(newProfilesPromises); 26 - 27 - await cache.put('meta', 'updatedBlentos', JSON.stringify(newProfiles)); 28 - 29 - return json('ok'); 30 - }
-32
src/routes/api/update/+server.ts
··· 1 - import { createCache } from '$lib/cache'; 2 - import { getCache, loadData } from '$lib/website/load'; 3 - import { env } from '$env/dynamic/private'; 4 - import { json } from '@sveltejs/kit'; 5 - import type { AppBskyActorDefs } from '@atcute/bluesky'; 6 - 7 - export async function GET({ platform }) { 8 - const cache = createCache(platform); 9 - if (!cache) return json('no cache'); 10 - 11 - const existingUsers = await cache.get('meta', 'updatedBlentos'); 12 - 13 - const existingUsersArray: AppBskyActorDefs.ProfileViewDetailed[] = existingUsers 14 - ? JSON.parse(existingUsers) 15 - : []; 16 - 17 - const existingUsersHandle = existingUsersArray.map((v) => v.handle); 18 - 19 - for (const handle of existingUsersHandle) { 20 - if (!handle) continue; 21 - 22 - try { 23 - const cached = await getCache(handle, 'self', cache); 24 - if (!cached) await loadData(handle, cache, true, 'self', env); 25 - } catch (error) { 26 - console.error(error); 27 - return json('error'); 28 - } 29 - } 30 - 31 - return json('ok'); 32 - }
+15 -69
src/routes/api/verify-domain/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 - import type { Did } from '@atcute/lexicons'; 3 - import { getClient, getRecord } from '$lib/atproto/methods'; 2 + import { verifyDomainDns } from '$lib/dns'; 4 3 5 - export async function POST({ request, platform }) { 6 - let body: { did: string; domain: string }; 4 + const EXPECTED_TARGET = 'blento-proxy.fly.dev'; 5 + 6 + export async function POST({ request }) { 7 + let body: { domain: string }; 7 8 try { 8 9 body = await request.json(); 9 10 } catch { 10 11 return json({ error: 'Invalid JSON body' }, { status: 400 }); 11 12 } 12 13 13 - const { did, domain } = body; 14 + const { domain } = body; 14 15 15 - if (!did || !domain) { 16 - return json({ error: 'Missing required fields: did and domain' }, { status: 400 }); 16 + if (!domain) { 17 + return json({ error: 'Missing required field: domain' }, { status: 400 }); 17 18 } 18 19 19 20 // Validate domain format ··· 25 26 return json({ error: 'Invalid domain format' }, { status: 400 }); 26 27 } 27 28 28 - // Check the user's site.standard.publication record 29 - try { 30 - const client = await getClient({ did: did as Did }); 31 - const record = await getRecord({ 32 - did: did as Did, 33 - collection: 'site.standard.publication', 34 - rkey: 'blento.self', 35 - client 36 - }); 37 - 38 - const recordUrl = record?.value?.url; 39 - const expectedUrl = `https://${domain}`; 40 - 41 - if (recordUrl !== expectedUrl) { 42 - return json( 43 - { 44 - error: `Publication record URL does not match. Expected "${expectedUrl}", got "${recordUrl || '(not set)'}".` 45 - }, 46 - { status: 400 } 47 - ); 48 - } 49 - } catch { 50 - return json( 51 - { error: 'Could not read site.standard.publication record. Make sure it exists.' }, 52 - { status: 400 } 53 - ); 54 - } 55 - 56 - // Verify CNAME via DNS-over-HTTPS 29 + // Verify DNS by querying authoritative nameservers directly. 30 + // This gives instant, accurate results instead of relying on cached resolvers. 31 + // Checks CNAME for subdomains and A records for root/apex domains. 32 + // See: https://jacob.gold/posts/stop-telling-users-their-dns-is-wrong/ 57 33 try { 58 - const dohUrl = `https://mozilla.cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=CNAME`; 59 - const dnsRes = await fetch(dohUrl, { 60 - headers: { Accept: 'application/dns-json' } 61 - }); 62 - const dnsData = await dnsRes.json(); 63 - 64 - const cnameTarget = 'blento-proxy.fly.dev.'; 65 - const cnameTargetNoDot = 'blento-proxy.fly.dev'; 66 - 67 - const hasCname = dnsData.Answer?.some( 68 - (answer: { type: number; data: string }) => 69 - answer.type === 5 && (answer.data === cnameTarget || answer.data === cnameTargetNoDot) 70 - ); 71 - 72 - if (!hasCname) { 73 - return json( 74 - { 75 - error: `CNAME record not found. Please set a CNAME for "${domain}" pointing to "blento-proxy.fly.dev".` 76 - }, 77 - { status: 400 } 78 - ); 34 + const result = await verifyDomainDns(domain, EXPECTED_TARGET); 35 + if (!result.ok) { 36 + return json({ error: result.error, hint: result.hint }, { status: 400 }); 79 37 } 80 38 } catch { 81 39 return json({ error: 'Failed to verify DNS records.' }, { status: 500 }); 82 - } 83 - 84 - // Write to CUSTOM_DOMAINS KV 85 - const kv = platform?.env?.CUSTOM_DOMAINS; 86 - if (!kv) { 87 - return json({ error: 'Domain storage is not available.' }, { status: 500 }); 88 - } 89 - 90 - try { 91 - await kv.put(domain, did); 92 - } catch { 93 - return json({ error: 'Failed to save custom domain.' }, { status: 500 }); 94 40 } 95 41 96 42 return json({ success: true });
+4 -1
vite.config.ts
··· 10 10 port: 5179 11 11 }, 12 12 build: { 13 - sourcemap: true 13 + sourcemap: true, 14 + rollupOptions: { 15 + external: ['cloudflare:sockets'] 16 + } 14 17 } 15 18 });