diff --git a/.eslintrc.js b/.eslintrc.js index 52359fb2..0a4fc28a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,7 +32,7 @@ module.exports = { ], eqeqeq: ["warn", "smart"], "func-style": ["warn"], - "require-await": ["error"], + "require-await": ["warn"], "@typescript-eslint/no-floating-promises": "error", "import/no-default-export": 2, "@typescript-eslint/strict-boolean-expressions": "warn", diff --git a/package-lock.json b/package-lock.json index c13a68e7..917eb3dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,11 +28,13 @@ "pino": "^8.17.2", "pino-pretty": "^10.3.1", "pm2": "^5.3.1", + "promisify-child-process": "^4.1.2", "readline-sync": "^1.4.10", "redis": "^4.6.12", "reflect-metadata": "^0.2.1", "sharp": "^0.33.2", "superagent": "^8.1.2", + "tempy": "^3.1.0", "ts-node": "^10.9.2", "tsyringe": "^4.8.0", "twitter-api-client": "^1.6.1", @@ -6949,7 +6951,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "dev": true, "dependencies": { "type-fest": "^1.0.1" }, @@ -6964,7 +6965,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, "engines": { "node": ">=10" }, @@ -15688,6 +15688,14 @@ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" }, + "node_modules/promisify-child-process": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/promisify-child-process/-/promisify-child-process-4.1.2.tgz", + "integrity": "sha512-APnkIgmaHNJpkAn7k+CrJSi9WMuff5ctYFbD0CO2XIPkM8yO7d/ShouU2clywbpHV/DUsyc4bpJCsNgddNtx4g==", + "engines": { + "node": ">=8" + } + }, "node_modules/promptly": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/promptly/-/promptly-2.2.0.tgz", @@ -17618,22 +17626,20 @@ } }, "node_modules/temp-dir": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", - "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", "engines": { - "node": ">=8" + "node": ">=14.16" } }, "node_modules/tempy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.0.0.tgz", - "integrity": "sha512-B2I9X7+o2wOaW4r/CWMkpOO9mdiTRCxXNgob6iGvPmfPWgH/KyUD6Uy5crtWBxIBe3YrNZKR2lSzv1JJKWD4vA==", - "dev": true, + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", + "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", "dependencies": { "is-stream": "^3.0.0", - "temp-dir": "^2.0.0", + "temp-dir": "^3.0.0", "type-fest": "^2.12.2", "unique-string": "^3.0.0" }, @@ -17648,7 +17654,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -17660,7 +17665,6 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, "engines": { "node": ">=12.20" }, @@ -18227,7 +18231,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "dev": true, "dependencies": { "crypto-random-string": "^4.0.0" }, diff --git a/package.json b/package.json index c1827981..451dabb4 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,13 @@ "pino": "^8.17.2", "pino-pretty": "^10.3.1", "pm2": "^5.3.1", + "promisify-child-process": "^4.1.2", "readline-sync": "^1.4.10", "redis": "^4.6.12", "reflect-metadata": "^0.2.1", "sharp": "^0.33.2", "superagent": "^8.1.2", + "tempy": "^3.1.0", "ts-node": "^10.9.2", "tsyringe": "^4.8.0", "twitter-api-client": "^1.6.1", diff --git a/src/fs_repository/index.ts b/src/fs_repository/index.ts index a6587302..ac9c6e89 100644 --- a/src/fs_repository/index.ts +++ b/src/fs_repository/index.ts @@ -4,6 +4,7 @@ import fs from "fs/promises"; import path from "path"; import { injectable } from "tsyringe"; import sharp from "sharp"; +import { updateImageDescription } from "../utils/exiftool"; @injectable() export class FilesystemRepository { @@ -55,4 +56,9 @@ export class FilesystemRepository { return Promise.resolve(); } } + + async updateImageDescription(baseFilename: string, description: string) { + const originalFile = this.photoPath(baseFilename); + await updateImageDescription(originalFile, description); + } } diff --git a/src/routes/images/index.ts b/src/routes/images/index.ts index 3272fb8c..84794770 100644 --- a/src/routes/images/index.ts +++ b/src/routes/images/index.ts @@ -13,17 +13,15 @@ import { asyncWrapper } from "../async"; import { ImageGet } from "./get"; import { SingleImageDelete } from "./delete"; import { Cache } from "../cache"; -import { - handler as singleImagePutHandler, - bodySchema as singleImagePutSchema, -} from "./put"; +import { ImagePut, bodySchema as singleImagePutSchema } from "./put"; @injectable() export class ImageRouter { constructor( private cache: Cache, private imageGet: ImageGet, - private singleDelete: SingleImageDelete + private singleDelete: SingleImageDelete, + private imagePut: ImagePut, ) {} readonly routes = express @@ -37,7 +35,7 @@ export class ImageRouter { validateBodySchema({ schema: singleImagePutSchema }), validateRole({ role: Roles.EDITOR }), validateId(), - asyncWrapper(singleImagePutHandler) + asyncWrapper(this.imagePut.handler), ) // single image metadata @@ -47,7 +45,7 @@ export class ImageRouter { "/:id", validateAdmin(), validateId(), - asyncWrapper(this.singleDelete.handler) + asyncWrapper(this.singleDelete.handler), ) // single image, cached @@ -56,6 +54,6 @@ export class ImageRouter { this.cache.middleware({ callNextWhenCacheable: false }), validateId(), validateQuerySchema({ schema: this.imageGet.querySchema }), - asyncWrapper(this.imageGet.binary) + asyncWrapper(this.imageGet.binary), ); } diff --git a/src/routes/images/put.ts b/src/routes/images/put.ts index 7549e4fd..3ed77955 100644 --- a/src/routes/images/put.ts +++ b/src/routes/images/put.ts @@ -3,25 +3,42 @@ import { Image, ImageDocument } from "../../database"; import { logger } from "../../utils"; import { PutImageBody, putImageBodySchema } from "../contract"; import { lenientImageSchema } from "./localTypes"; +import { FilesystemRepository } from "../../fs_repository"; +import { injectable } from "tsyringe"; export const bodySchema = putImageBodySchema; -export const handler = async (req: Request, res: Response) => { - const body = req.validated as PutImageBody; +@injectable() +export class ImagePut { + constructor(private fsRepository: FilesystemRepository) {} - const { id } = req.params; - logger.trace({ id, body }, "put single image"); + handler = async (req: Request, res: Response) => { + const body = req.validated as PutImageBody; - const modified: Partial = { ...body }; - if (modified.description != null) { - modified.description_from_exif = false; - } + const { id } = req.params; + logger.trace({ id, body }, "put single image"); - const result = await Image.findByIdAndUpdate(id, modified, { new: true }); - if (result != null) { - res.send(lenientImageSchema.parse(result)); - } else { - // not found - res.sendStatus(404); - } -}; + const modified: Partial = { ...body }; + if (modified.description != null) { + modified.description_from_exif = false; + } + + const oldValue = await Image.findByIdAndUpdate(id, modified); + if (oldValue != null) { + const newValue = await Image.findById(id); + if ( + oldValue.description !== newValue?.description && + newValue?.description != null + ) { + await this.fsRepository.updateImageDescription( + id, + newValue?.description, + ); + } + res.send(lenientImageSchema.parse(newValue)); + } else { + // not found + res.sendStatus(404); + } + }; +} diff --git a/src/utils/exiftool.test.ts b/src/utils/exiftool.test.ts new file mode 100644 index 00000000..a13d2a84 --- /dev/null +++ b/src/utils/exiftool.test.ts @@ -0,0 +1,28 @@ +import fs from "fs/promises"; +import path from "path"; +import { updateImageDescription } from "./exiftool"; +import { load } from "exifreader"; + +describe("exiftool", () => { + const imageFile = path.resolve( + __dirname, + "../../test_resources/test_DSC07588_with_description.jpg", + ); + let tempFile: string; + + beforeEach(async () => { + const { temporaryFile } = await import("tempy"); + tempFile = temporaryFile({ name: "exiftool_test" }); + await fs.copyFile(imageFile, tempFile); + }); + + it("behaves", async () => { + await updateImageDescription(tempFile, "new description"); + const tags = await load(tempFile, { expanded: true }); + expect(tags.exif?.ImageDescription).toEqual("new description"); + }); + + afterEach(async () => { + await fs.unlink(tempFile); + }); +}); diff --git a/src/utils/exiftool.ts b/src/utils/exiftool.ts new file mode 100644 index 00000000..390be34f --- /dev/null +++ b/src/utils/exiftool.ts @@ -0,0 +1,20 @@ +import { execFile } from "promisify-child-process"; +import fs from "fs/promises"; + +export const updateImageDescription = async ( + file: string, + description: string, +) => { + const { temporaryWrite, temporaryFile } = await import("tempy"); + const argFile = await temporaryWrite(description, { name: "args" }); + const newFile = temporaryFile({ name: "newFile" }); + await execFile("exiftool", [ + `-mwg:Description<${argFile}`, + file, + "-o", + newFile, + ]); + await fs.unlink(argFile); + await fs.copyFile(newFile, file); + await fs.unlink(newFile); +};