feat(link): add Open Graph fields for enhanced link sharing

- Introduced ogTitle, ogDescription, and ogImageUrl fields in the Link model for improved social media previews.
- Updated the database schema to accommodate new Open Graph fields.
- Modified link creation and editing forms to include inputs for Open Graph metadata.
- Enhanced the GET route to serve Open Graph metadata for bots.

This update allows for better customization of shared links, improving their presentation on social media platforms.
This commit is contained in:
crbon 2026-01-21 15:24:54 +10:00
parent b915f15ed9
commit e295fca187
6 changed files with 65 additions and 38 deletions

View file

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "link"
ADD COLUMN "og_description" VARCHAR(500),
ADD COLUMN "og_image_url" VARCHAR(500),
ADD COLUMN "og_title" VARCHAR(500);

View file

@ -281,9 +281,9 @@ model Link {
name String @db.VarChar(100) name String @db.VarChar(100)
url String @db.VarChar(500) url String @db.VarChar(500)
slug String @unique() @db.VarChar(100) slug String @unique() @db.VarChar(100)
title String? @db.VarChar(500) ogTitle String? @map("og_title") @db.VarChar(500)
description String? @db.VarChar(500) ogDescription String? @map("og_description") @db.VarChar(500)
image String? @db.VarChar(500) ogImageUrl String? @map("og_image_url") @db.VarChar(500)
userId String? @map("user_id") @db.Uuid userId String? @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid teamId String? @map("team_id") @db.Uuid
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)

View file

@ -7,6 +7,15 @@ import redis from '@/lib/redis';
import { notFound } from '@/lib/response'; import { notFound } from '@/lib/response';
import { findLink } from '@/queries/prisma'; import { findLink } from '@/queries/prisma';
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&#39;');
}
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; const { slug } = await params;
@ -45,22 +54,28 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug
/facebookexternalhit|twitterbot|linkedinbot|whatsapp|slackbot|discordbot|telegrambot|applebot|bingbot|googlebot/i.test( /facebookexternalhit|twitterbot|linkedinbot|whatsapp|slackbot|discordbot|telegrambot|applebot|bingbot|googlebot/i.test(
userAgent, userAgent,
); );
const l = link;
if (isBot && (l.title || l.description || l.image)) { if (isBot && (link.ogTitle || link.ogDescription || link.ogImageUrl)) {
const ogTitle = escapeHtml(link.ogTitle || link.name);
const ogDescription = escapeHtml(link.ogDescription || '');
const ogImageUrl = escapeHtml(link.ogImageUrl || '');
const url = escapeHtml(link.url);
return new Response( return new Response(
` `
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta property="og:title" content="${l.title || l.name}"> <title>${ogTitle}</title>
<meta property="og:description" content="${l.description || ''}"> <meta property="og:title" content="${ogTitle}">
<meta property="og:image" content="${l.image || ''}"> <meta property="og:description" content="${ogDescription}">
<meta property="og:url" content="${l.url}"> <meta property="og:image" content="${ogImageUrl}">
<meta property="og:url" content="${url}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${l.title || l.name}"> <meta name="twitter:title" content="${ogTitle}">
<meta name="twitter:description" content="${l.description || ''}"> <meta name="twitter:description" content="${ogDescription}">
<meta name="twitter:image" content="${l.image || ''}"> <meta name="twitter:image" content="${ogImageUrl}">
</head> </head>
<body></body> <body></body>
</html> </html>

View file

@ -101,15 +101,15 @@ export function LinkEditForm({
<TextField placeholder="https://example.com" autoComplete="off" /> <TextField placeholder="https://example.com" autoComplete="off" />
</FormField> </FormField>
<FormField label={formatMessage(labels.title)} name="title"> <FormField label="Title" name="ogTitle">
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormField> </FormField>
<FormField label={formatMessage(labels.description)} name="description"> <FormField label="Description" name="ogDescription">
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormField> </FormField>
<FormField label="OG Image URL" name="image"> <FormField label="Image URL" name="ogImageUrl">
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormField> </FormField>

View file

@ -26,10 +26,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
const schema = z.object({ const schema = z.object({
name: z.string().optional(), name: z.string().optional(),
url: z.string().optional(), url: z.string().optional(),
slug: z.string().min(8).optional(), slug: z.string().min(4).optional(),
title: z.string().max(500).optional(), ogTitle: z.string().max(500).optional(),
description: z.string().max(500).optional(), ogDescription: z.string().max(500).optional(),
image: z.string().max(500).optional(), ogImageUrl: z.url().max(500).optional().or(z.literal('')),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -39,14 +39,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ lin
} }
const { linkId } = await params; const { linkId } = await params;
const { name, url, slug, title, description, image } = body; const { name, url, slug, ogTitle, ogDescription, ogImageUrl } = body;
if (!(await canUpdateLink(auth, linkId))) { if (!(await canUpdateLink(auth, linkId))) {
return unauthorized(); return unauthorized();
} }
try { try {
const result = await updateLink(linkId, { name, url, slug, title, description, image }); const result = await updateLink(linkId, {
name,
url,
slug,
ogTitle,
ogDescription,
ogImageUrl,
});
return Response.json(result); return Response.json(result);
} catch (e: any) { } catch (e: any) {

View file

@ -29,10 +29,10 @@ export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
name: z.string().max(100), name: z.string().max(100),
url: z.string().max(500), url: z.string().max(500),
slug: z.string().max(100), slug: z.string().min(4).max(100),
title: z.string().max(500).optional(), ogTitle: z.string().max(500).optional(),
description: z.string().max(500).optional(), ogDescription: z.string().max(500).optional(),
image: z.string().max(500).optional(), ogImageUrl: z.url().max(500).optional().or(z.literal('')),
teamId: z.string().nullable().optional(), teamId: z.string().nullable().optional(),
id: z.uuid().nullable().optional(), id: z.uuid().nullable().optional(),
}); });
@ -43,7 +43,7 @@ export async function POST(request: Request) {
return error(); return error();
} }
const { id, name, url, slug, title, description, image, teamId } = body; const { id, name, url, slug, ogTitle, ogDescription, ogImageUrl, teamId } = body;
if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) {
return unauthorized(); return unauthorized();
@ -54,9 +54,9 @@ export async function POST(request: Request) {
name, name,
url, url,
slug, slug,
title, ogTitle,
description, ogDescription,
image, ogImageUrl,
teamId, teamId,
}; };