Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

place.wisp.settings support

Changed files
+1761 -200
hosting-service
lexicons
public
editor
src
lexicons
types
place
lib
routes
+51
hosting-service/debug-settings.ts
··· 1 + #!/usr/bin/env tsx 2 + /** 3 + * Debug script to check cached settings for a site 4 + * Usage: tsx debug-settings.ts <did> <rkey> 5 + */ 6 + 7 + import { readFile } from 'fs/promises'; 8 + import { existsSync } from 'fs'; 9 + 10 + const CACHE_DIR = './cache'; 11 + 12 + async function debugSettings(did: string, rkey: string) { 13 + const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`; 14 + 15 + console.log('Checking metadata at:', metadataPath); 16 + console.log('Exists:', existsSync(metadataPath)); 17 + 18 + if (!existsSync(metadataPath)) { 19 + console.log('\n❌ Metadata file does not exist - site may not be cached yet'); 20 + return; 21 + } 22 + 23 + const content = await readFile(metadataPath, 'utf-8'); 24 + const metadata = JSON.parse(content); 25 + 26 + console.log('\n=== Cached Metadata ==='); 27 + console.log('CID:', metadata.cid); 28 + console.log('Cached at:', metadata.cachedAt); 29 + console.log('\n=== Settings ==='); 30 + if (metadata.settings) { 31 + console.log(JSON.stringify(metadata.settings, null, 2)); 32 + } else { 33 + console.log('❌ No settings found in metadata'); 34 + console.log('This means:'); 35 + console.log(' 1. No place.wisp.settings record exists on the PDS'); 36 + console.log(' 2. Or the firehose hasn\'t picked up the settings yet'); 37 + console.log('\nTo fix:'); 38 + console.log(' 1. Create a place.wisp.settings record with the same rkey'); 39 + console.log(' 2. Wait for firehose to pick it up (a few seconds)'); 40 + console.log(' 3. Or manually re-cache the site'); 41 + } 42 + } 43 + 44 + const [did, rkey] = process.argv.slice(2); 45 + if (!did || !rkey) { 46 + console.log('Usage: tsx debug-settings.ts <did> <rkey>'); 47 + console.log('Example: tsx debug-settings.ts did:plc:abc123 my-site'); 48 + process.exit(1); 49 + } 50 + 51 + debugSettings(did, rkey).catch(console.error);
+86 -1
hosting-service/src/lexicon/lexicons.ts
··· 123 123 flat: { 124 124 type: 'boolean', 125 125 description: 126 - "If true, the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false (default), the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure.", 126 + "If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure.", 127 + }, 128 + }, 129 + }, 130 + }, 131 + }, 132 + PlaceWispSettings: { 133 + lexicon: 1, 134 + id: 'place.wisp.settings', 135 + defs: { 136 + main: { 137 + type: 'record', 138 + description: 139 + 'Configuration settings for a static site hosted on wisp.place', 140 + key: 'any', 141 + record: { 142 + type: 'object', 143 + properties: { 144 + directoryListing: { 145 + type: 'boolean', 146 + description: 147 + 'Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.', 148 + default: false, 149 + }, 150 + spaMode: { 151 + type: 'string', 152 + description: 153 + "File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.", 154 + maxLength: 500, 155 + }, 156 + custom404: { 157 + type: 'string', 158 + description: 159 + 'Custom 404 error page file path. Incompatible with directoryListing and spaMode.', 160 + maxLength: 500, 161 + }, 162 + indexFiles: { 163 + type: 'array', 164 + description: 165 + "Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.", 166 + items: { 167 + type: 'string', 168 + maxLength: 255, 169 + }, 170 + maxLength: 10, 171 + }, 172 + cleanUrls: { 173 + type: 'boolean', 174 + description: 175 + "Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.", 176 + default: false, 177 + }, 178 + headers: { 179 + type: 'array', 180 + description: 'Custom HTTP headers to set on responses', 181 + items: { 182 + type: 'ref', 183 + ref: 'lex:place.wisp.settings#customHeader', 184 + }, 185 + maxLength: 50, 186 + }, 187 + }, 188 + }, 189 + }, 190 + customHeader: { 191 + type: 'object', 192 + description: 'Custom HTTP header configuration', 193 + required: ['name', 'value'], 194 + properties: { 195 + name: { 196 + type: 'string', 197 + description: 198 + "HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')", 199 + maxLength: 100, 200 + }, 201 + value: { 202 + type: 'string', 203 + description: 'HTTP header value', 204 + maxLength: 1000, 205 + }, 206 + path: { 207 + type: 'string', 208 + description: 209 + "Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.", 210 + maxLength: 500, 127 211 }, 128 212 }, 129 213 }, ··· 275 359 276 360 export const ids = { 277 361 PlaceWispFs: 'place.wisp.fs', 362 + PlaceWispSettings: 'place.wisp.settings', 278 363 PlaceWispSubfs: 'place.wisp.subfs', 279 364 } as const
+1 -1
hosting-service/src/lexicon/types/place/wisp/fs.ts
··· 95 95 type: 'subfs' 96 96 /** AT-URI pointing to a place.wisp.subfs record containing this subtree. */ 97 97 subject: string 98 - /** If true, the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false (default), the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. */ 98 + /** If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. */ 99 99 flat?: boolean 100 100 } 101 101
+65
hosting-service/src/lexicon/types/place/wisp/settings.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'place.wisp.settings' 12 + 13 + export interface Main { 14 + $type: 'place.wisp.settings' 15 + /** Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. */ 16 + directoryListing: boolean 17 + /** File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404. */ 18 + spaMode?: string 19 + /** Custom 404 error page file path. Incompatible with directoryListing and spaMode. */ 20 + custom404?: string 21 + /** Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. */ 22 + indexFiles?: string[] 23 + /** Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. */ 24 + cleanUrls: boolean 25 + /** Custom HTTP headers to set on responses */ 26 + headers?: CustomHeader[] 27 + [k: string]: unknown 28 + } 29 + 30 + const hashMain = 'main' 31 + 32 + export function isMain<V>(v: V) { 33 + return is$typed(v, id, hashMain) 34 + } 35 + 36 + export function validateMain<V>(v: V) { 37 + return validate<Main & V>(v, id, hashMain, true) 38 + } 39 + 40 + export { 41 + type Main as Record, 42 + isMain as isRecord, 43 + validateMain as validateRecord, 44 + } 45 + 46 + /** Custom HTTP header configuration */ 47 + export interface CustomHeader { 48 + $type?: 'place.wisp.settings#customHeader' 49 + /** HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') */ 50 + name: string 51 + /** HTTP header value */ 52 + value: string 53 + /** Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. */ 54 + path?: string 55 + } 56 + 57 + const hashCustomHeader = 'customHeader' 58 + 59 + export function isCustomHeader<V>(v: V) { 60 + return is$typed(v, id, hashCustomHeader) 61 + } 62 + 63 + export function validateCustomHeader<V>(v: V) { 64 + return validate<CustomHeader & V>(v, id, hashCustomHeader) 65 + }
+65 -1
hosting-service/src/lib/firehose.ts
··· 55 55 this.firehose = new Firehose({ 56 56 idResolver: this.idResolver, 57 57 service: 'wss://bsky.network', 58 - filterCollections: ['place.wisp.fs'], 58 + filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 59 59 handleEvent: async (evt: any) => { 60 60 this.lastEventTime = Date.now() 61 61 ··· 95 95 }) 96 96 } 97 97 } 98 + // Handle settings changes 99 + else if (evt.collection === 'place.wisp.settings') { 100 + this.log('Received place.wisp.settings event', { 101 + did: evt.did, 102 + event: evt.event, 103 + rkey: evt.rkey 104 + }) 105 + 106 + try { 107 + await this.handleSettingsChange(evt.did, evt.rkey) 108 + } catch (err) { 109 + this.log('Error handling settings change', { 110 + did: evt.did, 111 + event: evt.event, 112 + rkey: evt.rkey, 113 + error: 114 + err instanceof Error 115 + ? err.message 116 + : String(err) 117 + }) 118 + } 119 + } 98 120 } else if ( 99 121 evt.event === 'delete' && 100 122 evt.collection === 'place.wisp.fs' ··· 108 130 await this.handleDelete(evt.did, evt.rkey) 109 131 } catch (err) { 110 132 this.log('Error handling delete', { 133 + did: evt.did, 134 + rkey: evt.rkey, 135 + error: 136 + err instanceof Error ? err.message : String(err) 137 + }) 138 + } 139 + } else if ( 140 + evt.event === 'delete' && 141 + evt.collection === 'place.wisp.settings' 142 + ) { 143 + this.log('Received settings delete event', { 144 + did: evt.did, 145 + rkey: evt.rkey 146 + }) 147 + 148 + try { 149 + await this.handleSettingsChange(evt.did, evt.rkey) 150 + } catch (err) { 151 + this.log('Error handling settings delete', { 111 152 did: evt.did, 112 153 rkey: evt.rkey, 113 154 error: ··· 284 325 this.deleteCache(did, site) 285 326 286 327 this.log('Successfully processed delete', { did, site }) 328 + } 329 + 330 + private async handleSettingsChange(did: string, rkey: string) { 331 + this.log('Processing settings change', { did, rkey }) 332 + 333 + // Invalidate in-memory caches (includes metadata which stores settings) 334 + invalidateSiteCache(did, rkey) 335 + 336 + // Update on-disk metadata with new settings 337 + try { 338 + const { fetchSiteSettings, updateCacheMetadataSettings } = await import('./utils') 339 + const settings = await fetchSiteSettings(did, rkey) 340 + await updateCacheMetadataSettings(did, rkey, settings) 341 + this.log('Updated cached settings', { did, rkey, hasSettings: !!settings }) 342 + } catch (err) { 343 + this.log('Failed to update cached settings', { 344 + did, 345 + rkey, 346 + error: err instanceof Error ? err.message : String(err) 347 + }) 348 + } 349 + 350 + this.log('Successfully processed settings change', { did, rkey }) 287 351 } 288 352 289 353 private deleteCache(did: string, site: string) {
+55 -3
hosting-service/src/lib/utils.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs'; 3 3 import type { Record as SubfsRecord } from '../lexicon/types/place/wisp/subfs'; 4 + import type { Record as WispSettings } from '../lexicon/types/place/wisp/settings'; 4 5 import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; 5 6 import { writeFile, readFile, rename } from 'fs/promises'; 6 7 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; ··· 16 17 rkey: string; 17 18 // Map of file path to blob CID for incremental updates 18 19 fileCids?: Record<string, string>; 20 + // Site settings 21 + settings?: WispSettings; 19 22 } 20 23 21 24 /** ··· 171 174 } 172 175 } 173 176 177 + export async function fetchSiteSettings(did: string, rkey: string): Promise<WispSettings | null> { 178 + try { 179 + const pdsEndpoint = await getPdsForDid(did); 180 + if (!pdsEndpoint) return null; 181 + 182 + const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.settings&rkey=${encodeURIComponent(rkey)}`; 183 + const data = await safeFetchJson(url); 184 + 185 + return data.value as WispSettings; 186 + } catch (err) { 187 + // Settings are optional, so return null if not found 188 + return null; 189 + } 190 + } 191 + 174 192 export function extractBlobCid(blobRef: unknown): string | null { 175 193 if (isIpldLink(blobRef)) { 176 194 return blobRef.$link; ··· 376 394 const newFileCids: Record<string, string> = {}; 377 395 collectFileCidsFromEntries(expandedRoot.entries, '', newFileCids); 378 396 397 + // Fetch site settings (optional) 398 + const settings = await fetchSiteSettings(did, rkey); 399 + 379 400 // Download/copy files to temporary directory (with incremental logic, using expanded root) 380 401 await cacheFiles(did, rkey, expandedRoot.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir); 381 - await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids); 402 + await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids, settings); 382 403 383 404 // Atomically replace old cache with new cache 384 405 // On POSIX systems (Linux/macOS), rename is atomic ··· 672 693 return existsSync(`${CACHE_DIR}/${did}/${site}`); 673 694 } 674 695 675 - async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>): Promise<void> { 696 + async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>, settings?: WispSettings | null): Promise<void> { 676 697 const metadata: CacheMetadata = { 677 698 recordCid, 678 699 cachedAt: Date.now(), 679 700 did, 680 701 rkey, 681 - fileCids 702 + fileCids, 703 + settings: settings || undefined 682 704 }; 683 705 684 706 const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`; ··· 701 723 } catch (err) { 702 724 console.error('Failed to read cache metadata', err); 703 725 return null; 726 + } 727 + } 728 + 729 + export async function getCachedSettings(did: string, rkey: string): Promise<WispSettings | null> { 730 + const metadata = await getCacheMetadata(did, rkey); 731 + return metadata?.settings || null; 732 + } 733 + 734 + export async function updateCacheMetadataSettings(did: string, rkey: string, settings: WispSettings | null): Promise<void> { 735 + const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`; 736 + 737 + if (!existsSync(metadataPath)) { 738 + console.warn('Metadata file does not exist, cannot update settings', { did, rkey }); 739 + return; 740 + } 741 + 742 + try { 743 + // Read existing metadata 744 + const content = await readFile(metadataPath, 'utf-8'); 745 + const metadata = JSON.parse(content) as CacheMetadata; 746 + 747 + // Update settings field 748 + metadata.settings = settings || undefined; 749 + 750 + // Write back to disk 751 + await writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); 752 + console.log('Updated metadata settings', { did, rkey, hasSettings: !!settings }); 753 + } catch (err) { 754 + console.error('Failed to update metadata settings', err); 755 + throw err; 704 756 } 705 757 } 706 758
+613 -103
hosting-service/src/server.ts
··· 1 1 import { Hono } from 'hono'; 2 2 import { cors } from 'hono/cors'; 3 3 import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db'; 4 - import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils'; 4 + import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType, getCachedSettings } from './lib/utils'; 5 + import type { Record as WispSettings } from './lexicon/types/place/wisp/settings'; 5 6 import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter'; 6 7 import { existsSync } from 'fs'; 7 8 import { readFile, access } from 'fs/promises'; ··· 13 14 const BASE_HOST = process.env.BASE_HOST || 'wisp.place'; 14 15 15 16 /** 16 - * Configurable index file names to check for directory requests 17 + * Default index file names to check for directory requests 17 18 * Will be checked in order until one is found 18 19 */ 19 - const INDEX_FILES = ['index.html', 'index.htm']; 20 + const DEFAULT_INDEX_FILES = ['index.html', 'index.htm']; 21 + 22 + /** 23 + * Get index files list from settings or use defaults 24 + */ 25 + function getIndexFiles(settings: WispSettings | null): string[] { 26 + if (settings?.indexFiles && settings.indexFiles.length > 0) { 27 + return settings.indexFiles; 28 + } 29 + return DEFAULT_INDEX_FILES; 30 + } 31 + 32 + /** 33 + * Match a file path against a glob pattern 34 + * Supports * wildcard and basic path matching 35 + */ 36 + function matchGlob(path: string, pattern: string): boolean { 37 + // Normalize paths 38 + const normalizedPath = path.startsWith('/') ? path : '/' + path; 39 + const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern; 40 + 41 + // Convert glob pattern to regex 42 + const regexPattern = normalizedPattern 43 + .replace(/\./g, '\\.') 44 + .replace(/\*/g, '.*') 45 + .replace(/\?/g, '.'); 46 + 47 + const regex = new RegExp('^' + regexPattern + '$'); 48 + return regex.test(normalizedPath); 49 + } 50 + 51 + /** 52 + * Apply custom headers from settings to response headers 53 + */ 54 + function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) { 55 + if (!settings?.headers || settings.headers.length === 0) return; 56 + 57 + for (const customHeader of settings.headers) { 58 + // If path glob is specified, check if it matches 59 + if (customHeader.path) { 60 + if (!matchGlob(filePath, customHeader.path)) { 61 + continue; 62 + } 63 + } 64 + // Apply the header 65 + headers[customHeader.name] = customHeader.value; 66 + } 67 + } 68 + 69 + /** 70 + * Generate 404 page HTML 71 + */ 72 + function generate404Page(): string { 73 + const html = `<!DOCTYPE html> 74 + <html> 75 + <head> 76 + <meta charset="utf-8"> 77 + <meta name="viewport" content="width=device-width, initial-scale=1"> 78 + <title>404 - Not Found</title> 79 + <style> 80 + @media (prefers-color-scheme: light) { 81 + :root { 82 + /* Warm beige background */ 83 + --background: oklch(0.90 0.012 35); 84 + /* Very dark brown text */ 85 + --foreground: oklch(0.18 0.01 30); 86 + --border: oklch(0.75 0.015 30); 87 + /* Bright pink accent for links */ 88 + --accent: oklch(0.78 0.15 345); 89 + } 90 + } 91 + @media (prefers-color-scheme: dark) { 92 + :root { 93 + /* Slate violet background */ 94 + --background: oklch(0.23 0.015 285); 95 + /* Light gray text */ 96 + --foreground: oklch(0.90 0.005 285); 97 + /* Subtle borders */ 98 + --border: oklch(0.38 0.02 285); 99 + /* Soft pink accent */ 100 + --accent: oklch(0.85 0.08 5); 101 + } 102 + } 103 + body { 104 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 105 + background: var(--background); 106 + color: var(--foreground); 107 + padding: 2rem; 108 + max-width: 800px; 109 + margin: 0 auto; 110 + display: flex; 111 + flex-direction: column; 112 + min-height: 100vh; 113 + justify-content: center; 114 + align-items: center; 115 + text-align: center; 116 + } 117 + h1 { 118 + font-size: 6rem; 119 + margin: 0; 120 + font-weight: 700; 121 + line-height: 1; 122 + } 123 + h2 { 124 + font-size: 1.5rem; 125 + margin: 1rem 0 2rem; 126 + font-weight: 400; 127 + opacity: 0.8; 128 + } 129 + p { 130 + font-size: 1rem; 131 + opacity: 0.7; 132 + margin-bottom: 2rem; 133 + } 134 + a { 135 + color: var(--accent); 136 + text-decoration: none; 137 + font-size: 1rem; 138 + } 139 + a:hover { 140 + text-decoration: underline; 141 + } 142 + footer { 143 + margin-top: 3rem; 144 + padding-top: 1.5rem; 145 + border-top: 1px solid var(--border); 146 + text-align: center; 147 + font-size: 0.875rem; 148 + opacity: 0.7; 149 + color: var(--foreground); 150 + } 151 + footer a { 152 + color: var(--accent); 153 + text-decoration: none; 154 + display: inline; 155 + } 156 + footer a:hover { 157 + text-decoration: underline; 158 + } 159 + </style> 160 + </head> 161 + <body> 162 + <div> 163 + <h1>404</h1> 164 + <h2>Page not found</h2> 165 + <p>The page you're looking for doesn't exist.</p> 166 + <a href="/">← Back to home</a> 167 + </div> 168 + <footer> 169 + Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 170 + </footer> 171 + </body> 172 + </html>`; 173 + return html; 174 + } 175 + 176 + /** 177 + * Generate directory listing HTML 178 + */ 179 + function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string { 180 + const title = path || 'Index'; 181 + 182 + // Sort: directories first, then files, alphabetically within each group 183 + const sortedEntries = [...entries].sort((a, b) => { 184 + if (a.isDirectory && !b.isDirectory) return -1; 185 + if (!a.isDirectory && b.isDirectory) return 1; 186 + return a.name.localeCompare(b.name); 187 + }); 188 + 189 + const html = `<!DOCTYPE html> 190 + <html> 191 + <head> 192 + <meta charset="utf-8"> 193 + <meta name="viewport" content="width=device-width, initial-scale=1"> 194 + <title>Index of /${path}</title> 195 + <style> 196 + @media (prefers-color-scheme: light) { 197 + :root { 198 + /* Warm beige background */ 199 + --background: oklch(0.90 0.012 35); 200 + /* Very dark brown text */ 201 + --foreground: oklch(0.18 0.01 30); 202 + --border: oklch(0.75 0.015 30); 203 + /* Bright pink accent for links */ 204 + --accent: oklch(0.78 0.15 345); 205 + /* Lavender for folders */ 206 + --folder: oklch(0.60 0.12 295); 207 + --icon: oklch(0.28 0.01 30); 208 + } 209 + } 210 + @media (prefers-color-scheme: dark) { 211 + :root { 212 + /* Slate violet background */ 213 + --background: oklch(0.23 0.015 285); 214 + /* Light gray text */ 215 + --foreground: oklch(0.90 0.005 285); 216 + /* Subtle borders */ 217 + --border: oklch(0.38 0.02 285); 218 + /* Soft pink accent */ 219 + --accent: oklch(0.85 0.08 5); 220 + /* Lavender for folders */ 221 + --folder: oklch(0.70 0.10 295); 222 + --icon: oklch(0.85 0.005 285); 223 + } 224 + } 225 + body { 226 + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; 227 + background: var(--background); 228 + color: var(--foreground); 229 + padding: 2rem; 230 + max-width: 800px; 231 + margin: 0 auto; 232 + } 233 + h1 { 234 + font-size: 1.5rem; 235 + margin-bottom: 2rem; 236 + padding-bottom: 0.5rem; 237 + border-bottom: 1px solid var(--border); 238 + } 239 + ul { 240 + list-style: none; 241 + padding: 0; 242 + } 243 + li { 244 + padding: 0.5rem 0; 245 + border-bottom: 1px solid var(--border); 246 + } 247 + li a { 248 + color: var(--accent); 249 + text-decoration: none; 250 + display: flex; 251 + align-items: center; 252 + gap: 0.75rem; 253 + } 254 + li a:hover { 255 + text-decoration: underline; 256 + } 257 + .folder { 258 + color: var(--folder); 259 + font-weight: 600; 260 + } 261 + .file { 262 + color: var(--accent); 263 + } 264 + .folder::before, 265 + .file::before, 266 + .parent::before { 267 + content: ""; 268 + display: inline-block; 269 + width: 1.25em; 270 + height: 1.25em; 271 + background-color: var(--icon); 272 + flex-shrink: 0; 273 + -webkit-mask-size: contain; 274 + mask-size: contain; 275 + -webkit-mask-repeat: no-repeat; 276 + mask-repeat: no-repeat; 277 + -webkit-mask-position: center; 278 + mask-position: center; 279 + } 280 + .folder::before { 281 + -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 282 + mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>'); 283 + } 284 + .file::before { 285 + -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 286 + mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>'); 287 + } 288 + .parent::before { 289 + -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 290 + mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>'); 291 + } 292 + footer { 293 + margin-top: 3rem; 294 + padding-top: 1.5rem; 295 + border-top: 1px solid var(--border); 296 + text-align: center; 297 + font-size: 0.875rem; 298 + opacity: 0.7; 299 + color: var(--foreground); 300 + } 301 + footer a { 302 + color: var(--accent); 303 + text-decoration: none; 304 + display: inline; 305 + } 306 + footer a:hover { 307 + text-decoration: underline; 308 + } 309 + </style> 310 + </head> 311 + <body> 312 + <h1>Index of /${path}</h1> 313 + <ul> 314 + ${path ? '<li><a href="../" class="parent">../</a></li>' : ''} 315 + ${sortedEntries.map(e => 316 + `<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>` 317 + ).join('\n ')} 318 + </ul> 319 + <footer> 320 + Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a> 321 + </footer> 322 + </body> 323 + </html>`; 324 + return html; 325 + } 20 326 21 327 /** 22 328 * Validate site name (rkey) to prevent injection attacks ··· 146 452 147 453 // Helper to serve files from cache 148 454 async function serveFromCache( 149 - did: string, 150 - rkey: string, 455 + did: string, 456 + rkey: string, 151 457 filePath: string, 152 458 fullUrl?: string, 153 459 headers?: Record<string, string> 154 460 ) { 155 - // Check for redirect rules first 461 + // Load settings for this site 462 + const settings = await getCachedSettings(did, rkey); 463 + const indexFiles = getIndexFiles(settings); 464 + 465 + // Check for redirect rules first (_redirects wins over settings) 156 466 const redirectCacheKey = `${did}:${rkey}`; 157 467 let redirectRules = redirectRulesCache.get(redirectCacheKey); 158 - 468 + 159 469 if (redirectRules === undefined) { 160 470 // Load rules for the first time 161 471 redirectRules = await loadRedirectRules(did, rkey); ··· 180 490 // If not forced, check if the requested file exists before redirecting 181 491 if (!rule.force) { 182 492 // Build the expected file path 183 - let checkPath = filePath || INDEX_FILES[0]; 493 + let checkPath = filePath || indexFiles[0]; 184 494 if (checkPath.endsWith('/')) { 185 - checkPath += INDEX_FILES[0]; 495 + checkPath += indexFiles[0]; 186 496 } 187 497 188 498 const cachedFile = getCachedFilePath(did, rkey, checkPath); ··· 190 500 191 501 // If file exists and redirect is not forced, serve the file normally 192 502 if (fileExistsOnDisk) { 193 - return serveFileInternal(did, rkey, filePath); 503 + return serveFileInternal(did, rkey, filePath, settings); 194 504 } 195 505 } 196 506 ··· 199 509 // Rewrite: serve different content but keep URL the same 200 510 // Remove leading slash for internal path resolution 201 511 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 202 - return serveFileInternal(did, rkey, rewritePath); 512 + return serveFileInternal(did, rkey, rewritePath, settings); 203 513 } else if (status === 301 || status === 302) { 204 514 // External redirect: change the URL 205 515 return new Response(null, { ··· 210 520 }, 211 521 }); 212 522 } else if (status === 404) { 213 - // Custom 404 page 523 + // Custom 404 page from _redirects (wins over settings.custom404) 214 524 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 215 - const response = await serveFileInternal(did, rkey, custom404Path); 525 + const response = await serveFileInternal(did, rkey, custom404Path, settings); 216 526 // Override status to 404 217 527 return new Response(response.body, { 218 528 status: 404, ··· 222 532 } 223 533 } 224 534 225 - // No redirect matched, serve normally 226 - return serveFileInternal(did, rkey, filePath); 535 + // No redirect matched, serve normally with settings 536 + return serveFileInternal(did, rkey, filePath, settings); 227 537 } 228 538 229 539 // Internal function to serve a file (used by both normal serving and rewrites) 230 - async function serveFileInternal(did: string, rkey: string, filePath: string) { 540 + async function serveFileInternal(did: string, rkey: string, filePath: string, settings: WispSettings | null = null) { 231 541 // Check if site is currently being cached - if so, return updating response 232 542 if (isSiteBeingCached(did, rkey)) { 233 543 return siteUpdatingResponse(); 234 544 } 235 545 236 - // Default to first index file if path is empty 237 - let requestPath = filePath || INDEX_FILES[0]; 546 + const indexFiles = getIndexFiles(settings); 238 547 239 - // If path ends with /, append first index file 240 - if (requestPath.endsWith('/')) { 241 - requestPath += INDEX_FILES[0]; 548 + // Normalize the request path (keep empty for root, remove trailing slash for others) 549 + let requestPath = filePath || ''; 550 + if (requestPath.endsWith('/') && requestPath.length > 1) { 551 + requestPath = requestPath.slice(0, -1); 242 552 } 243 553 244 - const cacheKey = getCacheKey(did, rkey, requestPath); 245 - const cachedFile = getCachedFilePath(did, rkey, requestPath); 246 - 247 - // Check if the cached file path is a directory 248 - if (await fileExists(cachedFile)) { 249 - const { stat } = await import('fs/promises'); 554 + // Check if this path is a directory first 555 + const directoryPath = getCachedFilePath(did, rkey, requestPath); 556 + if (await fileExists(directoryPath)) { 557 + const { stat, readdir } = await import('fs/promises'); 250 558 try { 251 - const stats = await stat(cachedFile); 559 + const stats = await stat(directoryPath); 252 560 if (stats.isDirectory()) { 253 561 // It's a directory, try each index file in order 254 - for (const indexFile of INDEX_FILES) { 255 - const indexPath = `${requestPath}/${indexFile}`; 562 + for (const indexFile of indexFiles) { 563 + const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile; 256 564 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 257 565 if (await fileExists(indexFilePath)) { 258 - return serveFileInternal(did, rkey, indexPath); 566 + return serveFileInternal(did, rkey, indexPath, settings); 259 567 } 260 568 } 261 - // No index file found, fall through to 404 569 + // No index file found - check if directory listing is enabled 570 + if (settings?.directoryListing) { 571 + const { stat } = await import('fs/promises'); 572 + const entries = await readdir(directoryPath); 573 + // Filter out .meta files and other hidden files 574 + const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json'); 575 + 576 + // Check which entries are directories 577 + const entriesWithType = await Promise.all( 578 + visibleEntries.map(async (name) => { 579 + try { 580 + const entryPath = `${directoryPath}/${name}`; 581 + const stats = await stat(entryPath); 582 + return { name, isDirectory: stats.isDirectory() }; 583 + } catch { 584 + return { name, isDirectory: false }; 585 + } 586 + }) 587 + ); 588 + 589 + const html = generateDirectoryListing(requestPath, entriesWithType); 590 + return new Response(html, { 591 + headers: { 592 + 'Content-Type': 'text/html; charset=utf-8', 593 + 'Cache-Control': 'public, max-age=300', 594 + }, 595 + }); 596 + } 597 + // Fall through to 404/SPA handling 262 598 } 263 599 } catch (err) { 264 600 // If stat fails, continue with normal flow 265 601 } 266 602 } 267 603 604 + // Not a directory, try to serve as a file 605 + const fileRequestPath = requestPath || indexFiles[0]; 606 + const cacheKey = getCacheKey(did, rkey, fileRequestPath); 607 + const cachedFile = getCachedFilePath(did, rkey, fileRequestPath); 608 + 268 609 // Check in-memory cache first 269 610 let content = fileCache.get(cacheKey); 270 611 let meta = metadataCache.get(cacheKey); ··· 297 638 const decompressed = gunzipSync(content); 298 639 headers['Content-Type'] = meta.mimeType; 299 640 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 641 + applyCustomHeaders(headers, fileRequestPath, settings); 300 642 return new Response(decompressed, { headers }); 301 643 } else { 302 644 // Meta says gzipped but content isn't - serve as-is 303 645 console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`); 304 646 headers['Content-Type'] = meta.mimeType; 305 647 headers['Cache-Control'] = 'public, max-age=31536000, immutable'; 648 + applyCustomHeaders(headers, fileRequestPath, settings); 306 649 return new Response(content, { headers }); 307 650 } 308 651 } ··· 312 655 headers['Cache-Control'] = meta.mimeType.startsWith('text/html') 313 656 ? 'public, max-age=300' 314 657 : 'public, max-age=31536000, immutable'; 658 + applyCustomHeaders(headers, fileRequestPath, settings); 315 659 return new Response(content, { headers }); 316 660 } 317 661 ··· 321 665 headers['Cache-Control'] = mimeType.startsWith('text/html') 322 666 ? 'public, max-age=300' 323 667 : 'public, max-age=31536000, immutable'; 668 + applyCustomHeaders(headers, fileRequestPath, settings); 324 669 return new Response(content, { headers }); 325 670 } 326 671 327 672 // Try index files for directory-like paths 328 - if (!requestPath.includes('.')) { 329 - for (const indexFileName of INDEX_FILES) { 330 - const indexPath = `${requestPath}/${indexFileName}`; 673 + if (!fileRequestPath.includes('.')) { 674 + for (const indexFileName of indexFiles) { 675 + const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 331 676 const indexCacheKey = getCacheKey(did, rkey, indexPath); 332 677 const indexFile = getCachedFilePath(did, rkey, indexPath); 333 678 ··· 356 701 headers['Content-Encoding'] = 'gzip'; 357 702 } 358 703 704 + applyCustomHeaders(headers, indexPath, settings); 359 705 return new Response(indexContent, { headers }); 360 706 } 361 707 } 362 708 } 363 709 364 - return new Response('Not Found', { status: 404 }); 710 + // Try clean URLs: /about -> /about.html 711 + if (settings?.cleanUrls && !fileRequestPath.includes('.')) { 712 + const htmlPath = `${fileRequestPath}.html`; 713 + const htmlFile = getCachedFilePath(did, rkey, htmlPath); 714 + if (await fileExists(htmlFile)) { 715 + return serveFileInternal(did, rkey, htmlPath, settings); 716 + } 717 + 718 + // Also try /about/index.html 719 + for (const indexFileName of indexFiles) { 720 + const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 721 + const indexFile = getCachedFilePath(did, rkey, indexPath); 722 + if (await fileExists(indexFile)) { 723 + return serveFileInternal(did, rkey, indexPath, settings); 724 + } 725 + } 726 + } 727 + 728 + // SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects) 729 + if (settings?.spaMode) { 730 + const spaFile = settings.spaMode; 731 + const spaFilePath = getCachedFilePath(did, rkey, spaFile); 732 + if (await fileExists(spaFilePath)) { 733 + return serveFileInternal(did, rkey, spaFile, settings); 734 + } 735 + } 736 + 737 + // Custom 404: serve custom 404 file if configured (wins conflict battle) 738 + if (settings?.custom404) { 739 + const custom404File = settings.custom404; 740 + const custom404Path = getCachedFilePath(did, rkey, custom404File); 741 + if (await fileExists(custom404Path)) { 742 + const response = await serveFileInternal(did, rkey, custom404File, settings); 743 + // Override status to 404 744 + return new Response(response.body, { 745 + status: 404, 746 + headers: response.headers, 747 + }); 748 + } 749 + } 750 + 751 + // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html) 752 + const auto404Pages = ['404.html', 'not_found.html']; 753 + for (const auto404Page of auto404Pages) { 754 + const auto404Path = getCachedFilePath(did, rkey, auto404Page); 755 + if (await fileExists(auto404Path)) { 756 + const response = await serveFileInternal(did, rkey, auto404Page, settings); 757 + // Override status to 404 758 + return new Response(response.body, { 759 + status: 404, 760 + headers: response.headers, 761 + }); 762 + } 763 + } 764 + 765 + // Default styled 404 page 766 + const html = generate404Page(); 767 + return new Response(html, { 768 + status: 404, 769 + headers: { 770 + 'Content-Type': 'text/html; charset=utf-8', 771 + 'Cache-Control': 'public, max-age=300', 772 + }, 773 + }); 365 774 } 366 775 367 776 // Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes ··· 373 782 fullUrl?: string, 374 783 headers?: Record<string, string> 375 784 ) { 376 - // Check for redirect rules first 785 + // Load settings for this site 786 + const settings = await getCachedSettings(did, rkey); 787 + const indexFiles = getIndexFiles(settings); 788 + 789 + // Check for redirect rules first (_redirects wins over settings) 377 790 const redirectCacheKey = `${did}:${rkey}`; 378 791 let redirectRules = redirectRulesCache.get(redirectCacheKey); 379 - 792 + 380 793 if (redirectRules === undefined) { 381 794 // Load rules for the first time 382 795 redirectRules = await loadRedirectRules(did, rkey); ··· 401 814 // If not forced, check if the requested file exists before redirecting 402 815 if (!rule.force) { 403 816 // Build the expected file path 404 - let checkPath = filePath || INDEX_FILES[0]; 817 + let checkPath = filePath || indexFiles[0]; 405 818 if (checkPath.endsWith('/')) { 406 - checkPath += INDEX_FILES[0]; 819 + checkPath += indexFiles[0]; 407 820 } 408 821 409 822 const cachedFile = getCachedFilePath(did, rkey, checkPath); ··· 411 824 412 825 // If file exists and redirect is not forced, serve the file normally 413 826 if (fileExistsOnDisk) { 414 - return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 827 + return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings); 415 828 } 416 829 } 417 830 ··· 419 832 if (status === 200) { 420 833 // Rewrite: serve different content but keep URL the same 421 834 const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 422 - return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath); 835 + return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings); 423 836 } else if (status === 301 || status === 302) { 424 837 // External redirect: change the URL 425 838 // For sites.wisp.place, we need to adjust the target path to include the base path ··· 436 849 }, 437 850 }); 438 851 } else if (status === 404) { 439 - // Custom 404 page 852 + // Custom 404 page from _redirects (wins over settings.custom404) 440 853 const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath; 441 - const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath); 854 + const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings); 442 855 // Override status to 404 443 856 return new Response(response.body, { 444 857 status: 404, ··· 448 861 } 449 862 } 450 863 451 - // No redirect matched, serve normally 452 - return serveFileInternalWithRewrite(did, rkey, filePath, basePath); 864 + // No redirect matched, serve normally with settings 865 + return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings); 453 866 } 454 867 455 868 // Internal function to serve a file with rewriting 456 - async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) { 869 + async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string, settings: WispSettings | null = null) { 457 870 // Check if site is currently being cached - if so, return updating response 458 871 if (isSiteBeingCached(did, rkey)) { 459 872 return siteUpdatingResponse(); 460 873 } 461 874 462 - // Default to first index file if path is empty 463 - let requestPath = filePath || INDEX_FILES[0]; 875 + const indexFiles = getIndexFiles(settings); 464 876 465 - // If path ends with /, append first index file 466 - if (requestPath.endsWith('/')) { 467 - requestPath += INDEX_FILES[0]; 877 + // Normalize the request path (keep empty for root, remove trailing slash for others) 878 + let requestPath = filePath || ''; 879 + if (requestPath.endsWith('/') && requestPath.length > 1) { 880 + requestPath = requestPath.slice(0, -1); 468 881 } 469 882 470 - const cacheKey = getCacheKey(did, rkey, requestPath); 471 - const cachedFile = getCachedFilePath(did, rkey, requestPath); 472 - 473 - // Check if the cached file path is a directory 474 - if (await fileExists(cachedFile)) { 475 - const { stat } = await import('fs/promises'); 883 + // Check if this path is a directory first 884 + const directoryPath = getCachedFilePath(did, rkey, requestPath); 885 + if (await fileExists(directoryPath)) { 886 + const { stat, readdir } = await import('fs/promises'); 476 887 try { 477 - const stats = await stat(cachedFile); 888 + const stats = await stat(directoryPath); 478 889 if (stats.isDirectory()) { 479 890 // It's a directory, try each index file in order 480 - for (const indexFile of INDEX_FILES) { 481 - const indexPath = `${requestPath}/${indexFile}`; 891 + for (const indexFile of indexFiles) { 892 + const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile; 482 893 const indexFilePath = getCachedFilePath(did, rkey, indexPath); 483 894 if (await fileExists(indexFilePath)) { 484 - return serveFileInternalWithRewrite(did, rkey, indexPath, basePath); 895 + return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings); 485 896 } 486 897 } 487 - // No index file found, fall through to 404 898 + // No index file found - check if directory listing is enabled 899 + if (settings?.directoryListing) { 900 + const { stat } = await import('fs/promises'); 901 + const entries = await readdir(directoryPath); 902 + // Filter out .meta files and other hidden files 903 + const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json'); 904 + 905 + // Check which entries are directories 906 + const entriesWithType = await Promise.all( 907 + visibleEntries.map(async (name) => { 908 + try { 909 + const entryPath = `${directoryPath}/${name}`; 910 + const stats = await stat(entryPath); 911 + return { name, isDirectory: stats.isDirectory() }; 912 + } catch { 913 + return { name, isDirectory: false }; 914 + } 915 + }) 916 + ); 917 + 918 + const html = generateDirectoryListing(requestPath, entriesWithType); 919 + return new Response(html, { 920 + headers: { 921 + 'Content-Type': 'text/html; charset=utf-8', 922 + 'Cache-Control': 'public, max-age=300', 923 + }, 924 + }); 925 + } 926 + // Fall through to 404/SPA handling 488 927 } 489 928 } catch (err) { 490 929 // If stat fails, continue with normal flow 491 930 } 492 931 } 493 932 933 + // Not a directory, try to serve as a file 934 + const fileRequestPath = requestPath || indexFiles[0]; 935 + const cacheKey = getCacheKey(did, rkey, fileRequestPath); 936 + const cachedFile = getCachedFilePath(did, rkey, fileRequestPath); 937 + 494 938 // Check for rewritten HTML in cache first (if it's HTML) 495 - const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream'; 496 - if (isHtmlContent(requestPath, mimeTypeGuess)) { 497 - const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 939 + const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream'; 940 + if (isHtmlContent(fileRequestPath, mimeTypeGuess)) { 941 + const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`); 498 942 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 499 943 if (rewrittenContent) { 500 - return new Response(rewrittenContent, { 501 - headers: { 502 - 'Content-Type': 'text/html; charset=utf-8', 503 - 'Content-Encoding': 'gzip', 504 - 'Cache-Control': 'public, max-age=300', 505 - }, 506 - }); 944 + const headers: Record<string, string> = { 945 + 'Content-Type': 'text/html; charset=utf-8', 946 + 'Content-Encoding': 'gzip', 947 + 'Cache-Control': 'public, max-age=300', 948 + }; 949 + applyCustomHeaders(headers, fileRequestPath, settings); 950 + return new Response(rewrittenContent, { headers }); 507 951 } 508 952 } 509 953 ··· 529 973 const isGzipped = meta?.encoding === 'gzip'; 530 974 531 975 // Check if this is HTML content that needs rewriting 532 - if (isHtmlContent(requestPath, mimeType)) { 976 + if (isHtmlContent(fileRequestPath, mimeType)) { 533 977 let htmlContent: string; 534 978 if (isGzipped) { 535 979 // Verify content is actually gzipped ··· 538 982 const { gunzipSync } = await import('zlib'); 539 983 htmlContent = gunzipSync(content).toString('utf-8'); 540 984 } else { 541 - console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 985 + console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`); 542 986 htmlContent = content.toString('utf-8'); 543 987 } 544 988 } else { 545 989 htmlContent = content.toString('utf-8'); 546 990 } 547 - const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath); 991 + const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath); 548 992 549 993 // Recompress and cache the rewritten HTML 550 994 const { gzipSync } = await import('zlib'); 551 995 const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8')); 552 996 553 - const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`); 997 + const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`); 554 998 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 555 999 556 - return new Response(recompressed, { 557 - headers: { 558 - 'Content-Type': 'text/html; charset=utf-8', 559 - 'Content-Encoding': 'gzip', 560 - 'Cache-Control': 'public, max-age=300', 561 - }, 562 - }); 1000 + const htmlHeaders: Record<string, string> = { 1001 + 'Content-Type': 'text/html; charset=utf-8', 1002 + 'Content-Encoding': 'gzip', 1003 + 'Cache-Control': 'public, max-age=300', 1004 + }; 1005 + applyCustomHeaders(htmlHeaders, fileRequestPath, settings); 1006 + return new Response(recompressed, { headers: htmlHeaders }); 563 1007 } 564 1008 565 1009 // Non-HTML files: serve as-is ··· 576 1020 if (hasGzipMagic) { 577 1021 const { gunzipSync } = await import('zlib'); 578 1022 const decompressed = gunzipSync(content); 1023 + applyCustomHeaders(headers, fileRequestPath, settings); 579 1024 return new Response(decompressed, { headers }); 580 1025 } else { 581 - console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`); 1026 + console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`); 1027 + applyCustomHeaders(headers, fileRequestPath, settings); 582 1028 return new Response(content, { headers }); 583 1029 } 584 1030 } 585 1031 headers['Content-Encoding'] = 'gzip'; 586 1032 } 587 1033 1034 + applyCustomHeaders(headers, fileRequestPath, settings); 588 1035 return new Response(content, { headers }); 589 1036 } 590 1037 591 1038 // Try index files for directory-like paths 592 - if (!requestPath.includes('.')) { 593 - for (const indexFileName of INDEX_FILES) { 594 - const indexPath = `${requestPath}/${indexFileName}`; 1039 + if (!fileRequestPath.includes('.')) { 1040 + for (const indexFileName of indexFiles) { 1041 + const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 595 1042 const indexCacheKey = getCacheKey(did, rkey, indexPath); 596 1043 const indexFile = getCachedFilePath(did, rkey, indexPath); 597 1044 ··· 599 1046 const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`); 600 1047 const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey); 601 1048 if (rewrittenContent) { 602 - return new Response(rewrittenContent, { 603 - headers: { 604 - 'Content-Type': 'text/html; charset=utf-8', 605 - 'Content-Encoding': 'gzip', 606 - 'Cache-Control': 'public, max-age=300', 607 - }, 608 - }); 1049 + const headers: Record<string, string> = { 1050 + 'Content-Type': 'text/html; charset=utf-8', 1051 + 'Content-Encoding': 'gzip', 1052 + 'Cache-Control': 'public, max-age=300', 1053 + }; 1054 + applyCustomHeaders(headers, indexPath, settings); 1055 + return new Response(rewrittenContent, { headers }); 609 1056 } 610 1057 611 1058 let indexContent = fileCache.get(indexCacheKey); ··· 647 1094 648 1095 rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length); 649 1096 650 - return new Response(recompressed, { 651 - headers: { 652 - 'Content-Type': 'text/html; charset=utf-8', 653 - 'Content-Encoding': 'gzip', 654 - 'Cache-Control': 'public, max-age=300', 655 - }, 656 - }); 1097 + const headers: Record<string, string> = { 1098 + 'Content-Type': 'text/html; charset=utf-8', 1099 + 'Content-Encoding': 'gzip', 1100 + 'Cache-Control': 'public, max-age=300', 1101 + }; 1102 + applyCustomHeaders(headers, indexPath, settings); 1103 + return new Response(recompressed, { headers }); 1104 + } 1105 + } 1106 + } 1107 + 1108 + // Try clean URLs: /about -> /about.html 1109 + if (settings?.cleanUrls && !fileRequestPath.includes('.')) { 1110 + const htmlPath = `${fileRequestPath}.html`; 1111 + const htmlFile = getCachedFilePath(did, rkey, htmlPath); 1112 + if (await fileExists(htmlFile)) { 1113 + return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings); 1114 + } 1115 + 1116 + // Also try /about/index.html 1117 + for (const indexFileName of indexFiles) { 1118 + const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName; 1119 + const indexFile = getCachedFilePath(did, rkey, indexPath); 1120 + if (await fileExists(indexFile)) { 1121 + return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings); 657 1122 } 658 1123 } 659 1124 } 660 1125 661 - return new Response('Not Found', { status: 404 }); 1126 + // SPA mode: serve SPA file for all non-existing routes 1127 + if (settings?.spaMode) { 1128 + const spaFile = settings.spaMode; 1129 + const spaFilePath = getCachedFilePath(did, rkey, spaFile); 1130 + if (await fileExists(spaFilePath)) { 1131 + return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings); 1132 + } 1133 + } 1134 + 1135 + // Custom 404: serve custom 404 file if configured (wins conflict battle) 1136 + if (settings?.custom404) { 1137 + const custom404File = settings.custom404; 1138 + const custom404Path = getCachedFilePath(did, rkey, custom404File); 1139 + if (await fileExists(custom404Path)) { 1140 + const response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings); 1141 + // Override status to 404 1142 + return new Response(response.body, { 1143 + status: 404, 1144 + headers: response.headers, 1145 + }); 1146 + } 1147 + } 1148 + 1149 + // Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html) 1150 + const auto404Pages = ['404.html', 'not_found.html']; 1151 + for (const auto404Page of auto404Pages) { 1152 + const auto404Path = getCachedFilePath(did, rkey, auto404Page); 1153 + if (await fileExists(auto404Path)) { 1154 + const response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings); 1155 + // Override status to 404 1156 + return new Response(response.body, { 1157 + status: 404, 1158 + headers: response.headers, 1159 + }); 1160 + } 1161 + } 1162 + 1163 + // Default styled 404 page 1164 + const html = generate404Page(); 1165 + return new Response(html, { 1166 + status: 404, 1167 + headers: { 1168 + 'Content-Type': 'text/html; charset=utf-8', 1169 + 'Cache-Control': 'public, max-age=300', 1170 + }, 1171 + }); 662 1172 } 663 1173 664 1174 // Helper to ensure site is cached
+76
lexicons/settings.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "place.wisp.settings", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Configuration settings for a static site hosted on wisp.place", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "directoryListing": { 13 + "type": "boolean", 14 + "description": "Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.", 15 + "default": false 16 + }, 17 + "spaMode": { 18 + "type": "string", 19 + "description": "File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.", 20 + "maxLength": 500 21 + }, 22 + "custom404": { 23 + "type": "string", 24 + "description": "Custom 404 error page file path. Incompatible with directoryListing and spaMode.", 25 + "maxLength": 500 26 + }, 27 + "indexFiles": { 28 + "type": "array", 29 + "description": "Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.", 30 + "items": { 31 + "type": "string", 32 + "maxLength": 255 33 + }, 34 + "maxLength": 10 35 + }, 36 + "cleanUrls": { 37 + "type": "boolean", 38 + "description": "Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.", 39 + "default": false 40 + }, 41 + "headers": { 42 + "type": "array", 43 + "description": "Custom HTTP headers to set on responses", 44 + "items": { 45 + "type": "ref", 46 + "ref": "#customHeader" 47 + }, 48 + "maxLength": 50 49 + } 50 + } 51 + } 52 + }, 53 + "customHeader": { 54 + "type": "object", 55 + "description": "Custom HTTP header configuration", 56 + "required": ["name", "value"], 57 + "properties": { 58 + "name": { 59 + "type": "string", 60 + "description": "HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')", 61 + "maxLength": 100 62 + }, 63 + "value": { 64 + "type": "string", 65 + "description": "HTTP header value", 66 + "maxLength": 1000 67 + }, 68 + "path": { 69 + "type": "string", 70 + "description": "Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.", 71 + "maxLength": 500 72 + } 73 + } 74 + } 75 + } 76 + }
+385 -85
public/editor/editor.tsx
··· 19 19 import { Label } from '@public/components/ui/label' 20 20 import { Badge } from '@public/components/ui/badge' 21 21 import { SkeletonShimmer } from '@public/components/ui/skeleton' 22 + import { Input } from '@public/components/ui/input' 23 + import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 22 24 import { 23 25 Loader2, 24 26 Trash2, ··· 59 61 const [isSavingConfig, setIsSavingConfig] = useState(false) 60 62 const [isDeletingSite, setIsDeletingSite] = useState(false) 61 63 64 + // Site settings state 65 + type RoutingMode = 'default' | 'spa' | 'directory' | 'custom404' 66 + const [routingMode, setRoutingMode] = useState<RoutingMode>('default') 67 + const [spaFile, setSpaFile] = useState('index.html') 68 + const [custom404File, setCustom404File] = useState('404.html') 69 + const [indexFiles, setIndexFiles] = useState<string[]>(['index.html']) 70 + const [newIndexFile, setNewIndexFile] = useState('') 71 + const [cleanUrls, setCleanUrls] = useState(false) 72 + const [corsEnabled, setCorsEnabled] = useState(false) 73 + const [corsOrigin, setCorsOrigin] = useState('*') 74 + 62 75 // Fetch initial data on mount 63 76 useEffect(() => { 64 77 fetchUserInfo() ··· 67 80 }, []) 68 81 69 82 // Handle site configuration modal 70 - const handleConfigureSite = (site: SiteWithDomains) => { 83 + const handleConfigureSite = async (site: SiteWithDomains) => { 71 84 setConfiguringSite(site) 72 85 73 86 // Build set of currently mapped domains ··· 85 98 } 86 99 87 100 setSelectedDomains(mappedDomains) 101 + 102 + // Fetch and populate settings for this site 103 + try { 104 + const response = await fetch(`/api/site/${site.rkey}/settings`, { 105 + credentials: 'include' 106 + }) 107 + if (response.ok) { 108 + const settings = await response.json() 109 + 110 + // Determine routing mode based on settings 111 + if (settings.spaMode) { 112 + setRoutingMode('spa') 113 + setSpaFile(settings.spaMode) 114 + } else if (settings.directoryListing) { 115 + setRoutingMode('directory') 116 + } else if (settings.custom404) { 117 + setRoutingMode('custom404') 118 + setCustom404File(settings.custom404) 119 + } else { 120 + setRoutingMode('default') 121 + } 122 + 123 + // Set other settings 124 + setIndexFiles(settings.indexFiles || ['index.html']) 125 + setCleanUrls(settings.cleanUrls || false) 126 + 127 + // Check for CORS headers 128 + const corsHeader = settings.headers?.find((h: any) => h.name === 'Access-Control-Allow-Origin') 129 + if (corsHeader) { 130 + setCorsEnabled(true) 131 + setCorsOrigin(corsHeader.value) 132 + } else { 133 + setCorsEnabled(false) 134 + setCorsOrigin('*') 135 + } 136 + } else { 137 + // Reset to defaults if no settings found 138 + setRoutingMode('default') 139 + setSpaFile('index.html') 140 + setCustom404File('404.html') 141 + setIndexFiles(['index.html']) 142 + setCleanUrls(false) 143 + setCorsEnabled(false) 144 + setCorsOrigin('*') 145 + } 146 + } catch (err) { 147 + console.error('Failed to fetch settings:', err) 148 + // Use defaults on error 149 + setRoutingMode('default') 150 + setSpaFile('index.html') 151 + setCustom404File('404.html') 152 + setIndexFiles(['index.html']) 153 + setCleanUrls(false) 154 + setCorsEnabled(false) 155 + setCorsOrigin('*') 156 + } 88 157 } 89 158 90 159 const handleSaveSiteConfig = async () => { ··· 135 204 if (!isAlreadyMapped) { 136 205 await mapCustomDomain(domainId, configuringSite.rkey) 137 206 } 207 + } 208 + 209 + // Save site settings 210 + const settings: any = { 211 + cleanUrls, 212 + indexFiles: indexFiles.filter(f => f.trim() !== '') 213 + } 214 + 215 + // Set routing mode based on selection 216 + if (routingMode === 'spa') { 217 + settings.spaMode = spaFile 218 + } else if (routingMode === 'directory') { 219 + settings.directoryListing = true 220 + } else if (routingMode === 'custom404') { 221 + settings.custom404 = custom404File 222 + } 223 + 224 + // Add CORS header if enabled 225 + if (corsEnabled) { 226 + settings.headers = [ 227 + { 228 + name: 'Access-Control-Allow-Origin', 229 + value: corsOrigin 230 + } 231 + ] 232 + } 233 + 234 + const settingsResponse = await fetch(`/api/site/${configuringSite.rkey}/settings`, { 235 + method: 'POST', 236 + headers: { 237 + 'Content-Type': 'application/json' 238 + }, 239 + credentials: 'include', 240 + body: JSON.stringify(settings) 241 + }) 242 + 243 + if (!settingsResponse.ok) { 244 + const error = await settingsResponse.json() 245 + throw new Error(error.error || 'Failed to save settings') 138 246 } 139 247 140 248 // Refresh both domains and sites to get updated mappings ··· 393 501 open={configuringSite !== null} 394 502 onOpenChange={(open) => !open && setConfiguringSite(null)} 395 503 > 396 - <DialogContent className="sm:max-w-lg"> 504 + <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"> 397 505 <DialogHeader> 398 - <DialogTitle>Configure Site Domains</DialogTitle> 506 + <DialogTitle>Configure Site</DialogTitle> 399 507 <DialogDescription> 400 - Select which domains should be mapped to this site. You can select multiple domains. 508 + Configure domains and settings for this site. 401 509 </DialogDescription> 402 510 </DialogHeader> 403 511 {configuringSite && ( ··· 410 518 </p> 411 519 </div> 412 520 413 - <div className="space-y-3"> 414 - <p className="text-sm font-medium">Available Domains:</p> 521 + <Tabs defaultValue="domains" className="w-full"> 522 + <TabsList className="grid w-full grid-cols-2"> 523 + <TabsTrigger value="domains">Domains</TabsTrigger> 524 + <TabsTrigger value="settings">Settings</TabsTrigger> 525 + </TabsList> 526 + 527 + {/* Domains Tab */} 528 + <TabsContent value="domains" className="space-y-3 mt-4"> 529 + <p className="text-sm font-medium">Available Domains:</p> 530 + 531 + {wispDomains.map((wispDomain) => { 532 + const domainId = `wisp:${wispDomain.domain}` 533 + return ( 534 + <div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"> 535 + <Checkbox 536 + id={domainId} 537 + checked={selectedDomains.has(domainId)} 538 + onCheckedChange={(checked) => { 539 + const newSelected = new Set(selectedDomains) 540 + if (checked) { 541 + newSelected.add(domainId) 542 + } else { 543 + newSelected.delete(domainId) 544 + } 545 + setSelectedDomains(newSelected) 546 + }} 547 + /> 548 + <Label 549 + htmlFor={domainId} 550 + className="flex-1 cursor-pointer" 551 + > 552 + <div className="flex items-center justify-between"> 553 + <span className="font-mono text-sm"> 554 + {wispDomain.domain} 555 + </span> 556 + <Badge variant="secondary" className="text-xs ml-2"> 557 + Wisp 558 + </Badge> 559 + </div> 560 + </Label> 561 + </div> 562 + ) 563 + })} 415 564 416 - {wispDomains.map((wispDomain) => { 417 - const domainId = `wisp:${wispDomain.domain}` 418 - return ( 419 - <div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"> 420 - <Checkbox 421 - id={domainId} 422 - checked={selectedDomains.has(domainId)} 423 - onCheckedChange={(checked) => { 424 - const newSelected = new Set(selectedDomains) 425 - if (checked) { 426 - newSelected.add(domainId) 427 - } else { 428 - newSelected.delete(domainId) 429 - } 430 - setSelectedDomains(newSelected) 431 - }} 432 - /> 433 - <Label 434 - htmlFor={domainId} 435 - className="flex-1 cursor-pointer" 565 + {customDomains 566 + .filter((d) => d.verified) 567 + .map((domain) => ( 568 + <div 569 + key={domain.id} 570 + className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30" 436 571 > 437 - <div className="flex items-center justify-between"> 438 - <span className="font-mono text-sm"> 439 - {wispDomain.domain} 440 - </span> 441 - <Badge variant="secondary" className="text-xs ml-2"> 442 - Wisp 443 - </Badge> 572 + <Checkbox 573 + id={domain.id} 574 + checked={selectedDomains.has(domain.id)} 575 + onCheckedChange={(checked) => { 576 + const newSelected = new Set(selectedDomains) 577 + if (checked) { 578 + newSelected.add(domain.id) 579 + } else { 580 + newSelected.delete(domain.id) 581 + } 582 + setSelectedDomains(newSelected) 583 + }} 584 + /> 585 + <Label 586 + htmlFor={domain.id} 587 + className="flex-1 cursor-pointer" 588 + > 589 + <div className="flex items-center justify-between"> 590 + <span className="font-mono text-sm"> 591 + {domain.domain} 592 + </span> 593 + <Badge 594 + variant="outline" 595 + className="text-xs ml-2" 596 + > 597 + Custom 598 + </Badge> 599 + </div> 600 + </Label> 601 + </div> 602 + ))} 603 + 604 + {customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && ( 605 + <p className="text-sm text-muted-foreground py-4 text-center"> 606 + No domains available. Add a custom domain or claim a wisp.place subdomain. 607 + </p> 608 + )} 609 + 610 + <div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50 mt-4"> 611 + <p className="text-xs text-muted-foreground"> 612 + <strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '} 613 + <span className="font-mono"> 614 + sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey} 615 + </span> 616 + </p> 617 + </div> 618 + </TabsContent> 619 + 620 + {/* Settings Tab */} 621 + <TabsContent value="settings" className="space-y-4 mt-4"> 622 + {/* Routing Mode */} 623 + <div className="space-y-3"> 624 + <Label className="text-sm font-medium">Routing Mode</Label> 625 + <RadioGroup value={routingMode} onValueChange={(value) => setRoutingMode(value as RoutingMode)}> 626 + <div className="flex items-center space-x-3 p-3 border rounded-lg"> 627 + <RadioGroupItem value="default" id="mode-default" /> 628 + <Label htmlFor="mode-default" className="flex-1 cursor-pointer"> 629 + <div> 630 + <p className="font-medium">Default</p> 631 + <p className="text-xs text-muted-foreground">Standard static file serving</p> 632 + </div> 633 + </Label> 634 + </div> 635 + <div className="flex items-center space-x-3 p-3 border rounded-lg"> 636 + <RadioGroupItem value="spa" id="mode-spa" /> 637 + <Label htmlFor="mode-spa" className="flex-1 cursor-pointer"> 638 + <div> 639 + <p className="font-medium">SPA Mode</p> 640 + <p className="text-xs text-muted-foreground">Route all requests to a single file</p> 641 + </div> 642 + </Label> 643 + </div> 644 + {routingMode === 'spa' && ( 645 + <div className="ml-7 space-y-2"> 646 + <Label htmlFor="spa-file" className="text-sm">SPA File</Label> 647 + <Input 648 + id="spa-file" 649 + value={spaFile} 650 + onChange={(e) => setSpaFile(e.target.value)} 651 + placeholder="index.html" 652 + /> 444 653 </div> 445 - </Label> 654 + )} 655 + <div className="flex items-center space-x-3 p-3 border rounded-lg"> 656 + <RadioGroupItem value="directory" id="mode-directory" /> 657 + <Label htmlFor="mode-directory" className="flex-1 cursor-pointer"> 658 + <div> 659 + <p className="font-medium">Directory Listing</p> 660 + <p className="text-xs text-muted-foreground">Show directory contents on 404</p> 661 + </div> 662 + </Label> 663 + </div> 664 + <div className="flex items-center space-x-3 p-3 border rounded-lg"> 665 + <RadioGroupItem value="custom404" id="mode-custom404" /> 666 + <Label htmlFor="mode-custom404" className="flex-1 cursor-pointer"> 667 + <div> 668 + <p className="font-medium">Custom 404 Page</p> 669 + <p className="text-xs text-muted-foreground">Serve custom error page</p> 670 + </div> 671 + </Label> 672 + </div> 673 + {routingMode === 'custom404' && ( 674 + <div className="ml-7 space-y-2"> 675 + <Label htmlFor="404-file" className="text-sm">404 File</Label> 676 + <Input 677 + id="404-file" 678 + value={custom404File} 679 + onChange={(e) => setCustom404File(e.target.value)} 680 + placeholder="404.html" 681 + /> 682 + </div> 683 + )} 684 + </RadioGroup> 685 + </div> 686 + 687 + {/* Index Files */} 688 + <div className="space-y-3"> 689 + <Label className={`text-sm font-medium ${routingMode === 'spa' ? 'text-muted-foreground' : ''}`}> 690 + Index Files 691 + {routingMode === 'spa' && ( 692 + <span className="ml-2 text-xs">(disabled in SPA mode)</span> 693 + )} 694 + </Label> 695 + <p className="text-xs text-muted-foreground">Files to try when serving a directory (in order)</p> 696 + <div className="space-y-2"> 697 + {indexFiles.map((file, idx) => ( 698 + <div key={idx} className="flex items-center gap-2"> 699 + <Input 700 + value={file} 701 + onChange={(e) => { 702 + const newFiles = [...indexFiles] 703 + newFiles[idx] = e.target.value 704 + setIndexFiles(newFiles) 705 + }} 706 + placeholder="index.html" 707 + disabled={routingMode === 'spa'} 708 + /> 709 + <Button 710 + variant="outline" 711 + size="sm" 712 + onClick={() => { 713 + setIndexFiles(indexFiles.filter((_, i) => i !== idx)) 714 + }} 715 + disabled={routingMode === 'spa'} 716 + className="w-20" 717 + > 718 + Remove 719 + </Button> 720 + </div> 721 + ))} 722 + <div className="flex items-center gap-2"> 723 + <Input 724 + value={newIndexFile} 725 + onChange={(e) => setNewIndexFile(e.target.value)} 726 + placeholder="Add index file..." 727 + onKeyDown={(e) => { 728 + if (e.key === 'Enter' && newIndexFile.trim()) { 729 + setIndexFiles([...indexFiles, newIndexFile.trim()]) 730 + setNewIndexFile('') 731 + } 732 + }} 733 + disabled={routingMode === 'spa'} 734 + /> 735 + <Button 736 + variant="outline" 737 + size="sm" 738 + onClick={() => { 739 + if (newIndexFile.trim()) { 740 + setIndexFiles([...indexFiles, newIndexFile.trim()]) 741 + setNewIndexFile('') 742 + } 743 + }} 744 + disabled={routingMode === 'spa'} 745 + className="w-20" 746 + > 747 + Add 748 + </Button> 749 + </div> 446 750 </div> 447 - ) 448 - })} 751 + </div> 752 + 753 + {/* Clean URLs */} 754 + <div className="flex items-center space-x-3 p-3 border rounded-lg"> 755 + <Checkbox 756 + id="clean-urls" 757 + checked={cleanUrls} 758 + onCheckedChange={(checked) => setCleanUrls(!!checked)} 759 + /> 760 + <Label htmlFor="clean-urls" className="flex-1 cursor-pointer"> 761 + <div> 762 + <p className="font-medium">Clean URLs</p> 763 + <p className="text-xs text-muted-foreground"> 764 + Serve /about as /about.html or /about/index.html 765 + </p> 766 + </div> 767 + </Label> 768 + </div> 449 769 450 - {customDomains 451 - .filter((d) => d.verified) 452 - .map((domain) => ( 453 - <div 454 - key={domain.id} 455 - className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30" 456 - > 770 + {/* CORS */} 771 + <div className="space-y-3"> 772 + <div className="flex items-center space-x-3 p-3 border rounded-lg"> 457 773 <Checkbox 458 - id={domain.id} 459 - checked={selectedDomains.has(domain.id)} 460 - onCheckedChange={(checked) => { 461 - const newSelected = new Set(selectedDomains) 462 - if (checked) { 463 - newSelected.add(domain.id) 464 - } else { 465 - newSelected.delete(domain.id) 466 - } 467 - setSelectedDomains(newSelected) 468 - }} 774 + id="cors-enabled" 775 + checked={corsEnabled} 776 + onCheckedChange={(checked) => setCorsEnabled(!!checked)} 469 777 /> 470 - <Label 471 - htmlFor={domain.id} 472 - className="flex-1 cursor-pointer" 473 - > 474 - <div className="flex items-center justify-between"> 475 - <span className="font-mono text-sm"> 476 - {domain.domain} 477 - </span> 478 - <Badge 479 - variant="outline" 480 - className="text-xs ml-2" 481 - > 482 - Custom 483 - </Badge> 778 + <Label htmlFor="cors-enabled" className="flex-1 cursor-pointer"> 779 + <div> 780 + <p className="font-medium">Enable CORS</p> 781 + <p className="text-xs text-muted-foreground"> 782 + Allow cross-origin requests 783 + </p> 484 784 </div> 485 785 </Label> 486 786 </div> 487 - ))} 488 - 489 - {customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && ( 490 - <p className="text-sm text-muted-foreground py-4 text-center"> 491 - No domains available. Add a custom domain or claim a wisp.place subdomain. 492 - </p> 493 - )} 494 - </div> 495 - 496 - <div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50"> 497 - <p className="text-xs text-muted-foreground"> 498 - <strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '} 499 - <span className="font-mono"> 500 - sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey} 501 - </span> 502 - </p> 503 - </div> 787 + {corsEnabled && ( 788 + <div className="ml-7 space-y-2"> 789 + <Label htmlFor="cors-origin" className="text-sm">Allowed Origin</Label> 790 + <Input 791 + id="cors-origin" 792 + value={corsOrigin} 793 + onChange={(e) => setCorsOrigin(e.target.value)} 794 + placeholder="*" 795 + /> 796 + <p className="text-xs text-muted-foreground"> 797 + Use * for all origins, or specify a domain like https://example.com 798 + </p> 799 + </div> 800 + )} 801 + </div> 802 + </TabsContent> 803 + </Tabs> 504 804 </div> 505 805 )} 506 806 <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
+86 -1
src/lexicons/lexicons.ts
··· 123 123 flat: { 124 124 type: 'boolean', 125 125 description: 126 - "If true, the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false (default), the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure.", 126 + "If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure.", 127 + }, 128 + }, 129 + }, 130 + }, 131 + }, 132 + PlaceWispSettings: { 133 + lexicon: 1, 134 + id: 'place.wisp.settings', 135 + defs: { 136 + main: { 137 + type: 'record', 138 + description: 139 + 'Configuration settings for a static site hosted on wisp.place', 140 + key: 'any', 141 + record: { 142 + type: 'object', 143 + properties: { 144 + directoryListing: { 145 + type: 'boolean', 146 + description: 147 + 'Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.', 148 + default: false, 149 + }, 150 + spaMode: { 151 + type: 'string', 152 + description: 153 + "File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.", 154 + maxLength: 500, 155 + }, 156 + custom404: { 157 + type: 'string', 158 + description: 159 + 'Custom 404 error page file path. Incompatible with directoryListing and spaMode.', 160 + maxLength: 500, 161 + }, 162 + indexFiles: { 163 + type: 'array', 164 + description: 165 + "Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.", 166 + items: { 167 + type: 'string', 168 + maxLength: 255, 169 + }, 170 + maxLength: 10, 171 + }, 172 + cleanUrls: { 173 + type: 'boolean', 174 + description: 175 + "Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.", 176 + default: false, 177 + }, 178 + headers: { 179 + type: 'array', 180 + description: 'Custom HTTP headers to set on responses', 181 + items: { 182 + type: 'ref', 183 + ref: 'lex:place.wisp.settings#customHeader', 184 + }, 185 + maxLength: 50, 186 + }, 187 + }, 188 + }, 189 + }, 190 + customHeader: { 191 + type: 'object', 192 + description: 'Custom HTTP header configuration', 193 + required: ['name', 'value'], 194 + properties: { 195 + name: { 196 + type: 'string', 197 + description: 198 + "HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')", 199 + maxLength: 100, 200 + }, 201 + value: { 202 + type: 'string', 203 + description: 'HTTP header value', 204 + maxLength: 1000, 205 + }, 206 + path: { 207 + type: 'string', 208 + description: 209 + "Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.", 210 + maxLength: 500, 127 211 }, 128 212 }, 129 213 }, ··· 275 359 276 360 export const ids = { 277 361 PlaceWispFs: 'place.wisp.fs', 362 + PlaceWispSettings: 'place.wisp.settings', 278 363 PlaceWispSubfs: 'place.wisp.subfs', 279 364 } as const
+1 -1
src/lexicons/types/place/wisp/fs.ts
··· 95 95 type: 'subfs' 96 96 /** AT-URI pointing to a place.wisp.subfs record containing this subtree. */ 97 97 subject: string 98 - /** If true, the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false (default), the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. */ 98 + /** If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. */ 99 99 flat?: boolean 100 100 } 101 101
+65
src/lexicons/types/place/wisp/settings.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../lexicons' 7 + import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'place.wisp.settings' 12 + 13 + export interface Main { 14 + $type: 'place.wisp.settings' 15 + /** Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. */ 16 + directoryListing: boolean 17 + /** File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404. */ 18 + spaMode?: string 19 + /** Custom 404 error page file path. Incompatible with directoryListing and spaMode. */ 20 + custom404?: string 21 + /** Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. */ 22 + indexFiles?: string[] 23 + /** Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. */ 24 + cleanUrls: boolean 25 + /** Custom HTTP headers to set on responses */ 26 + headers?: CustomHeader[] 27 + [k: string]: unknown 28 + } 29 + 30 + const hashMain = 'main' 31 + 32 + export function isMain<V>(v: V) { 33 + return is$typed(v, id, hashMain) 34 + } 35 + 36 + export function validateMain<V>(v: V) { 37 + return validate<Main & V>(v, id, hashMain, true) 38 + } 39 + 40 + export { 41 + type Main as Record, 42 + isMain as isRecord, 43 + validateMain as validateRecord, 44 + } 45 + 46 + /** Custom HTTP header configuration */ 47 + export interface CustomHeader { 48 + $type?: 'place.wisp.settings#customHeader' 49 + /** HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') */ 50 + name: string 51 + /** HTTP header value */ 52 + value: string 53 + /** Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. */ 54 + path?: string 55 + } 56 + 57 + const hashCustomHeader = 'customHeader' 58 + 59 + export function isCustomHeader<V>(v: V) { 60 + return is$typed(v, id, hashCustomHeader) 61 + } 62 + 63 + export function validateCustomHeader<V>(v: V) { 64 + return validate<CustomHeader & V>(v, id, hashCustomHeader) 65 + }
+2 -2
src/lib/oauth-client.ts
··· 110 110 // Loopback client for local development 111 111 // For loopback, scopes and redirect_uri must be in client_id query string 112 112 const redirectUri = 'http://127.0.0.1:8000/api/auth/callback'; 113 - const scope = 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview'; 113 + const scope = 'atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview'; 114 114 const params = new URLSearchParams(); 115 115 params.append('redirect_uri', redirectUri); 116 116 params.append('scope', scope); ··· 145 145 application_type: 'web', 146 146 token_endpoint_auth_method: 'private_key_jwt', 147 147 token_endpoint_auth_signing_alg: "ES256", 148 - scope: "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview", 148 + scope: "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings blob:*/* rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app#bsky_appview", 149 149 dpop_bound_access_tokens: true, 150 150 jwks_uri: `${config.domain}/jwks.json`, 151 151 subject_type: 'public',
+7 -1
src/routes/admin.ts
··· 5 5 import { db } from '../lib/db' 6 6 7 7 export const adminRoutes = (cookieSecret: string) => 8 - new Elysia({ prefix: '/api/admin' }) 8 + new Elysia({ 9 + prefix: '/api/admin', 10 + cookie: { 11 + secrets: cookieSecret, 12 + sign: ['admin_session'] 13 + } 14 + }) 9 15 // Login 10 16 .post( 11 17 '/login',
+108
src/routes/site.ts
··· 118 118 } 119 119 } 120 120 }) 121 + .get('/:rkey/settings', async ({ params, auth }) => { 122 + const { rkey } = params 123 + 124 + if (!rkey) { 125 + return { 126 + success: false, 127 + error: 'Site rkey is required' 128 + } 129 + } 130 + 131 + try { 132 + // Create agent with OAuth session 133 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 134 + 135 + // Fetch settings record 136 + try { 137 + const record = await agent.com.atproto.repo.getRecord({ 138 + repo: auth.did, 139 + collection: 'place.wisp.settings', 140 + rkey: rkey 141 + }) 142 + 143 + if (record.data.value) { 144 + return record.data.value 145 + } 146 + } catch (err: any) { 147 + // Record doesn't exist, return defaults 148 + if (err?.error === 'RecordNotFound') { 149 + return { 150 + indexFiles: ['index.html'], 151 + cleanUrls: false, 152 + directoryListing: false 153 + } 154 + } 155 + throw err 156 + } 157 + 158 + // Default settings 159 + return { 160 + indexFiles: ['index.html'], 161 + cleanUrls: false, 162 + directoryListing: false 163 + } 164 + } catch (err) { 165 + logger.error('[Site] Get settings error', err) 166 + return { 167 + success: false, 168 + error: err instanceof Error ? err.message : 'Failed to fetch settings' 169 + } 170 + } 171 + }) 172 + .post('/:rkey/settings', async ({ params, body, auth }) => { 173 + const { rkey } = params 174 + 175 + if (!rkey) { 176 + return { 177 + success: false, 178 + error: 'Site rkey is required' 179 + } 180 + } 181 + 182 + // Validate settings 183 + const settings = body as any 184 + 185 + // Ensure mutual exclusivity of routing modes 186 + const modes = [ 187 + settings.spaMode, 188 + settings.directoryListing, 189 + settings.custom404 190 + ].filter(Boolean) 191 + 192 + if (modes.length > 1) { 193 + return { 194 + success: false, 195 + error: 'Only one of spaMode, directoryListing, or custom404 can be enabled' 196 + } 197 + } 198 + 199 + try { 200 + // Create agent with OAuth session 201 + const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 202 + 203 + // Create or update settings record 204 + const record = await agent.com.atproto.repo.putRecord({ 205 + repo: auth.did, 206 + collection: 'place.wisp.settings', 207 + rkey: rkey, 208 + record: { 209 + $type: 'place.wisp.settings', 210 + ...settings 211 + } 212 + }) 213 + 214 + logger.info(`[Site] Saved settings for ${rkey} (${auth.did})`) 215 + 216 + return { 217 + success: true, 218 + uri: record.data.uri, 219 + cid: record.data.cid 220 + } 221 + } catch (err) { 222 + logger.error('[Site] Save settings error', err) 223 + return { 224 + success: false, 225 + error: err instanceof Error ? err.message : 'Failed to save settings' 226 + } 227 + } 228 + })
+95 -1
src/routes/wisp.ts
··· 166 166 currentFile: file.name 167 167 }); 168 168 169 - // Skip .git directory files 169 + // Skip unwanted files and directories 170 170 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 171 + const fileName = normalizedPath.split('/').pop() || ''; 172 + const pathParts = normalizedPath.split('/'); 173 + 174 + // .git directory (version control - thousands of files) 171 175 if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') { 172 176 console.log(`Skipping .git file: ${file.name}`); 173 177 skippedFiles.push({ 174 178 name: file.name, 175 179 reason: '.git directory excluded' 180 + }); 181 + continue; 182 + } 183 + 184 + // .DS_Store (macOS metadata - can leak info) 185 + if (fileName === '.DS_Store') { 186 + console.log(`Skipping .DS_Store file: ${file.name}`); 187 + skippedFiles.push({ 188 + name: file.name, 189 + reason: '.DS_Store file excluded' 190 + }); 191 + continue; 192 + } 193 + 194 + // .env files (environment variables with secrets) 195 + if (fileName.startsWith('.env')) { 196 + console.log(`Skipping .env file: ${file.name}`); 197 + skippedFiles.push({ 198 + name: file.name, 199 + reason: 'environment files excluded for security' 200 + }); 201 + continue; 202 + } 203 + 204 + // node_modules (dependency folder - can be 100,000+ files) 205 + if (pathParts.includes('node_modules')) { 206 + console.log(`Skipping node_modules file: ${file.name}`); 207 + skippedFiles.push({ 208 + name: file.name, 209 + reason: 'node_modules excluded' 210 + }); 211 + continue; 212 + } 213 + 214 + // OS metadata files 215 + if (fileName === 'Thumbs.db' || fileName === 'desktop.ini' || fileName.startsWith('._')) { 216 + console.log(`Skipping OS metadata file: ${file.name}`); 217 + skippedFiles.push({ 218 + name: file.name, 219 + reason: 'OS metadata file excluded' 220 + }); 221 + continue; 222 + } 223 + 224 + // macOS system directories 225 + if (pathParts.includes('.Spotlight-V100') || pathParts.includes('.Trashes') || pathParts.includes('.fseventsd')) { 226 + console.log(`Skipping macOS system file: ${file.name}`); 227 + skippedFiles.push({ 228 + name: file.name, 229 + reason: 'macOS system directory excluded' 230 + }); 231 + continue; 232 + } 233 + 234 + // Cache and temp directories 235 + if (pathParts.some(part => part === '.cache' || part === '.temp' || part === '.tmp')) { 236 + console.log(`Skipping cache/temp file: ${file.name}`); 237 + skippedFiles.push({ 238 + name: file.name, 239 + reason: 'cache/temp directory excluded' 240 + }); 241 + continue; 242 + } 243 + 244 + // Python cache 245 + if (pathParts.includes('__pycache__') || fileName.endsWith('.pyc')) { 246 + console.log(`Skipping Python cache file: ${file.name}`); 247 + skippedFiles.push({ 248 + name: file.name, 249 + reason: 'Python cache excluded' 250 + }); 251 + continue; 252 + } 253 + 254 + // Python virtual environments 255 + if (pathParts.some(part => part === '.venv' || part === 'venv' || part === 'env')) { 256 + console.log(`Skipping Python venv file: ${file.name}`); 257 + skippedFiles.push({ 258 + name: file.name, 259 + reason: 'Python virtual environment excluded' 260 + }); 261 + continue; 262 + } 263 + 264 + // Editor swap files 265 + if (fileName.endsWith('.swp') || fileName.endsWith('.swo') || fileName.endsWith('~')) { 266 + console.log(`Skipping editor swap file: ${file.name}`); 267 + skippedFiles.push({ 268 + name: file.name, 269 + reason: 'editor swap file excluded' 176 270 }); 177 271 continue; 178 272 }