feat!: absolute file paths (#19995)

feat: absolute file paths
This commit is contained in:
Jason Rasmussen
2025-07-18 10:57:29 -04:00
committed by GitHub
parent f32d4f15b6
commit 493d85b021
34 changed files with 689 additions and 257 deletions

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
@@ -335,6 +336,23 @@ export class AssetRepository {
return count;
}
@GenerateSql()
getFileSamples() {
return this.db
.selectFrom('asset')
.select((eb) => [
'asset.id',
'asset.originalPath',
'asset.sidecarPath',
'asset.encodedVideoPath',
jsonArrayFrom(eb.selectFrom('asset_file').select('path').whereRef('asset.id', '=', 'asset_file.assetId')).as(
'files',
),
])
.limit(sql.lit(3))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) {
return this.db

View File

@@ -13,6 +13,7 @@ const resetEnv = () => {
'IMMICH_WORKERS_EXCLUDE',
'IMMICH_TRUSTED_PROXIES',
'IMMICH_API_METRICS_PORT',
'IMMICH_MEDIA_LOCATION',
'IMMICH_MICROSERVICES_METRICS_PORT',
'IMMICH_TELEMETRY_INCLUDE',
'IMMICH_TELEMETRY_EXCLUDE',
@@ -76,6 +77,13 @@ describe('getEnv', () => {
});
});
describe('IMMICH_MEDIA_LOCATION', () => {
it('should throw an error for relative paths', () => {
process.env.IMMICH_MEDIA_LOCATION = './relative/path';
expect(() => getEnv()).toThrowError('IMMICH_MEDIA_LOCATION must be an absolute path');
});
});
describe('database', () => {
it('should use defaults', () => {
const { database } = getEnv();
@@ -95,7 +103,7 @@ describe('getEnv', () => {
it('should validate DB_SSL_MODE', () => {
process.env.DB_SSL_MODE = 'invalid';
expect(() => getEnv()).toThrowError('Invalid environment variables: DB_SSL_MODE');
expect(() => getEnv()).toThrowError('DB_SSL_MODE must be one of the following values:');
});
it('should accept a valid DB_SSL_MODE', () => {
@@ -239,7 +247,7 @@ describe('getEnv', () => {
it('should reject invalid trusted proxies', () => {
process.env.IMMICH_TRUSTED_PROXIES = '10.1';
expect(() => getEnv()).toThrowError('Invalid environment variables: IMMICH_TRUSTED_PROXIES');
expect(() => getEnv()).toThrow('IMMICH_TRUSTED_PROXIES must be an ip address, or ip address range');
});
});

View File

@@ -131,9 +131,11 @@ const getEnv = (): EnvData => {
const dto = plainToInstance(EnvDto, process.env);
const errors = validateSync(dto);
if (errors.length > 0) {
throw new Error(
`Invalid environment variables: ${errors.map((error) => `${error.property}=${error.value}`).join(', ')}`,
);
const messages = [`Invalid environment variables: `];
for (const error of errors) {
messages.push(` - ${error.property}=${error.value} (${Object.values(error.constraints || {}).join(', ')})`);
}
throw new Error(messages.join('\n'));
}
const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.Api, ImmichWorker.Microservices]);

View File

@@ -436,6 +436,39 @@ export class DatabaseRepository {
this.logger.debug('Finished running kysely migrations');
}
async migrateFilePaths(sourceFolder: string, targetFolder: string): Promise<void> {
// escaping regex special characters with a backslash
const sourceRegex = '^' + sourceFolder.replaceAll(/[-[\]{}()*+?.,\\^$|#\s]/g, String.raw`\$&`);
const source = sql.raw(`'${sourceRegex}'`);
const target = sql.lit(targetFolder);
await this.db.transaction().execute(async (tx) => {
await tx
.updateTable('asset')
.set((eb) => ({
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
sidecarPath: eb.fn('REGEXP_REPLACE', ['sidecarPath', source, target]),
}))
.execute();
await tx
.updateTable('asset_file')
.set((eb) => ({ path: eb.fn('REGEXP_REPLACE', ['path', source, target]) }))
.execute();
await tx
.updateTable('person')
.set((eb) => ({ thumbnailPath: eb.fn('REGEXP_REPLACE', ['thumbnailPath', source, target]) }))
.execute();
await tx
.updateTable('user')
.set((eb) => ({ profileImagePath: eb.fn('REGEXP_REPLACE', ['profileImagePath', source, target]) }))
.execute();
});
}
async withLock<R>(lock: DatabaseLock, callback: () => Promise<R>): Promise<R> {
let res;
await this.asyncLock.acquire(DatabaseLock[lock], async () => {

View File

@@ -142,6 +142,16 @@ export class PersonRepository {
.stream();
}
@GenerateSql()
getFileSamples() {
return this.db
.selectFrom('person')
.select(['id', 'thumbnailPath'])
.where('thumbnailPath', '!=', sql.lit(''))
.limit(sql.lit(3))
.execute();
}
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
const items = await this.db

View File

@@ -79,6 +79,16 @@ export class UserRepository {
.executeTakeFirst();
}
@GenerateSql()
getFileSamples() {
return this.db
.selectFrom('user')
.select(['id', 'profileImagePath'])
.where('profileImagePath', '!=', sql.lit(''))
.limit(sql.lit(3))
.execute();
}
@GenerateSql()
async hasAdmin(): Promise<boolean> {
const admin = await this.db