diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index f5fae7b303a..cf25285cb87 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -3148,7 +3148,21 @@ postRouteWithRWTransaction(apiRouter, "/images", async (req, res, trx) => { } } - const { asBlob, dimensions } = await processImageContent(content, type) + const { asBlob, dimensions, hash } = await processImageContent( + content, + type + ) + + const collision = await trx("images") + .where("hash", "=", hash) + .first() + + if (collision) { + return { + success: false, + error: `An image with this content already exists (filename: ${collision.filename})`, + } + } const cloudflareId = await uploadToCloudflare(filename, asBlob) @@ -3164,10 +3178,9 @@ postRouteWithRWTransaction(apiRouter, "/images", async (req, res, trx) => { originalWidth: dimensions.width, originalHeight: dimensions.height, cloudflareId, - // TODO: make defaultAlt nullable - defaultAlt: "Default alt text", updatedAt: new Date().getTime(), userId: res.locals.user.id, + hash, }) const image = await db.getCloudflareImage(trx, filename) @@ -3204,10 +3217,23 @@ putRouteWithRWTransaction(apiRouter, "/images/:id", async (req, res, trx) => { ) } - await deleteFromCloudflare(originalCloudflareId) - const { type, content } = validateImagePayload(req.body) - const { asBlob, dimensions } = await processImageContent(content, type) + const { asBlob, dimensions, hash } = await processImageContent( + content, + type + ) + const collision = await trx("images") + .where("hash", "=", hash) + .first() + + if (collision) { + return { + success: false, + error: `An image with this content already exists (filename: ${collision.filename})`, + } + } + + await deleteFromCloudflare(originalCloudflareId) const newCloudflareId = await uploadToCloudflare(originalFilename, asBlob) if (!newCloudflareId) { @@ -3218,6 +3244,7 @@ putRouteWithRWTransaction(apiRouter, "/images/:id", async (req, res, trx) => { originalWidth: dimensions.width, originalHeight: dimensions.height, updatedAt: new Date().getTime(), + hash, }) const updated = await db.getCloudflareImage(trx, originalFilename) diff --git a/adminSiteServer/imagesHelpers.ts b/adminSiteServer/imagesHelpers.ts index d6ab707121c..d2e90bea7e6 100644 --- a/adminSiteServer/imagesHelpers.ts +++ b/adminSiteServer/imagesHelpers.ts @@ -1,3 +1,4 @@ +import crypto from "crypto" import { JsonError } from "@ourworldindata/types" import sharp from "sharp" import { @@ -32,9 +33,11 @@ export async function processImageContent( ): Promise<{ asBlob: Blob dimensions: { width: number; height: number } + hash: string }> { const stripped = content.slice(content.indexOf(",") + 1) const asBuffer = Buffer.from(stripped, "base64") + const hash = crypto.createHash("sha256").update(asBuffer).digest("hex") const asBlob = new Blob([asBuffer], { type }) const { width, height } = await sharp(asBuffer) .metadata() @@ -50,6 +53,7 @@ export async function processImageContent( width, height, }, + hash, } } diff --git a/db/migration/1731360326761-CloudflareImages.ts b/db/migration/1731360326761-CloudflareImages.ts index 1a1adb47530..fc93ecd61e5 100644 --- a/db/migration/1731360326761-CloudflareImages.ts +++ b/db/migration/1731360326761-CloudflareImages.ts @@ -5,6 +5,8 @@ export class CloudflareImages1731360326761 implements MigrationInterface { await queryRunner.query(`-- sql ALTER TABLE images ADD COLUMN cloudflareId VARCHAR(255) NULL, + ADD CONSTRAINT images_cloudflareId_unique UNIQUE (cloudflareId), + ADD COLUMN hash VARCHAR(255) NULL, MODIFY COLUMN googleId VARCHAR(255) NULL, MODIFY COLUMN defaultAlt VARCHAR(1600) NULL;`) @@ -19,7 +21,8 @@ export class CloudflareImages1731360326761 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { await queryRunner.query(`-- sql ALTER TABLE images - DROP COLUMN cloudflareId + DROP COLUMN cloudflareId, + DROP COLUMN hash `) await queryRunner.query(`-- sql diff --git a/packages/@ourworldindata/types/src/dbTypes/Images.ts b/packages/@ourworldindata/types/src/dbTypes/Images.ts index e60157f9983..a17431a5a44 100644 --- a/packages/@ourworldindata/types/src/dbTypes/Images.ts +++ b/packages/@ourworldindata/types/src/dbTypes/Images.ts @@ -2,7 +2,7 @@ import { DbPlainUser } from "./Users.js" export const ImagesTableName = "images" export interface DbInsertImage { - googleId: string + googleId: string | null defaultAlt: string filename: string id?: number @@ -10,6 +10,7 @@ export interface DbInsertImage { originalHeight?: number | null updatedAt?: string | null // MySQL Date objects round to the nearest second, whereas Google includes milliseconds so we store as an epoch of type bigint to avoid any conversion issues cloudflareId?: string | null + hash?: string | null userId?: number | null } export type DbRawImage = Required