diff --git a/.gitignore b/.gitignore index b2bb625a..9acc368b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ yarn-error.log* .hatch-runs/ batches/ public/version.json +public/sticker-review/ src/generated/current-build.ts # CLI build artifacts (regenerated by `bun run build`) diff --git a/drizzle/0018_sticker_exports.sql b/drizzle/0018_sticker_exports.sql new file mode 100644 index 00000000..2163a772 --- /dev/null +++ b/drizzle/0018_sticker_exports.sql @@ -0,0 +1,119 @@ +ALTER TABLE "submitted_pets" ADD COLUMN IF NOT EXISTS "sprite_sha256" text; +ALTER TABLE "submitted_pets" ADD COLUMN IF NOT EXISTS "pet_json_sha256" text; +ALTER TABLE "submitted_pets" ADD COLUMN IF NOT EXISTS "zip_sha256" text; + +CREATE TABLE IF NOT EXISTS "pet_export_approvals" ( + "pet_id" text NOT NULL REFERENCES "submitted_pets"("id") ON DELETE CASCADE, + "scope" text DEFAULT 'stickers' NOT NULL, + "status" text NOT NULL, + "source_sha256" text NOT NULL, + "policy_version" text NOT NULL, + "reviewed_by" text NOT NULL, + "reason" text NOT NULL, + "reviewed_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "pet_export_approvals_pet_id_scope_pk" PRIMARY KEY("pet_id","scope") +); + +CREATE TABLE IF NOT EXISTS "pet_sticker_publications" ( + "pet_id" text PRIMARY KEY NOT NULL REFERENCES "submitted_pets"("id") ON DELETE CASCADE, + "source_sha256" text NOT NULL, + "artifact_version" text NOT NULL, + "states" jsonb NOT NULL, + "formats" jsonb NOT NULL, + "profiles" jsonb NOT NULL, + "treatments" jsonb NOT NULL, + "object_count" integer NOT NULL, + "total_bytes" integer NOT NULL, + "manifest_sha256" text NOT NULL, + "status" text NOT NULL, + "cleanup_status" text DEFAULT 'not_required' NOT NULL, + "cleanup_error" text, + "published_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "source_sha256" text; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "artifact_version" text; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "states" jsonb; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "formats" jsonb; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "profiles" jsonb; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "treatments" jsonb; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "object_count" integer; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "total_bytes" integer; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "manifest_sha256" text; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "status" text; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "cleanup_status" text; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "cleanup_error" text; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "published_at" timestamp with time zone; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "revoked_at" timestamp with time zone; +ALTER TABLE "pet_sticker_publications" ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone; +UPDATE "pet_sticker_publications" SET + "source_sha256" = COALESCE("source_sha256", ''), + "artifact_version" = COALESCE("artifact_version", 'legacy'), + "states" = COALESCE("states", '[]'::jsonb), + "formats" = COALESCE("formats", '[]'::jsonb), + "profiles" = COALESCE("profiles", '[]'::jsonb), + "treatments" = COALESCE("treatments", '[]'::jsonb), + "object_count" = COALESCE("object_count", 0), + "total_bytes" = COALESCE("total_bytes", 0), + "manifest_sha256" = COALESCE("manifest_sha256", ''), + "status" = COALESCE("status", 'revoked'), + "cleanup_status" = COALESCE("cleanup_status", 'pending'), + "published_at" = COALESCE("published_at", now()), + "updated_at" = COALESCE("updated_at", now()); +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "source_sha256" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "artifact_version" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "states" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "formats" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "profiles" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "treatments" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "object_count" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "total_bytes" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "manifest_sha256" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "status" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "cleanup_status" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "published_at" SET NOT NULL; +ALTER TABLE "pet_sticker_publications" ALTER COLUMN "updated_at" SET NOT NULL; + +CREATE INDEX IF NOT EXISTS "pet_export_approvals_scope_status_idx" ON "pet_export_approvals" ("scope","status"); +CREATE INDEX IF NOT EXISTS "pet_export_approvals_source_idx" ON "pet_export_approvals" ("pet_id","source_sha256"); +CREATE INDEX IF NOT EXISTS "pet_sticker_publications_status_idx" ON "pet_sticker_publications" ("status"); +CREATE INDEX IF NOT EXISTS "pet_sticker_publications_source_idx" ON "pet_sticker_publications" ("pet_id","source_sha256"); + +INSERT INTO "pet_collections" ( + "id", + "slug", + "title", + "description", + "cover_pet_slug", + "featured", + "updated_at" +) +VALUES ( + 'claude', + 'claude', + 'Claude', + 'Claude Code pets curated for reaction stickers.', + 'claude-crab', + false, + now() +) +ON CONFLICT ("slug") DO NOTHING; + +INSERT INTO "pet_collection_items" ("collection_id", "pet_slug", "position") +SELECT 'claude', pet."slug", candidate."position" +FROM (VALUES + ('claude-crab', 0), + ('claude-spectacles-3', 1), + ('claude-spectacles-4', 2), + ('clawd-music', 3), + ('clawd-2', 4), + ('clawd-4', 5), + ('clawd-3', 6), + ('clawdex', 7) +) AS candidate("slug", "position") +INNER JOIN "submitted_pets" pet ON pet."slug" = candidate."slug" AND pet."status" = 'approved' +ON CONFLICT ("collection_id", "pet_slug") DO NOTHING; diff --git a/package.json b/package.json index 45c66a2b..c968ce6a 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "previews:snapshot:apply": "bun --env-file=.env.local --env-file=.env.production.local --conditions react-server scripts/publish-pet-previews.ts apply", "stickers:snapshot:check": "bun --env-file=.env.local --env-file=.env.production.local --conditions react-server scripts/publish-pet-sticker-artifacts.ts check", "stickers:snapshot:apply": "bun --env-file=.env.local --env-file=.env.production.local --conditions react-server scripts/publish-pet-sticker-artifacts.ts apply", + "stickers:review": "bun --env-file=.env.local --env-file=.env.production.local --conditions react-server scripts/render-pet-sticker-review.ts", "cost:report": "bun scripts/vercel-cost-report.ts --project petdex", "cost:apply-route-buckets": "bun scripts/apply-route-cost-buckets.ts", "auto-tag": "bun scripts/auto-tag.ts", diff --git a/scripts/apply-sticker-exports.ts b/scripts/apply-sticker-exports.ts new file mode 100644 index 00000000..a9760f61 --- /dev/null +++ b/scripts/apply-sticker-exports.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { neon } from "@neondatabase/serverless"; + +import { splitSqlStatements } from "@/lib/sql-statements"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) throw new Error("DATABASE_URL is not set"); + +const sql = neon(databaseUrl); +const migration = await readFile( + join(process.cwd(), "drizzle/0018_sticker_exports.sql"), + "utf8", +); + +const statements = splitSqlStatements(migration); +await sql.transaction(statements.map((statement) => sql.query(statement, []))); + +console.log("sticker export schema applied"); diff --git a/scripts/publish-pet-sticker-artifacts.ts b/scripts/publish-pet-sticker-artifacts.ts index da3bed5e..0b0acabc 100644 --- a/scripts/publish-pet-sticker-artifacts.ts +++ b/scripts/publish-pet-sticker-artifacts.ts @@ -5,46 +5,67 @@ import { HeadObjectCommand, PutObjectCommand, } from "@aws-sdk/client-s3"; -import JSZip from "jszip"; -import sharp from "sharp"; +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { db, schema } from "@/lib/db/client"; import type { PetStateId } from "@/lib/pet-states"; -import { petStates } from "@/lib/pet-states"; import { PET_STICKER_CACHE_HEADER, PET_STICKER_STATES, type PetStickerFormat, + type PetStickerProfile, + type PetStickerTreatment, petStickerFilename, petStickerKey, - petStickerPackFilename, - petStickerPackKey, + petStickerTrayFilename, + petStickerTrayKey, + petStickerTrayUrl, + petStickerUrl, } from "@/lib/pet-sticker-artifacts"; -import { getAllApprovedPets } from "@/lib/pets"; import { R2_BUCKET, r2 } from "@/lib/r2"; import { keyFromR2PublicUrl } from "@/lib/r2-public-url"; -import { renderSticker, STICKER_SIZES } from "@/lib/sticker-renderer"; +import { + STICKER_ARTIFACT_VERSION, + STICKER_EXPORT_POLICY_VERSION, + STICKER_EXPORT_SCOPE, + STICKER_PUBLIC_FORMATS, + STICKER_PUBLIC_PROFILES, + STICKER_PUBLIC_TREATMENTS, + stickerFormatsForProfile, +} from "@/lib/sticker-export-policy"; +import { + renderSticker, + renderWhatsAppTray, + STICKER_SIZES, +} from "@/lib/sticker-renderer"; import { isAllowedAssetUrl } from "@/lib/url-allowlist"; +import { + assertWhatsAppSticker, + assertWhatsAppTray, +} from "@/lib/whatsapp-sticker"; type Mode = "check" | "apply"; -type Artifact = "idle-webp" | "all-webp" | "pack"; +type Artifact = "idle-webp" | "all-webp" | "reactions"; type ArtifactRef = | { kind: "sticker"; key: string; state: PetStateId; format: PetStickerFormat; + profile: PetStickerProfile; + treatment: PetStickerTreatment; } | { - kind: "pack"; + kind: "whatsapp-tray"; key: string; }; type StickerTask = { slug: string; - displayName: string; - description: string; + id: string; spritesheetPath: string; spritesheetKey: string; + sourceSha256: string; refs: ArtifactRef[]; }; @@ -56,6 +77,7 @@ const mode = parseMode(process.argv[2]); const force = process.argv.includes("--force"); const limit = parseLimit(process.argv); const artifacts = parseArtifacts(process.argv); +const collectionSlug = parseStringArg(process.argv, "collection"); const headConcurrency = parseConcurrency("PETDEX_STICKER_HEAD_CONCURRENCY", 24); const publishConcurrency = parseConcurrency( "PETDEX_STICKER_PUBLISH_CONCURRENCY", @@ -63,17 +85,17 @@ const publishConcurrency = parseConcurrency( ); const progressEvery = parseConcurrency("PETDEX_STICKER_PROGRESS_EVERY", 50); -const allPets = await getAllApprovedPets(); +const allPets = await getPublishablePets(collectionSlug); const selectedPets = typeof limit === "number" ? allPets.slice(0, limit) : allPets; const tasks = selectedPets.map((pet) => { - const spritesheetKey = keyFromR2PublicUrl(pet.spritesheetPath); + const spritesheetKey = keyFromR2PublicUrl(pet.spritesheetUrl); return { slug: pet.slug, - displayName: pet.displayName, - description: pet.description, - spritesheetPath: pet.spritesheetPath, + id: pet.id, + spritesheetPath: pet.spritesheetUrl, spritesheetKey: spritesheetKey ?? "", + sourceSha256: pet.spriteSha256, refs: refsForArtifacts(pet.slug, artifacts), }; }); @@ -84,14 +106,18 @@ const invalidTasks = tasks.filter( (task) => !isAllowedAssetUrl(task.spritesheetPath) || !task.spritesheetKey, ); const requiredRefs = validTasks.flatMap((task) => - task.refs.map((ref) => ({ slug: task.slug, key: ref.key })), + task.refs.map((ref) => ({ + slug: task.slug, + key: ref.key, + sourceSha256: task.sourceSha256, + })), ); const existingKeys = force ? new Set() : new Set( ( await mapLimit(requiredRefs, headConcurrency, async (ref) => - (await r2ObjectExists(ref.key)) ? ref.key : null, + (await r2ObjectIsCurrent(ref.key, ref.sourceSha256)) ? ref.key : null, ) ).filter((key): key is string => Boolean(key)), ); @@ -109,7 +135,8 @@ const pendingRefs = pendingTasks.reduce( ); console.log(`pet sticker artifacts ${mode}`); -console.log(`approved ${allPets.length}`); +console.log(`eligible ${allPets.length}`); +console.log(`collection ${collectionSlug ?? "all"}`); console.log(`selected ${selectedPets.length}`); console.log(`artifacts ${artifacts.join(",")}`); console.log(`valid pets ${validTasks.length}`); @@ -184,13 +211,75 @@ if (mode === "apply") { console.log(`failed ${result.slug} ${result.reason}`); } - if (failed.length > 0) process.exit(1); + if (artifacts.includes("reactions")) { + const failedSlugs = new Set(failed.map((result) => result.slug)); + const purged = await mapLimit( + validTasks.filter((task) => !failedSlugs.has(task.slug)), + publishConcurrency, + purgeReactionTask, + ); + const purgeFailures = purged.filter( + (result): result is Extract => !result.ok, + ); + for (const result of purgeFailures) failedSlugs.add(result.slug); + console.log(`purged publications ${purged.length - purgeFailures.length}`); + for (const result of purgeFailures.slice(0, 20)) { + console.log(`purge failed ${result.slug} ${result.reason}`); + } + const finalized = await mapLimit( + validTasks.filter((task) => !failedSlugs.has(task.slug)), + publishConcurrency, + finalizeReactionPublication, + ); + const finalizeFailures = finalized.filter( + (result): result is Extract => !result.ok, + ); + for (const result of finalizeFailures) failedSlugs.add(result.slug); + console.log( + `finalized publications ${finalized.length - finalizeFailures.length}`, + ); + for (const result of finalizeFailures.slice(0, 20)) { + console.log(`finalize failed ${result.slug} ${result.reason}`); + } + if ( + failed.length > 0 || + purgeFailures.length > 0 || + finalizeFailures.length > 0 + ) { + process.exit(1); + } + await notifyStickerRevalidation( + validTasks + .filter((task) => !failedSlugs.has(task.slug)) + .map((task) => task.slug), + ); + } else { + if (failed.length > 0) process.exit(1); + await purgeCdnUrls( + pendingTasks.flatMap((task) => + task.refs + .filter((ref) => ref.kind === "sticker") + .map((ref) => + petStickerUrl( + task.slug, + ref.state, + ref.format, + ref.treatment, + ref.profile, + ), + ), + ), + ); + } } async function publishTask(task: StickerTask): Promise { try { const source = await getR2ObjectBuffer(task.spritesheetKey); const sourceSha256 = createHash("sha256").update(source).digest("hex"); + if (sourceSha256 !== task.sourceSha256) { + throw new Error("source hash changed since approval"); + } const hash = createHash("sha256"); let bytes = 0; let artifacts = 0; @@ -208,6 +297,7 @@ async function publishTask(task: StickerTask): Promise { Metadata: { "petdex-slug": task.slug, "petdex-source-sha256": sourceSha256, + "petdex-artifact-version": STICKER_ARTIFACT_VERSION, "petdex-sha256": artifact.sha256, }, }), @@ -232,6 +322,124 @@ async function publishTask(task: StickerTask): Promise { } } +async function purgeReactionTask(task: StickerTask): Promise { + try { + const urls = task.refs.map((ref) => + ref.kind === "sticker" + ? petStickerUrl( + task.slug, + ref.state, + ref.format, + ref.treatment, + ref.profile, + ) + : petStickerTrayUrl(task.slug), + ); + await purgeCdnUrls(urls, true); + return { + ok: true, + slug: task.slug, + artifacts: urls.length, + bytes: 0, + sha256: "", + }; + } catch (error) { + return { ok: false, slug: task.slug, reason: errorReason(error) }; + } +} + +async function finalizeReactionPublication( + task: StickerTask, +): Promise { + try { + const refs = refsForArtifacts(task.slug, ["reactions"]); + const objects = await mapLimit(refs, headConcurrency, async (ref) => { + const head = await r2.send( + new HeadObjectCommand({ Bucket: R2_BUCKET, Key: ref.key }), + ); + if (head.Metadata?.["petdex-source-sha256"] !== task.sourceSha256) { + throw new Error(`stale object ${ref.key}`); + } + if ( + head.Metadata?.["petdex-artifact-version"] !== STICKER_ARTIFACT_VERSION + ) { + throw new Error(`outdated object ${ref.key}`); + } + const sha256 = head.Metadata?.["petdex-sha256"]; + if (!sha256) throw new Error(`missing artifact hash ${ref.key}`); + return { + key: ref.key, + bytes: head.ContentLength ?? 0, + sha256, + }; + }); + const manifestHash = createHash("sha256"); + for (const object of objects.sort((a, b) => a.key.localeCompare(b.key))) { + manifestHash.update(object.key); + manifestHash.update("\0"); + manifestHash.update(object.sha256); + manifestHash.update("\0"); + manifestHash.update(String(object.bytes)); + manifestHash.update("\0"); + } + const manifestSha256 = manifestHash.digest("hex"); + const totalBytes = objects.reduce( + (total, object) => total + object.bytes, + 0, + ); + const now = new Date(); + await db + .insert(schema.petStickerPublications) + .values({ + petId: task.id, + sourceSha256: task.sourceSha256, + artifactVersion: STICKER_ARTIFACT_VERSION, + states: [...PET_STICKER_STATES], + formats: [...STICKER_PUBLIC_FORMATS], + profiles: [...STICKER_PUBLIC_PROFILES], + treatments: [...STICKER_PUBLIC_TREATMENTS], + objectCount: objects.length, + totalBytes, + manifestSha256, + status: "complete", + cleanupStatus: "not_required", + cleanupError: null, + publishedAt: now, + revokedAt: null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: schema.petStickerPublications.petId, + set: { + sourceSha256: task.sourceSha256, + artifactVersion: STICKER_ARTIFACT_VERSION, + states: [...PET_STICKER_STATES], + formats: [...STICKER_PUBLIC_FORMATS], + profiles: [...STICKER_PUBLIC_PROFILES], + treatments: [...STICKER_PUBLIC_TREATMENTS], + objectCount: objects.length, + totalBytes, + manifestSha256, + status: "complete", + cleanupStatus: "not_required", + cleanupError: null, + publishedAt: now, + revokedAt: null, + updatedAt: now, + }, + }); + return { + ok: true, + slug: task.slug, + artifacts: objects.length, + bytes: totalBytes, + sha256: manifestSha256, + }; + } catch (error) { + return { ok: false, slug: task.slug, reason: errorReason(error) }; + } +} + async function buildArtifact( task: StickerTask, ref: ArtifactRef, @@ -242,119 +450,108 @@ async function buildArtifact( filename: string; sha256: string; }> { - if (ref.kind === "pack") { - const body = await buildPack(task, source); + if (ref.kind === "whatsapp-tray") { + const body = await renderWhatsAppTray(source); + await assertWhatsAppTray(body); return { body, - contentType: "application/zip", - filename: petStickerPackFilename(task.slug), + contentType: "image/png", + filename: petStickerTrayFilename(task.slug), sha256: createHash("sha256").update(body).digest("hex"), }; } - const sticker = await renderStickerWithFallback(source, ref); + const sticker = await renderStickerArtifact(source, ref); return { body: sticker.buffer, contentType: sticker.contentType, - filename: petStickerFilename(task.slug, ref.state, ref.format), + filename: petStickerFilename( + task.slug, + ref.state, + ref.format, + ref.treatment, + ref.profile, + ), sha256: createHash("sha256").update(sticker.buffer).digest("hex"), }; } -async function renderStickerWithFallback( +async function renderStickerArtifact( source: Buffer, ref: Extract, ) { - try { - return await renderSticker(source, { - state: ref.state, - format: ref.format, - }); - } catch (error) { - if (ref.format !== "webp" || !isExtractAreaError(error)) throw error; - const buffer = await sharp(source) - .extract({ left: 0, top: 0, width: 192, height: 208 }) - .resize(STICKER_SIZES.default, STICKER_SIZES.default, { - fit: "contain", - kernel: "nearest", - background: { r: 0, g: 0, b: 0, alpha: 0 }, - }) - .webp({ quality: 80, effort: 4 }) - .toBuffer(); - return { - buffer, - contentType: "image/webp" as const, - isAnimated: false, - frameCount: 1, - }; + const output = await renderSticker(source, { + state: ref.state, + format: ref.format, + treatment: ref.treatment, + size: + ref.profile === "whatsapp" + ? STICKER_SIZES.whatsapp + : STICKER_SIZES.default, + }); + if (ref.profile === "whatsapp") { + await assertWhatsAppSticker(output.buffer); } + return output; } -async function buildPack(task: StickerTask, source: Buffer): Promise { - const trayBuf = await buildTrayIcon(source); - const stickerBufs = await Promise.all( - petStates.map(async (state) => { - const out = await renderSticker(source, { - state: state.id, - size: STICKER_SIZES.whatsappPack, - }); - return { id: state.id, buf: out.buffer }; - }), - ); - const publisherWebsite = - process.env.PETDEX_URL?.trim() || "https://petdex.dev"; - const stateEmoji: Record = { - idle: ["🙂"], - "running-right": ["🏃"], - "running-left": ["🏃"], - waving: ["👋"], - jumping: ["⬆️"], - failed: ["😅"], - waiting: ["⏳"], - running: ["🏃"], - review: ["🤔"], +async function getPublishablePets(collectionSlug: string | null) { + const columns = { + id: schema.submittedPets.id, + slug: schema.submittedPets.slug, + spritesheetUrl: schema.submittedPets.spritesheetUrl, + spriteSha256: schema.submittedPets.spriteSha256, }; - const manifest = { - identifier: `petdex.${task.slug}`, - name: `${task.displayName} - Petdex`, - publisher: "Petdex", - tray_image_file: "tray.png", - publisher_email: "hello@crafter.run", - publisher_website: publisherWebsite, - privacy_policy_website: `${publisherWebsite}/legal/privacy`, - license_agreement_website: `${publisherWebsite}/legal/terms`, - image_data_version: "1", - avoid_cache: false, - animated_sticker_pack: true, - stickers: stickerBufs.map((sticker) => ({ - image_file: `${sticker.id}.webp`, - emojis: stateEmoji[sticker.id] ?? ["🙂"], - })), - }; - const zip = new JSZip(); - zip.file("manifest.json", JSON.stringify(manifest, null, 2)); - zip.file("contents.json", JSON.stringify(manifest, null, 2)); - zip.file("tray.png", trayBuf); - for (const sticker of stickerBufs) { - zip.file(`${sticker.id}.webp`, sticker.buf); + const approvalWhere = and( + eq(schema.submittedPets.status, "approved"), + isNotNull(schema.submittedPets.spriteSha256), + eq(schema.petExportApprovals.scope, STICKER_EXPORT_SCOPE), + eq(schema.petExportApprovals.status, "allowed"), + eq(schema.petExportApprovals.policyVersion, STICKER_EXPORT_POLICY_VERSION), + eq( + schema.petExportApprovals.sourceSha256, + schema.submittedPets.spriteSha256, + ), + ); + + if (collectionSlug) { + const rows = await db + .select(columns) + .from(schema.submittedPets) + .innerJoin( + schema.petExportApprovals, + eq(schema.petExportApprovals.petId, schema.submittedPets.id), + ) + .innerJoin( + schema.petCollectionItems, + eq(schema.petCollectionItems.petSlug, schema.submittedPets.slug), + ) + .innerJoin( + schema.petCollections, + eq(schema.petCollections.id, schema.petCollectionItems.collectionId), + ) + .where( + and( + approvalWhere, + eq(schema.petCollections.slug, collectionSlug), + isNull(schema.petCollections.ownerId), + ), + ); + return rows.map((row) => ({ + ...row, + spriteSha256: row.spriteSha256 ?? "", + })); } - return await zip.generateAsync({ - type: "nodebuffer", - compression: "DEFLATE", - compressionOptions: { level: 6 }, - }); -} -async function buildTrayIcon(sheet: Buffer): Promise { - return await sharp(sheet) - .extract({ left: 0, top: 0, width: 192, height: 208 }) - .resize(96, 96, { - fit: "contain", - kernel: "nearest", - background: { r: 0, g: 0, b: 0, alpha: 0 }, - }) - .png({ compressionLevel: 9 }) - .toBuffer(); + const rows = await db + .select(columns) + .from(schema.submittedPets) + .innerJoin( + schema.petExportApprovals, + eq(schema.petExportApprovals.petId, schema.submittedPets.id), + ) + .where(approvalWhere); + return rows.map((row) => ({ ...row, spriteSha256: row.spriteSha256 ?? "" })); } async function getR2ObjectBuffer(key: string): Promise { @@ -365,10 +562,18 @@ async function getR2ObjectBuffer(key: string): Promise { return Buffer.from(await response.Body.transformToByteArray()); } -async function r2ObjectExists(key: string): Promise { +async function r2ObjectIsCurrent( + key: string, + sourceSha256: string, +): Promise { try { - await r2.send(new HeadObjectCommand({ Bucket: R2_BUCKET, Key: key })); - return true; + const head = await r2.send( + new HeadObjectCommand({ Bucket: R2_BUCKET, Key: key }), + ); + return ( + head.Metadata?.["petdex-source-sha256"] === sourceSha256 && + head.Metadata?.["petdex-artifact-version"] === STICKER_ARTIFACT_VERSION + ); } catch (error) { if (isMissingObjectError(error)) return false; throw error; @@ -378,18 +583,49 @@ async function r2ObjectExists(key: string): Promise { function refsForArtifacts(slug: string, artifacts: Artifact[]): ArtifactRef[] { const refs = new Map(); if (artifacts.includes("idle-webp")) { - const key = petStickerKey(slug, "idle", "webp"); - refs.set(key, { kind: "sticker", key, state: "idle", format: "webp" }); + const key = petStickerKey(slug, "idle", "webp", "clean"); + refs.set(key, { + kind: "sticker", + key, + state: "idle", + format: "webp", + profile: "web", + treatment: "clean", + }); } if (artifacts.includes("all-webp")) { for (const state of PET_STICKER_STATES) { - const key = petStickerKey(slug, state, "webp"); - refs.set(key, { kind: "sticker", key, state, format: "webp" }); + const key = petStickerKey(slug, state, "webp", "clean"); + refs.set(key, { + kind: "sticker", + key, + state, + format: "webp", + profile: "web", + treatment: "clean", + }); } } - if (artifacts.includes("pack")) { - const key = petStickerPackKey(slug); - refs.set(key, { kind: "pack", key }); + if (artifacts.includes("reactions")) { + for (const profile of STICKER_PUBLIC_PROFILES) { + for (const state of PET_STICKER_STATES) { + for (const format of stickerFormatsForProfile(profile)) { + for (const treatment of STICKER_PUBLIC_TREATMENTS) { + const key = petStickerKey(slug, state, format, treatment, profile); + refs.set(key, { + kind: "sticker", + key, + state, + format, + profile, + treatment, + }); + } + } + } + } + const key = petStickerTrayKey(slug); + refs.set(key, { kind: "whatsapp-tray", key }); } return [...refs.values()]; } @@ -440,7 +676,7 @@ function parseArtifacts(args: string[]): Artifact[] { .split(",") .map((value) => value.trim()) .filter(Boolean); - const valid = new Set(["idle-webp", "all-webp", "pack"]); + const valid = new Set(["idle-webp", "all-webp", "reactions"]); if ( values.length > 0 && values.every((value) => valid.has(value as Artifact)) @@ -456,6 +692,75 @@ function parseConcurrency(key: string, fallback: number): number { return Number.isFinite(value) && value > 0 ? value : fallback; } +function parseStringArg(args: string[], key: string): string | null { + const prefix = `--${key}=`; + const value = args + .find((arg) => arg.startsWith(prefix)) + ?.slice(prefix.length); + return value?.trim().toLowerCase() || null; +} + +async function purgeCdnUrls(urls: string[], required = false): Promise { + if (urls.length === 0) return; + const zoneId = process.env.CLOUDFLARE_ZONE_ID; + const token = process.env.CLOUDFLARE_PURGE_TOKEN; + if (!zoneId || !token) { + if (required) throw new Error("cloudflare purge credentials are required"); + console.log(`cloudflare purge skipped (no creds) for ${urls.length} urls`); + return; + } + let purged = 0; + for (let index = 0; index < urls.length; index += 30) { + const batch = urls.slice(index, index + 30); + const response = await fetch( + `https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ files: batch }), + signal: AbortSignal.timeout(10_000), + }, + ); + const body = (await response.json().catch(() => null)) as { + success?: unknown; + } | null; + if (!response.ok || body?.success !== true) { + throw new Error(`cloudflare purge failed ${response.status}`); + } + purged += batch.length; + } + console.log(`cloudflare purged ${purged}`); +} + +async function notifyStickerRevalidation(slugs: string[]): Promise { + if (slugs.length === 0) return; + const secret = process.env.PETDEX_REVALIDATE_SECRET; + if (!secret) throw new Error("PETDEX_REVALIDATE_SECRET is required"); + const base = process.env.PETDEX_URL?.trim() || "https://petdex.dev"; + for (let index = 0; index < slugs.length; index += 100) { + const batch = slugs.slice(index, index + 100); + const response = await fetch(`${base}/api/revalidate`, { + method: "POST", + headers: { + authorization: `Bearer ${secret}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + slugs: batch, + tags: batch.map((slug) => `sticker:${slug}`), + }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`petdex revalidation failed ${response.status}`); + } + } + console.log(`revalidated ${slugs.length} pets`); +} + function isMissingObjectError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const name = "name" in error ? (error as { name?: unknown }).name : null; @@ -471,7 +776,3 @@ function errorReason(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } - -function isExtractAreaError(error: unknown): boolean { - return error instanceof Error && error.message.includes("extract_area"); -} diff --git a/scripts/render-pet-sticker-review.ts b/scripts/render-pet-sticker-review.ts new file mode 100644 index 00000000..5b56cbf7 --- /dev/null +++ b/scripts/render-pet-sticker-review.ts @@ -0,0 +1,187 @@ +import { createHash } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { and, asc, eq } from "drizzle-orm"; +import sharp from "sharp"; + +import { db, schema } from "@/lib/db/client"; +import { + PET_STICKER_STATES, + PET_STICKER_TREATMENTS, + petStickerFilename, + petStickerTrayFilename, +} from "@/lib/pet-sticker-artifacts"; +import { + STICKER_PUBLIC_PROFILES, + stickerFormatsForProfile, +} from "@/lib/sticker-export-policy"; +import { + fetchSpritesheet, + renderSticker, + renderWhatsAppTray, + STICKER_SIZES, +} from "@/lib/sticker-renderer"; +import { + assertWhatsAppSticker, + assertWhatsAppTray, +} from "@/lib/whatsapp-sticker"; + +const slug = readArg("slug"); +const collection = readArg("collection"); +const outputDir = resolve(readArg("output-dir") ?? "public/sticker-review"); +if (!slug && !collection) { + throw new Error("pass --slug= or --collection="); +} + +const pets = slug + ? await db + .select({ + slug: schema.submittedPets.slug, + displayName: schema.submittedPets.displayName, + spritesheetUrl: schema.submittedPets.spritesheetUrl, + }) + .from(schema.submittedPets) + .where( + and( + eq(schema.submittedPets.slug, slug), + eq(schema.submittedPets.status, "approved"), + ), + ) + .limit(1) + : await db + .select({ + slug: schema.submittedPets.slug, + displayName: schema.submittedPets.displayName, + spritesheetUrl: schema.submittedPets.spritesheetUrl, + }) + .from(schema.petCollectionItems) + .innerJoin( + schema.petCollections, + eq(schema.petCollectionItems.collectionId, schema.petCollections.id), + ) + .innerJoin( + schema.submittedPets, + eq(schema.petCollectionItems.petSlug, schema.submittedPets.slug), + ) + .where( + and( + eq(schema.petCollections.slug, collection ?? ""), + eq(schema.submittedPets.status, "approved"), + ), + ) + .orderBy(asc(schema.petCollectionItems.position)); + +if (pets.length === 0) throw new Error("no approved pets found"); +await mkdir(outputDir, { recursive: true }); + +for (const pet of pets) { + const petDir = join(outputDir, pet.slug); + await mkdir(petDir, { recursive: true }); + const source = await fetchSpritesheet(pet.spritesheetUrl); + const files: Array<{ + state: string; + treatment: string; + format: string; + profile: string; + filename: string; + bytes: number; + sha256: string; + }> = []; + const contactTiles: Array<{ input: Buffer; left: number; top: number }> = []; + let tile = 0; + + for (const profile of STICKER_PUBLIC_PROFILES) { + for (const treatment of PET_STICKER_TREATMENTS) { + for (const state of PET_STICKER_STATES) { + for (const format of stickerFormatsForProfile(profile)) { + const output = await renderSticker(source, { + state, + format, + treatment, + size: + profile === "whatsapp" + ? STICKER_SIZES.whatsapp + : STICKER_SIZES.default, + }); + if (profile === "whatsapp") { + await assertWhatsAppSticker(output.buffer); + } + const filename = petStickerFilename( + pet.slug, + state, + format, + treatment, + profile, + ); + await writeFile(join(petDir, filename), output.buffer); + files.push({ + state, + treatment, + format, + profile, + filename, + bytes: output.buffer.byteLength, + sha256: createHash("sha256").update(output.buffer).digest("hex"), + }); + if (profile === "web" && format === "png") { + contactTiles.push({ + input: output.buffer, + left: (tile % 6) * 240, + top: Math.floor(tile / 6) * 240, + }); + tile += 1; + } + } + } + } + } + + const tray = await renderWhatsAppTray(source); + await assertWhatsAppTray(tray); + const trayFilename = petStickerTrayFilename(pet.slug); + await writeFile(join(petDir, trayFilename), tray); + + const contactSheet = await sharp({ + create: { + width: 1440, + height: 720, + channels: 4, + background: { r: 18, g: 18, b: 18, alpha: 1 }, + }, + }) + .composite(contactTiles) + .png() + .toBuffer(); + await writeFile(join(petDir, `${pet.slug}-contact-sheet.png`), contactSheet); + await writeFile( + join(petDir, "manifest.json"), + JSON.stringify( + { + slug: pet.slug, + displayName: pet.displayName, + sourceSha256: createHash("sha256").update(source).digest("hex"), + generatedAt: new Date().toISOString(), + files, + tray: { + filename: trayFilename, + bytes: tray.byteLength, + sha256: createHash("sha256").update(tray).digest("hex"), + }, + }, + null, + 2, + ), + ); + console.log(`rendered ${pet.slug} ${files.length} stickers`); +} + +console.log(`output ${outputDir}`); + +function readArg(name: string): string | null { + const prefix = `--${name}=`; + return ( + process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) ?? + null + ); +} diff --git a/src/app/[locale]/pets/[slug]/page.tsx b/src/app/[locale]/pets/[slug]/page.tsx index 44cad154..d3108773 100644 --- a/src/app/[locale]/pets/[slug]/page.tsx +++ b/src/app/[locale]/pets/[slug]/page.tsx @@ -9,6 +9,7 @@ import { formatDexNumber, getDexEntryMap } from "@/lib/dex"; import { buildLocaleAlternates } from "@/lib/locale-routing"; import { resolveStoredOwnerCreditForSlug } from "@/lib/owner-credit"; import { getPet, getStaticPetSlugs } from "@/lib/pets"; +import { getPetStickerAvailability } from "@/lib/sticker-export"; import { getVariantsFor } from "@/lib/variants"; import { ClaimCTA } from "@/components/auth/claim-cta"; @@ -164,10 +165,16 @@ export default async function PetPage({ params }: PageProps) { } : null; - const [ownerCreditResult, variants, memberOfCollections] = await Promise.all([ + const [ + ownerCreditResult, + variants, + memberOfCollections, + stickerAvailability, + ] = await Promise.all([ resolveStoredOwnerCreditForSlug(slug), getVariantsFor(slug), getCollectionsContainingPet(slug), + getPetStickerAvailability(slug), ]); const ownerCredit = ownerCreditResult?.credit ?? null; @@ -383,10 +390,14 @@ export default async function PetPage({ params }: PageProps) { }} variant="detail" /> - + {stickerAvailability.available && + stickerAvailability.collectionSlug ? ( + + ) : null}
diff --git a/src/app/[locale]/stickers/[collection]/page.tsx b/src/app/[locale]/stickers/[collection]/page.tsx new file mode 100644 index 00000000..8cecba34 --- /dev/null +++ b/src/app/[locale]/stickers/[collection]/page.tsx @@ -0,0 +1,169 @@ +import { notFound } from "next/navigation"; + +import { getTranslations, setRequestLocale } from "next-intl/server"; + +import { buildLocaleAlternates } from "@/lib/locale-routing"; +import { PET_STICKER_STATES } from "@/lib/pet-sticker-artifacts"; +import { + getStickerCollection, + type StickerCollection, +} from "@/lib/sticker-export"; +import { + STICKER_PUBLIC_FORMATS, + STICKER_PUBLIC_PROFILES, + STICKER_PUBLIC_TREATMENTS, +} from "@/lib/sticker-export-policy"; + +import { SiteFooter } from "@/components/site-footer"; +import { SiteHeader } from "@/components/site-header"; +import { StickerExplorer } from "@/components/stickers/sticker-explorer"; + +import { defaultLocale, hasLocale } from "@/i18n/config"; + +export const dynamic = "force-dynamic"; + +type PageProps = { + params: Promise<{ collection: string; locale: string }>; + searchParams: Promise>; +}; + +export async function generateMetadata({ params }: PageProps) { + const { collection, locale } = await params; + return { + title: `${collection === "claude" ? "Claude" : collection} reactions`, + description: "Pick a reaction, copy the sticker, or share a reaction deck.", + alternates: buildLocaleAlternates( + `/stickers/${collection}`, + hasLocale(locale) ? locale : undefined, + ), + }; +} + +export default async function StickerCollectionPage({ + params, + searchParams, +}: PageProps) { + const { collection: collectionSlug, locale } = await params; + const localeValue = hasLocale(locale) ? locale : defaultLocale; + setRequestLocale(localeValue); + const demo = process.env.STICKER_EXPLORER_DEMO === "1"; + const collection = demo + ? demoCollection(collectionSlug) + : await getStickerCollection(collectionSlug); + if (!collection || collection.pets.length === 0) notFound(); + const query = await searchParams; + const initialQuery = new URLSearchParams( + Object.entries(query).flatMap(([key, value]) => + Array.isArray(value) + ? value.map((entry) => [key, entry] as [string, string]) + : value + ? [[key, value] as [string, string]] + : [], + ), + ).toString(); + const t = await getTranslations({ + locale: localeValue, + namespace: "stickers", + }); + const labels = { + reaction: t("reaction"), + pet: t("pet"), + treatment: t("treatment"), + clean: t("clean"), + outline: t("outline"), + copy: t("copy"), + copied: t("copied"), + download: t("download"), + downloadWhatsApp: t("downloadWhatsApp"), + whatsappNote: t("whatsappNote"), + nextPet: t("nextPet"), + addToDeck: t("addToDeck"), + deck: t("deck"), + emptyDeck: t("emptyDeck"), + shareReaction: t("shareReaction"), + reactionShared: t("reactionShared"), + shareDeck: t("shareDeck"), + shared: t("shared"), + deckFull: t("deckFull"), + reactions: Object.fromEntries( + PET_STICKER_STATES.map((state) => [state, t(`reactions.${state}`)]), + ), + }; + + return ( +
+ +
+

+ {t("eyebrow")} +

+
+
+

+ {t("title", { collection: collection.title })} +

+

+ {t("description")} +

+
+

+ {t("count", { count: collection.pets.length })} +

+
+ +
+ +
+ ); +} + +function demoCollection(slug: string): StickerCollection | null { + if (slug !== "claude") return null; + return { + slug: "claude", + title: "Claude", + description: "Claude Code pets turned into reactions.", + pets: [ + { + id: "demo-claude-crab", + slug: "claude-crab", + displayName: "Claude Crab", + description: + "A tiny orange blocky Claude Code mascot pet with black square eyes and four little legs.", + dominantColor: "#fc7434", + states: [...PET_STICKER_STATES], + formats: [...STICKER_PUBLIC_FORMATS], + profiles: [...STICKER_PUBLIC_PROFILES], + treatments: [...STICKER_PUBLIC_TREATMENTS], + }, + { + id: "demo-claude-spectacles-3", + slug: "claude-spectacles-3", + displayName: "Claude Spectacles", + description: + "A warm black-and-white pixel cat with Claude-specific reaction states.", + dominantColor: "#d79444", + states: [...PET_STICKER_STATES], + formats: [...STICKER_PUBLIC_FORMATS], + profiles: [...STICKER_PUBLIC_PROFILES], + treatments: [...STICKER_PUBLIC_TREATMENTS], + }, + { + id: "demo-clawd-music", + slug: "clawd-music", + displayName: "Clawd", + description: "A tiny Claude Code mascot with headphones.", + dominantColor: "#dc7454", + states: [...PET_STICKER_STATES], + formats: [...STICKER_PUBLIC_FORMATS], + profiles: [...STICKER_PUBLIC_PROFILES], + treatments: [...STICKER_PUBLIC_TREATMENTS], + }, + ], + }; +} diff --git a/src/app/api/pets/[slug]/sticker/route.test.ts b/src/app/api/pets/[slug]/sticker/route.test.ts new file mode 100644 index 00000000..f2f04836 --- /dev/null +++ b/src/app/api/pets/[slug]/sticker/route.test.ts @@ -0,0 +1,94 @@ +import * as BunTest from "bun:test"; + +import { R2_PUBLIC_BASE } from "@/lib/r2-public-url"; +import type { StickerArtifactAccess } from "@/lib/sticker-export"; + +const { beforeEach, describe, expect, it } = BunTest; +const testMock = ( + BunTest as typeof BunTest & { + mock: { module: (specifier: string, factory: () => object) => void }; + } +).mock; + +let accessStatus: StickerArtifactAccess = { + status: "ok", + petId: "pet-1", + slug: "claude-crab", +}; +const calls: unknown[][] = []; + +testMock.module("@/lib/sticker-export", () => ({ + getStickerArtifactAccess: async (...args: unknown[]) => { + calls.push(args); + return accessStatus; + }, +})); + +async function request(slug: string, query = ""): Promise { + const { GET } = await import("./route"); + return GET( + new Request(`https://petdex.local/api/pets/${slug}/sticker${query}`), + { params: Promise.resolve({ slug }) }, + ); +} + +describe("GET /api/pets/[slug]/sticker", () => { + beforeEach(() => { + calls.length = 0; + accessStatus = { + status: "ok", + petId: "pet-1", + slug: "claude-crab", + }; + }); + + it("rejects invalid input before checking publication access", async () => { + expect(await request("INVALID")).toHaveProperty("status", 400); + expect(await request("claude-crab", "?state=unknown")).toHaveProperty( + "status", + 400, + ); + expect( + await request("claude-crab", "?profile=whatsapp&format=png"), + ).toHaveProperty("status", 400); + expect(calls).toHaveLength(0); + }); + + it("retires GIF access without checking publication access", async () => { + const response = await request("claude-crab", "?format=gif"); + + expect(response.status).toBe(410); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(calls).toHaveLength(0); + }); + + it.each([ + ["disabled", 503], + ["ineligible", 403], + ["not_found", 404], + ["missing", 404], + ] as const)("maps %s access to %i", async (status, expectedStatus) => { + accessStatus = { status }; + + const response = await request("claude-crab"); + + expect(response.status).toBe(expectedStatus); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("redirects a compliant WhatsApp artifact", async () => { + const response = await request( + "claude-crab", + "?state=waiting&treatment=outline&profile=whatsapp&format=webp", + ); + + expect(response.status).toBe(307); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("location")).toBe( + `${R2_PUBLIC_BASE}/pets/claude-crab/stickers/whatsapp/waiting-outline.webp`, + ); + expect(calls).toEqual([ + ["claude-crab", "waiting", "webp", "outline", "whatsapp"], + ]); + }); +}); diff --git a/src/app/api/pets/[slug]/sticker/route.ts b/src/app/api/pets/[slug]/sticker/route.ts index e0604c08..3207ffbf 100644 --- a/src/app/api/pets/[slug]/sticker/route.ts +++ b/src/app/api/pets/[slug]/sticker/route.ts @@ -2,37 +2,72 @@ import { NextResponse } from "next/server"; import { isValidPetSlug, - PET_STICKER_REDIRECT_CACHE_HEADER, - petStickerFilename, + parseExactPetStickerFormat, + parseExactPetStickerProfile, + parseExactPetStickerState, + parseExactPetStickerTreatment, petStickerUrl, } from "@/lib/pet-sticker-artifacts"; -import { getPet } from "@/lib/pets"; +import { getStickerArtifactAccess } from "@/lib/sticker-export"; export const runtime = "nodejs"; export async function GET( - _req: Request, + req: Request, ctx: { params: Promise<{ slug: string }> }, ): Promise { const { slug } = await ctx.params; if (!isValidPetSlug(slug)) { return new NextResponse("invalid_slug", { status: 400 }); } - const pet = await getPet(slug); - if (!pet) { - return new NextResponse("not_found", { - status: 404, + const url = new URL(req.url); + const rawState = url.searchParams.get("state"); + const rawFormat = url.searchParams.get("format"); + const rawProfile = url.searchParams.get("profile"); + const rawTreatment = url.searchParams.get("treatment"); + const state = rawState ? parseExactPetStickerState(rawState) : "idle"; + const format = rawFormat ? parseExactPetStickerFormat(rawFormat) : "webp"; + const profile = rawProfile ? parseExactPetStickerProfile(rawProfile) : "web"; + const treatment = rawTreatment + ? parseExactPetStickerTreatment(rawTreatment) + : "clean"; + if (!state || !format || !treatment || !profile) { + return new NextResponse("invalid_sticker_variant", { status: 400 }); + } + if (format === "gif") { + return new NextResponse("gif_retired", { + status: 410, + headers: { "cache-control": "no-store" }, + }); + } + if (profile === "whatsapp" && format !== "webp") { + return new NextResponse("invalid_sticker_variant", { status: 400 }); + } + + const access = await getStickerArtifactAccess( + slug, + state, + format, + treatment, + profile, + ); + if (access.status !== "ok") { + const status = + access.status === "disabled" + ? 503 + : access.status === "ineligible" + ? 403 + : 404; + return new NextResponse(access.status, { + status, headers: { "cache-control": "no-store" }, }); } - const response = NextResponse.redirect(petStickerUrl(pet.slug), { - status: 308, - }); - response.headers.set("cache-control", PET_STICKER_REDIRECT_CACHE_HEADER); - response.headers.set( - "content-disposition", - `attachment; filename="${petStickerFilename(pet.slug)}"`, + const response = NextResponse.redirect( + petStickerUrl(access.slug, state, format, treatment, profile), + { status: 307 }, ); + response.headers.set("cache-control", "no-store"); return response; } diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts index 02664784..35315329 100644 --- a/src/app/api/revalidate/route.ts +++ b/src/app/api/revalidate/route.ts @@ -7,8 +7,10 @@ import { revalidatePetTags, } from "@/lib/db/cached-aggregates"; import { petPreviewUrl } from "@/lib/pet-preview"; -import { petStickerUrl } from "@/lib/pet-sticker-artifacts"; +import { petPublicArtifactKeys } from "@/lib/pet-public-artifact-keys"; +import { legacyPetStickerRedirectUrls } from "@/lib/pet-sticker-artifacts"; import { petThumbnailUrl } from "@/lib/pet-thumbnail"; +import { R2_PUBLIC_BASE } from "@/lib/r2-public-url"; import { locales } from "@/i18n/config"; @@ -107,7 +109,8 @@ async function purgeCloudflarePetUrls(slugs: string[]): Promise { // upload forever unless we purge it here (issue #553). petPreviewUrl(slug), petThumbnailUrl(slug), - petStickerUrl(slug), + ...petPublicArtifactKeys(slug).map((key) => `${R2_PUBLIC_BASE}/${key}`), + ...legacyPetStickerRedirectUrls(slug), ]); try { diff --git a/src/components/pets/save-as-sticker.tsx b/src/components/pets/save-as-sticker.tsx index 29d5f0c7..ddc70b21 100644 --- a/src/components/pets/save-as-sticker.tsx +++ b/src/components/pets/save-as-sticker.tsx @@ -1,268 +1,38 @@ "use client"; -import { useState } from "react"; +import Link from "next/link"; -import { - Check, - Copy, - Download, - Film, - Info, - Package, - Play, - Sticker, -} from "lucide-react"; +import { Sticker } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; -import { WeChatIcon, WhatsAppIcon } from "@/components/icons/wechat-icon"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { withLocale } from "@/lib/locale-routing"; + +import { hasLocale } from "@/i18n/config"; type Props = { slug: string; displayName: string; + collectionSlug: string; }; -type Status = "idle" | "working" | "done" | "error"; - -export function SaveAsSticker({ slug, displayName }: Props) { - const locale = useLocale(); - const t = useTranslations("sticker"); - const [open, setOpen] = useState(false); - const [status, setStatus] = useState("idle"); - - const isZh = locale === "zh"; - const stickerWebp = `/api/pets/${slug}/sticker`; - const stickerGif = `/api/pets/${slug}/sticker?format=gif`; - const stickerPng = `/api/pets/${slug}/sticker?format=png`; - const wastickersUrl = `/api/pets/${slug}/wastickers`; - - function flashDone() { - setStatus("done"); - setTimeout(() => setStatus("idle"), 2000); - setOpen(false); - } - - function flashError() { - setStatus("error"); - setTimeout(() => setStatus("idle"), 2500); - } - - function downloadFile(url: string, filename: string) { - setStatus("working"); - try { - const a = document.createElement("a"); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - flashDone(); - } catch { - flashError(); - } - } - - function downloadAnimated() { - downloadFile(`${stickerWebp}?download=1`, `${slug}-sticker.webp`); - } - - function downloadGif() { - downloadFile(`${stickerGif}&download=1`, `${slug}-sticker.gif`); - } - - function downloadStaticPng() { - downloadFile(`${stickerPng}&download=1`, `${slug}-sticker.png`); - } - - function downloadPack() { - downloadFile(wastickersUrl, `${slug}-petdex-stickers.zip`); - } - - async function copyToClipboard() { - setStatus("working"); - try { - const res = await fetch(stickerPng); - const blob = await res.blob(); - const item = new ClipboardItem({ [blob.type]: blob }); - await navigator.clipboard.write([item]); - flashDone(); - } catch { - try { - await navigator.clipboard.writeText( - `${window.location.origin}${stickerWebp}`, - ); - flashDone(); - } catch { - flashError(); - } - } - } - - function previewSticker() { - window.open(stickerWebp, "_blank", "noopener,noreferrer"); - setOpen(false); - } - - const ctaClasses = isZh - ? "bg-[#07C160] text-white hover:bg-[#06ae56] dark:bg-[#0a7d4d] dark:hover:bg-[#0c8c57]" - : "border border-border-base bg-surface/70 text-muted-2 backdrop-blur hover:bg-surface-muted hover:text-foreground"; +export function SaveAsSticker({ slug, displayName, collectionSlug }: Props) { + const rawLocale = useLocale(); + const locale = hasLocale(rawLocale) ? rawLocale : "en"; + const t = useTranslations("stickers"); + const params = new URLSearchParams({ + reaction: "waiting", + pet: slug, + treatment: "outline", + }); return ( -
- - - } - > - {isZh ? ( - - ) : ( - - )} - {t("ctaShort")} - - - -
- {isZh ? t("hintWeChat") : t("hintGeneric")} -
- - - {status === "working" ? ( - - ) : status === "done" ? ( - - ) : ( - - )} -
-
- {t("downloadAnimated")} - - {t("recommendedTag")} - -
-
- {t("downloadAnimatedDesc")} -
-
-
- - - - - {status === "working" ? ( - - ) : ( - - )} -
-
{t("downloadGif")}
-
{t("downloadGifDesc")}
-
-
- - - - - {status === "working" ? ( - - ) : status === "done" ? ( - - ) : ( - - )} -
-
{t("downloadPack")}
-
- {t("downloadPackDesc")} -
-
-
- - - - - -
-
{t("downloadPng")}
-
{t("downloadPngDesc")}
-
-
- - void copyToClipboard()} - className="gap-3" - > - -
-
{t("copyImage")}
-
{t("copyImageDesc")}
-
-
- - - -
-
{t("preview")}
-
{t("previewDesc")}
-
-
- -
- {isZh && ( -
- - {t("howToWeChat")} -
- )} -
- - {t("howToWhatsApp")} -
-
- - {t("desktopNote")} -
-
-
-
- - {status === "error" && ( -
- {t("errorGeneric")} -
- )} - - {displayName} -
+ + + {t("openExplorer")} + ); } diff --git a/src/components/site-header/index.tsx b/src/components/site-header/index.tsx index 28a2c063..9bfeb0f3 100644 --- a/src/components/site-header/index.tsx +++ b/src/components/site-header/index.tsx @@ -1,6 +1,7 @@ import { getLocale, getTranslations } from "next-intl/server"; import { withLocale } from "@/lib/locale-routing"; +import { isStickerExplorerEnabled } from "@/lib/sticker-export-policy"; import { AuthBadge } from "@/components/auth/auth-badge"; import { LocaleSwitcher } from "@/components/brand/locale-switcher"; @@ -21,18 +22,23 @@ export async function SiteHeader({ hideSubmitCta = false }: SiteHeaderProps) { const t = await getTranslations("header"); const common = await getTranslations("common"); const href = (pathname: string) => withLocale(pathname, currentLocale); - const nav = buildHeaderNav(href, { - collections: t("collections"), - creators: t("creators"), - requests: t("requests"), - download: t("download"), - docs: t("docs"), - create: t("create"), - builtWith: t("builtWith"), - community: t("community"), - github: common("github"), - githubRepoAria: t("githubRepoAria"), - }); + const nav = buildHeaderNav( + href, + { + collections: t("collections"), + reactions: t("reactions"), + creators: t("creators"), + requests: t("requests"), + download: t("download"), + docs: t("docs"), + create: t("create"), + builtWith: t("builtWith"), + community: t("community"), + github: common("github"), + githubRepoAria: t("githubRepoAria"), + }, + isStickerExplorerEnabled() || process.env.STICKER_EXPLORER_DEMO === "1", + ); return (
diff --git a/src/components/site-header/nav-items.ts b/src/components/site-header/nav-items.ts index 26709aa5..a03abdc5 100644 --- a/src/components/site-header/nav-items.ts +++ b/src/components/site-header/nav-items.ts @@ -4,6 +4,7 @@ const GITHUB_REPO_URL = "https://github.com/crafter-station/petdex"; type HeaderNavLabels = { collections: string; + reactions: string; creators: string; requests: string; download: string; @@ -18,9 +19,13 @@ type HeaderNavLabels = { export function buildHeaderNav( href: (pathname: string) => string, labels: HeaderNavLabels, + reactionsEnabled = false, ) { const primary: HeaderNavItem[] = [ { href: href("/collections"), label: labels.collections }, + ...(reactionsEnabled + ? [{ href: href("/stickers/claude"), label: labels.reactions }] + : []), { href: href("/leaderboard"), label: labels.creators }, { href: href("/requests"), label: labels.requests }, { href: href("/download"), label: labels.download }, diff --git a/src/components/stickers/sticker-explorer.tsx b/src/components/stickers/sticker-explorer.tsx new file mode 100644 index 00000000..8c5f69ca --- /dev/null +++ b/src/components/stickers/sticker-explorer.tsx @@ -0,0 +1,434 @@ +"use client"; + +import Image from "next/image"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { + Check, + ChevronRight, + Copy, + Download, + Plus, + Share2, + X, +} from "lucide-react"; + +import { + type PetStickerFormat, + type PetStickerProfile, + petStickerFilename, + petStickerUrl, +} from "@/lib/pet-sticker-artifacts"; +import { + parseStickerDeck, + parseStickerExplorerSelection, + STICKER_DECK_LIMIT, + STICKER_REACTION_STATES, + type StickerDeckItem, + upsertStickerExplorerParams, +} from "@/lib/sticker-explorer-url"; +import type { StickerCollection } from "@/lib/sticker-export"; + +type Labels = { + reaction: string; + pet: string; + treatment: string; + clean: string; + outline: string; + copy: string; + copied: string; + download: string; + downloadWhatsApp: string; + whatsappNote: string; + nextPet: string; + addToDeck: string; + deck: string; + emptyDeck: string; + shareReaction: string; + reactionShared: string; + shareDeck: string; + shared: string; + deckFull: string; + reactions: Record; +}; + +export function StickerExplorer({ + collection, + initialQuery, + labels, + demo = false, +}: { + collection: StickerCollection; + initialQuery: string; + labels: Labels; + demo?: boolean; +}) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const petSlugs = useMemo( + () => collection.pets.map((pet) => pet.slug), + [collection.pets], + ); + const initialParams = useMemo( + () => new URLSearchParams(initialQuery), + [initialQuery], + ); + const [selection, setSelection] = useState(() => + parseStickerExplorerSelection(initialParams, petSlugs), + ); + const [deck, setDeck] = useState(() => + parseStickerDeck(initialParams.get("deck"), petSlugs), + ); + const [notice, setNotice] = useState(null); + + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()); + setSelection(parseStickerExplorerSelection(params, petSlugs)); + setDeck(parseStickerDeck(params.get("deck"), petSlugs)); + }, [searchParams, petSlugs]); + + const selectedPet = + collection.pets.find((pet) => pet.slug === selection.pet) ?? + collection.pets[0]; + const previewUrl = stickerAssetUrl(selection, "webp", demo); + + function sync(nextSelection: StickerDeckItem, nextDeck = deck) { + setSelection(nextSelection); + setDeck(nextDeck); + const params = upsertStickerExplorerParams( + new URLSearchParams(searchParams.toString()), + nextSelection, + nextDeck, + ); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); + } + + async function copySticker() { + try { + const response = await fetch(stickerAssetUrl(selection, "png", demo)); + if (!response.ok) throw new Error("copy failed"); + const blob = await response.blob(); + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": blob }), + ]); + showNotice(labels.copied); + } catch { + await navigator.clipboard.writeText( + new URL(previewUrl, window.location.origin).toString(), + ); + showNotice(labels.copied); + } + } + + function addToDeck() { + const key = JSON.stringify(selection); + if (deck.some((item) => JSON.stringify(item) === key)) return; + if (deck.length >= STICKER_DECK_LIMIT) { + showNotice(labels.deckFull); + return; + } + sync(selection, [...deck, selection]); + } + + async function shareReaction() { + const params = upsertStickerExplorerParams( + new URLSearchParams(), + selection, + [], + ); + await navigator.clipboard.writeText( + `${window.location.origin}${pathname}?${params.toString()}`, + ); + showNotice(labels.reactionShared); + } + + async function shareDeck() { + const params = upsertStickerExplorerParams( + new URLSearchParams(searchParams.toString()), + selection, + deck, + ); + await navigator.clipboard.writeText( + `${window.location.origin}${pathname}?${params.toString()}`, + ); + showNotice(labels.shared); + } + + function showNotice(value: string) { + setNotice(value); + window.setTimeout(() => setNotice(null), 1800); + } + + function nextPet() { + const index = petSlugs.indexOf(selection.pet); + sync({ + ...selection, + pet: petSlugs[(index + 1) % petSlugs.length] ?? selection.pet, + }); + } + + return ( +
+
+
+
+ {selectedPet.displayName} · {labels.reactions[selection.state]} +
+ {`${selectedPet.displayName} + {notice ? ( +
+ + {notice} +
+ ) : null} +
+
+ + + + {labels.download} + + + + {labels.downloadWhatsApp} + + + + +

+ {labels.whatsappNote} +

+
+
+ + +
+ ); +} + +function Control({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function Choice({ + selected, + onClick, + children, +}: { + selected: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function stickerAssetUrl( + item: StickerDeckItem, + format: PetStickerFormat, + demo: boolean, + profile: PetStickerProfile = "web", +): string { + if (demo) { + return `/sticker-review/${item.pet}/${petStickerFilename( + item.pet, + item.state, + format, + item.treatment, + profile, + )}`; + } + return petStickerUrl(item.pet, item.state, format, item.treatment, profile); +} diff --git a/src/i18n/client-messages.ts b/src/i18n/client-messages.ts index a71510d6..b997fdbf 100644 --- a/src/i18n/client-messages.ts +++ b/src/i18n/client-messages.ts @@ -32,6 +32,7 @@ export const CLIENT_MESSAGE_PATHS = [ "profileShare", "requests.view", "sticker", + "stickers", "submit.form", "submit.form.copy", "submit.form.preview", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d258b9fc..771bc317 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -39,6 +39,7 @@ "create": "Create", "docs": "Docs", "collections": "Collections", + "reactions": "Reactions", "topCreators": "Creators", "creators": "Creators", "requests": "Requests", @@ -86,6 +87,43 @@ "more": "More", "explore": "Explore" }, + "stickers": { + "eyebrow": "Reaction lab", + "title": "{collection} reactions", + "description": "Choose a mood, switch pets, and copy a sticker straight into the conversation. Build a deck when one reaction is not enough.", + "count": "{count, plural, =1 {1 pet} other {# pets}}", + "reaction": "Reaction", + "pet": "Pet", + "treatment": "Treatment", + "clean": "Clean", + "outline": "Sticker outline", + "copy": "Copy sticker", + "openExplorer": "Reactions", + "copied": "Sticker copied", + "download": "Download WebP", + "downloadWhatsApp": "WhatsApp WebP · 512px", + "whatsappNote": "Animated WebP prepared to WhatsApp's media limits. This downloads one reaction, not an installable pack.", + "nextPet": "Next pet", + "addToDeck": "Add to deck", + "deck": "Reaction deck", + "emptyDeck": "Save reactions here, then share the whole deck as one URL.", + "shareReaction": "Copy reaction link", + "reactionShared": "Reaction link copied", + "shareDeck": "Copy deck link", + "shared": "Deck link copied", + "deckFull": "Deck is full", + "reactions": { + "idle": "Idle", + "running-right": "Running right", + "running-left": "Running left", + "waving": "Waving", + "jumping": "Jumping", + "failed": "Failed", + "waiting": "Waiting", + "running": "Running", + "review": "Review" + } + }, "theme": { "toggle": "Toggle theme", "dark": "Dark", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index f1cb1335..af1b2f10 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -39,6 +39,7 @@ "create": "Crear", "docs": "Docs", "collections": "Colecciones", + "reactions": "Reacciones", "topCreators": "Creadores", "creators": "Creadores", "requests": "Pedidos", @@ -86,6 +87,43 @@ "more": "Más", "explore": "Explorar" }, + "stickers": { + "eyebrow": "Laboratorio de reacciones", + "title": "Reacciones de {collection}", + "description": "Elige un mood, cambia de pet y copia el sticker directo a la conversación. Arma un deck cuando una reacción no alcanza.", + "count": "{count, plural, =1 {1 pet} other {# pets}}", + "reaction": "Reacción", + "pet": "Pet", + "treatment": "Estilo", + "clean": "Limpio", + "outline": "Borde de sticker", + "copy": "Copiar sticker", + "openExplorer": "Reacciones", + "copied": "Sticker copiado", + "download": "Descargar WebP", + "downloadWhatsApp": "WebP para WhatsApp · 512px", + "whatsappNote": "WebP animado preparado según los límites de WhatsApp. Descarga una reacción, no un pack instalable.", + "nextPet": "Siguiente pet", + "addToDeck": "Agregar al deck", + "deck": "Deck de reacciones", + "emptyDeck": "Guarda reacciones aquí y comparte todo el deck con una sola URL.", + "shareReaction": "Copiar link de reacción", + "reactionShared": "Link de reacción copiado", + "shareDeck": "Copiar link del deck", + "shared": "Link del deck copiado", + "deckFull": "El deck está lleno", + "reactions": { + "idle": "Quieto", + "running-right": "Corriendo a la derecha", + "running-left": "Corriendo a la izquierda", + "waving": "Saludando", + "jumping": "Saltando", + "failed": "Falló", + "waiting": "Esperando", + "running": "Ejecutando", + "review": "Revisando" + } + }, "theme": { "toggle": "Cambiar tema", "dark": "Oscuro", diff --git a/src/i18n/messages/zh.json b/src/i18n/messages/zh.json index bd606218..4185f3db 100644 --- a/src/i18n/messages/zh.json +++ b/src/i18n/messages/zh.json @@ -45,6 +45,7 @@ "create": "创建", "docs": "文档", "collections": "合集", + "reactions": "反应贴纸", "topCreators": "创作者", "creators": "创作者", "requests": "心愿单", @@ -93,6 +94,43 @@ "more": "更多", "explore": "探索" }, + "stickers": { + "eyebrow": "反应实验室", + "title": "{collection} 反应贴纸", + "description": "选择心情和宠物,直接复制贴纸到对话中。还可以组合并分享整套反应贴纸。", + "count": "{count} 个宠物", + "reaction": "反应", + "pet": "宠物", + "treatment": "样式", + "clean": "干净", + "outline": "贴纸描边", + "copy": "复制贴纸", + "openExplorer": "反应贴纸", + "copied": "贴纸已复制", + "download": "下载 WebP", + "downloadWhatsApp": "WhatsApp WebP · 512px", + "whatsappNote": "符合 WhatsApp 媒体限制的动态 WebP。此操作仅下载单个反应,不会安装贴纸包。", + "nextPet": "下一个宠物", + "addToDeck": "加入组合", + "deck": "反应组合", + "emptyDeck": "把反应保存在这里,然后用一个链接分享整个组合。", + "shareReaction": "复制反应链接", + "reactionShared": "反应链接已复制", + "shareDeck": "复制组合链接", + "shared": "组合链接已复制", + "deckFull": "组合已满", + "reactions": { + "idle": "待机", + "running-right": "向右跑", + "running-left": "向左跑", + "waving": "挥手", + "jumping": "跳跃", + "failed": "失败", + "waiting": "等待", + "running": "运行中", + "review": "审核中" + } + }, "theme": { "toggle": "切换主题", "dark": "深色", diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 771d2e28..73098316 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -59,6 +59,9 @@ export const submittedPets = pgTable( // 64-bit dHash of the first idle frame as a 16-char hex string. Used // for fast perceptual-similarity dedup at admin review time. dhash: text("dhash"), + spriteSha256: text("sprite_sha256"), + petJsonSha256: text("pet_json_sha256"), + zipSha256: text("zip_sha256"), // Gemini embedding + embedding_model live in raw pgvector columns. // Drizzle has no first-class pgvector type yet, so they are kept out // of this model and cast at query boundaries. @@ -181,6 +184,79 @@ export const submissionReviews = pgTable( }), ); +export const petExportApprovals = pgTable( + "pet_export_approvals", + { + petId: text("pet_id") + .notNull() + .references(() => submittedPets.id, { onDelete: "cascade" }), + scope: text("scope").notNull().default("stickers"), + status: text("status").$type<"allowed" | "revoked">().notNull(), + sourceSha256: text("source_sha256").notNull(), + policyVersion: text("policy_version").notNull(), + reviewedBy: text("reviewed_by").notNull(), + reason: text("reason").notNull(), + reviewedAt: timestamp("reviewed_at", { withTimezone: true }) + .notNull() + .defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.petId, table.scope] }), + scopeStatusIdx: index("pet_export_approvals_scope_status_idx").on( + table.scope, + table.status, + ), + sourceIdx: index("pet_export_approvals_source_idx").on( + table.petId, + table.sourceSha256, + ), + }), +); + +export const petStickerPublications = pgTable( + "pet_sticker_publications", + { + petId: text("pet_id") + .primaryKey() + .references(() => submittedPets.id, { onDelete: "cascade" }), + sourceSha256: text("source_sha256").notNull(), + artifactVersion: text("artifact_version").notNull(), + states: jsonb("states").$type().notNull(), + formats: jsonb("formats").$type().notNull(), + profiles: jsonb("profiles").$type().notNull(), + treatments: jsonb("treatments").$type().notNull(), + objectCount: integer("object_count").notNull(), + totalBytes: integer("total_bytes").notNull(), + manifestSha256: text("manifest_sha256").notNull(), + status: text("status").$type<"complete" | "revoked">().notNull(), + cleanupStatus: text("cleanup_status") + .$type<"not_required" | "pending" | "complete" | "failed">() + .notNull() + .default("not_required"), + cleanupError: text("cleanup_error"), + publishedAt: timestamp("published_at", { withTimezone: true }) + .notNull() + .defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + statusIdx: index("pet_sticker_publications_status_idx").on(table.status), + sourceIdx: index("pet_sticker_publications_source_idx").on( + table.petId, + table.sourceSha256, + ), + }), +); + export const petLikes = pgTable( "pet_likes", { diff --git a/src/lib/mock/db.ts b/src/lib/mock/db.ts index 28eb1aca..49f107e9 100644 --- a/src/lib/mock/db.ts +++ b/src/lib/mock/db.ts @@ -200,6 +200,37 @@ async function bootstrap(client: PGlite): Promise { "created_at" timestamp with time zone NOT NULL DEFAULT now(), "updated_at" timestamp with time zone NOT NULL DEFAULT now() )`, + `CREATE TABLE IF NOT EXISTS "pet_export_approvals" ( + "pet_id" text NOT NULL REFERENCES "submitted_pets"("id") ON DELETE CASCADE, + "scope" text NOT NULL DEFAULT 'stickers', + "status" text NOT NULL, + "source_sha256" text NOT NULL, + "policy_version" text NOT NULL, + "reviewed_by" text NOT NULL, + "reason" text NOT NULL, + "reviewed_at" timestamp with time zone NOT NULL DEFAULT now(), + "created_at" timestamp with time zone NOT NULL DEFAULT now(), + "updated_at" timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY ("pet_id", "scope") + )`, + `CREATE TABLE IF NOT EXISTS "pet_sticker_publications" ( + "pet_id" text PRIMARY KEY REFERENCES "submitted_pets"("id") ON DELETE CASCADE, + "source_sha256" text NOT NULL, + "artifact_version" text NOT NULL, + "states" jsonb NOT NULL, + "formats" jsonb NOT NULL, + "profiles" jsonb NOT NULL, + "treatments" jsonb NOT NULL, + "object_count" integer NOT NULL, + "total_bytes" integer NOT NULL, + "manifest_sha256" text NOT NULL, + "status" text NOT NULL, + "cleanup_status" text NOT NULL DEFAULT 'not_required', + "cleanup_error" text, + "published_at" timestamp with time zone NOT NULL DEFAULT now(), + "revoked_at" timestamp with time zone, + "updated_at" timestamp with time zone NOT NULL DEFAULT now() + )`, `CREATE TABLE IF NOT EXISTS "pet_likes" ( "user_id" text NOT NULL, "pet_slug" text NOT NULL, diff --git a/src/lib/pet-public-artifact-keys.test.ts b/src/lib/pet-public-artifact-keys.test.ts index e94b72b7..e67bb46a 100644 --- a/src/lib/pet-public-artifact-keys.test.ts +++ b/src/lib/pet-public-artifact-keys.test.ts @@ -3,25 +3,69 @@ import { describe, expect, it } from "bun:test"; import { petPreviewKey } from "@/lib/pet-preview"; import { petPublicArtifactKeys } from "@/lib/pet-public-artifact-keys"; import { + legacyPetStickerRedirectUrls, PET_STICKER_FORMATS, PET_STICKER_STATES, + PET_STICKER_TREATMENTS, + petStickerFilename, + petStickerKey, + petStickerTrayKey, } from "@/lib/pet-sticker-artifacts"; import { petThumbnailKey } from "@/lib/pet-thumbnail"; describe("pet public artifact keys", () => { - it("covers thumbnails, previews, every sticker derivative, and packs", () => { + it("covers thumbnails, previews, every sticker derivative, and retired artifacts", () => { const keys = petPublicArtifactKeys("cai-chao"); expect(keys).toContain(petThumbnailKey("cai-chao")); expect(keys).toContain(petPreviewKey("cai-chao")); expect(keys).toContain("pets/cai-chao/wastickers.zip"); + expect(keys).toContain(petStickerTrayKey("cai-chao")); for (const state of PET_STICKER_STATES) { for (const format of PET_STICKER_FORMATS) { - expect(keys).toContain(`pets/cai-chao/stickers/${state}.${format}`); + for (const treatment of PET_STICKER_TREATMENTS) { + expect(keys).toContain( + petStickerKey("cai-chao", state, format, treatment), + ); + } + } + for (const treatment of PET_STICKER_TREATMENTS) { + expect(keys).toContain( + petStickerKey("cai-chao", state, "webp", treatment, "whatsapp"), + ); } } expect(new Set(keys).size).toBe(keys.length); }); + + it("covers every legacy immutable sticker redirect", () => { + expect(legacyPetStickerRedirectUrls("claude-crab")).toEqual([ + "https://petdex.dev/api/pets/claude-crab/sticker", + "https://petdex.dev/api/pets/claude-crab/sticker?download=1", + "https://petdex.dev/api/pets/claude-crab/sticker?format=gif", + "https://petdex.dev/api/pets/claude-crab/sticker?format=gif&download=1", + "https://petdex.dev/api/pets/claude-crab/sticker?format=png", + "https://petdex.dev/api/pets/claude-crab/sticker?format=png&download=1", + ]); + }); + + it("keeps web and WhatsApp artifact contracts distinct", () => { + expect( + petStickerKey("claude-crab", "waiting", "webp", "outline", "whatsapp"), + ).toBe("pets/claude-crab/stickers/whatsapp/waiting-outline.webp"); + expect( + petStickerFilename( + "claude-crab", + "waiting", + "webp", + "outline", + "whatsapp", + ), + ).toBe("claude-crab-waiting-outline-whatsapp-sticker.webp"); + expect( + petStickerFilename("claude-crab", "idle", "png", "clean", "web"), + ).toBe("claude-crab-sticker.png"); + }); }); diff --git a/src/lib/pet-public-artifact-keys.ts b/src/lib/pet-public-artifact-keys.ts index 7e8a1dcf..824f164a 100644 --- a/src/lib/pet-public-artifact-keys.ts +++ b/src/lib/pet-public-artifact-keys.ts @@ -2,8 +2,9 @@ import { petPreviewKey } from "@/lib/pet-preview"; import { PET_STICKER_FORMATS, PET_STICKER_STATES, + PET_STICKER_TREATMENTS, petStickerKey, - petStickerPackKey, + petStickerTrayKey, } from "@/lib/pet-sticker-artifacts"; import { petThumbnailKey } from "@/lib/pet-thumbnail"; @@ -12,8 +13,18 @@ export function petPublicArtifactKeys(slug: string): string[] { petThumbnailKey(slug), petPreviewKey(slug), ...PET_STICKER_STATES.flatMap((state) => - PET_STICKER_FORMATS.map((format) => petStickerKey(slug, state, format)), + PET_STICKER_FORMATS.flatMap((format) => + PET_STICKER_TREATMENTS.map((treatment) => + petStickerKey(slug, state, format, treatment), + ), + ), ), - petStickerPackKey(slug), + ...PET_STICKER_STATES.flatMap((state) => + PET_STICKER_TREATMENTS.map((treatment) => + petStickerKey(slug, state, "webp", treatment, "whatsapp"), + ), + ), + petStickerTrayKey(slug), + `pets/${slug}/wastickers.zip`, ]; } diff --git a/src/lib/pet-search.ts b/src/lib/pet-search.ts index 3137f4b9..4cfd4da4 100644 --- a/src/lib/pet-search.ts +++ b/src/lib/pet-search.ts @@ -460,6 +460,9 @@ function rowToSchema( colorFamily: (row.color_family as string | null) ?? null, featured: row.featured as boolean, dhash: (row.dhash as string | null) ?? null, + spriteSha256: (row.sprite_sha256 as string | null) ?? null, + petJsonSha256: (row.pet_json_sha256 as string | null) ?? null, + zipSha256: (row.zip_sha256 as string | null) ?? null, status: row.status as "approved" | "pending" | "rejected", source: (row.source as "submit" | "discover" | "claimed" | undefined) ?? "submit", diff --git a/src/lib/pet-sticker-artifacts.ts b/src/lib/pet-sticker-artifacts.ts index 72ced11f..cbe8861b 100644 --- a/src/lib/pet-sticker-artifacts.ts +++ b/src/lib/pet-sticker-artifacts.ts @@ -2,11 +2,11 @@ import type { PetStateId } from "@/lib/pet-states"; import { R2_PUBLIC_BASE } from "@/lib/r2-public-url"; export type PetStickerFormat = "webp" | "gif" | "png"; +export type PetStickerProfile = "web" | "whatsapp"; +export type PetStickerTreatment = "clean" | "outline"; export const PET_STICKER_CACHE_HEADER = "public, max-age=31536000, s-maxage=31536000, immutable"; -export const PET_STICKER_REDIRECT_CACHE_HEADER = - "public, max-age=86400, s-maxage=604800"; export const PET_STICKER_UNAVAILABLE_CACHE_HEADER = "public, max-age=300, s-maxage=300"; @@ -28,6 +28,16 @@ export const PET_STICKER_FORMATS = [ "png", ] as const satisfies readonly PetStickerFormat[]; +export const PET_STICKER_TREATMENTS = [ + "clean", + "outline", +] as const satisfies readonly PetStickerTreatment[]; + +export const PET_STICKER_PROFILES = [ + "web", + "whatsapp", +] as const satisfies readonly PetStickerProfile[]; + export function isValidPetSlug(slug: string): boolean { return /^[a-z0-9-]{1,80}$/.test(slug); } @@ -46,39 +56,97 @@ export function parsePetStickerFormat(value: string | null): PetStickerFormat { : "webp"; } +export function parseExactPetStickerState( + value: string | null, +): PetStateId | null { + if (!value) return null; + return PET_STICKER_STATES.includes(value as PetStateId) + ? (value as PetStateId) + : null; +} + +export function parseExactPetStickerFormat( + value: string | null, +): PetStickerFormat | null { + if (!value) return null; + return PET_STICKER_FORMATS.includes(value as PetStickerFormat) + ? (value as PetStickerFormat) + : null; +} + +export function parseExactPetStickerTreatment( + value: string | null, +): PetStickerTreatment | null { + if (!value) return null; + return PET_STICKER_TREATMENTS.includes(value as PetStickerTreatment) + ? (value as PetStickerTreatment) + : null; +} + +export function parseExactPetStickerProfile( + value: string | null, +): PetStickerProfile | null { + if (!value) return null; + return PET_STICKER_PROFILES.includes(value as PetStickerProfile) + ? (value as PetStickerProfile) + : null; +} + export function petStickerKey( slug: string, state: PetStateId = "idle", format: PetStickerFormat = "webp", + treatment: PetStickerTreatment = "clean", + profile: PetStickerProfile = "web", ): string { - return `pets/${slug}/stickers/${state}.${format}`; + const suffix = treatment === "outline" ? "-outline" : ""; + const profilePath = profile === "whatsapp" ? "whatsapp/" : ""; + return `pets/${slug}/stickers/${profilePath}${state}${suffix}.${format}`; } export function petStickerUrl( slug: string, state: PetStateId = "idle", format: PetStickerFormat = "webp", + treatment: PetStickerTreatment = "clean", + profile: PetStickerProfile = "web", ): string { - return `${R2_PUBLIC_BASE}/${petStickerKey(slug, state, format)}`; + return `${R2_PUBLIC_BASE}/${petStickerKey(slug, state, format, treatment, profile)}`; } export function petStickerFilename( slug: string, state: PetStateId = "idle", format: PetStickerFormat = "webp", + treatment: PetStickerTreatment = "clean", + profile: PetStickerProfile = "web", ): string { const suffix = state === "idle" ? "" : `-${state}`; - return `${slug}${suffix}-sticker.${format}`; + const treatmentSuffix = treatment === "outline" ? "-outline" : ""; + const profileSuffix = profile === "whatsapp" ? "-whatsapp" : ""; + return `${slug}${suffix}${treatmentSuffix}${profileSuffix}-sticker.${format}`; +} + +export function legacyPetStickerRedirectUrls(slug: string): string[] { + const base = `https://petdex.dev/api/pets/${slug}/sticker`; + return [ + base, + `${base}?download=1`, + `${base}?format=gif`, + `${base}?format=gif&download=1`, + `${base}?format=png`, + `${base}?format=png&download=1`, + ]; } -export function petStickerPackKey(slug: string): string { - return `pets/${slug}/wastickers.zip`; +export function petStickerTrayKey(slug: string): string { + return `pets/${slug}/stickers/whatsapp/tray.png`; } -export function petStickerPackUrl(slug: string): string { - return `${R2_PUBLIC_BASE}/${petStickerPackKey(slug)}`; +export function petStickerTrayUrl(slug: string): string { + return `${R2_PUBLIC_BASE}/${petStickerTrayKey(slug)}`; } -export function petStickerPackFilename(slug: string): string { - return `${slug}-petdex-stickers.zip`; +export function petStickerTrayFilename(slug: string): string { + return `${slug}-whatsapp-tray.png`; } diff --git a/src/lib/sql-statements.test.ts b/src/lib/sql-statements.test.ts new file mode 100644 index 00000000..eea1c1f9 --- /dev/null +++ b/src/lib/sql-statements.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { splitSqlStatements } from "@/lib/sql-statements"; + +describe("SQL statement splitting", () => { + it("preserves semicolons inside quoted and dollar-quoted values", () => { + expect( + splitSqlStatements( + `SELECT ';' AS value; SELECT "semi;colon"; SELECT $$body;value$$;`, + ), + ).toEqual([ + "SELECT ';' AS value", + 'SELECT "semi;colon"', + "SELECT $$body;value$$", + ]); + }); + + it("round-trips the sticker export migration", () => { + const migration = readFileSync( + resolve("drizzle/0018_sticker_exports.sql"), + "utf8", + ); + const statements = splitSqlStatements(migration); + + expect(statements.length).toBeGreaterThan(20); + expect(splitSqlStatements(statements.join(";\n"))).toEqual(statements); + }); +}); diff --git a/src/lib/sql-statements.ts b/src/lib/sql-statements.ts new file mode 100644 index 00000000..8fd98540 --- /dev/null +++ b/src/lib/sql-statements.ts @@ -0,0 +1,76 @@ +export function splitSqlStatements(source: string): string[] { + const statements: string[] = []; + let current = ""; + let quote: "single" | "double" | null = null; + let dollarTag: string | null = null; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + + if (dollarTag) { + if (source.startsWith(dollarTag, index)) { + current += dollarTag; + index += dollarTag.length - 1; + dollarTag = null; + } else { + current += character; + } + continue; + } + + if (quote === "single") { + current += character; + if (character === "'" && source[index + 1] === "'") { + current += source[index + 1]; + index += 1; + } else if (character === "'") { + quote = null; + } + continue; + } + + if (quote === "double") { + current += character; + if (character === '"' && source[index + 1] === '"') { + current += source[index + 1]; + index += 1; + } else if (character === '"') { + quote = null; + } + continue; + } + + if (character === "'") { + quote = "single"; + current += character; + continue; + } + if (character === '"') { + quote = "double"; + current += character; + continue; + } + if (character === "$") { + const match = source + .slice(index) + .match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/); + if (match) { + dollarTag = match[0]; + current += dollarTag; + index += dollarTag.length - 1; + continue; + } + } + if (character === ";") { + const statement = current.trim(); + if (statement) statements.push(statement); + current = ""; + continue; + } + current += character; + } + + const statement = current.trim(); + if (statement) statements.push(statement); + return statements; +} diff --git a/src/lib/sticker-explorer-url.test.ts b/src/lib/sticker-explorer-url.test.ts new file mode 100644 index 00000000..53520a21 --- /dev/null +++ b/src/lib/sticker-explorer-url.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; + +import { + parseStickerDeck, + parseStickerExplorerSelection, + STICKER_DECK_LIMIT, + upsertStickerExplorerParams, +} from "@/lib/sticker-explorer-url"; + +const pets = ["claude-crab", "clawd"]; + +describe("sticker explorer URL", () => { + it("parses an exact selection and falls back safely", () => { + expect( + parseStickerExplorerSelection( + new URLSearchParams("reaction=review&pet=clawd&treatment=clean"), + pets, + ), + ).toEqual({ pet: "clawd", state: "review", treatment: "clean" }); + expect( + parseStickerExplorerSelection( + new URLSearchParams("reaction=nope&pet=nope&treatment=nope"), + pets, + ), + ).toEqual({ + pet: "claude-crab", + state: "waiting", + treatment: "outline", + }); + }); + + it("deduplicates, validates, and caps shared decks", () => { + const value = Array.from( + { length: STICKER_DECK_LIMIT + 4 }, + (_, index) => + `${index % 2 === 0 ? "claude-crab" : "clawd"}.${ + ["idle", "waiting", "review", "failed"][index % 4] + }.${index % 3 === 0 ? "clean" : "outline"}`, + ).join(","); + const deck = parseStickerDeck(`${value},invalid.waiting.clean`, pets); + expect(deck.length).toBeLessThanOrEqual(STICKER_DECK_LIMIT); + expect(new Set(deck.map((item) => JSON.stringify(item))).size).toBe( + deck.length, + ); + }); + + it("writes the canonical share URL contract", () => { + const selection = { + pet: "claude-crab", + state: "waiting" as const, + treatment: "outline" as const, + }; + const params = upsertStickerExplorerParams( + new URLSearchParams("ignored=1"), + selection, + [selection], + ); + expect(params.get("reaction")).toBe("waiting"); + expect(params.get("pet")).toBe("claude-crab"); + expect(params.get("treatment")).toBe("outline"); + expect(params.get("deck")).toBe("claude-crab.waiting.outline"); + }); +}); diff --git a/src/lib/sticker-explorer-url.ts b/src/lib/sticker-explorer-url.ts new file mode 100644 index 00000000..de70f348 --- /dev/null +++ b/src/lib/sticker-explorer-url.ts @@ -0,0 +1,95 @@ +import type { PetStateId } from "@/lib/pet-states"; +import { + isValidPetSlug, + PET_STICKER_STATES, + PET_STICKER_TREATMENTS, + type PetStickerTreatment, +} from "@/lib/pet-sticker-artifacts"; + +export const STICKER_REACTION_STATES = [ + "waiting", + "running", + "review", + "failed", + "jumping", +] as const satisfies readonly PetStateId[]; + +export const STICKER_DECK_LIMIT = 12; + +export type StickerDeckItem = { + pet: string; + state: PetStateId; + treatment: PetStickerTreatment; +}; + +export function parseStickerExplorerSelection( + params: URLSearchParams, + availablePets: string[], +): StickerDeckItem { + const pet = params.get("pet"); + const reaction = params.get("reaction"); + const treatment = params.get("treatment"); + return { + pet: pet && availablePets.includes(pet) ? pet : (availablePets[0] ?? ""), + state: PET_STICKER_STATES.includes(reaction as PetStateId) + ? (reaction as PetStateId) + : "waiting", + treatment: PET_STICKER_TREATMENTS.includes(treatment as PetStickerTreatment) + ? (treatment as PetStickerTreatment) + : "outline", + }; +} + +export function serializeStickerSelection(item: StickerDeckItem): string { + return `${item.pet}.${item.state}.${item.treatment}`; +} + +export function parseStickerDeck( + raw: string | null, + availablePets: string[], +): StickerDeckItem[] { + if (!raw) return []; + const seen = new Set(); + const items: StickerDeckItem[] = []; + for (const value of raw.split(",")) { + const parts = value.split("."); + if (parts.length !== 3) continue; + const [pet, state, treatment] = parts; + if ( + !isValidPetSlug(pet) || + !availablePets.includes(pet) || + !PET_STICKER_STATES.includes(state as PetStateId) || + !PET_STICKER_TREATMENTS.includes(treatment as PetStickerTreatment) + ) { + continue; + } + const item = { + pet, + state: state as PetStateId, + treatment: treatment as PetStickerTreatment, + }; + const key = serializeStickerSelection(item); + if (seen.has(key)) continue; + seen.add(key); + items.push(item); + if (items.length === STICKER_DECK_LIMIT) break; + } + return items; +} + +export function upsertStickerExplorerParams( + params: URLSearchParams, + selection: StickerDeckItem, + deck: StickerDeckItem[], +): URLSearchParams { + const next = new URLSearchParams(params); + next.set("reaction", selection.state); + next.set("pet", selection.pet); + next.set("treatment", selection.treatment); + if (deck.length > 0) { + next.set("deck", deck.map(serializeStickerSelection).join(",")); + } else { + next.delete("deck"); + } + return next; +} diff --git a/src/lib/sticker-export-policy.test.ts b/src/lib/sticker-export-policy.test.ts new file mode 100644 index 00000000..a3839f1e --- /dev/null +++ b/src/lib/sticker-export-policy.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from "bun:test"; + +import { + hasPublishedStickerArtifact, + isCurrentStickerExportAllowed, + isCurrentStickerPublication, + isStickerExplorerEnabled, + STICKER_ARTIFACT_VERSION, + STICKER_EXPORT_POLICY_VERSION, + STICKER_EXPORT_SCOPE, +} from "@/lib/sticker-export-policy"; + +const originalEnabled = process.env.STICKER_EXPLORER_ENABLED; +const originalDisabled = process.env.STICKER_EXPORT_DISABLED; + +afterEach(() => { + process.env.STICKER_EXPLORER_ENABLED = originalEnabled; + process.env.STICKER_EXPORT_DISABLED = originalDisabled; +}); + +describe("sticker export policy", () => { + it("requires an approved pet and an approval for its current sprite", () => { + const pet = { status: "approved" as const, spriteSha256: "sprite-v2" }; + const approval = { + petId: "pet-1", + scope: STICKER_EXPORT_SCOPE, + status: "allowed" as const, + sourceSha256: "sprite-v2", + policyVersion: STICKER_EXPORT_POLICY_VERSION, + reviewedBy: "admin", + reason: "approved for sticker export", + reviewedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(isCurrentStickerExportAllowed(pet, approval)).toBe(true); + expect( + isCurrentStickerExportAllowed(pet, { + ...approval, + sourceSha256: "sprite-v1", + }), + ).toBe(false); + }); + + it("requires the complete current publication matrix", () => { + const pet = { status: "approved" as const, spriteSha256: "sprite-v2" }; + const publication = { + petId: "pet-1", + sourceSha256: "sprite-v2", + artifactVersion: STICKER_ARTIFACT_VERSION, + states: [ + "idle", + "running-right", + "running-left", + "waving", + "jumping", + "failed", + "waiting", + "running", + "review", + ], + formats: ["webp", "png"], + profiles: ["web", "whatsapp"], + treatments: ["clean", "outline"], + objectCount: 55, + totalBytes: 123, + manifestSha256: "manifest", + status: "complete" as const, + cleanupStatus: "not_required" as const, + cleanupError: null, + publishedAt: new Date(), + revokedAt: null, + updatedAt: new Date(), + }; + + expect(isCurrentStickerPublication(pet, publication)).toBe(true); + expect( + isCurrentStickerPublication(pet, { + ...publication, + treatments: ["clean"], + }), + ).toBe(false); + expect( + isCurrentStickerPublication(pet, { + ...publication, + profiles: ["web"], + }), + ).toBe(false); + expect( + hasPublishedStickerArtifact( + publication, + "waiting", + "webp", + "outline", + "whatsapp", + ), + ).toBe(true); + expect( + hasPublishedStickerArtifact( + publication, + "waiting", + "png", + "outline", + "whatsapp", + ), + ).toBe(false); + }); + + it("keeps the explorer disabled unless explicitly enabled", () => { + process.env.STICKER_EXPLORER_ENABLED = "1"; + delete process.env.STICKER_EXPORT_DISABLED; + expect(isStickerExplorerEnabled()).toBe(true); + + process.env.STICKER_EXPORT_DISABLED = "true"; + expect(isStickerExplorerEnabled()).toBe(false); + }); +}); diff --git a/src/lib/sticker-export-policy.ts b/src/lib/sticker-export-policy.ts new file mode 100644 index 00000000..40252dcd --- /dev/null +++ b/src/lib/sticker-export-policy.ts @@ -0,0 +1,94 @@ +import type { schema } from "@/lib/db/client"; +import { + PET_STICKER_STATES, + type PetStickerFormat, + type PetStickerProfile, + type PetStickerTreatment, +} from "@/lib/pet-sticker-artifacts"; + +export const STICKER_EXPORT_SCOPE = "stickers"; +export const STICKER_EXPORT_POLICY_VERSION = "sticker-export-v1"; +export const STICKER_ARTIFACT_VERSION = "petdex-stickers-v2"; +export const STICKER_PUBLIC_FORMATS = ["webp", "png"] as const; +export const STICKER_PUBLIC_PROFILES = ["web", "whatsapp"] as const; +export const STICKER_PUBLIC_TREATMENTS = ["clean", "outline"] as const; + +type StickerApproval = typeof schema.petExportApprovals.$inferSelect; +type StickerPublication = typeof schema.petStickerPublications.$inferSelect; + +type EligiblePet = { + status: "pending" | "approved" | "rejected"; + spriteSha256: string | null; +}; + +export function isStickerExportDisabled(): boolean { + return readBooleanEnv(process.env.STICKER_EXPORT_DISABLED); +} + +export function isStickerExplorerEnabled(): boolean { + return ( + !isStickerExportDisabled() && + readBooleanEnv(process.env.STICKER_EXPLORER_ENABLED) + ); +} + +export function isCurrentStickerExportAllowed( + pet: EligiblePet, + approval: StickerApproval | null, +): boolean { + return Boolean( + pet.status === "approved" && + pet.spriteSha256 && + approval?.scope === STICKER_EXPORT_SCOPE && + approval.status === "allowed" && + approval.sourceSha256 === pet.spriteSha256 && + approval.policyVersion === STICKER_EXPORT_POLICY_VERSION, + ); +} + +export function isCurrentStickerPublication( + pet: EligiblePet, + publication: StickerPublication | null, +): boolean { + return Boolean( + pet.spriteSha256 && + publication?.status === "complete" && + publication.sourceSha256 === pet.spriteSha256 && + publication.artifactVersion === STICKER_ARTIFACT_VERSION && + includesAll(publication.states, PET_STICKER_STATES) && + includesAll(publication.formats, STICKER_PUBLIC_FORMATS) && + includesAll(publication.profiles, STICKER_PUBLIC_PROFILES) && + includesAll(publication.treatments, STICKER_PUBLIC_TREATMENTS), + ); +} + +export function hasPublishedStickerArtifact( + publication: StickerPublication, + state: string, + format: PetStickerFormat, + treatment: PetStickerTreatment, + profile: PetStickerProfile, +): boolean { + return ( + publication.states.includes(state) && + publication.formats.includes(format) && + publication.treatments.includes(treatment) && + publication.profiles.includes(profile) && + stickerFormatsForProfile(profile).includes(format as "webp" | "png") + ); +} + +export function stickerFormatsForProfile( + profile: PetStickerProfile, +): readonly ("webp" | "png")[] { + return profile === "whatsapp" ? ["webp"] : STICKER_PUBLIC_FORMATS; +} + +function includesAll(actual: string[], required: readonly string[]): boolean { + const values = new Set(actual); + return required.every((value) => values.has(value)); +} + +function readBooleanEnv(value: string | undefined): boolean { + return value === "1" || value?.toLowerCase() === "true"; +} diff --git a/src/lib/sticker-export.ts b/src/lib/sticker-export.ts new file mode 100644 index 00000000..80ed91f6 --- /dev/null +++ b/src/lib/sticker-export.ts @@ -0,0 +1,205 @@ +import "server-only"; + +import { and, asc, desc, eq, isNull } from "drizzle-orm"; + +import { db, schema } from "@/lib/db/client"; +import { withNextDataCache } from "@/lib/next-data-cache"; +import type { PetStateId } from "@/lib/pet-states"; +import type { + PetStickerFormat, + PetStickerProfile, + PetStickerTreatment, +} from "@/lib/pet-sticker-artifacts"; +import { + hasPublishedStickerArtifact, + isCurrentStickerExportAllowed, + isCurrentStickerPublication, + isStickerExplorerEnabled, + isStickerExportDisabled, + STICKER_EXPORT_SCOPE, +} from "@/lib/sticker-export-policy"; + +export type StickerCollectionPet = { + id: string; + slug: string; + displayName: string; + description: string; + dominantColor: string | null; + states: PetStateId[]; + formats: PetStickerFormat[]; + profiles: PetStickerProfile[]; + treatments: PetStickerTreatment[]; +}; + +export type StickerCollection = { + slug: string; + title: string; + description: string; + pets: StickerCollectionPet[]; +}; + +export type StickerArtifactAccess = + | { status: "disabled" } + | { status: "not_found" } + | { status: "ineligible" } + | { status: "missing" } + | { status: "ok"; petId: string; slug: string }; + +export async function getStickerCollection( + rawSlug: string, +): Promise { + if (!isStickerExplorerEnabled()) return null; + const slug = rawSlug.trim().toLowerCase(); + const collection = await db.query.petCollections.findFirst({ + where: and( + eq(schema.petCollections.slug, slug), + isNull(schema.petCollections.ownerId), + ), + }); + if (!collection) return null; + + const rows = await db + .select({ + id: schema.submittedPets.id, + slug: schema.submittedPets.slug, + displayName: schema.submittedPets.displayName, + description: schema.submittedPets.description, + dominantColor: schema.submittedPets.dominantColor, + status: schema.submittedPets.status, + spriteSha256: schema.submittedPets.spriteSha256, + approval: schema.petExportApprovals, + publication: schema.petStickerPublications, + }) + .from(schema.petCollectionItems) + .innerJoin( + schema.submittedPets, + eq(schema.petCollectionItems.petSlug, schema.submittedPets.slug), + ) + .leftJoin( + schema.petExportApprovals, + and( + eq(schema.petExportApprovals.petId, schema.submittedPets.id), + eq(schema.petExportApprovals.scope, STICKER_EXPORT_SCOPE), + ), + ) + .leftJoin( + schema.petStickerPublications, + eq(schema.petStickerPublications.petId, schema.submittedPets.id), + ) + .where(eq(schema.petCollectionItems.collectionId, collection.id)) + .orderBy(asc(schema.petCollectionItems.position)); + + return { + slug: collection.slug, + title: collection.title, + description: collection.description, + pets: rows + .filter( + (row) => + isCurrentStickerExportAllowed(row, row.approval) && + isCurrentStickerPublication(row, row.publication), + ) + .map((row) => ({ + id: row.id, + slug: row.slug, + displayName: row.displayName, + description: row.description, + dominantColor: row.dominantColor, + states: row.publication?.states as PetStateId[], + formats: row.publication?.formats as PetStickerFormat[], + profiles: row.publication?.profiles as PetStickerProfile[], + treatments: row.publication?.treatments as PetStickerTreatment[], + })), + }; +} + +export async function getStickerArtifactAccess( + slug: string, + state: PetStateId, + format: PetStickerFormat, + treatment: PetStickerTreatment, + profile: PetStickerProfile = "web", +): Promise { + if (isStickerExportDisabled()) return { status: "disabled" }; + const loadAccess = withNextDataCache( + async () => { + const rows = await db + .select({ + pet: schema.submittedPets, + approval: schema.petExportApprovals, + publication: schema.petStickerPublications, + }) + .from(schema.submittedPets) + .leftJoin( + schema.petExportApprovals, + and( + eq(schema.petExportApprovals.petId, schema.submittedPets.id), + eq(schema.petExportApprovals.scope, STICKER_EXPORT_SCOPE), + ), + ) + .leftJoin( + schema.petStickerPublications, + eq(schema.petStickerPublications.petId, schema.submittedPets.id), + ) + .where( + and( + eq(schema.submittedPets.slug, slug), + eq(schema.submittedPets.status, "approved"), + ), + ) + .limit(1); + return rows[0] ?? null; + }, + ["sticker-artifact-access", slug], + { tags: [`pet:${slug}`, `sticker:${slug}`], revalidate: 60 }, + ); + const row = await loadAccess(); + if (!row) return { status: "not_found" }; + if (!isCurrentStickerExportAllowed(row.pet, row.approval)) { + return { status: "ineligible" }; + } + if ( + !isCurrentStickerPublication(row.pet, row.publication) || + !row.publication || + !hasPublishedStickerArtifact( + row.publication, + state, + format, + treatment, + profile, + ) + ) { + return { status: "missing" }; + } + return { status: "ok", petId: row.pet.id, slug: row.pet.slug }; +} + +export async function getPetStickerAvailability(slug: string): Promise<{ + available: boolean; + collectionSlug: string | null; +}> { + if (!isStickerExplorerEnabled()) { + return { available: false, collectionSlug: null }; + } + const idle = await getStickerArtifactAccess(slug, "idle", "webp", "clean"); + if (idle.status !== "ok") return { available: false, collectionSlug: null }; + const collection = await db + .select({ slug: schema.petCollections.slug }) + .from(schema.petCollectionItems) + .innerJoin( + schema.petCollections, + eq(schema.petCollectionItems.collectionId, schema.petCollections.id), + ) + .where( + and( + eq(schema.petCollectionItems.petSlug, slug), + isNull(schema.petCollections.ownerId), + ), + ) + .orderBy( + desc(schema.petCollections.featured), + asc(schema.petCollections.slug), + ) + .limit(1); + return { available: true, collectionSlug: collection[0]?.slug ?? null }; +} diff --git a/src/lib/sticker-renderer.test.ts b/src/lib/sticker-renderer.test.ts new file mode 100644 index 00000000..6fd47c79 --- /dev/null +++ b/src/lib/sticker-renderer.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "bun:test"; + +import sharp from "sharp"; + +import { + applyStickerOutline, + renderSticker, + STICKER_SIZES, +} from "@/lib/sticker-renderer"; +import { assertWhatsAppSticker } from "@/lib/whatsapp-sticker"; + +describe("sticker renderer", () => { + it("adds an opaque white outline without changing source pixels", () => { + const rgba = Buffer.alloc(5 * 5 * 4); + const center = (2 * 5 + 2) * 4; + rgba[center] = 222; + rgba[center + 1] = 118; + rgba[center + 2] = 82; + rgba[center + 3] = 255; + + const output = applyStickerOutline(rgba, 5, 5, 1); + + expect([...output.subarray(center, center + 4)]).toEqual([ + 222, 118, 82, 255, + ]); + expect([...output.subarray(center - 4, center)]).toEqual([ + 255, 255, 255, 255, + ]); + expect([...output.subarray(0, 4)]).toEqual([0, 0, 0, 0]); + }); + + it("renders a compliant animated WhatsApp WebP", async () => { + const frames = await Promise.all( + Array.from({ length: 6 }, (_, index) => + sharp({ + create: { + width: 192, + height: 208, + channels: 4, + background: { + r: 180 + index * 10, + g: 90, + b: 60, + alpha: 1, + }, + }, + }) + .png() + .toBuffer(), + ), + ); + const spritesheet = await sharp({ + create: { + width: 192 * 6, + height: 208, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite( + frames.map((input, index) => ({ input, left: index * 192, top: 0 })), + ) + .png() + .toBuffer(); + const sticker = await renderSticker(spritesheet, { + state: "idle", + format: "webp", + treatment: "outline", + size: STICKER_SIZES.whatsapp, + }); + + expect(sticker.isAnimated).toBe(true); + expect(sticker.frameCount).toBe(6); + await expect( + assertWhatsAppSticker(sticker.buffer), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/sticker-renderer.ts b/src/lib/sticker-renderer.ts index 62dc112a..19b600a4 100644 --- a/src/lib/sticker-renderer.ts +++ b/src/lib/sticker-renderer.ts @@ -1,12 +1,11 @@ -// Sticker rendering for WeChat / WhatsApp / Discord export. +// Sticker rendering for web and messaging exports. // // Each pet has a 9-state spritesheet (see pet-states.ts). We slice the // requested state's row, extract its frames, and encode an animated WebP -// at 240×240 (sticker target across most platforms; WhatsApp wants 512 -// for packs but is tolerant of 240 for individual sends). +// at either the 240×240 web profile or the 512×512 WhatsApp profile. // // WebP animated is preferred over GIF: smaller files, better alpha, -// native WhatsApp pack format. Both WeChat and Discord accept it inline. +// native WhatsApp image format. Both WeChat and Discord accept it inline. // // Single-frame export (the default 'idle' caller) collapses to a static // PNG via the same pipeline by skipping the animation envelope. @@ -15,16 +14,13 @@ import { applyPalette, GIFEncoder, quantize } from "gifenc"; import sharp from "sharp"; import { defaultPetState, type PetStateId, petStates } from "@/lib/pet-states"; +import type { PetStickerTreatment } from "@/lib/pet-sticker-artifacts"; import { fetchR2Asset } from "@/lib/r2-fetch"; const FRAME_W = 192; const FRAME_H = 208; -// 240 is the WeChat custom sticker max + smallest common WhatsApp pack -// dimension that survives Tencent's preview crawler. 512 is the WhatsApp -// pack official spec but inflates files 4x for marginal quality gain on -// 192x208 source pixel art. const OUT_DEFAULT = 240; -const OUT_WHATSAPP_PACK = 512; +const OUT_WHATSAPP = 512; const RESIZE_OPTS = { fit: "contain" as const, @@ -38,6 +34,7 @@ export type StickerOptions = { state?: PetStateId; size?: number; format?: StickerFormat; + treatment?: PetStickerTreatment; }; export type StickerOutput = { @@ -97,15 +94,14 @@ async function buildAnimatedGif( frames: Buffer[], size: number, delayMs: number, + treatment: PetStickerTreatment, ): Promise { const channels = 4; const frameByteLength = size * size * channels; // Resize each frame to (size × size) and pull raw RGBA. const rawFrames = await Promise.all( - frames.map((b) => - sharp(b).resize(size, size, RESIZE_OPTS).ensureAlpha().raw().toBuffer(), - ), + frames.map((frame) => resizeFrame(frame, size, treatment)), ); for (const buf of rawFrames) { @@ -147,13 +143,79 @@ async function gifToAnimatedWebp(gifBuf: Buffer): Promise { .toBuffer(); } -async function buildStaticPng(frame: Buffer, size: number): Promise { - return await sharp(frame) - .resize(size, size, RESIZE_OPTS) +async function buildStaticPng( + frame: Buffer, + size: number, + treatment: PetStickerTreatment, +): Promise { + const raw = await resizeFrame(frame, size, treatment); + return await sharp(raw, { raw: { width: size, height: size, channels: 4 } }) .png({ compressionLevel: 9 }) .toBuffer(); } +async function resizeFrame( + frame: Buffer, + size: number, + treatment: PetStickerTreatment, +): Promise { + const raw = await sharp(frame) + .resize(size, size, RESIZE_OPTS) + .ensureAlpha() + .raw() + .toBuffer(); + return treatment === "outline" + ? applyStickerOutline(raw, size, size, Math.max(2, Math.round(size / 48))) + : raw; +} + +export function applyStickerOutline( + rgba: Buffer, + width: number, + height: number, + radius: number, +): Buffer { + const sourceAlpha = new Uint8Array(width * height); + for (let index = 0; index < sourceAlpha.length; index += 1) { + sourceAlpha[index] = rgba[index * 4 + 3] > 0 ? 1 : 0; + } + + let expanded = sourceAlpha; + for (let step = 0; step < radius; step += 1) { + const next = expanded.slice(); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = y * width + x; + if (expanded[index]) continue; + if ( + (x > 0 && expanded[index - 1]) || + (x + 1 < width && expanded[index + 1]) || + (y > 0 && expanded[index - width]) || + (y + 1 < height && expanded[index + width]) || + (x > 0 && y > 0 && expanded[index - width - 1]) || + (x + 1 < width && y > 0 && expanded[index - width + 1]) || + (x > 0 && y + 1 < height && expanded[index + width - 1]) || + (x + 1 < width && y + 1 < height && expanded[index + width + 1]) + ) { + next[index] = 1; + } + } + } + expanded = next; + } + + const output = Buffer.from(rgba); + for (let index = 0; index < sourceAlpha.length; index += 1) { + if (sourceAlpha[index] || !expanded[index]) continue; + const offset = index * 4; + output[offset] = 255; + output[offset + 1] = 255; + output[offset + 2] = 255; + output[offset + 3] = 255; + } + return output; +} + export type StickerInput = string | Buffer; export async function renderSticker( @@ -163,6 +225,7 @@ export async function renderSticker( const state = getStateSpec(options.state); const size = options.size ?? OUT_DEFAULT; const format: StickerFormat = options.format ?? "webp"; + const treatment = options.treatment ?? "clean"; // Accept either a URL (fetched once here) or a pre-fetched spritesheet // buffer. The pack endpoint fetches once and feeds the same buffer to @@ -175,7 +238,7 @@ export async function renderSticker( // OR the requested state has no animation to extract. if (format === "png" || frames.length <= 1) { return { - buffer: await buildStaticPng(frames[0], size), + buffer: await buildStaticPng(frames[0], size, treatment), contentType: "image/png", isAnimated: false, frameCount: 1, @@ -183,7 +246,7 @@ export async function renderSticker( } const delayMs = Math.round(state.durationMs / state.frames); - const gifBuf = await buildAnimatedGif(frames, size, delayMs); + const gifBuf = await buildAnimatedGif(frames, size, delayMs, treatment); if (format === "gif") { return { @@ -202,7 +265,15 @@ export async function renderSticker( }; } +export async function renderWhatsAppTray(source: Buffer): Promise { + return await sharp(source) + .extract({ left: 0, top: 0, width: FRAME_W, height: FRAME_H }) + .resize(96, 96, RESIZE_OPTS) + .png({ compressionLevel: 9 }) + .toBuffer(); +} + export const STICKER_SIZES = { default: OUT_DEFAULT, - whatsappPack: OUT_WHATSAPP_PACK, + whatsapp: OUT_WHATSAPP, }; diff --git a/src/lib/whatsapp-sticker.test.ts b/src/lib/whatsapp-sticker.test.ts new file mode 100644 index 00000000..ebad2fe4 --- /dev/null +++ b/src/lib/whatsapp-sticker.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; + +import { whatsappStickerErrors } from "@/lib/whatsapp-sticker"; + +describe("WhatsApp sticker compliance", () => { + it("accepts an animated WebP inside every official limit", () => { + expect( + whatsappStickerErrors({ + format: "webp", + width: 512, + height: 512, + pages: 6, + delays: [180, 180, 180, 180, 180, 180], + bytes: 43_376, + }), + ).toEqual([]); + }); + + it("reports dimensions, size, animation, frame, and duration failures", () => { + expect( + whatsappStickerErrors({ + format: "gif", + width: 240, + height: 240, + pages: 1, + delays: [10_001], + bytes: 500_001, + }), + ).toEqual([ + "format must be WebP", + "dimensions must be 512x512", + "file must be 500KB or smaller", + "sticker must be animated", + "animation must be 10 seconds or shorter", + ]); + }); + + it("requires timing metadata for every frame", () => { + expect( + whatsappStickerErrors({ + format: "webp", + width: 512, + height: 512, + pages: 2, + delays: [7], + bytes: 1, + }), + ).toEqual([ + "every frame must expose a duration", + "frame duration must be at least 8ms", + ]); + }); +}); diff --git a/src/lib/whatsapp-sticker.ts b/src/lib/whatsapp-sticker.ts new file mode 100644 index 00000000..11d034dd --- /dev/null +++ b/src/lib/whatsapp-sticker.ts @@ -0,0 +1,76 @@ +import sharp from "sharp"; + +export const WHATSAPP_STICKER_SIZE = 512; +export const WHATSAPP_STICKER_MAX_BYTES = 500_000; +export const WHATSAPP_STICKER_MAX_DURATION_MS = 10_000; +export const WHATSAPP_STICKER_MIN_FRAME_MS = 8; +export const WHATSAPP_TRAY_SIZE = 96; +export const WHATSAPP_TRAY_MAX_BYTES = 50_000; + +export type WhatsAppStickerFacts = { + format: string | undefined; + width: number | undefined; + height: number | undefined; + pages: number; + delays: number[]; + bytes: number; +}; + +export function whatsappStickerErrors(facts: WhatsAppStickerFacts): string[] { + const errors: string[] = []; + if (facts.format !== "webp") errors.push("format must be WebP"); + if ( + facts.width !== WHATSAPP_STICKER_SIZE || + facts.height !== WHATSAPP_STICKER_SIZE + ) { + errors.push("dimensions must be 512x512"); + } + if (facts.bytes > WHATSAPP_STICKER_MAX_BYTES) { + errors.push("file must be 500KB or smaller"); + } + if (facts.pages < 2) errors.push("sticker must be animated"); + if (facts.delays.length !== facts.pages) { + errors.push("every frame must expose a duration"); + } + if (facts.delays.some((delay) => delay < WHATSAPP_STICKER_MIN_FRAME_MS)) { + errors.push("frame duration must be at least 8ms"); + } + if ( + facts.delays.reduce((total, delay) => total + delay, 0) > + WHATSAPP_STICKER_MAX_DURATION_MS + ) { + errors.push("animation must be 10 seconds or shorter"); + } + return errors; +} + +export async function assertWhatsAppSticker(buffer: Buffer): Promise { + const metadata = await sharp(buffer, { animated: true }).metadata(); + const pages = metadata.pages ?? 1; + const delays = metadata.delay ?? []; + const errors = whatsappStickerErrors({ + format: metadata.format, + width: metadata.width, + height: metadata.pageHeight ?? metadata.height, + pages, + delays, + bytes: buffer.byteLength, + }); + if (errors.length > 0) throw new Error(errors.join("; ")); +} + +export async function assertWhatsAppTray(buffer: Buffer): Promise { + const metadata = await sharp(buffer).metadata(); + const errors: string[] = []; + if (metadata.format !== "png") errors.push("tray must be PNG"); + if ( + metadata.width !== WHATSAPP_TRAY_SIZE || + metadata.height !== WHATSAPP_TRAY_SIZE + ) { + errors.push("tray dimensions must be 96x96"); + } + if (buffer.byteLength > WHATSAPP_TRAY_MAX_BYTES) { + errors.push("tray must be 50KB or smaller"); + } + if (errors.length > 0) throw new Error(errors.join("; ")); +}