タグ注入で相対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;
}
/**
* アクセスされたドメイン(host)を取得。
* リバースプロキシ経由を考慮し X-Forwarded-Host があれば優先。
* ポート除去・小文字化し、ホスト名として妥当な場合のみ採用(不正/不明はnull)。
*/
function accessHost(): ?string
{
$candidates = [];
// X-Forwarded-Host("host1, host2" の先頭)を優先
$xfh = trim((string)($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ''));
if ($xfh !== '') {
$parts = array_map('trim', explode(',', $xfh));
if (!empty($parts[0])) $candidates[] = $parts[0];
}
// 次に Host ヘッダ
$candidates[] = trim((string)($_SERVER['HTTP_HOST'] ?? ''));
foreach ($candidates as $h) {
if ($h === '') continue;
$h = strtolower($h);
// ポート部除去
$colon = strpos($h, ':');
if ($colon !== false) $h = substr($h, 0, $colon);
// ホスト名として妥当な文字のみ採用
if ($h !== '' && preg_match('/^[a-z0-9.\-]{1,255}$/', $h)) {
return $h;
}
}
return null;
}
/**
* 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();
$host = accessHost();
$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, host, client_ip, user_agent, referer, http_method, status_code)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?)
");
$log->execute([$shortUrlId, $userId, $host, $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");
}