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 shouldCompressFile,
12 compressFile
13} from '../lib/wisp-utils'
14import { upsertSite } from '../lib/db'
15import { logger } from '../lib/observability'
16import { validateRecord } from '../lexicons/types/place/wisp/fs'
17import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants'
18
19function isValidSiteName(siteName: string): boolean {
20 if (!siteName || typeof siteName !== 'string') return false;
21
22 // Length check (AT Protocol rkey limit)
23 if (siteName.length < 1 || siteName.length > 512) return false;
24
25 // Check for path traversal
26 if (siteName === '.' || siteName === '..') return false;
27 if (siteName.includes('/') || siteName.includes('\\')) return false;
28 if (siteName.includes('\0')) return false;
29
30 // AT Protocol rkey format: alphanumeric, dots, dashes, underscores, tildes, colons
31 // Based on NSID format rules
32 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
33 if (!validRkeyPattern.test(siteName)) return false;
34
35 return true;
36}
37
38export const wispRoutes = (client: NodeOAuthClient) =>
39 new Elysia({ prefix: '/wisp' })
40 .derive(async ({ cookie }) => {
41 const auth = await requireAuth(client, cookie)
42 return { auth }
43 })
44 .post(
45 '/upload-files',
46 async ({ body, auth }) => {
47 const { siteName, files } = body as {
48 siteName: string;
49 files: File | File[]
50 };
51
52 try {
53 if (!siteName) {
54 throw new Error('Site name is required')
55 }
56
57 if (!isValidSiteName(siteName)) {
58 throw new Error('Invalid site name: must be 1-512 characters and contain only alphanumeric, dots, dashes, underscores, tildes, and colons')
59 }
60
61 // Check if files were provided
62 const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files);
63
64 if (!hasFiles) {
65 // Create agent with OAuth session
66 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
67
68 // Create empty manifest
69 const emptyManifest = {
70 $type: 'place.wisp.fs',
71 site: siteName,
72 root: {
73 type: 'directory',
74 entries: []
75 },
76 fileCount: 0,
77 createdAt: new Date().toISOString()
78 };
79
80 // Validate the manifest
81 const validationResult = validateRecord(emptyManifest);
82 if (!validationResult.success) {
83 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
84 }
85
86 // Use site name as rkey
87 const rkey = siteName;
88
89 const record = await agent.com.atproto.repo.putRecord({
90 repo: auth.did,
91 collection: 'place.wisp.fs',
92 rkey: rkey,
93 record: emptyManifest
94 });
95
96 await upsertSite(auth.did, rkey, siteName);
97
98 return {
99 success: true,
100 uri: record.data.uri,
101 cid: record.data.cid,
102 fileCount: 0,
103 siteName
104 };
105 }
106
107 // Create agent with OAuth session
108 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
109
110 // Convert File objects to UploadedFile format
111 // Elysia gives us File objects directly, handle both single file and array
112 const fileArray = Array.isArray(files) ? files : [files];
113 const uploadedFiles: UploadedFile[] = [];
114 const skippedFiles: Array<{ name: string; reason: string }> = [];
115
116
117
118 for (let i = 0; i < fileArray.length; i++) {
119 const file = fileArray[i];
120
121 // Skip files that are too large (limit to 100MB per file)
122 const maxSize = MAX_FILE_SIZE; // 100MB
123 if (file.size > maxSize) {
124 skippedFiles.push({
125 name: file.name,
126 reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)`
127 });
128 continue;
129 }
130
131 const arrayBuffer = await file.arrayBuffer();
132 const originalContent = Buffer.from(arrayBuffer);
133 const originalMimeType = file.type || 'application/octet-stream';
134
135 // Compress and base64 encode ALL files
136 const compressedContent = compressFile(originalContent);
137 // Base64 encode the gzipped content to prevent PDS content sniffing
138 const base64Content = Buffer.from(compressedContent.toString('base64'), 'utf-8');
139 const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1);
140 logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`);
141
142 uploadedFiles.push({
143 name: file.name,
144 content: base64Content,
145 mimeType: originalMimeType,
146 size: base64Content.length,
147 compressed: true,
148 originalMimeType
149 });
150 }
151
152 // Check total size limit (300MB)
153 const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0);
154 const maxTotalSize = MAX_SITE_SIZE; // 300MB
155
156 if (totalSize > maxTotalSize) {
157 throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`);
158 }
159
160 // Check file count limit (2000 files)
161 if (uploadedFiles.length > MAX_FILE_COUNT) {
162 throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`);
163 }
164
165 if (uploadedFiles.length === 0) {
166
167 // Create empty manifest
168 const emptyManifest = {
169 $type: 'place.wisp.fs',
170 site: siteName,
171 root: {
172 type: 'directory',
173 entries: []
174 },
175 fileCount: 0,
176 createdAt: new Date().toISOString()
177 };
178
179 // Validate the manifest
180 const validationResult = validateRecord(emptyManifest);
181 if (!validationResult.success) {
182 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`);
183 }
184
185 // Use site name as rkey
186 const rkey = siteName;
187
188 const record = await agent.com.atproto.repo.putRecord({
189 repo: auth.did,
190 collection: 'place.wisp.fs',
191 rkey: rkey,
192 record: emptyManifest
193 });
194
195 await upsertSite(auth.did, rkey, siteName);
196
197 return {
198 success: true,
199 uri: record.data.uri,
200 cid: record.data.cid,
201 fileCount: 0,
202 siteName,
203 skippedFiles,
204 message: 'Site created but no valid web files were found to upload'
205 };
206 }
207
208 // Process files into directory structure
209 const { directory, fileCount } = processUploadedFiles(uploadedFiles);
210
211 // Upload files as blobs in parallel
212 // For compressed files, we upload as octet-stream and store the original MIME type in metadata
213 // For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues
214 const uploadPromises = uploadedFiles.map(async (file, i) => {
215 try {
216 // If compressed, always upload as octet-stream
217 // Otherwise, workaround: PDS incorrectly processes text/html through image pipeline
218 const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html')
219 ? 'application/octet-stream'
220 : file.mimeType;
221
222 const compressionInfo = file.compressed ? ' (gzipped)' : '';
223 logger.info(`[File Upload] Uploading file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo})`);
224
225 const uploadResult = await agent.com.atproto.repo.uploadBlob(
226 file.content,
227 {
228 encoding: uploadMimeType
229 }
230 );
231
232 const returnedBlobRef = uploadResult.data.blob;
233
234 // Use the blob ref exactly as returned from PDS
235 return {
236 result: {
237 hash: returnedBlobRef.ref.toString(),
238 blobRef: returnedBlobRef,
239 ...(file.compressed && {
240 encoding: 'gzip' as const,
241 mimeType: file.originalMimeType || file.mimeType,
242 base64: true
243 })
244 },
245 filePath: file.name,
246 sentMimeType: file.mimeType,
247 returnedMimeType: returnedBlobRef.mimeType
248 };
249 } catch (uploadError) {
250 logger.error('Upload failed for file', uploadError);
251 throw uploadError;
252 }
253 });
254
255 // Wait for all uploads to complete
256 const uploadedBlobs = await Promise.all(uploadPromises);
257
258 // Extract results and file paths in correct order
259 const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result);
260 const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath);
261
262 // Update directory with file blobs
263 const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths);
264
265 // Create manifest
266 const manifest = createManifest(siteName, updatedDirectory, fileCount);
267
268 // Use site name as rkey
269 const rkey = siteName;
270
271 let record;
272 try {
273 record = await agent.com.atproto.repo.putRecord({
274 repo: auth.did,
275 collection: 'place.wisp.fs',
276 rkey: rkey,
277 record: manifest
278 });
279 } catch (putRecordError: any) {
280 logger.error('Failed to create record on PDS', putRecordError);
281
282 throw putRecordError;
283 }
284
285 // Store site in database cache
286 await upsertSite(auth.did, rkey, siteName);
287
288 const result = {
289 success: true,
290 uri: record.data.uri,
291 cid: record.data.cid,
292 fileCount,
293 siteName,
294 skippedFiles,
295 uploadedCount: uploadedFiles.length
296 };
297
298 return result;
299 } catch (error) {
300 logger.error('Upload error', error, {
301 message: error instanceof Error ? error.message : 'Unknown error',
302 name: error instanceof Error ? error.name : undefined
303 });
304 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`);
305 }
306 }
307 )