Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
192 changes: 90 additions & 102 deletions backend/src/routes/metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -95,7 +136,7 @@ router.get(
let resolved;
try {
({ accessInfo, resolved } = await resolvePathWithAccess(context, relativePath));
} catch (error) {
} catch (_) {
throw new NotFoundError('Path not found.');
}

Expand All @@ -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)) });
})
);

Expand Down
143 changes: 143 additions & 0 deletions backend/src/utils/exifDetails.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading