-32
hosting-service/src/index.ts
-32
hosting-service/src/index.ts
···
1
1
import { serve } from 'bun';
2
2
import app from './server';
3
3
import { FirehoseWorker } from './lib/firehose';
4
-
import { DNSVerificationWorker } from './lib/dns-verification-worker';
5
4
import { mkdirSync, existsSync } from 'fs';
6
5
7
6
const PORT = process.env.PORT || 3001;
···
20
19
21
20
firehose.start();
22
21
23
-
// Start DNS verification worker (runs every hour)
24
-
const dnsVerifier = new DNSVerificationWorker(
25
-
60 * 60 * 1000, // 1 hour
26
-
(msg, data) => {
27
-
console.log('[DNS Verifier]', msg, data || '');
28
-
}
29
-
);
30
-
31
-
dnsVerifier.start();
32
-
33
22
// Add health check endpoint
34
23
app.get('/health', (c) => {
35
24
const firehoseHealth = firehose.getHealth();
36
-
const dnsVerifierHealth = dnsVerifier.getHealth();
37
25
return c.json({
38
26
status: 'ok',
39
27
firehose: firehoseHealth,
40
-
dnsVerifier: dnsVerifierHealth,
41
28
});
42
29
});
43
30
44
-
// Add manual DNS verification trigger (for testing/admin)
45
-
app.post('/admin/verify-dns', async (c) => {
46
-
try {
47
-
await dnsVerifier.trigger();
48
-
return c.json({
49
-
success: true,
50
-
message: 'DNS verification triggered',
51
-
});
52
-
} catch (error) {
53
-
return c.json({
54
-
success: false,
55
-
error: error instanceof Error ? error.message : String(error),
56
-
}, 500);
57
-
}
58
-
});
59
-
60
31
// Start HTTP server
61
32
const server = serve({
62
33
port: PORT,
···
70
41
Health: http://localhost:${PORT}/health
71
42
Cache: ${CACHE_DIR}
72
43
Firehose: Connected to Jetstream
73
-
DNS Verifier: Checking every hour
74
44
`);
75
45
76
46
// Graceful shutdown
77
47
process.on('SIGINT', () => {
78
48
console.log('\n🛑 Shutting down...');
79
49
firehose.stop();
80
-
dnsVerifier.stop();
81
50
server.stop();
82
51
process.exit(0);
83
52
});
···
85
54
process.on('SIGTERM', () => {
86
55
console.log('\n🛑 Shutting down...');
87
56
firehose.stop();
88
-
dnsVerifier.stop();
89
57
server.stop();
90
58
process.exit(0);
91
59
});
+2
-2
hosting-service/src/lib/dns-verification-worker.ts
src/lib/dns-verification-worker.ts
+2
-2
hosting-service/src/lib/dns-verification-worker.ts
src/lib/dns-verification-worker.ts
+3
-19
hosting-service/src/lib/utils.ts
+3
-19
hosting-service/src/lib/utils.ts
···
4
4
import { writeFile, readFile } from 'fs/promises';
5
5
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
6
6
import { CID } from 'multiformats/cid';
7
-
import { createHash } from 'crypto';
8
7
9
8
const CACHE_DIR = './cache/sites';
10
9
const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
···
16
15
rkey: string;
17
16
}
18
17
19
-
// Type guards for different blob reference formats
20
18
interface IpldLink {
21
19
$link: string;
22
20
}
···
63
61
let doc;
64
62
65
63
if (did.startsWith('did:plc:')) {
66
-
// Resolve did:plc from plc.directory
67
64
doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
68
65
} else if (did.startsWith('did:web:')) {
69
-
// Resolve did:web from the domain
70
66
const didUrl = didWebToHttps(did);
71
67
doc = await safeFetchJson(didUrl);
72
68
} else {
···
85
81
}
86
82
87
83
function didWebToHttps(did: string): string {
88
-
// did:web:example.com -> https://example.com/.well-known/did.json
89
-
// did:web:example.com:path:to:did -> https://example.com/path/to/did/did.json
90
-
91
84
const didParts = did.split(':');
92
85
if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
93
86
throw new Error('Invalid did:web format');
···
97
90
const pathParts = didParts.slice(3);
98
91
99
92
if (pathParts.length === 0) {
100
-
// No path, use .well-known
101
93
return `https://${domain}/.well-known/did.json`;
102
94
} else {
103
-
// Has path
104
95
const path = pathParts.join('/');
105
96
return `https://${domain}/${path}/did.json`;
106
97
}
···
114
105
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
115
106
const data = await safeFetchJson(url);
116
107
117
-
// Return both the record and its CID for verification
118
108
return {
119
109
record: data.value as WispFsRecord,
120
110
cid: data.cid || ''
···
126
116
}
127
117
128
118
export function extractBlobCid(blobRef: unknown): string | null {
129
-
// Check if it's a direct IPLD link
130
119
if (isIpldLink(blobRef)) {
131
120
return blobRef.$link;
132
121
}
133
122
134
-
// Check if it's a typed blob ref with a ref property
135
123
if (isTypedBlobRef(blobRef)) {
136
124
const ref = blobRef.ref;
137
125
138
-
// Check if ref is a CID object
139
-
if (CID.isCID(ref)) {
140
-
return ref.toString();
126
+
const cid = CID.asCID(ref);
127
+
if (cid) {
128
+
return cid.toString();
141
129
}
142
130
143
-
// Check if ref is an IPLD link object
144
131
if (isIpldLink(ref)) {
145
132
return ref.$link;
146
133
}
147
134
}
148
135
149
-
// Check if it's an untyped blob ref with a cid string
150
136
if (isUntypedBlobRef(blobRef)) {
151
137
return blobRef.cid;
152
138
}
···
157
143
export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
158
144
console.log('Caching site', did, rkey);
159
145
160
-
// Validate record structure
161
146
if (!record.root) {
162
147
console.error('Record missing root directory:', JSON.stringify(record, null, 2));
163
148
throw new Error('Invalid record structure: missing root directory');
···
170
155
171
156
await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '');
172
157
173
-
// Save cache metadata with CID for verification
174
158
await saveCacheMetadata(did, rkey, recordCid);
175
159
}
176
160
+49
-6
public/editor/editor.tsx
+49
-6
public/editor/editor.tsx
···
94
94
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
95
95
const [isUploading, setIsUploading] = useState(false)
96
96
const [uploadProgress, setUploadProgress] = useState('')
97
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
98
+
const [uploadedCount, setUploadedCount] = useState(0)
97
99
98
100
// Custom domain modal state
99
101
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
···
232
234
const data = await response.json()
233
235
if (data.success) {
234
236
setUploadProgress('Upload complete!')
237
+
setSkippedFiles(data.skippedFiles || [])
238
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
235
239
setSiteName('')
236
240
setSelectedFiles(null)
237
241
238
242
// Refresh sites list
239
243
await fetchSites()
240
244
241
-
// Reset form
245
+
// Reset form - give more time if there are skipped files
246
+
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
242
247
setTimeout(() => {
243
248
setUploadProgress('')
249
+
setSkippedFiles([])
250
+
setUploadedCount(0)
244
251
setIsUploading(false)
245
-
}, 1500)
252
+
}, resetDelay)
246
253
} else {
247
254
throw new Error(data.error || 'Upload failed')
248
255
}
···
714
721
onChange={(e) => setSiteName(e.target.value)}
715
722
disabled={isUploading}
716
723
/>
724
+
<p className="text-xs text-muted-foreground">
725
+
File limits: 100MB per file, 300MB total
726
+
</p>
717
727
</div>
718
728
719
729
<div className="grid md:grid-cols-2 gap-4">
···
774
784
</div>
775
785
776
786
{uploadProgress && (
777
-
<div className="p-4 bg-muted rounded-lg">
778
-
<div className="flex items-center gap-2">
779
-
<Loader2 className="w-4 h-4 animate-spin" />
780
-
<span className="text-sm">{uploadProgress}</span>
787
+
<div className="space-y-3">
788
+
<div className="p-4 bg-muted rounded-lg">
789
+
<div className="flex items-center gap-2">
790
+
<Loader2 className="w-4 h-4 animate-spin" />
791
+
<span className="text-sm">{uploadProgress}</span>
792
+
</div>
781
793
</div>
794
+
795
+
{skippedFiles.length > 0 && (
796
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
797
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
798
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
799
+
<div className="flex-1">
800
+
<span className="font-medium">
801
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
802
+
</span>
803
+
{uploadedCount > 0 && (
804
+
<span className="text-sm ml-2">
805
+
({uploadedCount} uploaded successfully)
806
+
</span>
807
+
)}
808
+
</div>
809
+
</div>
810
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
811
+
{skippedFiles.slice(0, 5).map((file, idx) => (
812
+
<div key={idx} className="text-xs">
813
+
<span className="font-mono">{file.name}</span>
814
+
<span className="text-muted-foreground"> - {file.reason}</span>
815
+
</div>
816
+
))}
817
+
{skippedFiles.length > 5 && (
818
+
<div className="text-xs text-muted-foreground">
819
+
...and {skippedFiles.length - 5} more
820
+
</div>
821
+
)}
822
+
</div>
823
+
</div>
824
+
)}
782
825
</div>
783
826
)}
784
827
+58
-11
public/onboarding/onboarding.tsx
+58
-11
public/onboarding/onboarding.tsx
···
10
10
} from '@public/components/ui/card'
11
11
import { Input } from '@public/components/ui/input'
12
12
import { Label } from '@public/components/ui/label'
13
-
import { Globe, Upload, CheckCircle2, Loader2 } from 'lucide-react'
13
+
import { Globe, Upload, CheckCircle2, Loader2, AlertCircle } from 'lucide-react'
14
14
import Layout from '@public/layouts'
15
15
16
16
type OnboardingStep = 'domain' | 'upload' | 'complete'
···
28
28
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
29
29
const [isUploading, setIsUploading] = useState(false)
30
30
const [uploadProgress, setUploadProgress] = useState('')
31
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
32
+
const [uploadedCount, setUploadedCount] = useState(0)
31
33
32
34
// Check domain availability as user types
33
35
useEffect(() => {
···
117
119
const data = await response.json()
118
120
if (data.success) {
119
121
setUploadProgress('Upload complete!')
120
-
// Redirect to the claimed domain
121
-
setTimeout(() => {
122
-
window.location.href = `https://${claimedDomain}`
123
-
}, 1500)
122
+
setSkippedFiles(data.skippedFiles || [])
123
+
setUploadedCount(data.uploadedCount || data.fileCount || 0)
124
+
125
+
// If there are skipped files, show them briefly before redirecting
126
+
if (data.skippedFiles && data.skippedFiles.length > 0) {
127
+
setTimeout(() => {
128
+
window.location.href = `https://${claimedDomain}`
129
+
}, 3000) // Give more time to see skipped files
130
+
} else {
131
+
setTimeout(() => {
132
+
window.location.href = `https://${claimedDomain}`
133
+
}, 1500)
134
+
}
124
135
} else {
125
136
throw new Error(data.error || 'Upload failed')
126
137
}
···
355
366
<p className="text-xs text-muted-foreground">
356
367
Supported: HTML, CSS, JS, images, fonts, and more
357
368
</p>
369
+
<p className="text-xs text-muted-foreground">
370
+
Limits: 100MB per file, 300MB total
371
+
</p>
358
372
</div>
359
373
360
374
{uploadProgress && (
361
-
<div className="p-4 bg-muted rounded-lg">
362
-
<div className="flex items-center gap-2">
363
-
<Loader2 className="w-4 h-4 animate-spin" />
364
-
<span className="text-sm">
365
-
{uploadProgress}
366
-
</span>
375
+
<div className="space-y-3">
376
+
<div className="p-4 bg-muted rounded-lg">
377
+
<div className="flex items-center gap-2">
378
+
<Loader2 className="w-4 h-4 animate-spin" />
379
+
<span className="text-sm">
380
+
{uploadProgress}
381
+
</span>
382
+
</div>
367
383
</div>
384
+
385
+
{skippedFiles.length > 0 && (
386
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
387
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
388
+
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
389
+
<div className="flex-1">
390
+
<span className="font-medium">
391
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
392
+
</span>
393
+
{uploadedCount > 0 && (
394
+
<span className="text-sm ml-2">
395
+
({uploadedCount} uploaded successfully)
396
+
</span>
397
+
)}
398
+
</div>
399
+
</div>
400
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
401
+
{skippedFiles.slice(0, 5).map((file, idx) => (
402
+
<div key={idx} className="text-xs">
403
+
<span className="font-mono">{file.name}</span>
404
+
<span className="text-muted-foreground"> - {file.reason}</span>
405
+
</div>
406
+
))}
407
+
{skippedFiles.length > 5 && (
408
+
<div className="text-xs text-muted-foreground">
409
+
...and {skippedFiles.length - 5} more
410
+
</div>
411
+
)}
412
+
</div>
413
+
</div>
414
+
)}
368
415
</div>
369
416
)}
370
417
+37
-7
src/index.ts
+37
-7
src/index.ts
···
17
17
import { domainRoutes } from './routes/domain'
18
18
import { userRoutes } from './routes/user'
19
19
import { csrfProtection } from './lib/csrf'
20
+
import { DNSVerificationWorker } from './lib/dns-verification-worker'
21
+
import { logger } from './lib/logger'
20
22
21
23
const config: Config = {
22
24
domain: (Bun.env.DOMAIN ?? `https://${BASE_HOST}`) as `https://${string}`,
···
39
41
// Schedule maintenance to run every hour
40
42
setInterval(runMaintenance, 60 * 60 * 1000)
41
43
44
+
// Start DNS verification worker (runs every hour)
45
+
const dnsVerifier = new DNSVerificationWorker(
46
+
60 * 60 * 1000, // 1 hour
47
+
(msg, data) => {
48
+
logger.info('[DNS Verifier]', msg, data || '')
49
+
}
50
+
)
51
+
52
+
dnsVerifier.start()
53
+
logger.info('[DNS Verifier] Started - checking custom domains every hour')
54
+
42
55
export const app = new Elysia()
43
56
// Security headers middleware
44
57
.onAfterHandle(({ set }) => {
···
66
79
set.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
67
80
})
68
81
.use(
69
-
openapi({
70
-
references: fromTypes()
71
-
})
72
-
)
73
-
.use(
74
82
await staticPlugin({
75
83
prefix: '/'
76
84
})
···
83
91
.get('/client-metadata.json', (c) => {
84
92
return createClientMetadata(config)
85
93
})
86
-
.get('/jwks.json', (c) => {
87
-
const keys = getCurrentKeys()
94
+
.get('/jwks.json', async (c) => {
95
+
const keys = await getCurrentKeys()
88
96
if (!keys.length) return { keys: [] }
89
97
90
98
return {
···
93
101
const { ...pub } = jwk
94
102
return pub
95
103
})
104
+
}
105
+
})
106
+
.get('/api/health', () => {
107
+
const dnsVerifierHealth = dnsVerifier.getHealth()
108
+
return {
109
+
status: 'ok',
110
+
timestamp: new Date().toISOString(),
111
+
dnsVerifier: dnsVerifierHealth
112
+
}
113
+
})
114
+
.post('/api/admin/verify-dns', async () => {
115
+
try {
116
+
await dnsVerifier.trigger()
117
+
return {
118
+
success: true,
119
+
message: 'DNS verification triggered'
120
+
}
121
+
} catch (error) {
122
+
return {
123
+
success: false,
124
+
error: error instanceof Error ? error.message : String(error)
125
+
}
96
126
}
97
127
})
98
128
.use(cors({
+6
-10
src/lib/db.ts
+6
-10
src/lib/db.ts
···
387
387
return keys;
388
388
};
389
389
390
-
let currentKeys: JoseKey[] = [];
391
-
392
-
export const getCurrentKeys = () => currentKeys;
390
+
// Load keys from database every time (stateless - safe for horizontal scaling)
391
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
392
+
return await loadPersistedKeys();
393
+
};
393
394
394
395
// Key rotation - rotate keys older than 30 days (monthly rotation)
395
396
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
423
424
424
425
console.log(`[KeyRotation] Rotated key ${oldKid}`);
425
426
426
-
// Reload keys into memory
427
-
currentKeys = await ensureKeys();
428
-
429
427
return true;
430
428
} catch (err) {
431
429
console.error('[KeyRotation] Failed to rotate keys:', err);
···
434
432
};
435
433
436
434
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
437
-
if (currentKeys.length === 0) {
438
-
currentKeys = await ensureKeys();
439
-
}
435
+
const keys = await ensureKeys();
440
436
441
437
return new NodeOAuthClient({
442
438
clientMetadata: createClientMetadata(config),
443
-
keyset: currentKeys,
439
+
keyset: keys,
444
440
stateStore,
445
441
sessionStore
446
442
});
+6
-10
src/lib/oauth-client.ts
+6
-10
src/lib/oauth-client.ts
···
168
168
return keys;
169
169
};
170
170
171
-
let currentKeys: JoseKey[] = [];
172
-
173
-
export const getCurrentKeys = () => currentKeys;
171
+
// Load keys from database every time (stateless - safe for horizontal scaling)
172
+
export const getCurrentKeys = async (): Promise<JoseKey[]> => {
173
+
return await loadPersistedKeys();
174
+
};
174
175
175
176
// Key rotation - rotate keys older than 30 days (monthly rotation)
176
177
const KEY_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
···
204
205
205
206
logger.info(`[KeyRotation] Rotated key ${oldKid}`);
206
207
207
-
// Reload keys into memory
208
-
currentKeys = await ensureKeys();
209
-
210
208
return true;
211
209
} catch (err) {
212
210
logger.error('[KeyRotation] Failed to rotate keys', err);
···
215
213
};
216
214
217
215
export const getOAuthClient = async (config: { domain: `https://${string}`, clientName: string }) => {
218
-
if (currentKeys.length === 0) {
219
-
currentKeys = await ensureKeys();
220
-
}
216
+
const keys = await ensureKeys();
221
217
222
218
return new NodeOAuthClient({
223
219
clientMetadata: createClientMetadata(config),
224
-
keyset: currentKeys,
220
+
keyset: keys,
225
221
stateStore,
226
222
sessionStore
227
223
});
+11
-1
src/routes/wisp.ts
+11
-1
src/routes/wisp.ts
···
101
101
// Elysia gives us File objects directly, handle both single file and array
102
102
const fileArray = Array.isArray(files) ? files : [files];
103
103
const uploadedFiles: UploadedFile[] = [];
104
+
const skippedFiles: Array<{ name: string; reason: string }> = [];
104
105
105
106
// Define allowed file extensions for static site hosting
106
107
const allowedExtensions = new Set([
···
135
136
136
137
// Skip excluded files
137
138
if (excludedFiles.has(fileExtension)) {
139
+
skippedFiles.push({ name: file.name, reason: 'excluded file type' });
138
140
continue;
139
141
}
140
142
141
143
// Skip files that aren't in allowed extensions
142
144
if (!allowedExtensions.has(fileExtension)) {
145
+
skippedFiles.push({ name: file.name, reason: 'unsupported file type' });
143
146
continue;
144
147
}
145
148
146
149
// Skip files that are too large (limit to 100MB per file)
147
150
const maxSize = 100 * 1024 * 1024; // 100MB
148
151
if (file.size > maxSize) {
152
+
skippedFiles.push({
153
+
name: file.name,
154
+
reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)`
155
+
});
149
156
continue;
150
157
}
151
158
···
198
205
cid: record.data.cid,
199
206
fileCount: 0,
200
207
siteName,
208
+
skippedFiles,
201
209
message: 'Site created but no valid web files were found to upload'
202
210
};
203
211
}
···
273
281
uri: record.data.uri,
274
282
cid: record.data.cid,
275
283
fileCount,
276
-
siteName
284
+
siteName,
285
+
skippedFiles,
286
+
uploadedCount: uploadedFiles.length
277
287
};
278
288
279
289
return result;