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
21 changes: 17 additions & 4 deletions backend/src/routes/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { normalizeRelativePath } = require('../utils/pathUtils');
const { ensureDir } = require('../utils/fsUtils');
const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService');
const versions = require('../services/versions/operations');
const folderSizeHooks = require('../services/folderSizeHooks');
const asyncHandler = require('../utils/asyncHandler');
const { sendTextFile } = require('../utils/textFileResponse');
const { ValidationError, ForbiddenError, NotFoundError } = require('../errors/AppError');
Expand Down Expand Up @@ -133,10 +134,11 @@ router.put(
const { absolutePath } = resolved;

await ensureDir(path.dirname(absolutePath));
const existed = await fs
.stat(absolutePath)
.then((stats) => stats.isFile())
.catch(() => false);
// What the file weighed before, for the index: a save replaces content, so the
// folder it sits in gains the difference rather than the whole of the new file.
const before = await fs.stat(absolutePath).catch(() => null);
const existed = Boolean(before?.isFile());
const previousSize = existed ? before.size : 0;

// Written back in the encoding it already had: a UTF-16 file saved as UTF-8
// reads perfectly well here and breaks whatever wrote it.
Expand Down Expand Up @@ -165,6 +167,17 @@ router.put(
explicit: true,
}
);
// The index takes the difference the save made, from the size it can already
// see, instead of waiting for the periodic sweep to walk the folder again.
const updated = await fs.stat(absolutePath).catch(() => null);
if (updated) {
if (existed) {
await folderSizeHooks.onFileReplaced(absolutePath, previousSize, updated.size);
} else {
await folderSizeHooks.onFileWritten(absolutePath, updated.size);
}
}

res.send({ success: true });
})
);
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/files/file.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const fs = require('fs/promises');

const { normalizeRelativePath, ensureValidName, splitName } = require('../../utils/pathUtils');
const { ACTIONS, authorizeAndResolve } = require('../../services/authorizationService');
const folderSizeHooks = require('../../services/folderSizeHooks');
const asyncHandler = require('../../utils/asyncHandler');
const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors/AppError');
const { buildItemMetadata } = require('./utils');
Expand Down Expand Up @@ -184,6 +185,7 @@ router.post(
baseName,
contents
);
if (contents) await folderSizeHooks.onFileWritten(absolutePath, contents.length);

const item = await buildItemMetadata(absolutePath, parentRelative, finalName);
res.status(201).json({ success: true, item });
Expand Down
3 changes: 3 additions & 0 deletions backend/src/routes/files/folder.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const fs = require('fs/promises');
const { normalizeRelativePath, ensureValidName } = require('../../utils/pathUtils');
const { reserveAvailableName } = require('../../utils/placeWithoutOverwrite');
const { ACTIONS, authorizeAndResolve } = require('../../services/authorizationService');
const folderSizeHooks = require('../../services/folderSizeHooks');
const asyncHandler = require('../../utils/asyncHandler');
const { ValidationError, ForbiddenError, NotFoundError } = require('../../errors/AppError');
const { buildItemMetadata } = require('./utils');
Expand Down Expand Up @@ -64,6 +65,8 @@ router.post(
{ isDirectory: true, style: 'folder' }
);

folderSizeHooks.onFolderCreated(folderAbsolute);

const item = await buildItemMetadata(folderAbsolute, parentRelative, finalName);
res.status(201).json({ success: true, item });
})
Expand Down
11 changes: 6 additions & 5 deletions backend/src/routes/folderSize.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const express = require('express');
const fs = require('fs/promises');
const { normalizeRelativePath, parsePathSpace, resolveVolumePath } = require('../utils/pathUtils');
const { resolvePathWithAccess } = require('../services/accessManager');
const { getDb } = require('../services/db');
const { getIndexDb } = require('../services/indexDb');
const folderSizeIndex = require('../services/folderSizeIndex');
const { getVolumeScope } = require('../services/folderSizeIndexer');
const folderSizeManager = require('../services/folderSizeManager');
Expand Down Expand Up @@ -69,9 +69,11 @@ const lookupFolderSize = async (context, inputRelRaw) => {
// resolution so size is available even when navigation is denied.
const absolutePath = resolved?.absolutePath ?? (await fallbackAbsolutePath(context, inputRel));

const db = await getDb();
const db = await getIndexDb();
const scope = getVolumeScope();
const withinRoot = Boolean(absolutePath && folderSizeIndex.isWithinRoot(scope.root, absolutePath));
const withinRoot = Boolean(
absolutePath && folderSizeIndex.isWithinRoot(scope.root, absolutePath)
);
const excluded = withinRoot && folderSizeExclusions.isExcluded(absolutePath, scope);
const entry = withinRoot ? folderSizeIndex.getByAbsolutePath(db, absolutePath) : null;

Expand Down Expand Up @@ -191,7 +193,7 @@ router.post(
}

queueRefreshDirectory(absolutePath);
const db = await getDb();
const db = await getIndexDb();
res.status(202).json({
...indexResult(db, scope, absolutePath, resolved.relativePath, true),
refreshPending: true,
Expand Down Expand Up @@ -228,7 +230,6 @@ router.post(
const touchPaths = [];
for (const p of limited) {
try {
// eslint-disable-next-line no-await-in-loop
const { result, absolutePath } = await lookupFolderSize(
context,
typeof p === 'string' ? p : ''
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
5 changes: 5 additions & 0 deletions backend/src/routes/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const activityLog = require('../services/activityLog');
const { normalizeRelativePath } = require('../utils/pathUtils');
const { ACTIONS, authorizeAndResolve } = require('../services/authorizationService');
const logger = require('../utils/logger');
const folderSizeHooks = require('../services/folderSizeHooks');
const asyncHandler = require('../utils/asyncHandler');
const { ForbiddenError, ValidationError } = require('../errors/AppError');

Expand Down Expand Up @@ -100,6 +101,10 @@ router.post(
for (const file of req.files.filedata) {
const stats = await fs.stat(file.path);

// The file's exact size is already known here: the index takes a precise
// positive delta, with no filesystem traversal of its own.
folderSizeHooks.onFileWritten(file.path, stats.size);

// Prefer logicalPath set by upload service; fall back to empty string
const logicalPath = normalizeRelativePath(file.logicalPath || '');
const parentPath = normalizeRelativePath(path.dirname(logicalPath));
Expand Down
Loading
Loading