diff --git a/prisma/migrations/20260121051633_15_add_link_og_fields/migration.sql b/prisma/migrations/20260121051633_15_add_link_og_fields/migration.sql new file mode 100644 index 00000000..d485ffac --- /dev/null +++ b/prisma/migrations/20260121051633_15_add_link_og_fields/migration.sql @@ -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); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aeb11648..b57eeef2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -277,15 +277,18 @@ model Revenue { } model Link { - id String @id() @unique() @map("link_id") @db.Uuid - name String @db.VarChar(100) - url String @db.VarChar(500) - slug String @unique() @db.VarChar(100) - userId String? @map("user_id") @db.Uuid - teamId String? @map("team_id") @db.Uuid - createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) - deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + id String @id() @unique() @map("link_id") @db.Uuid + name String @db.VarChar(100) + url String @db.VarChar(500) + slug String @unique() @db.VarChar(100) + ogTitle String? @map("og_title") @db.VarChar(500) + ogDescription String? @map("og_description") @db.VarChar(500) + ogImageUrl String? @map("og_image_url") @db.VarChar(500) + userId String? @map("user_id") @db.Uuid + teamId String? @map("team_id") @db.Uuid + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) user User? @relation("user", fields: [userId], references: [id]) team Team? @relation(fields: [teamId], references: [id]) diff --git a/src/app/(collect)/q/[slug]/route.ts b/src/app/(collect)/q/[slug]/route.ts index 24089bdb..b702a244 100644 --- a/src/app/(collect)/q/[slug]/route.ts +++ b/src/app/(collect)/q/[slug]/route.ts @@ -1,5 +1,6 @@ export const dynamic = 'force-dynamic'; +import { isbot } from 'isbot'; import { NextResponse } from 'next/server'; import { POST } from '@/app/api/send/route'; import type { Link } from '@/generated/prisma/client'; @@ -7,6 +8,23 @@ import redis from '@/lib/redis'; import { notFound } from '@/lib/response'; import { findLink } from '@/queries/prisma'; +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') + .replace(/'/g, '''); +} + +function metaTag(property: string, content: string | undefined, isName = false): string { + if (!content) return ''; + const escaped = escapeHtml(content); + return isName + ? `` + : ``; +} + export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; @@ -40,6 +58,48 @@ export async function GET(request: Request, { params }: { params: Promise<{ slug } } + const userAgent = request.headers.get('user-agent') || ''; + + if (isbot(userAgent)) { + const ogTitle = link.ogTitle || link.name; + const ogDescription = link.ogDescription || undefined; + const ogImageUrl = link.ogImageUrl || undefined; + const twitterCard = ogImageUrl ? 'summary_large_image' : 'summary'; + + return new Response( + ` + + + + + ${escapeHtml(ogTitle)} + ${metaTag('title', ogTitle, true)} + ${metaTag('description', ogDescription, true)} + ${metaTag('og:type', 'website')} + ${metaTag('og:site_name', 'Umami')} + ${metaTag('og:title', ogTitle)} + ${metaTag('og:url', request.url)} + ${metaTag('og:description', ogDescription)} + ${metaTag('og:image', ogImageUrl)} + + ${metaTag('twitter:title', ogTitle, true)} + ${metaTag('twitter:description', ogDescription, true)} + ${metaTag('twitter:image', ogImageUrl, true)} + + +

Redirecting to ${escapeHtml(link.url)}...

+ + + `, + { + headers: { + 'content-type': 'text/html', + 'cache-control': 's-maxage=300, stale-while-revalidate', + }, + }, + ); + } + const payload = { type: 'event', payload: { diff --git a/src/app/(main)/links/LinkEditForm.tsx b/src/app/(main)/links/LinkEditForm.tsx index 6c10c7f0..2a206920 100644 --- a/src/app/(main)/links/LinkEditForm.tsx +++ b/src/app/(main)/links/LinkEditForm.tsx @@ -10,10 +10,10 @@ import { Row, TextField, } from '@umami/react-zen'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useConfig, useLinkQuery, useMessages } from '@/components/hooks'; import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; -import { RefreshCw } from '@/components/icons'; +import { ChevronDown, ChevronRight, RefreshCw } from '@/components/icons'; import { LINKS_URL } from '@/lib/constants'; import { getRandomChars } from '@/lib/generate'; import { isValidUrl } from '@/lib/url'; @@ -42,10 +42,15 @@ export function LinkEditForm({ const { linksUrl } = useConfig(); const hostUrl = linksUrl || LINKS_URL; const { data, isLoading } = useLinkQuery(linkId); - const [slug, setSlug] = useState(generateId()); + const [initialSlug] = useState(() => generateId()); + const [showAdvanced, setShowAdvanced] = useState(false); - const handleSubmit = async (data: any) => { - await mutateAsync(data, { + const handleSubmit = async (formData: any) => { + const { slug: formSlug, ...rest } = formData; + // Only include slug if creating new link or if it was modified + const payload = !linkId || formSlug !== data?.slug ? formData : rest; + + await mutateAsync(payload, { onSuccess: async () => { toast(formatMessage(messages.saved)); touch('links'); @@ -55,13 +60,7 @@ export function LinkEditForm({ }); }; - const handleSlug = () => { - const slug = generateId(); - - setSlug(slug); - - return slug; - }; + const handleSlug = () => generateId(); const checkUrl = (url: string) => { if (!isValidUrl(url)) { @@ -70,19 +69,35 @@ export function LinkEditForm({ return true; }; - useEffect(() => { - if (data) { - setSlug(data.slug); - } - }, [data]); - if (linkId && isLoading) { return ; } return ( -
- {({ setValue }) => { + + {({ setValue, watch }) => { + const currentSlug = watch('slug') ?? initialSlug; + return ( <> - - - -