your personal website on atproto - mirror blento.app

custom domains

+408 -14
+1
src/app.d.ts
··· 11 11 interface Platform { 12 12 env: { 13 13 USER_DATA_CACHE: KVNamespace; 14 + CUSTOM_DOMAINS: KVNamespace; 14 15 }; 15 16 } 16 17 }
+69
src/lib/components/modal/Modal.svelte
··· 1 + <script lang="ts" module> 2 + import { Dialog, type WithoutChild } from 'bits-ui'; 3 + import { cn } from '@foxui/core'; 4 + 5 + export type ModalProps = Dialog.RootProps & { 6 + interactOutsideBehavior?: 'close' | 'ignore'; 7 + closeButton?: boolean; 8 + contentProps?: WithoutChild<Dialog.ContentProps>; 9 + 10 + class?: string; 11 + 12 + onOpenAutoFocus?: (event: Event) => void; 13 + }; 14 + </script> 15 + 16 + <script lang="ts"> 17 + let { 18 + open = $bindable(false), 19 + children, 20 + contentProps, 21 + interactOutsideBehavior = 'close', 22 + closeButton = true, 23 + class: className, 24 + onOpenAutoFocus, 25 + ...restProps 26 + }: ModalProps = $props(); 27 + </script> 28 + 29 + <Dialog.Root bind:open {...restProps}> 30 + <Dialog.Portal> 31 + <Dialog.Overlay 32 + class="motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-base-200/10 dark:bg-base-900/10 fixed inset-0 z-50 backdrop-blur-sm" 33 + /> 34 + <Dialog.Content 35 + {onOpenAutoFocus} 36 + {interactOutsideBehavior} 37 + {...contentProps} 38 + class={cn( 39 + 'motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom-1/2 data-[state=open]:slide-in-from-bottom-1/2', 40 + 'fixed top-[50%] left-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] shadow-lg', 41 + 'border-base-200 bg-base-100 dark:border-base-700 dark:bg-base-800 gap-4 rounded-2xl border p-6 backdrop-blur-xl', 42 + className 43 + )} 44 + > 45 + {@render children?.()} 46 + 47 + {#if closeButton} 48 + <Dialog.Close 49 + class="text-base-900 dark:text-base-500 hover:text-base-800 dark:hover:text-base-200 hover:bg-base-200 dark:hover:bg-base-800 focus:outline-base-900 dark:focus:outline-base-50 focus:bg-base-200 dark:focus:bg-base-800 focus:text-base-800 dark:focus:text-base-200 absolute top-2 right-2 cursor-pointer rounded-xl p-1 transition-colors focus:outline-2 focus:outline-offset-2" 50 + > 51 + <svg 52 + xmlns="http://www.w3.org/2000/svg" 53 + viewBox="0 0 24 24" 54 + fill="currentColor" 55 + class="size-4" 56 + > 57 + <path 58 + fill-rule="evenodd" 59 + d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" 60 + clip-rule="evenodd" 61 + /> 62 + </svg> 63 + 64 + <span class="sr-only">Close</span> 65 + </Dialog.Close> 66 + {/if} 67 + </Dialog.Content> 68 + </Dialog.Portal> 69 + </Dialog.Root>
+12 -2
src/lib/website/Account.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 - import { user, login, logout } from '$lib/atproto'; 3 + import { user, logout } from '$lib/atproto'; 4 4 import { getHandleOrDid } from '$lib/atproto/methods'; 5 5 import type { WebsiteData } from '$lib/types'; 6 - import type { ActorIdentifier } from '@atcute/lexicons'; 7 6 import { Avatar, Button, Popover } from '@foxui/core'; 7 + import CustomDomainModal, { customDomainModalState } from '$lib/website/CustomDomainModal.svelte'; 8 8 9 9 let { 10 10 data ··· 34 34 > 35 35 {/if} 36 36 37 + <Button 38 + variant="ghost" 39 + onclick={() => { 40 + settingsPopoverOpen = false; 41 + customDomainModalState.show(); 42 + }}>Custom Domain</Button 43 + > 44 + 37 45 <Button variant="ghost" onclick={logout}>Logout</Button> 38 46 </div> 39 47 </Popover> 40 48 </div> 49 + 50 + <CustomDomainModal /> 41 51 {/if}
+177
src/lib/website/CustomDomainModal.svelte
··· 1 + <script lang="ts" module> 2 + export const customDomainModalState = $state({ 3 + visible: false, 4 + show: () => (customDomainModalState.visible = true), 5 + hide: () => (customDomainModalState.visible = false) 6 + }); 7 + </script> 8 + 9 + <script lang="ts"> 10 + import { putRecord, getRecord } from '$lib/atproto/methods'; 11 + import { user } from '$lib/atproto'; 12 + import { Button, Input } from '@foxui/core'; 13 + import Modal from '$lib/components/modal/Modal.svelte'; 14 + 15 + let step: 'input' | 'instructions' | 'verifying' | 'success' | 'error' = $state('input'); 16 + let domain = $state(''); 17 + let errorMessage = $state(''); 18 + 19 + $effect(() => { 20 + if (!customDomainModalState.visible) { 21 + step = 'input'; 22 + domain = ''; 23 + errorMessage = ''; 24 + } 25 + }); 26 + 27 + function goToInstructions() { 28 + if (!domain.trim()) return; 29 + step = 'instructions'; 30 + } 31 + 32 + async function verify() { 33 + step = 'verifying'; 34 + try { 35 + const existing = await getRecord({ 36 + collection: 'site.standard.publication', 37 + rkey: 'blento.self' 38 + }); 39 + 40 + await putRecord({ 41 + collection: 'site.standard.publication', 42 + rkey: 'blento.self', 43 + record: { 44 + ...(existing?.value || {}), 45 + url: 'https://' + domain 46 + } 47 + }); 48 + 49 + const res = await fetch('/api/verify-domain', { 50 + method: 'POST', 51 + headers: { 'Content-Type': 'application/json' }, 52 + body: JSON.stringify({ did: user.did, domain }) 53 + }); 54 + 55 + const data = await res.json(); 56 + 57 + if (data.success) { 58 + step = 'success'; 59 + } else if (data.error) { 60 + errorMessage = data.error; 61 + step = 'error'; 62 + } 63 + } catch (err: unknown) { 64 + errorMessage = err instanceof Error ? err.message : String(err); 65 + step = 'error'; 66 + } 67 + } 68 + 69 + async function copyToClipboard(text: string) { 70 + await navigator.clipboard.writeText(text); 71 + } 72 + </script> 73 + 74 + <Modal bind:open={customDomainModalState.visible}> 75 + {#if step === 'input'} 76 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 77 + Custom Domain 78 + </h3> 79 + 80 + <Input type="text" bind:value={domain} placeholder="mydomain.com" /> 81 + 82 + <div class="mt-4 flex gap-2"> 83 + <Button variant="ghost" onclick={() => customDomainModalState.hide()}>Cancel</Button> 84 + <Button onclick={goToInstructions} disabled={!domain.trim()}>Next</Button> 85 + </div> 86 + {:else if step === 'instructions'} 87 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 88 + Set up your domain 89 + </h3> 90 + 91 + <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 92 + Add a CNAME record for your domain pointing to: 93 + </p> 94 + 95 + <div 96 + 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" 97 + > 98 + <span>blento-proxy.fly.dev</span> 99 + <button 100 + class="text-base-600 hover:text-base-900 dark:text-base-400 dark:hover:text-base-100 ml-2 cursor-pointer" 101 + onclick={() => copyToClipboard('blento-proxy.fly.dev')} 102 + > 103 + <svg 104 + xmlns="http://www.w3.org/2000/svg" 105 + fill="none" 106 + viewBox="0 0 24 24" 107 + stroke-width="1.5" 108 + stroke="currentColor" 109 + class="size-4" 110 + > 111 + <path 112 + stroke-linecap="round" 113 + stroke-linejoin="round" 114 + d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" 115 + /> 116 + </svg> 117 + <span class="sr-only">Copy to clipboard</span> 118 + </button> 119 + </div> 120 + 121 + <div class="mt-4 flex gap-2"> 122 + <Button variant="ghost" onclick={() => (step = 'input')}>Back</Button> 123 + <Button onclick={verify}>Verify</Button> 124 + </div> 125 + {:else if step === 'verifying'} 126 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 127 + Verifying... 128 + </h3> 129 + 130 + <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 131 + Checking DNS records and verifying your domain. 132 + </p> 133 + 134 + <div class="mt-4 flex items-center gap-2"> 135 + <svg 136 + class="text-base-500 size-5 animate-spin" 137 + xmlns="http://www.w3.org/2000/svg" 138 + fill="none" 139 + viewBox="0 0 24 24" 140 + > 141 + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" 142 + ></circle> 143 + <path 144 + class="opacity-75" 145 + fill="currentColor" 146 + 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" 147 + ></path> 148 + </svg> 149 + <span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span> 150 + </div> 151 + {:else if step === 'success'} 152 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 153 + Domain verified! 154 + </h3> 155 + 156 + <p class="text-base-800 dark:text-base-200 mt-2 text-sm"> 157 + Your custom domain {domain} has been set up successfully. 158 + </p> 159 + 160 + <div class="mt-4"> 161 + <Button onclick={() => customDomainModalState.hide()}>Close</Button> 162 + </div> 163 + {:else if step === 'error'} 164 + <h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title"> 165 + Verification failed 166 + </h3> 167 + 168 + <p class="mt-2 text-sm text-red-500 dark:text-red-400"> 169 + {errorMessage} 170 + </p> 171 + 172 + <div class="mt-4 flex gap-2"> 173 + <Button variant="ghost" onclick={() => customDomainModalState.hide()}>Close</Button> 174 + <Button onclick={verify}>Retry</Button> 175 + </div> 176 + {/if} 177 + </Modal>
+16 -6
src/routes/+page.server.ts
··· 1 1 import { loadData } from '$lib/website/load'; 2 2 import { env } from '$env/dynamic/public'; 3 3 import type { UserCache } from '$lib/types'; 4 - import type { Handle } from '@atcute/lexicons'; 4 + import type { ActorIdentifier, Handle } from '@atcute/lexicons'; 5 5 6 6 export async function load({ platform, url }) { 7 7 const hostname = url.hostname; 8 8 9 - let handle = env.PUBLIC_HANDLE; 10 - if (hostname === 'flo-bit.blento.app') { 11 - handle = 'flo-bit.dev'; 12 - } 9 + const handle = env.PUBLIC_HANDLE; 10 + 11 + const kv = platform?.env?.CUSTOM_DOMAINS; 12 + 13 13 const cache = platform?.env?.USER_DATA_CACHE as unknown; 14 14 15 - return await loadData(handle as Handle, cache as UserCache); 15 + if (kv) { 16 + try { 17 + const did = await kv.get(hostname); 18 + 19 + if (did) return await loadData(did as ActorIdentifier, cache as UserCache); 20 + } catch (error) { 21 + console.error('failed to get custom domain kv', error); 22 + } 23 + } 24 + 25 + return await loadData(handle as ActorIdentifier, cache as UserCache); 16 26 }
+97
src/routes/api/verify-domain/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getClient, getRecord } from '$lib/atproto/methods'; 4 + 5 + export async function POST({ request, platform }) { 6 + let body: { did: string; domain: string }; 7 + try { 8 + body = await request.json(); 9 + } catch { 10 + return json({ error: 'Invalid JSON body' }, { status: 400 }); 11 + } 12 + 13 + const { did, domain } = body; 14 + 15 + if (!did || !domain) { 16 + return json({ error: 'Missing required fields: did and domain' }, { status: 400 }); 17 + } 18 + 19 + // Validate domain format 20 + if ( 21 + !/^[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( 22 + domain 23 + ) 24 + ) { 25 + return json({ error: 'Invalid domain format' }, { status: 400 }); 26 + } 27 + 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 57 + 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 + ); 79 + } 80 + } catch { 81 + 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 + } 95 + 96 + return json({ success: true }); 97 + }
+15 -6
src/routes/edit/+page.server.ts
··· 1 1 import { loadData } from '$lib/website/load'; 2 2 import { env } from '$env/dynamic/public'; 3 3 import type { UserCache } from '$lib/types'; 4 - import type { Handle } from '@atcute/lexicons'; 4 + import type { ActorIdentifier } from '@atcute/lexicons'; 5 5 6 6 export async function load({ url, platform }) { 7 7 const hostname = url.hostname; 8 8 9 - let handle = env.PUBLIC_HANDLE; 10 - if (hostname === 'flo-bit.blento.app') { 11 - handle = 'flo-bit.dev'; 12 - } 9 + const handle = env.PUBLIC_HANDLE; 10 + 11 + const kv = platform?.env?.CUSTOM_DOMAINS; 13 12 14 13 const cache = platform?.env?.USER_DATA_CACHE as unknown; 15 14 16 - return await loadData(handle as Handle, cache as UserCache); 15 + if (kv) { 16 + try { 17 + const did = await kv.get(hostname); 18 + 19 + if (did) return await loadData(did as ActorIdentifier, cache as UserCache); 20 + } catch (error) { 21 + console.error('failed to get custom domain kv', error); 22 + } 23 + } 24 + 25 + return await loadData(handle as ActorIdentifier, cache as UserCache); 17 26 }
+16
src/routes/test/domains/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + 3 + export async function GET({ platform }) { 4 + const kv = platform?.env?.CUSTOM_DOMAINS; 5 + if (!kv) return json({ error: 'KV not available' }, { status: 500 }); 6 + 7 + const list = await kv.list(); 8 + const entries: Record<string, string> = {}; 9 + 10 + for (const key of list.keys) { 11 + const value = await kv.get(key.name); 12 + entries[key.name] = value ?? ''; 13 + } 14 + 15 + return json(entries); 16 + }
+5
wrangler.jsonc
··· 42 42 { 43 43 "binding": "USER_DATA_CACHE", 44 44 "id": "d6ff203259de48538d332b0a5df258a7" 45 + }, 46 + { 47 + "binding": "CUSTOM_DOMAINS", 48 + "id": "f449b3b5c8a349478405e2c04ed265f0", 49 + "remote": true 45 50 } 46 51 ] 47 52