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 type UploadedFile,
7 type FileUploadResult,
8 processUploadedFiles,
9 createManifest,
10 updateFileBlobs
11} from '../lib/wisp-utils'
12import { upsertSite } from '../lib/db'
13
14export const wispRoutes = (client: NodeOAuthClient) =>
15 new Elysia({ prefix: '/wisp' })
16 .derive(async ({ cookie }) => {
17 const auth = await requireAuth(client, cookie)
18 return { auth }
19 })
20 .post(
21 '/upload-files',
22 async ({ body, auth }) => {
23 const { siteName, files } = body as {
24 siteName: string;
25 files: File | File[]
26 };
27
28 console.log('🚀 Starting upload process', { siteName, fileCount: Array.isArray(files) ? files.length : 1 });
29
30 try {
31 if (!siteName) {
32 console.error('❌ Site name is required');
33 throw new Error('Site name is required')
34 }
35
36 console.log('✅ Initial validation passed');
37
38 // Check if files were provided
39 const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files);
40
41 if (!hasFiles) {
42 console.log('📝 Creating empty site (no files provided)');
43
44 // Create agent with OAuth session
45 console.log('🔐 Creating agent with OAuth session');
46 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
47 console.log('✅ Agent created successfully');
48
49 // Create empty manifest
50 const emptyManifest = {
51 $type: 'place.wisp.fs',
52 site: siteName,
53 root: {
54 type: 'directory',
55 entries: []
56 },
57 fileCount: 0,
58 createdAt: new Date().toISOString()
59 };
60
61 // Use site name as rkey
62 const rkey = siteName;
63
64 // Create the record with explicit rkey
65 console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`);
66 const record = await agent.com.atproto.repo.putRecord({
67 repo: auth.did,
68 collection: 'place.wisp.fs',
69 rkey: rkey,
70 record: emptyManifest
71 });
72
73 console.log('✅ Empty site record created successfully:', {
74 uri: record.data.uri,
75 cid: record.data.cid
76 });
77
78 // Store site in database cache
79 console.log('💾 Storing site in database cache');
80 await upsertSite(auth.did, rkey, siteName);
81 console.log('✅ Site stored in database');
82
83 return {
84 success: true,
85 uri: record.data.uri,
86 cid: record.data.cid,
87 fileCount: 0,
88 siteName
89 };
90 }
91
92 // Create agent with OAuth session
93 console.log('🔐 Creating agent with OAuth session');
94 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
95 console.log('✅ Agent created successfully');
96
97 // Convert File objects to UploadedFile format
98 // Elysia gives us File objects directly, handle both single file and array
99 const fileArray = Array.isArray(files) ? files : [files];
100 console.log(`📁 Processing ${fileArray.length} files`);
101 const uploadedFiles: UploadedFile[] = [];
102
103 // Define allowed file extensions for static site hosting
104 const allowedExtensions = new Set([
105 // HTML
106 '.html', '.htm',
107 // CSS
108 '.css',
109 // JavaScript
110 '.js', '.mjs', '.jsx', '.ts', '.tsx',
111 // Images
112 '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif',
113 // Fonts
114 '.woff', '.woff2', '.ttf', '.otf', '.eot',
115 // Documents
116 '.pdf', '.txt',
117 // JSON (for config files, but not .map files)
118 '.json',
119 // Audio/Video
120 '.mp3', '.mp4', '.webm', '.ogg', '.wav',
121 // Other web assets
122 '.xml', '.rss', '.atom'
123 ]);
124
125 // Files to explicitly exclude
126 const excludedFiles = new Set([
127 '.map', '.DS_Store', 'Thumbs.db'
128 ]);
129
130 for (let i = 0; i < fileArray.length; i++) {
131 const file = fileArray[i];
132 const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
133
134 console.log(`📄 Processing file ${i + 1}/${fileArray.length}: ${file.name} (${file.size} bytes, ${file.type})`);
135
136 // Skip excluded files
137 if (excludedFiles.has(fileExtension)) {
138 console.log(`⏭️ Skipping excluded file: ${file.name}`);
139 continue;
140 }
141
142 // Skip files that aren't in allowed extensions
143 if (!allowedExtensions.has(fileExtension)) {
144 console.log(`⏭️ Skipping non-web file: ${file.name} (${fileExtension})`);
145 continue;
146 }
147
148 // Skip files that are too large (limit to 100MB per file)
149 const maxSize = 100 * 1024 * 1024; // 100MB
150 if (file.size > maxSize) {
151 console.log(`⏭️ Skipping large file: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB > 100MB limit)`);
152 continue;
153 }
154
155 console.log(`✅ Including file: ${file.name}`);
156 const arrayBuffer = await file.arrayBuffer();
157 uploadedFiles.push({
158 name: file.name,
159 content: Buffer.from(arrayBuffer),
160 mimeType: file.type || 'application/octet-stream',
161 size: file.size
162 });
163 }
164
165 // Check total size limit (300MB)
166 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0);
167 const maxTotalSize = 300 * 1024 * 1024; // 300MB
168
169 console.log(`📊 Filtered to ${uploadedFiles.length} files from ${fileArray.length} total files`);
170 console.log(`📦 Total size: ${(totalSize / 1024 / 1024).toFixed(2)}MB (limit: 300MB)`);
171
172 if (totalSize > maxTotalSize) {
173 throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`);
174 }
175
176 if (uploadedFiles.length === 0) {
177 console.log('⚠️ No valid web files found, creating empty site instead');
178
179 // Create empty manifest
180 const emptyManifest = {
181 $type: 'place.wisp.fs',
182 site: siteName,
183 root: {
184 type: 'directory',
185 entries: []
186 },
187 fileCount: 0,
188 createdAt: new Date().toISOString()
189 };
190
191 // Use site name as rkey
192 const rkey = siteName;
193
194 // Create the record with explicit rkey
195 console.log(`📝 Creating empty site record in repo with rkey: ${rkey}`);
196 const record = await agent.com.atproto.repo.putRecord({
197 repo: auth.did,
198 collection: 'place.wisp.fs',
199 rkey: rkey,
200 record: emptyManifest
201 });
202
203 console.log('✅ Empty site record created successfully:', {
204 uri: record.data.uri,
205 cid: record.data.cid
206 });
207
208 // Store site in database cache
209 console.log('💾 Storing site in database cache');
210 await upsertSite(auth.did, rkey, siteName);
211 console.log('✅ Site stored in database');
212
213 return {
214 success: true,
215 uri: record.data.uri,
216 cid: record.data.cid,
217 fileCount: 0,
218 siteName,
219 message: 'Site created but no valid web files were found to upload'
220 };
221 }
222
223 console.log('✅ File conversion completed');
224
225 // Process files into directory structure
226 console.log('🏗️ Building directory structure');
227 const { directory, fileCount } = processUploadedFiles(uploadedFiles);
228 console.log(`✅ Directory structure created with ${fileCount} files`);
229
230 // Upload files as blobs
231 const uploadResults: FileUploadResult[] = [];
232 const filePaths: string[] = [];
233
234 console.log('⬆️ Starting blob upload process');
235 for (let i = 0; i < uploadedFiles.length; i++) {
236 const file = uploadedFiles[i];
237 console.log(`📤 Uploading blob ${i + 1}/${uploadedFiles.length}: ${file.name}`);
238
239 try {
240 console.log(`🔍 Upload details:`, {
241 fileName: file.name,
242 fileSize: file.size,
243 mimeType: file.mimeType,
244 contentLength: file.content.length
245 });
246
247 const uploadResult = await agent.com.atproto.repo.uploadBlob(
248 file.content,
249 {
250 encoding: file.mimeType
251 }
252 );
253
254 console.log(`✅ Upload successful for ${file.name}:`, {
255 hash: uploadResult.data.blob.ref.toString(),
256 mimeType: uploadResult.data.blob.mimeType,
257 size: uploadResult.data.blob.size
258 });
259
260 uploadResults.push({
261 hash: uploadResult.data.blob.ref.toString(),
262 blobRef: uploadResult.data.blob
263 });
264
265 filePaths.push(file.name);
266 } catch (uploadError) {
267 console.error(`❌ Upload failed for file ${file.name}:`, uploadError);
268 console.error('Upload error details:', {
269 fileName: file.name,
270 fileSize: file.size,
271 mimeType: file.mimeType,
272 error: uploadError
273 });
274 throw uploadError;
275 }
276 }
277
278 console.log('✅ All blobs uploaded successfully');
279
280 // Update directory with file blobs
281 console.log('🔄 Updating file blobs in directory structure');
282 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths);
283 console.log('✅ File blobs updated');
284
285 // Create manifest
286 console.log('📋 Creating manifest');
287 const manifest = createManifest(siteName, updatedDirectory, fileCount);
288 console.log('✅ Manifest created');
289
290 // Use site name as rkey
291 const rkey = siteName;
292
293 // Create the record with explicit rkey
294 console.log(`📝 Creating record in repo with rkey: ${rkey}`);
295 const record = await agent.com.atproto.repo.putRecord({
296 repo: auth.did,
297 collection: 'place.wisp.fs',
298 rkey: rkey,
299 record: manifest
300 });
301
302 console.log('✅ Record created successfully:', {
303 uri: record.data.uri,
304 cid: record.data.cid
305 });
306
307 // Store site in database cache
308 console.log('💾 Storing site in database cache');
309 await upsertSite(auth.did, rkey, siteName);
310 console.log('✅ Site stored in database');
311
312 const result = {
313 success: true,
314 uri: record.data.uri,
315 cid: record.data.cid,
316 fileCount,
317 siteName
318 };
319
320 console.log('🎉 Upload process completed successfully');
321 return result;
322 } catch (error) {
323 console.error('❌ Upload error:', error);
324 console.error('Error details:', {
325 message: error instanceof Error ? error.message : 'Unknown error',
326 stack: error instanceof Error ? error.stack : undefined,
327 name: error instanceof Error ? error.name : undefined
328 });
329 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
330 }
331 }
332 )