diff --git a/content/public/asset-manifest.json b/content/public/asset-manifest.json index cd4bf11..43982d1 100644 --- a/content/public/asset-manifest.json +++ b/content/public/asset-manifest.json @@ -1,49 +1,13 @@ { "assets": [ { - "id": "soundtrack-intro-opus", - "fileName": "nightdrive-intro.opus", - "sha256": "561c05cea71d0920ec323375644039007b91b0a4c346dc663e233ad42d6ab490", - "mediaType": "audio/ogg", - "width": null, - "height": null, - "durationMs": 56064, - "rightsHolder": "Luca Pîslaru", - "license": "All rights reserved", - "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" - }, - { - "id": "soundtrack-intro-mp3", - "fileName": "nightdrive-intro.mp3", - "sha256": "008794ebd7f545c32f19aab4ada6aba40d9fc3341830a66e77f21d87a41c009b", + "id": "soundtrack-afterglow-mp3", + "fileName": "nightdrive-afterglow.mp3", + "sha256": "685687ad131d61c34e42bcee29af16a4c8180d788c67d75f2331fa9082a3a44e", "mediaType": "audio/mpeg", "width": null, "height": null, - "durationMs": 56064, - "rightsHolder": "Luca Pîslaru", - "license": "All rights reserved", - "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" - }, - { - "id": "soundtrack-main-opus", - "fileName": "nightdrive-main.opus", - "sha256": "81933fc8e589428ef07fb265572a79975f183848b6a23616cb289e33fe3fb88c", - "mediaType": "audio/ogg", - "width": null, - "height": null, - "durationMs": 178605, - "rightsHolder": "Luca Pîslaru", - "license": "All rights reserved", - "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" - }, - { - "id": "soundtrack-main-mp3", - "fileName": "nightdrive-main.mp3", - "sha256": "796de8247c4cb8012d7c6883b534112ac09a40255a2c33cd68acf0f87c066659", - "mediaType": "audio/mpeg", - "width": null, - "height": null, - "durationMs": 178605, + "durationMs": 25116, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" @@ -61,13 +25,13 @@ "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" }, { - "id": "soundtrack-afterglow-mp3", - "fileName": "nightdrive-afterglow.mp3", - "sha256": "685687ad131d61c34e42bcee29af16a4c8180d788c67d75f2331fa9082a3a44e", + "id": "soundtrack-drive-lift-mp3", + "fileName": "nightdrive-drive-lift.mp3", + "sha256": "edcb802938d3d1e53392f9ccd2e153f72a24b494c205ce6f556d73de5fe709ec", "mediaType": "audio/mpeg", "width": null, "height": null, - "durationMs": 25116, + "durationMs": 11163, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" @@ -85,16 +49,136 @@ "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" }, { - "id": "soundtrack-drive-lift-mp3", - "fileName": "nightdrive-drive-lift.mp3", - "sha256": "edcb802938d3d1e53392f9ccd2e153f72a24b494c205ce6f556d73de5fe709ec", + "id": "soundtrack-intro-mp3", + "fileName": "nightdrive-intro.mp3", + "sha256": "008794ebd7f545c32f19aab4ada6aba40d9fc3341830a66e77f21d87a41c009b", "mediaType": "audio/mpeg", "width": null, "height": null, - "durationMs": 11163, + "durationMs": 56064, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" + }, + { + "id": "soundtrack-intro-opus", + "fileName": "nightdrive-intro.opus", + "sha256": "561c05cea71d0920ec323375644039007b91b0a4c346dc663e233ad42d6ab490", + "mediaType": "audio/ogg", + "width": null, + "height": null, + "durationMs": 56064, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" + }, + { + "id": "soundtrack-main-mp3", + "fileName": "nightdrive-main.mp3", + "sha256": "796de8247c4cb8012d7c6883b534112ac09a40255a2c33cd68acf0f87c066659", + "mediaType": "audio/mpeg", + "width": null, + "height": null, + "durationMs": 178605, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" + }, + { + "id": "soundtrack-main-opus", + "fileName": "nightdrive-main.opus", + "sha256": "81933fc8e589428ef07fb265572a79975f183848b6a23616cb289e33fe3fb88c", + "mediaType": "audio/ogg", + "width": null, + "height": null, + "durationMs": 178605, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5" + }, + { + "id": "currency-converter-interface", + "fileName": "project-currency-converter.webp", + "sha256": "57159ae72a075ba5317217233c3c0eb210a485c51b5f2c37b5ed156d5ea37efb", + "mediaType": "image/webp", + "width": 1440, + "height": 820, + "durationMs": null, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Luca Pîslaru" + }, + { + "id": "medication-mobile-data-settings", + "fileName": "project-medication-data-settings.webp", + "sha256": "d951dd723047b737ab6a7da46b82f3f3f3aca7ec8853314f74ac57e1950ce1ba", + "mediaType": "image/webp", + "width": 720, + "height": 1280, + "durationMs": null, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Luca Pîslaru" + }, + { + "id": "medication-mobile-logs", + "fileName": "project-medication-logs.webp", + "sha256": "d34b2d41855e6dca4762fb9fc7728b64a94fef60de36149e465ce50821bc1dc7", + "mediaType": "image/webp", + "width": 720, + "height": 1280, + "durationMs": null, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Luca Pîslaru" + }, + { + "id": "medication-mobile-notification-settings", + "fileName": "project-medication-notification-settings.webp", + "sha256": "d2fe0076d6581e76c361cb656350c58f6685f6fe91c5b6c5871e60977e8d5be7", + "mediaType": "image/webp", + "width": 720, + "height": 1280, + "durationMs": null, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Luca Pîslaru" + }, + { + "id": "opinion-final-distribution-chart", + "fileName": "project-opinion-final-distribution.webp", + "sha256": "2f2f6d64b8bb4f79ecbce1a8bc486cf7bbdf4087eb4f10062a6fe889246dbdf1", + "mediaType": "image/webp", + "width": 571, + "height": 455, + "durationMs": null, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Luca Pîslaru" + }, + { + "id": "opinion-polarization-chart", + "fileName": "project-opinion-polarization.webp", + "sha256": "77f0d5076769e6e45b566c5f322d8f535686f9e9f928e18fe4c16b3dac989c43", + "mediaType": "image/webp", + "width": 584, + "height": 455, + "durationMs": null, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Luca Pîslaru" + }, + { + "id": "social-preview-nightdrive", + "fileName": "project-social-preview.webp", + "sha256": "ed470760643ef3d413b6ca4a0e1434771fcc9b61b6ac34011feae77a13f2daca", + "mediaType": "image/webp", + "width": 1200, + "height": 630, + "durationMs": null, + "rightsHolder": "Luca Pîslaru", + "license": "All rights reserved", + "attribution": "Luca Pîslaru" } ] } diff --git a/content/public/asset-recipes.json b/content/public/asset-recipes.json index 0b76f60..fa3073d 100644 --- a/content/public/asset-recipes.json +++ b/content/public/asset-recipes.json @@ -4,13 +4,9 @@ { "id": "medication-mobile-logs", "kind": "image", "output": "project-medication-logs.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, { "id": "medication-mobile-data-settings", "kind": "image", "output": "project-medication-data-settings.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, { "id": "medication-mobile-notification-settings", "kind": "image", "output": "project-medication-notification-settings.webp", "maxWidth": 720, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, - { "id": "footy-detection-frame", "kind": "image", "output": "project-footy-detection.webp", "maxWidth": 1600, "rightsHolder": "Third-party footage and Luca Pîslaru", "license": "Publication requires confirmation", "attribution": "Detection overlay by Luca Pîslaru" }, { "id": "opinion-polarization-chart", "kind": "image", "output": "project-opinion-polarization.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, - { "id": "opinion-cascade-thresholds-chart", "kind": "image", "output": "project-opinion-cascade-thresholds.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, - { "id": "opinion-average-degree-chart", "kind": "image", "output": "project-opinion-average-degree.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, - { "id": "opinion-revolt-frequency-chart", "kind": "image", "output": "project-opinion-revolt-frequency.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, { "id": "opinion-final-distribution-chart", "kind": "image", "output": "project-opinion-final-distribution.webp", "maxWidth": 1000, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, - { "id": "private-game-loop", "kind": "video", "output": "project-private-game-loop.webm", "maxWidth": 1280, "startSeconds": 3, "durationSeconds": 5, "rightsHolder": "Rights holder confirmation pending", "license": "Publication requires confirmation", "attribution": "Prototype contribution by Luca Pîslaru" }, - { "id": "currency-converter-interface", "kind": "image", "output": "project-currency-converter.webp", "maxWidth": 1440, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" } + { "id": "currency-converter-interface", "kind": "image", "output": "project-currency-converter.webp", "maxWidth": 1440, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" }, + { "id": "social-preview-nightdrive", "kind": "image", "output": "project-social-preview.webp", "maxWidth": 1200, "rightsHolder": "Luca Pîslaru", "license": "All rights reserved", "attribution": "Luca Pîslaru" } ] } diff --git a/inputs/prepared-assets/project-currency-converter.webp b/inputs/prepared-assets/project-currency-converter.webp new file mode 100644 index 0000000..4ee8076 Binary files /dev/null and b/inputs/prepared-assets/project-currency-converter.webp differ diff --git a/inputs/prepared-assets/project-medication-data-settings.webp b/inputs/prepared-assets/project-medication-data-settings.webp new file mode 100644 index 0000000..a2e95ac Binary files /dev/null and b/inputs/prepared-assets/project-medication-data-settings.webp differ diff --git a/inputs/prepared-assets/project-medication-logs.webp b/inputs/prepared-assets/project-medication-logs.webp new file mode 100644 index 0000000..e28563a Binary files /dev/null and b/inputs/prepared-assets/project-medication-logs.webp differ diff --git a/inputs/prepared-assets/project-medication-notification-settings.webp b/inputs/prepared-assets/project-medication-notification-settings.webp new file mode 100644 index 0000000..d1fe0a5 Binary files /dev/null and b/inputs/prepared-assets/project-medication-notification-settings.webp differ diff --git a/inputs/prepared-assets/project-opinion-final-distribution.webp b/inputs/prepared-assets/project-opinion-final-distribution.webp new file mode 100644 index 0000000..885d313 Binary files /dev/null and b/inputs/prepared-assets/project-opinion-final-distribution.webp differ diff --git a/inputs/prepared-assets/project-opinion-polarization.webp b/inputs/prepared-assets/project-opinion-polarization.webp new file mode 100644 index 0000000..67bfc4d Binary files /dev/null and b/inputs/prepared-assets/project-opinion-polarization.webp differ diff --git a/inputs/prepared-assets/project-social-preview.webp b/inputs/prepared-assets/project-social-preview.webp new file mode 100644 index 0000000..bc1f52d Binary files /dev/null and b/inputs/prepared-assets/project-social-preview.webp differ diff --git a/scripts/generate-social-preview.mjs b/scripts/generate-social-preview.mjs new file mode 100644 index 0000000..5312f46 --- /dev/null +++ b/scripts/generate-social-preview.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import { mkdir, realpath } from 'node:fs/promises'; +import { dirname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import sharp from 'sharp'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const permittedPrivateRoot = resolve(repositoryRoot, 'content/private'); + +const fail = () => { + throw new Error('SOCIAL-PREVIEW-PATH-001'); +}; + +const canonicalizeForWrite = async (path) => { + const absolute = resolve(path); + let cursor = dirname(absolute); + const suffix = []; + while (true) { + try { + return resolve(await realpath(cursor), ...suffix, absolute.slice(dirname(absolute).length + 1)); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) fail(); + const parent = dirname(cursor); + if (parent === cursor) fail(); + suffix.unshift(cursor.slice(parent.length + 1)); + cursor = parent; + } + } +}; + +const isInside = (path, root) => path === root || path.startsWith(`${root}${sep}`); + +export const generateSocialPreview = async (outputPath) => { + if (typeof outputPath !== 'string' || outputPath.trim() === '') fail(); + const output = await canonicalizeForWrite(outputPath); + const privateRoot = await canonicalizeForWrite(permittedPrivateRoot); + if (!isInside(output, privateRoot) && !isInside(output, '/tmp')) fail(); + + const svg = Buffer.from(` + + + + + + + + + + Luca Pîslaru + System Administrator & Full-Stack Developer + + + + `); + + await mkdir(dirname(output), { recursive: true }); + await sharp(svg, { density: 72 }) + .flatten({ background: '#070910' }) + .png({ compressionLevel: 9, adaptiveFiltering: false, palette: false }) + .toFile(output); + return output; +}; + +const parseOutput = (argv) => { + if (argv.length !== 2 || argv[0] !== '--output') fail(); + return argv[1]; +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + generateSocialPreview(parseOutput(process.argv.slice(2))).catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : 'SOCIAL-PREVIEW-001'}\n`); + process.exitCode = 1; + }); +} diff --git a/tests/integration/prepare-assets.test.ts b/tests/integration/prepare-assets.test.ts index 4f2602d..f85d083 100644 --- a/tests/integration/prepare-assets.test.ts +++ b/tests/integration/prepare-assets.test.ts @@ -16,6 +16,8 @@ import { scanImage, scanText } from '../../scripts/lib/privacy-scan.mjs'; import { mergeAssetRecords } from '../../scripts/lib/merge-assets.mjs'; // @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper. import { prepareAssets, publishPreparedAssets, verifyPreparedAssets } from '../../scripts/prepare-assets.mjs'; +// @ts-expect-error The production script is native ESM and intentionally has no TypeScript runtime wrapper. +import { generateSocialPreview } from '../../scripts/generate-social-preview.mjs'; const execFileAsync = promisify(execFile); interface Candidate { id: string; fileName: string; sha256: string; } @@ -293,6 +295,31 @@ describe('prepared candidate publication', () => { }); describe('deterministic asset preparation', () => { + it('renders the deterministic social preview at 1200×630 as opaque privacy-safe pixels', async () => { + const root = await createRoot(); + const firstPath = join(root, 'social-preview-one.png'); + const secondPath = join(root, 'social-preview-two.png'); + await generateSocialPreview(firstPath); + await generateSocialPreview(secondPath); + + const metadata = await sharp(firstPath).metadata(); + const stats = await sharp(firstPath).stats(); + expect(metadata.width).toBe(1200); + expect(metadata.height).toBe(630); + expect(stats.isOpaque).toBe(true); + expect(await hashFile(secondPath)).toBe(await hashFile(firstPath)); + await expect(scanImage(firstPath, [ + 'private canary identity', + 'private canary domain', + 'private canary medication', + ], { runOcr: true })).resolves.toMatchObject({ width: 1200, height: 630 }); + }, 30_000); + + it('rejects social-preview output paths outside private or temporary storage', async () => { + await expect(generateSocialPreview(join(process.cwd(), 'public', 'unsafe.png'))) + .rejects.toThrow(/SOCIAL-PREVIEW-PATH-001/u); + }); + it('re-encodes images without metadata under generated safe filenames, identically across runs', async () => { const root = await createRoot(); const inputs = await makeInputs(root, baseRecipe());