diff --git a/my-sonicjs-app/migrations/032_redirect_plugin.sql b/my-sonicjs-app/migrations/032_redirect_plugin.sql new file mode 100644 index 000000000..8c16ea789 --- /dev/null +++ b/my-sonicjs-app/migrations/032_redirect_plugin.sql @@ -0,0 +1,118 @@ +-- Redirect Management Plugin Migration +-- Version: 1.0.0 +-- Description: Initialize redirect management plugin with redirects and analytics tables + +-- Insert plugin entry into plugins table +INSERT INTO plugins ( + id, + name, + display_name, + description, + version, + author, + category, + status, + settings, + installed_at, + last_updated +) VALUES ( + 'redirect-management', + 'redirect-management', + 'Redirect Management', + 'URL redirect management with exact, partial, and regex matching', + '1.0.0', + 'SonicJS Community', + 'utilities', + 'inactive', + json('{ + "enabled": true + }'), + strftime('%s', 'now') * 1000, + strftime('%s', 'now') * 1000 +) +ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + version = excluded.version, + updated_at = excluded.last_updated; + +-- Create redirects table +CREATE TABLE IF NOT EXISTS redirects ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + destination TEXT NOT NULL, + match_type INTEGER NOT NULL DEFAULT 0, + status_code INTEGER NOT NULL DEFAULT 301, + is_active INTEGER NOT NULL DEFAULT 1, + created_by TEXT NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Create indexes for redirects table +CREATE INDEX IF NOT EXISTS idx_redirects_source ON redirects(source); +CREATE INDEX IF NOT EXISTS idx_redirects_active ON redirects(is_active); +CREATE INDEX IF NOT EXISTS idx_redirects_match_type ON redirects(match_type); + +-- Create redirect_analytics table +CREATE TABLE IF NOT EXISTS redirect_analytics ( + id TEXT PRIMARY KEY, + redirect_id TEXT NOT NULL REFERENCES redirects(id) ON DELETE CASCADE, + hit_count INTEGER NOT NULL DEFAULT 0, + last_hit_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +-- Create UNIQUE index for redirect_analytics table (required for ON CONFLICT) +CREATE UNIQUE INDEX IF NOT EXISTS idx_redirect_analytics_redirect_id ON redirect_analytics(redirect_id); + +-- Insert sample redirect data +-- Get the first active admin user ID (or use a placeholder) +INSERT OR IGNORE INTO redirects (id, source, destination, match_type, status_code, is_active, created_by, created_at, updated_at) +SELECT + 'redirect-1', + '/old-page', + '/new-page', + 0, + 301, + 1, + COALESCE((SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY created_at LIMIT 1), 'admin-user-id'), + strftime('%s', 'now') * 1000, + strftime('%s', 'now') * 1000; + +INSERT OR IGNORE INTO redirects (id, source, destination, match_type, status_code, is_active, created_by, created_at, updated_at) +SELECT + 'redirect-2', + '/blog/old-post', + '/blog/new-post', + 0, + 302, + 1, + COALESCE((SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY created_at LIMIT 1), 'admin-user-id'), + strftime('%s', 'now') * 1000, + strftime('%s', 'now') * 1000; + +INSERT OR IGNORE INTO redirects (id, source, destination, match_type, status_code, is_active, created_by, created_at, updated_at) +SELECT + 'redirect-3', + '/temp-page', + '/permanent-page', + 0, + 307, + 0, + COALESCE((SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY created_at LIMIT 1), 'admin-user-id'), + strftime('%s', 'now') * 1000, + strftime('%s', 'now') * 1000; + +INSERT OR IGNORE INTO redirects (id, source, destination, match_type, status_code, is_active, created_by, created_at, updated_at) +SELECT + 'redirect-4', + '/gone-page', + '/gone-page', + 0, + 410, + 1, + COALESCE((SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY created_at LIMIT 1), 'admin-user-id'), + strftime('%s', 'now') * 1000, + strftime('%s', 'now') * 1000; diff --git a/my-sonicjs-app/migrations/033_redirect_query_params.sql b/my-sonicjs-app/migrations/033_redirect_query_params.sql new file mode 100644 index 000000000..c9f907bca --- /dev/null +++ b/my-sonicjs-app/migrations/033_redirect_query_params.sql @@ -0,0 +1,6 @@ +-- Add query parameter handling columns to redirects table +-- Version: 1.0.1 +-- Description: Add include_query_params and preserve_query_params columns + +ALTER TABLE redirects ADD COLUMN include_query_params INTEGER NOT NULL DEFAULT 0; +ALTER TABLE redirects ADD COLUMN preserve_query_params INTEGER NOT NULL DEFAULT 0; diff --git a/my-sonicjs-app/migrations/034_add_updated_by_to_redirects.sql b/my-sonicjs-app/migrations/034_add_updated_by_to_redirects.sql new file mode 100644 index 000000000..1228bc2f3 --- /dev/null +++ b/my-sonicjs-app/migrations/034_add_updated_by_to_redirects.sql @@ -0,0 +1,8 @@ +-- Add updated_by column to track who last modified each redirect +ALTER TABLE redirects ADD COLUMN updated_by TEXT REFERENCES users(id); + +-- Add index for JOIN performance +CREATE INDEX IF NOT EXISTS idx_redirects_updated_by ON redirects(updated_by); + +-- Backfill existing records (set updated_by = created_by for existing redirects) +UPDATE redirects SET updated_by = created_by WHERE updated_by IS NULL; diff --git a/my-sonicjs-app/migrations/035_add_source_plugin_to_redirects.sql b/my-sonicjs-app/migrations/035_add_source_plugin_to_redirects.sql new file mode 100644 index 000000000..15b8c8f40 --- /dev/null +++ b/my-sonicjs-app/migrations/035_add_source_plugin_to_redirects.sql @@ -0,0 +1,6 @@ +-- Add source_plugin column to track which plugin created the redirect +-- NULL = created via admin UI, otherwise contains plugin ID (e.g., 'qr-code') +ALTER TABLE redirects ADD COLUMN source_plugin TEXT; + +-- Add index for filtering by source plugin +CREATE INDEX IF NOT EXISTS idx_redirects_source_plugin ON redirects(source_plugin); diff --git a/my-sonicjs-app/migrations/036_align_redirects_with_cloudflare.sql b/my-sonicjs-app/migrations/036_align_redirects_with_cloudflare.sql new file mode 100644 index 000000000..c1612ca22 --- /dev/null +++ b/my-sonicjs-app/migrations/036_align_redirects_with_cloudflare.sql @@ -0,0 +1,25 @@ +-- Migration 036: Align redirects table with Cloudflare Bulk Redirects options +-- +-- Changes: +-- 1. Rename preserve_query_params → preserve_query_string (Cloudflare alignment) +-- 2. Remove include_query_params (not a Cloudflare option) - column remains but unused +-- 3. Add include_subdomains column (Cloudflare: include_subdomains) +-- 4. Add subpath_matching column (Cloudflare: subpath_matching) +-- 5. Add preserve_path_suffix column (Cloudflare: preserve_path_suffix, default true) +-- +-- Note: SQLite does not support DROP COLUMN, so include_query_params remains but is unused. +-- Note: SQLite does not support RENAME COLUMN in older versions, so we add new column and migrate data. + +-- Step 1: Add preserve_query_string column (copy of preserve_query_params) +ALTER TABLE redirects ADD COLUMN preserve_query_string INTEGER DEFAULT 0; + +-- Step 2: Copy existing data from preserve_query_params to preserve_query_string +UPDATE redirects SET preserve_query_string = COALESCE(preserve_query_params, 0); + +-- Step 3: Add new Cloudflare-aligned columns +ALTER TABLE redirects ADD COLUMN include_subdomains INTEGER DEFAULT 0; +ALTER TABLE redirects ADD COLUMN subpath_matching INTEGER DEFAULT 0; +ALTER TABLE redirects ADD COLUMN preserve_path_suffix INTEGER DEFAULT 1; + +-- Note: The old columns (include_query_params, preserve_query_params) remain in the table +-- but will be ignored by the application. Future migrations can clean these up. diff --git a/my-sonicjs-app/migrations/037_update_redirect_plugin_settings.sql b/my-sonicjs-app/migrations/037_update_redirect_plugin_settings.sql new file mode 100644 index 000000000..89cb7f9be --- /dev/null +++ b/my-sonicjs-app/migrations/037_update_redirect_plugin_settings.sql @@ -0,0 +1,12 @@ +-- Migration: Update redirect-management plugin author and add autoOffloadEnabled setting +-- This updates the plugin metadata after the Cloudflare Bulk Redirects integration + +UPDATE plugins +SET + author = 'ahaas', + settings = json_set( + COALESCE(settings, '{}'), + '$.autoOffloadEnabled', + json('false') + ) +WHERE id = 'redirect-management'; diff --git a/my-sonicjs-app/migrations/038_add_soft_delete_to_redirects.sql b/my-sonicjs-app/migrations/038_add_soft_delete_to_redirects.sql new file mode 100644 index 000000000..d9f330971 --- /dev/null +++ b/my-sonicjs-app/migrations/038_add_soft_delete_to_redirects.sql @@ -0,0 +1,7 @@ +-- Migration: Add soft delete support to redirects table +-- Adds deleted_at column for soft delete timestamp (NULL = not deleted) + +ALTER TABLE redirects ADD COLUMN deleted_at INTEGER DEFAULT NULL; + +-- Create index for efficient filtering of non-deleted records +CREATE INDEX IF NOT EXISTS idx_redirects_deleted_at ON redirects(deleted_at); diff --git a/my-sonicjs-app/src/plugins/redirect-management/index.ts b/my-sonicjs-app/src/plugins/redirect-management/index.ts new file mode 100644 index 000000000..29c2fb33e --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/index.ts @@ -0,0 +1,109 @@ +import { PluginBuilder } from '@sonicjs-cms/core' +import type { Plugin, PluginContext } from '@sonicjs-cms/core' +import manifest from './manifest.json' +import { RedirectService } from './services/redirect' +import { createRedirectAdminRoutes } from './routes/admin' +import { createRedirectApiRoutes } from './routes/api' + +// Export middleware for direct mounting in app +export { createRedirectMiddleware, invalidateRedirectCache, warmRedirectCache } from './middleware/redirect' + +// Export admin routes for mounting +export { createRedirectAdminRoutes } from './routes/admin' + +// Export API routes for mounting +export { createRedirectApiRoutes } from './routes/api' + +export function createRedirectPlugin(): Plugin { + const builder = PluginBuilder.create({ + name: manifest.id, + version: manifest.version, + description: manifest.description + }) + + builder.metadata({ + author: { name: manifest.author }, + license: manifest.license, + compatibility: '^2.0.0' + }) + + // Admin routes + builder.addRoute('/admin/redirects', createRedirectAdminRoutes(), { + description: 'Redirect management admin routes', + requiresAuth: true, + priority: 100 + }) + + // API routes + builder.addRoute('/api/redirects', createRedirectApiRoutes(), { + description: 'Redirect management REST API', + requiresAuth: false, // API handles its own auth via Bearer tokens + priority: 100 + }) + + // Add admin page + builder.addAdminPage( + '/redirect-management/settings', + 'Redirect Management Settings', + 'RedirectManagementSettings', + { + description: 'Configure redirect settings and manage URL redirects', + icon: 'arrow-right', + permissions: ['admin', 'redirect.manage'] + } + ) + + // Add menu item + builder.addMenuItem('Redirects', '/admin/redirects', { + icon: 'arrow-right', + order: 85, + permissions: ['admin', 'redirect.manage'] + }) + + // Register service + let redirectService: RedirectService | null = null + + builder.addService('redirectService', { + implementation: RedirectService, + description: 'Redirect management service for lifecycle and settings', + singleton: true + }) + + // Lifecycle + builder.lifecycle({ + install: async (context: PluginContext) => { + redirectService = new RedirectService(context.db) + await redirectService.install() + console.log('Redirect Management plugin installed successfully') + }, + activate: async (context: PluginContext) => { + redirectService = new RedirectService(context.db) + await redirectService.activate() + console.log('Redirect Management plugin activated') + }, + deactivate: async (context: PluginContext) => { + if (redirectService) { + await redirectService.deactivate() + redirectService = null + } + console.log('Redirect Management plugin deactivated') + }, + uninstall: async (context: PluginContext) => { + if (redirectService) { + await redirectService.uninstall() + redirectService = null + } + console.log('Redirect Management plugin uninstalled') + }, + configure: async (config: any) => { + if (redirectService) { + await redirectService.saveSettings(config) + } + console.log('Redirect Management plugin configured', config) + } + }) + + return builder.build() +} + +export default createRedirectPlugin() diff --git a/my-sonicjs-app/src/plugins/redirect-management/manifest.json b/my-sonicjs-app/src/plugins/redirect-management/manifest.json new file mode 100644 index 000000000..220a7f2bb --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/manifest.json @@ -0,0 +1,37 @@ +{ + "id": "redirect-management", + "name": "Redirect Management", + "version": "1.0.0", + "description": "URL redirect management with exact, partial, and regex matching", + "author": "ahaas", + "homepage": "https://sonicjs.com/plugins/redirect-management", + "license": "MIT", + "category": "utilities", + "tags": ["redirects", "seo", "urls", "utilities"], + "dependencies": [], + "settings": { + "enabled": { + "type": "boolean", + "label": "Enable Redirects", + "description": "Enable or disable redirect processing", + "default": true + }, + "autoOffloadEnabled": { + "type": "boolean", + "label": "Auto-Sync to Cloudflare", + "description": "Automatically sync eligible redirects (EXACT/WILDCARD with 301/302/307/308) to Cloudflare Bulk Redirects. Requires CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables.", + "default": false + } + }, + "permissions": { + "redirect.manage": "Manage redirects and settings", + "redirect.view": "View redirects" + }, + "routes": [], + "adminMenu": { + "label": "Redirects", + "icon": "arrow-right", + "path": "/admin/redirects", + "order": 85 + } +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/middleware/redirect.ts b/my-sonicjs-app/src/plugins/redirect-management/middleware/redirect.ts new file mode 100644 index 000000000..47d8d6f15 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/middleware/redirect.ts @@ -0,0 +1,208 @@ +import type { Context, Next } from 'hono' +import type { D1Database } from '@cloudflare/workers-types' +import { normalizeUrl, normalizeUrlWithQuery } from '../utils/url-normalizer' +import { RedirectCache } from '../utils/cache' +import { RedirectService } from '../services/redirect' + +// Module-level cache (singleton per worker instance) +let redirectCache: RedirectCache | null = null + +interface RedirectMiddlewareOptions { + cacheSize?: number // Default 1000 +} + +export function createRedirectMiddleware(options: RedirectMiddlewareOptions = {}) { + const cacheSize = options.cacheSize ?? 1000 + + // Initialize cache on first call + if (!redirectCache) { + redirectCache = new RedirectCache(cacheSize) + } + + return async (c: Context, next: Next): Promise => { + const url = new URL(c.req.url) + const pathname = url.pathname + + // Skip redirect processing for admin routes + if (pathname.startsWith('/admin/redirects')) { + await next() + return + } + + const db = (c.env?.DB || c.get('db')) as D1Database | undefined + if (!db) { + // No database, skip redirect processing + await next() + return + } + + // Normalize URL for matching + const normalizedPath = normalizeUrl(pathname) + + // Check cache first (sub-millisecond) + let cached = redirectCache?.get(normalizedPath) + + if (!cached) { + // Also try with full path + query for query-inclusive redirects + const normalizedWithQuery = normalizeUrlWithQuery(url.pathname + url.search, true) + cached = redirectCache?.get(normalizedWithQuery) + } + + if (!cached) { + // Cache miss - lookup in database using RedirectService + const redirectService = new RedirectService(db) + const redirect = await redirectService.lookupBySource(normalizedPath) + + if (redirect && redirect.isActive) { + // Cache the result + cached = { + id: redirect.id, + destination: redirect.destination, + statusCode: redirect.statusCode, + isActive: redirect.isActive, + matchType: redirect.matchType, + preserveQueryString: redirect.preserveQueryString, + includeSubdomains: redirect.includeSubdomains, + subpathMatching: redirect.subpathMatching, + preservePathSuffix: redirect.preservePathSuffix + } + redirectCache?.set(normalizedPath, cached) + + // Also record hit asynchronously (don't block redirect) + recordHitAsync(db, redirect.id) + } + } + + // Execute redirect if found and active + if (cached && cached.isActive) { + // Handle 410 Gone specially (not a redirect) + if (cached.statusCode === 410) { + return new Response(null, { + status: 410, + headers: { + 'Cache-Control': 'public, max-age=31536000' // 410 is cacheable + } + }) + } + + // Build destination URL + let destination = cached.destination + + // Preserve query string if configured (Cloudflare-aligned) + if (cached.preserveQueryString && url.search) { + if (destination.includes('?')) { + // Append to existing query + destination += '&' + url.search.slice(1) + } else { + destination += url.search + } + } + + // Handle subpath matching with path suffix preservation + if (cached.subpathMatching && cached.preservePathSuffix) { + // If the request path extends beyond the source pattern, append the suffix + const sourcePath = normalizedPath + const requestPath = pathname + if (requestPath.length > sourcePath.length && requestPath.startsWith(sourcePath)) { + const pathSuffix = requestPath.slice(sourcePath.length) + if (destination.includes('?')) { + // Insert before query string + const [basePath, query] = destination.split('?') + destination = basePath + pathSuffix + '?' + query + } else { + destination += pathSuffix + } + } + } + + // Record hit asynchronously (cache hit path) + recordHitAsync((c.env?.DB || c.get('db')) as D1Database, cached.id) + + // Execute redirect + return c.redirect(destination, cached.statusCode as 301 | 302 | 307 | 308) + } + + // No redirect found or inactive - continue to next middleware + await next() + } +} + +// Async hit recording (don't await - fire and forget) +function recordHitAsync(db: D1Database | undefined, redirectId: string): void { + if (!db) return + + // Use waitUntil if available (Cloudflare Workers), otherwise fire-and-forget + void db + .prepare(` + INSERT INTO redirect_analytics (id, redirect_id, hit_count, last_hit_at, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, ?) + ON CONFLICT(redirect_id) DO UPDATE SET + hit_count = hit_count + 1, + last_hit_at = excluded.last_hit_at, + updated_at = excluded.updated_at + `) + .bind( + crypto.randomUUID(), + redirectId, + Date.now(), + Date.now(), + Date.now() + ) + .run() + .catch(err => console.error('[RedirectMiddleware] Hit recording error:', err)) + + // Don't await - let it run in background +} + +// Cache invalidation function (call from service layer) +export function invalidateRedirectCache(): void { + if (redirectCache) { + redirectCache.clear() + } +} + +// Pre-warm cache function (call on startup) +export async function warmRedirectCache(db: D1Database): Promise { + if (!redirectCache) { + redirectCache = new RedirectCache(1000) + } + + try { + const { results } = await db + .prepare(` + SELECT r.id, r.source, r.destination, r.status_code, r.is_active, + r.match_type, + COALESCE(r.preserve_query_string, 0) as preserve_query_string, + COALESCE(r.include_subdomains, 0) as include_subdomains, + COALESCE(r.subpath_matching, 0) as subpath_matching, + COALESCE(r.preserve_path_suffix, 1) as preserve_path_suffix, + COALESCE(a.hit_count, 0) as hit_count + FROM redirects r + LEFT JOIN redirect_analytics a ON r.id = a.redirect_id + WHERE r.is_active = 1 AND r.deleted_at IS NULL + ORDER BY hit_count DESC + LIMIT 1000 + `) + .all() + + for (const row of results) { + const normalizedSource = normalizeUrl(row.source as string) + redirectCache.set(normalizedSource, { + id: row.id as string, + destination: row.destination as string, + statusCode: row.status_code as number, + isActive: true, + matchType: row.match_type as number, + preserveQueryString: (row.preserve_query_string as number ?? 0) === 1, + includeSubdomains: (row.include_subdomains as number ?? 0) === 1, + subpathMatching: (row.subpath_matching as number ?? 0) === 1, + preservePathSuffix: (row.preserve_path_suffix as number ?? 1) === 1 + }) + } + + return results.length + } catch (error) { + console.error('[RedirectMiddleware] Cache warming error:', error) + return 0 + } +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/routes/admin.ts b/my-sonicjs-app/src/plugins/redirect-management/routes/admin.ts new file mode 100644 index 000000000..4fc5a59b7 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/routes/admin.ts @@ -0,0 +1,623 @@ +import { Hono } from 'hono' +import { RedirectService } from '../services/redirect' +import { renderRedirectListPage } from '../templates/redirect-list.template' +import { renderRedirectFormPage } from '../templates/redirect-form.template' +import { generateCSV, buildExportFilename, parseCSV, validateCSVBatch, generateErrorCSV } from '../services/csv.service' +import type { RedirectFilter, MatchType, StatusCode, CreateRedirectInput, UpdateRedirectInput, DuplicateHandling, ParsedRedirectRow } from '../types' + +/** + * Render an alert message HTML fragment for HTMX + */ +function renderAlertFragment(type: 'error' | 'warning', message: string): string { + const colors = { + error: 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400', + warning: 'border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400' + } + return `

${message}

` +} + +/** + * Create admin route handlers for redirect management UI + */ +export function createRedirectAdminRoutes(): Hono { + const admin = new Hono() + + /** + * GET / (mounted at /admin/redirects) + * Display the redirect list page with filtering and pagination + */ + admin.get('/', async (c: any) => { + try { + // Get DB from context (Cloudflare Workers env) + const db = c.env?.DB || c.get('db') + if (!db) { + console.error('[Redirect Admin] Database not available. c.env:', c.env, 'c.get(db):', c.get('db')) + return c.html('

Database not available

', 500) + } + + // Parse query parameters + const page = parseInt(c.req.query('page') || '1') + const limit = parseInt(c.req.query('limit') || '20') + const search = c.req.query('search') || undefined + const statusCodeParam = c.req.query('statusCode') + const matchTypeParam = c.req.query('matchType') + const isActiveParam = c.req.query('isActive') + const successMessage = c.req.query('success') || undefined + + // Parse status code filter + let statusCode: StatusCode | undefined + if (statusCodeParam && ['301', '302', '307', '308', '410'].includes(statusCodeParam)) { + statusCode = parseInt(statusCodeParam) as StatusCode + } + + // Parse match type filter + let matchType: MatchType | undefined + if (matchTypeParam && ['0', '1', '2'].includes(matchTypeParam)) { + matchType = parseInt(matchTypeParam) as MatchType + } + + // Parse active status filter + let isActive: boolean | undefined + if (isActiveParam === 'true') { + isActive = true + } else if (isActiveParam === 'false') { + isActive = false + } + + // Build filter object with only defined properties + const filter: RedirectFilter = { + limit, + offset: (page - 1) * limit + } + + if (search !== undefined) filter.search = search + if (statusCode !== undefined) filter.statusCode = statusCode + if (matchType !== undefined) filter.matchType = matchType + if (isActive !== undefined) filter.isActive = isActive + + // Fetch redirects and count in parallel + const service = new RedirectService(db) + const [redirects, total] = await Promise.all([ + service.list(filter), + service.count(filter) + ]) + + // Calculate pagination + const totalPages = Math.ceil(total / limit) + + // Render page + const html = renderRedirectListPage({ + redirects, + pagination: { + page, + limit, + total, + totalPages + }, + filters: { + search, + statusCode: statusCodeParam, + matchType: matchTypeParam, + isActive: isActiveParam + }, + user: c.get('user'), + successMessage + }) + + return c.html(html) + } catch (error) { + console.error('Error loading redirect list page:', error) + return c.html('

Error loading redirects

', 500) + } + }) + + /** + * GET /admin/redirects/export + * Export redirects as CSV file, respecting current filters + */ + admin.get('/export', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.text('Database not available', 500) + } + + // Parse the same filter parameters as the list route + const statusCodeParam = c.req.query('statusCode') + const matchTypeParam = c.req.query('matchType') + const isActiveParam = c.req.query('isActive') + const search = c.req.query('search') || undefined + + // Build filter object (same logic as list route) + const filter: RedirectFilter = {} + + if (statusCodeParam && ['301', '302', '307', '308', '410'].includes(statusCodeParam)) { + filter.statusCode = parseInt(statusCodeParam) as StatusCode + } + if (matchTypeParam && ['0', '1', '2'].includes(matchTypeParam)) { + filter.matchType = parseInt(matchTypeParam) as MatchType + } + if (isActiveParam === 'true') { + filter.isActive = true + } else if (isActiveParam === 'false') { + filter.isActive = false + } + if (search) { + filter.search = search + } + + // Remove pagination limits - export all matching redirects + // (but keep a reasonable safety limit) + filter.limit = 10000 + filter.offset = 0 + + // Fetch redirects matching filters + const service = new RedirectService(db) + const redirects = await service.list(filter) + + // Generate CSV + const csv = generateCSV(redirects) + + // Build descriptive filename + const filename = buildExportFilename({ + statusCode: statusCodeParam, + matchType: matchTypeParam, + isActive: isActiveParam, + search + }) + + // Return CSV with proper headers for download + return new Response(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"` + } + }) + } catch (error) { + console.error('Error exporting CSV:', error) + return c.text('Failed to export redirects', 500) + } + }) + + /** + * POST /admin/redirects/import + * Import redirects from CSV file + */ + admin.post('/import', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.html(renderAlertFragment('error', 'Database not available'), 500) + } + + // Parse multipart form data + const body = await c.req.parseBody() + const file = body.csv_file as File + const duplicateHandling = (body.duplicate_handling || 'reject') as DuplicateHandling + + // Validate file exists + if (!file || file.size === 0) { + return c.html(renderAlertFragment('error', 'No file uploaded'), 400) + } + + // Validate file size (10MB limit) + const MAX_FILE_SIZE = 10 * 1024 * 1024 + if (file.size > MAX_FILE_SIZE) { + return c.html( + renderAlertFragment('error', + `File too large. Maximum size is 10MB, got ${(file.size / 1024 / 1024).toFixed(1)}MB` + ), + 400 + ) + } + + // Parse CSV content + const content = await file.text() + const parseResult = parseCSV(content) + + if (!parseResult.isValid) { + // Parse errors (malformed CSV) + const errorList = parseResult.errors.map(e => `Line ${e.line}: ${e.error}`).join('; ') + return c.html( + renderAlertFragment('error', `CSV parsing failed: ${errorList}`), + 400 + ) + } + + // Validate row count (10,000 limit) + const MAX_ROWS = 10000 + if (parseResult.rows.length > MAX_ROWS) { + return c.html( + renderAlertFragment('error', + `Too many rows. Maximum is ${MAX_ROWS}, got ${parseResult.rows.length}` + ), + 400 + ) + } + + if (parseResult.rows.length === 0) { + return c.html( + renderAlertFragment('error', 'CSV file is empty (no data rows found)'), + 400 + ) + } + + // Get existing redirects for validation + const service = new RedirectService(db) + const existingMap = await service.getAllSourceDestinationMap() + + // Validate all rows + const validation = await validateCSVBatch( + parseResult.rows as ParsedRedirectRow[], + existingMap, + duplicateHandling + ) + + if (!validation.isValid) { + // Return error CSV as download + const errorCSV = generateErrorCSV(parseResult.rows as ParsedRedirectRow[], validation.errors) + + return new Response(errorCSV, { + status: 400, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': 'attachment; filename="import-errors.csv"' + } + }) + } + + // All valid - batch insert + const userId = c.get('user')?.id + let actualUserId = userId + if (!actualUserId) { + const adminUser = await db.prepare('SELECT id FROM users WHERE role = ? LIMIT 1').bind('admin').first() + actualUserId = adminUser?.id as string || 'system' + } + + const imported = await service.batchCreate(validation.validRows, actualUserId) + + // Build success message + let message = `Successfully imported ${imported} redirect${imported !== 1 ? 's' : ''}` + if (validation.skipped > 0) { + message += ` (${validation.skipped} duplicate${validation.skipped !== 1 ? 's' : ''} skipped)` + } + + // Return success with HX-Redirect header for HTMX compatibility + return new Response(null, { + status: 200, + headers: { + 'HX-Redirect': `/admin/redirects?success=${encodeURIComponent(message)}` + } + }) + + } catch (error) { + console.error('Error importing CSV:', error) + return c.html( + renderAlertFragment('error', + `Failed to import CSV: ${error instanceof Error ? error.message : 'Unknown error'}` + ), + 500 + ) + } + }) + + /** + * GET /admin/redirects/new + * Display the create redirect form + */ + admin.get('/new', async (c: any) => { + try { + const ref = c.req.query('ref') || undefined + const html = renderRedirectFormPage({ + isEdit: false, + referrerParams: ref, + user: c.get('user') + }) + return c.html(html) + } catch (error) { + console.error('Error loading create form:', error) + return c.html('

