From 1d8a66836ab29ee8034c1b0d24c5f349ae376c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EB=AF=BC?= Date: Sun, 7 Jun 2026 15:57:54 +0900 Subject: [PATCH] feat(meta): OG image + PWA manifest --- app/apple-icon.tsx | 29 +++++ app/icon.tsx | 30 +++++ app/icons/[size]/route.tsx | 42 +++++++ app/layout.tsx | 18 ++- app/manifest.ts | 42 +++++++ app/opengraph-image.tsx | 62 ++++++++++ app/sessions/[id]/opengraph-image.tsx | 172 ++++++++++++++++++++++++++ app/sessions/[id]/page.tsx | 27 ++++ lib/og-font.ts | 72 +++++++++++ middleware.ts | 11 +- 10 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 app/apple-icon.tsx create mode 100644 app/icon.tsx create mode 100644 app/icons/[size]/route.tsx create mode 100644 app/manifest.ts create mode 100644 app/opengraph-image.tsx create mode 100644 app/sessions/[id]/opengraph-image.tsx create mode 100644 lib/og-font.ts diff --git a/app/apple-icon.tsx b/app/apple-icon.tsx new file mode 100644 index 0000000..b820ac0 --- /dev/null +++ b/app/apple-icon.tsx @@ -0,0 +1,29 @@ +import { ImageResponse } from "next/og"; + +// iOS 홈 화면 추가용 아이콘 (180x180). +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ M +
+ ), + { ...size }, + ); +} diff --git a/app/icon.tsx b/app/icon.tsx new file mode 100644 index 0000000..09690fc --- /dev/null +++ b/app/icon.tsx @@ -0,0 +1,30 @@ +import { ImageResponse } from "next/og"; + +// Favicon (32x32). Latin "M" — 한국어 폰트 fetch 없이도 안정적으로 렌더. +export const size = { width: 32, height: 32 }; +export const contentType = "image/png"; + +export default function Icon() { + return new ImageResponse( + ( +
+ M +
+ ), + { ...size }, + ); +} diff --git a/app/icons/[size]/route.tsx b/app/icons/[size]/route.tsx new file mode 100644 index 0000000..a7d28ee --- /dev/null +++ b/app/icons/[size]/route.tsx @@ -0,0 +1,42 @@ +import { ImageResponse } from "next/og"; + +// PWA manifest 가 참조하는 동적 아이콘 라우트. `/icons/192`, `/icons/512` 만 허용. +// Korean 폰트 없이 안정적으로 렌더되도록 Latin "M" 사용. + +export const runtime = "nodejs"; + +const ALLOWED_SIZES = new Set([192, 512]); + +type Params = { size: string }; + +export async function GET( + _req: Request, + context: { params: Promise }, +) { + const { size: raw } = await context.params; + const n = Number.parseInt(raw, 10); + if (!ALLOWED_SIZES.has(n)) { + return new Response("Not Found", { status: 404 }); + } + return new ImageResponse( + ( +
+ M +
+ ), + { width: n, height: n }, + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 204e394..64f7fcc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -7,8 +7,24 @@ const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim(); export const metadata: Metadata = { metadataBase: appUrl ? new URL(appUrl) : undefined, - title: "뮤런", + title: { + default: "뮤런", + template: "%s · 뮤런", + }, description: "애니뮤 러닝 소모임의 정기 운동 아카이브", + applicationName: "뮤런", + openGraph: { + type: "website", + locale: "ko_KR", + siteName: "뮤런", + title: "뮤런", + description: "애니뮤 러닝 소모임의 정기 운동 아카이브", + }, + twitter: { + card: "summary_large_image", + title: "뮤런", + description: "애니뮤 러닝 소모임의 정기 운동 아카이브", + }, }; export const viewport: Viewport = { diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..5c4e28e --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,42 @@ +import type { MetadataRoute } from "next"; + +// PWA 매니페스트. iOS / Android 모두 홈 화면 추가 가능. +// 아이콘은 동적 라우트(`app/icons/[size]/route.tsx`)가 생성. +export default function manifest(): MetadataRoute.Manifest { + return { + name: "뮤런", + short_name: "뮤런", + description: "애니뮤 러닝 소모임의 정기 운동 아카이브", + start_url: "/", + display: "standalone", + orientation: "portrait", + background_color: "#ffffff", + theme_color: "#0f172a", + icons: [ + { + src: "/icons/192", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: "/icons/512", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + { + src: "/icons/192", + sizes: "192x192", + type: "image/png", + purpose: "maskable", + }, + { + src: "/icons/512", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx new file mode 100644 index 0000000..0d57b34 --- /dev/null +++ b/app/opengraph-image.tsx @@ -0,0 +1,62 @@ +import { ImageResponse } from "next/og"; + +import { getOgFonts } from "@/lib/og-font"; + +// 기본 사이트 OG (홈/그 외 페이지에서 더 구체적인 게 없을 때). 1200x630. +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const alt = "뮤런 — 애니뮤 러닝 소모임 아카이브"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function DefaultOg() { + let fonts: Awaited> | undefined; + try { + fonts = await getOgFonts(); + } catch { + fonts = undefined; + } + + return new ImageResponse( + ( +
+
+ MURUN +
+
+
+ 뮤런 +
+
+ 애니뮤 러닝 소모임의 정기 운동 아카이브 +
+
+
+ ), + { ...size, fonts }, + ); +} diff --git a/app/sessions/[id]/opengraph-image.tsx b/app/sessions/[id]/opengraph-image.tsx new file mode 100644 index 0000000..bb05f4e --- /dev/null +++ b/app/sessions/[id]/opengraph-image.tsx @@ -0,0 +1,172 @@ +import { ImageResponse } from "next/og"; +import { readFile } from "node:fs/promises"; +import sharp from "sharp"; + +import { db } from "@/lib/db"; +import { getOgFonts } from "@/lib/og-font"; +import { resolveUploadPath } from "@/lib/uploads"; + +// /sessions/[id] 의 OG 이미지 (1200x630). +// +// 카톡/슬랙 등 외부 크롤러는 인증을 안 하므로 이 라우트는 의도적으로 public. +// `middleware.ts` 가 `opengraph-image` 로 끝나는 path 를 auth 우회 처리한다. +// 단체사진은 1200x630 으로 리사이즈 + JPEG 70% 품질로 압축해 data URL 로 embed. +// 사진이 없으면 그라데이션 + 텍스트만. + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const alt = "뮤런 세션"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +type Props = { + params: Promise<{ id: string }>; +}; + +export default async function SessionOgImage({ params }: Props) { + const { id: idParam } = await params; + const id = Number.parseInt(idParam, 10); + + const sessionRow = + Number.isFinite(id) && id > 0 + ? await db.session.findUnique({ + where: { id }, + select: { + date: true, + location: true, + groupPhotoPath: true, + host: { select: { name: true } }, + }, + }) + : null; + + let photoDataUrl: string | null = null; + if (sessionRow?.groupPhotoPath) { + try { + const abs = resolveUploadPath(sessionRow.groupPhotoPath); + if (abs) { + // 디스크에서 원본을 읽고 OG 크기로 리사이즈 + JPEG 압축. + const buf = await readFile(abs); + const resized = await sharp(buf) + .resize(size.width, size.height, { fit: "cover" }) + .jpeg({ quality: 72, mozjpeg: true }) + .toBuffer(); + photoDataUrl = `data:image/jpeg;base64,${resized.toString("base64")}`; + } + } catch { + photoDataUrl = null; + } + } + + let fonts: Awaited> | undefined; + try { + fonts = await getOgFonts(); + } catch { + fonts = undefined; + } + + const dateText = sessionRow + ? new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + }).format(sessionRow.date) + : "뮤런"; + + const locationText = sessionRow?.location ?? "애니뮤 러닝"; + const hostText = sessionRow ? `호스트 · ${sessionRow.host.name}` : ""; + + return new ImageResponse( + ( +
+ {photoDataUrl && ( + // 단체사진 배경. ImageResponse(Satori) 는 미지원, 일반 필수. + // eslint-disable-next-line @next/next/no-img-element + + )} +
+
+
+ MURUN +
+
+
+ {dateText} +
+
+ {locationText} +
+ {hostText && ( +
+ {hostText} +
+ )} +
+
+
+ ), + { ...size, fonts }, + ); +} diff --git a/app/sessions/[id]/page.tsx b/app/sessions/[id]/page.tsx index b690996..75988f6 100644 --- a/app/sessions/[id]/page.tsx +++ b/app/sessions/[id]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -15,6 +16,32 @@ import { PhotoSection } from "./_components/PhotoSection"; export const dynamic = "force-dynamic"; +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { id: idParam } = await params; + const id = Number.parseInt(idParam, 10); + if (!Number.isFinite(id) || id <= 0) return {}; + const session = await db.session.findUnique({ + where: { id }, + select: { date: true, location: true, host: { select: { name: true } } }, + }); + if (!session) return {}; + const dateText = new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }).format(session.date); + const title = `${dateText} · ${session.location}`; + const description = `호스트: ${session.host.name}`; + return { + title, + description, + openGraph: { title, description }, + twitter: { title, description }, + }; +} + type PageProps = { params: Promise<{ id: string }>; }; diff --git a/lib/og-font.ts b/lib/og-font.ts new file mode 100644 index 0000000..c1430d6 --- /dev/null +++ b/lib/og-font.ts @@ -0,0 +1,72 @@ +// next/og 의 ImageResponse 는 기본 폰트로 한국어를 못 그린다 (□ 박스). +// 첫 OG 요청 시 Google Fonts 에서 Noto Sans KR Regular/Bold 두 weight 를 받아 +// 모듈 캐시에 보관 → 같은 서버 인스턴스 내 후속 요청은 fetch 없이 재사용. +// +// server-only. ImageResponse 안에서만 사용. + +import "server-only"; + +type FontEntry = { + name: string; + data: ArrayBuffer; + weight: 400 | 700; + style: "normal"; +}; + +let cache: FontEntry[] | null = null; +let inflight: Promise | null = null; + +const CSS_URL = + "https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap"; + +// Google Fonts 는 User-Agent 에 따라 woff2 vs ttf 를 다르게 준다. +// woff2 는 Satori 가 못 읽으니, 일반 데스크톱 UA 가 아닌 옛 UA 로 ttf 를 받는다. +const TTF_UA = + "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_0; en-US) AppleWebKit/525.13 (KHTML, like Gecko) Version/3.1 Safari/525.13"; + +async function fetchFonts(): Promise { + const cssResp = await fetch(CSS_URL, { headers: { "User-Agent": TTF_UA } }); + if (!cssResp.ok) { + throw new Error(`OG font CSS fetch failed: ${cssResp.status}`); + } + const css = await cssResp.text(); + + // CSS 안의 `@font-face` 블록에서 weight 와 src url 을 묶어서 추출. + const blocks = css.split("@font-face").slice(1); + const entries: FontEntry[] = []; + for (const block of blocks) { + const weightMatch = block.match(/font-weight:\s*(\d+)/); + const urlMatch = block.match(/url\((https:\/\/[^)]+\.ttf)\)/); + if (!weightMatch || !urlMatch) continue; + const weight = Number.parseInt(weightMatch[1], 10); + if (weight !== 400 && weight !== 700) continue; + const ttfResp = await fetch(urlMatch[1]); + if (!ttfResp.ok) continue; + entries.push({ + name: "Noto Sans KR", + data: await ttfResp.arrayBuffer(), + weight: weight as 400 | 700, + style: "normal", + }); + } + if (entries.length === 0) { + throw new Error("OG font TTF urls not found in Google Fonts CSS"); + } + return entries; +} + +export async function getOgFonts(): Promise { + if (cache) return cache; + if (inflight) return inflight; + inflight = fetchFonts() + .then((entries) => { + cache = entries; + return entries; + }) + .catch((err) => { + // 실패하면 캐시 비우고 다음 호출에서 재시도. 호출자는 fallback (영문) 로 떨어지기 가능. + inflight = null; + throw err; + }); + return inflight; +} diff --git a/middleware.ts b/middleware.ts index d4cd236..fd30433 100644 --- a/middleware.ts +++ b/middleware.ts @@ -16,6 +16,13 @@ export default auth((req) => { const path = nextUrl.pathname; + // 카톡/슬랙 등 외부 미리보기 크롤러용 OG/twitter 이미지 — 의도적으로 public. + // 메타데이터(날짜/장소/사진) 노출은 동아리 내부 서비스 + URL 추측이 아주 어렵지 않은 + // 점을 알면서도, 카톡 공유 UX 가치를 더 크게 봐서 수용. (회고-Week3 참조.) + if (path.endsWith("/opengraph-image") || path.endsWith("/twitter-image")) { + return; + } + // /login 은 비로그인만. 로그인된 사용자는 상태에 맞춰 보냄. if (path === "/login") { if (isLoggedIn) { @@ -42,6 +49,8 @@ export default auth((req) => { export const config = { matcher: [ - "/((?!api/auth|api/health|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico)).*)", + // exclude: api/auth, api/health, PWA manifest, Next icons, our /icons/[size], + // static assets, common image extensions. + "/((?!api/auth|api/health|manifest\\.webmanifest|icon|apple-icon|icons/|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:png|jpg|jpeg|gif|svg|webp|avif|ico)).*)", ], };