Skip to content
File

Blob: src/worker/sites/cover.ts

typescript286 lines
1import { and, eq } from "drizzle-orm";
2 
3import { isGradientPreset, parseUploadCoverUrl } from "@/shared/page-cover";
4import type { SiteOgImage } from "@/sites/types";
5import type { Db } from "@/worker/db/d1/client";
6import { uploads } from "@/worker/db/d1/schema";
7import type { ResolvedPublishedPage } from "@/worker/lib/published-pages";
8import { createRenderDependencyHash } from "@/worker/sites/cache";
9 
10export const SITE_COVER_WIDTH = 1200;
11export const SITE_COVER_HEIGHT = 630;
12export const SITE_COVER_FIT = "cover";
13export const SITE_COVER_GRAVITY = "center";
14export const SITE_COVER_FORMAT = "image/png";
15export const SITE_COVER_ANIMATED = false;
16 
17const LINEAR_GRADIENT = /^linear-gradient\(\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))deg\s*,\s*(.+)\s*\)$/;
18const GRADIENT_STOP = /^\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\s+([+-]?(?:\d+(?:\.\d+)?|\.\d+))%\s*$/;
19const OG_SAFE_UPLOAD_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
20const VERSIONED_CACHE_CONTROL = "public, max-age=31536000, immutable";
21const UNVERSIONED_CACHE_CONTROL = "public, max-age=300, must-revalidate";
22 
23export type SiteCoverSource = "gradient" | "upload";
24 
25export interface ParsedGradientStop {
26 position: number;
27 r: number;
28 g: number;
29 b: number;
30}
31 
32export interface SupportedLinearGradient {
33 angleDeg: number;
34 stops: ParsedGradientStop[];
35}
36 
37export interface ServeSiteCoverArgs {
38 env: Pick<Env, "R2" | "SITES" | "TASKS_QUEUE">;
39 db: Db;
40 page: ResolvedPublishedPage;
41 versionHash: string | null;
42}
43 
44export interface ResolvedUploadCover {
45 id: string;
46 contentType: string;
47 r2Key: string;
48}
49 
50export function siteCoverKey(workspaceId: string, pageId: string): string {
51 return `${workspaceId}/${pageId}/cover.png`;
52}
53 
54export 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 
67export 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 
92export function isOgImageType(contentType: string): boolean {
93 return safeImageType(contentType) !== null;
94}
95 
96export 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 
115export 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 
144export 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 
179async 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 
203export 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 
213export 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 
221export 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 
230function 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 
238function 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 
243function 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 
261function 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 
268export function coverNotFound(): Response {
269 return new Response(null, {
270 status: 404,
271 headers: {
272 "Cache-Control": "no-store",
273 },
274 });
275}
276 
277function coverRepairing(): Response {
278 return new Response(null, {
279 status: 503,
280 headers: {
281 "Cache-Control": "no-store",
282 "Retry-After": "5",
283 },
284 });
285}