Skip to content

Commit 0942a22

Browse files
committed
feat(links): branded social-preview cards with auto OG metadata
When a Umami short link is shared on Twitter/X, Facebook, LinkedIn, Slack, Discord, WhatsApp, Telegram, or iMessage, the platform's crawler now receives an Open Graph card from Umami's own domain instead of being 307'd straight through to the destination. The card content is auto-derived from the destination URL's OG tags at create/update time, so existing-link UX stays set-and-forget. Humans keep the existing 307 redirect path with UTMs merged. - 6 new Link columns: ogTitle / ogDescription / ogImage plus per-field *Manual booleans tracking whether each value was user-set (preserved across URL changes) or auto-detected (re-fetched on URL change). - src/lib/og.ts: SSRF-hardened OG fetcher built on undici with a custom connect.lookup (closes the DNS-rebinding TOCTOU), an IP-literal pre-check that allows only `unicast` ranges (rejects loopback, private, link-local, NAT64 / 6to4 / teredo transition prefixes, and IPv4-mapped IPv6), manual redirect loop with per-hop revalidation, body/timeout/content-type caps, and entity-decoded regex meta extraction with surrogate guards. - src/lib/og-html.ts: single-line HTML renderer with strict per-response headers (CSP, X-Frame-Options DENY, Referrer-Policy, private/no-store + Vary: User-Agent). Conditional meta tag emission so empty fields produce no broken-image cards. - /q/[slug] route: isbot() branch returns OG HTML; human branch unchanged. - GET /api/links/og-preview: authed live-preview endpoint powering the form's debounced auto-detection. - API schemas (create + update) gain the three OG fields with the '' → null Zod transform plus a public-http(s)-only refinement on ogImage that reuses the same SSRF guard at the API edge. Update route preserves the undefined-vs-null PATCH distinction. - Form: collapsible "Customize preview" section with image preview, AbortController-cancellable live-preview fetch as you type, dirty- field-based submit normalization to avoid spurious clear-to-auto. - LinksTable: minmax(0, 1fr) on flex columns so long destinations no longer blow out row width. - 5 new i18n keys added to en-US and translated into all 51 other locales at correct alphabetical positions (each locale diff is exactly +5 lines). - New direct dep: undici@^8.2.0 for the Agent + connect.lookup APIs. Existing links are non-destructively migrated; old rows fall back to a basic preview card (name as title, "Redirects to <host>" as description, og:image omitted → twitter:card downgrades to summary). Future edits organically backfill via the URL-change re-parse path.
1 parent b3b65fc commit 0942a22

67 files changed

Lines changed: 1180 additions & 66 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
"serialize-error": "^13.0.1",
115115
"thenby": "^1.4.0",
116116
"ua-parser-js": "^2.0.9",
117+
"undici": "^8.2.0",
117118
"uuid": "^14.0.0",
118119
"zod": "^4.3.6",
119120
"zustand": "^5.0.12"

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- AlterTable
2+
ALTER TABLE "link"
3+
ADD COLUMN "og_title" VARCHAR(255),
4+
ADD COLUMN "og_description" VARCHAR(500),
5+
ADD COLUMN "og_image" VARCHAR(2047),
6+
ADD COLUMN "og_title_manual" BOOLEAN NOT NULL DEFAULT false,
7+
ADD COLUMN "og_description_manual" BOOLEAN NOT NULL DEFAULT false,
8+
ADD COLUMN "og_image_manual" BOOLEAN NOT NULL DEFAULT false;

prisma/schema.prisma

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -287,20 +287,26 @@ model Revenue {
287287
}
288288

