Files
DiscordClone/convex/links.ts
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

117 lines
3.9 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()),
}),
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);
// 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 };
} 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)));
}