Error loading form

', 500) + } + }) + + /** + * GET /admin/redirects/:id/edit + * Display the edit redirect form + */ + admin.get('/:id/edit', async (c: any) => { + try { + const id = c.req.param('id') + const db = c.env?.DB || c.get('db') + if (!db) { + return c.html('

Database not available

', 500) + } + + const ref = c.req.query('ref') || undefined + const service = new RedirectService(db) + const redirect = await service.getById(id) + + if (!redirect) { + return c.redirect('/admin/redirects', 303) + } + + const html = renderRedirectFormPage({ + isEdit: true, + redirect, + referrerParams: ref, + user: c.get('user') + }) + return c.html(html) + } catch (error) { + console.error('Error loading edit form:', error) + return c.html('

Error loading form

', 500) + } + }) + + /** + * POST /admin/redirects + * Create a new redirect + */ + admin.post('/', async (c: any) => { + console.error('=== POST /admin/redirects HANDLER HIT ===') + console.error('[Redirect Admin] Request URL:', c.req.url) + console.error('[Redirect Admin] Request method:', c.req.method) + console.error('[Redirect Admin] Request headers:', Object.fromEntries(c.req.raw.headers.entries())) + + try { + const db = c.env?.DB || c.get('db') + console.error('[Redirect Admin] Database available:', !!db) + if (!db) { + console.error('[Redirect Admin] NO DATABASE - returning 500') + return c.html('

Database not available

', 500) + } + + console.error('[Redirect Admin] About to parse body...') + const body = await c.req.parseBody() + console.error('[Redirect Admin] POST /admin/redirects - Form body:', body) + + const input: CreateRedirectInput = { + source: body.source as string, + destination: body.destination as string, + statusCode: (parseInt(body.status_code as string) || 301) as StatusCode, + matchType: (parseInt(body.match_type as string) || 0) as MatchType, + preserveQueryString: body.preserve_query_string === '1', + includeSubdomains: body.include_subdomains === '1', + subpathMatching: body.subpath_matching === '1', + preservePathSuffix: body.preserve_path_suffix === '1', + isActive: body.active === '1' + } + + console.log('[Redirect Admin] Parsed input:', JSON.stringify(input, null, 2)) + + // Get user ID from context or fallback to first admin user + let userId = c.get('user')?.id + if (!userId) { + // Fallback: get first admin user from database + const adminUser = await db.prepare('SELECT id FROM users WHERE role = ? LIMIT 1').bind('admin').first() + userId = adminUser?.id as string || 'system' + } + + const service = new RedirectService(db, c.env) + const result = await service.create(input, userId) + + console.log('[Redirect Admin] Service result:', JSON.stringify({ success: result.success, error: result.error, warning: result.warning }, null, 2)) + + if (result.success) { + // Use HTTP 303 See Other - forces browser to use GET when following redirect + return c.redirect('/admin/redirects', 303) + } else { + // Return error/warning fragments for HTMX to insert into #form-messages + let html = '' + if (result.error) { + console.log('[Redirect Admin] Returning 400 with error:', result.error) + html += renderAlertFragment('error', result.error) + } + if (result.warning) { + html += renderAlertFragment('warning', result.warning) + } + return c.html(html || renderAlertFragment('error', 'An error occurred'), 400) + } + } catch (error) { + console.error('[Redirect Admin] Error creating redirect:', error) + // Return error fragment for HTMX to insert into #form-messages + const errorMessage = error instanceof Error ? error.message : String(error) + return c.html(renderAlertFragment('error', `Failed to create redirect: ${errorMessage}`), 500) + } + }) + + /** + * PUT /admin/redirects/:id + * Update an existing redirect + */ + admin.put('/:id', async (c: any) => { + try { + const id = c.req.param('id') + const db = c.env?.DB || c.get('db') + if (!db) { + return c.html('

