Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { readFile } from 'fs/promises';
2import { existsSync } from 'fs';
3
4export interface RedirectRule {
5 from: string;
6 to: string;
7 status: number;
8 force: boolean;
9 conditions?: {
10 country?: string[];
11 language?: string[];
12 role?: string[];
13 cookie?: string[];
14 };
15 // For pattern matching
16 fromPattern?: RegExp;
17 fromParams?: string[]; // Named parameters from the pattern
18 queryParams?: Record<string, string>; // Expected query parameters
19}
20
21export interface RedirectMatch {
22 rule: RedirectRule;
23 targetPath: string;
24 status: number;
25}
26
27// Maximum number of redirect rules to prevent DoS attacks
28const MAX_REDIRECT_RULES = 1000;
29
30/**
31 * Parse a _redirects file into an array of redirect rules
32 */
33export function parseRedirectsFile(content: string): RedirectRule[] {
34 const lines = content.split('\n');
35 const rules: RedirectRule[] = [];
36
37 for (let lineNum = 0; lineNum < lines.length; lineNum++) {
38 const lineRaw = lines[lineNum];
39 if (!lineRaw) continue;
40
41 const line = lineRaw.trim();
42
43 // Skip empty lines and comments
44 if (!line || line.startsWith('#')) {
45 continue;
46 }
47
48 // Enforce max rules limit
49 if (rules.length >= MAX_REDIRECT_RULES) {
50 console.warn(`Redirect rules limit reached (${MAX_REDIRECT_RULES}), ignoring remaining rules`);
51 break;
52 }
53
54 try {
55 const rule = parseRedirectLine(line);
56 if (rule && rule.fromPattern) {
57 rules.push(rule);
58 }
59 } catch (err) {
60 console.warn(`Failed to parse redirect rule on line ${lineNum + 1}: ${line}`, err);
61 }
62 }
63
64 return rules;
65}
66
67/**
68 * Parse a single redirect rule line
69 * Format: /from [query_params] /to [status] [conditions]
70 */
71function parseRedirectLine(line: string): RedirectRule | null {
72 // Split by whitespace, but respect quoted strings (though not commonly used)
73 const parts = line.split(/\s+/);
74
75 if (parts.length < 2) {
76 return null;
77 }
78
79 let idx = 0;
80 const from = parts[idx++];
81
82 if (!from) {
83 return null;
84 }
85
86 let status = 301; // Default status
87 let force = false;
88 const conditions: NonNullable<RedirectRule['conditions']> = {};
89 const queryParams: Record<string, string> = {};
90
91 // Parse query parameters that come before the destination path
92 // They look like: key=:value (and don't start with /)
93 while (idx < parts.length) {
94 const part = parts[idx];
95 if (!part) {
96 idx++;
97 continue;
98 }
99
100 // If it starts with / or http, it's the destination path
101 if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) {
102 break;
103 }
104
105 // If it contains = and comes before the destination, it's a query param
106 if (part.includes('=')) {
107 const splitIndex = part.indexOf('=');
108 const key = part.slice(0, splitIndex);
109 const value = part.slice(splitIndex + 1);
110
111 if (key && value) {
112 queryParams[key] = value;
113 }
114 idx++;
115 } else {
116 // Not a query param, must be destination or something else
117 break;
118 }
119 }
120
121 // Next part should be the destination
122 if (idx >= parts.length) {
123 return null;
124 }
125
126 const to = parts[idx++];
127 if (!to) {
128 return null;
129 }
130
131 // Parse remaining parts for status code and conditions
132 for (let i = idx; i < parts.length; i++) {
133 const part = parts[i];
134
135 if (!part) continue;
136
137 // Check for status code (with optional ! for force)
138 if (/^\d+!?$/.test(part)) {
139 if (part.endsWith('!')) {
140 force = true;
141 status = parseInt(part.slice(0, -1));
142 } else {
143 status = parseInt(part);
144 }
145 continue;
146 }
147
148 // Check for condition parameters (Country=, Language=, Role=, Cookie=)
149 if (part.includes('=')) {
150 const splitIndex = part.indexOf('=');
151 const key = part.slice(0, splitIndex);
152 const value = part.slice(splitIndex + 1);
153
154 if (!key || !value) continue;
155
156 const keyLower = key.toLowerCase();
157
158 if (keyLower === 'country') {
159 conditions.country = value.split(',').map(v => v.trim().toLowerCase());
160 } else if (keyLower === 'language') {
161 conditions.language = value.split(',').map(v => v.trim().toLowerCase());
162 } else if (keyLower === 'role') {
163 conditions.role = value.split(',').map(v => v.trim());
164 } else if (keyLower === 'cookie') {
165 conditions.cookie = value.split(',').map(v => v.trim().toLowerCase());
166 }
167 }
168 }
169
170 // Parse the 'from' pattern
171 const { pattern, params } = convertPathToRegex(from);
172
173 return {
174 from,
175 to,
176 status,
177 force,
178 conditions: Object.keys(conditions).length > 0 ? conditions : undefined,
179 queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined,
180 fromPattern: pattern,
181 fromParams: params,
182 };
183}
184
185/**
186 * Convert a path pattern with placeholders and splats to a regex
187 * Examples:
188 * /blog/:year/:month/:day -> captures year, month, day
189 * /news/* -> captures splat
190 */
191function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } {
192 const params: string[] = [];
193 let regexStr = '^';
194
195 // Split by query string if present
196 const pathPart = pattern.split('?')[0] || pattern;
197
198 // Escape special regex characters except * and :
199 let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&');
200
201 // Replace :param with named capture groups
202 escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, paramName) => {
203 params.push(paramName);
204 // Match path segment (everything except / and ?)
205 return '([^/?]+)';
206 });
207
208 // Replace * with splat capture (matches everything including /)
209 if (escaped.includes('*')) {
210 escaped = escaped.replace(/\*/g, '(.*)');
211 params.push('splat');
212 }
213
214 regexStr += escaped;
215
216 // Make trailing slash optional
217 if (!regexStr.endsWith('.*')) {
218 regexStr += '/?';
219 }
220
221 regexStr += '$';
222
223 return {
224 pattern: new RegExp(regexStr),
225 params,
226 };
227}
228
229/**
230 * Match a request path against redirect rules with loop detection
231 */
232export function matchRedirectRule(
233 requestPath: string,
234 rules: RedirectRule[],
235 context?: {
236 queryParams?: Record<string, string>;
237 headers?: Record<string, string>;
238 cookies?: Record<string, string>;
239 },
240 visitedPaths: Set<string> = new Set()
241): RedirectMatch | null {
242 // Normalize path: ensure leading slash, remove trailing slash (except for root)
243 let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
244
245 // Detect redirect loops
246 if (visitedPaths.has(normalizedPath)) {
247 console.warn(`Redirect loop detected for path: ${normalizedPath}`);
248 return null;
249 }
250
251 // Track this path to detect loops
252 visitedPaths.add(normalizedPath);
253
254 // Limit redirect chain depth to 10
255 if (visitedPaths.size > 10) {
256 console.warn(`Redirect chain too deep (>10) for path: ${normalizedPath}`);
257 return null;
258 }
259
260 for (const rule of rules) {
261 // Check query parameter conditions first (if any)
262 if (rule.queryParams) {
263 // If rule requires query params but none provided, skip this rule
264 if (!context?.queryParams) {
265 continue;
266 }
267
268 // Check that all required query params are present
269 // The value in rule.queryParams is either a literal or a placeholder (:name)
270 const queryMatches = Object.entries(rule.queryParams).every(([key, expectedValue]) => {
271 const actualValue = context.queryParams?.[key];
272
273 // Query param must exist
274 if (actualValue === undefined) {
275 return false;
276 }
277
278 // If expected value is a placeholder (:name), any value is acceptable
279 // If it's a literal, it must match exactly
280 if (expectedValue && !expectedValue.startsWith(':')) {
281 return actualValue === expectedValue;
282 }
283
284 return true;
285 });
286
287 if (!queryMatches) {
288 continue;
289 }
290 }
291
292 // Check conditional redirects (country, language, role, cookie)
293 if (rule.conditions) {
294 if (rule.conditions.country && context?.headers) {
295 const cfCountry = context.headers['cf-ipcountry'];
296 const xCountry = context.headers['x-country'];
297 const country = (cfCountry?.toLowerCase() || xCountry?.toLowerCase());
298 if (!country || !rule.conditions.country.includes(country)) {
299 continue;
300 }
301 }
302
303 if (rule.conditions.language && context?.headers) {
304 const acceptLang = context.headers['accept-language'];
305 if (!acceptLang) {
306 continue;
307 }
308 // Parse accept-language header (simplified)
309 const langs = acceptLang.split(',').map(l => {
310 const langPart = l.split(';')[0];
311 return langPart ? langPart.trim().toLowerCase() : '';
312 }).filter(l => l !== '');
313 const hasMatch = rule.conditions.language.some(lang =>
314 langs.some(l => l === lang || l.startsWith(lang + '-'))
315 );
316 if (!hasMatch) {
317 continue;
318 }
319 }
320
321 if (rule.conditions.cookie && context?.cookies) {
322 const hasCookie = rule.conditions.cookie.some(cookieName =>
323 context.cookies && cookieName in context.cookies
324 );
325 if (!hasCookie) {
326 continue;
327 }
328 }
329
330 // Role-based redirects would need JWT verification - skip for now
331 if (rule.conditions.role) {
332 continue;
333 }
334 }
335
336 // Match the path pattern
337 const match = rule.fromPattern?.exec(normalizedPath);
338 if (!match) {
339 continue;
340 }
341
342 // Build the target path by replacing placeholders
343 let targetPath = rule.to;
344
345 // Replace captured parameters (with URL encoding)
346 if (rule.fromParams && match.length > 1) {
347 for (let i = 0; i < rule.fromParams.length; i++) {
348 const paramName = rule.fromParams[i];
349 const paramValue = match[i + 1];
350
351 if (!paramName || !paramValue) continue;
352
353 // URL encode captured values to prevent invalid URLs
354 const encodedValue = encodeURIComponent(paramValue);
355
356 if (paramName === 'splat') {
357 // For splats, preserve slashes by re-decoding them
358 const splatValue = encodedValue.replace(/%2F/g, '/');
359 targetPath = targetPath.replace(':splat', splatValue);
360 } else {
361 targetPath = targetPath.replace(`:${paramName}`, encodedValue);
362 }
363 }
364 }
365
366 // Handle query parameter replacements (with URL encoding)
367 if (rule.queryParams && context?.queryParams) {
368 for (const [key, placeholder] of Object.entries(rule.queryParams)) {
369 const actualValue = context.queryParams[key];
370 if (actualValue && placeholder && placeholder.startsWith(':')) {
371 const paramName = placeholder.slice(1);
372 if (paramName) {
373 // URL encode query parameter values
374 const encodedValue = encodeURIComponent(actualValue);
375 targetPath = targetPath.replace(`:${paramName}`, encodedValue);
376 }
377 }
378 }
379 }
380
381 // Preserve query string for 200, 301, 302 redirects (unless target already has one)
382 if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) {
383 const queryString = Object.entries(context.queryParams)
384 .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
385 .join('&');
386 if (queryString) {
387 targetPath += `?${queryString}`;
388 }
389 }
390
391 return {
392 rule,
393 targetPath,
394 status: rule.status,
395 };
396 }
397
398 return null;
399}
400
401/**
402 * Load redirect rules from a cached site
403 */
404export async function loadRedirectRules(did: string, rkey: string): Promise<RedirectRule[]> {
405 const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
406 const redirectsPath = `${CACHE_DIR}/${did}/${rkey}/_redirects`;
407
408 if (!existsSync(redirectsPath)) {
409 return [];
410 }
411
412 try {
413 const content = await readFile(redirectsPath, 'utf-8');
414 return parseRedirectsFile(content);
415 } catch (err) {
416 console.error('Failed to load _redirects file', err);
417 return [];
418 }
419}
420
421/**
422 * Parse cookies from Cookie header
423 */
424export function parseCookies(cookieHeader?: string): Record<string, string> {
425 if (!cookieHeader) return {};
426
427 const cookies: Record<string, string> = {};
428 const parts = cookieHeader.split(';');
429
430 for (const part of parts) {
431 const [key, ...valueParts] = part.split('=');
432 if (key && valueParts.length > 0) {
433 cookies[key.trim()] = valueParts.join('=').trim();
434 }
435 }
436
437 return cookies;
438}
439
440/**
441 * Parse query string into object
442 */
443export function parseQueryString(url: string): Record<string, string> {
444 const queryStart = url.indexOf('?');
445 if (queryStart === -1) return {};
446
447 const queryString = url.slice(queryStart + 1);
448 const params: Record<string, string> = {};
449
450 for (const pair of queryString.split('&')) {
451 const [key, value] = pair.split('=');
452 if (key) {
453 params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
454 }
455 }
456
457 return params;
458}
459