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; } $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, ]); $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 = ''; $ch2 = curl_init($faviconUrl); 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)', ]); $favBytes = curl_exec($ch2); $favStatus = curl_getinfo($ch2, CURLINFO_HTTP_CODE); $favMime = curl_getinfo($ch2, CURLINFO_CONTENT_TYPE) ?: 'image/x-icon'; 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; });