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