From 41a495e5cf5800f1e5200416eb1d8149ddd62db0 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 20:17:03 +0200 Subject: [PATCH] Read EXIF with a parser somebody maintains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `exifr` was last published in 2022. It is the one thing in the image that opens a file somebody else wrote using code nobody maintains any more, and it is loaded lazily in the metadata route precisely because nobody was sure of it. `exif-reader` replaces it: maintained alongside sharp, and it does nothing but walk a TIFF-shaped block with a bounds check on every read. The block itself needs no file access — sharp already opens the image to report its dimensions and hands the raw EXIF block back with them — so a photograph's details now come from one pass instead of two, and `exifr` leaves the dependencies. `utils/exifDetails.js` holds what the block is read for: when the picture was taken, the camera and lens, the exposure, the orientation, the place. Written as its own file because the metadata route is about what kind of thing a file is, and this is about what a photograph says of itself. ## Checks `exif-details.test.js`, 20 tests over the reading itself — a block that is not EXIF, a truncated one, dates in the three shapes cameras write them, a rational that divides by zero, coordinates in both hemispheres. Making the reader answer nothing turns six of them red. `metadata.test.js`, 13 tests through the route. Whole backend suite: 2 509 passed, 2 failed — the two that fail on `main` on its own. `capabilities.js`, `documentText.js` and `pdfTextExtract.js` were in this batch at first and are not any more: probing which optional tools the machine has, and reading a document's text, are their own ground, and bringing them here turned three of `main`'s own tests red. --- backend/package.json | 2 +- backend/src/routes/metadata.js | 192 ++++++----- backend/src/utils/exifDetails.js | 143 +++++++++ backend/tests/routes/metadata.test.js | 277 ++++++++++++++++ backend/tests/utils/exif-details.test.js | 297 ++++++++++++++++++ frontend/src/config/media.js | 15 +- .../src/plugins/preview/useMediaTracks.js | 2 +- package-lock.json | 10 +- 8 files changed, 815 insertions(+), 123 deletions(-) create mode 100644 backend/src/utils/exifDetails.js create mode 100644 backend/tests/routes/metadata.test.js create mode 100644 backend/tests/utils/exif-details.test.js diff --git a/backend/package.json b/backend/package.json index d32f44a57..bfd6f12e4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,7 +30,7 @@ "connect-sqlite3": "^0.9.16", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "exifr": "^7.1.3", + "exif-reader": "^2.0.3", "exiftool-vendored": "^34.1.0", "express": "^5.2.1", "express-openid-connect": "^2.19.2", diff --git a/backend/src/routes/metadata.js b/backend/src/routes/metadata.js index 2f1f570fe..293098295 100644 --- a/backend/src/routes/metadata.js +++ b/backend/src/routes/metadata.js @@ -2,10 +2,10 @@ const express = require('express'); const fs = require('fs/promises'); const path = require('path'); const sharp = require('sharp'); -const ffmpeg = require('fluent-ffmpeg'); -let exifr = null; +const ffmpegRunner = require('../services/ffmpegRunner'); const { normalizeRelativePath } = require('../utils/pathUtils'); +const { readExifDetails } = require('../utils/exifDetails'); const { extensions } = require('../config/index'); const { resolvePathWithAccess } = require('../services/accessManager'); const logger = require('../utils/logger'); @@ -14,39 +14,17 @@ const { ValidationError, ForbiddenError, NotFoundError } = require('../errors/Ap const router = express.Router(); -// Optional: try to require exifr only when route is hit -const loadExifr = () => { - if (exifr) return exifr; - try { - // eslint-disable-next-line global-require - exifr = require('exifr'); - } catch (e) { - exifr = null; - } - return exifr; +const probeVideo = async (filePath) => { + const data = await ffmpegRunner.probe(filePath); + if (!data) return null; + const stream = (data.streams || []).find((s) => s.width && s.height) || {}; + return { + width: Number(stream.width) || null, + height: Number(stream.height) || null, + duration: Number(data.format?.duration) || null, + }; }; -const probeVideo = (filePath) => - new Promise((resolve) => { - ffmpeg.ffprobe(filePath, (error, data) => { - if (error || !data) { - resolve(null); - return; - } - try { - const stream = (data.streams || []).find((s) => s.width && s.height) || {}; - const duration = Number(data.format?.duration) || null; - resolve({ - width: Number(stream.width) || null, - height: Number(stream.height) || null, - duration, - }); - } catch (_) { - resolve(null); - } - }); - }); - const sumDirectory = async (dirPath, limit = 200000) => { const stack = [dirPath]; let totalSize = 0; @@ -81,6 +59,69 @@ const sumDirectory = async (dirPath, limit = 200000) => { return { totalSize, fileCount, dirCount, truncated: visited > limit }; }; +/** + * What a picture says about itself. + * + * Two readings, asked separately and both allowed to fail: a file that cannot + * be read as an image still has a name, a size and a date, which is what + * somebody looking at a damaged file most needs. Losing the whole answer over + * a broken header would be the wrong trade. + * + * One read of the file covers both, because sharp hands back the EXIF block + * along with the dimensions it was opened for. + */ +const readImageDetails = async (absolutePath, extension) => { + const details = {}; + let metadata = null; + + try { + metadata = await sharp(absolutePath).metadata(); + details.width = metadata.width || null; + details.height = metadata.height || null; + details.orientation = metadata.orientation || null; + } catch (e) { + logger.debug({ err: e }, 'sharp.metadata failed'); + } + + try { + const exif = await readExifDetails(absolutePath, metadata, extension); + if (exif) Object.assign(details, exif); + } catch (e) { + logger.debug({ err: e }, 'EXIF parse failed'); + } + + return Object.keys(details).length > 0 ? details : null; +}; + +/** What the filesystem alone knows about a path. */ +const describeEntry = (logicalPath, stats) => { + const extension = path.extname(logicalPath).slice(1).toLowerCase(); + + return { + path: logicalPath, + name: path.basename(logicalPath), + kind: stats.isDirectory() ? 'directory' : extension || 'unknown', + size: stats.size, + dateModified: stats.mtime, + dateCreated: stats.birthtime, + }; +}; + +/** The file's own details, when its kind has any to give. */ +const readKindDetails = async (absolutePath, extension) => { + if (extensions.images.includes(extension)) { + const image = await readImageDetails(absolutePath, extension); + return image ? { image } : {}; + } + + if (extensions.videos.includes(extension)) { + const video = await probeVideo(absolutePath); + return video ? { video } : {}; + } + + return {}; +}; + router.get( '/metadata/{*splat}', asyncHandler(async (req, res) => { @@ -95,7 +136,7 @@ router.get( let resolved; try { ({ accessInfo, resolved } = await resolvePathWithAccess(context, relativePath)); - } catch (error) { + } catch (_) { throw new NotFoundError('Path not found.'); } @@ -104,82 +145,29 @@ router.get( throw new ForbiddenError(accessInfo?.denialReason || 'Path is not accessible.'); } - const absolutePath = resolved.absolutePath; - const logicalPath = resolved.relativePath; - const stats = await fs.stat(absolutePath); - const name = path.basename(logicalPath); - const ext = path.extname(logicalPath).slice(1).toLowerCase(); - - const base = { - path: logicalPath, - name, - kind: stats.isDirectory() ? 'directory' : ext || 'unknown', - size: stats.size, - dateModified: stats.mtime, - dateCreated: stats.birthtime, - }; - - const payload = { ...base }; - - if (stats.isDirectory()) { - payload.directory = await sumDirectory(absolutePath); - return res.json(payload); - } - - // File-specific metadata - if (extensions.images.includes(ext)) { - try { - const meta = await sharp(absolutePath).metadata(); - payload.image = { - width: meta.width || null, - height: meta.height || null, - orientation: meta.orientation || null, - }; - } catch (e) { - logger.debug({ err: e }, 'sharp.metadata failed'); - } - - try { - const ex = loadExifr() - ? await exifr.parse(absolutePath, { - tiff: true, - ifd0: true, - exif: true, - gps: true, - iptc: true, - }) - : null; - if (ex) { - payload.image = Object.assign(payload.image || {}, { - cameraMake: ex.Make || ex.make || null, - cameraModel: ex.Model || ex.model || null, - lensModel: ex.LensModel || ex.lensModel || null, - software: ex.Software || null, - dateTaken: ex.DateTimeOriginal || ex.CreateDate || ex.ModifyDate || null, - gps: - ex.latitude && ex.longitude - ? { lat: ex.latitude, lon: ex.longitude } - : ex.GPSLatitude && ex.GPSLongitude - ? { lat: ex.GPSLatitude, lon: ex.GPSLongitude } - : null, - }); - } - } catch (e) { - logger.debug({ err: e }, 'EXIF parse failed'); - } - } else if (extensions.videos.includes(ext)) { - const v = await probeVideo(absolutePath); - if (v) payload.video = v; - } + const { absolutePath, relativePath: logicalPath } = resolved; + // Resolving a path does not require it to exist, so this is where a file + // that has just been deleted is discovered. Left unhandled it left the + // details panel answering 500 for the ordinary case of asking about + // something that is gone. + let stats; try { - return res.json(payload); + stats = await fs.stat(absolutePath); } catch (error) { if (error.code === 'ENOENT') { throw new NotFoundError('Path not found.'); } throw error; } + + const base = describeEntry(logicalPath, stats); + + if (stats.isDirectory()) { + return res.json({ ...base, directory: await sumDirectory(absolutePath) }); + } + + return res.json({ ...base, ...(await readKindDetails(absolutePath, base.kind)) }); }) ); diff --git a/backend/src/utils/exifDetails.js b/backend/src/utils/exifDetails.js new file mode 100644 index 000000000..4d561819b --- /dev/null +++ b/backend/src/utils/exifDetails.js @@ -0,0 +1,143 @@ +const fs = require('fs/promises'); +const exifReader = require('exif-reader'); + +/** + * What a photograph says about itself. + * + * The EXIF block is read by `exif-reader`, which is maintained alongside sharp + * and does nothing but walk a TIFF-shaped block with a bounds check on every + * read. It replaced `exifr`, a parser that stopped being published in 2022 and + * was the one thing in the image that opened somebody else's file with code + * nobody maintains any more. + * + * The block itself does not need a parser: sharp already opens the file to + * report its dimensions, and hands the raw block back with them. So a JPEG, a + * PNG, a WebP, an AVIF or a HEIC costs one read of the file, not two — and the + * container is picked apart by libvips rather than by us. + */ + +/** + * TIFF is the exception: the file *is* the block, so sharp reports no separate + * EXIF and there is nothing to hand over. The file is read instead, and given + * to the same parser, which accepts a bare TIFF header. + * + * With a ceiling, because this is one file read inside a request: libtiff + * writes its directory *after* the image data, so the tags of a large scan sit + * at the far end of it and only reading the whole file reaches them. Above the + * ceiling the picture keeps its dimensions and loses the camera's name, which + * is the right way round — a details panel must not read 200 MB to fill six + * lines. + */ +const TIFF_READ_MAX_BYTES = 32 * 1024 * 1024; + +const readTiffBlock = async (absolutePath) => { + const stats = await fs.stat(absolutePath); + if (!stats.isFile() || stats.size > TIFF_READ_MAX_BYTES) return null; + return fs.readFile(absolutePath); +}; + +/** Where the EXIF block of a file already described by sharp is to be found. */ +const readExifBlock = async (absolutePath, metadata, extension) => { + if (metadata?.exif) return metadata.exif; + const isTiff = metadata?.format === 'tiff' || extension === 'tif' || extension === 'tiff'; + return isTiff ? readTiffBlock(absolutePath) : null; +}; + +/** + * What each detail is called in an EXIF block, in the order to look. + * + * Cameras disagree about which date they write, and the block is split into + * directories — the picture's own (`Image`), the camera's (`Photo`) — so every + * field is several places rather than one. A table rather than a chain of + * `||`, which is what it plainly is and what makes adding a camera's spelling + * a one-line change. + */ +const EXIF_FIELDS = { + cameraMake: [['Image', 'Make']], + cameraModel: [['Image', 'Model']], + lensModel: [['Photo', 'LensModel']], + software: [['Image', 'Software']], + dateTaken: [ + ['Photo', 'DateTimeOriginal'], + ['Photo', 'DateTimeDigitized'], + ['Image', 'DateTime'], + ], +}; + +/** + * A moment with no timezone in it. + * + * EXIF records the wall clock the camera showed and says nothing about where + * that was, so the parser reads it as UTC — and a browser then shifts it by + * its own offset and shows an hour the photograph was not taken at. Sent + * without a zone, it is read back as local time wherever it is displayed, + * which is the hour written on the camera. + */ +const withoutTimezone = (value) => { + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return typeof value === 'string' ? value : null; + } + const pad = (n, width = 2) => String(n).padStart(width, '0'); + return ( + `${pad(value.getUTCFullYear(), 4)}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}` + + `T${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}` + ); +}; + +/** Degrees, minutes and seconds, as the one number a map needs. */ +const toDecimalDegrees = (dms, ref) => { + if (!Array.isArray(dms) || dms.length === 0) return null; + const [degrees = 0, minutes = 0, seconds = 0] = dms.map(Number); + if (![degrees, minutes, seconds].every(Number.isFinite)) return null; + const magnitude = Math.abs(degrees) + Math.abs(minutes) / 60 + Math.abs(seconds) / 3600; + const southOrWest = ref === 'S' || ref === 'W'; + return southOrWest ? -magnitude : magnitude; +}; + +/** Where a photograph was taken, when it says so at all. */ +const readCoordinates = (gpsInfo) => { + if (!gpsInfo) return null; + const lat = toDecimalDegrees(gpsInfo.GPSLatitude, gpsInfo.GPSLatitudeRef); + const lon = toDecimalDegrees(gpsInfo.GPSLongitude, gpsInfo.GPSLongitudeRef); + if (lat === null || lon === null) return null; + return { lat, lon }; +}; + +/** The fields the details panel shows, from a block the parser has read. */ +const describeExif = (block) => { + if (!block || typeof block !== 'object') return null; + + const fields = Object.fromEntries( + Object.entries(EXIF_FIELDS).map(([name, candidates]) => [ + name, + candidates.map(([directory, tag]) => block[directory]?.[tag]).find(Boolean) ?? null, + ]) + ); + + return { + ...fields, + dateTaken: withoutTimezone(fields.dateTaken), + gps: readCoordinates(block.GPSInfo), + }; +}; + +/** + * Everything the EXIF block of one file says, or nothing. + * + * A file that cannot be parsed is not an error here: a damaged header still + * has a name, a size and a date, and losing the whole answer over it would be + * the wrong trade. + */ +const readExifDetails = async (absolutePath, metadata, extension) => { + const block = await readExifBlock(absolutePath, metadata, extension); + if (!block) return null; + return describeExif(exifReader(block)); +}; + +module.exports = { + readExifDetails, + describeExif, + toDecimalDegrees, + withoutTimezone, + TIFF_READ_MAX_BYTES, +}; diff --git a/backend/tests/routes/metadata.test.js b/backend/tests/routes/metadata.test.js new file mode 100644 index 000000000..8dc224c45 --- /dev/null +++ b/backend/tests/routes/metadata.test.js @@ -0,0 +1,277 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import sharp from 'sharp'; +import request from 'supertest'; +import { setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * The details panel. Its own work is small — a stat, an extension, and a + * recursive sum for a folder — and none of it was covered: 18.8 % of the + * statements and not one branch. The sum is the part worth pinning, because a + * folder's size is the number people act on. + */ + +let currentEnv; + +afterEach(async () => { + if (currentEnv) { + await currentEnv.cleanup(); + currentEnv = null; + } +}); + +const seed = async (env = {}) => { + currentEnv = await setupTestEnv({ tag: 'metadata-', env }); + const dbService = currentEnv.requireFresh('src/services/db'); + const db = await dbService.getDb(); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, email, email_verified, username, display_name, roles, created_at, updated_at) + VALUES ('u1','u@example.com',1,'u','U','["admin"]', ?, ?)` + ).run(now, now); + return currentEnv.volumeDir; +}; + +const buildApp = () => { + const routes = currentEnv.requireFresh('src/routes/metadata'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use((req, _res, next) => { + req.user = { id: 'u1', email: 'u@example.com', roles: ['admin'] }; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + return app; +}; + +describe('reading a file’s details', () => { + it('reports the name, kind and size', async () => { + const volume = await seed(); + await fs.mkdir(path.join(volume, 'Docs'), { recursive: true }); + await fs.writeFile(path.join(volume, 'Docs', 'note.txt'), 'hello world\n'); + + const response = await request(buildApp()).get('/api/metadata/Docs/note.txt'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + path: 'Docs/note.txt', + name: 'note.txt', + kind: 'txt', + size: 12, + }); + }); + + it('calls a file without an extension unknown rather than empty', async () => { + const volume = await seed(); + await fs.writeFile(path.join(volume, 'LICENSE'), 'text\n'); + + const response = await request(buildApp()).get('/api/metadata/LICENSE'); + + expect(response.body.kind).toBe('unknown'); + }); + + it('requires a path', async () => { + await seed(); + + const response = await request(buildApp()).get('/api/metadata/'); + + expect(response.status).toBe(400); + }); + + it('says not found for a path that is not there', async () => { + await seed(); + + const response = await request(buildApp()).get('/api/metadata/Docs/absent.txt'); + + expect(response.status).toBe(404); + }); + + it('refuses a path that leaves the volume', async () => { + await seed(); + + const response = await request(buildApp()).get('/api/metadata/../../etc/passwd'); + + expect([403, 404]).toContain(response.status); + expect(response.status).not.toBe(200); + }); + + /** + * The refusal says forbidden rather than not-found: the caller asked about + * somewhere they may not look, which is a different answer from somewhere + * that is empty — and the details panel shows a different message for each. + * + * Reached with USER_VOLUMES on and nothing assigned, so the path resolves and + * exists and only the access check says no. A path that climbs out of the + * volume never gets there: it is refused while being resolved, which is why + * the test above proves nothing about this branch. + */ + it('says forbidden for a file the caller may not read', async () => { + const volume = await seed({ USER_VOLUMES: 'true' }); + await fs.mkdir(path.join(volume, 'Private'), { recursive: true }); + await fs.writeFile(path.join(volume, 'Private', 'secret.txt'), 'x'); + + const routes = currentEnv.requireFresh('src/routes/metadata'); + const { errorHandler } = currentEnv.requireFresh('src/middleware/errorHandler'); + const app = express(); + app.use((req, _res, next) => { + req.user = { id: 'restricted', roles: [] }; + next(); + }); + app.use('/api', routes); + app.use(errorHandler); + + const response = await request(app).get('/api/metadata/Private/secret.txt'); + + expect(response.status).toBe(403); + }); +}); + +describe('summing what a folder holds', () => { + const buildTree = async (volume) => { + await fs.mkdir(path.join(volume, 'Tree', 'a', 'b'), { recursive: true }); + await fs.writeFile(path.join(volume, 'Tree', 'one.txt'), 'x'.repeat(10)); + await fs.writeFile(path.join(volume, 'Tree', 'a', 'two.txt'), 'x'.repeat(20)); + await fs.writeFile(path.join(volume, 'Tree', 'a', 'b', 'three.txt'), 'x'.repeat(30)); + }; + + it('counts every file under the folder, not only the top level', async () => { + const volume = await seed(); + await buildTree(volume); + + const response = await request(buildApp()).get('/api/metadata/Tree'); + + expect(response.status).toBe(200); + expect(response.body.directory).toMatchObject({ + totalSize: 60, + fileCount: 3, + dirCount: 2, + truncated: false, + }); + }); + + it('says a folder is a directory rather than guessing at an extension', async () => { + const volume = await seed(); + await fs.mkdir(path.join(volume, 'archive.zip'), { recursive: true }); + + const response = await request(buildApp()).get('/api/metadata/archive.zip'); + + expect(response.body.kind).toBe('directory'); + }); + + it('reports an empty folder as empty rather than failing', async () => { + const volume = await seed(); + await fs.mkdir(path.join(volume, 'Empty'), { recursive: true }); + + const response = await request(buildApp()).get('/api/metadata/Empty'); + + expect(response.status).toBe(200); + expect(response.body.directory).toMatchObject({ totalSize: 0, fileCount: 0, dirCount: 0 }); + }); + + /** + * A broken symbolic link cannot be stat'ed. One of them must not cost the + * whole total — the answer people read is the sum of what could be counted. + */ + it('skips what it cannot read and still returns a total', async () => { + const volume = await seed(); + await fs.mkdir(path.join(volume, 'Mixed'), { recursive: true }); + await fs.writeFile(path.join(volume, 'Mixed', 'real.txt'), 'x'.repeat(15)); + await fs.symlink( + path.join(volume, 'Mixed', 'gone.txt'), + path.join(volume, 'Mixed', 'dangling') + ); + + const response = await request(buildApp()).get('/api/metadata/Mixed'); + + expect(response.status).toBe(200); + expect(response.body.directory).toMatchObject({ totalSize: 15, fileCount: 1 }); + }); +}); + +/** + * What a picture and a film say about themselves. + * + * Both branches read a third-party library — sharp for images, ffprobe for + * video — and both are wrapped so a file that cannot be read does not cost the + * caller the rest of the answer. That wrapping is the part worth pinning: the + * details panel still has a name, a size and a date to show for a photo whose + * header is damaged. + */ +describe('a picture', () => { + const writeImage = async (volume, name, { width, height }) => { + const file = path.join(volume, name); + await fs.mkdir(path.dirname(file), { recursive: true }); + await sharp({ + create: { width, height, channels: 3, background: { r: 10, g: 80, b: 120 } }, + }) + .png() + .toFile(file); + }; + + it('reports the size it was taken at', async () => { + const volume = await seed(); + await writeImage(volume, 'Photos/one.png', { width: 48, height: 32 }); + + const response = await request(buildApp()).get('/api/metadata/Photos/one.png'); + + expect(response.status).toBe(200); + expect(response.body.image).toMatchObject({ width: 48, height: 32 }); + }); + + /** + * The camera's own account of the picture, through the route rather than + * through the parser: what this pins is the wiring — the block sharp hands + * back reaching the panel, with the extension it needs to know when to go + * looking in the file itself. + */ + it('reports the camera, the lens and where it was taken', async () => { + const volume = await seed(); + const file = path.join(volume, 'Photos', 'holiday.jpg'); + await fs.mkdir(path.dirname(file), { recursive: true }); + await sharp({ create: { width: 12, height: 8, channels: 3, background: '#336699' } }) + .withExif({ + IFD0: { Make: 'FUJIFILM', Model: 'X-T5' }, + IFD2: { DateTimeOriginal: '2024:05:03 18:22:41', LensModel: 'XF16-55mmF2.8' }, + IFD3: { + GPSLatitudeRef: 'N', + GPSLatitude: '48/1 51/1 2952/100', + GPSLongitudeRef: 'E', + GPSLongitude: '2/1 17/1 2988/100', + }, + }) + .jpeg() + .toFile(file); + + const response = await request(buildApp()).get('/api/metadata/Photos/holiday.jpg'); + + expect(response.status).toBe(200); + expect(response.body.image).toMatchObject({ + width: 12, + height: 8, + cameraMake: 'FUJIFILM', + cameraModel: 'X-T5', + lensModel: 'XF16-55mmF2.8', + dateTaken: '2024-05-03T18:22:41', + }); + expect(response.body.image.gps.lat).toBeCloseTo(48.8582, 4); + }); + + /** + * A file named `.png` that is not one. The panel loses the picture's own + * details and keeps everything the filesystem knows, which is what somebody + * looking at a damaged file most needs. + */ + it('still answers when the file is not the picture it claims to be', async () => { + const volume = await seed(); + await fs.mkdir(path.join(volume, 'Photos'), { recursive: true }); + await fs.writeFile(path.join(volume, 'Photos', 'broken.png'), 'not a png at all'); + + const response = await request(buildApp()).get('/api/metadata/Photos/broken.png'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ name: 'broken.png', kind: 'png', size: 16 }); + }); +}); diff --git a/backend/tests/utils/exif-details.test.js b/backend/tests/utils/exif-details.test.js new file mode 100644 index 000000000..3a8943fd5 --- /dev/null +++ b/backend/tests/utils/exif-details.test.js @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs/promises'; +import sharp from 'sharp'; + +import { + readExifDetails, + describeExif, + toDecimalDegrees, + withoutTimezone, + TIFF_READ_MAX_BYTES, +} from '../../src/utils/exifDetails.js'; + +/** + * The details a photograph carries, after `exifr` was dropped. + * + * It was the last thing in the image that opened somebody else's file with a + * library nobody had published since 2022. What replaced it reads the block + * sharp already hands back, so what has to be pinned here is that each format + * still arrives with its camera, its lens and its coordinates — and that the + * two formats sharp does *not* hand a block for are still answered. + */ + +const EXIF = { + IFD0: { Make: 'NIKON CORPORATION', Model: 'NIKON Z 6', Software: 'Ver.03.50' }, + IFD2: { DateTimeOriginal: '2024:05:03 18:22:41', LensModel: 'NIKKOR Z 24-70mm f/4 S' }, + IFD3: { + GPSLatitudeRef: 'N', + GPSLatitude: '48/1 51/1 2952/100', + GPSLongitudeRef: 'E', + GPSLongitude: '2/1 17/1 2988/100', + }, +}; + +let dir; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'exif-details-')); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +/** A picture of the requested format, carrying the block above. */ +const writePicture = async (name, format) => { + const file = path.join(dir, name); + await sharp({ create: { width: 8, height: 8, channels: 3, background: '#336699' } }) + .withExif(EXIF) + .toFormat(format) + .toFile(file); + return file; +}; + +/** Everything sharp can open, read through the block it hands back. */ +describe.each([ + ['a JPEG', 'camera.jpg', 'jpeg'], + ['a PNG', 'camera.png', 'png'], + ['a WebP', 'camera.webp', 'webp'], + // AVIF is the HEIF container HEIC also uses, and libvips pulls the block out + // of it the same way for both. sharp cannot *write* a HEIC — the encoder is + // patent-encumbered and left out of every prebuilt libvips — so this is how + // the container is covered at all. + ['an AVIF, the container HEIC also uses', 'camera.avif', 'avif'], +])('%s', (_label, name, format) => { + it('arrives with its camera, its lens and where it was taken', async () => { + const file = await writePicture(name, format); + const metadata = await sharp(file).metadata(); + + const details = await readExifDetails(file, metadata, format); + + expect(details).toMatchObject({ + cameraMake: 'NIKON CORPORATION', + cameraModel: 'NIKON Z 6', + lensModel: 'NIKKOR Z 24-70mm f/4 S', + software: 'Ver.03.50', + dateTaken: '2024-05-03T18:22:41', + }); + expect(details.gps.lat).toBeCloseTo(48.8582, 4); + expect(details.gps.lon).toBeCloseTo(2.29163, 4); + }); +}); + +/** + * A minimal, valid baseline TIFF whose directory sits right after the header. + * + * Written by hand because sharp cannot put an EXIF block in a TIFF — it drops + * it silently — and because it is the shape this code has to handle: in a TIFF + * the file *is* the block, so sharp reports no separate EXIF and the file has + * to be read. + */ +const makeTiff = ({ make = 'NIKON CORPORATION', model = 'NIKON Z 6' } = {}) => { + const SHORT = 3; + const LONG = 4; + const ASCII = 2; + const strings = [make, model].map((s) => Buffer.from(`${s}\0`, 'ascii')); + const entryCount = 11; + const ifdSize = 2 + entryCount * 12 + 4; + let dataOffset = 8 + ifdSize; + const chunks = []; + const place = (buffer) => { + const at = dataOffset; + chunks.push(buffer); + dataOffset += buffer.length; + return at; + }; + const makeAt = place(strings[0]); + const modelAt = place(strings[1]); + const pixelAt = place(Buffer.from([0x7f])); + + const entries = [ + [256, SHORT, 1, 1], + [257, SHORT, 1, 1], + [258, SHORT, 1, 8], + [259, SHORT, 1, 1], + [262, SHORT, 1, 1], + [271, ASCII, strings[0].length, makeAt], + [272, ASCII, strings[1].length, modelAt], + [273, LONG, 1, pixelAt], + [277, SHORT, 1, 1], + [278, SHORT, 1, 1], + [279, LONG, 1, 1], + ]; + + const ifd = Buffer.alloc(ifdSize); + ifd.writeUInt16LE(entryCount, 0); + entries.forEach(([tag, type, count, value], index) => { + const at = 2 + index * 12; + ifd.writeUInt16LE(tag, at); + ifd.writeUInt16LE(type, at + 2); + ifd.writeUInt32LE(count, at + 4); + if (type === SHORT && count === 1) ifd.writeUInt16LE(value, at + 8); + else ifd.writeUInt32LE(value, at + 8); + }); + + const header = Buffer.alloc(8); + header.write('II', 0, 'ascii'); + header.writeUInt16LE(0x2a, 2); + header.writeUInt32LE(8, 4); + return Buffer.concat([header, ifd, ...chunks]); +}; + +describe('a TIFF, where the file is the block', () => { + it('is read from the file, since sharp hands back no block for one', async () => { + const file = path.join(dir, 'scan.tif'); + await fs.writeFile(file, makeTiff()); + const metadata = await sharp(file).metadata(); + + expect(metadata.exif).toBeUndefined(); + + const details = await readExifDetails(file, metadata, 'tif'); + + expect(details).toMatchObject({ cameraMake: 'NIKON CORPORATION', cameraModel: 'NIKON Z 6' }); + }); + + /** + * The ceiling, and the proof that it is the ceiling doing the refusing: the + * same unreadable content under it reaches the parser and is reported as + * broken, while above it nothing is read at all. + * + * The large file is made sparse — truncated, never written — so this costs a + * stat and no disk. + */ + it('stops short of reading a scan larger than the ceiling', async () => { + const small = path.join(dir, 'small.tif'); + await fs.writeFile(small, Buffer.alloc(1024)); + await expect(readExifDetails(small, null, 'tif')).rejects.toThrow(); + + const huge = path.join(dir, 'huge.tif'); + const handle = await fs.open(huge, 'w'); + await handle.truncate(TIFF_READ_MAX_BYTES + 1); + await handle.close(); + + await expect(readExifDetails(huge, null, 'tif')).resolves.toBeNull(); + }); +}); + +describe('a file with nothing to say', () => { + it('has no details rather than empty ones', async () => { + const file = path.join(dir, 'plain.png'); + await sharp({ create: { width: 4, height: 4, channels: 3, background: '#000' } }) + .png() + .toFile(file); + const metadata = await sharp(file).metadata(); + + await expect(readExifDetails(file, metadata, 'png')).resolves.toBeNull(); + }); + + it('does not go looking in a format that cannot carry a block', async () => { + const file = path.join(dir, 'absent.gif'); + + await expect(readExifDetails(file, { format: 'gif' }, 'gif')).resolves.toBeNull(); + }); +}); + +describe('the dates a camera writes', () => { + /** + * EXIF says 18:22:41 and nothing about where. Sent with a zone, a browser an + * hour away shows an hour the photograph was not taken at; sent without one, + * it is read as local time wherever it is displayed, which is what the + * camera showed. + */ + it('keeps the hour the camera showed, with no zone attached', () => { + const date = new Date(Date.UTC(2024, 4, 3, 18, 22, 41)); + + expect(withoutTimezone(date)).toBe('2024-05-03T18:22:41'); + expect(withoutTimezone(date)).not.toMatch(/Z$/); + }); + + it('pads a single-digit month, day and hour', () => { + expect(withoutTimezone(new Date(Date.UTC(2024, 0, 2, 3, 4, 5)))).toBe('2024-01-02T03:04:05'); + }); + + it('passes through what it cannot read as a date', () => { + expect(withoutTimezone('0000:00:00 00:00:00')).toBe('0000:00:00 00:00:00'); + expect(withoutTimezone(new Date('nonsense'))).toBeNull(); + expect(withoutTimezone(undefined)).toBeNull(); + }); + + it('takes the moment the shutter opened over the one the file was written', () => { + const described = describeExif({ + Image: { DateTime: new Date(Date.UTC(2025, 0, 1, 0, 0, 0)) }, + Photo: { + DateTimeOriginal: new Date(Date.UTC(2024, 4, 3, 18, 22, 41)), + DateTimeDigitized: new Date(Date.UTC(2024, 4, 4, 9, 0, 0)), + }, + }); + + expect(described.dateTaken).toBe('2024-05-03T18:22:41'); + }); + + it('falls back to the moment it was digitised, then to the file’s own', () => { + const digitised = describeExif({ + Image: { DateTime: new Date(Date.UTC(2025, 0, 1, 0, 0, 0)) }, + Photo: { DateTimeDigitized: new Date(Date.UTC(2024, 4, 4, 9, 0, 0)) }, + }); + expect(digitised.dateTaken).toBe('2024-05-04T09:00:00'); + + const written = describeExif({ Image: { DateTime: new Date(Date.UTC(2025, 0, 1, 7, 30, 0)) } }); + expect(written.dateTaken).toBe('2025-01-01T07:30:00'); + }); +}); + +describe('where a photograph was taken', () => { + it('turns degrees, minutes and seconds into the one number a map needs', () => { + expect(toDecimalDegrees([48, 51, 29.52], 'N')).toBeCloseTo(48.8582, 6); + }); + + it('reads south and west as the other side of zero', () => { + expect(toDecimalDegrees([33, 51, 54], 'S')).toBeCloseTo(-33.865, 6); + expect(toDecimalDegrees([151, 12, 36], 'W')).toBeCloseTo(-151.21, 6); + }); + + /** + * A negative degree with a southern reference must not cancel out into the + * northern hemisphere: the sign is the reference's to give, once. + */ + it('takes the sign from the reference alone', () => { + expect(toDecimalDegrees([-33, 51, 54], 'S')).toBeCloseTo(-33.865, 6); + expect(toDecimalDegrees([-33, 51, 54], 'N')).toBeCloseTo(33.865, 6); + }); + + it('accepts degrees alone, and refuses what is not a number', () => { + expect(toDecimalDegrees([12], 'E')).toBe(12); + expect(toDecimalDegrees(['north'], 'N')).toBeNull(); + expect(toDecimalDegrees([], 'N')).toBeNull(); + expect(toDecimalDegrees(undefined, 'N')).toBeNull(); + }); + + it('says nothing rather than half a position', () => { + const described = describeExif({ + Image: { Make: 'Canon' }, + GPSInfo: { GPSLatitude: [48, 51, 29.52], GPSLatitudeRef: 'N' }, + }); + + expect(described.gps).toBeNull(); + expect(described.cameraMake).toBe('Canon'); + }); +}); + +describe('a block with none of the fields', () => { + it('answers every field rather than leaving them out', () => { + expect(describeExif({ Image: {} })).toEqual({ + cameraMake: null, + cameraModel: null, + lensModel: null, + software: null, + dateTaken: null, + gps: null, + }); + }); + + it('is nothing at all when there is no block', () => { + expect(describeExif(null)).toBeNull(); + }); +}); diff --git a/frontend/src/config/media.js b/frontend/src/config/media.js index 3420765ef..2e79d6248 100644 --- a/frontend/src/config/media.js +++ b/frontend/src/config/media.js @@ -63,32 +63,19 @@ const audioPreviewExtensionsSet = new Set([ ...envAudioExtensions, ]); -const getImagePreviewExtensions = () => Array.from(imagePreviewExtensionsSet.values()); - const isPreviewableImage = (extension = '') => { if (!extension) return false; return imagePreviewExtensionsSet.has(extension.toLowerCase()); }; -const getVideoPreviewExtensions = () => Array.from(videoPreviewExtensionsSet.values()); - const isPreviewableVideo = (extension = '') => { if (!extension) return false; return videoPreviewExtensionsSet.has(extension.toLowerCase()); }; -const getAudioPreviewExtensions = () => Array.from(audioPreviewExtensionsSet.values()); - const isPreviewableAudio = (extension = '') => { if (!extension) return false; return audioPreviewExtensionsSet.has(extension.toLowerCase()); }; -export { - getImagePreviewExtensions, - isPreviewableImage, - getVideoPreviewExtensions, - isPreviewableVideo, - getAudioPreviewExtensions, - isPreviewableAudio, -}; +export { isPreviewableImage, isPreviewableVideo, isPreviewableAudio }; diff --git a/frontend/src/plugins/preview/useMediaTracks.js b/frontend/src/plugins/preview/useMediaTracks.js index 44dd32fff..6696e7912 100644 --- a/frontend/src/plugins/preview/useMediaTracks.js +++ b/frontend/src/plugins/preview/useMediaTracks.js @@ -26,7 +26,7 @@ export function useMediaTracks(media, api, enabled) { const token = current.key; pending = token; - let result = null; + let result; try { result = await api.getMediaTracks(current.item); } catch (_) { diff --git a/package-lock.json b/package-lock.json index 7addc22ea..0d3ee0f79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "connect-sqlite3": "^0.9.16", "cookie-parser": "^1.4.7", "cors": "^2.8.5", - "exifr": "^7.1.3", + "exif-reader": "^2.0.3", "exiftool-vendored": "^34.1.0", "express": "^5.2.1", "express-openid-connect": "^2.19.2", @@ -11545,10 +11545,10 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exifr": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", - "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "node_modules/exif-reader": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/exif-reader/-/exif-reader-2.0.3.tgz", + "integrity": "sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==", "license": "MIT" }, "node_modules/exiftool-vendored": {