import { ImageResponse, loadGoogleFont } from 'workers-og'
interface Env {
OG_IMAGES: R2Bucket
OG_SECRET: string
}
// 正規化(Nuxt側と同じロジック)
function normalizeText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}
function normalizePath(path: string): string {
return path.replace(/^\/+/, '')
}
// 署名検証
async function verifySignature(
params: { path: string; version: string; title: string; author: string },
signature: string,
secret: string
): Promise<boolean> {
const encoder = new TextEncoder()
const payload = [
normalizePath(params.path),
params.version,
normalizeText(params.title),
normalizeText(params.author)
].join('|')
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
const expected = Array.from(new Uint8Array(sig))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
return expected === signature
}
// サニタイズ
function sanitize(text: string, maxLen: number): string {
const escaped = text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
return escaped.length > maxLen ? escaped.slice(0, maxLen - 3) + '...' : escaped
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// 既存: 日本語クイズOGP
if (url.pathname === '/og/japanese-quiz') {
return handleJapaneseQuizImage(url)
}
if (url.pathname === '/share/japanese-quiz') {
return handleSharePage(url)
}
// 新規: ブログ記事OGP
if (url.pathname.startsWith('/og/blog/')) {
return handleBlogOgImage(url, env)
}
return new Response('Not Found', { status: 404 })
},
}
async function handleBlogOgImage(url: URL, env: Env): Promise<Response> {
const match = url.pathname.match(/^\/og\/blog\/(.+)\.png$/)
if (!match) {
return new Response('Invalid path', { status: 400 })
}
const articlePath = match[1]
const signature = url.searchParams.get('sig')
const version = url.searchParams.get('v') || 'v1'
const title = url.searchParams.get('title') || articlePath
const author = url.searchParams.get('author') || 'Kei Komatsu'
// 署名検証
if (!signature || !(await verifySignature({ path: articlePath, version, title, author }, signature, env.OG_SECRET))) {
return new Response('Forbidden', { status: 403 })
}
// R2キャッシュ確認
const cacheKey = `blog/${articlePath}/${version}.png`
const cached = await env.OG_IMAGES.get(cacheKey)
if (cached) {
return new Response(cached.body, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'HIT'
}
})
}
// 生成
const safeTitle = sanitize(title, 80)
const safeAuthor = sanitize(author, 30)
const imageResponse = await generateBlogOgImage(safeTitle, safeAuthor)
const imageBuffer = await imageResponse.arrayBuffer()
// R2保存
await env.OG_IMAGES.put(cacheKey, imageBuffer, {
httpMetadata: { contentType: 'image/png' }
})
return new Response(imageBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-OG-Cache': 'MISS'
}
})
}
async function generateBlogOgImage(title: string, authorName: string): Promise<ImageResponse> {
const fontData = await loadGoogleFont({
family: 'Noto Sans JP',
weight: 700,
text: title + authorName + 'Eurekapu.com'
})
const html = `
<div style="display: flex; width: 1200px; height: 630px; justify-content: center; align-items: center; padding: 40px; background: linear-gradient(135deg, #ff0080 0%, #0066ff 100%); font-family: Noto Sans JP, sans-serif;">
<div style="display: flex; flex-direction: column; justify-content: space-between; width: 1100px; height: 530px; background: white; border-radius: 24px; padding: 50px 60px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);">
<div style="display: flex; flex: 1; align-items: center; justify-content: center; font-size: 52px; font-weight: 700; color: #1a1a1a; line-height: 1.4; text-align: center;">
${title}
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; font-size: 24px; color: #444; font-weight: 500;">
${authorName}
</div>
<div style="display: flex; font-size: 24px; color: #888; font-weight: 500;">
Eurekapu.com
</div>
</div>
</div>
</div>
`
return new ImageResponse(html, {
width: 1200,
height: 630,
fonts: [{ name: 'Noto Sans JP', data: fontData, weight: 700, style: 'normal' }]
})
}
import { createHmac } from 'crypto'
interface OgSignatureParams {
path: string
version: string
title: string
author: string
}
function normalizeText(text: string): string {
return text.trim().replace(/\s+/g, ' ')
}
function normalizePath(path: string): string {
return path.replace(/^\/+/, '')
}
export function generateOgSignature(params: OgSignatureParams): string {
const secret = process.env.OG_SECRET
if (!secret) {
throw new Error('OG_SECRET is not set. OGP signing is disabled.')
}
const payload = [
normalizePath(params.path),
params.version,
normalizeText(params.title),
normalizeText(params.author)
].join('|')
return createHmac('sha256', secret).update(payload).digest('hex').slice(0, 16)
}
import { generateOgSignature } from '~/server/utils/og-signature'
const authorName = 'Kei Komatsu'
const signature = generateOgSignature({
path,
version: updatedAt,
title,
author: authorName
})
const ogImageUrl = `https://log.eurekapu.com/og/blog${path}.png?v=${updatedAt}&title=${encodeURIComponent(title)}&author=${encodeURIComponent(authorName)}&sig=${signature}`
useHead({
meta: [
{ property: 'og:image', content: ogImageUrl },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:image', content: ogImageUrl },
]
})