Files
DiscordClone/convex/links.ts
Bryan1029384756 6813bb40dc
All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s
1.1.00
2026-04-16 20:14:27 -05:00

152 lines
5.6 KiB
TypeScript

"use node";
import { action } from "./_generated/server";
import { v } from "convex/values";
export const fetchPreview = action({
args: { url: v.string() },
returns: v.union(
v.object({
url: v.string(),
title: v.optional(v.string()),
description: v.optional(v.string()),
image: v.optional(v.string()),
siteName: v.optional(v.string()),
// Image dimensions sourced from og:image:width/height or
// twitter:image:width/height when the page emits them. The client
// uses these to reserve the preview card's image slot *before* the
// image decodes, eliminating the height shift that used to expand
// the card on first paint.
imageWidth: v.optional(v.number()),
imageHeight: v.optional(v.number()),
}),
v.null(),
),
handler: async (_ctx, args) => {
try {
// Validate URL + prevent loopback SSRF
const u = new URL(args.url);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(u.toString(), {
method: "GET",
headers: {
// Discordbot User-Agent — a lot of sites (YouTube included)
// only emit og: metadata when they recognise a known crawler,
// and the generic Brycord UA gets routed to consent / interstitial
// pages that never include the tags we're after.
"User-Agent":
"Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timeout);
if (!res.ok) return null;
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("text/html")) return null;
// Read up to 512 KB so giant pages don't DOS the action
const reader = res.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let total = 0;
const MAX = 512 * 1024;
while (total < MAX) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
total += value.length;
}
}
try { await reader.cancel(); } catch {}
const merged = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
merged.set(c, offset);
offset += c.length;
}
const html = new TextDecoder("utf-8").decode(merged);
// Parse OG / twitter / <title> tags with regex — no DOM in Node
const pick = (re: RegExp): string | undefined => {
const m = html.match(re);
return m ? decodeEntities(m[1].trim()) : undefined;
};
const title =
pick(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<title[^>]*>([^<]+)<\/title>/i);
const description =
pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
let image =
pick(/<meta[^>]+property=["']og:image(?::secure_url)?["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']twitter:image(?::src)?["'][^>]+content=["']([^"']+)["']/i);
const siteName =
pick(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i);
const pickNum = (re: RegExp): number | undefined => {
const raw = pick(re);
if (!raw) return undefined;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
};
// og: and twitter: tags both publish the intended image dimensions
// in their own namespaces — use whichever is available. The attr
// order in HTML varies (some sites emit `content="w"` before
// `property=...`), so fall back to a second regex with the
// attributes swapped.
const imageWidth =
pickNum(/<meta[^>]+property=["']og:image:width["'][^>]+content=["']([^"']+)["']/i) ??
pickNum(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:width["']/i) ??
pickNum(/<meta[^>]+name=["']twitter:image:width["'][^>]+content=["']([^"']+)["']/i);
const imageHeight =
pickNum(/<meta[^>]+property=["']og:image:height["'][^>]+content=["']([^"']+)["']/i) ??
pickNum(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image:height["']/i) ??
pickNum(/<meta[^>]+name=["']twitter:image:height["'][^>]+content=["']([^"']+)["']/i);
// Resolve relative image URLs
if (image) {
try {
image = new URL(image, u).toString();
} catch {}
}
if (!title && !description && !image) return null;
return {
url: u.toString(),
title,
description,
image,
siteName,
imageWidth,
imageHeight,
};
} catch {
return null;
}
},
});
function decodeEntities(s: string): string {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
}