/** * Redirect CRUD and 404 log handlers */ import type { Kysely } from "kysely"; import { OptionsRepository } from "../../database/repositories/options.js"; import { RedirectRepository, type Redirect, type NotFoundEntry, type NotFoundSummary, } from "../../database/repositories/redirect.js"; import { InvalidCursorError } from "../../database/repositories/types.js"; import type { FindManyResult } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; import { wouldCreateLoop, detectLoops, type RedirectEdge } from "../../redirects/loops.js"; import { validatePattern, validateDestinationParams, isPattern } from "../../redirects/patterns.js"; import { isTerminalStatus } from "../../redirects/status.js"; import type { ApiResult } from "../types.js"; // --------------------------------------------------------------------------- // Redirects // --------------------------------------------------------------------------- /** * List redirects with cursor pagination and optional filters */ export async function handleRedirectList( db: Kysely, params: { cursor?: string; limit?: number; search?: string; group?: string; enabled?: boolean; auto?: boolean; }, ): Promise & { loopRedirectIds?: string[] }>> { try { const repo = new RedirectRepository(db); const result = await repo.findMany(params); const loopRedirectIds = await getLoopRedirectIds(db); return { success: true, data: { ...result, ...(loopRedirectIds.length > 0 ? { loopRedirectIds } : {}), }, }; } catch (error) { if (error instanceof InvalidCursorError) { return { success: false, error: { code: "INVALID_CURSOR", message: error.message }, }; } return { success: false, error: { code: "REDIRECT_LIST_ERROR", message: "Failed to fetch redirects" }, }; } } /** * Create a redirect rule */ export async function handleRedirectCreate( db: Kysely, input: { source: string; destination?: string; type?: number; enabled?: boolean; groupName?: string | null; }, ): Promise> { try { const repo = new RedirectRepository(db); const type = input.type ?? 301; // Terminal statuses (410 Gone / 451) are served directly and have no // destination — skip the destination/loop checks for them. const terminal = isTerminalStatus(type); const destination = terminal ? "" : (input.destination ?? ""); // Source and destination must differ if (!terminal && input.source === destination) { return { success: false, error: { code: "VALIDATION_ERROR", message: "Source and destination must be different", }, }; } // If source looks like a pattern, validate it const sourceIsPattern = isPattern(input.source); if (sourceIsPattern) { const patternError = validatePattern(input.source); if (patternError) { return { success: false, error: { code: "VALIDATION_ERROR", message: `Invalid source pattern: ${patternError}` }, }; } // Validate destination params reference valid source params // (terminal rules have no destination to interpolate) if (!terminal) { const destError = validateDestinationParams(input.source, destination); if (destError) { return { success: false, error: { code: "VALIDATION_ERROR", message: destError }, }; } } } // Check for duplicate source (exact match only for non-patterns) const existing = await repo.findBySource(input.source); if (existing) { return { success: false, error: { code: "CONFLICT", message: `A redirect from "${input.source}" already exists`, }, }; } // Check for redirect loops (skip if creating as disabled, or terminal — // a Gone rule has no destination, so it can't form a loop) if (!terminal && input.enabled !== false) { const edges = toEdges(await repo.findAllEnabled()); const loopPath = wouldCreateLoop(input.source, destination, edges); if (loopPath) return loopError(loopPath); } const redirect = await repo.create({ source: input.source, destination, type, isPattern: sourceIsPattern, enabled: input.enabled ?? true, groupName: input.groupName ?? null, }); return { success: true, data: redirect }; } catch { return { success: false, error: { code: "REDIRECT_CREATE_ERROR", message: "Failed to create redirect" }, }; } } /** * Get a redirect by ID */ export async function handleRedirectGet( db: Kysely, id: string, ): Promise> { try { const repo = new RedirectRepository(db); const redirect = await repo.findById(id); if (!redirect) { return { success: false, error: { code: "NOT_FOUND", message: `Redirect "${id}" not found` }, }; } return { success: true, data: redirect }; } catch { return { success: false, error: { code: "REDIRECT_GET_ERROR", message: "Failed to fetch redirect" }, }; } } /** * Update a redirect by ID */ export async function handleRedirectUpdate( db: Kysely, id: string, input: { source?: string; destination?: string; type?: number; enabled?: boolean; groupName?: string | null; }, ): Promise> { try { const repo = new RedirectRepository(db); const existing = await repo.findById(id); if (!existing) { return { success: false, error: { code: "NOT_FOUND", message: `Redirect "${id}" not found` }, }; } const newSource = input.source ?? existing.source; const newDest = input.destination ?? existing.destination; // Source and destination must differ if (newSource === newDest) { return { success: false, error: { code: "VALIDATION_ERROR", message: "Source and destination must be different", }, }; } // If source is changing, validate patterns if (input.source !== undefined) { const sourceIsPattern = isPattern(input.source); if (sourceIsPattern) { const patternError = validatePattern(input.source); if (patternError) { return { success: false, error: { code: "VALIDATION_ERROR", message: `Invalid source pattern: ${patternError}`, }, }; } } // Check for duplicate source (exclude self) const dup = await repo.findBySource(input.source); if (dup && dup.id !== id) { return { success: false, error: { code: "CONFLICT", message: `A redirect from "${input.source}" already exists`, }, }; } } // Validate destination params against the (possibly updated) source const newSourceIsPattern = isPattern(newSource); if (newSourceIsPattern) { const destError = validateDestinationParams(newSource, newDest); if (destError) { return { success: false, error: { code: "VALIDATION_ERROR", message: destError }, }; } } // Check for redirect loops if source or destination changed if (input.source !== undefined || input.destination !== undefined) { const edges = toEdges(await repo.findAllEnabled()); const loopPath = wouldCreateLoop(newSource, newDest, edges, id); if (loopPath) return loopError(loopPath); } const updated = await repo.update(id, { source: input.source, destination: input.destination, type: input.type, enabled: input.enabled, groupName: input.groupName, }); if (!updated) { return { success: false, error: { code: "REDIRECT_UPDATE_ERROR", message: "Failed to update redirect" }, }; } // Recompute cache — redirect was modified, so re-fetch await updateLoopCache(db); return { success: true, data: updated }; } catch { return { success: false, error: { code: "REDIRECT_UPDATE_ERROR", message: "Failed to update redirect" }, }; } } /** * Delete a redirect by ID */ export async function handleRedirectDelete( db: Kysely, id: string, ): Promise> { try { const repo = new RedirectRepository(db); const deleted = await repo.delete(id); if (!deleted) { return { success: false, error: { code: "NOT_FOUND", message: `Redirect "${id}" not found` }, }; } await updateLoopCache(db); return { success: true, data: { deleted: true } }; } catch { return { success: false, error: { code: "REDIRECT_DELETE_ERROR", message: "Failed to delete redirect" }, }; } } // --------------------------------------------------------------------------- // Loop analysis cache // --------------------------------------------------------------------------- function loopError(loopPath: string[]): ApiResult { const hops = loopPath .slice(0, -1) .map((p, i) => `${p} \u2192 ${loopPath[i + 1]}`) .join("\n"); return { success: false, error: { code: "VALIDATION_ERROR", message: `This redirect would create a loop:\n${hops}`, }, }; } function toEdges(redirects: Redirect[]): RedirectEdge[] { return redirects.map((r) => ({ id: r.id, source: r.source, destination: r.destination, enabled: r.enabled, isPattern: r.isPattern, })); } const LOOP_CACHE_KEY = "_redirect_loop_ids"; /** * Recompute loop redirect IDs and store in the options table. */ async function updateLoopCache(db: Kysely): Promise { try { const options = new OptionsRepository(db); const edges = toEdges(await new RedirectRepository(db).findAllEnabled()); const loopRedirectIds = detectLoops(edges); await options.set(LOOP_CACHE_KEY, loopRedirectIds); } catch (error) { console.error("Failed to update redirect loop cache:", error); } } /** * Get loop redirect IDs from cache, computing lazily on first access. */ async function getLoopRedirectIds(db: Kysely): Promise { try { const options = new OptionsRepository(db); const cached = await options.get(LOOP_CACHE_KEY); if (cached !== null) return cached; // First access after upgrade — compute and cache await updateLoopCache(db); return (await options.get(LOOP_CACHE_KEY)) ?? []; } catch { return []; } } // --------------------------------------------------------------------------- // 404 Log // --------------------------------------------------------------------------- /** * List 404 log entries with cursor pagination */ export async function handleNotFoundList( db: Kysely, params: { cursor?: string; limit?: number; search?: string }, ): Promise>> { try { const repo = new RedirectRepository(db); const result = await repo.find404s(params); return { success: true, data: result }; } catch (error) { if (error instanceof InvalidCursorError) { return { success: false, error: { code: "INVALID_CURSOR", message: error.message }, }; } return { success: false, error: { code: "NOT_FOUND_LIST_ERROR", message: "Failed to fetch 404 log" }, }; } } /** * Get 404 summary (grouped by path, sorted by count) */ export async function handleNotFoundSummary( db: Kysely, limit?: number, ): Promise> { try { const repo = new RedirectRepository(db); const items = await repo.get404Summary(limit); return { success: true, data: { items } }; } catch { return { success: false, error: { code: "NOT_FOUND_SUMMARY_ERROR", message: "Failed to fetch 404 summary" }, }; } } /** * Clear all 404 log entries */ export async function handleNotFoundClear( db: Kysely, ): Promise> { try { const repo = new RedirectRepository(db); const deleted = await repo.clear404s(); return { success: true, data: { deleted } }; } catch { return { success: false, error: { code: "NOT_FOUND_CLEAR_ERROR", message: "Failed to clear 404 log" }, }; } } /** * Prune 404 log entries older than a given date */ export async function handleNotFoundPrune( db: Kysely, olderThan: string, ): Promise> { try { const repo = new RedirectRepository(db); const deleted = await repo.prune404s(olderThan); return { success: true, data: { deleted } }; } catch { return { success: false, error: { code: "NOT_FOUND_PRUNE_ERROR", message: "Failed to prune 404 log" }, }; } }