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
···
+
#!/usr/bin/env tsx
+
/**
+
* Debug script to check cached settings for a site
+
* Usage: tsx debug-settings.ts <did> <rkey>
+
*/
+
+
import { readFile } from 'fs/promises';
+
import { existsSync } from 'fs';
+
+
const CACHE_DIR = './cache';
+
+
async function debugSettings(did: string, rkey: string) {
+
const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
+
+
console.log('Checking metadata at:', metadataPath);
+
console.log('Exists:', existsSync(metadataPath));
+
+
if (!existsSync(metadataPath)) {
+
console.log('\n❌ Metadata file does not exist - site may not be cached yet');
+
return;
+
}
+
+
const content = await readFile(metadataPath, 'utf-8');
+
const metadata = JSON.parse(content);
+
+
console.log('\n=== Cached Metadata ===');
+
console.log('CID:', metadata.cid);
+
console.log('Cached at:', metadata.cachedAt);
+
console.log('\n=== Settings ===');
+
if (metadata.settings) {
+
console.log(JSON.stringify(metadata.settings, null, 2));
+
} else {
+
console.log('❌ No settings found in metadata');
+
console.log('This means:');
+
console.log(' 1. No place.wisp.settings record exists on the PDS');
+
console.log(' 2. Or the firehose hasn\'t picked up the settings yet');
+
console.log('\nTo fix:');
+
console.log(' 1. Create a place.wisp.settings record with the same rkey');
+
console.log(' 2. Wait for firehose to pick it up (a few seconds)');
+
console.log(' 3. Or manually re-cache the site');
+
}
+
}
+
+
const [did, rkey] = process.argv.slice(2);
+
if (!did || !rkey) {
+
console.log('Usage: tsx debug-settings.ts <did> <rkey>');
+
console.log('Example: tsx debug-settings.ts did:plc:abc123 my-site');
+
process.exit(1);
+
}
+
+
debugSettings(did, rkey).catch(console.error);
+86 -1
hosting-service/src/lexicon/lexicons.ts
···
flat: {
type: 'boolean',
description:
-
"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.",
+
"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.",
+
},
+
},
+
},
+
},
+
},
+
PlaceWispSettings: {
+
lexicon: 1,
+
id: 'place.wisp.settings',
+
defs: {
+
main: {
+
type: 'record',
+
description:
+
'Configuration settings for a static site hosted on wisp.place',
+
key: 'any',
+
record: {
+
type: 'object',
+
properties: {
+
directoryListing: {
+
type: 'boolean',
+
description:
+
'Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.',
+
default: false,
+
},
+
spaMode: {
+
type: 'string',
+
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.",
+
maxLength: 500,
+
},
+
custom404: {
+
type: 'string',
+
description:
+
'Custom 404 error page file path. Incompatible with directoryListing and spaMode.',
+
maxLength: 500,
+
},
+
indexFiles: {
+
type: 'array',
+
description:
+
"Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.",
+
items: {
+
type: 'string',
+
maxLength: 255,
+
},
+
maxLength: 10,
+
},
+
cleanUrls: {
+
type: 'boolean',
+
description:
+
"Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.",
+
default: false,
+
},
+
headers: {
+
type: 'array',
+
description: 'Custom HTTP headers to set on responses',
+
items: {
+
type: 'ref',
+
ref: 'lex:place.wisp.settings#customHeader',
+
},
+
maxLength: 50,
+
},
+
},
+
},
+
},
+
customHeader: {
+
type: 'object',
+
description: 'Custom HTTP header configuration',
+
required: ['name', 'value'],
+
properties: {
+
name: {
+
type: 'string',
+
description:
+
"HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')",
+
maxLength: 100,
+
},
+
value: {
+
type: 'string',
+
description: 'HTTP header value',
+
maxLength: 1000,
+
},
+
path: {
+
type: 'string',
+
description:
+
"Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.",
+
maxLength: 500,
},
},
},
···
export const ids = {
PlaceWispFs: 'place.wisp.fs',
+
PlaceWispSettings: 'place.wisp.settings',
PlaceWispSubfs: 'place.wisp.subfs',
} as const
+1 -1
hosting-service/src/lexicon/types/place/wisp/fs.ts
···
type: 'subfs'
/** AT-URI pointing to a place.wisp.subfs record containing this subtree. */
subject: string
-
/** 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. */
+
/** 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. */
flat?: boolean
}
+65
hosting-service/src/lexicon/types/place/wisp/settings.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
+
import { CID } from 'multiformats/cid'
+
import { validate as _validate } from '../../../lexicons'
+
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+
+
const is$typed = _is$typed,
+
validate = _validate
+
const id = 'place.wisp.settings'
+
+
export interface Main {
+
$type: 'place.wisp.settings'
+
/** Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. */
+
directoryListing: boolean
+
/** 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. */
+
spaMode?: string
+
/** Custom 404 error page file path. Incompatible with directoryListing and spaMode. */
+
custom404?: string
+
/** Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. */
+
indexFiles?: string[]
+
/** Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. */
+
cleanUrls: boolean
+
/** Custom HTTP headers to set on responses */
+
headers?: CustomHeader[]
+
[k: string]: unknown
+
}
+
+
const hashMain = 'main'
+
+
export function isMain<V>(v: V) {
+
return is$typed(v, id, hashMain)
+
}
+
+
export function validateMain<V>(v: V) {
+
return validate<Main & V>(v, id, hashMain, true)
+
}
+
+
export {
+
type Main as Record,
+
isMain as isRecord,
+
validateMain as validateRecord,
+
}
+
+
/** Custom HTTP header configuration */
+
export interface CustomHeader {
+
$type?: 'place.wisp.settings#customHeader'
+
/** HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') */
+
name: string
+
/** HTTP header value */
+
value: string
+
/** Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. */
+
path?: string
+
}
+
+
const hashCustomHeader = 'customHeader'
+
+
export function isCustomHeader<V>(v: V) {
+
return is$typed(v, id, hashCustomHeader)
+
}
+
+
export function validateCustomHeader<V>(v: V) {
+
return validate<CustomHeader & V>(v, id, hashCustomHeader)
+
}
+65 -1
hosting-service/src/lib/firehose.ts
···
this.firehose = new Firehose({
idResolver: this.idResolver,
service: 'wss://bsky.network',
-
filterCollections: ['place.wisp.fs'],
+
filterCollections: ['place.wisp.fs', 'place.wisp.settings'],
handleEvent: async (evt: any) => {
this.lastEventTime = Date.now()
···
})
}
}
+
// Handle settings changes
+
else if (evt.collection === 'place.wisp.settings') {
+
this.log('Received place.wisp.settings event', {
+
did: evt.did,
+
event: evt.event,
+
rkey: evt.rkey
+
})
+
+
try {
+
await this.handleSettingsChange(evt.did, evt.rkey)
+
} catch (err) {
+
this.log('Error handling settings change', {
+
did: evt.did,
+
event: evt.event,
+
rkey: evt.rkey,
+
error:
+
err instanceof Error
+
? err.message
+
: String(err)
+
})
+
}
+
}
} else if (
evt.event === 'delete' &&
evt.collection === 'place.wisp.fs'
···
await this.handleDelete(evt.did, evt.rkey)
} catch (err) {
this.log('Error handling delete', {
+
did: evt.did,
+
rkey: evt.rkey,
+
error:
+
err instanceof Error ? err.message : String(err)
+
})
+
}
+
} else if (
+
evt.event === 'delete' &&
+
evt.collection === 'place.wisp.settings'
+
) {
+
this.log('Received settings delete event', {
+
did: evt.did,
+
rkey: evt.rkey
+
})
+
+
try {
+
await this.handleSettingsChange(evt.did, evt.rkey)
+
} catch (err) {
+
this.log('Error handling settings delete', {
did: evt.did,
rkey: evt.rkey,
error:
···
this.deleteCache(did, site)
this.log('Successfully processed delete', { did, site })
+
}
+
+
private async handleSettingsChange(did: string, rkey: string) {
+
this.log('Processing settings change', { did, rkey })
+
+
// Invalidate in-memory caches (includes metadata which stores settings)
+
invalidateSiteCache(did, rkey)
+
+
// Update on-disk metadata with new settings
+
try {
+
const { fetchSiteSettings, updateCacheMetadataSettings } = await import('./utils')
+
const settings = await fetchSiteSettings(did, rkey)
+
await updateCacheMetadataSettings(did, rkey, settings)
+
this.log('Updated cached settings', { did, rkey, hasSettings: !!settings })
+
} catch (err) {
+
this.log('Failed to update cached settings', {
+
did,
+
rkey,
+
error: err instanceof Error ? err.message : String(err)
+
})
+
}
+
+
this.log('Successfully processed settings change', { did, rkey })
}
private deleteCache(did: string, site: string) {
+55 -3
hosting-service/src/lib/utils.ts
···
import { AtpAgent } from '@atproto/api';
import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs';
import type { Record as SubfsRecord } from '../lexicon/types/place/wisp/subfs';
+
import type { Record as WispSettings } from '../lexicon/types/place/wisp/settings';
import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
import { writeFile, readFile, rename } from 'fs/promises';
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
···
rkey: string;
// Map of file path to blob CID for incremental updates
fileCids?: Record<string, string>;
+
// Site settings
+
settings?: WispSettings;
}
/**
···
}
}
+
export async function fetchSiteSettings(did: string, rkey: string): Promise<WispSettings | null> {
+
try {
+
const pdsEndpoint = await getPdsForDid(did);
+
if (!pdsEndpoint) return null;
+
+
const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.settings&rkey=${encodeURIComponent(rkey)}`;
+
const data = await safeFetchJson(url);
+
+
return data.value as WispSettings;
+
} catch (err) {
+
// Settings are optional, so return null if not found
+
return null;
+
}
+
}
+
export function extractBlobCid(blobRef: unknown): string | null {
if (isIpldLink(blobRef)) {
return blobRef.$link;
···
const newFileCids: Record<string, string> = {};
collectFileCidsFromEntries(expandedRoot.entries, '', newFileCids);
+
// Fetch site settings (optional)
+
const settings = await fetchSiteSettings(did, rkey);
+
// Download/copy files to temporary directory (with incremental logic, using expanded root)
await cacheFiles(did, rkey, expandedRoot.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir);
-
await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids);
+
await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids, settings);
// Atomically replace old cache with new cache
// On POSIX systems (Linux/macOS), rename is atomic
···
return existsSync(`${CACHE_DIR}/${did}/${site}`);
}
-
async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>): Promise<void> {
+
async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>, settings?: WispSettings | null): Promise<void> {
const metadata: CacheMetadata = {
recordCid,
cachedAt: Date.now(),
did,
rkey,
-
fileCids
+
fileCids,
+
settings: settings || undefined
};
const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`;
···
} catch (err) {
console.error('Failed to read cache metadata', err);
return null;
+
}
+
}
+
+
export async function getCachedSettings(did: string, rkey: string): Promise<WispSettings | null> {
+
const metadata = await getCacheMetadata(did, rkey);
+
return metadata?.settings || null;
+
}
+
+
export async function updateCacheMetadataSettings(did: string, rkey: string, settings: WispSettings | null): Promise<void> {
+
const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
+
+
if (!existsSync(metadataPath)) {
+
console.warn('Metadata file does not exist, cannot update settings', { did, rkey });
+
return;
+
}
+
+
try {
+
// Read existing metadata
+
const content = await readFile(metadataPath, 'utf-8');
+
const metadata = JSON.parse(content) as CacheMetadata;
+
+
// Update settings field
+
metadata.settings = settings || undefined;
+
+
// Write back to disk
+
await writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
+
console.log('Updated metadata settings', { did, rkey, hasSettings: !!settings });
+
} catch (err) {
+
console.error('Failed to update metadata settings', err);
+
throw err;
}
}
+613 -103
hosting-service/src/server.ts
···
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getWispDomain, getCustomDomain, getCustomDomainByHash } from './lib/db';
-
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType } from './lib/utils';
+
import { resolveDid, getPdsForDid, fetchSiteRecord, downloadAndCacheSite, getCachedFilePath, isCached, sanitizePath, shouldCompressMimeType, getCachedSettings } from './lib/utils';
+
import type { Record as WispSettings } from './lexicon/types/place/wisp/settings';
import { rewriteHtmlPaths, isHtmlContent } from './lib/html-rewriter';
import { existsSync } from 'fs';
import { readFile, access } from 'fs/promises';
···
const BASE_HOST = process.env.BASE_HOST || 'wisp.place';
/**
-
* Configurable index file names to check for directory requests
+
* Default index file names to check for directory requests
* Will be checked in order until one is found
*/
-
const INDEX_FILES = ['index.html', 'index.htm'];
+
const DEFAULT_INDEX_FILES = ['index.html', 'index.htm'];
+
+
/**
+
* Get index files list from settings or use defaults
+
*/
+
function getIndexFiles(settings: WispSettings | null): string[] {
+
if (settings?.indexFiles && settings.indexFiles.length > 0) {
+
return settings.indexFiles;
+
}
+
return DEFAULT_INDEX_FILES;
+
}
+
+
/**
+
* Match a file path against a glob pattern
+
* Supports * wildcard and basic path matching
+
*/
+
function matchGlob(path: string, pattern: string): boolean {
+
// Normalize paths
+
const normalizedPath = path.startsWith('/') ? path : '/' + path;
+
const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
+
+
// Convert glob pattern to regex
+
const regexPattern = normalizedPattern
+
.replace(/\./g, '\\.')
+
.replace(/\*/g, '.*')
+
.replace(/\?/g, '.');
+
+
const regex = new RegExp('^' + regexPattern + '$');
+
return regex.test(normalizedPath);
+
}
+
+
/**
+
* Apply custom headers from settings to response headers
+
*/
+
function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) {
+
if (!settings?.headers || settings.headers.length === 0) return;
+
+
for (const customHeader of settings.headers) {
+
// If path glob is specified, check if it matches
+
if (customHeader.path) {
+
if (!matchGlob(filePath, customHeader.path)) {
+
continue;
+
}
+
}
+
// Apply the header
+
headers[customHeader.name] = customHeader.value;
+
}
+
}
+
+
/**
+
* Generate 404 page HTML
+
*/
+
function generate404Page(): string {
+
const html = `<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<title>404 - Not Found</title>
+
<style>
+
@media (prefers-color-scheme: light) {
+
:root {
+
/* Warm beige background */
+
--background: oklch(0.90 0.012 35);
+
/* Very dark brown text */
+
--foreground: oklch(0.18 0.01 30);
+
--border: oklch(0.75 0.015 30);
+
/* Bright pink accent for links */
+
--accent: oklch(0.78 0.15 345);
+
}
+
}
+
@media (prefers-color-scheme: dark) {
+
:root {
+
/* Slate violet background */
+
--background: oklch(0.23 0.015 285);
+
/* Light gray text */
+
--foreground: oklch(0.90 0.005 285);
+
/* Subtle borders */
+
--border: oklch(0.38 0.02 285);
+
/* Soft pink accent */
+
--accent: oklch(0.85 0.08 5);
+
}
+
}
+
body {
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+
background: var(--background);
+
color: var(--foreground);
+
padding: 2rem;
+
max-width: 800px;
+
margin: 0 auto;
+
display: flex;
+
flex-direction: column;
+
min-height: 100vh;
+
justify-content: center;
+
align-items: center;
+
text-align: center;
+
}
+
h1 {
+
font-size: 6rem;
+
margin: 0;
+
font-weight: 700;
+
line-height: 1;
+
}
+
h2 {
+
font-size: 1.5rem;
+
margin: 1rem 0 2rem;
+
font-weight: 400;
+
opacity: 0.8;
+
}
+
p {
+
font-size: 1rem;
+
opacity: 0.7;
+
margin-bottom: 2rem;
+
}
+
a {
+
color: var(--accent);
+
text-decoration: none;
+
font-size: 1rem;
+
}
+
a:hover {
+
text-decoration: underline;
+
}
+
footer {
+
margin-top: 3rem;
+
padding-top: 1.5rem;
+
border-top: 1px solid var(--border);
+
text-align: center;
+
font-size: 0.875rem;
+
opacity: 0.7;
+
color: var(--foreground);
+
}
+
footer a {
+
color: var(--accent);
+
text-decoration: none;
+
display: inline;
+
}
+
footer a:hover {
+
text-decoration: underline;
+
}
+
</style>
+
</head>
+
<body>
+
<div>
+
<h1>404</h1>
+
<h2>Page not found</h2>
+
<p>The page you're looking for doesn't exist.</p>
+
<a href="/">← Back to home</a>
+
</div>
+
<footer>
+
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>
+
</footer>
+
</body>
+
</html>`;
+
return html;
+
}
+
+
/**
+
* Generate directory listing HTML
+
*/
+
function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string {
+
const title = path || 'Index';
+
+
// Sort: directories first, then files, alphabetically within each group
+
const sortedEntries = [...entries].sort((a, b) => {
+
if (a.isDirectory && !b.isDirectory) return -1;
+
if (!a.isDirectory && b.isDirectory) return 1;
+
return a.name.localeCompare(b.name);
+
});
+
+
const html = `<!DOCTYPE html>
+
<html>
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<title>Index of /${path}</title>
+
<style>
+
@media (prefers-color-scheme: light) {
+
:root {
+
/* Warm beige background */
+
--background: oklch(0.90 0.012 35);
+
/* Very dark brown text */
+
--foreground: oklch(0.18 0.01 30);
+
--border: oklch(0.75 0.015 30);
+
/* Bright pink accent for links */
+
--accent: oklch(0.78 0.15 345);
+
/* Lavender for folders */
+
--folder: oklch(0.60 0.12 295);
+
--icon: oklch(0.28 0.01 30);
+
}
+
}
+
@media (prefers-color-scheme: dark) {
+
:root {
+
/* Slate violet background */
+
--background: oklch(0.23 0.015 285);
+
/* Light gray text */
+
--foreground: oklch(0.90 0.005 285);
+
/* Subtle borders */
+
--border: oklch(0.38 0.02 285);
+
/* Soft pink accent */
+
--accent: oklch(0.85 0.08 5);
+
/* Lavender for folders */
+
--folder: oklch(0.70 0.10 295);
+
--icon: oklch(0.85 0.005 285);
+
}
+
}
+
body {
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+
background: var(--background);
+
color: var(--foreground);
+
padding: 2rem;
+
max-width: 800px;
+
margin: 0 auto;
+
}
+
h1 {
+
font-size: 1.5rem;
+
margin-bottom: 2rem;
+
padding-bottom: 0.5rem;
+
border-bottom: 1px solid var(--border);
+
}
+
ul {
+
list-style: none;
+
padding: 0;
+
}
+
li {
+
padding: 0.5rem 0;
+
border-bottom: 1px solid var(--border);
+
}
+
li a {
+
color: var(--accent);
+
text-decoration: none;
+
display: flex;
+
align-items: center;
+
gap: 0.75rem;
+
}
+
li a:hover {
+
text-decoration: underline;
+
}
+
.folder {
+
color: var(--folder);
+
font-weight: 600;
+
}
+
.file {
+
color: var(--accent);
+
}
+
.folder::before,
+
.file::before,
+
.parent::before {
+
content: "";
+
display: inline-block;
+
width: 1.25em;
+
height: 1.25em;
+
background-color: var(--icon);
+
flex-shrink: 0;
+
-webkit-mask-size: contain;
+
mask-size: contain;
+
-webkit-mask-repeat: no-repeat;
+
mask-repeat: no-repeat;
+
-webkit-mask-position: center;
+
mask-position: center;
+
}
+
.folder::before {
+
-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>');
+
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>');
+
}
+
.file::before {
+
-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>');
+
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>');
+
}
+
.parent::before {
+
-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>');
+
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>');
+
}
+
footer {
+
margin-top: 3rem;
+
padding-top: 1.5rem;
+
border-top: 1px solid var(--border);
+
text-align: center;
+
font-size: 0.875rem;
+
opacity: 0.7;
+
color: var(--foreground);
+
}
+
footer a {
+
color: var(--accent);
+
text-decoration: none;
+
display: inline;
+
}
+
footer a:hover {
+
text-decoration: underline;
+
}
+
</style>
+
</head>
+
<body>
+
<h1>Index of /${path}</h1>
+
<ul>
+
${path ? '<li><a href="../" class="parent">../</a></li>' : ''}
+
${sortedEntries.map(e =>
+
`<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>`
+
).join('\n ')}
+
</ul>
+
<footer>
+
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>
+
</footer>
+
</body>
+
</html>`;
+
return html;
+
}
/**
* Validate site name (rkey) to prevent injection attacks
···
// Helper to serve files from cache
async function serveFromCache(
-
did: string,
-
rkey: string,
+
did: string,
+
rkey: string,
filePath: string,
fullUrl?: string,
headers?: Record<string, string>
) {
-
// Check for redirect rules first
+
// Load settings for this site
+
const settings = await getCachedSettings(did, rkey);
+
const indexFiles = getIndexFiles(settings);
+
+
// Check for redirect rules first (_redirects wins over settings)
const redirectCacheKey = `${did}:${rkey}`;
let redirectRules = redirectRulesCache.get(redirectCacheKey);
-
+
if (redirectRules === undefined) {
// Load rules for the first time
redirectRules = await loadRedirectRules(did, rkey);
···
// If not forced, check if the requested file exists before redirecting
if (!rule.force) {
// Build the expected file path
-
let checkPath = filePath || INDEX_FILES[0];
+
let checkPath = filePath || indexFiles[0];
if (checkPath.endsWith('/')) {
-
checkPath += INDEX_FILES[0];
+
checkPath += indexFiles[0];
}
const cachedFile = getCachedFilePath(did, rkey, checkPath);
···
// If file exists and redirect is not forced, serve the file normally
if (fileExistsOnDisk) {
-
return serveFileInternal(did, rkey, filePath);
+
return serveFileInternal(did, rkey, filePath, settings);
}
}
···
// Rewrite: serve different content but keep URL the same
// Remove leading slash for internal path resolution
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
return serveFileInternal(did, rkey, rewritePath);
+
return serveFileInternal(did, rkey, rewritePath, settings);
} else if (status === 301 || status === 302) {
// External redirect: change the URL
return new Response(null, {
···
},
});
} else if (status === 404) {
-
// Custom 404 page
+
// Custom 404 page from _redirects (wins over settings.custom404)
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
const response = await serveFileInternal(did, rkey, custom404Path);
+
const response = await serveFileInternal(did, rkey, custom404Path, settings);
// Override status to 404
return new Response(response.body, {
status: 404,
···
}
}
-
// No redirect matched, serve normally
-
return serveFileInternal(did, rkey, filePath);
+
// No redirect matched, serve normally with settings
+
return serveFileInternal(did, rkey, filePath, settings);
}
// Internal function to serve a file (used by both normal serving and rewrites)
-
async function serveFileInternal(did: string, rkey: string, filePath: string) {
+
async function serveFileInternal(did: string, rkey: string, filePath: string, settings: WispSettings | null = null) {
// Check if site is currently being cached - if so, return updating response
if (isSiteBeingCached(did, rkey)) {
return siteUpdatingResponse();
}
-
// Default to first index file if path is empty
-
let requestPath = filePath || INDEX_FILES[0];
+
const indexFiles = getIndexFiles(settings);
-
// If path ends with /, append first index file
-
if (requestPath.endsWith('/')) {
-
requestPath += INDEX_FILES[0];
+
// Normalize the request path (keep empty for root, remove trailing slash for others)
+
let requestPath = filePath || '';
+
if (requestPath.endsWith('/') && requestPath.length > 1) {
+
requestPath = requestPath.slice(0, -1);
}
-
const cacheKey = getCacheKey(did, rkey, requestPath);
-
const cachedFile = getCachedFilePath(did, rkey, requestPath);
-
-
// Check if the cached file path is a directory
-
if (await fileExists(cachedFile)) {
-
const { stat } = await import('fs/promises');
+
// Check if this path is a directory first
+
const directoryPath = getCachedFilePath(did, rkey, requestPath);
+
if (await fileExists(directoryPath)) {
+
const { stat, readdir } = await import('fs/promises');
try {
-
const stats = await stat(cachedFile);
+
const stats = await stat(directoryPath);
if (stats.isDirectory()) {
// It's a directory, try each index file in order
-
for (const indexFile of INDEX_FILES) {
-
const indexPath = `${requestPath}/${indexFile}`;
+
for (const indexFile of indexFiles) {
+
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
if (await fileExists(indexFilePath)) {
-
return serveFileInternal(did, rkey, indexPath);
+
return serveFileInternal(did, rkey, indexPath, settings);
}
}
-
// No index file found, fall through to 404
+
// No index file found - check if directory listing is enabled
+
if (settings?.directoryListing) {
+
const { stat } = await import('fs/promises');
+
const entries = await readdir(directoryPath);
+
// Filter out .meta files and other hidden files
+
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
+
+
// Check which entries are directories
+
const entriesWithType = await Promise.all(
+
visibleEntries.map(async (name) => {
+
try {
+
const entryPath = `${directoryPath}/${name}`;
+
const stats = await stat(entryPath);
+
return { name, isDirectory: stats.isDirectory() };
+
} catch {
+
return { name, isDirectory: false };
+
}
+
})
+
);
+
+
const html = generateDirectoryListing(requestPath, entriesWithType);
+
return new Response(html, {
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
// Fall through to 404/SPA handling
}
} catch (err) {
// If stat fails, continue with normal flow
}
}
+
// Not a directory, try to serve as a file
+
const fileRequestPath = requestPath || indexFiles[0];
+
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
+
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
+
// Check in-memory cache first
let content = fileCache.get(cacheKey);
let meta = metadataCache.get(cacheKey);
···
const decompressed = gunzipSync(content);
headers['Content-Type'] = meta.mimeType;
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
return new Response(decompressed, { headers });
} else {
// Meta says gzipped but content isn't - serve as-is
console.warn(`File ${filePath} has gzip encoding in meta but content lacks gzip magic bytes`);
headers['Content-Type'] = meta.mimeType;
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
return new Response(content, { headers });
}
}
···
headers['Cache-Control'] = meta.mimeType.startsWith('text/html')
? 'public, max-age=300'
: 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
return new Response(content, { headers });
}
···
headers['Cache-Control'] = mimeType.startsWith('text/html')
? 'public, max-age=300'
: 'public, max-age=31536000, immutable';
+
applyCustomHeaders(headers, fileRequestPath, settings);
return new Response(content, { headers });
}
// Try index files for directory-like paths
-
if (!requestPath.includes('.')) {
-
for (const indexFileName of INDEX_FILES) {
-
const indexPath = `${requestPath}/${indexFileName}`;
+
if (!fileRequestPath.includes('.')) {
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
const indexCacheKey = getCacheKey(did, rkey, indexPath);
const indexFile = getCachedFilePath(did, rkey, indexPath);
···
headers['Content-Encoding'] = 'gzip';
}
+
applyCustomHeaders(headers, indexPath, settings);
return new Response(indexContent, { headers });
}
}
}
-
return new Response('Not Found', { status: 404 });
+
// Try clean URLs: /about -> /about.html
+
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
+
const htmlPath = `${fileRequestPath}.html`;
+
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
+
if (await fileExists(htmlFile)) {
+
return serveFileInternal(did, rkey, htmlPath, settings);
+
}
+
+
// Also try /about/index.html
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
+
if (await fileExists(indexFile)) {
+
return serveFileInternal(did, rkey, indexPath, settings);
+
}
+
}
+
}
+
+
// SPA mode: serve SPA file for all non-existing routes (wins over custom404 but loses to _redirects)
+
if (settings?.spaMode) {
+
const spaFile = settings.spaMode;
+
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
+
if (await fileExists(spaFilePath)) {
+
return serveFileInternal(did, rkey, spaFile, settings);
+
}
+
}
+
+
// Custom 404: serve custom 404 file if configured (wins conflict battle)
+
if (settings?.custom404) {
+
const custom404File = settings.custom404;
+
const custom404Path = getCachedFilePath(did, rkey, custom404File);
+
if (await fileExists(custom404Path)) {
+
const response = await serveFileInternal(did, rkey, custom404File, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
+
const auto404Pages = ['404.html', 'not_found.html'];
+
for (const auto404Page of auto404Pages) {
+
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
+
if (await fileExists(auto404Path)) {
+
const response = await serveFileInternal(did, rkey, auto404Page, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Default styled 404 page
+
const html = generate404Page();
+
return new Response(html, {
+
status: 404,
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
}
// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
···
fullUrl?: string,
headers?: Record<string, string>
) {
-
// Check for redirect rules first
+
// Load settings for this site
+
const settings = await getCachedSettings(did, rkey);
+
const indexFiles = getIndexFiles(settings);
+
+
// Check for redirect rules first (_redirects wins over settings)
const redirectCacheKey = `${did}:${rkey}`;
let redirectRules = redirectRulesCache.get(redirectCacheKey);
-
+
if (redirectRules === undefined) {
// Load rules for the first time
redirectRules = await loadRedirectRules(did, rkey);
···
// If not forced, check if the requested file exists before redirecting
if (!rule.force) {
// Build the expected file path
-
let checkPath = filePath || INDEX_FILES[0];
+
let checkPath = filePath || indexFiles[0];
if (checkPath.endsWith('/')) {
-
checkPath += INDEX_FILES[0];
+
checkPath += indexFiles[0];
}
const cachedFile = getCachedFilePath(did, rkey, checkPath);
···
// If file exists and redirect is not forced, serve the file normally
if (fileExistsOnDisk) {
-
return serveFileInternalWithRewrite(did, rkey, filePath, basePath);
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
}
}
···
if (status === 200) {
// Rewrite: serve different content but keep URL the same
const rewritePath = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath);
+
return serveFileInternalWithRewrite(did, rkey, rewritePath, basePath, settings);
} else if (status === 301 || status === 302) {
// External redirect: change the URL
// For sites.wisp.place, we need to adjust the target path to include the base path
···
},
});
} else if (status === 404) {
-
// Custom 404 page
+
// Custom 404 page from _redirects (wins over settings.custom404)
const custom404Path = targetPath.startsWith('/') ? targetPath.slice(1) : targetPath;
-
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath);
+
const response = await serveFileInternalWithRewrite(did, rkey, custom404Path, basePath, settings);
// Override status to 404
return new Response(response.body, {
status: 404,
···
}
}
-
// No redirect matched, serve normally
-
return serveFileInternalWithRewrite(did, rkey, filePath, basePath);
+
// No redirect matched, serve normally with settings
+
return serveFileInternalWithRewrite(did, rkey, filePath, basePath, settings);
}
// Internal function to serve a file with rewriting
-
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string) {
+
async function serveFileInternalWithRewrite(did: string, rkey: string, filePath: string, basePath: string, settings: WispSettings | null = null) {
// Check if site is currently being cached - if so, return updating response
if (isSiteBeingCached(did, rkey)) {
return siteUpdatingResponse();
}
-
// Default to first index file if path is empty
-
let requestPath = filePath || INDEX_FILES[0];
+
const indexFiles = getIndexFiles(settings);
-
// If path ends with /, append first index file
-
if (requestPath.endsWith('/')) {
-
requestPath += INDEX_FILES[0];
+
// Normalize the request path (keep empty for root, remove trailing slash for others)
+
let requestPath = filePath || '';
+
if (requestPath.endsWith('/') && requestPath.length > 1) {
+
requestPath = requestPath.slice(0, -1);
}
-
const cacheKey = getCacheKey(did, rkey, requestPath);
-
const cachedFile = getCachedFilePath(did, rkey, requestPath);
-
-
// Check if the cached file path is a directory
-
if (await fileExists(cachedFile)) {
-
const { stat } = await import('fs/promises');
+
// Check if this path is a directory first
+
const directoryPath = getCachedFilePath(did, rkey, requestPath);
+
if (await fileExists(directoryPath)) {
+
const { stat, readdir } = await import('fs/promises');
try {
-
const stats = await stat(cachedFile);
+
const stats = await stat(directoryPath);
if (stats.isDirectory()) {
// It's a directory, try each index file in order
-
for (const indexFile of INDEX_FILES) {
-
const indexPath = `${requestPath}/${indexFile}`;
+
for (const indexFile of indexFiles) {
+
const indexPath = requestPath ? `${requestPath}/${indexFile}` : indexFile;
const indexFilePath = getCachedFilePath(did, rkey, indexPath);
if (await fileExists(indexFilePath)) {
-
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath);
+
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
}
}
-
// No index file found, fall through to 404
+
// No index file found - check if directory listing is enabled
+
if (settings?.directoryListing) {
+
const { stat } = await import('fs/promises');
+
const entries = await readdir(directoryPath);
+
// Filter out .meta files and other hidden files
+
const visibleEntries = entries.filter(entry => !entry.endsWith('.meta') && entry !== '.metadata.json');
+
+
// Check which entries are directories
+
const entriesWithType = await Promise.all(
+
visibleEntries.map(async (name) => {
+
try {
+
const entryPath = `${directoryPath}/${name}`;
+
const stats = await stat(entryPath);
+
return { name, isDirectory: stats.isDirectory() };
+
} catch {
+
return { name, isDirectory: false };
+
}
+
})
+
);
+
+
const html = generateDirectoryListing(requestPath, entriesWithType);
+
return new Response(html, {
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
+
}
+
// Fall through to 404/SPA handling
}
} catch (err) {
// If stat fails, continue with normal flow
}
}
+
// Not a directory, try to serve as a file
+
const fileRequestPath = requestPath || indexFiles[0];
+
const cacheKey = getCacheKey(did, rkey, fileRequestPath);
+
const cachedFile = getCachedFilePath(did, rkey, fileRequestPath);
+
// Check for rewritten HTML in cache first (if it's HTML)
-
const mimeTypeGuess = lookup(requestPath) || 'application/octet-stream';
-
if (isHtmlContent(requestPath, mimeTypeGuess)) {
-
const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`);
+
const mimeTypeGuess = lookup(fileRequestPath) || 'application/octet-stream';
+
if (isHtmlContent(fileRequestPath, mimeTypeGuess)) {
+
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
if (rewrittenContent) {
-
return new Response(rewrittenContent, {
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
+
const headers: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(headers, fileRequestPath, settings);
+
return new Response(rewrittenContent, { headers });
}
}
···
const isGzipped = meta?.encoding === 'gzip';
// Check if this is HTML content that needs rewriting
-
if (isHtmlContent(requestPath, mimeType)) {
+
if (isHtmlContent(fileRequestPath, mimeType)) {
let htmlContent: string;
if (isGzipped) {
// Verify content is actually gzipped
···
const { gunzipSync } = await import('zlib');
htmlContent = gunzipSync(content).toString('utf-8');
} else {
-
console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`);
+
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
htmlContent = content.toString('utf-8');
}
} else {
htmlContent = content.toString('utf-8');
}
-
const rewritten = rewriteHtmlPaths(htmlContent, basePath, requestPath);
+
const rewritten = rewriteHtmlPaths(htmlContent, basePath, fileRequestPath);
// Recompress and cache the rewritten HTML
const { gzipSync } = await import('zlib');
const recompressed = gzipSync(Buffer.from(rewritten, 'utf-8'));
-
const rewrittenKey = getCacheKey(did, rkey, requestPath, `rewritten:${basePath}`);
+
const rewrittenKey = getCacheKey(did, rkey, fileRequestPath, `rewritten:${basePath}`);
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
-
return new Response(recompressed, {
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
+
const htmlHeaders: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(htmlHeaders, fileRequestPath, settings);
+
return new Response(recompressed, { headers: htmlHeaders });
}
// Non-HTML files: serve as-is
···
if (hasGzipMagic) {
const { gunzipSync } = await import('zlib');
const decompressed = gunzipSync(content);
+
applyCustomHeaders(headers, fileRequestPath, settings);
return new Response(decompressed, { headers });
} else {
-
console.warn(`File ${requestPath} marked as gzipped but lacks magic bytes, serving as-is`);
+
console.warn(`File ${fileRequestPath} marked as gzipped but lacks magic bytes, serving as-is`);
+
applyCustomHeaders(headers, fileRequestPath, settings);
return new Response(content, { headers });
}
}
headers['Content-Encoding'] = 'gzip';
}
+
applyCustomHeaders(headers, fileRequestPath, settings);
return new Response(content, { headers });
}
// Try index files for directory-like paths
-
if (!requestPath.includes('.')) {
-
for (const indexFileName of INDEX_FILES) {
-
const indexPath = `${requestPath}/${indexFileName}`;
+
if (!fileRequestPath.includes('.')) {
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
const indexCacheKey = getCacheKey(did, rkey, indexPath);
const indexFile = getCachedFilePath(did, rkey, indexPath);
···
const rewrittenKey = getCacheKey(did, rkey, indexPath, `rewritten:${basePath}`);
const rewrittenContent = rewrittenHtmlCache.get(rewrittenKey);
if (rewrittenContent) {
-
return new Response(rewrittenContent, {
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
+
const headers: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(headers, indexPath, settings);
+
return new Response(rewrittenContent, { headers });
}
let indexContent = fileCache.get(indexCacheKey);
···
rewrittenHtmlCache.set(rewrittenKey, recompressed, recompressed.length);
-
return new Response(recompressed, {
-
headers: {
-
'Content-Type': 'text/html; charset=utf-8',
-
'Content-Encoding': 'gzip',
-
'Cache-Control': 'public, max-age=300',
-
},
-
});
+
const headers: Record<string, string> = {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Content-Encoding': 'gzip',
+
'Cache-Control': 'public, max-age=300',
+
};
+
applyCustomHeaders(headers, indexPath, settings);
+
return new Response(recompressed, { headers });
+
}
+
}
+
}
+
+
// Try clean URLs: /about -> /about.html
+
if (settings?.cleanUrls && !fileRequestPath.includes('.')) {
+
const htmlPath = `${fileRequestPath}.html`;
+
const htmlFile = getCachedFilePath(did, rkey, htmlPath);
+
if (await fileExists(htmlFile)) {
+
return serveFileInternalWithRewrite(did, rkey, htmlPath, basePath, settings);
+
}
+
+
// Also try /about/index.html
+
for (const indexFileName of indexFiles) {
+
const indexPath = fileRequestPath ? `${fileRequestPath}/${indexFileName}` : indexFileName;
+
const indexFile = getCachedFilePath(did, rkey, indexPath);
+
if (await fileExists(indexFile)) {
+
return serveFileInternalWithRewrite(did, rkey, indexPath, basePath, settings);
}
}
}
-
return new Response('Not Found', { status: 404 });
+
// SPA mode: serve SPA file for all non-existing routes
+
if (settings?.spaMode) {
+
const spaFile = settings.spaMode;
+
const spaFilePath = getCachedFilePath(did, rkey, spaFile);
+
if (await fileExists(spaFilePath)) {
+
return serveFileInternalWithRewrite(did, rkey, spaFile, basePath, settings);
+
}
+
}
+
+
// Custom 404: serve custom 404 file if configured (wins conflict battle)
+
if (settings?.custom404) {
+
const custom404File = settings.custom404;
+
const custom404Path = getCachedFilePath(did, rkey, custom404File);
+
if (await fileExists(custom404Path)) {
+
const response = await serveFileInternalWithRewrite(did, rkey, custom404File, basePath, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Autodetect 404 pages (GitHub Pages: 404.html, Neocities/Nekoweb: not_found.html)
+
const auto404Pages = ['404.html', 'not_found.html'];
+
for (const auto404Page of auto404Pages) {
+
const auto404Path = getCachedFilePath(did, rkey, auto404Page);
+
if (await fileExists(auto404Path)) {
+
const response = await serveFileInternalWithRewrite(did, rkey, auto404Page, basePath, settings);
+
// Override status to 404
+
return new Response(response.body, {
+
status: 404,
+
headers: response.headers,
+
});
+
}
+
}
+
+
// Default styled 404 page
+
const html = generate404Page();
+
return new Response(html, {
+
status: 404,
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
'Cache-Control': 'public, max-age=300',
+
},
+
});
}
// Helper to ensure site is cached
+76
lexicons/settings.json
···
+
{
+
"lexicon": 1,
+
"id": "place.wisp.settings",
+
"defs": {
+
"main": {
+
"type": "record",
+
"description": "Configuration settings for a static site hosted on wisp.place",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"properties": {
+
"directoryListing": {
+
"type": "boolean",
+
"description": "Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.",
+
"default": false
+
},
+
"spaMode": {
+
"type": "string",
+
"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.",
+
"maxLength": 500
+
},
+
"custom404": {
+
"type": "string",
+
"description": "Custom 404 error page file path. Incompatible with directoryListing and spaMode.",
+
"maxLength": 500
+
},
+
"indexFiles": {
+
"type": "array",
+
"description": "Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.",
+
"items": {
+
"type": "string",
+
"maxLength": 255
+
},
+
"maxLength": 10
+
},
+
"cleanUrls": {
+
"type": "boolean",
+
"description": "Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.",
+
"default": false
+
},
+
"headers": {
+
"type": "array",
+
"description": "Custom HTTP headers to set on responses",
+
"items": {
+
"type": "ref",
+
"ref": "#customHeader"
+
},
+
"maxLength": 50
+
}
+
}
+
}
+
},
+
"customHeader": {
+
"type": "object",
+
"description": "Custom HTTP header configuration",
+
"required": ["name", "value"],
+
"properties": {
+
"name": {
+
"type": "string",
+
"description": "HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')",
+
"maxLength": 100
+
},
+
"value": {
+
"type": "string",
+
"description": "HTTP header value",
+
"maxLength": 1000
+
},
+
"path": {
+
"type": "string",
+
"description": "Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.",
+
"maxLength": 500
+
}
+
}
+
}
+
}
+
}
+385 -85
public/editor/editor.tsx
···
import { Label } from '@public/components/ui/label'
import { Badge } from '@public/components/ui/badge'
import { SkeletonShimmer } from '@public/components/ui/skeleton'
+
import { Input } from '@public/components/ui/input'
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
import {
Loader2,
Trash2,
···
const [isSavingConfig, setIsSavingConfig] = useState(false)
const [isDeletingSite, setIsDeletingSite] = useState(false)
+
// Site settings state
+
type RoutingMode = 'default' | 'spa' | 'directory' | 'custom404'
+
const [routingMode, setRoutingMode] = useState<RoutingMode>('default')
+
const [spaFile, setSpaFile] = useState('index.html')
+
const [custom404File, setCustom404File] = useState('404.html')
+
const [indexFiles, setIndexFiles] = useState<string[]>(['index.html'])
+
const [newIndexFile, setNewIndexFile] = useState('')
+
const [cleanUrls, setCleanUrls] = useState(false)
+
const [corsEnabled, setCorsEnabled] = useState(false)
+
const [corsOrigin, setCorsOrigin] = useState('*')
+
// Fetch initial data on mount
useEffect(() => {
fetchUserInfo()
···
}, [])
// Handle site configuration modal
-
const handleConfigureSite = (site: SiteWithDomains) => {
+
const handleConfigureSite = async (site: SiteWithDomains) => {
setConfiguringSite(site)
// Build set of currently mapped domains
···
}
setSelectedDomains(mappedDomains)
+
+
// Fetch and populate settings for this site
+
try {
+
const response = await fetch(`/api/site/${site.rkey}/settings`, {
+
credentials: 'include'
+
})
+
if (response.ok) {
+
const settings = await response.json()
+
+
// Determine routing mode based on settings
+
if (settings.spaMode) {
+
setRoutingMode('spa')
+
setSpaFile(settings.spaMode)
+
} else if (settings.directoryListing) {
+
setRoutingMode('directory')
+
} else if (settings.custom404) {
+
setRoutingMode('custom404')
+
setCustom404File(settings.custom404)
+
} else {
+
setRoutingMode('default')
+
}
+
+
// Set other settings
+
setIndexFiles(settings.indexFiles || ['index.html'])
+
setCleanUrls(settings.cleanUrls || false)
+
+
// Check for CORS headers
+
const corsHeader = settings.headers?.find((h: any) => h.name === 'Access-Control-Allow-Origin')
+
if (corsHeader) {
+
setCorsEnabled(true)
+
setCorsOrigin(corsHeader.value)
+
} else {
+
setCorsEnabled(false)
+
setCorsOrigin('*')
+
}
+
} else {
+
// Reset to defaults if no settings found
+
setRoutingMode('default')
+
setSpaFile('index.html')
+
setCustom404File('404.html')
+
setIndexFiles(['index.html'])
+
setCleanUrls(false)
+
setCorsEnabled(false)
+
setCorsOrigin('*')
+
}
+
} catch (err) {
+
console.error('Failed to fetch settings:', err)
+
// Use defaults on error
+
setRoutingMode('default')
+
setSpaFile('index.html')
+
setCustom404File('404.html')
+
setIndexFiles(['index.html'])
+
setCleanUrls(false)
+
setCorsEnabled(false)
+
setCorsOrigin('*')
+
}
}
const handleSaveSiteConfig = async () => {
···
if (!isAlreadyMapped) {
await mapCustomDomain(domainId, configuringSite.rkey)
}
+
}
+
+
// Save site settings
+
const settings: any = {
+
cleanUrls,
+
indexFiles: indexFiles.filter(f => f.trim() !== '')
+
}
+
+
// Set routing mode based on selection
+
if (routingMode === 'spa') {
+
settings.spaMode = spaFile
+
} else if (routingMode === 'directory') {
+
settings.directoryListing = true
+
} else if (routingMode === 'custom404') {
+
settings.custom404 = custom404File
+
}
+
+
// Add CORS header if enabled
+
if (corsEnabled) {
+
settings.headers = [
+
{
+
name: 'Access-Control-Allow-Origin',
+
value: corsOrigin
+
}
+
]
+
}
+
+
const settingsResponse = await fetch(`/api/site/${configuringSite.rkey}/settings`, {
+
method: 'POST',
+
headers: {
+
'Content-Type': 'application/json'
+
},
+
credentials: 'include',
+
body: JSON.stringify(settings)
+
})
+
+
if (!settingsResponse.ok) {
+
const error = await settingsResponse.json()
+
throw new Error(error.error || 'Failed to save settings')
}
// Refresh both domains and sites to get updated mappings
···
open={configuringSite !== null}
onOpenChange={(open) => !open && setConfiguringSite(null)}
>
-
<DialogContent className="sm:max-w-lg">
+
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
-
<DialogTitle>Configure Site Domains</DialogTitle>
+
<DialogTitle>Configure Site</DialogTitle>
<DialogDescription>
-
Select which domains should be mapped to this site. You can select multiple domains.
+
Configure domains and settings for this site.
</DialogDescription>
</DialogHeader>
{configuringSite && (
···
</p>
</div>
-
<div className="space-y-3">
-
<p className="text-sm font-medium">Available Domains:</p>
+
<Tabs defaultValue="domains" className="w-full">
+
<TabsList className="grid w-full grid-cols-2">
+
<TabsTrigger value="domains">Domains</TabsTrigger>
+
<TabsTrigger value="settings">Settings</TabsTrigger>
+
</TabsList>
+
+
{/* Domains Tab */}
+
<TabsContent value="domains" className="space-y-3 mt-4">
+
<p className="text-sm font-medium">Available Domains:</p>
+
+
{wispDomains.map((wispDomain) => {
+
const domainId = `wisp:${wispDomain.domain}`
+
return (
+
<div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
+
<Checkbox
+
id={domainId}
+
checked={selectedDomains.has(domainId)}
+
onCheckedChange={(checked) => {
+
const newSelected = new Set(selectedDomains)
+
if (checked) {
+
newSelected.add(domainId)
+
} else {
+
newSelected.delete(domainId)
+
}
+
setSelectedDomains(newSelected)
+
}}
+
/>
+
<Label
+
htmlFor={domainId}
+
className="flex-1 cursor-pointer"
+
>
+
<div className="flex items-center justify-between">
+
<span className="font-mono text-sm">
+
{wispDomain.domain}
+
</span>
+
<Badge variant="secondary" className="text-xs ml-2">
+
Wisp
+
</Badge>
+
</div>
+
</Label>
+
</div>
+
)
+
})}
-
{wispDomains.map((wispDomain) => {
-
const domainId = `wisp:${wispDomain.domain}`
-
return (
-
<div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
-
<Checkbox
-
id={domainId}
-
checked={selectedDomains.has(domainId)}
-
onCheckedChange={(checked) => {
-
const newSelected = new Set(selectedDomains)
-
if (checked) {
-
newSelected.add(domainId)
-
} else {
-
newSelected.delete(domainId)
-
}
-
setSelectedDomains(newSelected)
-
}}
-
/>
-
<Label
-
htmlFor={domainId}
-
className="flex-1 cursor-pointer"
+
{customDomains
+
.filter((d) => d.verified)
+
.map((domain) => (
+
<div
+
key={domain.id}
+
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
>
-
<div className="flex items-center justify-between">
-
<span className="font-mono text-sm">
-
{wispDomain.domain}
-
</span>
-
<Badge variant="secondary" className="text-xs ml-2">
-
Wisp
-
</Badge>
+
<Checkbox
+
id={domain.id}
+
checked={selectedDomains.has(domain.id)}
+
onCheckedChange={(checked) => {
+
const newSelected = new Set(selectedDomains)
+
if (checked) {
+
newSelected.add(domain.id)
+
} else {
+
newSelected.delete(domain.id)
+
}
+
setSelectedDomains(newSelected)
+
}}
+
/>
+
<Label
+
htmlFor={domain.id}
+
className="flex-1 cursor-pointer"
+
>
+
<div className="flex items-center justify-between">
+
<span className="font-mono text-sm">
+
{domain.domain}
+
</span>
+
<Badge
+
variant="outline"
+
className="text-xs ml-2"
+
>
+
Custom
+
</Badge>
+
</div>
+
</Label>
+
</div>
+
))}
+
+
{customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && (
+
<p className="text-sm text-muted-foreground py-4 text-center">
+
No domains available. Add a custom domain or claim a wisp.place subdomain.
+
</p>
+
)}
+
+
<div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50 mt-4">
+
<p className="text-xs text-muted-foreground">
+
<strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
+
<span className="font-mono">
+
sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
+
</span>
+
</p>
+
</div>
+
</TabsContent>
+
+
{/* Settings Tab */}
+
<TabsContent value="settings" className="space-y-4 mt-4">
+
{/* Routing Mode */}
+
<div className="space-y-3">
+
<Label className="text-sm font-medium">Routing Mode</Label>
+
<RadioGroup value={routingMode} onValueChange={(value) => setRoutingMode(value as RoutingMode)}>
+
<div className="flex items-center space-x-3 p-3 border rounded-lg">
+
<RadioGroupItem value="default" id="mode-default" />
+
<Label htmlFor="mode-default" className="flex-1 cursor-pointer">
+
<div>
+
<p className="font-medium">Default</p>
+
<p className="text-xs text-muted-foreground">Standard static file serving</p>
+
</div>
+
</Label>
+
</div>
+
<div className="flex items-center space-x-3 p-3 border rounded-lg">
+
<RadioGroupItem value="spa" id="mode-spa" />
+
<Label htmlFor="mode-spa" className="flex-1 cursor-pointer">
+
<div>
+
<p className="font-medium">SPA Mode</p>
+
<p className="text-xs text-muted-foreground">Route all requests to a single file</p>
+
</div>
+
</Label>
+
</div>
+
{routingMode === 'spa' && (
+
<div className="ml-7 space-y-2">
+
<Label htmlFor="spa-file" className="text-sm">SPA File</Label>
+
<Input
+
id="spa-file"
+
value={spaFile}
+
onChange={(e) => setSpaFile(e.target.value)}
+
placeholder="index.html"
+
/>
</div>
-
</Label>
+
)}
+
<div className="flex items-center space-x-3 p-3 border rounded-lg">
+
<RadioGroupItem value="directory" id="mode-directory" />
+
<Label htmlFor="mode-directory" className="flex-1 cursor-pointer">
+
<div>
+
<p className="font-medium">Directory Listing</p>
+
<p className="text-xs text-muted-foreground">Show directory contents on 404</p>
+
</div>
+
</Label>
+
</div>
+
<div className="flex items-center space-x-3 p-3 border rounded-lg">
+
<RadioGroupItem value="custom404" id="mode-custom404" />
+
<Label htmlFor="mode-custom404" className="flex-1 cursor-pointer">
+
<div>
+
<p className="font-medium">Custom 404 Page</p>
+
<p className="text-xs text-muted-foreground">Serve custom error page</p>
+
</div>
+
</Label>
+
</div>
+
{routingMode === 'custom404' && (
+
<div className="ml-7 space-y-2">
+
<Label htmlFor="404-file" className="text-sm">404 File</Label>
+
<Input
+
id="404-file"
+
value={custom404File}
+
onChange={(e) => setCustom404File(e.target.value)}
+
placeholder="404.html"
+
/>
+
</div>
+
)}
+
</RadioGroup>
+
</div>
+
+
{/* Index Files */}
+
<div className="space-y-3">
+
<Label className={`text-sm font-medium ${routingMode === 'spa' ? 'text-muted-foreground' : ''}`}>
+
Index Files
+
{routingMode === 'spa' && (
+
<span className="ml-2 text-xs">(disabled in SPA mode)</span>
+
)}
+
</Label>
+
<p className="text-xs text-muted-foreground">Files to try when serving a directory (in order)</p>
+
<div className="space-y-2">
+
{indexFiles.map((file, idx) => (
+
<div key={idx} className="flex items-center gap-2">
+
<Input
+
value={file}
+
onChange={(e) => {
+
const newFiles = [...indexFiles]
+
newFiles[idx] = e.target.value
+
setIndexFiles(newFiles)
+
}}
+
placeholder="index.html"
+
disabled={routingMode === 'spa'}
+
/>
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() => {
+
setIndexFiles(indexFiles.filter((_, i) => i !== idx))
+
}}
+
disabled={routingMode === 'spa'}
+
className="w-20"
+
>
+
Remove
+
</Button>
+
</div>
+
))}
+
<div className="flex items-center gap-2">
+
<Input
+
value={newIndexFile}
+
onChange={(e) => setNewIndexFile(e.target.value)}
+
placeholder="Add index file..."
+
onKeyDown={(e) => {
+
if (e.key === 'Enter' && newIndexFile.trim()) {
+
setIndexFiles([...indexFiles, newIndexFile.trim()])
+
setNewIndexFile('')
+
}
+
}}
+
disabled={routingMode === 'spa'}
+
/>
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() => {
+
if (newIndexFile.trim()) {
+
setIndexFiles([...indexFiles, newIndexFile.trim()])
+
setNewIndexFile('')
+
}
+
}}
+
disabled={routingMode === 'spa'}
+
className="w-20"
+
>
+
Add
+
</Button>
+
</div>
</div>
-
)
-
})}
+
</div>
+
+
{/* Clean URLs */}
+
<div className="flex items-center space-x-3 p-3 border rounded-lg">
+
<Checkbox
+
id="clean-urls"
+
checked={cleanUrls}
+
onCheckedChange={(checked) => setCleanUrls(!!checked)}
+
/>
+
<Label htmlFor="clean-urls" className="flex-1 cursor-pointer">
+
<div>
+
<p className="font-medium">Clean URLs</p>
+
<p className="text-xs text-muted-foreground">
+
Serve /about as /about.html or /about/index.html
+
</p>
+
</div>
+
</Label>
+
</div>
-
{customDomains
-
.filter((d) => d.verified)
-
.map((domain) => (
-
<div
-
key={domain.id}
-
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
-
>
+
{/* CORS */}
+
<div className="space-y-3">
+
<div className="flex items-center space-x-3 p-3 border rounded-lg">
<Checkbox
-
id={domain.id}
-
checked={selectedDomains.has(domain.id)}
-
onCheckedChange={(checked) => {
-
const newSelected = new Set(selectedDomains)
-
if (checked) {
-
newSelected.add(domain.id)
-
} else {
-
newSelected.delete(domain.id)
-
}
-
setSelectedDomains(newSelected)
-
}}
+
id="cors-enabled"
+
checked={corsEnabled}
+
onCheckedChange={(checked) => setCorsEnabled(!!checked)}
/>
-
<Label
-
htmlFor={domain.id}
-
className="flex-1 cursor-pointer"
-
>
-
<div className="flex items-center justify-between">
-
<span className="font-mono text-sm">
-
{domain.domain}
-
</span>
-
<Badge
-
variant="outline"
-
className="text-xs ml-2"
-
>
-
Custom
-
</Badge>
+
<Label htmlFor="cors-enabled" className="flex-1 cursor-pointer">
+
<div>
+
<p className="font-medium">Enable CORS</p>
+
<p className="text-xs text-muted-foreground">
+
Allow cross-origin requests
+
</p>
</div>
</Label>
</div>
-
))}
-
-
{customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && (
-
<p className="text-sm text-muted-foreground py-4 text-center">
-
No domains available. Add a custom domain or claim a wisp.place subdomain.
-
</p>
-
)}
-
</div>
-
-
<div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
-
<p className="text-xs text-muted-foreground">
-
<strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
-
<span className="font-mono">
-
sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
-
</span>
-
</p>
-
</div>
+
{corsEnabled && (
+
<div className="ml-7 space-y-2">
+
<Label htmlFor="cors-origin" className="text-sm">Allowed Origin</Label>
+
<Input
+
id="cors-origin"
+
value={corsOrigin}
+
onChange={(e) => setCorsOrigin(e.target.value)}
+
placeholder="*"
+
/>
+
<p className="text-xs text-muted-foreground">
+
Use * for all origins, or specify a domain like https://example.com
+
</p>
+
</div>
+
)}
+
</div>
+
</TabsContent>
+
</Tabs>
</div>
)}
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
+86 -1
src/lexicons/lexicons.ts
···
flat: {
type: 'boolean',
description:
-
"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.",
+
"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.",
+
},
+
},
+
},
+
},
+
},
+
PlaceWispSettings: {
+
lexicon: 1,
+
id: 'place.wisp.settings',
+
defs: {
+
main: {
+
type: 'record',
+
description:
+
'Configuration settings for a static site hosted on wisp.place',
+
key: 'any',
+
record: {
+
type: 'object',
+
properties: {
+
directoryListing: {
+
type: 'boolean',
+
description:
+
'Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode.',
+
default: false,
+
},
+
spaMode: {
+
type: 'string',
+
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.",
+
maxLength: 500,
+
},
+
custom404: {
+
type: 'string',
+
description:
+
'Custom 404 error page file path. Incompatible with directoryListing and spaMode.',
+
maxLength: 500,
+
},
+
indexFiles: {
+
type: 'array',
+
description:
+
"Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.",
+
items: {
+
type: 'string',
+
maxLength: 255,
+
},
+
maxLength: 10,
+
},
+
cleanUrls: {
+
type: 'boolean',
+
description:
+
"Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically.",
+
default: false,
+
},
+
headers: {
+
type: 'array',
+
description: 'Custom HTTP headers to set on responses',
+
items: {
+
type: 'ref',
+
ref: 'lex:place.wisp.settings#customHeader',
+
},
+
maxLength: 50,
+
},
+
},
+
},
+
},
+
customHeader: {
+
type: 'object',
+
description: 'Custom HTTP header configuration',
+
required: ['name', 'value'],
+
properties: {
+
name: {
+
type: 'string',
+
description:
+
"HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')",
+
maxLength: 100,
+
},
+
value: {
+
type: 'string',
+
description: 'HTTP header value',
+
maxLength: 1000,
+
},
+
path: {
+
type: 'string',
+
description:
+
"Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.",
+
maxLength: 500,
},
},
},
···
export const ids = {
PlaceWispFs: 'place.wisp.fs',
+
PlaceWispSettings: 'place.wisp.settings',
PlaceWispSubfs: 'place.wisp.subfs',
} as const
+1 -1
src/lexicons/types/place/wisp/fs.ts
···
type: 'subfs'
/** AT-URI pointing to a place.wisp.subfs record containing this subtree. */
subject: string
-
/** 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. */
+
/** 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. */
flat?: boolean
}
+65
src/lexicons/types/place/wisp/settings.ts
···
+
/**
+
* GENERATED CODE - DO NOT MODIFY
+
*/
+
import { type ValidationResult, BlobRef } from '@atproto/lexicon'
+
import { CID } from 'multiformats/cid'
+
import { validate as _validate } from '../../../lexicons'
+
import { type $Typed, is$typed as _is$typed, type OmitKey } from '../../../util'
+
+
const is$typed = _is$typed,
+
validate = _validate
+
const id = 'place.wisp.settings'
+
+
export interface Main {
+
$type: 'place.wisp.settings'
+
/** Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. */
+
directoryListing: boolean
+
/** 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. */
+
spaMode?: string
+
/** Custom 404 error page file path. Incompatible with directoryListing and spaMode. */
+
custom404?: string
+
/** Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. */
+
indexFiles?: string[]
+
/** Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. */
+
cleanUrls: boolean
+
/** Custom HTTP headers to set on responses */
+
headers?: CustomHeader[]
+
[k: string]: unknown
+
}
+
+
const hashMain = 'main'
+
+
export function isMain<V>(v: V) {
+
return is$typed(v, id, hashMain)
+
}
+
+
export function validateMain<V>(v: V) {
+
return validate<Main & V>(v, id, hashMain, true)
+
}
+
+
export {
+
type Main as Record,
+
isMain as isRecord,
+
validateMain as validateRecord,
+
}
+
+
/** Custom HTTP header configuration */
+
export interface CustomHeader {
+
$type?: 'place.wisp.settings#customHeader'
+
/** HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') */
+
name: string
+
/** HTTP header value */
+
value: string
+
/** Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. */
+
path?: string
+
}
+
+
const hashCustomHeader = 'customHeader'
+
+
export function isCustomHeader<V>(v: V) {
+
return is$typed(v, id, hashCustomHeader)
+
}
+
+
export function validateCustomHeader<V>(v: V) {
+
return validate<CustomHeader & V>(v, id, hashCustomHeader)
+
}
+2 -2
src/lib/oauth-client.ts
···
// Loopback client for local development
// For loopback, scopes and redirect_uri must be in client_id query string
const redirectUri = 'http://127.0.0.1:8000/api/auth/callback';
-
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';
+
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';
const params = new URLSearchParams();
params.append('redirect_uri', redirectUri);
params.append('scope', scope);
···
application_type: 'web',
token_endpoint_auth_method: 'private_key_jwt',
token_endpoint_auth_signing_alg: "ES256",
-
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",
+
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",
dpop_bound_access_tokens: true,
jwks_uri: `${config.domain}/jwks.json`,
subject_type: 'public',
+7 -1
src/routes/admin.ts
···
import { db } from '../lib/db'
export const adminRoutes = (cookieSecret: string) =>
-
new Elysia({ prefix: '/api/admin' })
+
new Elysia({
+
prefix: '/api/admin',
+
cookie: {
+
secrets: cookieSecret,
+
sign: ['admin_session']
+
}
+
})
// Login
.post(
'/login',
+108
src/routes/site.ts
···
}
}
})
+
.get('/:rkey/settings', async ({ params, auth }) => {
+
const { rkey } = params
+
+
if (!rkey) {
+
return {
+
success: false,
+
error: 'Site rkey is required'
+
}
+
}
+
+
try {
+
// Create agent with OAuth session
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
+
+
// Fetch settings record
+
try {
+
const record = await agent.com.atproto.repo.getRecord({
+
repo: auth.did,
+
collection: 'place.wisp.settings',
+
rkey: rkey
+
})
+
+
if (record.data.value) {
+
return record.data.value
+
}
+
} catch (err: any) {
+
// Record doesn't exist, return defaults
+
if (err?.error === 'RecordNotFound') {
+
return {
+
indexFiles: ['index.html'],
+
cleanUrls: false,
+
directoryListing: false
+
}
+
}
+
throw err
+
}
+
+
// Default settings
+
return {
+
indexFiles: ['index.html'],
+
cleanUrls: false,
+
directoryListing: false
+
}
+
} catch (err) {
+
logger.error('[Site] Get settings error', err)
+
return {
+
success: false,
+
error: err instanceof Error ? err.message : 'Failed to fetch settings'
+
}
+
}
+
})
+
.post('/:rkey/settings', async ({ params, body, auth }) => {
+
const { rkey } = params
+
+
if (!rkey) {
+
return {
+
success: false,
+
error: 'Site rkey is required'
+
}
+
}
+
+
// Validate settings
+
const settings = body as any
+
+
// Ensure mutual exclusivity of routing modes
+
const modes = [
+
settings.spaMode,
+
settings.directoryListing,
+
settings.custom404
+
].filter(Boolean)
+
+
if (modes.length > 1) {
+
return {
+
success: false,
+
error: 'Only one of spaMode, directoryListing, or custom404 can be enabled'
+
}
+
}
+
+
try {
+
// Create agent with OAuth session
+
const agent = new Agent((url, init) => auth.session.fetchHandler(url, init))
+
+
// Create or update settings record
+
const record = await agent.com.atproto.repo.putRecord({
+
repo: auth.did,
+
collection: 'place.wisp.settings',
+
rkey: rkey,
+
record: {
+
$type: 'place.wisp.settings',
+
...settings
+
}
+
})
+
+
logger.info(`[Site] Saved settings for ${rkey} (${auth.did})`)
+
+
return {
+
success: true,
+
uri: record.data.uri,
+
cid: record.data.cid
+
}
+
} catch (err) {
+
logger.error('[Site] Save settings error', err)
+
return {
+
success: false,
+
error: err instanceof Error ? err.message : 'Failed to save settings'
+
}
+
}
+
})
+95 -1
src/routes/wisp.ts
···
currentFile: file.name
});
-
// Skip .git directory files
+
// Skip unwanted files and directories
const normalizedPath = file.name.replace(/^[^\/]*\//, '');
+
const fileName = normalizedPath.split('/').pop() || '';
+
const pathParts = normalizedPath.split('/');
+
+
// .git directory (version control - thousands of files)
if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') {
console.log(`Skipping .git file: ${file.name}`);
skippedFiles.push({
name: file.name,
reason: '.git directory excluded'
+
});
+
continue;
+
}
+
+
// .DS_Store (macOS metadata - can leak info)
+
if (fileName === '.DS_Store') {
+
console.log(`Skipping .DS_Store file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: '.DS_Store file excluded'
+
});
+
continue;
+
}
+
+
// .env files (environment variables with secrets)
+
if (fileName.startsWith('.env')) {
+
console.log(`Skipping .env file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'environment files excluded for security'
+
});
+
continue;
+
}
+
+
// node_modules (dependency folder - can be 100,000+ files)
+
if (pathParts.includes('node_modules')) {
+
console.log(`Skipping node_modules file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'node_modules excluded'
+
});
+
continue;
+
}
+
+
// OS metadata files
+
if (fileName === 'Thumbs.db' || fileName === 'desktop.ini' || fileName.startsWith('._')) {
+
console.log(`Skipping OS metadata file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'OS metadata file excluded'
+
});
+
continue;
+
}
+
+
// macOS system directories
+
if (pathParts.includes('.Spotlight-V100') || pathParts.includes('.Trashes') || pathParts.includes('.fseventsd')) {
+
console.log(`Skipping macOS system file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'macOS system directory excluded'
+
});
+
continue;
+
}
+
+
// Cache and temp directories
+
if (pathParts.some(part => part === '.cache' || part === '.temp' || part === '.tmp')) {
+
console.log(`Skipping cache/temp file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'cache/temp directory excluded'
+
});
+
continue;
+
}
+
+
// Python cache
+
if (pathParts.includes('__pycache__') || fileName.endsWith('.pyc')) {
+
console.log(`Skipping Python cache file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'Python cache excluded'
+
});
+
continue;
+
}
+
+
// Python virtual environments
+
if (pathParts.some(part => part === '.venv' || part === 'venv' || part === 'env')) {
+
console.log(`Skipping Python venv file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'Python virtual environment excluded'
+
});
+
continue;
+
}
+
+
// Editor swap files
+
if (fileName.endsWith('.swp') || fileName.endsWith('.swo') || fileName.endsWith('~')) {
+
console.log(`Skipping editor swap file: ${file.name}`);
+
skippedFiles.push({
+
name: file.name,
+
reason: 'editor swap file excluded'
});
continue;
}