and favicon so the * frontend doesn't have to deal with CORS. Light and stateless — * the bookmarks themselves live in the card body (JSON blob). * * Routes: * GET /api/bookmarks/fetch-meta?url=... → { title, favicon } */ PluginManager::instance()->onApiRoute(function (string $route, string $method) { if ($route !== '/api/bookmarks/fetch-meta' || $method !== 'GET') return; $url = trim((string)($_GET['url'] ?? '')); if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) { http_response_code(400); echo json_encode(['error' => 'valid url required']); exit; } $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); if ($scheme !== 'http' && $scheme !== 'https') { http_response_code(400); echo json_encode(['error' => 'only http/https URLs allowed']); exit; } if (!hyprcrdUrlHostIsPublic($url)) { http_response_code(400); echo json_encode(['error' => 'URL host is not publicly reachable']); exit; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 5, CURLOPT_TIMEOUT => 8, CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)', CURLOPT_SSL_VERIFYPEER => true, ]); hyprcrdApplySafeCurlOpts($ch); $html = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $url; curl_close($ch); $title = ''; if ($html && $status >= 200 && $status < 400) { // Extract if (preg_match('/<title[^>]*>(.*?)<\/title>/si', $html, $m)) { $title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')); } // og:title fallback if ($title === '' && preg_match('/<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)/si', $html, $m)) { $title = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')); } } // Favicon: resolve the URL, then download the bytes and return a // self-contained data: URI so the card body is fully offline-capable // and opening the overlay doesn't leak requests to every bookmarked // site. Caps at 32 KB — anything bigger is probably not an icon. $parsed = parse_url($finalUrl); $origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? ''); $faviconUrl = $origin . '/favicon.ico'; if ($html && preg_match('/<link[^>]+rel=["\'](?:shortcut )?icon["\'][^>]+href=["\']([^"\']+)/si', $html, $m)) { $href = $m[1]; if (str_starts_with($href, '//')) $href = ($parsed['scheme'] ?? 'https') . ':' . $href; elseif (str_starts_with($href, '/')) $href = $origin . $href; elseif (!str_starts_with($href, 'http')) $href = rtrim($finalUrl, '/') . '/' . $href; $faviconUrl = $href; } $faviconData = ''; $faviconScheme = strtolower((string)parse_url($faviconUrl, PHP_URL_SCHEME)); $favCh = ($faviconScheme === 'http' || $faviconScheme === 'https') && hyprcrdUrlHostIsPublic($faviconUrl); $ch2 = $favCh ? curl_init($faviconUrl) : null; if ($ch2) curl_setopt_array($ch2, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_TIMEOUT => 5, CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; hyprcrd-bookmarks/1.0)', ]); if ($ch2) hyprcrdApplySafeCurlOpts($ch2); $favBytes = $ch2 ? curl_exec($ch2) : false; $favStatus = $ch2 ? curl_getinfo($ch2, CURLINFO_HTTP_CODE) : 0; $favMime = $ch2 ? (curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon') : 'image/x-icon'; if ($ch2) curl_close($ch2); if ($favBytes && $favStatus >= 200 && $favStatus < 400 && strlen($favBytes) <= 32768) { $mime = preg_replace('/;.*$/', '', $favMime); $faviconData = 'data:' . $mime . ';base64,' . base64_encode($favBytes); } header('Content-Type: application/json'); echo json_encode([ 'ok' => true, 'url' => $finalUrl, 'title' => mb_substr($title, 0, 200), 'favicon' => $faviconData, ]); exit; });