Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions api/link-preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { jsonResponse, serverError } from '../server/json.js';

const MAX_HTML_LENGTH = 180000;
const REQUEST_TIMEOUT_MS = 6000;
const PREVIEW_CACHE_TTL_MS = 1000 * 60 * 60 * 12;
const previewCache = new Map();

function extractMetaTag(html, attribute, value) {
const pattern = new RegExp(`<meta[^>]*${attribute}=["']${value}["'][^>]*content=["']([^"']+)["'][^>]*>`, 'i');
const match = html.match(pattern);
return match ? decodeHtml(match[1].trim()) : '';
}

function extractTitle(html) {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return match ? decodeHtml(match[1].replace(/\s+/g, ' ').trim()) : '';
}

function decodeHtml(value) {
return String(value || '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, '\'');
}

function truncate(value, maxLength = 280) {
if (!value) return '';
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1).trim()}…`;
}

function stripTags(value) {
return String(value || '')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}

function extractFirstParagraph(html) {
const match = html.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
if (!match) return '';
return decodeHtml(stripTags(match[1]));
}

async function fetchHtml(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, {
redirect: 'follow',
signal: controller.signal,
headers: {
'User-Agent': 'voice-notes-link-preview/1.0',
Accept: 'text/html,application/xhtml+xml',
},
});
if (!response.ok) {
throw new Error(`Upstream request failed with ${response.status}`);
}
const text = await response.text();
return text.slice(0, MAX_HTML_LENGTH);
Comment on lines +63 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce HTML size limit before reading full response

fetchHtml calls response.text() and only then truncates to MAX_HTML_LENGTH, so the configured cap does not prevent large upstream payloads from being fully downloaded and buffered. A large response can still cause avoidable memory/CPU pressure before truncation is applied.

Useful? React with 👍 / 👎.

} finally {
clearTimeout(timeout);
}
}

function buildPreview(html, url) {
const ogTitle = extractMetaTag(html, 'property', 'og:title');
const twitterTitle = extractMetaTag(html, 'name', 'twitter:title');
const title = truncate(ogTitle || twitterTitle || extractTitle(html) || url, 180);

const ogDescription = extractMetaTag(html, 'property', 'og:description');
const metaDescription = extractMetaTag(html, 'name', 'description');
const twitterDescription = extractMetaTag(html, 'name', 'twitter:description');
const firstParagraph = extractFirstParagraph(html);
const description = truncate(ogDescription || twitterDescription || metaDescription || firstParagraph, 320);

const siteName = truncate(extractMetaTag(html, 'property', 'og:site_name'), 100);

return {
title,
description,
summary: description,
siteName,
url,
};
}

export async function GET(request) {
try {
const requestUrl = new URL(request.url);
const target = requestUrl.searchParams.get('url') || '';
if (!target) {
return jsonResponse({ ok: false, error: 'Missing url query parameter.' }, { status: 400 });
}

let parsed;
try {
parsed = new URL(target);
} catch {
return jsonResponse({ ok: false, error: 'Invalid URL.' }, { status: 400 });
}

if (!/^https?:$/i.test(parsed.protocol)) {
return jsonResponse({ ok: false, error: 'Only HTTP(S) URLs are supported.' }, { status: 400 });
}

const normalizedUrl = parsed.toString();
const cached = previewCache.get(normalizedUrl);
if (cached && Date.now() - cached.cachedAt < PREVIEW_CACHE_TTL_MS) {
return jsonResponse({ ok: true, preview: cached.preview, cached: true });
}

const html = await fetchHtml(normalizedUrl);
const preview = buildPreview(html, normalizedUrl);
previewCache.set(normalizedUrl, {
cachedAt: Date.now(),
preview,
});

return jsonResponse({ ok: true, preview });
} catch (error) {
return serverError(error.message || 'Failed to fetch link preview.');
}
}
67 changes: 66 additions & 1 deletion app.css
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,11 @@ header {
color: var(--accent);
}

.list-mode-badge[data-mode="links"] {
background: rgba(37, 99, 235, 0.18);
color: #93c5fd;
}

.list-card-count {
font-size: 0.75rem;
color: var(--text-muted);
Expand Down Expand Up @@ -697,6 +702,11 @@ header {
color: var(--accent);
}

#list-detail-mode[data-mode="links"] {
background: rgba(37, 99, 235, 0.18);
color: #93c5fd;
}

#list-detail-actions {
display: flex;
gap: 4px;
Expand Down Expand Up @@ -975,6 +985,60 @@ header {
font-style: italic;
}

.note-link,
.link-preview-title {
color: #93c5fd;
text-decoration: none;
}

.note-link:hover,
.note-link:focus-visible,
.link-preview-title:hover,
.link-preview-title:focus-visible {
text-decoration: underline;
}

.note-link {
margin-top: 10px;
display: inline-block;
font-size: 0.78rem;
}

.link-preview {
border: 1px solid var(--surface-2);
border-radius: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.02);
}

.link-preview.loading {
color: var(--text-muted);
font-size: 0.8rem;
}

.link-preview-excerpt {
margin: 6px 0 0;
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.45;
}

.link-preview-footer {
margin-top: 8px;
font-size: 0.72rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}

.tweet-embed {
border: none;
width: 100%;
min-height: 420px;
border-radius: 12px;
background: #fff;
}

.note-actions {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -1150,7 +1214,8 @@ header {
}

#mode-selector {
display: flex;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
}
Expand Down
Loading