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