+51
hosting-service/debug-settings.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
}