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
105 // Define allowed file extensions for static site hosting
106 const allowedExtensions = new Set([
107 // HTML
108 '.html', '.htm',
109 // CSS
110 '.css',
111 // JavaScript
112 '.js', '.mjs', '.jsx', '.ts', '.tsx',
113 // Images
114 '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif',
115 // Fonts
116 '.woff', '.woff2', '.ttf', '.otf', '.eot',
117 // Documents
118 '.pdf', '.txt',
119 // JSON (for config files, but not .map files)
120 '.json',
121 // Audio/Video
122 '.mp3', '.mp4', '.webm', '.ogg', '.wav',
123 // Other web assets
124 '.xml', '.rss', '.atom'
125 ]);
126
127 // Files to explicitly exclude
128 const excludedFiles = new Set([
129 '.map', '.DS_Store', 'Thumbs.db'
130 ]);
131
132 for (let i = 0; i < fileArray.length; i++) {
133 const file = fileArray[i];
134 const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
135
136 // Skip excluded files
137 if (excludedFiles.has(fileExtension)) {
138 continue;
139 }
140
141 // Skip files that aren't in allowed extensions
142 if (!allowedExtensions.has(fileExtension)) {
143 continue;
144 }
145
146 // Skip files that are too large (limit to 100MB per file)
147 const maxSize = 100 * 1024 * 1024; // 100MB
148 if (file.size > maxSize) {
149 continue;
150 }
151
152 const arrayBuffer = await file.arrayBuffer();
153 uploadedFiles.push({
154 name: file.name,
155 content: Buffer.from(arrayBuffer),
156 mimeType: 'application/octet-stream',
157 size: file.size
158 });
159 }
160
161 // Check total size limit (300MB)
162 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0);
163 const maxTotalSize = 300 * 1024 * 1024; // 300MB
164
165 if (totalSize > maxTotalSize) {
166 throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`);
167 }
168
169 if (uploadedFiles.length === 0) {
170
171 // Create empty manifest
172 const emptyManifest = {
173 $type: 'place.wisp.fs',
174 site: siteName,
175 root: {
176 type: 'directory',
177 entries: []
178 },
179 fileCount: 0,
180 createdAt: new Date().toISOString()
181 };
182
183 // Use site name as rkey
184 const rkey = siteName;
185
186 const record = await agent.com.atproto.repo.putRecord({
187 repo: auth.did,
188 collection: 'place.wisp.fs',
189 rkey: rkey,
190 record: emptyManifest
191 });
192
193 await upsertSite(auth.did, rkey, siteName);
194
195 return {
196 success: true,
197 uri: record.data.uri,
198 cid: record.data.cid,
199 fileCount: 0,
200 siteName,
201 message: 'Site created but no valid web files were found to upload'
202 };
203 }
204
205 // Process files into directory structure
206 const { directory, fileCount } = processUploadedFiles(uploadedFiles);
207
208 // Upload files as blobs in parallel (always as octet-stream)
209 const uploadPromises = uploadedFiles.map(async (file, i) => {
210 try {
211 const uploadResult = await agent.com.atproto.repo.uploadBlob(
212 file.content,
213 {
214 encoding: 'application/octet-stream'
215 }
216 );
217
218 const sentMimeType = file.mimeType;
219 const returnedBlobRef = uploadResult.data.blob;
220
221 // Use the blob ref exactly as returned from PDS
222 return {
223 result: {
224 hash: returnedBlobRef.ref.toString(),
225 blobRef: returnedBlobRef
226 },
227 filePath: file.name,
228 sentMimeType,
229 returnedMimeType: returnedBlobRef.mimeType
230 };
231 } catch (uploadError) {
232 logger.error('[Wisp] Upload failed for file', uploadError);
233 throw uploadError;
234 }
235 });
236
237 // Wait for all uploads to complete
238 const uploadedBlobs = await Promise.all(uploadPromises);
239
240 // Extract results and file paths in correct order
241 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result);
242 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath);
243
244 // Update directory with file blobs
245 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths);
246
247 // Create manifest
248 const manifest = createManifest(siteName, updatedDirectory, fileCount);
249
250 // Use site name as rkey
251 const rkey = siteName;
252
253 let record;
254 try {
255 record = await agent.com.atproto.repo.putRecord({
256 repo: auth.did,
257 collection: 'place.wisp.fs',
258 rkey: rkey,
259 record: manifest
260 });
261 } catch (putRecordError: any) {
262 logger.error('[Wisp] Failed to create record on PDS');
263 logger.error('[Wisp] Record creation error', putRecordError);
264
265 throw putRecordError;
266 }
267
268 // Store site in database cache
269 await upsertSite(auth.did, rkey, siteName);
270
271 const result = {
272 success: true,
273 uri: record.data.uri,
274 cid: record.data.cid,
275 fileCount,
276 siteName
277 };
278
279 return result;
280 } catch (error) {
281 logger.error('[Wisp] Upload error', error);
282 logger.errorWithContext('[Wisp] Upload error details', {
283 message: error instanceof Error ? error.message : 'Unknown error',
284 name: error instanceof Error ? error.name : undefined
285 }, error);
286 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
287 }
288 }
289 )