fix(server): library exclusion patterns can soft-delete assets outside their directory (#30850)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Patryk Gęborys
2026-08-21 12:28:12 +02:00
committed by GitHub
co-authored by Daniel Dietzler
parent c9776fb990
commit 8ce6b18563
4 changed files with 28 additions and 48 deletions
+1 -1
View File
@@ -531,7 +531,7 @@ where
and "libraryId" = $5::uuid
and (
not "originalPath" like $6
or "originalPath" like $7
or "originalPath" ~ $7
)
-- AssetRepository.filterNewExternalAssetPaths
+3 -3
View File
@@ -54,7 +54,7 @@ import {
withTagId,
withTags,
} from 'src/utils/database';
import { globToSqlPattern } from 'src/utils/misc';
import { globToPostgresRegex } from 'src/utils/misc';
export type AssetStats = Record<AssetType, number>;
@@ -1086,7 +1086,7 @@ export class AssetRepository {
exclusionPatterns: string[],
): Promise<UpdateResult> {
const paths = importPaths.map((importPath) => `${importPath}%`);
const exclusions = exclusionPatterns.map((pattern) => globToSqlPattern(pattern));
const exclusions = exclusionPatterns.map((pattern) => globToPostgresRegex(pattern));
return this.db
.updateTable('asset')
@@ -1100,7 +1100,7 @@ export class AssetRepository {
.where((eb) =>
eb.or([
eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))),
eb.or(exclusions.map((path) => eb('originalPath', 'like', path))),
eb.or(exclusions.map((pattern) => eb('originalPath', '~', pattern))),
]),
)
.executeTakeFirstOrThrow();
+20 -12
View File
@@ -1,4 +1,4 @@
import { getKeysDeep, globToSqlPattern, unsetDeep } from 'src/utils/misc';
import { getKeysDeep, globToPostgresRegex, unsetDeep } from 'src/utils/misc';
import { describe, expect, it } from 'vitest';
describe('getKeysDeep', () => {
@@ -52,18 +52,26 @@ describe('unsetDeep', () => {
});
});
describe('globToSqlPattern', () => {
const testCases = [
['**/Raw/**', '%/Raw/%'],
['**/abc/*.tif', '%/abc/%.tif'],
['**/*.tif', '%/%.tif'],
['**/*.jp?', '%/%.jp_'],
['**/@eaDir/**', '%/@eaDir/%'],
['**/._*', `%/._%`],
['/absolute/path/**', `/absolute/path/%`],
const matches = (glob: string, value: string) => new RegExp(globToPostgresRegex(glob)).test(value);
describe('globToPostgresRegex', () => {
const testCases: [string, string, boolean][] = [
['**/Raw/**', '/foo/Raw/bar.jpg', true],
['**/Raw/**', '/foo/bar.jpg', false],
['**/abc/*.tif', '/foo/abc/scan.tif', true],
['**/abc/*.tif', '/foo/abc/sub/scan.tif', false],
['**/*.tif', '/foo/bar.tif', true],
['**/*.jp?', '/foo/bar.jpg', true],
['**/@eaDir/**', '/foo/@eaDir/thumb.jpg', true],
['**/._*', '/foo/._resource', true],
['/absolute/path/**', '/absolute/path/photo.jpg', true],
['/absolute/path/**', '/other/path/photo.jpg', false],
// a bare `*` must not cross a path separator, unlike SQL `LIKE`'s `%`
['/path/*.*', '/path/photo.jpg', true],
['/path/*.*', '/path/2020/photo.jpg', false],
];
it.each(testCases)('should convert %s to %s', (input, expected) => {
expect(globToSqlPattern(input)).toEqual(expected);
it.each(testCases)('should match %s against %s as %s', (glob, value, expected) => {
expect(matches(glob, value)).toEqual(expected);
});
});
+4 -32
View File
@@ -12,7 +12,6 @@ import { cleanupOpenApiDoc } from 'nestjs-zod';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import picomatch from 'picomatch';
import parse from 'picomatch/lib/parse';
import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants';
import { extraModels } from 'src/decorators';
import { SystemConfig } from 'src/dtos/config.dto';
@@ -338,37 +337,10 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
}
};
const convertTokenToSqlPattern = (token: parse.Token): string => {
switch (token.type) {
case 'slash': {
return '/';
}
case 'text': {
return token.value;
}
case 'globstar':
case 'star': {
return '%';
}
case 'underscore': {
return String.raw`\_`;
}
case 'qmark': {
return '_';
}
case 'dot': {
return '.';
}
default: {
return '';
}
}
};
export const globToSqlPattern = (glob: string) => {
const tokens = picomatch.parse(glob).tokens;
return tokens.map((token) => convertTokenToSqlPattern(token)).join('');
};
// Compiles a glob to the equivalent Postgres regex (Postgres's Advanced Regular Expression
// dialect is a superset of what picomatch emits, so the two stay in sync with `picomatch.isMatch`,
// including which paths a lone `*` may cross vs `/`).
export const globToPostgresRegex = (glob: string) => picomatch.makeRe(glob).source;
export function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));