tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
custom domains
Florian
5 days ago
ecea2cbd
0eb70243
+408
-14
9 changed files
expand all
collapse all
unified
split
src
app.d.ts
lib
components
modal
Modal.svelte
website
Account.svelte
CustomDomainModal.svelte
routes
+page.server.ts
api
verify-domain
+server.ts
edit
+page.server.ts
test
domains
+server.ts
wrangler.jsonc
+1
src/app.d.ts
···
11
11
interface Platform {
12
12
env: {
13
13
USER_DATA_CACHE: KVNamespace;
14
14
+
CUSTOM_DOMAINS: KVNamespace;
14
15
};
15
16
}
16
17
}
+69
src/lib/components/modal/Modal.svelte
···
1
1
+
<script lang="ts" module>
2
2
+
import { Dialog, type WithoutChild } from 'bits-ui';
3
3
+
import { cn } from '@foxui/core';
4
4
+
5
5
+
export type ModalProps = Dialog.RootProps & {
6
6
+
interactOutsideBehavior?: 'close' | 'ignore';
7
7
+
closeButton?: boolean;
8
8
+
contentProps?: WithoutChild<Dialog.ContentProps>;
9
9
+
10
10
+
class?: string;
11
11
+
12
12
+
onOpenAutoFocus?: (event: Event) => void;
13
13
+
};
14
14
+
</script>
15
15
+
16
16
+
<script lang="ts">
17
17
+
let {
18
18
+
open = $bindable(false),
19
19
+
children,
20
20
+
contentProps,
21
21
+
interactOutsideBehavior = 'close',
22
22
+
closeButton = true,
23
23
+
class: className,
24
24
+
onOpenAutoFocus,
25
25
+
...restProps
26
26
+
}: ModalProps = $props();
27
27
+
</script>
28
28
+
29
29
+
<Dialog.Root bind:open {...restProps}>
30
30
+
<Dialog.Portal>
31
31
+
<Dialog.Overlay
32
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
33
+
/>
34
34
+
<Dialog.Content
35
35
+
{onOpenAutoFocus}
36
36
+
{interactOutsideBehavior}
37
37
+
{...contentProps}
38
38
+
class={cn(
39
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
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
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
42
+
className
43
43
+
)}
44
44
+
>
45
45
+
{@render children?.()}
46
46
+
47
47
+
{#if closeButton}
48
48
+
<Dialog.Close
49
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
50
+
>
51
51
+
<svg
52
52
+
xmlns="http://www.w3.org/2000/svg"
53
53
+
viewBox="0 0 24 24"
54
54
+
fill="currentColor"
55
55
+
class="size-4"
56
56
+
>
57
57
+
<path
58
58
+
fill-rule="evenodd"
59
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
60
+
clip-rule="evenodd"
61
61
+
/>
62
62
+
</svg>
63
63
+
64
64
+
<span class="sr-only">Close</span>
65
65
+
</Dialog.Close>
66
66
+
{/if}
67
67
+
</Dialog.Content>
68
68
+
</Dialog.Portal>
69
69
+
</Dialog.Root>
+12
-2
src/lib/website/Account.svelte
···
1
1
<script lang="ts">
2
2
import { goto } from '$app/navigation';
3
3
-
import { user, login, logout } from '$lib/atproto';
3
3
+
import { user, logout } from '$lib/atproto';
4
4
import { getHandleOrDid } from '$lib/atproto/methods';
5
5
import type { WebsiteData } from '$lib/types';
6
6
-
import type { ActorIdentifier } from '@atcute/lexicons';
7
6
import { Avatar, Button, Popover } from '@foxui/core';
7
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
37
+
<Button
38
38
+
variant="ghost"
39
39
+
onclick={() => {
40
40
+
settingsPopoverOpen = false;
41
41
+
customDomainModalState.show();
42
42
+
}}>Custom Domain</Button
43
43
+
>
44
44
+
37
45
<Button variant="ghost" onclick={logout}>Logout</Button>
38
46
</div>
39
47
</Popover>
40
48
</div>
49
49
+
50
50
+
<CustomDomainModal />
41
51
{/if}
+177
src/lib/website/CustomDomainModal.svelte
···
1
1
+
<script lang="ts" module>
2
2
+
export const customDomainModalState = $state({
3
3
+
visible: false,
4
4
+
show: () => (customDomainModalState.visible = true),
5
5
+
hide: () => (customDomainModalState.visible = false)
6
6
+
});
7
7
+
</script>
8
8
+
9
9
+
<script lang="ts">
10
10
+
import { putRecord, getRecord } 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
+
15
15
+
let step: 'input' | 'instructions' | 'verifying' | 'success' | 'error' = $state('input');
16
16
+
let domain = $state('');
17
17
+
let errorMessage = $state('');
18
18
+
19
19
+
$effect(() => {
20
20
+
if (!customDomainModalState.visible) {
21
21
+
step = 'input';
22
22
+
domain = '';
23
23
+
errorMessage = '';
24
24
+
}
25
25
+
});
26
26
+
27
27
+
function goToInstructions() {
28
28
+
if (!domain.trim()) return;
29
29
+
step = 'instructions';
30
30
+
}
31
31
+
32
32
+
async function verify() {
33
33
+
step = 'verifying';
34
34
+
try {
35
35
+
const existing = await getRecord({
36
36
+
collection: 'site.standard.publication',
37
37
+
rkey: 'blento.self'
38
38
+
});
39
39
+
40
40
+
await putRecord({
41
41
+
collection: 'site.standard.publication',
42
42
+
rkey: 'blento.self',
43
43
+
record: {
44
44
+
...(existing?.value || {}),
45
45
+
url: 'https://' + domain
46
46
+
}
47
47
+
});
48
48
+
49
49
+
const res = await fetch('/api/verify-domain', {
50
50
+
method: 'POST',
51
51
+
headers: { 'Content-Type': 'application/json' },
52
52
+
body: JSON.stringify({ did: user.did, domain })
53
53
+
});
54
54
+
55
55
+
const data = await res.json();
56
56
+
57
57
+
if (data.success) {
58
58
+
step = 'success';
59
59
+
} else if (data.error) {
60
60
+
errorMessage = data.error;
61
61
+
step = 'error';
62
62
+
}
63
63
+
} catch (err: unknown) {
64
64
+
errorMessage = err instanceof Error ? err.message : String(err);
65
65
+
step = 'error';
66
66
+
}
67
67
+
}
68
68
+
69
69
+
async function copyToClipboard(text: string) {
70
70
+
await navigator.clipboard.writeText(text);
71
71
+
}
72
72
+
</script>
73
73
+
74
74
+
<Modal bind:open={customDomainModalState.visible}>
75
75
+
{#if step === 'input'}
76
76
+
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
77
77
+
Custom Domain
78
78
+
</h3>
79
79
+
80
80
+
<Input type="text" bind:value={domain} placeholder="mydomain.com" />
81
81
+
82
82
+
<div class="mt-4 flex gap-2">
83
83
+
<Button variant="ghost" onclick={() => customDomainModalState.hide()}>Cancel</Button>
84
84
+
<Button onclick={goToInstructions} disabled={!domain.trim()}>Next</Button>
85
85
+
</div>
86
86
+
{:else if step === 'instructions'}
87
87
+
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
88
88
+
Set up your domain
89
89
+
</h3>
90
90
+
91
91
+
<p class="text-base-800 dark:text-base-200 mt-2 text-sm">
92
92
+
Add a CNAME record for your domain pointing to:
93
93
+
</p>
94
94
+
95
95
+
<div
96
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
97
+
>
98
98
+
<span>blento-proxy.fly.dev</span>
99
99
+
<button
100
100
+
class="text-base-600 hover:text-base-900 dark:text-base-400 dark:hover:text-base-100 ml-2 cursor-pointer"
101
101
+
onclick={() => copyToClipboard('blento-proxy.fly.dev')}
102
102
+
>
103
103
+
<svg
104
104
+
xmlns="http://www.w3.org/2000/svg"
105
105
+
fill="none"
106
106
+
viewBox="0 0 24 24"
107
107
+
stroke-width="1.5"
108
108
+
stroke="currentColor"
109
109
+
class="size-4"
110
110
+
>
111
111
+
<path
112
112
+
stroke-linecap="round"
113
113
+
stroke-linejoin="round"
114
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
115
+
/>
116
116
+
</svg>
117
117
+
<span class="sr-only">Copy to clipboard</span>
118
118
+
</button>
119
119
+
</div>
120
120
+
121
121
+
<div class="mt-4 flex gap-2">
122
122
+
<Button variant="ghost" onclick={() => (step = 'input')}>Back</Button>
123
123
+
<Button onclick={verify}>Verify</Button>
124
124
+
</div>
125
125
+
{:else if step === 'verifying'}
126
126
+
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
127
127
+
Verifying...
128
128
+
</h3>
129
129
+
130
130
+
<p class="text-base-800 dark:text-base-200 mt-2 text-sm">
131
131
+
Checking DNS records and verifying your domain.
132
132
+
</p>
133
133
+
134
134
+
<div class="mt-4 flex items-center gap-2">
135
135
+
<svg
136
136
+
class="text-base-500 size-5 animate-spin"
137
137
+
xmlns="http://www.w3.org/2000/svg"
138
138
+
fill="none"
139
139
+
viewBox="0 0 24 24"
140
140
+
>
141
141
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
142
142
+
></circle>
143
143
+
<path
144
144
+
class="opacity-75"
145
145
+
fill="currentColor"
146
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
147
+
></path>
148
148
+
</svg>
149
149
+
<span class="text-base-600 dark:text-base-400 text-sm">Verifying...</span>
150
150
+
</div>
151
151
+
{:else if step === 'success'}
152
152
+
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
153
153
+
Domain verified!
154
154
+
</h3>
155
155
+
156
156
+
<p class="text-base-800 dark:text-base-200 mt-2 text-sm">
157
157
+
Your custom domain {domain} has been set up successfully.
158
158
+
</p>
159
159
+
160
160
+
<div class="mt-4">
161
161
+
<Button onclick={() => customDomainModalState.hide()}>Close</Button>
162
162
+
</div>
163
163
+
{:else if step === 'error'}
164
164
+
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="custom-domain-modal-title">
165
165
+
Verification failed
166
166
+
</h3>
167
167
+
168
168
+
<p class="mt-2 text-sm text-red-500 dark:text-red-400">
169
169
+
{errorMessage}
170
170
+
</p>
171
171
+
172
172
+
<div class="mt-4 flex gap-2">
173
173
+
<Button variant="ghost" onclick={() => customDomainModalState.hide()}>Close</Button>
174
174
+
<Button onclick={verify}>Retry</Button>
175
175
+
</div>
176
176
+
{/if}
177
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
4
-
import type { Handle } from '@atcute/lexicons';
4
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
9
-
let handle = env.PUBLIC_HANDLE;
10
10
-
if (hostname === 'flo-bit.blento.app') {
11
11
-
handle = 'flo-bit.dev';
12
12
-
}
9
9
+
const handle = env.PUBLIC_HANDLE;
10
10
+
11
11
+
const kv = platform?.env?.CUSTOM_DOMAINS;
12
12
+
13
13
const cache = platform?.env?.USER_DATA_CACHE as unknown;
14
14
15
15
-
return await loadData(handle as Handle, cache as UserCache);
15
15
+
if (kv) {
16
16
+
try {
17
17
+
const did = await kv.get(hostname);
18
18
+
19
19
+
if (did) return await loadData(did as ActorIdentifier, cache as UserCache);
20
20
+
} catch (error) {
21
21
+
console.error('failed to get custom domain kv', error);
22
22
+
}
23
23
+
}
24
24
+
25
25
+
return await loadData(handle as ActorIdentifier, cache as UserCache);
16
26
}
+97
src/routes/api/verify-domain/+server.ts
···
1
1
+
import { json } from '@sveltejs/kit';
2
2
+
import type { Did } from '@atcute/lexicons';
3
3
+
import { getClient, getRecord } from '$lib/atproto/methods';
4
4
+
5
5
+
export async function POST({ request, platform }) {
6
6
+
let body: { did: string; domain: string };
7
7
+
try {
8
8
+
body = await request.json();
9
9
+
} catch {
10
10
+
return json({ error: 'Invalid JSON body' }, { status: 400 });
11
11
+
}
12
12
+
13
13
+
const { did, domain } = body;
14
14
+
15
15
+
if (!did || !domain) {
16
16
+
return json({ error: 'Missing required fields: did and domain' }, { status: 400 });
17
17
+
}
18
18
+
19
19
+
// Validate domain format
20
20
+
if (
21
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
22
+
domain
23
23
+
)
24
24
+
) {
25
25
+
return json({ error: 'Invalid domain format' }, { status: 400 });
26
26
+
}
27
27
+
28
28
+
// Check the user's site.standard.publication record
29
29
+
try {
30
30
+
const client = await getClient({ did: did as Did });
31
31
+
const record = await getRecord({
32
32
+
did: did as Did,
33
33
+
collection: 'site.standard.publication',
34
34
+
rkey: 'blento.self',
35
35
+
client
36
36
+
});
37
37
+
38
38
+
const recordUrl = record?.value?.url;
39
39
+
const expectedUrl = `https://${domain}`;
40
40
+
41
41
+
if (recordUrl !== expectedUrl) {
42
42
+
return json(
43
43
+
{
44
44
+
error: `Publication record URL does not match. Expected "${expectedUrl}", got "${recordUrl || '(not set)'}".`
45
45
+
},
46
46
+
{ status: 400 }
47
47
+
);
48
48
+
}
49
49
+
} catch {
50
50
+
return json(
51
51
+
{ error: 'Could not read site.standard.publication record. Make sure it exists.' },
52
52
+
{ status: 400 }
53
53
+
);
54
54
+
}
55
55
+
56
56
+
// Verify CNAME via DNS-over-HTTPS
57
57
+
try {
58
58
+
const dohUrl = `https://mozilla.cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}&type=CNAME`;
59
59
+
const dnsRes = await fetch(dohUrl, {
60
60
+
headers: { Accept: 'application/dns-json' }
61
61
+
});
62
62
+
const dnsData = await dnsRes.json();
63
63
+
64
64
+
const cnameTarget = 'blento-proxy.fly.dev.';
65
65
+
const cnameTargetNoDot = 'blento-proxy.fly.dev';
66
66
+
67
67
+
const hasCname = dnsData.Answer?.some(
68
68
+
(answer: { type: number; data: string }) =>
69
69
+
answer.type === 5 && (answer.data === cnameTarget || answer.data === cnameTargetNoDot)
70
70
+
);
71
71
+
72
72
+
if (!hasCname) {
73
73
+
return json(
74
74
+
{
75
75
+
error: `CNAME record not found. Please set a CNAME for "${domain}" pointing to "blento-proxy.fly.dev".`
76
76
+
},
77
77
+
{ status: 400 }
78
78
+
);
79
79
+
}
80
80
+
} catch {
81
81
+
return json({ error: 'Failed to verify DNS records.' }, { status: 500 });
82
82
+
}
83
83
+
84
84
+
// Write to CUSTOM_DOMAINS KV
85
85
+
const kv = platform?.env?.CUSTOM_DOMAINS;
86
86
+
if (!kv) {
87
87
+
return json({ error: 'Domain storage is not available.' }, { status: 500 });
88
88
+
}
89
89
+
90
90
+
try {
91
91
+
await kv.put(domain, did);
92
92
+
} catch {
93
93
+
return json({ error: 'Failed to save custom domain.' }, { status: 500 });
94
94
+
}
95
95
+
96
96
+
return json({ success: true });
97
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
4
-
import type { Handle } from '@atcute/lexicons';
4
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
9
-
let handle = env.PUBLIC_HANDLE;
10
10
-
if (hostname === 'flo-bit.blento.app') {
11
11
-
handle = 'flo-bit.dev';
12
12
-
}
9
9
+
const handle = env.PUBLIC_HANDLE;
10
10
+
11
11
+
const kv = platform?.env?.CUSTOM_DOMAINS;
13
12
14
13
const cache = platform?.env?.USER_DATA_CACHE as unknown;
15
14
16
16
-
return await loadData(handle as Handle, cache as UserCache);
15
15
+
if (kv) {
16
16
+
try {
17
17
+
const did = await kv.get(hostname);
18
18
+
19
19
+
if (did) return await loadData(did as ActorIdentifier, cache as UserCache);
20
20
+
} catch (error) {
21
21
+
console.error('failed to get custom domain kv', error);
22
22
+
}
23
23
+
}
24
24
+
25
25
+
return await loadData(handle as ActorIdentifier, cache as UserCache);
17
26
}
+16
src/routes/test/domains/+server.ts
···
1
1
+
import { json } from '@sveltejs/kit';
2
2
+
3
3
+
export async function GET({ platform }) {
4
4
+
const kv = platform?.env?.CUSTOM_DOMAINS;
5
5
+
if (!kv) return json({ error: 'KV not available' }, { status: 500 });
6
6
+
7
7
+
const list = await kv.list();
8
8
+
const entries: Record<string, string> = {};
9
9
+
10
10
+
for (const key of list.keys) {
11
11
+
const value = await kv.get(key.name);
12
12
+
entries[key.name] = value ?? '';
13
13
+
}
14
14
+
15
15
+
return json(entries);
16
16
+
}
+5
wrangler.jsonc
···
42
42
{
43
43
"binding": "USER_DATA_CACHE",
44
44
"id": "d6ff203259de48538d332b0a5df258a7"
45
45
+
},
46
46
+
{
47
47
+
"binding": "CUSTOM_DOMAINS",
48
48
+
"id": "f449b3b5c8a349478405e2c04ed265f0",
49
49
+
"remote": true
45
50
}
46
51
]
47
52