File
Blob: src/worker/sites/cover.ts
| 1 | import { and, eq } from "drizzle-orm"; |
| 2 | |
| 3 | import { isGradientPreset, parseUploadCoverUrl } from "@/shared/page-cover"; |
| 4 | import type { SiteOgImage } from "@/sites/types"; |
| 5 | import type { Db } from "@/worker/db/d1/client"; |
| 6 | import { uploads } from "@/worker/db/d1/schema"; |
| 7 | import type { ResolvedPublishedPage } from "@/worker/lib/published-pages"; |
| 8 | import { createRenderDependencyHash } from "@/worker/sites/cache"; |
| 9 | |
| 10 | export const SITE_COVER_WIDTH = 1200; |
| 11 | export const SITE_COVER_HEIGHT = 630; |
| 12 | export const SITE_COVER_FIT = "cover"; |
| 13 | export const SITE_COVER_GRAVITY = "center"; |
| 14 | export const SITE_COVER_FORMAT = "image/png"; |
| 15 | export const SITE_COVER_ANIMATED = false; |
| 16 | |
| 17 | const LINEAR_GRADIENT = /^linear-gradient\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))deg\s*,\s*(.+)\s*\)$/; |
| 18 | const GRADIENT_STOP = /^\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\s+([+-]?(?:\d+(?:\.\d+)?|\.\d+))%\s*$/; |
| 19 | const OG_SAFE_UPLOAD_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); |
| 20 | const VERSIONED_CACHE_CONTROL = "public, max-age=31536000, immutable"; |
| 21 | const UNVERSIONED_CACHE_CONTROL = "public, max-age=300, must-revalidate"; |
| 22 | |
| 23 | export type SiteCoverSource = "gradient" | "upload"; |
| 24 | |
| 25 | export interface ParsedGradientStop { |
| 26 | position: number; |
| 27 | r: number; |
| 28 | g: number; |
| 29 | b: number; |
| 30 | } |
| 31 | |
| 32 | export interface SupportedLinearGradient { |
| 33 | angleDeg: number; |
| 34 | stops: ParsedGradientStop[]; |
| 35 | } |
| 36 | |
| 37 | export interface ServeSiteCoverArgs { |
| 38 | env: Pick<Env, "R2" | "SITES" | "TASKS_QUEUE">; |
| 39 | db: Db; |
| 40 | page: ResolvedPublishedPage; |
| 41 | versionHash: string | null; |
| 42 | } |
| 43 | |
| 44 | export interface ResolvedUploadCover { |
| 45 | id: string; |
| 46 | contentType: string; |
| 47 | r2Key: string; |
| 48 | } |
| 49 | |
| 50 | export function siteCoverKey(workspaceId: string, pageId: string): string { |
| 51 | return `${workspaceId}/${pageId}/cover.png`; |
| 52 | } |
| 53 | |
| 54 | export function createSiteCoverHash(coverUrl: string): Promise<string> { |
| 55 | return createRenderDependencyHash({ |
| 56 | version: "site-cover:v2", |
| 57 | width: SITE_COVER_WIDTH, |
| 58 | height: SITE_COVER_HEIGHT, |
| 59 | fit: SITE_COVER_FIT, |
| 60 | gravity: SITE_COVER_GRAVITY, |
| 61 | format: SITE_COVER_FORMAT, |
| 62 | anim: SITE_COVER_ANIMATED, |
| 63 | coverUrl, |
| 64 | }); |
| 65 | } |
| 66 | |
| 67 | export function parseGradient(coverUrl: string): SupportedLinearGradient | null { |
| 68 | const match = LINEAR_GRADIENT.exec(coverUrl); |
| 69 | if (!match) return null; |
| 70 | |
| 71 | const angleDeg = Number(match[1]); |
| 72 | if (!Number.isFinite(angleDeg)) return null; |
| 73 | |
| 74 | const rawStops = match[2].split(","); |
| 75 | if (rawStops.length < 2) return null; |
| 76 | |
| 77 | const stops: ParsedGradientStop[] = []; |
| 78 | for (const rawStop of rawStops) { |
| 79 | const stop = GRADIENT_STOP.exec(rawStop); |
| 80 | if (!stop) return null; |
| 81 | const color = parseHexColor(stop[1]); |
| 82 | const position = clampPercent(Number(stop[2])); |
| 83 | if (!color || !Number.isFinite(position)) return null; |
| 84 | stops.push({ position, ...color }); |
| 85 | } |
| 86 | |
| 87 | if (stops.length < 2) return null; |
| 88 | stops.sort((a, b) => a.position - b.position); |
| 89 | return { angleDeg, stops }; |
| 90 | } |
| 91 | |
| 92 | export function isOgImageType(contentType: string): boolean { |
| 93 | return safeImageType(contentType) !== null; |
| 94 | } |
| 95 | |
| 96 | export async function resolveUploadCover( |
| 97 | db: Db, |
| 98 | page: Pick<ResolvedPublishedPage, "id" | "workspace_id">, |
| 99 | uploadId: string, |
| 100 | ): Promise<ResolvedUploadCover | null> { |
| 101 | const upload = await db |
| 102 | .select({ |
| 103 | id: uploads.id, |
| 104 | content_type: uploads.content_type, |
| 105 | r2_key: uploads.r2_key, |
| 106 | }) |
| 107 | .from(uploads) |
| 108 | .where(and(eq(uploads.id, uploadId), eq(uploads.workspace_id, page.workspace_id), eq(uploads.page_id, page.id))) |
| 109 | .get(); |
| 110 | const contentType = upload ? safeImageType(upload.content_type) : null; |
| 111 | if (!upload || !contentType) return null; |
| 112 | return { id: upload.id, contentType, r2Key: upload.r2_key }; |
| 113 | } |
| 114 | |
| 115 | export async function resolveOgCover( |
| 116 | db: Db, |
| 117 | page: ResolvedPublishedPage, |
| 118 | canonicalUrl: string, |
| 119 | ): Promise<SiteOgImage | null> { |
| 120 | const coverUrl = page.cover_url; |
| 121 | if (!coverUrl) return null; |
| 122 | |
| 123 | const uploadId = parseUploadCoverUrl(coverUrl); |
| 124 | if (uploadId) { |
| 125 | const upload = await resolveUploadCover(db, page, uploadId); |
| 126 | if (!upload) return null; |
| 127 | return { |
| 128 | url: await absoluteCoverUrl(page.id, coverUrl, canonicalUrl), |
| 129 | type: SITE_COVER_FORMAT, |
| 130 | width: SITE_COVER_WIDTH, |
| 131 | height: SITE_COVER_HEIGHT, |
| 132 | }; |
| 133 | } |
| 134 | |
| 135 | if (!isGradientPreset(coverUrl) || !parseGradient(coverUrl)) return null; |
| 136 | return { |
| 137 | url: await absoluteCoverUrl(page.id, coverUrl, canonicalUrl), |
| 138 | type: SITE_COVER_FORMAT, |
| 139 | width: SITE_COVER_WIDTH, |
| 140 | height: SITE_COVER_HEIGHT, |
| 141 | }; |
| 142 | } |
| 143 | |
| 144 | export async function serveSiteCover(args: ServeSiteCoverArgs): Promise<Response> { |
| 145 | const { env, db, page, versionHash } = args; |
| 146 | const coverUrl = page.cover_url; |
| 147 | if (!coverUrl) return coverNotFound(); |
| 148 | |
| 149 | const coverHash = await createSiteCoverHash(coverUrl); |
| 150 | if (versionHash !== null && versionHash !== coverHash) return coverNotFound(); |
| 151 | |
| 152 | const cacheControl = versionHash ? VERSIONED_CACHE_CONTROL : UNVERSIONED_CACHE_CONTROL; |
| 153 | const uploadId = parseUploadCoverUrl(coverUrl); |
| 154 | if (uploadId) { |
| 155 | const upload = await resolveUploadCover(db, page, uploadId); |
| 156 | if (!upload) return coverNotFound(); |
| 157 | const source = await env.R2.head(upload.r2Key); |
| 158 | if (!source) return coverNotFound(); |
| 159 | return serveArtifact({ |
| 160 | env, |
| 161 | page, |
| 162 | coverUrl, |
| 163 | coverHash, |
| 164 | cacheControl, |
| 165 | }); |
| 166 | } |
| 167 | |
| 168 | if (!isGradientPreset(coverUrl) || !parseGradient(coverUrl)) return coverNotFound(); |
| 169 | |
| 170 | return serveArtifact({ |
| 171 | env, |
| 172 | page, |
| 173 | coverUrl, |
| 174 | coverHash, |
| 175 | cacheControl, |
| 176 | }); |
| 177 | } |
| 178 | |
| 179 | async function serveArtifact(args: { |
| 180 | env: Pick<Env, "SITES" | "TASKS_QUEUE">; |
| 181 | page: Pick<ResolvedPublishedPage, "id" | "workspace_id">; |
| 182 | coverUrl: string; |
| 183 | coverHash: string; |
| 184 | cacheControl: string; |
| 185 | }): Promise<Response> { |
| 186 | const { env, page, coverUrl, coverHash, cacheControl } = args; |
| 187 | const object = await env.SITES.get(siteCoverKey(page.workspace_id, page.id)); |
| 188 | if (!object || !coverMatches(object, coverUrl, coverHash)) { |
| 189 | await enqueueSiteCover(env, page.id); |
| 190 | return coverRepairing(); |
| 191 | } |
| 192 | |
| 193 | return new Response(object.body, { |
| 194 | headers: { |
| 195 | "Content-Type": SITE_COVER_FORMAT, |
| 196 | "Content-Length": String(object.size), |
| 197 | "Cache-Control": cacheControl, |
| 198 | ETag: object.httpEtag, |
| 199 | }, |
| 200 | }); |
| 201 | } |
| 202 | |
| 203 | export function coverMetadata(coverUrl: string, coverHash: string, source: SiteCoverSource): Record<string, string> { |
| 204 | return { |
| 205 | cover_hash: coverHash, |
| 206 | cover_url: coverUrl, |
| 207 | width: String(SITE_COVER_WIDTH), |
| 208 | height: String(SITE_COVER_HEIGHT), |
| 209 | source, |
| 210 | }; |
| 211 | } |
| 212 | |
| 213 | export async function enqueueSiteCover(env: Pick<Env, "TASKS_QUEUE">, pageId: string): Promise<void> { |
| 214 | try { |
| 215 | await env.TASKS_QUEUE.send({ type: "site-cover", pageId }); |
| 216 | } catch { |
| 217 | // Generated covers are derived artifacts; later saves or requests can repair them. |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | export function coverMatches(object: R2Object, coverUrl: string, coverHash: string): boolean { |
| 222 | return ( |
| 223 | object.customMetadata?.cover_hash === coverHash && |
| 224 | object.customMetadata.cover_url === coverUrl && |
| 225 | object.customMetadata.width === String(SITE_COVER_WIDTH) && |
| 226 | object.customMetadata.height === String(SITE_COVER_HEIGHT) |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | function absoluteCoverUrl(pageId: string, coverUrl: string, canonicalUrl: string): Promise<string> { |
| 231 | return createSiteCoverHash(coverUrl).then((hash) => { |
| 232 | const url = new URL(`/_assets/${pageId}/cover`, canonicalUrl); |
| 233 | url.searchParams.set("v", hash); |
| 234 | return url.toString(); |
| 235 | }); |
| 236 | } |
| 237 | |
| 238 | function safeImageType(contentType: string): string | null { |
| 239 | const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; |
| 240 | return OG_SAFE_UPLOAD_TYPES.has(normalized) ? normalized : null; |
| 241 | } |
| 242 | |
| 243 | function parseHexColor(hex: string): { r: number; g: number; b: number } | null { |
| 244 | if (hex.length === 3) { |
| 245 | return { |
| 246 | r: Number.parseInt(`${hex[0]}${hex[0]}`, 16), |
| 247 | g: Number.parseInt(`${hex[1]}${hex[1]}`, 16), |
| 248 | b: Number.parseInt(`${hex[2]}${hex[2]}`, 16), |
| 249 | }; |
| 250 | } |
| 251 | if (hex.length === 6) { |
| 252 | return { |
| 253 | r: Number.parseInt(hex.slice(0, 2), 16), |
| 254 | g: Number.parseInt(hex.slice(2, 4), 16), |
| 255 | b: Number.parseInt(hex.slice(4, 6), 16), |
| 256 | }; |
| 257 | } |
| 258 | return null; |
| 259 | } |
| 260 | |
| 261 | function clampPercent(value: number): number { |
| 262 | if (!Number.isFinite(value)) return Number.NaN; |
| 263 | if (value < 0) return 0; |
| 264 | if (value > 100) return 100; |
| 265 | return value; |
| 266 | } |
| 267 | |
| 268 | export function coverNotFound(): Response { |
| 269 | return new Response(null, { |
| 270 | status: 404, |
| 271 | headers: { |
| 272 | "Cache-Control": "no-store", |
| 273 | }, |
| 274 | }); |
| 275 | } |
| 276 | |
| 277 | function coverRepairing(): Response { |
| 278 | return new Response(null, { |
| 279 | status: 503, |
| 280 | headers: { |
| 281 | "Cache-Control": "no-store", |
| 282 | "Retry-After": "5", |
| 283 | }, |
| 284 | }); |
| 285 | } |