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