Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1/**
2 * Request utilities for validation and helper functions
3 */
4
5import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
6import { access } from 'fs/promises';
7
8/**
9 * Default index file names to check for directory requests
10 * Will be checked in order until one is found
11 */
12export const DEFAULT_INDEX_FILES = ['index.html', 'index.htm'];
13
14/**
15 * Get index files list from settings or use defaults
16 */
17export function getIndexFiles(settings: WispSettings | null): string[] {
18 if (settings?.indexFiles && settings.indexFiles.length > 0) {
19 return settings.indexFiles;
20 }
21 return DEFAULT_INDEX_FILES;
22}
23
24/**
25 * Match a file path against a glob pattern
26 * Supports * wildcard and basic path matching
27 */
28export function matchGlob(path: string, pattern: string): boolean {
29 // Normalize paths
30 const normalizedPath = path.startsWith('/') ? path : '/' + path;
31 const normalizedPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
32
33 // Convert glob pattern to regex
34 const regexPattern = normalizedPattern
35 .replace(/\./g, '\\.')
36 .replace(/\*/g, '.*')
37 .replace(/\?/g, '.');
38
39 const regex = new RegExp('^' + regexPattern + '$');
40 return regex.test(normalizedPath);
41}
42
43/**
44 * Apply custom headers from settings to response headers
45 */
46export function applyCustomHeaders(headers: Record<string, string>, filePath: string, settings: WispSettings | null) {
47 if (!settings?.headers || settings.headers.length === 0) return;
48
49 for (const customHeader of settings.headers) {
50 // If path glob is specified, check if it matches
51 if (customHeader.path) {
52 if (!matchGlob(filePath, customHeader.path)) {
53 continue;
54 }
55 }
56 // Apply the header
57 headers[customHeader.name] = customHeader.value;
58 }
59}
60
61/**
62 * Validate site name (rkey) to prevent injection attacks
63 * Must match AT Protocol rkey format
64 */
65export function isValidRkey(rkey: string): boolean {
66 if (!rkey || typeof rkey !== 'string') return false;
67 if (rkey.length < 1 || rkey.length > 512) return false;
68 if (rkey === '.' || rkey === '..') return false;
69 if (rkey.includes('/') || rkey.includes('\\') || rkey.includes('\0')) return false;
70 const validRkeyPattern = /^[a-zA-Z0-9._~:-]+$/;
71 return validRkeyPattern.test(rkey);
72}
73
74/**
75 * Async file existence check
76 */
77export async function fileExists(path: string): Promise<boolean> {
78 try {
79 await access(path);
80 return true;
81 } catch {
82 return false;
83 }
84}
85
86/**
87 * Extract and normalize headers from request
88 */
89export function extractHeaders(rawHeaders: Headers): Record<string, string> {
90 const headers: Record<string, string> = {};
91 rawHeaders.forEach((value, key) => {
92 headers[key.toLowerCase()] = value;
93 });
94 return headers;
95}
96