289289
model Link {
290-
id String @id() @map("link_id") @db.Uuid
291-
name String @db.VarChar(100)
292-
url String @db.VarChar(500)
293-
slug String @unique() @db.VarChar(100)
294-
utmSource String? @map("utm_source") @db.VarChar(255)
295-
utmMedium String? @map("utm_medium") @db.VarChar(255)
296-
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
297-
utmTerm String? @map("utm_term") @db.VarChar(255)
298-
utmContent String? @map("utm_content") @db.VarChar(255)
299-
userId String? @map("user_id") @db.Uuid
300-
teamId String? @map("team_id") @db.Uuid
301-
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
302-
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
303-
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
290+
id String @id() @map("link_id") @db.Uuid
291+
name String @db.VarChar(100)
292+
url String @db.VarChar(500)
293+
slug String @unique() @db.VarChar(100)
294+
utmSource String? @map("utm_source") @db.VarChar(255)
295+
utmMedium String? @map("utm_medium") @db.VarChar(255)
296+
utmCampaign String? @map("utm_campaign") @db.VarChar(255)
297+
utmTerm String? @map("utm_term") @db.VarChar(255)
298+
utmContent String? @map("utm_content") @db.VarChar(255)
299+
ogTitle String? @map("og_title") @db.VarChar(255)
300+
ogDescription String? @map("og_description") @db.VarChar(500)
301+
ogImage String? @map("og_image") @db.VarChar(2047)
302+
ogTitleManual Boolean @default(false) @map("og_title_manual")
303+
ogDescriptionManual Boolean @default(false) @map("og_description_manual")
304+
ogImageManual Boolean @default(false) @map("og_image_manual")
305+
userId String? @map("user_id") @db.Uuid
306+
teamId String? @map("team_id") @db.Uuid
307+
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
308+
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
309+
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
304310
305311
user User? @relation("user", fields: [userId], references: [id])
306312
team Team? @relation(fields: [teamId], references: [id])

public/intl/messages/ar-SA.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"attribution": "الإسناد",
2828
"attribution-description": "شاهد كيف يتفاعل المستخدمون مع حملاتك التسويقية وما الذي يحفز التحويلات.",
2929
"audience": "جمهور",
30+
"auto-detected-from-destination": "تم اكتشافه تلقائيًا من الوجهة",
3031
"average": "المتوسط",
3132
"back": "للخلف",
3233
"before": "قبل",
@@ -74,6 +75,8 @@
7475
"current": "الحالي",
7576
"current-password": "كلمة المرور الحالية",
7677
"custom-range": "فترة مخصّصة",
78+
"customize-preview": "تخصيص المعاينة",
79+
"customize-preview-description": "تجاوز العنوان والوصف والصورة المستخدمة عند مشاركة هذا الرابط القصير على وسائل التواصل الاجتماعي. يتم اكتشاف الحقول الفارغة تلقائيًا من عنوان URL الوجهة.",
7780
"dashboard": "لوحة التحكم",
7881
"data": "البيانات",
7982
"date": "التاريخ",
@@ -140,6 +143,7 @@
140143
"growth": "نمو",
141144
"hostname": "اسم المضيف",
142145
"hour": "ساعة",
146+
"image-url": "رابط الصورة",
143147
"includes": "يتضمن",
144148
"inp": "INP",
145149
"insight": "رؤية معمقة",
@@ -230,6 +234,7 @@
230234
"poor": "ضعيف",
231235
"powered-by": "مشغل بواسطة {name}",
232236
"preferences": "التفضيلات",
237+
"preview": "معاينة",
233238
"previous": "السابق",
234239
"previous-period": "الفترة السابقة",
235240
"previous-year": "العام السابق",

public/intl/messages/be-BY.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"attribution": "Атрыбуцыя",
2828
"attribution-description": "Глядзіце, як карыстальнікі ўзаемадзейнічаюць з вашым маркетынгам і што прыводзіць да канверсій.",
2929
"audience": "Аўдыторыя",
30+
"auto-detected-from-destination": "Аўтаматычна вызначана з прызначэння",
3031
"average": "Сярэдняе",
3132
"back": "Назад",
3233
"before": "Да",
@@ -74,6 +75,8 @@
7475
"current": "Цяперашні",
7576
"current-password": "Цяперашні пароль",
7677
"custom-range": "Іншы дыяпазон",
78+
"customize-preview": "Наладзіць папярэдні прагляд",
79+
"customize-preview-description": "Перавызначыць загаловак, апісанне і відарыс, якія выкарыстоўваюцца пры абагульванні гэтай кароткай спасылкі ў сацыяльных сетках. Пустыя палі вызначаюцца аўтаматычна з URL прызначэння.",
7780
"dashboard": "Інфармацыйная панэль",
7881
"data": "Дадзеныя",
7982
"date": "Дата",
@@ -140,6 +143,7 @@
140143
"growth": "Рост",
141144
"hostname": "Імя хаста",
142145
"hour": "Гадзіна",
146+
"image-url": "URL відарыса",
143147
"includes": "Уключае",
144148
"inp": "INP",
145149
"insight": "Інсайт",
@@ -230,6 +234,7 @@
230234
"poor": "Дрэнна",
231235
"powered-by": "Зроблена {name}",
232236
"preferences": "Налады",
237+
"preview": "Папярэдні прагляд",
233238
"previous": "Папярэдні",
234239
"previous-period": "Папярэдні перыяд",
235240
"previous-year": "Папярэдні год",

public/intl/messages/bg-BG.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"attribution": "Атрибуция",
2828
"attribution-description": "Вижте как потребителите взаимодействат с вашия маркетинг и какво води до конверсии.",
2929
"audience": "Аудитория",
30+
"auto-detected-from-destination": "Автоматично открито от местоназначението",
3031
"average": "Средно",
3132
"back": "Назад",
3233
"before": "Преди",
@@ -74,6 +75,8 @@
7475
"current": "Текущ",
7576
"current-password": "Текуща парола",
7677
"custom-range": "Обхват",
78+
"customize-preview": "Персонализирай прегледа",
79+
"customize-preview-description": "Замени заглавието, описанието и изображението, използвани при споделяне на тази кратка връзка в социалните мрежи. Празните полета се откриват автоматично от URL на местоназначението.",
7780
"dashboard": "Табло",
7881
"data": "Данни",
7982
"date": "Дата",
@@ -140,6 +143,7 @@
140143
"growth": "Растеж",
141144
"hostname": "Име на хост",
142145
"hour": "Час",
146+
"image-url": "URL на изображението",
143147
"includes": "Включва",
144148
"inp": "INP",
145149
"insight": "Прозрение",
@@ -230,6 +234,7 @@
230234
"poor": "Слабо",
231235
"powered-by": "Поддържано от {name}",
232236
"preferences": "Предпочитания",
237+
"preview": "Преглед",
233238
"previous": "Предишен",
234239
"previous-period": "Предишен период",
235240
"previous-year": "Предишна година",

public/intl/messages/bn-BD.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"attribution": "অ্যাট্রিবিউশন",
2828
"attribution-description": "দেখুন ব্যবহারকারীরা কীভাবে আপনার মার্কেটিংয়ের সাথে যুক্ত হয় এবং কীভাবে রূপান্তর ঘটে।",
2929
"audience": "দর্শক",
30+
"auto-detected-from-destination": "গন্তব্য থেকে স্বয়ংক্রিয়ভাবে শনাক্ত",
3031
"average": "গড়",
3132
"back": "পেছনে",
3233
"before": "পূর্বে",
@@ -74,6 +75,8 @@
7475
"current": "বর্তমান",
7576
"current-password": "বর্তমান পাসওয়ার্ড",
7677
"custom-range": "কাস্টম রেঞ্জ",
78+
"customize-preview": "প্রিভিউ কাস্টমাইজ করুন",
79+
"customize-preview-description": "এই সংক্ষিপ্ত লিঙ্কটি সোশ্যাল মিডিয়ায় শেয়ার করার সময় ব্যবহৃত শিরোনাম, বিবরণ এবং চিত্র ওভাররাইড করুন। খালি ক্ষেত্রগুলি গন্তব্য URL থেকে স্বয়ংক্রিয়ভাবে শনাক্ত করা হয়।",
7780
"dashboard": "ড্যাশবোর্ড",
7881
"data": "ডেটা",
7982
"date": "তারিখ",
@@ -140,6 +143,7 @@
140143
"growth": "বৃদ্ধি",
141144
"hostname": "হোস্টনেম",
142145
"hour": "ঘণ্টা",
146+
"image-url": "চিত্রের URL",
143147
"includes": "অন্তর্ভুক্ত",
144148
"inp": "INP",
145149
"insight": "অন্তর্দৃষ্টি",
@@ -230,6 +234,7 @@
230234
"poor": "খারাপ",
231235
"powered-by": "{name} দ্বারা চালিত",
232236
"preferences": "পছন্দসমূহ",
237+
"preview": "প্রিভিউ",
233238
"previous": "পূর্ববর্তী",
234239
"previous-period": "পূর্ববর্তী সময়কাল",
235240
"previous-year": "গত বছর",

public/intl/messages/bs-BA.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"attribution": "Atribucija",
2626
"attribution-description": "Pogledajte kako korisnici komuniciraju s vašim marketingom i šta dovodi do konverzija.",
2727
"audience": "Publika",
28+
"auto-detected-from-destination": "Automatski otkriveno iz odredišta",
2829
"average": "Prosjek",
2930
"back": "Nazad",
3031
"before": "Prije",
@@ -54,6 +55,10 @@
5455
"confirm": "Potvrdi",
5556
"confirm-password": "Potvrdi šifru",
5657
"contains": "Sadrži",
58+
"customize-preview": "Prilagodi pregled",
59+
"customize-preview-description": "Zamijenite naslov, opis i sliku koji se koriste kada se ovaj kratki link dijeli na društvenim mrežama. Prazna polja se automatski otkrivaju iz odredišnog URL-a.",
60+
"image-url": "URL slike",
61+
"preview": "Pregled",
5762
"regex-match": "Odgovara regularnom izrazu",
5863
"regex-not-match": "Ne odgovara regularnom izrazu",
5964
"content": "Sadržaj",

public/intl/messages/ca-ES.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"attribution": "Atribució",
2626
"attribution-description": "Vegeu com els usuaris interactuen amb el vostre màrqueting i què impulsa les conversions.",
2727
"audience": "Audiència",
28+
"auto-detected-from-destination": "Detectat automàticament des de la destinació",
2829
"average": "Mitjana",
2930
"back": "Enrere",
3031
"before": "Abans",
@@ -54,6 +55,10 @@
5455
"confirm": "Confirmar",
5556
"confirm-password": "Confirma la contrasenya",
5657
"contains": "Conté",
58+
"customize-preview": "Personalitza la visualització prèvia",
59+
"customize-preview-description": "Substitueix el títol, la descripció i la imatge que s'utilitzen quan es comparteix aquest enllaç curt a les xarxes socials. Els camps buits es detecten automàticament des de l'URL de destinació.",
60+
"image-url": "URL de la imatge",
61+
"preview": "Visualització prèvia",
5762
"regex-match": "Coincideix amb l'expressió regular",
5863
"regex-not-match": "No coincideix amb l'expressió regular",
5964
"content": "Contingut",

0 commit comments

Comments
 (0)