Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-02-25 11:53:18 +00:00
379 changed files with 14057 additions and 12369 deletions
+12 -11
View File
@@ -35,6 +35,7 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@immich/sql-tools": "^0.2.0",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
@@ -45,14 +46,14 @@
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.211.0",
"@opentelemetry/instrumentation-http": "^0.211.0",
"@opentelemetry/instrumentation-ioredis": "^0.59.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.57.0",
"@opentelemetry/instrumentation-pg": "^0.63.0",
"@opentelemetry/exporter-prometheus": "^0.212.0",
"@opentelemetry/instrumentation-http": "^0.212.0",
"@opentelemetry/instrumentation-ioredis": "^0.60.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
"@opentelemetry/instrumentation-pg": "^0.64.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
@@ -70,7 +71,7 @@
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cron": "4.4.0",
"exiftool-vendored": "^34.3.0",
"exiftool-vendored": "^35.0.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@@ -116,7 +117,7 @@
"validator": "^13.12.0"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@eslint/js": "^10.0.0",
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.4",
@@ -146,11 +147,11 @@
"@types/ua-parser-js": "^0.7.36",
"@types/validator": "^13.15.2",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.14.0",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"eslint-plugin-unicorn": "^63.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"node-gyp": "^12.0.0",
"pngjs": "^7.0.0",
+3 -5
View File
@@ -1,16 +1,15 @@
#!/usr/bin/env node
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Kysely, sql } from 'kysely';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
import postgres from 'postgres';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
import { getKyselyConfig } from 'src/utils/database';
const main = async () => {
const command = process.argv[2];
@@ -130,10 +129,9 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const target = await schemaFromDatabase(db, {});
const target = await schemaFromDatabase({ connection: database.config });
console.log(source.warnings.join('\n'));
+1 -1
View File
@@ -1,7 +1,7 @@
import { asHuman } from '@immich/sql-tools';
import { Command, CommandRunner } from 'nest-commander';
import { ErrorMessages } from 'src/constants';
import { CliService } from 'src/services/cli.service';
import { asHuman } from 'src/sql-tools/schema-diff';
@Command({
name: 'schema-check',
@@ -369,6 +369,31 @@ describe(AssetController.name, () => {
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should check the action and parameters discriminator', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/edits`)
.send({
edits: [
{
action: 'rotate',
parameters: {
x: 0,
y: 0,
width: 100,
height: 100,
},
},
],
});
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('parameters.angle must be one of the following values')]),
),
);
});
it('should require at least one edit', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/edits`)
+4 -4
View File
@@ -20,7 +20,7 @@ import {
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
import { AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
@@ -235,7 +235,7 @@ export class AssetController {
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
})
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsDto> {
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsResponseDto> {
return this.service.getAssetEdits(auth, id);
}
@@ -249,8 +249,8 @@ export class AssetController {
editAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetEditActionListDto,
): Promise<AssetEditsDto> {
@Body() dto: AssetEditsCreateDto,
): Promise<AssetEditsResponseDto> {
return this.service.editAsset(auth, id, dto);
}
@@ -51,6 +51,20 @@ describe(MemoryController.name, () => {
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
);
});
it('should accept showAt and hideAt', async () => {
const { status } = await request(ctx.getHttpServer())
.post('/memories')
.send({
type: 'on_this_day',
data: { year: 2020 },
memoryAt: new Date(2021).toISOString(),
showAt: new Date(2022).toISOString(),
hideAt: new Date(2023).toISOString(),
});
expect(status).toBe(201);
});
});
describe('GET /memories/statistics', () => {
+1
View File
@@ -352,6 +352,7 @@ export const columns = {
'asset_file.type',
'asset_file.isEdited',
'asset_file.isProgressive',
'asset_file.isTransparent',
],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'],
+1 -1
View File
@@ -1,10 +1,10 @@
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
import { setUnion } from 'src/utils/set';
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
+4 -1
View File
@@ -25,7 +25,10 @@ export class SanitizedAssetResponseDto {
id!: string;
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
type!: AssetType;
@ApiProperty({ description: 'Thumbhash for thumbnail generation' })
@ApiProperty({
description:
'Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.',
})
thumbhash!: string | null;
@ApiPropertyOptional({ description: 'Original MIME type' })
originalMimeType?: string;
+44 -68
View File
@@ -1,7 +1,8 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
import { ExtraModel } from 'src/dtos/sync.dto';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
export enum AssetEditAction {
Crop = 'crop',
@@ -14,6 +15,7 @@ export enum MirrorAxis {
Vertical = 'vertical',
}
@ExtraModel()
export class CropParameters {
@IsInt()
@Min(0)
@@ -36,48 +38,21 @@ export class CropParameters {
height!: number;
}
@ExtraModel()
export class RotateParameters {
@IsAxisAlignedRotation()
@ApiProperty({ description: 'Rotation angle in degrees' })
angle!: number;
}
@ExtraModel()
export class MirrorParameters {
@IsEnum(MirrorAxis)
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
axis!: MirrorAxis;
}
class AssetEditActionBase {
@IsEnum(AssetEditAction)
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction', description: 'Type of edit action to perform' })
action!: AssetEditAction;
}
export class AssetEditActionCrop extends AssetEditActionBase {
@ValidateNested()
@Type(() => CropParameters)
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
parameters!: CropParameters;
}
export class AssetEditActionRotate extends AssetEditActionBase {
@ValidateNested()
@Type(() => RotateParameters)
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
parameters!: RotateParameters;
}
export class AssetEditActionMirror extends AssetEditActionBase {
@ValidateNested()
@Type(() => MirrorParameters)
// Description lives on schema to avoid duplication
@ApiProperty({ description: undefined })
parameters!: MirrorParameters;
}
export type AssetEditParameters = CropParameters | RotateParameters | MirrorParameters;
export type AssetEditActionItem =
| {
action: AssetEditAction.Crop;
@@ -92,47 +67,48 @@ export type AssetEditActionItem =
parameters: MirrorParameters;
};
export type AssetEditActionParameter = {
[AssetEditAction.Crop]: CropParameters;
[AssetEditAction.Rotate]: RotateParameters;
[AssetEditAction.Mirror]: MirrorParameters;
export class AssetEditActionItemDto {
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
action!: AssetEditAction;
@ApiProperty({
description: 'List of edit actions to apply (crop, rotate, or mirror)',
anyOf: [CropParameters, RotateParameters, MirrorParameters].map((type) => ({
$ref: getSchemaPath(type),
})),
})
@ValidateNested()
@Type((options) => actionParameterMap[options?.object.action as keyof AssetEditActionParameter])
parameters!: AssetEditActionItem['parameters'];
}
export class AssetEditActionItemResponseDto extends AssetEditActionItemDto {
@ValidateUUID()
id!: string;
}
export type AssetEditActionParameter = typeof actionParameterMap;
const actionParameterMap = {
[AssetEditAction.Crop]: CropParameters,
[AssetEditAction.Rotate]: RotateParameters,
[AssetEditAction.Mirror]: MirrorParameters,
};
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror;
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
[AssetEditAction.Crop]: AssetEditActionCrop,
[AssetEditAction.Rotate]: AssetEditActionRotate,
[AssetEditAction.Mirror]: AssetEditActionMirror,
} as const;
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
actionToClass[item.action];
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
export class AssetEditActionListDto {
/** list of edits */
export class AssetEditsCreateDto {
@ArrayMinSize(1)
@IsUniqueEditActions()
@ValidateNested({ each: true })
@Transform(({ value: edits }) =>
Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits,
)
@ApiProperty({
items: {
anyOf: Object.values(actionToClass).map((type) => ({ $ref: getSchemaPath(type) })),
discriminator: {
propertyName: 'action',
mapping: Object.fromEntries(
Object.entries(actionToClass).map(([action, type]) => [action, getSchemaPath(type)]),
),
},
},
description: 'List of edit actions to apply (crop, rotate, or mirror)',
})
edits!: AssetEditActionItem[];
@Type(() => AssetEditActionItemDto)
@ApiProperty({ description: 'List of edit actions to apply (crop, rotate, or mirror)' })
edits!: AssetEditActionItemDto[];
}
export class AssetEditsDto extends AssetEditActionListDto {
@ValidateUUID({ description: 'Asset ID to apply edits to' })
export class AssetEditsResponseDto {
@ValidateUUID({ description: 'Asset ID these edits belong to' })
assetId!: string;
@ApiProperty({
description: 'List of edit actions applied to the asset',
})
edits!: AssetEditActionItemResponseDto[];
}
+10 -1
View File
@@ -1,8 +1,17 @@
import { Transform, Type } from 'class-transformer';
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
import { DatabaseSslMode, ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
// TODO import from sql-tools once the swagger plugin supports external enums
enum DatabaseSslMode {
Disable = 'disable',
Allow = 'allow',
Prefer = 'prefer',
Require = 'require',
VerifyFull = 'verify-full',
}
export class EnvDto {
@IsInt()
@Optional()
+15
View File
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { Memory } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandom, MemoryType } from 'src/enum';
@@ -77,6 +78,20 @@ export class MemoryCreateDto extends MemoryBaseDto {
@ValidateDate({ description: 'Memory date' })
memoryAt!: Date;
@ValidateDate({
optional: true,
description: 'Date when memory should be shown',
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
})
showAt?: Date;
@ValidateDate({
optional: true,
description: 'Date when memory should be hidden',
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
})
hideAt?: Date;
@ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' })
assetIds?: string[];
}
+15
View File
@@ -422,6 +422,20 @@ export class SyncAssetFaceV1 {
sourceType!: string;
}
@ExtraModel()
export class SyncAssetFaceV2 extends SyncAssetFaceV1 {
@ApiProperty({ description: 'Face deleted at' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Is the face visible in the asset' })
isVisible!: boolean;
}
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
return faceV1;
}
@ExtraModel()
export class SyncAssetFaceDeleteV1 {
@ApiProperty({ description: 'Asset face ID' })
@@ -497,6 +511,7 @@ export type SyncItem = {
[SyncEntityType.PersonV1]: SyncPersonV1;
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
+2 -8
View File
@@ -762,6 +762,7 @@ export enum SyncRequestType {
UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1',
AssetFacesV1 = 'AssetFacesV1',
AssetFacesV2 = 'AssetFacesV2',
UserMetadataV1 = 'UserMetadataV1',
}
@@ -820,6 +821,7 @@ export enum SyncEntityType {
PersonDeleteV1 = 'PersonDeleteV1',
AssetFaceV1 = 'AssetFaceV1',
AssetFaceV2 = 'AssetFaceV2',
AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
UserMetadataV1 = 'UserMetadataV1',
@@ -851,14 +853,6 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretBasic = 'client_secret_basic',
}
export enum DatabaseSslMode {
Disable = 'disable',
Allow = 'allow',
Prefer = 'prefer',
Require = 'require',
VerifyFull = 'verify-full',
}
export enum AssetVisibility {
Archive = 'archive',
Timeline = 'timeline',
+2 -2
View File
@@ -52,9 +52,9 @@ class Workers {
try {
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
return value?.isMaintenanceMode || false;
} catch (error) {
} catch (error: Error | any) {
// Table doesn't exist (migrations haven't run yet)
if (error instanceof PostgresError && error.code === '42P01') {
if ((error as PostgresError).code === '42P01') {
return false;
}
@@ -9,6 +9,7 @@ rollback
-- AssetEditRepository.getAll
select
"id",
"action",
"parameters"
from
+2 -1
View File
@@ -216,7 +216,8 @@ select
"asset_file"."path",
"asset_file"."type",
"asset_file"."isEdited",
"asset_file"."isProgressive"
"asset_file"."isProgressive",
"asset_file"."isTransparent"
from
"asset_file"
where
+2
View File
@@ -540,6 +540,8 @@ select
"boundingBoxX2",
"boundingBoxY2",
"sourceType",
"isVisible",
"asset_face"."deletedAt",
"asset_face"."updateId"
from
"asset_face" as "asset_face"
@@ -31,7 +31,7 @@ export class ApiKeyRepository {
}
@GenerateSql({ params: [DummyValue.STRING] })
getKey(hashedToken: string) {
getKey(hashedToken: Buffer) {
return this.db
.selectFrom('api_key')
.select((eb) => [
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
import { DB } from 'src/schema';
@Injectable()
@@ -12,7 +12,7 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
@@ -20,8 +20,8 @@ export class AssetEditRepository {
return trx
.insertInto('asset_edit')
.values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
.returning(['action', 'parameters'])
.execute() as Promise<AssetEditActionItem[]>;
.returning(['id', 'action', 'parameters'])
.execute();
}
return [];
@@ -31,12 +31,12 @@ export class AssetEditRepository {
@GenerateSql({
params: [DummyValue.UUID],
})
getAll(assetId: string): Promise<AssetEditActionItem[]> {
getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
return this.db
.selectFrom('asset_edit')
.select(['action', 'parameters'])
.select(['id', 'action', 'parameters'])
.where('assetId', '=', assetId)
.orderBy('sequence', 'asc')
.execute() as Promise<AssetEditActionItem[]>;
.execute();
}
}
+9 -2
View File
@@ -903,7 +903,10 @@ export class AssetRepository {
}
async upsertFile(
file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>,
file: Pick<
Insertable<AssetFileTable>,
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
>,
): Promise<void> {
await this.db
.insertInto('asset_file')
@@ -917,7 +920,10 @@ export class AssetRepository {
}
async upsertFiles(
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[],
files: Pick<
Insertable<AssetFileTable>,
'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive' | 'isTransparent'
>[],
): Promise<void> {
if (files.length === 0) {
return;
@@ -930,6 +936,7 @@ export class AssetRepository {
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'),
isProgressive: eb.ref('excluded.isProgressive'),
isTransparent: eb.ref('excluded.isTransparent'),
})),
)
.execute();
+3 -2
View File
@@ -1,3 +1,4 @@
import { DatabaseConnectionParams } from '@immich/sql-tools';
import { RegisterQueueOptions } from '@nestjs/bullmq';
import { Inject, Injectable, Optional } from '@nestjs/common';
import { QueueOptions } from 'bullmq';
@@ -21,7 +22,7 @@ import {
LogLevel,
QueueName,
} from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
import { VectorExtension } from 'src/types';
import { setDifference } from 'src/utils/set';
export interface EnvData {
@@ -184,7 +185,7 @@ const getEnv = (): EnvData => {
try {
redisConfig = JSON.parse(Buffer.from(redisUrl.slice(10), 'base64').toString());
} catch (error) {
throw new Error(`Failed to decode redis options: ${error}`);
throw new Error('Failed to decode redis options', { cause: error });
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class CryptoRepository {
}
hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64');
return createHash('sha256').update(value).digest();
}
verifySha256(value: string, encryptedValue: string, publicKey: string) {
@@ -1,3 +1,4 @@
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
import { Injectable } from '@nestjs/common';
import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
@@ -21,7 +22,6 @@ import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
import { DB } from 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation';
@@ -289,7 +289,8 @@ export class DatabaseRepository {
async getSchemaDrift() {
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
const target = await schemaFromDatabase(this.db, {});
const { database } = this.configRepository.getEnv();
const target = await schemaFromDatabase({ connection: database.config });
const drift = schemaDiff(source, target, {
tables: { ignoreExtra: true },
+3 -3
View File
@@ -309,9 +309,9 @@ export class MediaRepository {
});
}
async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> {
const { width = 0, height = 0 } = await sharp(input).metadata();
return { width, height };
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata();
return { width, height, isTransparent: hasAlpha };
}
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) {
@@ -69,7 +69,7 @@ export class ServerInfoRepository {
return response.json();
} catch (error) {
throw new Error(`Failed to fetch GitHub release: ${error}`);
throw new Error('Failed to fetch GitHub release', { cause: error });
}
}
@@ -48,7 +48,7 @@ export class SessionRepository {
}
@GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string) {
getByToken(token: Buffer) {
return this.db
.selectFrom('session')
.select((eb) => [
@@ -479,6 +479,8 @@ class AssetFaceSync extends BaseSync {
'boundingBoxX2',
'boundingBoxY2',
'sourceType',
'isVisible',
'asset_face.deletedAt',
'asset_face.updateId',
])
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
+1 -1
View File
@@ -1,5 +1,5 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, SourceType } from 'src/enum';
import { registerEnum } from 'src/sql-tools';
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
+1 -1
View File
@@ -1,4 +1,4 @@
import { registerFunction } from 'src/sql-tools';
import { registerFunction } from '@immich/sql-tools';
export const immich_uuid_v7 = registerFunction({
name: 'immich_uuid_v7',
+1 -1
View File
@@ -1,3 +1,4 @@
import { Database, Extensions, Generated, Int8 } from '@immich/sql-tools';
import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import {
album_delete_audit,
@@ -73,7 +74,6 @@ import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { Database, Extensions, Generated, Int8 } from 'src/sql-tools';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@Database({ name: 'immich' })
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" ADD "isTransparent" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" DROP COLUMN "isTransparent";`.execute(db);
}
@@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "api_key" ALTER COLUMN "key" TYPE bytea USING decode("key", 'base64');`.execute(db);
await sql`ALTER TABLE "session" ALTER COLUMN "token" TYPE bytea USING decode("token", 'base64');`.execute(db);
await sql`CREATE INDEX "api_key_key_idx" ON "api_key" ("key");`.execute(db);
await sql`CREATE INDEX "session_token_idx" ON "session" ("token");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "api_key_key_idx";`.execute(db);
await sql`DROP INDEX "session_token_idx";`.execute(db);
await sql`ALTER TABLE "api_key" ALTER COLUMN "key" TYPE character varying USING encode("key", 'base64');`.execute(db);
await sql`ALTER TABLE "session" ALTER COLUMN "token" TYPE character varying USING encode("token", 'base64');`.execute(db);
}
+6 -6
View File
@@ -1,8 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
Check,
Column,
@@ -15,7 +10,12 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('activity')
@UpdatedAtTrigger('activity_updatedAt')
@@ -1,6 +1,6 @@
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { AlbumTable } from 'src/schema/tables/album.table';
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('album_asset_audit')
export class AlbumAssetAuditTable {
@@ -1,7 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { album_asset_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
AfterDeleteTrigger,
CreateDateColumn,
@@ -10,7 +6,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { album_asset_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table({ name: 'album_asset' })
@UpdatedAtTrigger('album_asset_updatedAt')
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('album_audit')
export class AlbumAuditTable {
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('album_user_audit')
export class AlbumUserAuditTable {
+6 -6
View File
@@ -1,8 +1,3 @@
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
AfterInsertTrigger,
@@ -13,7 +8,12 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album_user' })
// Pre-existing indices from original album <--> user ManyToMany mapping
+6 -6
View File
@@ -1,8 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { album_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
@@ -14,7 +9,12 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { album_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album' })
@UpdatedAtTrigger('album_updatedAt')
+6 -6
View File
@@ -1,6 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { Permission } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
@@ -10,7 +7,10 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { Permission } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
@Table('api_key')
@UpdatedAtTrigger('api_key_updatedAt')
@@ -21,8 +21,8 @@ export class ApiKeyTable {
@Column()
name!: string;
@Column()
key!: string;
@Column({ type: 'bytea', index: true })
key!: Buffer;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('asset_audit')
export class AssetAuditTable {
+7 -7
View File
@@ -1,6 +1,3 @@
import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto';
import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
AfterDeleteTrigger,
AfterInsertTrigger,
@@ -10,7 +7,10 @@ import {
PrimaryGeneratedColumn,
Table,
Unique,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto';
import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('asset_edit')
@AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' })
@@ -21,7 +21,7 @@ import {
when: 'pg_trigger_depth() = 0',
})
@Unique({ columns: ['assetId', 'sequence'] })
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
export class AssetEditTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -29,10 +29,10 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
assetId!: string;
@Column()
action!: T;
action!: AssetEditAction;
@Column({ type: 'jsonb' })
parameters!: AssetEditActionParameter[T];
parameters!: AssetEditParameters;
@Column({ type: 'integer' })
sequence!: number;
+1 -1
View File
@@ -1,7 +1,7 @@
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
import { LockableProperty } from 'src/database';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
@Table('asset_exif')
@UpdatedAtTrigger('asset_exif_updatedAt')
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('asset_face_audit')
export class AssetFaceAuditTable {
+7 -7
View File
@@ -1,9 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table';
import {
AfterDeleteTrigger,
Column,
@@ -15,7 +9,13 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table';
@Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt')
+7 -4
View File
@@ -1,6 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetFileType } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
Column,
CreateDateColumn,
@@ -11,7 +8,10 @@ import {
Timestamp,
Unique,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetFileType } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('asset_file')
@Unique({ columns: ['assetId', 'type', 'isEdited'] })
@@ -43,4 +43,7 @@ export class AssetFileTable {
@Column({ type: 'boolean', default: false })
isProgressive!: Generated<boolean>;
@Column({ type: 'boolean', default: false })
isTransparent!: Generated<boolean>;
}
@@ -1,5 +1,5 @@
import { Column, ForeignKeyColumn, Table, Timestamp } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Table, Timestamp } from 'src/sql-tools';
@Table('asset_job_status')
export class AssetJobStatusTable {
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('asset_metadata_audit')
export class AssetMetadataAuditTable {
@@ -1,7 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetMetadataKey } from 'src/enum';
import { asset_metadata_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import {
AfterDeleteTrigger,
Column,
@@ -11,7 +7,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetMetadataKey } from 'src/enum';
import { asset_metadata_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
@UpdatedAtTrigger('asset_metadata_updated_at')
@Table('asset_metadata')
+1 -1
View File
@@ -1,5 +1,5 @@
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table('asset_ocr')
export class AssetOcrTable {
+8 -8
View File
@@ -1,10 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { asset_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
@@ -17,7 +10,14 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { asset_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
@Table('asset')
+1 -1
View File
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools';
import { DatabaseAction, EntityType } from 'src/enum';
import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools';
@Table('audit')
@Index({ columns: ['ownerId', 'createdAt'] })
@@ -1,5 +1,5 @@
import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Table({ name: 'face_search' })
@Index({
@@ -1,4 +1,4 @@
import { Column, Index, PrimaryColumn, Table, Timestamp } from 'src/sql-tools';
import { Column, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools';
@Table({ name: 'geodata_places', primaryConstraintName: 'geodata_places_pkey' })
@Index({
+3 -3
View File
@@ -1,5 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
@@ -10,7 +8,9 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
@Table('library')
@UpdatedAtTrigger('library_updatedAt')
@@ -1,6 +1,6 @@
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('memory_asset_audit')
export class MemoryAssetAuditTable {
@@ -1,7 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { memory_asset_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import {
AfterDeleteTrigger,
CreateDateColumn,
@@ -10,7 +6,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { memory_asset_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
@Table('memory_asset')
@UpdatedAtTrigger('memory_asset_updatedAt')
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('memory_audit')
export class MemoryAuditTable {
+5 -5
View File
@@ -1,7 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { MemoryType } from 'src/enum';
import { memory_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
@@ -13,7 +9,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { MemoryType } from 'src/enum';
import { memory_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
@Table('memory')
@UpdatedAtTrigger('memory_updatedAt')
+1 -1
View File
@@ -1,5 +1,5 @@
import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools';
import { PathType } from 'src/enum';
import { Column, Generated, PrimaryGeneratedColumn, Table, Unique } from 'src/sql-tools';
@Table('move_history')
// path lock (per entity)
@@ -1,4 +1,4 @@
import { Column, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import { Column, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
@Table({ name: 'naturalearth_countries', primaryConstraintName: 'naturalearth_countries_pkey' })
export class NaturalEarthCountriesTable {
@@ -1,6 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { NotificationLevel, NotificationType } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
@@ -11,7 +8,10 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { NotificationLevel, NotificationType } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
@Table('notification')
@UpdatedAtTrigger('notification_updatedAt')
+1 -1
View File
@@ -1,5 +1,5 @@
import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Table('ocr_search')
@Index({
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('partner_audit')
export class PartnerAuditTable {
+4 -4
View File
@@ -1,6 +1,3 @@
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { partner_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
@@ -10,7 +7,10 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { partner_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
@Table('partner')
@UpdatedAtTrigger('partner_updatedAt')
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('person_audit')
export class PersonAuditTable {
+5 -5
View File
@@ -1,7 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Check,
@@ -13,7 +9,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@UpdatedAtTrigger('person_updatedAt')
+2 -2
View File
@@ -1,4 +1,3 @@
import { PluginContext } from 'src/enum';
import {
Column,
CreateDateColumn,
@@ -9,7 +8,8 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { PluginContext } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
@Table('plugin')
+5 -6
View File
@@ -1,5 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
@@ -9,7 +7,9 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'session' })
@UpdatedAtTrigger('session_updatedAt')
@@ -17,9 +17,8 @@ export class SessionTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
// TODO convert to byte[]
@Column()
token!: string;
@Column({ type: 'bytea', index: true })
token!: Buffer;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@@ -1,6 +1,6 @@
import { ForeignKeyColumn, Table } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('shared_link_asset')
export class SharedLinkAssetTable {
@@ -1,6 +1,3 @@
import { SharedLinkType } from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
@@ -9,7 +6,10 @@ import {
PrimaryGeneratedColumn,
Table,
Timestamp,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { SharedLinkType } from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('shared_link')
export class SharedLinkTable {
@@ -1,5 +1,5 @@
import { Column, ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Table({ name: 'smart_search' })
@Index({
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('stack_audit')
export class StackAuditTable {
+5 -5
View File
@@ -1,7 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { stack_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
CreateDateColumn,
@@ -11,7 +7,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { stack_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('stack')
@UpdatedAtTrigger('stack_updatedAt')
@@ -1,6 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SyncEntityType } from 'src/enum';
import { SessionTable } from 'src/schema/tables/session.table';
import {
Column,
CreateDateColumn,
@@ -10,7 +7,10 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SyncEntityType } from 'src/enum';
import { SessionTable } from 'src/schema/tables/session.table';
@Table('session_sync_checkpoint')
@UpdatedAtTrigger('session_sync_checkpoint_updatedAt')
@@ -1,5 +1,5 @@
import { Column, PrimaryColumn, Table } from '@immich/sql-tools';
import { SystemMetadataKey } from 'src/enum';
import { Column, PrimaryColumn, Table } from 'src/sql-tools';
import { SystemMetadata } from 'src/types';
@Table('system_metadata')
+1 -1
View File
@@ -1,6 +1,6 @@
import { ForeignKeyColumn, Index, Table } from '@immich/sql-tools';
import { AssetTable } from 'src/schema/tables/asset.table';
import { TagTable } from 'src/schema/tables/tag.table';
import { ForeignKeyColumn, Index, Table } from 'src/sql-tools';
@Index({ columns: ['assetId', 'tagId'] })
@Table('tag_asset')
@@ -1,5 +1,5 @@
import { ForeignKeyColumn, Table } from '@immich/sql-tools';
import { TagTable } from 'src/schema/tables/tag.table';
import { ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('tag_closure')
export class TagClosureTable {
+3 -3
View File
@@ -1,5 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
@@ -10,7 +8,9 @@ import {
Timestamp,
Unique,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserTable } from 'src/schema/tables/user.table';
@Table('tag')
@UpdatedAtTrigger('tag_updatedAt')
+1 -1
View File
@@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('user_audit')
export class UserAuditTable {
@@ -1,6 +1,6 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { UserMetadataKey } from 'src/enum';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('user_metadata_audit')
export class UserMetadataAuditTable {
@@ -1,7 +1,3 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserMetadataKey } from 'src/enum';
import { user_metadata_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
@@ -11,7 +7,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserMetadataKey } from 'src/enum';
import { user_metadata_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
import { UserMetadata, UserMetadataItem } from 'src/types';
@UpdatedAtTrigger('user_metadata_updated_at')
+5 -5
View File
@@ -1,7 +1,3 @@
import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserAvatarColor, UserStatus } from 'src/enum';
import { user_delete_audit } from 'src/schema/functions';
import {
AfterDeleteTrigger,
Column,
@@ -13,7 +9,11 @@ import {
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserAvatarColor, UserStatus } from 'src/enum';
import { user_delete_audit } from 'src/schema/functions';
@Table('user')
@UpdatedAtTrigger('user_updatedAt')
@@ -1,4 +1,4 @@
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from 'src/sql-tools';
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools';
@Table('version_history')
export class VersionHistoryTable {
+4 -4
View File
@@ -1,6 +1,3 @@
import { PluginTriggerType } from 'src/enum';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
@@ -10,7 +7,10 @@ import {
PrimaryGeneratedColumn,
Table,
Timestamp,
} from 'src/sql-tools';
} from '@immich/sql-tools';
import { PluginTriggerType } from 'src/enum';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { UserTable } from 'src/schema/tables/user.table';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
@Table('workflow')
+2 -2
View File
@@ -24,7 +24,7 @@ describe(ApiKeyService.name, () => {
await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions });
expect(mocks.apiKey.create).toHaveBeenCalledWith({
key: 'super-secret (hashed)',
key: Buffer.from('super-secret (hashed)'),
name: apiKey.name,
permissions: apiKey.permissions,
userId: apiKey.userId,
@@ -44,7 +44,7 @@ describe(ApiKeyService.name, () => {
await sut.create(auth, { permissions: [Permission.All] });
expect(mocks.apiKey.create).toHaveBeenCalledWith({
key: 'super-secret (hashed)',
key: Buffer.from('super-secret (hashed)'),
name: 'API Key',
permissions: [Permission.All],
userId: auth.user.id,
+2 -2
View File
@@ -10,14 +10,14 @@ import { isGranted } from 'src/utils/access';
export class ApiKeyService extends BaseService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
const hashed = this.cryptoRepository.hashSha256(token);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have');
}
const entity = await this.apiKeyRepository.create({
key: tokenHashed,
key: hashed,
name: dto.name || 'API Key',
userId: auth.user.id,
permissions: dto.permissions,
+10 -9
View File
@@ -21,7 +21,7 @@ import {
mapStats,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditAction, AssetEditActionCrop, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
import { AssetEditAction, AssetEditActionItem, AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import {
AssetFileType,
@@ -543,7 +543,7 @@ export class AssetService extends BaseService {
}
}
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsDto> {
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const edits = await this.assetEditRepository.getAll(id);
return {
@@ -552,7 +552,7 @@ export class AssetService extends BaseService {
};
}
async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise<AssetEditsDto> {
async editAsset(auth: AuthDto, id: string, dto: AssetEditsCreateDto): Promise<AssetEditsResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] });
const asset = await this.assetRepository.getForEdit(id);
@@ -587,12 +587,13 @@ export class AssetService extends BaseService {
throw new BadRequestException('Asset dimensions are not available for editing');
}
const cropIndex = dto.edits.findIndex((e) => e.action === AssetEditAction.Crop);
if (cropIndex > 0) {
throw new BadRequestException('Crop action must be the first edit action');
}
const crop = cropIndex === -1 ? null : (dto.edits[cropIndex] as AssetEditActionCrop);
const edits = dto.edits as AssetEditActionItem[];
const crop = edits.find((e) => e.action === AssetEditAction.Crop);
if (crop) {
if (edits[0].action !== AssetEditAction.Crop) {
throw new BadRequestException('Crop action must be the first edit action');
}
// check that crop parameters will not go out of bounds
const { width: assetWidth, height: assetHeight } = getDimensions(asset);
@@ -606,7 +607,7 @@ export class AssetService extends BaseService {
}
}
const newEdits = await this.assetEditRepository.replaceAll(id, dto.edits);
const newEdits = await this.assetEditRepository.replaceAll(id, edits);
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
// Return the asset and its applied edits
+2 -2
View File
@@ -513,7 +513,7 @@ describe(AuthService.name, () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}),
).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)');
expect(mocks.apiKey.getKey).toHaveBeenCalledWith(Buffer.from('auth_token (hashed)'));
});
it('should throw an error if api key has insufficient permissions', async () => {
@@ -574,7 +574,7 @@ describe(AuthService.name, () => {
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}),
).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) });
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)');
expect(mocks.apiKey.getKey).toHaveBeenCalledWith(Buffer.from('auth_token (hashed)'));
});
});
+7 -7
View File
@@ -456,8 +456,8 @@ export class AuthService extends BaseService {
}
private async validateApiKey(key: string): Promise<AuthDto> {
const hashedKey = this.cryptoRepository.hashSha256(key);
const apiKey = await this.apiKeyRepository.getKey(hashedKey);
const hashed = this.cryptoRepository.hashSha256(key);
const apiKey = await this.apiKeyRepository.getKey(hashed);
if (apiKey?.user) {
return {
user: apiKey.user,
@@ -476,9 +476,9 @@ export class AuthService extends BaseService {
return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
}
private async validateSession(tokenValue: string, headers: IncomingHttpHeaders): Promise<AuthDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
const session = await this.sessionRepository.getByToken(hashedToken);
private async validateSession(token: string, headers: IncomingHttpHeaders): Promise<AuthDto> {
const hashed = this.cryptoRepository.hashSha256(token);
const session = await this.sessionRepository.getByToken(hashed);
if (session?.user) {
const { appVersion, deviceOS, deviceType } = getUserAgentDetails(headers);
const now = DateTime.now();
@@ -543,10 +543,10 @@ export class AuthService extends BaseService {
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
const hashed = this.cryptoRepository.hashSha256(token);
await this.sessionRepository.create({
token: tokenHashed,
token: hashed,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
appVersion: loginDetails.appVersion,
+1 -1
View File
@@ -1,3 +1,4 @@
import { schemaDiff } from '@immich/sql-tools';
import { Injectable } from '@nestjs/common';
import { isAbsolute, join } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
@@ -5,7 +6,6 @@ import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { schemaDiff } from 'src/sql-tools';
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
+85 -12
View File
@@ -348,6 +348,7 @@ describe(MediaService.name, () => {
: { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted
),
);
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
});
it('should skip thumbnail generation if asset not found', async () => {
@@ -467,6 +468,7 @@ describe(MediaService.name, () => {
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
@@ -474,6 +476,7 @@ describe(MediaService.name, () => {
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, thumbhash: thumbhashBuffer });
@@ -508,6 +511,7 @@ describe(MediaService.name, () => {
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
@@ -515,6 +519,7 @@ describe(MediaService.name, () => {
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
});
@@ -548,6 +553,7 @@ describe(MediaService.name, () => {
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
@@ -555,6 +561,7 @@ describe(MediaService.name, () => {
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
});
@@ -770,10 +777,12 @@ describe(MediaService.name, () => {
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: true,
isTransparent: false,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: false,
isTransparent: false,
}),
]);
});
@@ -807,10 +816,12 @@ describe(MediaService.name, () => {
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: false,
isTransparent: false,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: true,
isTransparent: false,
}),
]);
});
@@ -829,10 +840,12 @@ describe(MediaService.name, () => {
expect.objectContaining({
type: AssetFileType.Preview,
isProgressive: false,
isTransparent: false,
}),
expect.objectContaining({
type: AssetFileType.Thumbnail,
isProgressive: false,
isTransparent: false,
}),
]);
});
@@ -857,7 +870,7 @@ describe(MediaService.name, () => {
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
@@ -871,12 +884,39 @@ describe(MediaService.name, () => {
});
});
it('should not check transparency metadata for raw files without extracted images', async () => {
const asset = AssetFactory.from({ originalFileName: 'file.dng' })
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.getImageMetadata).not.toHaveBeenCalled();
});
it('should not check transparency metadata for raw files with extracted images', async () => {
const asset = AssetFactory.from({ originalFileName: 'file.dng' })
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.getImageMetadata).toHaveBeenCalledOnce();
expect(mocks.media.getImageMetadata).toHaveBeenCalledWith(extractedBuffer);
});
it('should resize original image if embedded image is too small', async () => {
const asset = AssetFactory.from({ originalFileName: 'file.dng' })
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
@@ -970,7 +1010,7 @@ describe(MediaService.name, () => {
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: asset.id });
@@ -1008,7 +1048,7 @@ describe(MediaService.name, () => {
image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: asset.id });
@@ -1056,7 +1096,7 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: asset.id });
@@ -1100,7 +1140,7 @@ describe(MediaService.name, () => {
it('should generate full-size preview from non-web-friendly images', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
.exif({
@@ -1139,7 +1179,7 @@ describe(MediaService.name, () => {
const asset = AssetFactory.from().exif().build();
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
await sut.handleGenerateThumbnails({ id: asset.id });
@@ -1162,7 +1202,7 @@ describe(MediaService.name, () => {
it('should always generate full-size preview from non-web-friendly panoramas', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.media.copyTagGroup.mockResolvedValue(true);
const asset = AssetFactory.from({ originalFileName: 'panorama.tif' })
@@ -1208,7 +1248,7 @@ describe(MediaService.name, () => {
image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
// HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari.
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
.exif({
@@ -1248,7 +1288,7 @@ describe(MediaService.name, () => {
image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } },
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
const asset = AssetFactory.from({ originalFileName: 'image.hif' })
.exif({
fileSizeInByte: 5000,
@@ -1286,6 +1326,7 @@ describe(MediaService.name, () => {
: { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted
),
);
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
});
it('should skip videos', async () => {
@@ -1719,7 +1760,7 @@ describe(MediaService.name, () => {
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.getImageDimensions.mockResolvedValue(info);
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
@@ -1802,7 +1843,7 @@ describe(MediaService.name, () => {
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
mocks.media.getImageDimensions.mockResolvedValue(info);
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
@@ -3554,6 +3595,7 @@ describe(MediaService.name, () => {
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
@@ -3561,6 +3603,7 @@ describe(MediaService.name, () => {
path: '/new/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
@@ -3571,6 +3614,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: 'asset-id',
@@ -3578,6 +3622,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail,
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
@@ -3595,6 +3640,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
id: 'file-2',
@@ -3603,6 +3649,7 @@ describe(MediaService.name, () => {
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
],
};
@@ -3614,6 +3661,7 @@ describe(MediaService.name, () => {
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
@@ -3621,6 +3669,7 @@ describe(MediaService.name, () => {
path: '/new/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
@@ -3631,6 +3680,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: 'asset-id',
@@ -3638,6 +3688,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Thumbnail,
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
@@ -3658,6 +3709,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
id: 'file-2',
@@ -3666,6 +3718,7 @@ describe(MediaService.name, () => {
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
],
};
@@ -3681,6 +3734,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
id: 'file-2',
@@ -3689,6 +3743,7 @@ describe(MediaService.name, () => {
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3708,6 +3763,7 @@ describe(MediaService.name, () => {
path: '/same/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
id: 'file-2',
@@ -3716,6 +3772,7 @@ describe(MediaService.name, () => {
path: '/same/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
],
};
@@ -3727,6 +3784,7 @@ describe(MediaService.name, () => {
path: '/same/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
@@ -3734,6 +3792,7 @@ describe(MediaService.name, () => {
path: '/same/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
@@ -3753,6 +3812,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
id: 'file-2',
@@ -3761,6 +3821,7 @@ describe(MediaService.name, () => {
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
],
};
@@ -3772,6 +3833,7 @@ describe(MediaService.name, () => {
path: '/new/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
}, // replace
{
assetId: asset.id,
@@ -3779,6 +3841,7 @@ describe(MediaService.name, () => {
path: '/new/fullsize.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
}, // new
]);
@@ -3789,6 +3852,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
assetId: 'asset-id',
@@ -3796,6 +3860,7 @@ describe(MediaService.name, () => {
type: AssetFileType.FullSize,
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
@@ -3806,6 +3871,7 @@ describe(MediaService.name, () => {
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3838,6 +3904,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
],
};
@@ -3853,6 +3920,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3872,6 +3940,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
{
id: 'file-2',
@@ -3880,6 +3949,7 @@ describe(MediaService.name, () => {
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
],
};
@@ -3891,6 +3961,7 @@ describe(MediaService.name, () => {
path: '/old/preview.jpg',
isEdited: false,
isProgressive: true,
isTransparent: false,
},
{
assetId: asset.id,
@@ -3898,6 +3969,7 @@ describe(MediaService.name, () => {
path: '/old/thumbnail.jpg',
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
@@ -3908,6 +3980,7 @@ describe(MediaService.name, () => {
type: AssetFileType.Preview,
isEdited: false,
isProgressive: true,
isTransparent: false,
},
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
+64 -21
View File
@@ -52,6 +52,7 @@ interface UpsertFileOptions {
path: string;
isEdited: boolean;
isProgressive: boolean;
isTransparent: boolean;
}
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
@@ -280,14 +281,20 @@ export class MediaService extends BaseService {
useEdits;
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
const thumbSource = extracted ? extracted.buffer : asset.originalPath;
const { data, info, colorspace } = await this.decodeImage(
extracted ? extracted.buffer : asset.originalPath,
thumbSource,
// only specify orientation to extracted images which don't have EXIF orientation data
// or it can double rotate the image
extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null },
convertFullsize ? undefined : image.preview.size,
);
let isTransparent = false;
if (!extracted && mimeTypes.canBeTransparent(asset.originalPath)) {
({ isTransparent } = await this.mediaRepository.getImageMetadata(asset.originalPath));
}
return {
extracted,
data,
@@ -295,50 +302,64 @@ export class MediaService extends BaseService {
colorspace,
convertFullsize,
generateFullsize,
isTransparent,
};
}
private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
// Handle embedded preview extraction for RAW files
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
const { info, data, colorspace, generateFullsize, convertFullsize, extracted, isTransparent } = extractedImage;
const previewFormat = image.preview.format;
this.warnOnTransparencyLoss(isTransparent, previewFormat, asset.id);
const thumbnailFormat = image.thumbnail.format;
this.warnOnTransparencyLoss(isTransparent, thumbnailFormat, asset.id);
const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview,
format: image.preview.format,
format: previewFormat,
isEdited: useEdits,
isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp,
isTransparent,
});
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
format: thumbnailFormat,
isEdited: useEdits,
isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp,
isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp,
isTransparent,
});
this.storageCore.ensureFolders(previewFile.path);
// Handle embedded preview extraction for RAW files
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage;
// generate final images
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const baseOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat };
const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat };
const promises = [
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path),
this.mediaRepository.generateThumbhash(data, baseOptions),
this.mediaRepository.generateThumbnail(data, thumbnailOptions, thumbnailFile.path),
this.mediaRepository.generateThumbnail(data, previewOptions, previewFile.path),
];
let fullsizeFile: UpsertFileOptions | undefined;
if (convertFullsize) {
const fullsizeFormat = image.fullsize.format;
this.warnOnTransparencyLoss(isTransparent, fullsizeFormat, asset.id);
// convert a new fullsize image from the same source as the thumbnail
fullsizeFile = this.getImageFile(asset, {
fileType: AssetFileType.FullSize,
format: image.fullsize.format,
format: fullsizeFormat,
isEdited: useEdits,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp,
isTransparent,
});
const fullsizeOptions = {
format: image.fullsize.format,
...baseOptions,
format: fullsizeFormat,
quality: image.fullsize.quality,
progressive: image.fullsize.progressive,
...thumbnailOptions,
};
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
@@ -347,6 +368,7 @@ export class MediaService extends BaseService {
format: extracted.format,
isEdited: false,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
isTransparent,
});
this.storageCore.ensureFolders(fullsizeFile.path);
@@ -493,12 +515,14 @@ export class MediaService extends BaseService {
format: image.preview.format,
isEdited: false,
isProgressive: false,
isTransparent: false,
});
const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: false,
isProgressive: false,
isTransparent: false,
});
this.storageCore.ensureFolders(previewFile.path);
@@ -758,7 +782,7 @@ export class MediaService extends BaseService {
}
private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) {
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer);
const { width, height } = await this.mediaRepository.getImageMetadata(extractedPathOrBuffer);
const extractedSize = Math.min(width, height);
return extractedSize >= targetSize;
}
@@ -785,7 +809,10 @@ export class MediaService extends BaseService {
}
}
private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) {
private async syncFiles(
oldFiles: (AssetFile & { isProgressive: boolean; isTransparent: boolean })[],
newFiles: UpsertFileOptions[],
) {
const toUpsert: UpsertFileOptions[] = [];
const pathsToDelete: string[] = [];
const toDelete = new Set(oldFiles);
@@ -797,7 +824,11 @@ export class MediaService extends BaseService {
}
// upsert new file path
if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) {
if (
existingFile?.path !== newFile.path ||
existingFile.isProgressive !== newFile.isProgressive ||
existingFile.isTransparent !== newFile.isTransparent
) {
toUpsert.push(newFile);
// delete old file from disk
@@ -857,7 +888,18 @@ export class MediaService extends BaseService {
return generated;
}
private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {
private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) {
if (isTransparent && format === ImageFormat.Jpeg) {
this.logger.warn(
`Asset ${assetId} has transparency but the configured format is ${format} which does not support it, consider using a format that does, such as ${ImageFormat.Webp}`,
);
}
}
private getImageFile(
asset: ThumbnailPathEntity,
options: ImagePathOptions & { isProgressive: boolean; isTransparent: boolean },
) {
const path = StorageCore.getImagePath(asset, options);
return {
assetId: asset.id,
@@ -865,6 +907,7 @@ export class MediaService extends BaseService {
path,
isEdited: options.isEdited,
isProgressive: options.isProgressive,
isTransparent: options.isTransparent,
};
}
}
+2
View File
@@ -100,6 +100,8 @@ export class MemoryService extends BaseService {
data: dto.data,
isSaved: dto.isSaved,
memoryAt: dto.memoryAt,
showAt: dto.showAt,
hideAt: dto.hideAt,
seenAt: dto.seenAt,
},
allowedAssetIds,
+2 -2
View File
@@ -169,7 +169,7 @@ export class MetadataService extends BaseService {
this.logger.log(`Initialized local reverse geocoder`);
} catch (error: Error | any) {
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
throw new Error(`Metadata service init failed`);
throw new Error('Metadata service init failed', { cause: error });
}
}
@@ -286,7 +286,7 @@ export class MetadataService extends BaseService {
orientation: validate(exifTags.Orientation)?.toString() ?? null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace ?? null,
colorspace: exifTags.ColorSpace === undefined ? null : String(exifTags.ColorSpace),
// camera
make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null,
@@ -59,7 +59,7 @@ export class NotificationAdminService extends BaseService {
async getTemplate(name: EmailTemplate, customTemplate: string) {
const { server, templates } = await this.getConfig({ withCache: false });
let templateResponse = '';
let templateResponse: string;
switch (name) {
case EmailTemplate.WELCOME: {
+1 -1
View File
@@ -134,7 +134,7 @@ export class NotificationService extends BaseService {
}
} catch (error: Error | any) {
this.logger.error(`Failed to validate SMTP configuration: ${error}`, error?.stack);
throw new Error(`Invalid SMTP configuration: ${error}`);
throw new Error('Invalid SMTP configuration', { cause: error });
}
}
+2 -2
View File
@@ -33,14 +33,14 @@ export class SessionService extends BaseService {
}
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
const hashed = this.cryptoRepository.hashSha256(token);
const session = await this.sessionRepository.create({
parentId: auth.session.id,
userId: auth.user.id,
expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
deviceType: dto.deviceType,
deviceOS: dto.deviceOS,
token: tokenHashed,
token: hashed,
});
return { ...mapSession(session), token };
@@ -69,8 +69,9 @@ describe(SharedLinkService.name, () => {
it('should accept a valid shared link auth token', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token');
await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined();
const secret = Buffer.from('auth-token-123');
mocks.crypto.hashSha256.mockReturnValue(secret);
await expect(sut.getMine(authStub.adminSharedLink, [secret.toString('base64')])).resolves.toBeDefined();
expect(mocks.sharedLink.get).toHaveBeenCalledWith(
authStub.adminSharedLink.user.id,
authStub.adminSharedLink.sharedLink?.id,

Some files were not shown because too many files have changed in this diff Show More