Database not available

', 500) + } + + const body = await c.req.parseBody() + console.log('[Redirect Admin] PUT /admin/redirects/:id - Form body:', body) + + const input: UpdateRedirectInput = { + source: body.source as string, + destination: body.destination as string, + statusCode: (parseInt(body.status_code as string) || 301) as StatusCode, + matchType: (parseInt(body.match_type as string) || 0) as MatchType, + preserveQueryString: body.preserve_query_string === '1', + includeSubdomains: body.include_subdomains === '1', + subpathMatching: body.subpath_matching === '1', + preservePathSuffix: body.preserve_path_suffix === '1', + isActive: body.active === '1' + } + + console.log('[Redirect Admin] Parsed input:', JSON.stringify(input, null, 2)) + + // Get user ID from context + const userId = c.get('user')?.id + const service = new RedirectService(db, c.env) + const result = await service.update(id, input, userId) + + console.log('[Redirect Admin] Service result:', JSON.stringify({ success: result.success, error: result.error, warning: result.warning }, null, 2)) + + if (result.success) { + // Use HTTP 303 See Other - forces browser to use GET when following redirect + return c.redirect('/admin/redirects', 303) + } else { + // Return error/warning fragments for HTMX to insert into #form-messages + let html = '' + if (result.error) { + console.log('[Redirect Admin] Returning 400 with error:', result.error) + html += renderAlertFragment('error', result.error) + } + if (result.warning) { + html += renderAlertFragment('warning', result.warning) + } + return c.html(html || renderAlertFragment('error', 'An error occurred'), 400) + } + } catch (error) { + console.error('[Redirect Admin] Error updating redirect:', error) + const errorMessage = error instanceof Error ? error.message : String(error) + return c.html(renderAlertFragment('error', `Failed to update redirect: ${errorMessage}`), 500) + } + }) + + /** + * DELETE /admin/redirects/:id + * Delete a single redirect + */ + admin.delete('/:id', async (c: any) => { + try { + const id = c.req.param('id') + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json({ success: false, error: 'Database not available' }, 500) + } + + const service = new RedirectService(db, c.env) + const result = await service.delete(id) + + if (result.success) { + return c.json({ success: true, message: 'Redirect deleted successfully' }) + } else { + return c.json({ success: false, error: result.error }, 404) + } + } catch (error) { + console.error('Error deleting redirect:', error) + return c.json({ success: false, error: 'Failed to delete redirect' }, 500) + } + }) + + /** + * POST /admin/redirects/bulk-delete + * Delete multiple redirects in bulk + */ + admin.post('/bulk-delete', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json({ success: false, error: 'Database not available' }, 500) + } + + // Parse request body to get IDs + const body = await c.req.json() + const ids: string[] = body.ids || [] + + if (!Array.isArray(ids) || ids.length === 0) { + return c.json({ success: false, error: 'No redirect IDs provided' }, 400) + } + + const service = new RedirectService(db) + let deleted = 0 + let failed = 0 + const errors: string[] = [] + + // Delete each redirect + for (const id of ids) { + try { + const result = await service.delete(id) + if (result.success) { + deleted++ + } else { + failed++ + errors.push(`ID ${id}: ${result.error || 'Unknown error'}`) + } + } catch (error) { + failed++ + errors.push(`ID ${id}: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + // Return summary + if (failed === ids.length) { + // All failed + return c.json({ + success: false, + error: `Failed to delete all ${failed} redirects`, + details: errors + }, 400) + } else { + // At least some succeeded + return c.json({ + success: true, + deleted, + failed, + total: ids.length, + errors: failed > 0 ? errors : undefined + }) + } + } catch (error) { + console.error('Error in bulk delete:', error) + return c.json({ success: false, error: 'Failed to process bulk delete request' }, 500) + } + }) + + /** + * POST /admin/redirects/sync-cloudflare + * Manually sync all eligible redirects to Cloudflare + */ + admin.post('/sync-cloudflare', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json({ success: false, error: 'Database not available' }, 500) + } + + const service = new RedirectService(db, c.env) + + if (!service.isCloudflareConfigured()) { + return c.json({ + success: false, + error: 'Cloudflare not configured. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables.' + }, 400) + } + + const result = await service.syncAllToCloudflare() + + if (result.success) { + return c.json({ + success: true, + message: `Successfully synced ${result.itemsAdded} redirects to Cloudflare`, + itemsAdded: result.itemsAdded + }) + } else { + return c.json({ + success: false, + error: result.error || 'Failed to sync to Cloudflare' + }, 500) + } + } catch (error) { + console.error('Error syncing to Cloudflare:', error) + return c.json({ + success: false, + error: `Failed to sync: ${error instanceof Error ? error.message : 'Unknown error'}` + }, 500) + } + }) + + return admin +} + +export default createRedirectAdminRoutes diff --git a/my-sonicjs-app/src/plugins/redirect-management/routes/api.ts b/my-sonicjs-app/src/plugins/redirect-management/routes/api.ts new file mode 100644 index 000000000..1473565ff --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/routes/api.ts @@ -0,0 +1,271 @@ +import { Hono } from 'hono' +import { bearerAuth } from 'hono/bearer-auth' +import { RedirectService } from '../services/redirect' +import type { + RedirectFilter, + CreateRedirectInput, + UpdateRedirectInput, + MatchType, + StatusCode +} from '../types' + +/** + * RFC 9457 Problem Details error response + */ +interface APIError { + type: string + title: string + status: number + detail: string + instance?: string +} + +/** + * Helper: Create RFC 9457-compliant error response + */ +function apiError(status: number, detail: string, title?: string): APIError { + const titles: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 409: 'Conflict', + 500: 'Internal Server Error', + 503: 'Service Unavailable' + } + + return { + type: 'about:blank', + title: title || titles[status] || 'Error', + status, + detail + } +} + +/** + * Create API route handlers for redirect management + */ +export function createRedirectApiRoutes(): Hono { + const api = new Hono() + + // Optional: Apply Bearer auth to all API routes + // Skip if request has user context (internal plugin call) + api.use('/*', async (c: any, next) => { + // Internal authenticated calls bypass Bearer auth + const user = c.get('user') + if (user) { + return next() + } + + // External calls require API key + const apiKey = (c.env as any)?.REDIRECTS_API_KEY + if (apiKey) { + return bearerAuth({ token: apiKey })(c, next) + } + + // No API key configured - allow in dev, block in prod + if ((c.env as any)?.ENVIRONMENT === 'production') { + return c.json(apiError(401, 'API key required'), 401) + } + + return next() + }) + + // GET /api/redirects - List redirects with filtering + api.get('/', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json(apiError(503, 'Database unavailable'), 503) + } + + // Parse query parameters + const isActiveParam = c.req.query('isActive') + const statusCodeParam = c.req.query('statusCode') + const matchTypeParam = c.req.query('matchType') + const searchParam = c.req.query('search') + + const filter: RedirectFilter = { + limit: parseInt(c.req.query('limit') || '50'), + offset: parseInt(c.req.query('offset') || '0') + } + + if (isActiveParam === 'true') { + filter.isActive = true + } else if (isActiveParam === 'false') { + filter.isActive = false + } + + if (statusCodeParam) { + filter.statusCode = parseInt(statusCodeParam) as StatusCode + } + + if (matchTypeParam) { + filter.matchType = parseInt(matchTypeParam) as MatchType + } + + if (searchParam) { + filter.search = searchParam + } + + // Fetch data + const service = new RedirectService(db) + const [redirects, total] = await Promise.all([ + service.list(filter), + service.count(filter) + ]) + + return c.json({ + data: redirects, + pagination: { + limit: filter.limit, + offset: filter.offset, + total + } + }) + } catch (error) { + console.error('Error listing redirects:', error) + return c.json( + apiError(500, 'Failed to list redirects'), + 500 + ) + } + }) + + // GET /api/redirects/:id - Get redirect by ID + api.get('/:id', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json(apiError(503, 'Database unavailable'), 503) + } + + const id = c.req.param('id') + const service = new RedirectService(db) + const redirect = await service.getById(id) + + if (!redirect) { + return c.json( + apiError(404, `Redirect with ID ${id} not found`), + 404 + ) + } + + return c.json({ data: redirect }) + } catch (error) { + console.error('Error getting redirect:', error) + return c.json( + apiError(500, 'Failed to get redirect'), + 500 + ) + } + }) + + // POST /api/redirects - Create new redirect + api.post('/', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json(apiError(503, 'Database unavailable'), 503) + } + + const body = await c.req.json() as CreateRedirectInput + + // Basic validation + if (!body.source || !body.destination) { + return c.json( + apiError(400, 'Source and destination are required'), + 400 + ) + } + + // Get user ID (from authenticated user or API context) + const userId = c.get('user')?.id || 'api' + + const service = new RedirectService(db) + const result = await service.create(body, userId) + + if (!result.success) { + return c.json( + apiError(400, result.error!), + 400 + ) + } + + return c.json({ data: result.redirect }, 201) + } catch (error) { + console.error('Error creating redirect:', error) + return c.json( + apiError(500, 'Failed to create redirect'), + 500 + ) + } + }) + + // PUT /api/redirects/:id - Update redirect + api.put('/:id', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json(apiError(503, 'Database unavailable'), 503) + } + + const id = c.req.param('id') + const body = await c.req.json() as UpdateRedirectInput + + const service = new RedirectService(db) + const result = await service.update(id, body) + + if (!result.success) { + const status = result.error === 'Redirect not found' ? 404 : 400 + return c.json( + apiError(status, result.error!), + status + ) + } + + return c.json({ data: result.redirect }) + } catch (error) { + console.error('Error updating redirect:', error) + return c.json( + apiError(500, 'Failed to update redirect'), + 500 + ) + } + }) + + // DELETE /api/redirects/:id - Delete redirect + api.delete('/:id', async (c: any) => { + try { + const db = c.env?.DB || c.get('db') + if (!db) { + return c.json(apiError(503, 'Database unavailable'), 503) + } + + const id = c.req.param('id') + + const service = new RedirectService(db) + const result = await service.delete(id) + + if (!result.success) { + const status = result.error === 'Redirect not found' ? 404 : 400 + return c.json( + apiError(status, result.error!), + status + ) + } + + return c.json({ success: true }, 200) + } catch (error) { + console.error('Error deleting redirect:', error) + return c.json( + apiError(500, 'Failed to delete redirect'), + 500 + ) + } + }) + + return api +} + +export default createRedirectApiRoutes diff --git a/my-sonicjs-app/src/plugins/redirect-management/services/cloudflare-bulk.ts b/my-sonicjs-app/src/plugins/redirect-management/services/cloudflare-bulk.ts new file mode 100644 index 000000000..e0715a56c --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/services/cloudflare-bulk.ts @@ -0,0 +1,504 @@ +/** + * Cloudflare Bulk Redirects Service + * + * Handles synchronization of redirects to Cloudflare's Bulk Redirects feature. + * This offloads redirect processing to the edge, improving performance. + * + * Required environment variables: + * - CLOUDFLARE_API_TOKEN: API token with Account Filter Lists Edit + Account Rulesets Edit permissions + * - CLOUDFLARE_ACCOUNT_ID: Cloudflare account ID (not zone ID) + */ + +import type { Redirect, MatchType } from '../types' + +const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4' +const LIST_NAME = 'sonicjs-redirects' +const RULESET_NAME = 'SonicJS Bulk Redirects' + +export interface CloudflareConfig { + apiToken: string + accountId: string +} + +export interface CloudflareSyncResult { + success: boolean + error?: string + listId?: string + ruleId?: string + itemsAdded?: number + itemsRemoved?: number +} + +interface CloudflareListItem { + redirect: { + source_url: string + target_url: string + status_code: number + preserve_query_string?: boolean + include_subdomains?: boolean + subpath_matching?: boolean + preserve_path_suffix?: boolean + } +} + +interface CloudflareList { + id: string + name: string + kind: string +} + +interface CloudflareRuleset { + id: string + name: string + phase: string + rules: Array<{ + id: string + expression: string + action: string + }> +} + +/** + * Check if Cloudflare integration is configured + */ +export function isConfigured(env: any): boolean { + const token = env?.CLOUDFLARE_API_TOKEN + const accountId = env?.CLOUDFLARE_ACCOUNT_ID + return !!(token && accountId && token.length > 0 && accountId.length > 0) +} + +/** + * Get Cloudflare configuration from environment + */ +export function getConfig(env: any): CloudflareConfig | null { + if (!isConfigured(env)) { + return null + } + return { + apiToken: env.CLOUDFLARE_API_TOKEN, + accountId: env.CLOUDFLARE_ACCOUNT_ID + } +} + +/** + * Check if a redirect is eligible for Cloudflare sync + * Rules: + * - matchType must be EXACT (0) or WILDCARD (1) - Cloudflare doesn't support regex + * - statusCode must be 301, 302, 307, or 308 - Cloudflare doesn't support 410 + * - isActive must be true - don't sync disabled redirects + */ +export function isEligibleForSync(redirect: Redirect): boolean { + const matchType = redirect.matchType as MatchType + // EXACT = 0, WILDCARD = 1 are eligible; REGEX = 2 is not + const validMatchType = matchType === 0 || matchType === 1 + const validStatusCode = [301, 302, 307, 308].includes(redirect.statusCode) + return validMatchType && validStatusCode && redirect.isActive +} + +/** + * Make an authenticated request to the Cloudflare API + */ +async function cfFetch( + config: CloudflareConfig, + path: string, + options: RequestInit = {} +): Promise<{ success: boolean; result?: T; errors?: Array<{ message: string }> }> { + const url = `${CLOUDFLARE_API_BASE}${path}` + const headers = { + 'Authorization': `Bearer ${config.apiToken}`, + 'Content-Type': 'application/json', + ...options.headers + } + + try { + const response = await fetch(url, { + ...options, + headers + }) + + const data = await response.json() as any + + if (!response.ok || !data.success) { + console.error('[CloudflareBulk] API error:', data.errors) + return { + success: false, + errors: data.errors || [{ message: `HTTP ${response.status}` }] + } + } + + return { + success: true, + result: data.result as T + } + } catch (error) { + console.error('[CloudflareBulk] Fetch error:', error) + return { + success: false, + errors: [{ message: error instanceof Error ? error.message : 'Network error' }] + } + } +} + +/** + * Get or create the redirect list named "sonicjs-redirects" + */ +export async function getOrCreateList(config: CloudflareConfig): Promise<{ success: boolean; listId?: string; error?: string }> { + // First, try to find existing list + const listPath = `/accounts/${config.accountId}/rules/lists` + const listResponse = await cfFetch(config, listPath) + + if (listResponse.success && listResponse.result) { + const existingList = listResponse.result.find(l => l.name === LIST_NAME && l.kind === 'redirect') + if (existingList) { + console.log('[CloudflareBulk] Found existing list:', existingList.id) + return { success: true, listId: existingList.id } + } + } + + // Create new list + const createResponse = await cfFetch(config, listPath, { + method: 'POST', + body: JSON.stringify({ + name: LIST_NAME, + kind: 'redirect', + description: 'SonicJS managed redirects - auto-synced from admin panel' + }) + }) + + if (createResponse.success && createResponse.result) { + console.log('[CloudflareBulk] Created new list:', createResponse.result.id) + return { success: true, listId: createResponse.result.id } + } + + return { + success: false, + error: createResponse.errors?.[0]?.message || 'Failed to create redirect list' + } +} + +/** + * Get or create the ruleset that references our list + */ +export async function getOrCreateRule(config: CloudflareConfig, _listId: string): Promise<{ success: boolean; ruleId?: string; error?: string }> { + // Check for existing ruleset + const rulesetPath = `/accounts/${config.accountId}/rulesets` + const rulesetResponse = await cfFetch(config, rulesetPath) + + if (rulesetResponse.success && rulesetResponse.result) { + const existingRuleset = rulesetResponse.result.find(r => r.name === RULESET_NAME && r.phase === 'http_request_redirect') + if (existingRuleset) { + console.log('[CloudflareBulk] Found existing ruleset:', existingRuleset.id) + // Return first rule ID if it exists + const ruleId = existingRuleset.rules?.[0]?.id + return { success: true, ruleId: ruleId || existingRuleset.id } + } + } + + // Create new ruleset with rule + const createResponse = await cfFetch(config, rulesetPath, { + method: 'POST', + body: JSON.stringify({ + name: RULESET_NAME, + kind: 'root', + phase: 'http_request_redirect', + description: 'SonicJS bulk redirects - auto-synced from admin panel', + rules: [{ + action: 'redirect', + expression: `http.request.full_uri in $${LIST_NAME}`, + description: 'Redirect from SonicJS managed list', + action_parameters: { + from_list: { + name: LIST_NAME, + key: 'http.request.full_uri' + } + } + }] + }) + }) + + if (createResponse.success && createResponse.result) { + console.log('[CloudflareBulk] Created new ruleset:', createResponse.result.id) + const ruleId = createResponse.result.rules?.[0]?.id + return { success: true, ruleId: ruleId || createResponse.result.id } + } + + return { + success: false, + error: createResponse.errors?.[0]?.message || 'Failed to create redirect ruleset' + } +} + +/** + * Convert a SonicJS redirect to Cloudflare list item format + */ +function redirectToCloudflareItem(redirect: Redirect): CloudflareListItem { + // Build source URL - Cloudflare expects format like "example.com/path" (no protocol) + let sourceUrl = redirect.source + // Remove leading slash if present and no domain + if (sourceUrl.startsWith('/') && !sourceUrl.includes('://')) { + // For relative paths, we need a domain. This will be filled in by the middleware + // that checks the host. For now, use a placeholder pattern. + sourceUrl = `*${sourceUrl}` + } + + return { + redirect: { + source_url: sourceUrl, + target_url: redirect.destination, + status_code: redirect.statusCode, + preserve_query_string: redirect.preserveQueryString ?? false, + include_subdomains: redirect.includeSubdomains ?? false, + subpath_matching: redirect.subpathMatching ?? false, + preserve_path_suffix: redirect.preservePathSuffix ?? true + } + } +} + +/** + * Sync a single redirect to Cloudflare + * Used for real-time sync on create/update + */ +export async function syncRedirect( + config: CloudflareConfig, + listId: string, + redirect: Redirect +): Promise { + if (!isEligibleForSync(redirect)) { + console.log('[CloudflareBulk] Redirect not eligible for sync:', redirect.id) + return { success: true, itemsAdded: 0 } + } + + const item = redirectToCloudflareItem(redirect) + const path = `/accounts/${config.accountId}/rules/lists/${listId}/items` + + const response = await cfFetch<{ operation_id: string }>(config, path, { + method: 'POST', + body: JSON.stringify([item]) + }) + + if (response.success) { + console.log('[CloudflareBulk] Synced redirect:', redirect.source) + return { success: true, listId, itemsAdded: 1 } + } + + return { + success: false, + error: response.errors?.[0]?.message || 'Failed to sync redirect' + } +} + +/** + * Remove a redirect from Cloudflare by source URL + * Used for real-time sync on delete + */ +export async function removeRedirect( + config: CloudflareConfig, + listId: string, + sourceUrl: string +): Promise { + // First, get all items in the list to find the one with matching source + const listPath = `/accounts/${config.accountId}/rules/lists/${listId}/items` + const listResponse = await cfFetch>(config, listPath) + + if (!listResponse.success || !listResponse.result) { + return { + success: false, + error: 'Failed to fetch list items' + } + } + + // Find item with matching source URL + // Handle both formats: with and without leading wildcard + const normalizedSource = sourceUrl.startsWith('/') ? `*${sourceUrl}` : sourceUrl + const item = listResponse.result.find(i => + i.redirect.source_url === sourceUrl || + i.redirect.source_url === normalizedSource + ) + + if (!item) { + // Item doesn't exist in Cloudflare, nothing to remove + console.log('[CloudflareBulk] Item not found in list:', sourceUrl) + return { success: true, itemsRemoved: 0 } + } + + // Delete the item + const deleteResponse = await cfFetch(config, listPath, { + method: 'DELETE', + body: JSON.stringify({ items: [{ id: item.id }] }) + }) + + if (deleteResponse.success) { + console.log('[CloudflareBulk] Removed redirect:', sourceUrl) + return { success: true, listId, itemsRemoved: 1 } + } + + return { + success: false, + error: 'Failed to remove redirect from Cloudflare' + } +} + +/** + * Sync all eligible redirects to Cloudflare (full sync) + * Used for initial setup or manual sync + */ +export async function syncAll( + config: CloudflareConfig, + redirects: Redirect[] +): Promise { + // Get or create the list + const listResult = await getOrCreateList(config) + if (!listResult.success || !listResult.listId) { + return { + success: false, + error: listResult.error || 'Failed to get/create redirect list' + } + } + + const listId = listResult.listId + + // Get or create the rule + const ruleResult = await getOrCreateRule(config, listId) + if (!ruleResult.success) { + return { + success: false, + error: ruleResult.error || 'Failed to get/create redirect rule', + listId + } + } + + // Filter eligible redirects + const eligibleRedirects = redirects.filter(isEligibleForSync) + console.log(`[CloudflareBulk] Syncing ${eligibleRedirects.length} of ${redirects.length} redirects`) + + if (eligibleRedirects.length === 0) { + return { + success: true, + listId, + ruleId: ruleResult.ruleId, + itemsAdded: 0 + } + } + + // Clear existing items first (replace mode) + const clearPath = `/accounts/${config.accountId}/rules/lists/${listId}/items` + const existingItems = await cfFetch>(config, clearPath) + + if (existingItems.success && existingItems.result && existingItems.result.length > 0) { + const deleteBody = { items: existingItems.result.map(i => ({ id: i.id })) } + await cfFetch(config, clearPath, { + method: 'DELETE', + body: JSON.stringify(deleteBody) + }) + console.log(`[CloudflareBulk] Cleared ${existingItems.result.length} existing items`) + } + + // Add all eligible redirects + const items = eligibleRedirects.map(redirectToCloudflareItem) + + // Cloudflare has a limit of 1000 items per request, batch if needed + const BATCH_SIZE = 1000 + let totalAdded = 0 + + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE) + const addResponse = await cfFetch(config, clearPath, { + method: 'POST', + body: JSON.stringify(batch) + }) + + if (addResponse.success) { + totalAdded += batch.length + console.log(`[CloudflareBulk] Added batch of ${batch.length} items (${totalAdded}/${items.length})`) + } else { + console.error('[CloudflareBulk] Failed to add batch:', addResponse.errors) + return { + success: false, + error: `Failed to sync batch: ${addResponse.errors?.[0]?.message}`, + listId, + itemsAdded: totalAdded + } + } + } + + return { + success: true, + listId, + ruleId: ruleResult.ruleId, + itemsAdded: totalAdded + } +} + +/** + * CloudflareBulkService class for dependency injection + */ +export class CloudflareBulkService { + private config: CloudflareConfig | null + private listId: string | null = null + + constructor(env: any) { + this.config = getConfig(env) + } + + isConfigured(): boolean { + return this.config !== null + } + + async ensureSetup(): Promise<{ success: boolean; error?: string }> { + if (!this.config) { + return { success: false, error: 'Cloudflare not configured' } + } + + const listResult = await getOrCreateList(this.config) + if (!listResult.success) { + return { success: false, error: listResult.error } + } + + this.listId = listResult.listId! + + const ruleResult = await getOrCreateRule(this.config, this.listId) + if (!ruleResult.success) { + return { success: false, error: ruleResult.error } + } + + return { success: true } + } + + async syncRedirect(redirect: Redirect): Promise { + if (!this.config) { + return { success: false, error: 'Cloudflare not configured' } + } + + if (!this.listId) { + const setup = await this.ensureSetup() + if (!setup.success) { + return { success: false, error: setup.error } + } + } + + return syncRedirect(this.config, this.listId!, redirect) + } + + async removeRedirect(sourceUrl: string): Promise { + if (!this.config) { + return { success: false, error: 'Cloudflare not configured' } + } + + if (!this.listId) { + const setup = await this.ensureSetup() + if (!setup.success) { + return { success: false, error: setup.error } + } + } + + return removeRedirect(this.config, this.listId!, sourceUrl) + } + + async syncAll(redirects: Redirect[]): Promise { + if (!this.config) { + return { success: false, error: 'Cloudflare not configured' } + } + + return syncAll(this.config, redirects) + } +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/services/csv.service.ts b/my-sonicjs-app/src/plugins/redirect-management/services/csv.service.ts new file mode 100644 index 000000000..c1464d88c --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/services/csv.service.ts @@ -0,0 +1,454 @@ +/** + * CSV Service + * + * Handles CSV parsing and generation for redirect import/export + */ + +import { parse } from 'csv-parse/browser/esm/sync' +import { sanitizeCSVField } from '../utils/csv-sanitizer.js' +import { validateUrl, detectCircularRedirect } from '../utils/validator.js' +import { normalizeUrl } from '../utils/url-normalizer.js' +import type { Redirect, CSVParseResult, CSVError, ParsedRedirectRow, MatchType, CSVValidationResult, ValidatedRedirectRow, DuplicateHandling, StatusCode } from '../types.js' + +/** + * Parse CSV content into redirect rows + * + * @param content - Raw CSV content string + * @returns Parse result with rows and any errors + * + * @example + * const result = parseCSV(csvContent) + * if (result.isValid) { + * // Process result.rows + * } else { + * // Handle result.errors + * } + */ +export function parseCSV(content: string): CSVParseResult { + const errors: CSVError[] = [] + const rows: ParsedRedirectRow[] = [] + + try { + // Parse CSV with headers + const records = parse(content, { + columns: true, + skip_empty_lines: true, + trim: true + }) as Array> + + // Validate and map each row + for (let i = 0; i < records.length; i++) { + const lineNumber = i + 2 // +1 for 0-index, +1 for header row + const record = records[i] + + // Check required fields + if (!record.source_url || !record.destination_url) { + errors.push({ + line: lineNumber, + error: 'Missing required fields: source_url and destination_url' + }) + continue + } + + // Map to ParsedRedirectRow + rows.push({ + source_url: record.source_url, + destination_url: record.destination_url, + match_type: record.match_type || 'exact', + status_code: record.status_code || '301', + active: record.active || 'true', + preserve_query_string: record.preserve_query_string, + include_subdomains: record.include_subdomains, + subpath_matching: record.subpath_matching, + preserve_path_suffix: record.preserve_path_suffix + }) + } + } catch (error) { + errors.push({ + line: 1, + error: `CSV parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}` + }) + } + + return { + isValid: errors.length === 0, + rows, + errors + } +} + +/** + * Generate CSV content from redirect records + * + * @param redirects - Array of redirect records to export + * @returns CSV content string with headers and sanitized data + * + * @example + * const csv = generateCSV(redirects) + * // Returns: "id,source_url,destination_url,match_type,..." + */ +export function generateCSV(redirects: Redirect[]): string { + // Define headers (Cloudflare-aligned column names) + const headers = [ + 'id', + 'source_url', + 'destination_url', + 'match_type', + 'status_code', + 'active', + 'preserve_query_string', + 'include_subdomains', + 'subpath_matching', + 'preserve_path_suffix', + 'created_at', + 'updated_at' + ] + + // Map redirects to CSV rows + const rows = redirects.map(r => [ + r.id, + sanitizeCSVField(r.source), + sanitizeCSVField(r.destination), + matchTypeToLabel(r.matchType), + r.statusCode.toString(), + r.isActive ? 'true' : 'false', + r.preserveQueryString ? 'true' : 'false', + r.includeSubdomains ? 'true' : 'false', + r.subpathMatching ? 'true' : 'false', + r.preservePathSuffix ? 'true' : 'false', + new Date(r.createdAt).toISOString(), + new Date(r.updatedAt).toISOString() + ]) + + // Build CSV content + const csvLines = [ + headers.join(','), + ...rows.map(row => row.join(',')) + ] + + return csvLines.join('\n') +} + +/** + * Convert match type number to text label + * + * @param matchType - Numeric match type (0, 1, 2) + * @returns Text label ('exact', 'wildcard', 'regex') + */ +export function matchTypeToLabel(matchType: MatchType): string { + switch (matchType) { + case 0: + return 'exact' + case 1: + return 'wildcard' + case 2: + return 'regex' + default: + return 'exact' + } +} + +/** + * Convert match type label to number + * + * @param label - Text label or numeric string + * @returns Numeric match type (0, 1, 2) or undefined if invalid + */ +export function labelToMatchType(label: string): MatchType | undefined { + const normalized = label.toLowerCase().trim() + + switch (normalized) { + case 'exact': + case '0': + return 0 + case 'wildcard': + case 'partial': // Keep backwards compatibility with old CSV exports + case '1': + return 1 + case 'regex': + case '2': + return 2 + default: + return undefined + } +} + +/** + * Build descriptive filename based on active filters + * + * @param filters - Active filter parameters + * @returns Descriptive filename for CSV export + * + * @example + * buildExportFilename({}) // "redirects.csv" + * buildExportFilename({ statusCode: '301' }) // "redirects-301.csv" + * buildExportFilename({ statusCode: '301', isActive: 'true' }) // "redirects-301-active.csv" + * buildExportFilename({ matchType: '1' }) // "redirects-partial-match.csv" + */ +export function buildExportFilename(filters: { + statusCode?: string + matchType?: string + isActive?: string + search?: string +}): string { + const parts = ['redirects'] + + if (filters.statusCode) { + parts.push(filters.statusCode) + } + + if (filters.matchType !== undefined) { + const labels = { '0': 'exact', '1': 'wildcard', '2': 'regex' } + parts.push(`${labels[filters.matchType as keyof typeof labels] || filters.matchType}-match`) + } + + if (filters.isActive === 'true') { + parts.push('active') + } else if (filters.isActive === 'false') { + parts.push('inactive') + } + + if (filters.search) { + // Sanitize search term for filename (remove special chars) + const sanitized = filters.search.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 20) + if (sanitized) { + parts.push(`search-${sanitized}`) + } + } + + return `${parts.join('-')}.csv` +} + +/** + * Validate entire CSV batch before import (all-or-nothing) + * + * @param rows - Parsed CSV rows from parseCSV + * @param existingRedirects - Map of source->destination for existing redirects + * @param duplicateHandling - How to handle duplicates ('reject', 'skip', 'update') + * @returns Validation result with valid rows or errors + * + * @example + * const result = await validateCSVBatch(rows, existingMap, 'reject') + * if (result.isValid) { + * // Import result.validRows + * } else { + * // Show result.errors to user + * } + */ +export async function validateCSVBatch( + rows: ParsedRedirectRow[], + existingRedirects: Map, + duplicateHandling: DuplicateHandling +): Promise { + const errors: CSVError[] = [] + const validRows: ValidatedRedirectRow[] = [] + let skipped = 0 + + // Build combined map: existing + import file (for circular detection) + const combinedMap = new Map(existingRedirects) + + // Track sources seen in this import file (for intra-file duplicate detection) + const seenInFile = new Set() + + for (let i = 0; i < rows.length; i++) { + const lineNumber = i + 2 // +1 for 0-index, +1 for header row + const row = rows[i] + + // Required field validation + if (!row.source_url || !row.destination_url) { + errors.push({ + line: lineNumber, + error: 'Missing required fields: source_url and destination_url are required' + }) + continue + } + + // URL format validation + const sourceValidation = validateUrl(row.source_url) + if (!sourceValidation.isValid) { + errors.push({ + line: lineNumber, + field: 'source_url', + value: row.source_url, + error: sourceValidation.error! + }) + continue + } + + const destValidation = validateUrl(row.destination_url) + if (!destValidation.isValid) { + errors.push({ + line: lineNumber, + field: 'destination_url', + value: row.destination_url, + error: destValidation.error! + }) + continue + } + + // Parse and validate status code + const statusCode = parseInt(row.status_code || '301') + if (![301, 302, 307, 308, 410].includes(statusCode)) { + errors.push({ + line: lineNumber, + field: 'status_code', + value: row.status_code, + error: 'Invalid status code. Must be 301, 302, 307, 308, or 410' + }) + continue + } + + // Parse match type (accept both labels and numbers) + const matchType = labelToMatchType(row.match_type || 'exact') + if (matchType === undefined) { + errors.push({ + line: lineNumber, + field: 'match_type', + value: row.match_type, + error: 'Invalid match type. Must be exact, wildcard, regex (or 0, 1, 2)' + }) + continue + } + + // Normalize source for duplicate detection + const normalizedSource = normalizeUrl(row.source_url) + + // Check for intra-file duplicates + if (seenInFile.has(normalizedSource)) { + if (duplicateHandling === 'reject') { + errors.push({ + line: lineNumber, + field: 'source_url', + value: row.source_url, + error: 'Duplicate source URL found earlier in this file' + }) + continue + } else { + skipped++ + continue // skip or update: skip the duplicate row in file + } + } + + // Check for database duplicates + if (existingRedirects.has(normalizedSource)) { + if (duplicateHandling === 'reject') { + errors.push({ + line: lineNumber, + field: 'source_url', + value: row.source_url, + error: 'Source URL already exists in database' + }) + continue + } else if (duplicateHandling === 'skip') { + skipped++ + continue + } + // 'update' mode: will overwrite, continue processing + } + + // Add to tracking sets + seenInFile.add(normalizedSource) + combinedMap.set(normalizedSource, row.destination_url) + + // Create validated row + validRows.push({ + source: normalizedSource, + destination: row.destination_url, + matchType: matchType as MatchType, + statusCode: statusCode as StatusCode, + isActive: row.active?.toLowerCase() !== 'false', + preserveQueryString: row.preserve_query_string?.toLowerCase() === 'true', + includeSubdomains: row.include_subdomains?.toLowerCase() === 'true', + subpathMatching: row.subpath_matching?.toLowerCase() === 'true', + preservePathSuffix: row.preserve_path_suffix?.toLowerCase() !== 'false' // Default true + }) + } + + // Second pass: circular redirect detection across entire batch + for (let i = 0; i < validRows.length; i++) { + const row = validRows[i] + const lineNumber = findLineNumberForSource(rows, row.source) + + const circularCheck = detectCircularRedirect( + row.source, + row.destination, + combinedMap + ) + + if (!circularCheck.isValid) { + errors.push({ + line: lineNumber, + field: 'destination_url', + value: row.destination, + error: circularCheck.error! + }) + } + } + + // If any errors, return empty validRows (all-or-nothing) + return { + isValid: errors.length === 0, + validRows: errors.length === 0 ? validRows : [], + errors, + skipped + } +} + +/** + * Helper to find line number for a source URL + */ +function findLineNumberForSource(rows: ParsedRedirectRow[], normalizedSource: string): number { + for (let i = 0; i < rows.length; i++) { + if (normalizeUrl(rows[i].source_url) === normalizedSource) { + return i + 2 + } + } + return 0 +} + +/** + * Generate error CSV with line numbers and error messages + * + * @param rows - Original parsed CSV rows + * @param errors - Validation errors to include + * @returns CSV content string with error information + * + * @example + * const errorCSV = generateErrorCSV(rows, validationResult.errors) + * // Download as "import-errors.csv" + */ +export function generateErrorCSV(rows: ParsedRedirectRow[], errors: CSVError[]): string { + // Map errors by line number for quick lookup + const errorMap = new Map() + for (const err of errors) { + const existing = errorMap.get(err.line) || [] + existing.push(err.error) + errorMap.set(err.line, existing) + } + + // Headers: add line_number and error columns at the start + const headers = ['line_number', 'error', 'source_url', 'destination_url', 'match_type', 'status_code', 'active'] + + const csvRows: string[] = [headers.join(',')] + + // Only include rows that have errors + for (let i = 0; i < rows.length; i++) { + const lineNumber = i + 2 + const rowErrors = errorMap.get(lineNumber) + + if (rowErrors) { + const row = rows[i] + csvRows.push([ + lineNumber.toString(), + sanitizeCSVField(rowErrors.join('; ')), + sanitizeCSVField(row.source_url), + sanitizeCSVField(row.destination_url), + sanitizeCSVField(row.match_type), + sanitizeCSVField(row.status_code), + sanitizeCSVField(row.active) + ].join(',')) + } + } + + return csvRows.join('\n') +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/services/redirect.ts b/my-sonicjs-app/src/plugins/redirect-management/services/redirect.ts new file mode 100644 index 000000000..cfc606823 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/services/redirect.ts @@ -0,0 +1,883 @@ +import manifest from '../manifest.json' +import type { RedirectSettings, Redirect, CreateRedirectInput, UpdateRedirectInput, RedirectFilter, RedirectOperationResult, MatchType, StatusCode, ValidatedRedirectRow } from '../types' +import type { D1Database } from '@cloudflare/workers-types' +import { normalizeUrl } from '../utils/url-normalizer' +import { validateRedirect, type ValidationResult } from '../utils/validator' +import { invalidateRedirectCache } from '../middleware/redirect' +import { CloudflareBulkService, isEligibleForSync } from './cloudflare-bulk' + +export class RedirectService { + private cloudflareService: CloudflareBulkService | null = null + + constructor(private db: D1Database, private env?: any) { + if (env) { + this.cloudflareService = new CloudflareBulkService(env) + } + } + + /** + * Get plugin settings from the database + */ + async getSettings(): Promise<{ status: string; data: RedirectSettings }> { + try { + const record = await this.db + .prepare(`SELECT settings, status FROM plugins WHERE id = ?`) + .bind(manifest.id) + .first() + + if (!record) { + return { + status: 'inactive', + data: this.getDefaultSettings() + } + } + + return { + status: (record?.status as string) || 'inactive', + data: record?.settings ? JSON.parse(record.settings as string) : this.getDefaultSettings() + } + } catch (error) { + console.error('Error getting redirect management settings:', error) + return { + status: 'inactive', + data: this.getDefaultSettings() + } + } + } + + /** + * Get default settings + */ + getDefaultSettings(): RedirectSettings { + return { + enabled: true, + autoOffloadEnabled: false + } + } + + /** + * Check if Cloudflare auto-offload is enabled and configured + */ + private async shouldSyncToCloudflare(): Promise { + if (!this.cloudflareService?.isConfigured()) { + return false + } + const { data: settings } = await this.getSettings() + return settings.autoOffloadEnabled === true + } + + /** + * Sync redirect to Cloudflare if enabled (fire-and-forget) + */ + private async syncToCloudflareIfEnabled(redirect: Redirect): Promise { + try { + const shouldSync = await this.shouldSyncToCloudflare() + if (shouldSync && this.cloudflareService && isEligibleForSync(redirect)) { + const result = await this.cloudflareService.syncRedirect(redirect) + if (!result.success) { + console.error('[RedirectService] Cloudflare sync failed:', result.error) + } + } + } catch (error) { + // Log but don't throw - Cloudflare sync failures shouldn't block D1 operations + console.error('[RedirectService] Cloudflare sync error:', error) + } + } + + /** + * Remove redirect from Cloudflare if enabled (fire-and-forget) + */ + private async removeFromCloudflareIfEnabled(sourceUrl: string): Promise { + try { + const shouldSync = await this.shouldSyncToCloudflare() + if (shouldSync && this.cloudflareService) { + const result = await this.cloudflareService.removeRedirect(sourceUrl) + if (!result.success) { + console.error('[RedirectService] Cloudflare remove failed:', result.error) + } + } + } catch (error) { + console.error('[RedirectService] Cloudflare remove error:', error) + } + } + + // CRUD Operations + + /** + * Create a new redirect with validation + */ + async create(input: CreateRedirectInput, userId: string): Promise { + try { + // Generate unique ID + const id = crypto.randomUUID() + + // Set defaults for optional fields + const matchType = input.matchType ?? 0 // MatchType.EXACT + const statusCode = input.statusCode ?? 301 + const isActive = input.isActive ?? true + const preserveQueryString = input.preserveQueryString ?? false + const includeSubdomains = input.includeSubdomains ?? false + const subpathMatching = input.subpathMatching ?? false + const preservePathSuffix = input.preservePathSuffix ?? true + const sourcePlugin = input.sourcePlugin ?? null + + // Load existing redirects for circular detection + const existingMap = await this.getAllSourceDestinationMap() + + // Validate redirect + const validation = validateRedirect(input.source, input.destination, existingMap) + if (!validation.isValid) { + return { + success: false, + redirect: undefined, + error: validation.error, + warning: undefined + } + } + + // Normalize source URL for storage (lowercase, no trailing slash) + const normalizedSource = normalizeUrl(input.source) + const now = Date.now() + + // Insert into database + // NOTE: Migration 036 adds Cloudflare-aligned columns + await this.db + .prepare(` + INSERT INTO redirects ( + id, source, destination, match_type, status_code, is_active, + preserve_query_string, include_subdomains, subpath_matching, preserve_path_suffix, + source_plugin, created_by, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .bind( + id, + normalizedSource, + input.destination, + matchType, + statusCode, + isActive ? 1 : 0, + preserveQueryString ? 1 : 0, + includeSubdomains ? 1 : 0, + subpathMatching ? 1 : 0, + preservePathSuffix ? 1 : 0, + sourcePlugin, + userId, + now, + now + ) + .run() + + // Fetch the created redirect + const redirect = await this.getById(id) + + // Invalidate cache after successful creation + invalidateRedirectCache() + + // Sync to Cloudflare if enabled (async, non-blocking) + if (redirect) { + this.syncToCloudflareIfEnabled(redirect) + } + + return { + success: true, + redirect: redirect!, + error: undefined, + warning: validation.warning + } + } catch (error) { + console.error('Error creating redirect:', error) + return { + success: false, + redirect: undefined, + error: `Failed to create redirect: ${error instanceof Error ? error.message : String(error)}`, + warning: undefined + } + } + } + + /** + * Batch create redirects (for CSV import) + * Uses D1 batch API for performance + */ + async batchCreate(rows: ValidatedRedirectRow[], userId: string): Promise { + const now = Date.now() + + // D1 has 100 parameter limit per statement + // With 13 columns, max ~7 rows per INSERT + const BATCH_SIZE = 7 + const statements = [] + + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE) + + const placeholders = batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ') + const values = batch.flatMap(r => [ + crypto.randomUUID(), + r.source, + r.destination, + r.matchType, + r.statusCode, + r.isActive ? 1 : 0, + r.preserveQueryString ? 1 : 0, + r.includeSubdomains ? 1 : 0, + r.subpathMatching ? 1 : 0, + r.preservePathSuffix ? 1 : 0, + userId, + now, + now + ]) + + statements.push( + this.db.prepare(` + INSERT INTO redirects ( + id, source, destination, match_type, status_code, is_active, + preserve_query_string, include_subdomains, subpath_matching, preserve_path_suffix, + created_by, created_at, updated_at + ) VALUES ${placeholders} + `).bind(...values) + ) + } + + // Execute all INSERTs in single batch (transaction) + await this.db.batch(statements) + + // Invalidate cache + invalidateRedirectCache() + + // Note: Cloudflare sync for batch imports should be done via manual "Sync Now" button + // to avoid rate limiting and performance issues + + return rows.length + } + + /** + * Get redirect by ID + */ + async getById(id: string): Promise { + try { + const row = await this.db + .prepare(` + SELECT + r.id, r.source, r.destination, r.match_type, r.status_code, r.is_active, + COALESCE(r.preserve_query_string, 0) as preserve_query_string, + COALESCE(r.include_subdomains, 0) as include_subdomains, + COALESCE(r.subpath_matching, 0) as subpath_matching, + COALESCE(r.preserve_path_suffix, 1) as preserve_path_suffix, + r.source_plugin, + r.created_by, r.created_at, r.updated_at, r.updated_by, + COALESCE(a.hit_count, 0) as hit_count, + a.last_hit_at, + creator.first_name || ' ' || creator.last_name as created_by_name, + updater.first_name || ' ' || updater.last_name as updated_by_name + FROM redirects r + LEFT JOIN redirect_analytics a ON r.id = a.redirect_id + LEFT JOIN users creator ON r.created_by = creator.id + LEFT JOIN users updater ON r.updated_by = updater.id + WHERE r.id = ? AND r.deleted_at IS NULL + `) + .bind(id) + .first() + + if (!row) { + return null + } + + return this.mapRowToRedirect(row) + } catch (error) { + console.error('Error getting redirect by ID:', error) + return null + } + } + + /** + * Update an existing redirect + */ + async update(id: string, input: UpdateRedirectInput, userId?: string): Promise { + try { + // Fetch existing redirect + const existing = await this.getById(id) + if (!existing) { + return { + success: false, + redirect: undefined, + error: 'Redirect not found', + warning: undefined + } + } + + // If source or destination changed, validate + let validation: ValidationResult | undefined + if (input.source || input.destination) { + const newSource = input.source ?? existing.source + const newDestination = input.destination ?? existing.destination + + // Build map excluding current redirect (so we don't detect self as circular) + const existingMap = await this.getAllSourceDestinationMap() + existingMap.delete(normalizeUrl(existing.source)) + + validation = validateRedirect(newSource, newDestination, existingMap) + if (!validation.isValid) { + return { + success: false, + redirect: undefined, + error: validation.error, + warning: undefined + } + } + } + + // Build update query dynamically based on provided fields + const updates: string[] = [] + const bindings: any[] = [] + + if (input.source !== undefined) { + updates.push('source = ?') + bindings.push(normalizeUrl(input.source)) + } + if (input.destination !== undefined) { + updates.push('destination = ?') + bindings.push(input.destination) + } + if (input.matchType !== undefined) { + updates.push('match_type = ?') + bindings.push(input.matchType) + } + if (input.statusCode !== undefined) { + updates.push('status_code = ?') + bindings.push(input.statusCode) + } + if (input.isActive !== undefined) { + updates.push('is_active = ?') + bindings.push(input.isActive ? 1 : 0) + } + if (input.preserveQueryString !== undefined) { + updates.push('preserve_query_string = ?') + bindings.push(input.preserveQueryString ? 1 : 0) + } + if (input.includeSubdomains !== undefined) { + updates.push('include_subdomains = ?') + bindings.push(input.includeSubdomains ? 1 : 0) + } + if (input.subpathMatching !== undefined) { + updates.push('subpath_matching = ?') + bindings.push(input.subpathMatching ? 1 : 0) + } + if (input.preservePathSuffix !== undefined) { + updates.push('preserve_path_suffix = ?') + bindings.push(input.preservePathSuffix ? 1 : 0) + } + + // Track who made this update + if (userId) { + updates.push('updated_by = ?') + bindings.push(userId) + } + + // Always update updated_at + updates.push('updated_at = ?') + bindings.push(Date.now()) + + // Add ID to bindings + bindings.push(id) + + if (updates.length === 1) { + // Only updated_at would change, nothing to do + return { + success: true, + redirect: existing, + error: undefined, + warning: undefined + } + } + + // Execute update + await this.db + .prepare(`UPDATE redirects SET ${updates.join(', ')} WHERE id = ?`) + .bind(...bindings) + .run() + + // Fetch updated redirect + const updated = await this.getById(id) + + // Invalidate cache after successful update + invalidateRedirectCache() + + // Sync to Cloudflare if enabled (async, non-blocking) + if (updated) { + this.syncToCloudflareIfEnabled(updated) + } + + return { + success: true, + redirect: updated!, + error: undefined, + warning: validation?.warning + } + } catch (error) { + console.error('Error updating redirect:', error) + return { + success: false, + redirect: undefined, + error: `Failed to update redirect: ${error instanceof Error ? error.message : String(error)}`, + warning: undefined + } + } + } + + /** + * Delete a redirect (soft delete - sets deleted_at timestamp) + */ + async delete(id: string): Promise { + try { + // Get redirect before deleting (for Cloudflare sync) + const redirect = await this.getById(id) + + const now = Date.now() + const result = await this.db + .prepare(`UPDATE redirects SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`) + .bind(now, id) + .run() + + if (result.meta.changes > 0) { + // Invalidate cache after successful deletion + invalidateRedirectCache() + + // Remove from Cloudflare if enabled (async, non-blocking) + if (redirect) { + this.removeFromCloudflareIfEnabled(redirect.source) + } + + return { + success: true, + redirect: undefined, + error: undefined, + warning: undefined + } + } else { + return { + success: false, + redirect: undefined, + error: 'Redirect not found', + warning: undefined + } + } + } catch (error) { + console.error('Error deleting redirect:', error) + return { + success: false, + redirect: undefined, + error: `Failed to delete redirect: ${error instanceof Error ? error.message : String(error)}`, + warning: undefined + } + } + } + + /** + * List redirects with optional filtering and pagination + */ + async list(filter?: RedirectFilter): Promise { + try { + const conditions: string[] = ['r.deleted_at IS NULL'] + const bindings: any[] = [] + + // Build WHERE clause from filters + if (filter?.isActive !== undefined) { + conditions.push('r.is_active = ?') + bindings.push(filter.isActive ? 1 : 0) + } + if (filter?.statusCode !== undefined) { + conditions.push('r.status_code = ?') + bindings.push(filter.statusCode) + } + if (filter?.matchType !== undefined) { + conditions.push('r.match_type = ?') + bindings.push(filter.matchType) + } + if (filter?.search) { + conditions.push('(r.source LIKE ? OR r.destination LIKE ?)') + const searchPattern = `%${filter.search}%` + bindings.push(searchPattern, searchPattern) + } + if (filter?.sourcePlugin !== undefined) { + if (filter.sourcePlugin === null) { + conditions.push('r.source_plugin IS NULL') + } else { + conditions.push('r.source_plugin = ?') + bindings.push(filter.sourcePlugin) + } + } + + const whereClause = `WHERE ${conditions.join(' AND ')}` + + // Build query with pagination + const limit = filter?.limit ?? 50 + const offset = filter?.offset ?? 0 + + const query = ` + SELECT + r.id, r.source, r.destination, r.match_type, r.status_code, r.is_active, + COALESCE(r.preserve_query_string, 0) as preserve_query_string, + COALESCE(r.include_subdomains, 0) as include_subdomains, + COALESCE(r.subpath_matching, 0) as subpath_matching, + COALESCE(r.preserve_path_suffix, 1) as preserve_path_suffix, + r.source_plugin, + r.created_by, r.created_at, r.updated_at, r.updated_by, + COALESCE(a.hit_count, 0) as hit_count, + a.last_hit_at, + creator.first_name || ' ' || creator.last_name as created_by_name, + updater.first_name || ' ' || updater.last_name as updated_by_name + FROM redirects r + LEFT JOIN redirect_analytics a ON r.id = a.redirect_id + LEFT JOIN users creator ON r.created_by = creator.id + LEFT JOIN users updater ON r.updated_by = updater.id + ${whereClause} + ORDER BY r.created_at DESC + LIMIT ? OFFSET ? + ` + + bindings.push(limit, offset) + + const result = await this.db.prepare(query).bind(...bindings).all() + + return result.results.map(row => this.mapRowToRedirect(row)) + } catch (error) { + console.error('Error listing redirects:', error) + return [] + } + } + + /** + * Count redirects matching filter (for pagination) + */ + async count(filter?: RedirectFilter): Promise { + try { + const conditions: string[] = ['deleted_at IS NULL'] + const bindings: any[] = [] + + // Build WHERE clause from filters (same as list()) + if (filter?.isActive !== undefined) { + conditions.push('is_active = ?') + bindings.push(filter.isActive ? 1 : 0) + } + if (filter?.statusCode !== undefined) { + conditions.push('status_code = ?') + bindings.push(filter.statusCode) + } + if (filter?.matchType !== undefined) { + conditions.push('match_type = ?') + bindings.push(filter.matchType) + } + if (filter?.search) { + conditions.push('(source LIKE ? OR destination LIKE ?)') + const searchPattern = `%${filter.search}%` + bindings.push(searchPattern, searchPattern) + } + if (filter?.sourcePlugin !== undefined) { + if (filter.sourcePlugin === null) { + conditions.push('source_plugin IS NULL') + } else { + conditions.push('source_plugin = ?') + bindings.push(filter.sourcePlugin) + } + } + + const whereClause = `WHERE ${conditions.join(' AND ')}` + + const result = await this.db + .prepare(`SELECT COUNT(*) as count FROM redirects ${whereClause}`) + .bind(...bindings) + .first() + + return (result?.count as number) ?? 0 + } catch (error) { + console.error('Error counting redirects:', error) + return 0 + } + } + + /** + * Lookup redirect by source URL (used by middleware) + */ + async lookupBySource(normalizedSource: string): Promise { + try { + const row = await this.db + .prepare(` + SELECT + id, source, destination, match_type, status_code, is_active, + COALESCE(preserve_query_string, 0) as preserve_query_string, + COALESCE(include_subdomains, 0) as include_subdomains, + COALESCE(subpath_matching, 0) as subpath_matching, + COALESCE(preserve_path_suffix, 1) as preserve_path_suffix, + created_by, created_at, updated_at + FROM redirects + WHERE LOWER(source) = ? AND is_active = 1 AND deleted_at IS NULL + LIMIT 1 + `) + .bind(normalizedSource.toLowerCase()) + .first() + + if (!row) { + return null + } + + return this.mapRowToRedirect(row) + } catch (error) { + console.error('Error looking up redirect by source:', error) + return null + } + } + + /** + * Get all source->destination mappings for circular detection + * @internal Helper method for validation + */ + async getAllSourceDestinationMap(): Promise> { + try { + const result = await this.db + .prepare(`SELECT source, destination FROM redirects WHERE is_active = 1 AND deleted_at IS NULL`) + .all() + + const map = new Map() + for (const row of result.results) { + const normalizedSource = normalizeUrl(row.source as string) + map.set(normalizedSource, row.destination as string) + } + + return map + } catch (error) { + console.error('Error getting source-destination map:', error) + return new Map() + } + } + + /** + * Map database row to Redirect type + * @internal Helper method for type conversion + */ + private mapRowToRedirect(row: any): Redirect { + const redirect: Redirect = { + id: row.id as string, + source: row.source as string, + destination: row.destination as string, + matchType: row.match_type as MatchType, + statusCode: row.status_code as StatusCode, + isActive: row.is_active === 1, + preserveQueryString: (row.preserve_query_string ?? 0) === 1, + includeSubdomains: (row.include_subdomains ?? 0) === 1, + subpathMatching: (row.subpath_matching ?? 0) === 1, + preservePathSuffix: (row.preserve_path_suffix ?? 1) === 1, + createdBy: row.created_by as string, + createdAt: row.created_at as number, + updatedAt: row.updated_at as number + } + + // Add optional analytics fields if present + if (row.hit_count !== undefined) { + redirect.hitCount = (row.hit_count ?? 0) as number + } + if (row.last_hit_at !== undefined) { + redirect.lastHitAt = row.last_hit_at as number | null + } + if (row.created_by_name) { + redirect.createdByName = row.created_by_name as string + } + if (row.updated_by_name) { + redirect.updatedByName = row.updated_by_name as string + } + if (row.updated_by !== undefined) { + redirect.updatedBy = row.updated_by as string + } + if (row.source_plugin !== undefined) { + redirect.sourcePlugin = row.source_plugin as string | null + } + if (row.deleted_at !== undefined) { + redirect.deletedAt = row.deleted_at as number | null + } + + return redirect + } + + /** + * Sync all eligible redirects to Cloudflare (manual sync) + */ + async syncAllToCloudflare(): Promise<{ success: boolean; itemsAdded?: number; error?: string }> { + if (!this.cloudflareService?.isConfigured()) { + return { success: false, error: 'Cloudflare not configured. Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID environment variables.' } + } + + try { + // Fetch all active redirects + const redirects = await this.list({ isActive: true, limit: 10000 }) + const result = await this.cloudflareService.syncAll(redirects) + return result + } catch (error) { + console.error('[RedirectService] Full Cloudflare sync error:', error) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to sync to Cloudflare' + } + } + } + + /** + * Check if Cloudflare integration is configured + */ + isCloudflareConfigured(): boolean { + return this.cloudflareService?.isConfigured() ?? false + } + + /** + * Save plugin settings to the database + */ + async saveSettings(settings: RedirectSettings): Promise { + try { + console.log('[RedirectService.saveSettings] Starting save for plugin:', manifest.id) + console.log('[RedirectService.saveSettings] Settings:', JSON.stringify(settings)) + + // Check if plugin row exists + const existing = await this.db + .prepare(`SELECT id, status FROM plugins WHERE id = ?`) + .bind(manifest.id) + .first() + + console.log('[RedirectService.saveSettings] Existing row:', JSON.stringify(existing)) + + if (existing) { + // Update existing row + console.log('[RedirectService.saveSettings] Updating existing row...') + const result = await this.db + .prepare(`UPDATE plugins SET settings = ?, last_updated = ? WHERE id = ?`) + .bind(JSON.stringify(settings), Date.now(), manifest.id) + .run() + console.log('[RedirectService.saveSettings] UPDATE result:', JSON.stringify(result)) + console.log('[RedirectService.saveSettings] Successfully updated') + } else { + // Insert new row + console.log('[RedirectService.saveSettings] No existing row, inserting new...') + const result = await this.db + .prepare(` + INSERT INTO plugins (id, name, display_name, description, version, author, category, status, settings, installed_at, last_updated) + VALUES (?, ?, ?, ?, ?, ?, ?, 'inactive', ?, ?, ?) + `) + .bind( + manifest.id, + manifest.id, + manifest.name, + manifest.description || '', + manifest.version || '1.0.0', + manifest.author || 'Unknown', + manifest.category || 'other', + JSON.stringify(settings), + Date.now(), + Date.now() + ) + .run() + console.log('[RedirectService.saveSettings] INSERT result:', JSON.stringify(result)) + console.log('[RedirectService.saveSettings] Successfully inserted') + } + console.log('[RedirectService.saveSettings] Settings saved successfully') + } catch (error) { + console.error('[RedirectService.saveSettings] ERROR:', error) + console.error('[RedirectService.saveSettings] Error message:', error instanceof Error ? error.message : String(error)) + console.error('[RedirectService.saveSettings] Error stack:', error instanceof Error ? error.stack : 'No stack') + throw new Error(`Failed to save redirect management settings: ${error instanceof Error ? error.message : String(error)}`) + } + } + + // Lifecycle methods + /** + * Install the plugin (create database entry) + */ + async install(): Promise { + try { + const defaultSettings = this.getDefaultSettings() + await this.db + .prepare(` + INSERT INTO plugins ( + id, name, display_name, description, version, author, + category, status, settings, installed_at, last_updated + ) + VALUES (?, ?, ?, ?, ?, ?, ?, 'inactive', ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + version = excluded.version, + updated_at = excluded.last_updated + `) + .bind( + manifest.id, + manifest.id, + manifest.name, + manifest.description, + manifest.version, + manifest.author, + manifest.category, + JSON.stringify(defaultSettings), + Date.now(), + Date.now() + ) + .run() + console.log('Redirect management plugin installed successfully') + } catch (error) { + console.error('Error installing redirect management plugin:', error) + throw new Error('Failed to install redirect management plugin') + } + } + + /** + * Activate the plugin + */ + async activate(): Promise { + try { + await this.db + .prepare(` + UPDATE plugins + SET status = 'active', last_updated = ? + WHERE id = ? + `) + .bind(Date.now(), manifest.id) + .run() + console.log('Redirect management plugin activated') + } catch (error) { + console.error('Error activating redirect management plugin:', error) + throw new Error('Failed to activate redirect management plugin') + } + } + + /** + * Deactivate the plugin + */ + async deactivate(): Promise { + try { + await this.db + .prepare(` + UPDATE plugins + SET status = 'inactive', last_updated = ? + WHERE id = ? + `) + .bind(Date.now(), manifest.id) + .run() + console.log('Redirect management plugin deactivated') + } catch (error) { + console.error('Error deactivating redirect management plugin:', error) + throw new Error('Failed to deactivate redirect management plugin') + } + } + + /** + * Uninstall the plugin (remove database entry) + */ + async uninstall(): Promise { + try { + await this.db + .prepare(`DELETE FROM plugins WHERE id = ?`) + .bind(manifest.id) + .run() + console.log('Redirect management plugin uninstalled') + } catch (error) { + console.error('Error uninstalling redirect management plugin:', error) + throw new Error('Failed to uninstall redirect management plugin') + } + } +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/templates/redirect-form.template.ts b/my-sonicjs-app/src/plugins/redirect-management/templates/redirect-form.template.ts new file mode 100644 index 000000000..b1ca7d032 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/templates/redirect-form.template.ts @@ -0,0 +1,411 @@ +import { html } from 'hono/html' +import type { HtmlEscapedString } from 'hono/utils/html' +import type { Redirect } from '../types' +import { renderAdminLayoutCatalyst } from '@sonicjs-cms/core/templates' + +export interface RedirectFormPageData { + /** Whether this is an edit form (true) or create form (false) */ + isEdit: boolean + /** The redirect being edited (only populated for edit forms) */ + redirect?: Redirect | undefined + /** Validation error message to display */ + error?: string | undefined + /** Warning message to display */ + warning?: string | undefined + /** Preserved filter params from list page for back navigation */ + referrerParams?: string | undefined + /** Current user */ + user: any +} + +/** + * Render the redirect create/edit form page + */ +export function renderRedirectFormPage(data: RedirectFormPageData): HtmlEscapedString | Promise { + const { isEdit, redirect, error, warning, referrerParams } = data + const pageTitle = isEdit ? 'Edit Redirect' : 'New Redirect' + const submitText = isEdit ? 'Update Redirect' : 'Create Redirect' + const formAction = isEdit ? `/admin/redirects/${redirect?.id}` : '/admin/redirects' + + const backUrl = referrerParams + ? `/admin/redirects?${referrerParams}` + : '/admin/redirects' + + const content = html` +
+ +
+
+

+ ${pageTitle} +

+

+ ${isEdit ? 'Modify an existing redirect rule' : 'Create a new redirect rule'} +

+
+
+ + + ${error ? renderAlert('error', error) : ''} + ${warning ? renderAlert('warning', warning) : ''} + + +
+
+
+ + +
+

+ URLs +

+ + +
+ + +

+ The URL path to redirect from (e.g., /old-page) +

+
+ + +
+ + +

+ The URL to redirect to (path or full URL) +

+
+
+ + +
+

+ Behavior +

+ + +
+ + +

+ 301/308 for permanent moves (SEO), 302/307 for temporary, 410 for deleted pages +

+
+ + +
+ + +

+ Exact: URL must match exactly. Wildcard: Matches URLs with prefix/contains. Regex: Pattern matching (local only). +

+
+
+ + +
+

+ Options + (Cloudflare Bulk Redirects compatible) +

+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + ${isEdit && redirect ? html` + +
+

+ Audit Trail +

+
+
+
Created By
+
+ ${(redirect as any).createdByName || 'Unknown'} + + (${formatRelativeTime(redirect.createdAt)}) + +
+
+ ${(redirect as any).updatedByName ? html` +
+
Last Updated By
+
+ ${(redirect as any).updatedByName} + + (${formatRelativeTime(redirect.updatedAt)}) + +
+
+ ` : ''} + ${(redirect as any).hitCount !== undefined ? html` +
+
Total Hits
+
+ ${((redirect as any).hitCount || 0).toLocaleString()} + ${(redirect as any).lastHitAt ? html` + + (last: ${formatRelativeTime((redirect as any).lastHitAt)}) + + ` : ''} +
+
+ ` : ''} +
+
+ ` : ''} + + +
+ + Cancel + + +
+
+
+
+ + ${getFormScripts()} + ` + + return renderLayout(pageTitle, content) +} + +/** + * Format relative time using native Intl.RelativeTimeFormat + */ +function formatRelativeTime(timestamp: number): string { + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + const seconds = Math.floor((timestamp - Date.now()) / 1000) + + if (Math.abs(seconds) < 60) return rtf.format(seconds, 'second') + const minutes = Math.floor(seconds / 60) + if (Math.abs(minutes) < 60) return rtf.format(minutes, 'minute') + const hours = Math.floor(minutes / 60) + if (Math.abs(hours) < 24) return rtf.format(hours, 'hour') + const days = Math.floor(hours / 24) + if (Math.abs(days) < 30) return rtf.format(days, 'day') + const months = Math.floor(days / 30) + if (Math.abs(months) < 12) return rtf.format(months, 'month') + const years = Math.floor(months / 12) + return rtf.format(years, 'year') +} + +/** + * Render alert message box + */ +function renderAlert(type: 'error' | 'warning', message: string): HtmlEscapedString | Promise { + const colors = { + error: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 border-red-200 dark:border-red-800', + warning: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800' + } + + return html` +
+

${message}

+
+ ` +} + +/** + * Get form interaction scripts + */ +function getFormScripts(): HtmlEscapedString | Promise { + return html` + + + ` +} + +/** + * Render page layout using shared admin layout template + */ +function renderLayout(title: string, content: any): HtmlEscapedString | Promise { + return renderAdminLayoutCatalyst({ + title, + currentPath: '/admin/redirects', + content: content.toString() + }) as HtmlEscapedString +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/templates/redirect-list.template.ts b/my-sonicjs-app/src/plugins/redirect-management/templates/redirect-list.template.ts new file mode 100644 index 000000000..108379985 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/templates/redirect-list.template.ts @@ -0,0 +1,1030 @@ +import { html } from 'hono/html' +import type { HtmlEscapedString } from 'hono/utils/html' +import type { Redirect } from '../types' +import { renderAdminLayoutCatalyst } from '@sonicjs-cms/core/templates' + +export interface RedirectListPageData { + redirects: Redirect[] + pagination: { + page: number + limit: number + total: number + totalPages: number + } + filters: { + search?: string + statusCode?: string + matchType?: string + isActive?: string + } + user: any + successMessage?: string +} + +/** + * Render the redirect list page with table, filters, and pagination + */ +export function renderRedirectListPage(data: RedirectListPageData): HtmlEscapedString | Promise { + const { redirects, pagination, filters, successMessage } = data + + const content = html` +
+ + ${successMessage ? html` +
+

${successMessage}

+
+ ` : ''} + + +
+
+

↗️ Redirects

+

+ Manage URL redirects and monitor redirect activity +

+
+ +
+ + + + + + ${renderFilterBar(filters)} + + + ${renderActiveFilterChips(filters)} + + +
+ ${redirects.length > 0 ? renderTable(redirects) : renderEmptyState(filters)} +
+ + + ${pagination.totalPages > 1 ? renderPagination(pagination, filters) : ''} +
+ + ${getConfirmationDialogScript()} + ` + + return renderLayout('Redirects', content) +} + +/** + * Render filter bar with search and filter controls + */ +function renderFilterBar(filters: RedirectListPageData['filters']): HtmlEscapedString | Promise { + return html` +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + ${hasActiveFilters(filters) ? html` + + ` : ''} +
+
+ + + ` +} + +/** + * Check if any filters are active + */ +function hasActiveFilters(filters: RedirectListPageData['filters']): boolean { + return !!(filters.search || filters.statusCode || filters.matchType || filters.isActive) +} + +/** + * Build query string from filters for export URL + */ +function buildQueryString(filters: RedirectListPageData['filters']): string { + const params = new URLSearchParams() + if (filters.search) params.set('search', filters.search) + if (filters.statusCode) params.set('statusCode', filters.statusCode) + if (filters.matchType) params.set('matchType', filters.matchType) + if (filters.isActive) params.set('isActive', filters.isActive) + const str = params.toString() + return str ? `?${str}` : '' +} + +/** + * Render active filter chips showing current filters + */ +function renderActiveFilterChips(filters: RedirectListPageData['filters']): HtmlEscapedString | Promise { + if (!hasActiveFilters(filters)) { + return html`` + } + + const chips: (HtmlEscapedString | Promise)[] = [] + + // Search filter chip + if (filters.search) { + chips.push(html` + + Search: ${filters.search} + + + `) + } + + // Status Code filter chip + if (filters.statusCode) { + const statusLabels: Record = { + '301': '301 Permanent', + '302': '302 Temporary', + '307': '307 Temp (Keep Method)', + '308': '308 Perm (Keep Method)', + '410': '410 Gone' + } + chips.push(html` + + Status: ${statusLabels[filters.statusCode] || filters.statusCode} + + + `) + } + + // Match Type filter chip + if (filters.matchType) { + const matchTypeLabels: Record = { + '0': 'Exact', + '1': 'Wildcard', + '2': 'Regex' + } + chips.push(html` + + Match: ${matchTypeLabels[filters.matchType] || filters.matchType} + + + `) + } + + // Active status filter chip + if (filters.isActive) { + const activeLabel = filters.isActive === 'true' ? 'Active Only' : 'Inactive Only' + chips.push(html` + + Status: ${activeLabel} + + + `) + } + + return html` +
+ Active filters: + ${chips} + +
+ + + ` +} + +/** + * Render the redirects table + */ +function renderTable(redirects: Redirect[]): HtmlEscapedString | Promise { + return html` + + + +
+ + + + + + + + + + + + + + + ${redirects.map(redirect => renderTableRow(redirect))} + +
+ + + Source URL + + + Destination URL + + + Status + + + Match Type + + + Active + + + Hits + + + Actions +
+
+ + + ` +} + +/** + * Render a single table row + */ +function renderTableRow(redirect: Redirect): HtmlEscapedString | Promise { + return html` + + + + + +
+ + ${redirect.source} + + ${renderSourcePluginBadge((redirect as any).sourcePlugin)} +
+ + + + ${redirect.destination} + + + + ${renderStatusBadge(redirect.statusCode)} + + + ${renderMatchTypeBadge(redirect.matchType)} + + + ${renderActiveIndicator(redirect.isActive)} + + + ${renderHitCountBadge((redirect as any).hitCount || 0)} + + + + Edit + + + + + ` +} + +/** + * Render status code badge + */ +function renderStatusBadge(code: number): HtmlEscapedString | Promise { + const colors: Record = { + 301: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400', + 302: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400', + 307: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400', + 308: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400', + 410: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400' + } + + const colorClass = colors[code] || 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-300' + + return html` + + ${code} + + ` +} + +/** + * Render match type badge + */ +function renderMatchTypeBadge(type: number): HtmlEscapedString | Promise { + const labels: Record = { + 0: 'Exact', + 1: 'Wildcard', + 2: 'Regex' + } + + return html` + + ${labels[type] || 'Unknown'} + + ` +} + +/** + * Render active status indicator + */ +function renderActiveIndicator(active: boolean): HtmlEscapedString | Promise { + if (active) { + return html` + + + Active + + ` + } else { + return html` + + + Inactive + + ` + } +} + +/** + * Render hit count badge with color coding + */ +function renderHitCountBadge(hitCount: number): HtmlEscapedString | Promise { + // Color coding based on hit count ranges + const colorClass = hitCount === 0 + ? 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400' + : hitCount < 10 + ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400' + : hitCount < 100 + ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' + : 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400' + + return html` + + ${hitCount.toLocaleString()} + + ` +} + +/** + * Render source plugin badge (shows which plugin created the redirect) + */ +function renderSourcePluginBadge(sourcePlugin: string | null | undefined): HtmlEscapedString | Promise { + if (!sourcePlugin) { + return html`` + } + + return html` + + ${sourcePlugin} + + ` +} + +/** + * Render empty state when no redirects exist + */ +function renderEmptyState(filters: RedirectListPageData['filters']): HtmlEscapedString | Promise { + const hasFilters = hasActiveFilters(filters) + + return html` +
+ + + +

No redirects

+

+ ${hasFilters + ? 'No redirects match your filters. Try adjusting your search criteria.' + : 'No redirects created yet. Click "New Redirect" to get started.' + } +

+ ${hasFilters ? html` +
+ +
+ ` : html` + + `} +
+ ` +} + +/** + * Render pagination controls + */ +function renderPagination(pagination: RedirectListPageData['pagination'], filters: RedirectListPageData['filters']): HtmlEscapedString | Promise { + const { page, totalPages, total, limit } = pagination + const startItem = (page - 1) * limit + 1 + const endItem = Math.min(page * limit, total) + + // Build base URL with filters + const params = new URLSearchParams() + if (filters.search) params.set('search', filters.search) + if (filters.statusCode) params.set('statusCode', filters.statusCode) + if (filters.matchType) params.set('matchType', filters.matchType) + if (filters.isActive) params.set('isActive', filters.isActive) + const baseUrl = '/admin/redirects' + (params.toString() ? '?' + params.toString() + '&' : '?') + + return html` +
+
+ ${page > 1 ? html` + + Previous + + ` : ''} + ${page < totalPages ? html` + + Next + + ` : ''} +
+ +
+ ` +} + +/** + * Get confirmation dialog script for delete operations + */ +function getConfirmationDialogScript(): HtmlEscapedString | Promise { + return html` + + +
+

Delete Redirect

+

+
+ + +
+
+
+ + + +
+

Delete Multiple Redirects

+

+ Delete 0 redirects? This action cannot be undone. +

+
+ + +
+
+
+ + + ` +} + +/** + * Render page layout using shared admin layout template + */ +function renderLayout(title: string, content: any): HtmlEscapedString | Promise { + // Add custom styles for dialog backdrop + const customStyles = ` + + ` + + return renderAdminLayoutCatalyst({ + title, + currentPath: '/admin/redirects', + content: customStyles + content.toString() + }) as HtmlEscapedString +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/types.ts b/my-sonicjs-app/src/plugins/redirect-management/types.ts new file mode 100644 index 000000000..f1fb4126a --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/types.ts @@ -0,0 +1,273 @@ +/** + * Redirect Management Plugin Types + * + * Type definitions for the redirect management plugin + */ + +/** + * Match type enum for redirect patterns + */ +export enum MatchType { + /** Exact URL match */ + EXACT = 0, + /** Wildcard URL match (prefix/contains) - syncs to Cloudflare */ + WILDCARD = 1, + /** Regular expression pattern match - NOT synced to Cloudflare */ + REGEX = 2 +} + +/** + * HTTP status codes supported for redirects + */ +export type StatusCode = 301 | 302 | 307 | 308 | 410 + +/** + * Redirect interface + */ +export interface Redirect { + /** Unique identifier */ + id: string + /** Source URL pattern to match */ + source: string + /** Destination URL to redirect to */ + destination: string + /** Type of pattern matching to use */ + matchType: MatchType + /** HTTP status code for the redirect */ + statusCode: StatusCode + /** Whether this redirect is currently active */ + isActive: boolean + /** User ID who created this redirect */ + createdBy: string + /** Timestamp when redirect was created (milliseconds) */ + createdAt: number + /** Timestamp when redirect was last updated (milliseconds) */ + updatedAt: number + /** Whether to preserve query string when redirecting (Cloudflare: preserve_query_string) */ + preserveQueryString: boolean + /** Whether to include subdomains in matching (Cloudflare: include_subdomains) */ + includeSubdomains: boolean + /** Whether to enable subpath matching (Cloudflare: subpath_matching) */ + subpathMatching: boolean + /** Whether to preserve path suffix when redirecting (Cloudflare: preserve_path_suffix) */ + preservePathSuffix: boolean + /** Number of times this redirect has been triggered (populated via JOIN with redirect_analytics) */ + hitCount?: number + /** Timestamp of last redirect hit in milliseconds (populated via JOIN with redirect_analytics) */ + lastHitAt?: number | null + /** Name of user who created this redirect (populated via JOIN with users table) */ + createdByName?: string + /** User ID who last updated this redirect */ + updatedBy?: string + /** Name of user who last updated this redirect (populated via JOIN with users table) */ + updatedByName?: string + /** Plugin ID that created this redirect (null if created via admin UI) */ + sourcePlugin?: string | null + /** Timestamp when redirect was soft-deleted (null if not deleted) */ + deletedAt?: number | null +} + +/** + * Redirect management plugin settings + */ +export interface RedirectSettings { + /** Whether redirect processing is enabled */ + enabled: boolean + /** Whether to auto-sync eligible redirects to Cloudflare Bulk Redirects */ + autoOffloadEnabled?: boolean +} + +/** + * Redirect analytics tracking + */ +export interface RedirectAnalytics { + /** Unique identifier */ + id: string + /** Associated redirect ID */ + redirectId: string + /** Number of times this redirect has been triggered */ + hitCount: number + /** Timestamp of last redirect hit (milliseconds, nullable) */ + lastHitAt: number | null + /** Timestamp when analytics record was created */ + createdAt: number + /** Timestamp when analytics record was last updated */ + updatedAt: number +} + +/** + * Input type for creating a new redirect + */ +export interface CreateRedirectInput { + /** Source URL pattern to match */ + source: string + /** Destination URL to redirect to */ + destination: string + /** Type of pattern matching to use (default: EXACT) */ + matchType?: MatchType + /** HTTP status code for the redirect (default: 301) */ + statusCode?: StatusCode + /** Whether this redirect is currently active (default: true) */ + isActive?: boolean + /** Whether to preserve query string when redirecting (default: false) */ + preserveQueryString?: boolean + /** Whether to include subdomains in matching (default: false) */ + includeSubdomains?: boolean + /** Whether to enable subpath matching (default: false) */ + subpathMatching?: boolean + /** Whether to preserve path suffix when redirecting (default: true) */ + preservePathSuffix?: boolean + /** Plugin ID that created this redirect (null if created via admin UI) */ + sourcePlugin?: string | null +} + +/** + * Input type for updating an existing redirect + */ +export interface UpdateRedirectInput { + /** Source URL pattern to match */ + source?: string + /** Destination URL to redirect to */ + destination?: string + /** Type of pattern matching to use */ + matchType?: MatchType + /** HTTP status code for the redirect */ + statusCode?: StatusCode + /** Whether this redirect is currently active */ + isActive?: boolean + /** Whether to preserve query string when redirecting */ + preserveQueryString?: boolean + /** Whether to include subdomains in matching */ + includeSubdomains?: boolean + /** Whether to enable subpath matching */ + subpathMatching?: boolean + /** Whether to preserve path suffix when redirecting */ + preservePathSuffix?: boolean +} + +/** + * Filter options for listing redirects + */ +export interface RedirectFilter { + /** Filter by active status */ + isActive?: boolean + /** Filter by status code */ + statusCode?: StatusCode + /** Filter by match type */ + matchType?: MatchType + /** Search term (searches source and destination) */ + search?: string + /** Filter by source plugin (null = admin-created, string = plugin ID) */ + sourcePlugin?: string | null + /** Maximum number of results to return (default: 50) */ + limit?: number + /** Number of results to skip (for pagination) */ + offset?: number +} + +/** + * Result of a redirect operation (create, update, delete) + */ +export interface RedirectOperationResult { + /** Whether the operation was successful */ + success: boolean + /** The redirect object (if operation succeeded) */ + redirect?: Redirect | undefined + /** Error message (if operation failed) */ + error?: string | undefined + /** Warning message (if operation succeeded but with warnings) */ + warning?: string | undefined +} + +/** + * CSV parsing error with line number context + */ +export interface CSVError { + /** Line number in CSV file (1-indexed, includes header) */ + line: number + /** Field name where error occurred */ + field?: string + /** Value that caused the error */ + value?: string + /** Error message */ + error: string +} + +/** + * Parsed redirect row from CSV (before validation) + */ +export interface ParsedRedirectRow { + /** Source URL pattern to match */ + source_url: string + /** Destination URL to redirect to */ + destination_url: string + /** Match type as string: 'exact', 'wildcard', 'regex' or '0', '1', '2' */ + match_type: string + /** HTTP status code as string */ + status_code: string + /** Active status as string: 'true' or 'false' */ + active: string + /** Whether to preserve query string when redirecting */ + preserve_query_string?: string + /** Whether to include subdomains in matching */ + include_subdomains?: string + /** Whether to enable subpath matching */ + subpath_matching?: string + /** Whether to preserve path suffix when redirecting */ + preserve_path_suffix?: string +} + +/** + * Result of CSV parsing + */ +export interface CSVParseResult { + /** Whether parsing and basic validation succeeded */ + isValid: boolean + /** Successfully parsed rows */ + rows: ParsedRedirectRow[] + /** Parse errors with line number context */ + errors: CSVError[] +} + +/** + * Duplicate handling strategy for CSV import + */ +export type DuplicateHandling = 'reject' | 'skip' | 'update' + +/** + * Result of batch CSV validation + */ +export interface CSVValidationResult { + /** Whether validation succeeded (no errors) */ + isValid: boolean + /** Validated rows ready for database insert */ + validRows: ValidatedRedirectRow[] + /** Validation errors with line number context */ + errors: CSVError[] + /** Count of rows skipped due to duplicate handling */ + skipped: number +} + +/** + * Validated redirect row ready for database insert + */ +export interface ValidatedRedirectRow { + /** Normalized source URL */ + source: string + /** Destination URL */ + destination: string + /** Match type (numeric) */ + matchType: MatchType + /** HTTP status code */ + statusCode: StatusCode + /** Whether redirect is active */ + isActive: boolean + /** Whether to preserve query string when redirecting */ + preserveQueryString: boolean + /** Whether to include subdomains in matching */ + includeSubdomains: boolean + /** Whether to enable subpath matching */ + subpathMatching: boolean + /** Whether to preserve path suffix when redirecting */ + preservePathSuffix: boolean +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/utils/cache.ts b/my-sonicjs-app/src/plugins/redirect-management/utils/cache.ts new file mode 100644 index 000000000..38453bc99 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/utils/cache.ts @@ -0,0 +1,124 @@ +/** + * LRU Cache Wrapper for Redirect Lookups + * + * Provides sub-millisecond redirect lookups via in-memory caching with LRU eviction. + * Cache keys are ALREADY normalized URLs (caller is responsible for normalization). + * + * Uses tiny-lru library for automatic LRU eviction when cache reaches max capacity. + */ + +import { lru } from 'tiny-lru' + +/** + * Cache entry for a redirect + * + * Stores all redirect metadata needed to execute redirect without database lookup. + * Note: Cache keys are normalized source URLs (lowercase, no trailing slash). + */ +export interface CacheEntry { + /** Unique redirect identifier */ + id: string + /** Destination URL to redirect to */ + destination: string + /** HTTP status code (301, 302, 307, 308, 410) */ + statusCode: number + /** Whether redirect is currently active */ + isActive: boolean + /** Match type: 0=exact, 1=wildcard, 2=regex */ + matchType: number + /** Whether to preserve query string in destination (Cloudflare: preserve_query_string) */ + preserveQueryString: boolean + /** Whether to include subdomains in matching (Cloudflare: include_subdomains) */ + includeSubdomains: boolean + /** Whether to enable subpath matching (Cloudflare: subpath_matching) */ + subpathMatching: boolean + /** Whether to preserve path suffix when redirecting (Cloudflare: preserve_path_suffix) */ + preservePathSuffix: boolean +} + +/** + * LRU cache for redirect lookups + * + * Provides O(1) lookups with automatic LRU eviction at max capacity. + * Entire cache is invalidated on any redirect change for consistency. + * + * Default capacity: 1000 entries (based on research recommendation) + * + * Usage: + * ```typescript + * const cache = new RedirectCache() + * cache.set('/blog', { id: '123', destination: '/new-blog', ... }) + * const entry = cache.get('/blog') + * cache.clear() // Invalidate on any redirect change + * ``` + */ +export class RedirectCache { + private cache: ReturnType> + + /** + * Create redirect cache with optional max size + * + * @param maxSize - Maximum number of entries (default 1000) + */ + constructor(maxSize: number = 1000) { + this.cache = lru(maxSize) + } + + /** + * Get redirect entry from cache + * + * @param normalizedSource - Already normalized source URL + * @returns Cache entry if found, undefined otherwise + */ + get(normalizedSource: string): CacheEntry | undefined { + return this.cache.get(normalizedSource) + } + + /** + * Store redirect entry in cache + * + * @param normalizedSource - Already normalized source URL + * @param entry - Cache entry to store + */ + set(normalizedSource: string, entry: CacheEntry): void { + this.cache.set(normalizedSource, entry) + } + + /** + * Check if redirect entry exists in cache + * + * @param normalizedSource - Already normalized source URL + * @returns true if entry exists, false otherwise + */ + has(normalizedSource: string): boolean { + return this.cache.has(normalizedSource) + } + + /** + * Delete redirect entry from cache + * + * @param normalizedSource - Already normalized source URL + */ + delete(normalizedSource: string): void { + this.cache.delete(normalizedSource) + } + + /** + * Clear entire cache + * + * Called on any redirect change (create, update, delete) to ensure consistency. + * Simple invalidation strategy: clear all on any change. + */ + clear(): void { + this.cache.clear() + } + + /** + * Get current cache size + * + * @returns Number of entries currently in cache + */ + size(): number { + return this.cache.size + } +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/utils/csv-sanitizer.ts b/my-sonicjs-app/src/plugins/redirect-management/utils/csv-sanitizer.ts new file mode 100644 index 000000000..96b26ca7b --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/utils/csv-sanitizer.ts @@ -0,0 +1,44 @@ +/** + * CSV Sanitizer Utility + * + * Prevents CSV formula injection and ensures proper RFC 4180 escaping + * Following OWASP guidance: https://owasp.org/www-community/attacks/CSV_Injection + */ + +/** + * Sanitizes a field value for CSV export + * + * - Prevents formula injection by prefixing dangerous characters with ' + * - Handles RFC 4180 escaping for fields containing commas, quotes, or newlines + * - Doubles internal quotes for proper CSV escaping + * + * @param value - The value to sanitize (string, null, or undefined) + * @returns Sanitized CSV-safe string + * + * @example + * sanitizeCSVField('=SUM(A1:A10)') // Returns '=SUM(A1:A10)' (prefixed to prevent formula) + * sanitizeCSVField('Hello, World') // Returns '"Hello, World"' (quoted for comma) + * sanitizeCSVField('Say "Hello"') // Returns '"Say ""Hello"""' (quoted and escaped) + */ +export function sanitizeCSVField(value: string | null | undefined): string { + // Handle null/undefined/empty + if (!value) return '' + + const str = String(value) + + // Check if starts with dangerous character (formula injection prevention) + const dangerousChars = ['=', '+', '-', '@', '\t', '\r'] + if (dangerousChars.some(char => str.startsWith(char))) { + // Prefix with single quote to force text treatment in spreadsheets + return `'${str.replace(/"/g, '""')}` + } + + // Check if needs quoting per RFC 4180 (contains comma, quote, or newline) + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + // Escape quotes by doubling, wrap in quotes + return `"${str.replace(/"/g, '""')}"` + } + + // No sanitization needed + return str +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/utils/url-normalizer.ts b/my-sonicjs-app/src/plugins/redirect-management/utils/url-normalizer.ts new file mode 100644 index 000000000..bc13794e8 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/utils/url-normalizer.ts @@ -0,0 +1,80 @@ +/** + * URL Normalization Utilities + * + * Provides consistent URL comparison for redirect matching: + * - Case-insensitive matching (lowercase) + * - Trailing slash normalization (strip except root) + * - Query parameter handling (configurable) + */ + +/** + * Normalize URL for consistent redirect matching + * + * Transformations: + * - Convert to lowercase for case-insensitive matching + * - Remove trailing slash EXCEPT for root "/" + * - Handle edge cases: empty string returns "/", null/undefined returns "/" + * - Preserve encoded characters (do NOT decode URI components) + * + * Examples: + * - "/Blog" -> "/blog" + * - "/page/" -> "/page" + * - "/" -> "/" + * - "" -> "/" + * - "/Page%20Name" -> "/page%20name" (preserves encoding) + * + * @param url - URL path to normalize + * @returns Normalized URL path + */ +export function normalizeUrl(url: string): string { + // Handle edge cases: empty, null, undefined + if (!url || url.trim() === '') { + return '/' + } + + // 1. Convert to lowercase for case-insensitive matching + let normalized = url.toLowerCase() + + // 2. Remove trailing slash (except root "/") + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1) + } + + // 3. Do NOT decode URI components - preserve encoded characters + // This ensures "/page%20name" matches exactly as stored + + return normalized +} + +/** + * Normalize URL with optional query parameter handling + * + * First normalizes the URL path using normalizeUrl(), then: + * - If includeQuery is false: Strip query string (everything after ?) + * - If includeQuery is true: Keep query string intact + * + * Examples: + * - "/Page?ref=email" with includeQuery=false -> "/page" + * - "/Page?ref=email" with includeQuery=true -> "/page?ref=email" + * - "/Blog/" with includeQuery=false -> "/blog" + * - "/Blog/" with includeQuery=true -> "/blog" + * + * @param url - URL path to normalize + * @param includeQuery - Whether to include query parameters in normalized result + * @returns Normalized URL path with or without query string + */ +export function normalizeUrlWithQuery(url: string, includeQuery: boolean): string { + // First normalize the URL path + const normalized = normalizeUrl(url) + + // If we should exclude query params, strip everything after ? + if (!includeQuery) { + const queryIndex = normalized.indexOf('?') + if (queryIndex !== -1) { + return normalized.slice(0, queryIndex) + } + } + + // Otherwise, return with query params intact + return normalized +} diff --git a/my-sonicjs-app/src/plugins/redirect-management/utils/validator.ts b/my-sonicjs-app/src/plugins/redirect-management/utils/validator.ts new file mode 100644 index 000000000..b04a5a8b9 --- /dev/null +++ b/my-sonicjs-app/src/plugins/redirect-management/utils/validator.ts @@ -0,0 +1,303 @@ +/** + * Redirect Validation Utilities + * + * Provides validation for redirect configurations: + * - Circular redirect detection (A->B->A) + * - Redirect chain detection (A->B->C->D) + * - URL format validation + * - Optional destination existence checking + */ + +import { normalizeUrl } from './url-normalizer' + +/** + * Result of a validation check + */ +export interface ValidationResult { + /** Whether the validation passed */ + isValid: boolean + /** Error message if validation failed */ + error?: string + /** Warning message (valid but concerning) */ + warning?: string + /** Number of hops in redirect chain */ + chainLength?: number + /** URLs in the redirect chain */ + chainUrls?: string[] +} + +/** + * Detect if adding a redirect would create a circular loop + * + * Uses a visited-set pattern to detect cycles by following the redirect chain. + * Also detects long chains (3+ hops) and returns warnings. + * + * Algorithm: + * 1. Start with source URL in visited set + * 2. Follow destination through existing redirects + * 3. If we reach a URL already visited -> circular (invalid) + * 4. If chain length >= 3 -> valid but warning + * 5. Safety limit: stop at 10 hops (error) + * + * Examples: + * - A->B with existing B->A: CIRCULAR (invalid) + * - A->B with existing B->C, C->D: Valid with warning (3 hops) + * - A->B with no existing redirects: Valid + * + * @param source - Source URL of the new redirect + * @param destination - Destination URL of the new redirect + * @param existingRedirects - Map of source URL -> destination URL for all existing redirects + * @returns ValidationResult indicating if redirect is valid + */ +export function detectCircularRedirect( + source: string, + destination: string, + existingRedirects: Map +): ValidationResult { + // Normalize URLs for case-insensitive comparison + const normalizedSource = normalizeUrl(source) + const normalizedDest = normalizeUrl(destination) + + // Check for self-redirect (source equals destination) + if (normalizedSource === normalizedDest) { + return { + isValid: false, + error: `Self-redirect detected: ${normalizedSource} redirects to itself`, + chainLength: 1, + chainUrls: [normalizedSource, normalizedDest] + } + } + + // Track visited URLs to detect cycles + const visited = new Set() + visited.add(normalizedSource) + + // Track chain for debugging and warnings + const chainUrls = [normalizedSource, normalizedDest] + let current = normalizedDest + let chainLength = 1 + + // Safety limit to prevent infinite loops + const MAX_CHAIN_LENGTH = 10 + + // Follow the redirect chain + while (true) { + // Check if current URL is already visited (circular redirect) + if (visited.has(current)) { + return { + isValid: false, + error: `Circular redirect detected: ${chainUrls.join(' -> ')}`, + chainLength, + chainUrls + } + } + + // If current URL doesn't have a redirect, chain ends here + if (!existingRedirects.has(current)) { + break + } + + // Add current URL to visited set + visited.add(current) + + // Move to next destination in chain + const next = existingRedirects.get(current)! + const normalizedNext = normalizeUrl(next) + chainUrls.push(normalizedNext) + current = normalizedNext + chainLength++ + + // Safety limit check + if (chainLength > MAX_CHAIN_LENGTH) { + return { + isValid: false, + error: `Redirect chain exceeds safety limit of ${MAX_CHAIN_LENGTH} hops`, + chainLength, + chainUrls + } + } + } + + // Check for long chains (3+ hops) + if (chainLength >= 3) { + return { + isValid: true, + warning: `Redirect chain has ${chainLength} hops: ${chainUrls.join(' -> ')}. Consider simplifying.`, + chainLength, + chainUrls + } + } + + // Valid redirect + return { + isValid: true, + chainLength, + chainUrls + } +} + +/** + * Validate URL format + * + * Checks: + * - URL is non-empty + * - URL starts with "/" (relative) OR "http://" or "https://" (absolute) + * + * Does NOT validate if destination exists - that's a separate concern. + * This is basic format checking only. + * + * Examples: + * - "/page" -> valid (relative) + * - "https://example.com" -> valid (absolute) + * - "page" -> invalid (missing leading slash) + * - "" -> invalid (empty) + * + * @param url - URL to validate + * @returns ValidationResult indicating if URL format is valid + */ +export function validateUrl(url: string): ValidationResult { + // Check for empty URL + if (!url || url.trim() === '') { + return { + isValid: false, + error: 'URL cannot be empty' + } + } + + const trimmed = url.trim() + + // Check if URL starts with "/" (relative) or "http://" or "https://" (absolute) + if (trimmed.startsWith('/')) { + return { isValid: true } + } + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return { isValid: true } + } + + return { + isValid: false, + error: 'URL must start with "/" (relative) or "http://" or "https://" (absolute)' + } +} + +/** + * Validate a redirect configuration + * + * Runs all validation checks: + * 1. Source URL format validation + * 2. Destination URL format validation + * 3. Circular redirect detection + * + * Returns first error encountered, or warning from circular detection, or valid. + * + * @param source - Source URL of the redirect + * @param destination - Destination URL of the redirect + * @param existingRedirects - Map of source URL -> destination URL for all existing redirects + * @returns ValidationResult indicating if redirect is valid + */ +export function validateRedirect( + source: string, + destination: string, + existingRedirects: Map +): ValidationResult { + // Validate source URL format + const sourceValidation = validateUrl(source) + if (!sourceValidation.isValid) { + return { + isValid: false, + error: `Invalid source URL: ${sourceValidation.error}` + } + } + + // Validate destination URL format + const destValidation = validateUrl(destination) + if (!destValidation.isValid) { + return { + isValid: false, + error: `Invalid destination URL: ${destValidation.error}` + } + } + + // Check for circular redirects + const circularCheck = detectCircularRedirect(source, destination, existingRedirects) + if (!circularCheck.isValid) { + return circularCheck + } + + // Return result (may have warning about chain length) + return circularCheck +} + +/** + * Check if a destination URL exists (optional helper for admin UI) + * + * This is an async helper function that attempts to verify if a destination + * URL is accessible. It returns warnings rather than blocking saves, since: + * - Internal routes can't be easily checked + * - Destinations might not exist yet (forward-planning redirects) + * - Network errors shouldn't prevent redirect creation + * + * Behavior: + * - Relative URLs ("/page"): Always valid (can't check internal routes) + * - Absolute URLs: Attempt HEAD request with 3-second timeout + * - 200-399 response: Valid + * - 404/410: Valid with warning + * - Network error: Valid with warning (don't block save) + * + * @param destination - Destination URL to check + * @param fetchFn - Optional fetch function (for testing/mocking) + * @returns ValidationResult with warnings if destination may not exist + */ +export async function checkDestinationExists( + destination: string, + fetchFn: typeof fetch = fetch +): Promise { + // Relative URLs - can't easily check internal routes + if (destination.startsWith('/')) { + return { + isValid: true + } + } + + // Absolute URLs - attempt HEAD request + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 3000) // 3 second timeout + + const response = await fetchFn(destination, { + method: 'HEAD', + signal: controller.signal, + // Don't follow redirects to check the actual destination + redirect: 'manual' + }) + + clearTimeout(timeoutId) + + // 200-399 responses are good (including redirects) + if (response.status >= 200 && response.status < 400) { + return { isValid: true } + } + + // 404/410 - destination not found + if (response.status === 404 || response.status === 410) { + return { + isValid: true, + warning: `Destination returned ${response.status}. The URL may not exist yet.` + } + } + + // Other status codes + return { + isValid: true, + warning: `Destination returned status ${response.status}. Please verify the URL is correct.` + } + } catch (error) { + // Network errors, timeouts, etc. - don't block save + const message = error instanceof Error ? error.message : 'Unknown error' + return { + isValid: true, + warning: `Could not verify destination: ${message}. The URL may still be valid.` + } + } +}