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'
13import { logger } from '../lib/logger'
14
15function isValidSiteName(siteName: string): boolean {
16 if (!siteName || typeof siteName !== 'string') return false;
17
18 // Length check (AT Protocol rkey limit)
19 if (siteName.length < 1 || siteName.length > 512) return false;
20
21 // Check for path traversal
22 if (siteName === '.' || siteName === '..') return false;
23 if (siteName.includes('/') || siteName.includes('\\')) return false;
24 if (siteName.includes('\0')) return false;
25
26 // AT Protocol rkey format: alphanumeric, dots, dashes, underscores, tildes, colons
27 // Based on NSID format rules
28 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
29 if (!validRkeyPattern.test(siteName)) return false;
30
31 return true;
32}
33
34export const wispRoutes = (client: NodeOAuthClient) =>
35 new Elysia({ prefix: '/wisp' })
36 .derive(async ({ cookie }) => {
37 const auth = await requireAuth(client, cookie)
38 return { auth }
39 })
40 .post(
41 '/upload-files',
42 async ({ body, auth }) => {
43 const { siteName, files } = body as {
44 siteName: string;
45 files: File | File[]
46 };
47
48 try {
49 if (!siteName) {
50 throw new Error('Site name is required')
51 }
52
53 if (!isValidSiteName(siteName)) {
54 throw new Error('Invalid site name: must be 1-512 characters and contain only alphanumeric, dots, dashes, underscores, tildes, and colons')
55 }
56
57 // Check if files were provided
58 const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files);
59
60 if (!hasFiles) {
61 // Create agent with OAuth session
62 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
63
64 // Create empty manifest
65 const emptyManifest = {
66 $type: 'place.wisp.fs',
67 site: siteName,
68 root: {
69 type: 'directory',
70 entries: []
71 },
72 fileCount: 0,
73 createdAt: new Date().toISOString()
74 };
75
76 // Use site name as rkey
77 const rkey = siteName;
78
79 const record = await agent.com.atproto.repo.putRecord({
80 repo: auth.did,
81 collection: 'place.wisp.fs',
82 rkey: rkey,
83 record: emptyManifest
84 });
85
86 await upsertSite(auth.did, rkey, siteName);
87
88 return {
89 success: true,
90 uri: record.data.uri,
91 cid: record.data.cid,
92 fileCount: 0,
93 siteName
94 };
95 }
96
97 // Create agent with OAuth session
98 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
99
100 // Convert File objects to UploadedFile format
101 // Elysia gives us File objects directly, handle both single file and array
102 const fileArray = Array.isArray(files) ? files : [files];
103 const uploadedFiles: UploadedFile[] = [];
104 const skippedFiles: Array<{ name: string; reason: string }> = [];
105
106 // Define allowed file extensions for static site hosting
107 const allowedExtensions = new Set([
108 // HTML
109 '.html', '.htm',
110 // CSS
111 '.css',
112 // JavaScript
113 '.js', '.mjs', '.jsx', '.ts', '.tsx',
114 // Images
115 '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif',
116 // Fonts
117 '.woff', '.woff2', '.ttf', '.otf', '.eot',
118 // Documents
119 '.pdf', '.txt',
120 // JSON (for config files, but not .map files)
121 '.json',
122 // Audio/Video
123 '.mp3', '.mp4', '.webm', '.ogg', '.wav',
124 // Other web assets
125 '.xml', '.rss', '.atom'
126 ]);
127
128 // Files to explicitly exclude
129 const excludedFiles = new Set([
130 '.map', '.DS_Store', 'Thumbs.db'
131 ]);
132
133 for (let i = 0; i < fileArray.length; i++) {
134 const file = fileArray[i];
135 const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
136
137 // Skip excluded files
138 if (excludedFiles.has(fileExtension)) {
139 skippedFiles.push({ name: file.name, reason: 'excluded file type' });
140 continue;
141 }
142
143 // Skip files that aren't in allowed extensions
144 if (!allowedExtensions.has(fileExtension)) {
145 skippedFiles.push({ name: file.name, reason: 'unsupported file type' });
146 continue;
147 }
148
149 // Skip files that are too large (limit to 100MB per file)
150 const maxSize = 100 * 1024 * 1024; // 100MB
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 });
156 continue;
157 }
158
159 const arrayBuffer = await file.arrayBuffer();
160 uploadedFiles.push({
161 name: file.name,
162 content: Buffer.from(arrayBuffer),
163 mimeType: 'application/octet-stream',
164 size: file.size
165 });
166 }
167
168 // Check total size limit (300MB)
169 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0);
170 const maxTotalSize = 300 * 1024 * 1024; // 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
178 // Create empty manifest
179 const emptyManifest = {
180 $type: 'place.wisp.fs',
181 site: siteName,
182 root: {
183 type: 'directory',
184 entries: []
185 },
186 fileCount: 0,
187 createdAt: new Date().toISOString()
188 };
189
190 // Use site name as rkey
191 const rkey = siteName;
192
193 const record = await agent.com.atproto.repo.putRecord({
194 repo: auth.did,
195 collection: 'place.wisp.fs',
196 rkey: rkey,
197 record: emptyManifest
198 });
199
200 await upsertSite(auth.did, rkey, siteName);
201
202 return {
203 success: true,
204 uri: record.data.uri,
205 cid: record.data.cid,
206 fileCount: 0,
207 siteName,
208 skippedFiles,
209 message: 'Site created but no valid web files were found to upload'
210 };
211 }
212
213 // Process files into directory structure
214 const { directory, fileCount } = processUploadedFiles(uploadedFiles);
215
216 // Upload files as blobs in parallel (always as octet-stream)
217 const uploadPromises = uploadedFiles.map(async (file, i) => {
218 try {
219 const uploadResult = await agent.com.atproto.repo.uploadBlob(
220 file.content,
221 {
222 encoding: 'application/octet-stream'
223 }
224 );
225
226 const sentMimeType = file.mimeType;
227 const returnedBlobRef = uploadResult.data.blob;
228
229 // Use the blob ref exactly as returned from PDS
230 return {
231 result: {
232 hash: returnedBlobRef.ref.toString(),
233 blobRef: returnedBlobRef
234 },
235 filePath: file.name,
236 sentMimeType,
237 returnedMimeType: returnedBlobRef.mimeType
238 };
239 } catch (uploadError) {
240 logger.error('[Wisp] Upload failed for file', uploadError);
241 throw uploadError;
242 }
243 });
244
245 // Wait for all uploads to complete
246 const uploadedBlobs = await Promise.all(uploadPromises);
247
248 // Extract results and file paths in correct order
249 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result);
250 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath);
251
252 // Update directory with file blobs
253 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths);
254
255 // Create manifest
256 const manifest = createManifest(siteName, updatedDirectory, fileCount);
257
258 // Use site name as rkey
259 const rkey = siteName;
260
261 let record;
262 try {
263 record = await agent.com.atproto.repo.putRecord({
264 repo: auth.did,
265 collection: 'place.wisp.fs',
266 rkey: rkey,
267 record: manifest
268 });
269 } catch (putRecordError: any) {
270 logger.error('[Wisp] Failed to create record on PDS');
271 logger.error('[Wisp] Record creation error', putRecordError);
272
273 throw putRecordError;
274 }
275
276 // Store site in database cache
277 await upsertSite(auth.did, rkey, siteName);
278
279 const result = {
280 success: true,
281 uri: record.data.uri,
282 cid: record.data.cid,
283 fileCount,
284 siteName,
285 skippedFiles,
286 uploadedCount: uploadedFiles.length
287 };
288
289 return result;
290 } catch (error) {
291 logger.error('[Wisp] Upload error', error);
292 logger.errorWithContext('[Wisp] Upload error details', {
293 message: error instanceof Error ? error.message : 'Unknown error',
294 name: error instanceof Error ? error.name : undefined
295 }, error);
296 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
297 }
298 }
299 )