mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
add error handling
This commit is contained in:
+1
-1
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/walkrs": "^0.0.12",
|
||||
"@immich/walkrs": "^0.0.13",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
@@ -197,7 +198,7 @@ export class StorageRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<string[], void, unknown> {
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<WalkItem[], void, unknown> {
|
||||
const { pathsToWalk, exclusionPatterns, includeHidden } = walkOptions;
|
||||
if (pathsToWalk.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -162,7 +162,7 @@ describe(LibraryService.name, () => {
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
@@ -202,7 +202,7 @@ describe(LibraryService.name, () => {
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
@@ -225,7 +225,7 @@ describe(LibraryService.name, () => {
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { R_OK } from 'node:constants';
|
||||
import { Stats } from 'node:fs';
|
||||
import path, { basename, isAbsolute, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
|
||||
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
@@ -639,7 +640,7 @@ export class LibraryService extends BaseService {
|
||||
|
||||
this.logger.log(`Starting disk crawl of ${validImportPaths.length} import path(s) for library ${library.id}...`);
|
||||
|
||||
const fileGenerator = this.storageRepository.walk({
|
||||
const fileWalker = this.storageRepository.walk({
|
||||
pathsToWalk: validImportPaths,
|
||||
includeHidden: false, // TODO: make this configurable?
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
@@ -649,7 +650,20 @@ export class LibraryService extends BaseService {
|
||||
let progressCounter = 0;
|
||||
let lastLoggedMilestone = 0;
|
||||
|
||||
for await (const paths of fileGenerator) {
|
||||
for await (const walkItems of fileWalker) {
|
||||
const paths: string[] = [];
|
||||
for (const item of walkItems) {
|
||||
if (item.type === 'error') {
|
||||
this.logger.warn(`Error walking ${item.path ?? 'unknown path'}: ${item.message}`);
|
||||
} else {
|
||||
paths.push(item.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (paths.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
progressCounter += paths.length;
|
||||
|
||||
await this.jobRepository.queue({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { WalkError, WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
|
||||
import { Kysely } from 'kysely';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import path, { join } from 'node:path';
|
||||
import { WalkOptionsDto } from 'src/dtos/library.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
@@ -229,7 +230,11 @@ describe(StorageRepository.name, () => {
|
||||
|
||||
const actual: string[] = [];
|
||||
for await (const batch of sut.walk(adjustedOptions)) {
|
||||
actual.push(...batch);
|
||||
for (const item of batch) {
|
||||
if (item.type === 'entry') {
|
||||
actual.push(item.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = Object.entries(files)
|
||||
.filter((entry) => entry[1])
|
||||
@@ -239,5 +244,90 @@ describe(StorageRepository.name, () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('should handle access denied errors gracefully', async () => {
|
||||
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
|
||||
const restrictedDir = join(testDir, 'restricted');
|
||||
const restrictedFile = join(restrictedDir, 'file.jpg');
|
||||
const accessibleFile = join(testDir, 'accessible.jpg');
|
||||
|
||||
try {
|
||||
// Create test directory structure
|
||||
await fs.mkdir(restrictedDir, { recursive: true });
|
||||
await fs.writeFile(accessibleFile, 'accessible content');
|
||||
await fs.writeFile(restrictedFile, 'restricted content');
|
||||
|
||||
// Remove all permissions from restricted directory to simulate access denied
|
||||
await fs.chmod(restrictedDir, 0o000);
|
||||
|
||||
const actual: string[] = [];
|
||||
const errors: WalkItem[] = [];
|
||||
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'entry') {
|
||||
actual.push(item.path);
|
||||
} else {
|
||||
errors.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should successfully walk accessible file but skip restricted directory
|
||||
expect(actual).toContain(accessibleFile);
|
||||
expect(actual).not.toContain(restrictedFile);
|
||||
// Should have encountered an error for the restricted directory
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors.some((e) => e.type === 'error' && e.message?.includes('restricted'))).toBe(true);
|
||||
} finally {
|
||||
// Cleanup: restore permissions before deletion
|
||||
try {
|
||||
await fs.chmod(restrictedDir, 0o755);
|
||||
} catch {
|
||||
// Ignore errors if directory was already deleted or permissions cannot be restored
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should return error details for access denied paths', async () => {
|
||||
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
|
||||
const restrictedDir = join(testDir, 'restricted');
|
||||
const restrictedFile = join(restrictedDir, 'file.jpg');
|
||||
const accessibleFile = join(testDir, 'accessible.jpg');
|
||||
|
||||
try {
|
||||
// Create test directory structure
|
||||
await fs.mkdir(restrictedDir, { recursive: true });
|
||||
await fs.writeFile(accessibleFile, 'accessible content');
|
||||
await fs.writeFile(restrictedFile, 'restricted content');
|
||||
|
||||
// Remove all permissions from restricted directory to simulate access denied
|
||||
await fs.chmod(restrictedDir, 0o000);
|
||||
|
||||
const errors: WalkError[] = [];
|
||||
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
|
||||
for (const item of batch) {
|
||||
if (item.type === 'error') {
|
||||
errors.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should have error details including path and message
|
||||
expect(errors.length).toBe(1);
|
||||
const restrictedError = errors.find((e) => e.type === 'error' && e.message?.includes('restricted'));
|
||||
expect(restrictedError).toBeDefined();
|
||||
expect(restrictedError?.type).toBe('error');
|
||||
expect(restrictedError?.message).toBeDefined();
|
||||
} finally {
|
||||
// Cleanup: restore permissions before deletion
|
||||
try {
|
||||
await fs.chmod(restrictedDir, 0o755);
|
||||
} catch {
|
||||
// Ignore errors if directory was already deleted or permissions cannot be restored
|
||||
}
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user