タグ注入で相対URLを絶対解決して出力 * - short_url_access_logs にアクセスログ記録(client_ipは文字列) * - client_ip は X-Forwarded-For があれば常に優先(ただしIP形式として妥当な場合のみ) * - DB: /var/www/NEXTSTEP-RELAY/db.conf */ function loadDbConfig(string $path): array { if (!is_file($path) || !is_readable($path)) { throw new RuntimeException("db.conf not readable: {$path}"); } $ini = parse_ini_file($path, true, INI_SCANNER_TYPED); if ($ini === false || !isset($ini['database']) || !is_array($ini['database'])) { throw new RuntimeException("db.conf parse error: {$path}"); } $db = $ini['database']; $dsn = (string)($db['dsn'] ?? ''); $user = (string)($db['user'] ?? ''); $pass = (string)($db['pass'] ?? ''); if ($dsn === '' || $user === '') { throw new RuntimeException("db.conf missing dsn/user: {$path}"); } return [$dsn, $user, $pass]; } function trunc(?string $s, int $max): ?string { if ($s === null) return null; $s = str_replace("\0", '', $s); if (mb_strlen($s, 'UTF-8') <= $max) return $s; return mb_substr($s, 0, $max, 'UTF-8'); } function textResponse(int $code, string $body): void { http_response_code($code); header('Content-Type: text/plain; charset=UTF-8'); echo $body; exit; } function notFound(): void { textResponse(404, "Not Found\n"); } /** * Location に入れるURLの最小安全チェック */ function sanitizeRedirectUrl(string $url): ?string { $url = str_replace(["\r", "\n", "\0"], '', trim($url)); if ($url === '') return null; if (!preg_match('~^https?://~i', $url)) return null; // http/httpsのみ if (strlen($url) > 2048) return null; $p = @parse_url($url); if (!is_array($p) || empty($p['host'])) return null; return $url; } /** * config.redirect_method を取得('301'|'302'|'proxy')。取得失敗時は'302' */ function loadRedirectMethod(PDO $pdo): string { try { $st = $pdo->prepare("SELECT value_text FROM config WHERE cfg_key='redirect_method' AND is_active=1 LIMIT 1"); $st->execute(); $v = (string)$st->fetchColumn(); if (in_array($v, ['301', '302', 'proxy'], true)) return $v; } catch (Throwable $e) { /* ignore */ } return '302'; } /** * プロキシ配信: URL先のコンテンツを取得し、HTMLなら注入して出力、それ以外はそのまま出力 * - cURL取得(タイムアウト10秒、User-Agent/Referer転送、最大3リダイレクト追跡) * - 失敗時は 502 を返す * - Set-Cookie等のヘッダーは転送しない(選別) */ function proxyDeliver(string $url): void { $ch = curl_init($url); if ($ch === false) { textResponse(502, "Proxy init failed\n"); } $ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'); $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? 'text/html,application/xhtml+xml,*/*;q=0.8'); $acceptLang = (string)($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'ja,en;q=0.8'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HEADER => true, CURLOPT_ENCODING => '', // gzip等を自動展開 CURLOPT_HTTPHEADER => [ 'User-Agent: ' . $ua, 'Accept: ' . $accept, 'Accept-Language: ' . $acceptLang, ], ]); $raw = curl_exec($ch); if ($raw === false) { $err = curl_error($ch); curl_close($ch); textResponse(502, "Proxy fetch failed: " . $err . "\n"); } $httpCode = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $headerSize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE); $finalUrl = (string)(curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url); $contentType = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); curl_close($ch); $rawHeaders = substr((string)$raw, 0, $headerSize); $body = substr((string)$raw, $headerSize); // HTMLなら に を注入 if ($contentType !== '' && stripos($contentType, 'text/html') !== false) { $baseTag = ''; if (preg_match('/]*>/i', $body)) { $body = preg_replace('/(]*>)/i', '$1' . "\n" . $baseTag, $body, 1); } else { // が無い場合はHTMLの先頭付近に差し込む if (preg_match('/]*>/i', $body)) { $body = preg_replace('/(]*>)/i', '$1' . "\n" . $baseTag . "", $body, 1); } else { $body = "" . $baseTag . "\n" . $body; } } } // レスポンス: 元のContent-Type維持、他ヘッダーは選別 http_response_code($httpCode > 0 ? $httpCode : 200); if ($contentType !== '') { header('Content-Type: ' . $contentType); } // 対象が返したContent-Encoding等は既にデコード済みなので転送しない header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); echo $body; exit; } /** * X-Forwarded-For があれば常に優先(先頭要素) * ただし、IP形式として正しい場合のみ採用する */ function clientIp(): string { $xff = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')); if ($xff !== '') { // "client, proxy1, proxy2" の先頭を取る $parts = array_map('trim', explode(',', $xff)); $candidate = $parts[0] ?? ''; if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) { return $candidate; } } $remote = trim((string)($_SERVER['REMOTE_ADDR'] ?? '')); if ($remote !== '' && filter_var($remote, FILTER_VALIDATE_IP)) { return $remote; } return '0.0.0.0'; } // code取得(rewriteで PATH_INFO などから取るならここを拡張) $code = $_GET['code'] ?? ''; if (!is_string($code) || !preg_match('/^[0-9A-Za-z]{4,32}$/', $code)) { notFound(); } try { [$dsn, $dbUser, $dbPass] = loadDbConfig('/var/www/NEXTSTEP-RELAY/db.conf'); $pdo = new PDO( $dsn, $dbUser, $dbPass, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ] ); $st = $pdo->prepare(" SELECT id, user_id, long_url FROM short_urls WHERE code = ? AND is_active = 1 AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1 "); $st->execute([$code]); $row = $st->fetch(); if (!$row) { notFound(); } $shortUrlId = (int)$row['id']; $userId = (int)$row['user_id']; $longUrlRaw = (string)$row['long_url']; $longUrl = sanitizeRedirectUrl($longUrlRaw); if ($longUrl === null) { // DB汚染などで危険なURLの場合は503(404でも可) textResponse(503, "Service Unavailable\n"); } $ip = clientIp(); $ua = trunc($_SERVER['HTTP_USER_AGENT'] ?? null, 512); $ref = trunc($_SERVER['HTTP_REFERER'] ?? null, 1024); $httpMethod = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); // 配信方式をconfigから取得 $redirectMethod = loadRedirectMethod($pdo); if ($redirectMethod === 'proxy') { $status = 200; } else { $status = (int)$redirectMethod; // 301 or 302 } // アクセスログ(失敗してもリダイレクト優先) try { $log = $pdo->prepare(" INSERT INTO short_url_access_logs (short_url_id, user_id, client_ip, user_agent, referer, http_method, status_code) VALUES (?, ?, ?, ?, ?, ?, ?) "); $log->execute([$shortUrlId, $userId, $ip, $ua, $ref, $httpMethod, $status]); } catch (Throwable $e) { // error_log("access log insert failed: " . $e->getMessage()); } if ($redirectMethod === 'proxy') { proxyDeliver($longUrl); exit; } http_response_code($status); header('Location: ' . $longUrl); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); exit; } catch (Throwable $e) { textResponse(503, "Service Unavailable\n"); }