Merge branch 'main' into refactor/nonnegative-rating

This commit is contained in:
Mees Frensel
2026-04-29 16:14:20 +02:00
851 changed files with 39426 additions and 10740 deletions
+1 -1
View File
@@ -1 +1 @@
24.14.1
24.15.0
+13 -17
View File
@@ -26,7 +26,6 @@
"test": "vitest --config test/vitest.config.mjs",
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs",
"typeorm": "typeorm",
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
@@ -40,30 +39,29 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@immich/sql-tools": "^0.3.2",
"@immich/sql-tools": "^0.5.1",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
"@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "^11.0.2",
"@nestjs/swagger": "^11.4.2",
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.214.0",
"@opentelemetry/instrumentation-http": "^0.214.0",
"@opentelemetry/instrumentation-ioredis": "^0.62.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0",
"@opentelemetry/instrumentation-pg": "^0.66.0",
"@opentelemetry/exporter-prometheus": "^0.215.0",
"@opentelemetry/instrumentation-http": "^0.215.0",
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
"@opentelemetry/instrumentation-pg": "^0.67.0",
"@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.214.0",
"@opentelemetry/sdk-node": "^0.215.0",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^1.0.0",
"@react-email/render": "^2.0.0",
"@socket.io/redis-adapter": "^8.3.0",
"ajv": "^8.17.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
@@ -86,7 +84,7 @@
"jose": "^6.0.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.15",
"kysely": "0.28.16",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
@@ -96,13 +94,12 @@
"nestjs-cls": "^6.0.0",
"nestjs-kysely": "3.1.2",
"nestjs-otel": "^7.0.0",
"nodemailer": "^8.0.0",
"nestjs-zod": "^5.3.0",
"nodemailer": "^8.0.0",
"openid-client": "^6.3.3",
"pg": "^8.11.3",
"pg-connection-string": "^2.9.1",
"picomatch": "^4.0.2",
"postgres": "3.4.8",
"postgres": "3.4.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-email": "^5.0.0",
@@ -117,7 +114,7 @@
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"uuid": "^14.0.0",
"validator": "^13.12.0",
"zod": "^4.3.6"
},
@@ -157,7 +154,6 @@
"eslint-plugin-unicorn": "^64.0.0",
"globals": "^17.0.0",
"mock-fs": "^5.2.0",
"node-gyp": "^12.0.0",
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
@@ -172,7 +168,7 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.14.1"
"node": "24.15.0"
},
"overrides": {
"sharp": "^0.34.5"
+2 -1
View File
@@ -1,5 +1,5 @@
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import { json, urlencoded } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmetMiddleware from 'helmet';
@@ -56,6 +56,7 @@ export async function configureExpress(
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
app.use(urlencoded({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
+4
View File
@@ -104,8 +104,10 @@ export type SystemConfig = {
defaultStorageQuota: number | null;
enabled: boolean;
issuerUrl: string;
endSessionEndpoint: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
prompt: string;
scope: string;
signingAlgorithm: string;
profileSigningAlgorithm: string;
@@ -296,8 +298,10 @@ export const defaults = Object.freeze<SystemConfig>({
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
-4
View File
@@ -1,4 +1,3 @@
import { Duration } from 'luxon';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
@@ -52,9 +51,6 @@ const packageFile = join(basePath, '..', 'package.json');
const { version } = JSON.parse(readFileSync(packageFile, 'utf8'));
export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const citiesFile = 'cities500.txt';
export const reverseGeocodeMaxDistance = 25_000;
@@ -96,6 +96,12 @@ describe(MemoryController.name, () => {
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined']));
});
it('should require at least one field', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['At least one field must be provided']));
});
});
describe('DELETE /memories/:id', () => {
+15 -1
View File
@@ -1,11 +1,12 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AuthDto,
LoginResponseDto,
OAuthAuthorizeResponseDto,
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
} from 'src/dtos/auth.dto';
@@ -112,4 +113,17 @@ export class OAuthController {
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth);
}
@Post('backchannel-logout')
@HttpCode(HttpStatus.OK)
@ApiConsumes('application/x-www-form-urlencoded')
@Endpoint({
summary: 'Backchannel OAuth logout',
description:
'Logout the OAuth account and invalidate the session specified by the sid claim or all sessions if the sid claim is not present.',
history: new HistoryBuilder().added('v2'),
})
async logoutOAuth(@Body() dto: OAuthBackchannelLogoutDto): Promise<void> {
return this.service.backchannelLogout(dto);
}
}
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
});
it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
});
@@ -11,7 +11,6 @@ import {
ServerPingResponse,
ServerStatsResponseDto,
ServerStorageResponseDto,
ServerThemeDto,
ServerVersionHistoryResponseDto,
ServerVersionResponseDto,
} from 'src/dtos/server.dto';
@@ -104,16 +103,6 @@ export class ServerController {
return this.service.getFeatures();
}
@Get('theme')
@Endpoint({
summary: 'Get theme',
description: 'Retrieve the custom CSS, if existent.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getTheme(): Promise<ServerThemeDto> {
return this.service.getTheme();
}
@Get('config')
@Endpoint({
summary: 'Get config',
+1 -4
View File
@@ -18,7 +18,7 @@ import {
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
@@ -195,7 +195,6 @@ export type SharedLink = {
};
export type Album = Selectable<AlbumTable> & {
owner: ShallowDehydrateObject<User>;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
};
@@ -277,8 +276,6 @@ export type AssetFace = {
isVisible: boolean;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
methodName: string;
title: string;
+1 -12
View File
@@ -1,6 +1,6 @@
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
import { ApiOperation, ApiOperationOptions, ApiTags } from '@nestjs/swagger';
import _ from 'lodash';
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
import { EmitEvent } from 'src/repositories/event.repository';
@@ -172,17 +172,6 @@ export const Endpoint = ({ history, ...options }: EndpointOptions) => {
return applyDecorators(...decorators);
};
export type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
export const Property = ({ history, ...options }: PropertyOptions) => {
const extensions = history?.getExtensions() ?? {};
if (history?.isDeprecated()) {
options.deprecated = true;
}
return ApiProperty({ ...options, ...extensions });
};
type HistoryEntry = {
version: string;
state: ApiState | 'Added' | 'Updated';
+9 -13
View File
@@ -1,7 +1,6 @@
import { ShallowDehydrateObject } from 'kysely';
import _ from 'lodash';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
@@ -104,7 +103,6 @@ const ContributorCountResponseSchema = z
export const AlbumResponseSchema = z
.object({
id: z.string().describe('Album ID'),
ownerId: z.string().describe('Owner user ID'),
albumName: z.string().describe('Album name'),
description: z.string().describe('Album description'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -113,9 +111,13 @@ export const AlbumResponseSchema = z
updatedAt: z.string().meta({ format: 'date-time' }).describe('Last update date'),
albumThumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
shared: z.boolean().describe('Is shared album'),
albumUsers: z.array(AlbumUserResponseSchema),
albumUsers: z
.array(AlbumUserResponseSchema)
.min(1)
.describe(
'First entry is always the album owner. Second entry is the auth user, if it differs from the owner. The rest are ordered alphabetically.',
),
hasSharedLink: z.boolean().describe('Has shared link'),
owner: UserResponseSchema,
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
lastModifiedAssetTimestamp: z
@@ -155,8 +157,6 @@ export type MapAlbumDto = {
createdAt: Date;
updatedAt: Date;
id: string;
ownerId: string;
owner: ShallowDehydrateObject<User>;
isActivityEnabled: boolean;
order: AssetOrder;
};
@@ -174,12 +174,10 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
}
}
const albumUsersSorted = _.orderBy(albumUsers, ['role', 'user.name']);
const assets = entity.assets || [];
const hasSharedLink = !!entity.sharedLinks && entity.sharedLinks.length > 0;
const hasSharedUser = albumUsers.length > 0;
const hasSharedUser = albumUsers.length > 1;
let startDate = assets.at(0)?.localDateTime;
let endDate = assets.at(-1)?.localDateTime;
@@ -195,9 +193,7 @@ export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto
createdAt: asDateString(entity.createdAt),
updatedAt: asDateString(entity.updatedAt),
id: entity.id,
ownerId: entity.ownerId,
owner: mapUser(entity.owner),
albumUsers: albumUsersSorted,
albumUsers,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
startDate: asDateString(startDate),
@@ -3,7 +3,6 @@ import z from 'zod';
export enum AssetMediaStatus {
CREATED = 'created',
REPLACED = 'replaced',
DUPLICATE = 'duplicate',
}
+2 -2
View File
@@ -50,8 +50,8 @@ const SanitizedAssetResponseSchema = z
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.number().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
})
.meta({ id: 'SanitizedAssetResponseDto' });
+1 -1
View File
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
});
+5
View File
@@ -124,6 +124,10 @@ const OAuthAuthorizeResponseSchema = z
})
.meta({ id: 'OAuthAuthorizeResponseDto' });
const OAuthBackchannelLogoutSchema = z
.object({ logout_token: z.string().describe('OAuth logout token') })
.meta({ id: 'OAuthBackchannelLogoutDto' });
const AuthStatusResponseSchema = z
.object({
pinCode: z.boolean().describe('Has PIN code set'),
@@ -147,4 +151,5 @@ export class ValidateAccessTokenResponseDto extends createZodDto(ValidateAccessT
export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z
.object({
filename: z.string().describe('Backup filename'),
filesize: z.number().describe('Backup file size'),
filesize: z.int().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'),
})
.meta({ id: 'DatabaseBackupDto' });
+4 -4
View File
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z
.object({
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'),
x: z.int().min(0).describe('Top-Left X coordinate of crop'),
y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
width: z.int().min(1).describe('Width of the crop'),
height: z.int().min(1).describe('Height of the crop'),
})
.meta({ id: 'CropParameters' });
+3 -3
View File
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
.object({
make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -20,7 +20,7 @@ export const ExifResponseSchema = z
lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.number().nullish().default(null).describe('ISO sensitivity'),
iso: z.int().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'),
+2 -2
View File
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({
active: z.boolean(),
action: MaintenanceActionSchema,
progress: z.number().optional(),
progress: z.int().optional(),
task: z.string().optional(),
error: z.string().optional(),
})
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'),
files: z.number().describe('Number of files in the folder'),
files: z.int().describe('Number of files in the folder'),
})
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
+6 -8
View File
@@ -4,7 +4,7 @@ import { HistoryBuilder } from 'src/decorators';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandomSchema, MemoryType, MemoryTypeSchema } from 'src/enum';
import { isoDatetimeToDate, stringToBool } from 'src/validation';
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import z from 'zod';
const MemorySearchSchema = z
@@ -26,13 +26,11 @@ const OnThisDaySchema = z
type MemoryData = z.infer<typeof OnThisDaySchema>;
const MemoryUpdateSchema = z
.object({
isSaved: z.boolean().optional().describe('Is memory saved'),
seenAt: isoDatetimeToDate.optional().describe('Date when memory was seen'),
memoryAt: isoDatetimeToDate.optional().describe('Memory date'),
})
.meta({ id: 'MemoryUpdateDto' });
const MemoryUpdateSchema = nonEmptyPartial({
isSaved: z.boolean().describe('Is memory saved'),
seenAt: isoDatetimeToDate.describe('Date when memory was seen'),
memoryAt: isoDatetimeToDate.describe('Memory date'),
}).meta({ id: 'MemoryUpdateDto' });
const MemoryCreateSchema = z
.object({
+2 -2
View File
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
})
.meta({ id: 'PersonSearchDto' });
+6 -5
View File
@@ -35,8 +35,9 @@ const BaseSearchSchema = z.object({
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z
.int()
.min(1)
.min(0)
.max(5)
.transform((value) => (value === 0 ? null : value))
.nullish()
.describe('Filter by rating [1-5], or null for unrated')
.meta({
@@ -53,7 +54,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
});
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -63,7 +64,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -76,7 +77,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -87,7 +88,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z
-7
View File
@@ -104,12 +104,6 @@ const ServerMediaTypesResponseSchema = z
})
.meta({ id: 'ServerMediaTypesResponseDto' });
const ServerThemeSchema = z
.object({
customCss: z.string().describe('Custom CSS for theming'),
})
.meta({ id: 'ServerThemeDto' });
const ServerConfigSchema = z
.object({
oauthButtonText: z.string().describe('OAuth button text'),
@@ -161,7 +155,6 @@ export class ServerVersionHistoryResponseDto extends createZodDto(ServerVersionH
export class UsageByUserDto extends createZodDto(UsageByUserSchema) {}
export class ServerStatsResponseDto extends createZodDto(ServerStatsResponseSchema) {}
export class ServerMediaTypesResponseDto extends createZodDto(ServerMediaTypesResponseSchema) {}
export class ServerThemeDto extends createZodDto(ServerThemeSchema) {}
export class ServerConfigDto extends createZodDto(ServerConfigSchema) {}
export class ServerFeaturesDto extends createZodDto(ServerFeaturesSchema) {}
+1 -1
View File
@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z
.object({
duration: z.number().min(1).optional().describe('Session duration in seconds'),
duration: z.int().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'),
})
+26
View File
@@ -2,6 +2,7 @@
import { createZodDto } from 'nestjs-zod';
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
AlbumUserRoleSchema,
AssetOrderSchema,
AssetTypeSchema,
@@ -211,6 +212,19 @@ const SyncAlbumV1Schema = z
})
.meta({ id: 'SyncAlbumV1' });
const SyncAlbumV2Schema = z
.object({
id: z.string().describe('Album ID'),
name: z.string().describe('Album name'),
description: z.string().describe('Album description'),
createdAt: isoDatetimeToDate.describe('Created at'),
updatedAt: isoDatetimeToDate.describe('Updated at'),
thumbnailAssetId: z.string().nullable().describe('Thumbnail asset ID'),
isActivityEnabled: z.boolean().describe('Is activity enabled'),
order: AssetOrderSchema,
})
.meta({ id: 'SyncAlbumV2' });
const SyncAlbumToAssetV1Schema = z
.object({
albumId: z.string().describe('Album ID'),
@@ -234,10 +248,21 @@ class SyncAlbumUserV1 extends createZodDto(SyncAlbumUserV1Schema) {}
@ExtraModel()
class SyncAlbumV1 extends createZodDto(SyncAlbumV1Schema) {}
@ExtraModel()
class SyncAlbumV2 extends createZodDto(SyncAlbumV2Schema) {}
@ExtraModel()
class SyncAlbumToAssetV1 extends createZodDto(SyncAlbumToAssetV1Schema) {}
@ExtraModel()
class SyncAlbumToAssetDeleteV1 extends createZodDto(SyncAlbumToAssetDeleteV1Schema) {}
export function syncAlbumV2ToV1(
albumV2: SyncAlbumV2,
albumUsers: { userId: string; role: AlbumUserRole }[],
): SyncAlbumV1 {
const owner = albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
return { ...albumV2, ownerId: owner.userId };
}
const SyncMemoryV1Schema = z
.object({
id: z.string().describe('Memory ID'),
@@ -407,6 +432,7 @@ export type SyncItem = {
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1;
[SyncEntityType.AlbumV1]: SyncAlbumV1;
[SyncEntityType.AlbumV2]: SyncAlbumV2;
[SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
+12 -5
View File
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({
enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema,
keepLastAmount: z.number().min(1).describe('Keep last amount'),
keepLastAmount: z.int().min(1).describe('Keep last amount'),
})
.meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z
.object({
enabled: configBool.describe('Enabled'),
timeout: z.number(),
interval: z.number(),
timeout: z.int(),
interval: z.int(),
})
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'),
defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'),
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled'),
issuerUrl: z
.string()
@@ -189,6 +189,13 @@ const SystemConfigOAuthSchema = z
})
.describe('Issuer URL'),
scope: z.string().describe('Scope'),
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
endSessionEndpoint: z
.string()
.refine((url) => url.length === 0 || z.url().safeParse(url).success, {
error: 'endSessionEndpoint must be an empty string or a valid URL',
})
.describe('End session endpoint'),
signingAlgorithm: z.string().describe('Signing algorithm'),
profileSigningAlgorithm: z.string().describe('Profile signing algorithm'),
storageLabelClaim: z.string().describe('Storage label claim'),
@@ -247,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'),
port: z.number().min(0).max(65_535).describe('SMTP server port'),
port: z.int().min(0).max(65_535).describe('SMTP server port'),
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'),
+2 -2
View File
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(),
order: z.number().describe('Filter order'),
order: z.int().describe('Filter order'),
})
.meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(),
order: z.number().describe('Action order'),
order: z.int().describe('Action order'),
})
.meta({ id: 'WorkflowActionResponseDto' });
+4 -95
View File
@@ -5,8 +5,6 @@ export enum AuthType {
OAuth = 'oauth',
}
export const AuthTypeSchema = z.enum(AuthType).describe('Auth type').meta({ id: 'AuthType' });
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
@@ -17,8 +15,6 @@ export enum ImmichCookie {
OAuthCodeVerifier = 'immich_oauth_code_verifier',
}
export const ImmichCookieSchema = z.enum(ImmichCookie).describe('Immich cookie').meta({ id: 'ImmichCookie' });
export enum ImmichHeader {
ApiKey = 'x-api-key',
UserToken = 'x-immich-user-token',
@@ -26,11 +22,9 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
CorrelationId = 'X-Correlation-ID',
}
export const ImmichHeaderSchema = z.enum(ImmichHeader).describe('Immich header').meta({ id: 'ImmichHeader' });
export enum ImmichQuery {
SharedLinkKey = 'key',
SharedLinkSlug = 'slug',
@@ -38,8 +32,6 @@ export enum ImmichQuery {
SessionKey = 'sessionKey',
}
export const ImmichQuerySchema = z.enum(ImmichQuery).describe('Immich query').meta({ id: 'ImmichQuery' });
export enum AssetType {
Image = 'IMAGE',
Video = 'VIDEO',
@@ -56,11 +48,6 @@ export enum ChecksumAlgorithm {
sha1Path = 'sha1-path',
}
export const ChecksumAlgorithmSchema = z
.enum(ChecksumAlgorithm)
.describe('Checksum algorithm')
.meta({ id: 'ChecksumAlgorithmEnum' });
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
@@ -72,10 +59,9 @@ export enum AssetFileType {
EncodedVideo = 'encoded_video',
}
export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Asset file type').meta({ id: 'AssetFileType' });
export enum AlbumUserRole {
Editor = 'editor',
Owner = 'owner',
Viewer = 'viewer',
}
@@ -313,8 +299,6 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export const PermissionSchema = z.enum(Permission).describe('Permission').meta({ id: 'Permission' });
export enum SharedLinkType {
Album = 'ALBUM',
@@ -351,11 +335,6 @@ export enum SystemMetadataKey {
License = 'license',
}
export const SystemMetadataKeySchema = z
.enum(SystemMetadataKey)
.describe('System metadata key')
.meta({ id: 'SystemMetadataKey' });
export enum UserMetadataKey {
Preferences = 'preferences',
License = 'license',
@@ -371,11 +350,6 @@ export enum AssetMetadataKey {
MobileApp = 'mobile-app',
}
export const AssetMetadataKeySchema = z
.enum(AssetMetadataKey)
.describe('Asset metadata key')
.meta({ id: 'AssetMetadataKey' });
export enum UserAvatarColor {
Primary = 'primary',
Pink = 'pink',
@@ -408,8 +382,6 @@ export enum AssetStatus {
Deleted = 'deleted',
}
export const AssetStatusSchema = z.enum(AssetStatus).describe('Asset status').meta({ id: 'AssetStatus' });
export enum SourceType {
MachineLearning = 'machine-learning',
Exif = 'exif',
@@ -434,20 +406,14 @@ export enum AssetPathType {
EncodedVideo = 'encoded_video',
}
export const AssetPathTypeSchema = z.enum(AssetPathType).describe('Asset path type').meta({ id: 'AssetPathType' });
export enum PersonPathType {
Face = 'face',
}
export const PersonPathTypeSchema = z.enum(PersonPathType).describe('Person path type').meta({ id: 'PersonPathType' });
export enum UserPathType {
Profile = 'profile',
}
export const UserPathTypeSchema = z.enum(UserPathType).describe('User path type').meta({ id: 'UserPathType' });
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy {
@@ -470,11 +436,6 @@ export enum TranscodeTarget {
All = 'ALL',
}
export const TranscodeTargetSchema = z
.enum(TranscodeTarget)
.describe('Transcode target')
.meta({ id: 'TranscodeTarget' });
export enum VideoCodec {
H264 = 'h264',
Hevc = 'hevc',
@@ -556,11 +517,6 @@ export enum RawExtractedFormat {
Jxl = 'jxl',
}
export const RawExtractedFormatSchema = z
.enum(RawExtractedFormat)
.describe('Raw extracted format')
.meta({ id: 'RawExtractedFormat' });
export enum LogLevel {
Verbose = 'verbose',
Debug = 'debug',
@@ -586,38 +542,25 @@ export enum ApiCustomExtension {
State = 'x-immich-state',
}
export const ApiCustomExtensionSchema = z
.enum(ApiCustomExtension)
.describe('API custom extension')
.meta({ id: 'ApiCustomExtension' });
export enum MetadataKey {
AuthRoute = 'auth_route',
AdminRoute = 'admin_route',
SharedRoute = 'shared_route',
ApiKeySecurity = 'api_key',
EventConfig = 'event_config',
JobConfig = 'job_config',
TelemetryEnabled = 'telemetry_enabled',
}
export const MetadataKeySchema = z.enum(MetadataKey).describe('Metadata key').meta({ id: 'MetadataKey' });
export enum RouteKey {
Asset = 'assets',
User = 'users',
}
export const RouteKeySchema = z.enum(RouteKey).describe('Route key').meta({ id: 'RouteKey' });
export enum CacheControl {
PrivateWithCache = 'private_with_cache',
PrivateWithoutCache = 'private_without_cache',
None = 'none',
}
export const CacheControlSchema = z.enum(CacheControl).describe('Cache control').meta({ id: 'CacheControl' });
export enum ImmichEnvironment {
Development = 'development',
Testing = 'testing',
@@ -635,8 +578,6 @@ export enum ImmichWorker {
Microservices = 'microservices',
}
export const ImmichWorkerSchema = z.enum(ImmichWorker).describe('Immich worker').meta({ id: 'ImmichWorker' });
export enum ImmichTelemetry {
Host = 'host',
Api = 'api',
@@ -645,11 +586,6 @@ export enum ImmichTelemetry {
Job = 'job',
}
export const ImmichTelemetrySchema = z
.enum(ImmichTelemetry)
.describe('Immich telemetry')
.meta({ id: 'ImmichTelemetry' });
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,
@@ -661,11 +597,6 @@ export enum ExifOrientation {
Rotate270CW = 8,
}
export const ExifOrientationSchema = z
.enum(ExifOrientation)
.describe('EXIF orientation')
.meta({ id: 'ExifOrientation' });
export enum DatabaseExtension {
Cube = 'cube',
EarthDistance = 'earthdistance',
@@ -674,11 +605,6 @@ export enum DatabaseExtension {
VectorChord = 'vchord',
}
export const DatabaseExtensionSchema = z
.enum(DatabaseExtension)
.describe('Database extension')
.meta({ id: 'DatabaseExtension' });
export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access
DatabaseService = -200,
@@ -690,11 +616,6 @@ export enum BootstrapEventPriority {
SystemConfig = 100,
}
export const BootstrapEventPrioritySchema = z
.enum(BootstrapEventPriority)
.describe('Bootstrap event priority')
.meta({ id: 'BootstrapEventPriority' });
export enum QueueName {
ThumbnailGeneration = 'thumbnailGeneration',
MetadataExtraction = 'metadataExtraction',
@@ -833,21 +754,15 @@ export enum JobStatus {
Skipped = 'skipped',
}
export const JobStatusSchema = z.enum(JobStatus).describe('Job status').meta({ id: 'JobStatus' });
export enum QueueCleanType {
Failed = 'failed',
}
export const QueueCleanTypeSchema = z.enum(QueueCleanType).describe('Queue clean type').meta({ id: 'QueueCleanType' });
export enum VectorIndex {
Clip = 'clip_index',
Face = 'face_index',
}
export const VectorIndexSchema = z.enum(VectorIndex).describe('Vector index').meta({ id: 'VectorIndex' });
export enum DatabaseLock {
GeodataImport = 100,
Migrations = 200,
@@ -865,8 +780,6 @@ export enum DatabaseLock {
VersionCheck = 800,
}
export const DatabaseLockSchema = z.enum(DatabaseLock).describe('Database lock').meta({ id: 'DatabaseLock' });
export enum MaintenanceAction {
Start = 'start',
End = 'end',
@@ -883,10 +796,9 @@ export enum ExitCode {
AppRestart = 7,
}
export const ExitCodeSchema = z.enum(ExitCode).describe('Exit code').meta({ id: 'ExitCode' });
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumsV2 = 'AlbumsV2',
AlbumUsersV1 = 'AlbumUsersV1',
AlbumToAssetsV1 = 'AlbumToAssetsV1',
AlbumAssetsV1 = 'AlbumAssetsV1',
@@ -942,6 +854,7 @@ export enum SyncEntityType {
PartnerStackV1 = 'PartnerStackV1',
AlbumV1 = 'AlbumV1',
AlbumV2 = 'AlbumV2',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',
@@ -1043,8 +956,6 @@ export enum CronJob {
VersionCheck = 'VersionCheck',
}
export const CronJobSchema = z.enum(CronJob).describe('Cron job').meta({ id: 'CronJob' });
export enum ApiTag {
Activities = 'Activities',
Albums = 'Albums',
@@ -1085,8 +996,6 @@ export enum ApiTag {
Workflows = 'Workflows',
}
export const ApiTagSchema = z.enum(ApiTag).describe('API tag').meta({ id: 'ApiTag' });
export enum PluginContext {
Asset = 'asset',
Album = 'album',
@@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/co
import { Response } from 'express';
import { ClsService } from 'nestjs-cls';
import { ZodSerializationException, ZodValidationException } from 'nestjs-zod';
import { ImmichHeader } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { logGlobalError } from 'src/utils/logger';
import { ZodError } from 'zod';
@@ -16,18 +17,13 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
}
catch(error: Error, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
}
this.handleError(host.switchToHttp().getResponse<Response>(), error);
}
handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error);
if (!res.headersSent) {
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
res.header(ImmichHeader.CorrelationId, this.cls.getId()).status(status).json(body);
}
}
@@ -36,26 +32,24 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
if (error instanceof HttpException) {
const status = error.getStatus();
let body = error.getResponse();
// unclear what circumstances would return a string
if (typeof body === 'string') {
body = { message: body };
}
const response = error.getResponse();
const body: Record<string, unknown> =
typeof response === 'string' ? { message: response } : { ...(response as object) };
// handle both request and response validation errors
if (error instanceof ZodValidationException || error instanceof ZodSerializationException) {
const zodError = error.getZodError();
if (zodError instanceof ZodError && zodError.issues.length > 0) {
body = {
message: zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
),
error: 'Bad Request',
};
body['message'] = zodError.issues.map((issue) =>
issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message,
);
}
}
// remove fields that duplicate the HTTP response line or will be reformatted in a later step
delete body['error'];
delete body['statusCode'];
delete body['errors'];
return { status, body };
}
+15 -17
View File
@@ -14,27 +14,26 @@ select
"activity"."id"
from
"activity"
left join "album" on "activity"."albumId" = "album"."id"
inner join "album" on "activity"."albumId" = "album"."id"
and "album"."deletedAt" is null
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1::uuid
where
"activity"."id" in ($1)
and "album"."ownerId" = $2::uuid
"activity"."id" in ($2)
-- AccessRepository.activity.checkCreateAccess
select
"album"."id"
from
"album"
left join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
left join "user" on "user"."id" = "albumUsers"."userId"
inner join "album_user" as "albumUsers" on "albumUsers"."albumId" = "album"."id"
inner join "user" on "user"."id" = "albumUsers"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
and "album"."isActivityEnabled" = $2
and (
"album"."ownerId" = $3
or "user"."id" = $4
)
and "user"."id" = $3
and "album"."deletedAt" is null
-- AccessRepository.album.checkOwnerAccess
@@ -42,9 +41,11 @@ select
"album"."id"
from
"album"
inner join "album_user" on "album"."id" = "album_user"."albumId"
and "album_user"."role" = 'owner'
and "album_user"."userId" = $1
where
"album"."id" in ($1)
and "album"."ownerId" = $2
"album"."id" in ($2)
and "album"."deletedAt" is null
-- AccessRepository.album.checkSharedAlbumAccess
@@ -52,8 +53,8 @@ select
"album"."id"
from
"album"
left join "album_user" on "album_user"."albumId" = "album"."id"
left join "user" on "user"."id" = "album_user"."userId"
inner join "album_user" on "album_user"."albumId" = "album"."id"
inner join "user" on "user"."id" = "album_user"."userId"
and "user"."deletedAt" is null
where
"album"."id" in ($1)
@@ -93,10 +94,7 @@ where
"asset"."id" = any (target.ids)
or "asset"."livePhotoVideoId" = any (target.ids)
)
and (
"album"."ownerId" = $2
or "user"."id" = $3
)
and "user"."id" = $2
and "album"."deletedAt" is null
-- AccessRepository.asset.checkOwnerAccess
+253 -146
View File
@@ -1,26 +1,17 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AlbumRepository.getById
with
"album_user" as (
select
*
from
"album_user"
where
"album_user"."albumId" = $1
)
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -41,15 +32,21 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $2 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
@@ -88,30 +85,12 @@ select
from
"album"
where
"album"."id" = $1
"album"."id" = $3
and "album"."deletedAt" is null
-- AlbumRepository.getByAssetId
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -132,36 +111,38 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
and "album_asset"."assetId" = $3
and "album"."deletedAt" is null
order by
"album"."createdAt" desc,
"album"."createdAt" desc
-- AlbumRepository.getByAssetIds
@@ -172,18 +153,15 @@ from
"album"
inner join "album_asset" on "album_asset"."albumId" = "album"."id"
where
(
"album"."ownerId" = $1
or exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
)
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and "album_user"."userId" = $1
)
and "album_asset"."assetId" in ($3)
and "album_asset"."assetId" in ($2)
and "album"."deletedAt" is null
-- AlbumRepository.getMetadataForIds
@@ -210,24 +188,6 @@ group by
-- AlbumRepository.getOwned
select
"album".*,
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -248,15 +208,21 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
@@ -274,9 +240,11 @@ select
) as "sharedLinks"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
"album"."deletedAt" is null
order by
"album"."createdAt" desc
@@ -303,35 +271,23 @@ select
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album_user"."userId"
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
@@ -347,29 +303,34 @@ select
) as "sharedLinks"
from
"album"
inner join (
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $2
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."role" != 'owner'
)
union
select
"shared_link"."albumId" as "id"
from
"shared_link"
where
"shared_link"."userId" = $3
and "shared_link"."albumId" is not null
) as "matching" on "matching"."id" = "album"."id"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."role" = 'owner'
where
(
exists (
select
from
"album_user"
where
"album_user"."albumId" = "album"."id"
and (
"album"."ownerId" = $1
or "album_user"."userId" = $2
)
)
or exists (
select
from
"shared_link"
where
"shared_link"."albumId" = "album"."id"
and "shared_link"."userId" = $3
)
)
and "album"."deletedAt" is null
"album"."deletedAt" is null
order by
"album"."createdAt" desc
@@ -378,33 +339,68 @@ select
"album".*,
(
select
to_json(obj)
coalesce(json_agg(agg), '[]')
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
"shared_link".*
from
"user"
"shared_link"
where
"user"."id" = "album"."ownerId"
) as obj
) as "owner"
"shared_link"."albumId" = "album"."id"
) as agg
) as "sharedLinks",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"album_user"."userId" = $1 desc,
"user"."name" asc
) as agg
) as "albumUsers"
from
"album"
inner join "album_user" on "album_user"."albumId" = "album"."id"
and "album_user"."userId" = $2
and "album_user"."role" = 'owner'
where
"album"."ownerId" = $1
and "album"."deletedAt" is null
"album"."deletedAt" is null
and not exists (
select
from
"album_user"
"album_user" as "au"
where
"album_user"."albumId" = "album"."id"
"au"."albumId" = "album"."id"
and "au"."role" != 'owner'
)
and not exists (
select
@@ -430,6 +426,117 @@ where
"album_asset"."albumId" = $1
and "album_asset"."assetId" in ($2)
-- AlbumRepository.addAssetIds
insert into
"album_asset"
select
$1::uuid as "albumId",
unnest($2::uuid[]) as "assetId"
from
(
select
1
) as "dummy"
on conflict do nothing
-- AlbumRepository.create
with
"album" as (
insert into
"album" ("albumName")
values
($1)
returning
*
),
"album_user" as (
insert into
"album_user"
select
"album"."id" as "albumId",
unnest($2::uuid[]) as "userId",
unnest($3::album_user_role_enum[]) as "role"
from
"album"
returning
"album_user"."albumId",
"album_user"."userId",
"album_user"."role"
),
"album_asset" as (
insert into
"album_asset"
select
"album"."id" as "albumId",
unnest($4::uuid[]) as "assetId"
from
"album"
on conflict do nothing
returning
"album_asset"."albumId",
"album_asset"."assetId"
)
select
"album".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_user"."role",
(
select
to_json(obj)
from
(
select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
(
select
1
) as "dummy"
) as obj
) as "user"
from
"album_user"
inner join "user" on "user"."id" = "album_user"."userId"
where
"album_user"."albumId" = "album"."id"
order by
"album_user"."role",
"user"."name" asc
) as agg
) as "albumUsers",
(
select
json_agg("asset") as "assets"
from
(
select
"asset".*,
"asset_exif" as "exifInfo"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
where
"album_asset"."albumId" = "album"."id"
and "asset"."deletedAt" is null
and "asset"."visibility" in ('archive', 'timeline')
order by
"asset"."fileCreatedAt" desc
) as "asset"
) as "assets"
from
"album"
-- AlbumRepository.getContributorCounts
select
"asset"."ownerId" as "userId",
@@ -163,6 +163,7 @@ where
'%.jp2',
'%.jpe',
'%.jxl',
'%.mpo',
'%.svg',
'%.tif',
'%.tiff'
+1 -1
View File
@@ -74,7 +74,7 @@ delete from "session"
where
"id" = $1::uuid
-- SessionRepository.invalidate
-- SessionRepository.invalidateAll
delete from "session"
where
"userId" = $1
+18 -2
View File
@@ -139,7 +139,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where
@@ -201,7 +209,15 @@ from
from
"user"
where
"user"."id" = "album"."ownerId"
exists (
select
from
"album_user"
where
"album_user"."role" = 'owner'
and "album_user"."albumId" = "album"."id"
and "album_user"."userId" = "user"."id"
)
and "user"."deletedAt" is null
) as "owner" on true
where
+28 -69
View File
@@ -29,7 +29,6 @@ order by
-- SyncRepository.album.getUpserts
select distinct
on ("album"."id", "album"."updateId") "album"."id",
"album"."ownerId",
"album"."albumName" as "name",
"album"."description",
"album"."createdAt",
@@ -44,13 +43,19 @@ from
where
"album"."updateId" < $1
and "album"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_users"."userId" = $4
)
and "album_users"."userId" = $3
order by
"album"."updateId" asc
-- SyncRepository.album.getAlbumUsers
select
"userId",
"role"
from
"album_user"
where
"albumId" = $1
-- SyncRepository.albumAsset.getBackfill
select
"asset"."id",
@@ -109,16 +114,12 @@ select
from
"asset" as "asset"
inner join "album_asset" on "album_asset"."assetId" = "asset"."id"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset"."updateId" < $1
and "asset"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset"."updateId" asc
@@ -147,15 +148,11 @@ select
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -229,16 +226,12 @@ select
from
"asset_exif" as "asset_exif"
inner join "album_asset" on "album_asset"."assetId" = "asset_exif"."assetId"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" > $2
and "album_asset"."updateId" <= $3
and (
"album"."ownerId" = $4
or "album_user"."userId" = $5
)
and "album_user"."userId" = $4
order by
"asset_exif"."updateId" asc
@@ -278,10 +271,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -312,20 +302,11 @@ where
and "album_asset_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_asset_audit"."id" asc
@@ -337,15 +318,11 @@ select
"album_asset"."updateId"
from
"album_asset" as "album_asset"
inner join "album" on "album"."id" = "album_asset"."albumId"
left join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" > $2
and (
"album"."ownerId" = $3
or "album_user"."userId" = $4
)
and "album_user"."userId" = $3
order by
"album_asset"."updateId" asc
@@ -377,20 +354,11 @@ where
and "album_user_audit"."id" > $2
and "albumId" in (
select
"id"
"album_user"."albumId" as "id"
from
"album"
"album_user"
where
"ownerId" = $3
union
(
select
"album_user"."albumId" as "id"
from
"album_user"
where
"album_user"."userId" = $4
)
"album_user"."userId" = $3
)
order by
"album_user_audit"."id" asc
@@ -408,20 +376,11 @@ where
and "album_user"."updateId" > $2
and "album_user"."albumId" in (
select
"id"
"albumUsers"."albumId" as "id"
from
"album"
"album_user" as "albumUsers"
where
"ownerId" = $3
union
(
select
"albumUsers"."albumId" as "id"
from
"album_user" as "albumUsers"
where
"albumUsers"."userId" = $4
)
"albumUsers"."userId" = $3
)
order by
"album_user"."updateId" asc
+19 -9
View File
@@ -35,9 +35,14 @@ class ActivityAccess {
return this.db
.selectFrom('activity')
.select('activity.id')
.leftJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album', (join) => join.onRef('activity.albumId', '=', 'album.id').on('album.deletedAt', 'is', null))
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', asUuid(userId)),
)
.where('activity.id', 'in', [...activityIds])
.whereRef('album.ownerId', '=', asUuid(userId))
.execute()
.then((activities) => new Set(activities.map((activity) => activity.id)));
}
@@ -52,11 +57,11 @@ class ActivityAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user as albumUsers', 'albumUsers.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'albumUsers.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.isActivityEnabled', '=', true)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where((eb) => eb('user.id', '=', userId))
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@@ -77,7 +82,12 @@ class AlbumAccess {
.selectFrom('album')
.select('album.id')
.where('album.id', 'in', [...albumIds])
.where('album.ownerId', '=', userId)
.innerJoin('album_user', (join) =>
join
.onRef('album.id', '=', 'album_user.albumId')
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.on('album_user.userId', '=', userId),
)
.where('album.deletedAt', 'is', null)
.execute()
.then((albums) => new Set(albums.map((album) => album.id)));
@@ -96,8 +106,8 @@ class AlbumAccess {
return this.db
.selectFrom('album')
.select('album.id')
.leftJoin('album_user', 'album_user.albumId', 'album.id')
.leftJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.innerJoin('album_user', 'album_user.albumId', 'album.id')
.innerJoin('user', (join) => join.onRef('user.id', '=', 'album_user.userId').on('user.deletedAt', 'is', null))
.where('album.id', 'in', [...albumIds])
.where('album.deletedAt', 'is', null)
.where('user.id', '=', userId)
@@ -152,7 +162,7 @@ class AssetAccess {
eb('asset.livePhotoVideoId', '=', sql<string>`any(target.ids)`),
]),
)
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('user.id', '=', userId)]))
.where('user.id', '=', userId)
.where('album.deletedAt', 'is', null)
.execute()
.then((assets) => {
@@ -7,7 +7,7 @@ import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { asUuid } from 'src/utils/database';
import { asUuid, dummy } from 'src/utils/database';
export interface ActivitySearch {
albumId?: string;
@@ -31,11 +31,7 @@ export class ActivityRepository {
join.onRef('user2.id', '=', 'activity.userId').on('user2.deletedAt', 'is', null),
)
.innerJoinLateral(
(eb) =>
eb
.selectFrom(sql`(select 1)`.as('dummy'))
.select(columns.userWithPrefix)
.as('user'),
(eb) => eb.selectFrom(dummy).select(columns.userWithPrefix).as('user'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('user').as('user'))
+167 -121
View File
@@ -14,10 +14,11 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { withDefaultVisibility } from 'src/utils/database';
import { asUuid, dummy, withDefaultVisibility } from 'src/utils/database';
export interface AlbumAssetCount {
albumId: string;
@@ -31,33 +32,25 @@ export interface AlbumInfoOptions {
withAssets: boolean;
}
const withOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album.ownerId'))
.$notNull()
.as('owner');
};
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withAlbumUsers = (authUserId?: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.innerJoin('user', 'user.id', 'album_user.userId')
.whereRef('album_user.albumId', '=', 'album.id')
.select('album_user.role')
.select((eb) =>
jsonObjectFrom(eb.selectFrom('user').select(columns.user).whereRef('user.id', '=', 'album_user.userId'))
.$notNull()
.as('user'),
)
.whereRef('album_user.albumId', '=', 'album.id'),
.select((eb) => jsonObjectFrom(eb.selectFrom(dummy).select(columns.user)).$notNull().as('user'))
.orderBy('album_user.role')
.$if(!!authUserId, (qb) => qb.orderBy((eb) => eb('album_user.userId', '=', authUserId!), 'desc'))
.orderBy('user.name', 'asc'),
)
.$notNull()
.as('albumUsers');
};
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) => {
return jsonArrayFrom(
const withSharedLink = (eb: ExpressionBuilder<DB, 'album'>) =>
jsonArrayFrom(
eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'),
).as('sharedLinks');
};
const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
@@ -80,19 +73,28 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
.as('assets');
};
const isAlbumOwned = (ownerId: string) => (eb: ExpressionBuilder<DB, 'album'>) =>
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.role', '=', AlbumUserRole.Owner)
.where('album_user.userId', '=', ownerId),
);
@Injectable()
export class AlbumRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
async getById(id: string, options: AlbumInfoOptions) {
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }, DummyValue.UUID] })
getById(id: string, options: AlbumInfoOptions, authUserId?: string) {
return this.db
.with('album_user', (qb) => qb.selectFrom('album_user').selectAll().where('album_user.albumId', '=', id))
.selectFrom('album')
.selectAll('album')
.where('album.id', '=', id)
.where('album.deletedAt', 'is', null)
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(authUserId))
.select(withSharedLink)
.$if(options.withAssets, (eb) => eb.select(withAssets))
.$narrowType<{ assets: NotNull }>()
@@ -100,27 +102,22 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string) {
getByAssetId(ownerId: string, assetId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', '=', assetId)
.where('album.deletedAt', 'is', null)
.orderBy('album.createdAt', 'desc')
.select(withOwner)
.select(withAlbumUsers)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
@@ -137,15 +134,12 @@ export class AlbumRepository {
.select('album.id')
.innerJoin('album_asset', 'album_asset.albumId', 'album.id')
.where((eb) =>
eb.or([
eb('album.ownerId', '=', ownerId),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
]),
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where('album_user.userId', '=', ownerId),
),
)
.where('album_asset.assetId', 'in', assetIds)
.where('album.deletedAt', 'is', null)
@@ -190,15 +184,19 @@ export class AlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getOwned(ownerId: string) {
getOwned(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.select(withOwner)
.select(withAlbumUsers)
.select(withSharedLink)
.where('album.ownerId', '=', ownerId)
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
}
@@ -207,29 +205,40 @@ export class AlbumRepository {
* Get albums shared with and shared by owner.
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getShared(ownerId: string) {
getShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where((eb) =>
eb.or([
eb.exists(
eb
.selectFrom('album_user')
.whereRef('album_user.albumId', '=', 'album.id')
.where((eb) => eb.or([eb('album.ownerId', '=', ownerId), eb('album_user.userId', '=', ownerId)])),
),
eb.exists(
eb
.selectFrom('shared_link')
.whereRef('shared_link.albumId', '=', 'album.id')
.where('shared_link.userId', '=', ownerId),
),
]),
.innerJoin(
(eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId as id')
.where('album_user.userId', '=', ownerId)
.where(
'album_user.albumId',
'in',
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where('album_user.role', '!=', sql.lit(AlbumUserRole.Owner)),
)
.union(
eb
.selectFrom('shared_link')
.where('shared_link.userId', '=', ownerId)
.where('shared_link.albumId', 'is not', null)
.select('shared_link.albumId as id')
.$narrowType<{ id: NotNull }>(),
)
.as('matching'),
(join) => join.onRef('matching.id', '=', 'album.id'),
)
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album.id').on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.select(withAlbumUsers)
.select(withOwner)
.select(withAlbumUsers(ownerId))
.select(withSharedLink)
.orderBy('album.createdAt', 'desc')
.execute();
@@ -239,29 +248,45 @@ export class AlbumRepository {
* Get albums of owner that are _not_ shared
*/
@GenerateSql({ params: [DummyValue.UUID] })
async getNotShared(ownerId: string) {
getNotShared(ownerId: string) {
return this.db
.selectFrom('album')
.selectAll('album')
.where('album.ownerId', '=', ownerId)
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album.id')
.on('album_user.userId', '=', ownerId)
.on('album_user.role', '=', sql.lit(AlbumUserRole.Owner)),
)
.where('album.deletedAt', 'is', null)
.where((eb) => eb.not(eb.exists(eb.selectFrom('album_user').whereRef('album_user.albumId', '=', 'album.id'))))
.where((eb) => eb.not(eb.exists(eb.selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))))
.select(withOwner)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('album_user as au')
.whereRef('au.albumId', '=', 'album.id')
.where('au.role', '!=', sql.lit(AlbumUserRole.Owner)),
),
),
)
.where(({ not, exists, selectFrom }) =>
not(exists(selectFrom('shared_link').whereRef('shared_link.albumId', '=', 'album.id'))),
)
.select(withSharedLink)
.select(withAlbumUsers(ownerId))
.orderBy('album.createdAt', 'desc')
.execute();
}
async restoreAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: null }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: null }).where(isAlbumOwned(userId)).execute();
}
async softDeleteAll(userId: string): Promise<void> {
await this.db.updateTable('album').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute();
await this.db.updateTable('album').set({ deletedAt: new Date() }).where(isAlbumOwned(userId)).execute();
}
async deleteAll(userId: string): Promise<void> {
await this.db.deleteFrom('album').where('ownerId', '=', userId).execute();
await this.db.deleteFrom('album').where(isAlbumOwned(userId)).execute();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@@ -306,52 +331,86 @@ export class AlbumRepository {
.then((results) => new Set(results.map(({ assetId }) => assetId)));
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
await this.addAssets(this.db, albumId, assetIds);
if (assetIds.length === 0) {
return;
}
await this.db
.insertInto('album_asset')
.expression((eb) =>
eb.selectFrom(dummy).select([asUuid(albumId).as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.execute();
}
create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
return this.db.transaction().execute(async (tx) => {
const newAlbum = await tx.insertInto('album').values(album).returning('album.id').executeTakeFirst();
@GenerateSql({
params: [
{ albumName: DummyValue.STRING },
[],
[{ userId: DummyValue.UUID, role: AlbumUserRole.Owner }, DummyValue.UUID],
],
})
async create(
album: Insertable<AlbumTable>,
assetIds: string[],
albumUsers: AlbumUserCreateDto[],
authUserId: string,
) {
if (!albumUsers.some((u) => u.role === AlbumUserRole.Owner)) {
throw new Error('Album must have an owner');
}
if (!newAlbum) {
throw new Error('Failed to create album');
}
const userIds = albumUsers.map((u) => u.userId);
const roles = albumUsers.map((u) => u.role);
if (assetIds.length > 0) {
await this.addAssets(tx, newAlbum.id, assetIds);
}
if (albumUsers.length > 0) {
await tx
const result = await this.db
.with('album', (db) => db.insertInto('album').values(album).returningAll())
.with('album_user', (db) =>
db
.insertInto('album_user')
.values(
albumUsers.map((albumUser) => ({ albumId: newAlbum.id, userId: albumUser.userId, role: albumUser.role })),
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [
ref('album.id').as('albumId'),
sql`unnest(${userIds}::uuid[])`.as('userId'),
sql`unnest(${roles}::album_user_role_enum[])`.as('role'),
]),
)
.execute();
}
.returning(['album_user.albumId', 'album_user.userId', 'album_user.role']),
)
.with('album_asset', (db) =>
db
.insertInto('album_asset')
.expression((eb) =>
eb
.selectFrom('album')
.select(({ ref }) => [ref('album.id').as('albumId'), sql`unnest(${assetIds}::uuid[])`.as('assetId')]),
)
.onConflict((oc) => oc.doNothing())
.returning(['album_asset.albumId', 'album_asset.assetId']),
)
.selectFrom('album')
.selectAll('album')
.select(withAlbumUsers(authUserId))
.select(withAssets)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
return tx
.selectFrom('album')
.selectAll('album')
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withAssets)
.select(withAlbumUsers)
.$narrowType<{ assets: NotNull }>()
.executeTakeFirstOrThrow();
});
return result;
}
update(id: string, album: Updateable<AlbumTable>) {
update(id: string, album: Updateable<AlbumTable>, authUserId: string) {
return this.db
.updateTable('album')
.set(album)
.where('id', '=', id)
.where('album.id', '=', id)
.returningAll('album')
.returning(withOwner)
.returning(withSharedLink)
.returning(withAlbumUsers)
.returning(withAlbumUsers(authUserId))
.executeTakeFirstOrThrow();
}
@@ -359,19 +418,6 @@ export class AlbumRepository {
await this.db.deleteFrom('album').where('id', '=', id).execute();
}
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await db
.insertInto('album_asset')
.values(assetIds.map((assetId) => ({ albumId, assetId })))
.onConflict((oc) => oc.doNothing())
.execute();
}
@Chunked({ chunkSize: 30_000 })
async addAssetIdsToAlbums(values: { albumId: string; assetId: string }[]): Promise<void> {
if (values.length === 0) {
@@ -402,7 +448,7 @@ export class AlbumRepository {
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
.select('album_asset.assetId')
.orderBy('asset.fileCreatedAt', 'desc')
.limit(1),
.limit(sql.lit(1)),
}))
.where((eb) =>
eb.or([
+2 -4
View File
@@ -301,11 +301,9 @@ const getEnv = (): EnvData => {
mount: true,
generateId: true,
setup: (cls, req: Request, res: Response) => {
const headerValues = req.headers[ImmichHeader.Cid];
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
const cid = req.header(ImmichHeader.CorrelationId) || cls.get(CLS_ID);
cls.set(CLS_ID, cid);
res.header(ImmichHeader.Cid, cid);
res.header(ImmichHeader.CorrelationId, cid);
},
},
},
+1 -1
View File
@@ -39,7 +39,7 @@ type EventMap = {
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumInvite: [{ id: string; userId: string }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events
AssetCreate: [{ asset: Asset }];
@@ -33,12 +33,6 @@ export interface ReverseGeocodeResult {
city: string | null;
}
export interface MapMarker extends ReverseGeocodeResult {
id: string;
lat: number;
lon: number;
}
interface MapDB extends DB {
geodata_places_tmp: GeodataPlacesTable;
naturalearth_countries_tmp: NaturalEarthCountriesTable;
+71 -3
View File
@@ -1,4 +1,5 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { createRemoteJWKSet, jwtVerify, JWTVerifyGetKey } from 'jose';
import {
allowInsecureRequests as allowInsecureRequestsExecute,
authorizationCodeGrant,
@@ -21,9 +22,11 @@ export type OAuthConfig = {
clientId: string;
clientSecret?: string;
issuerUrl: string;
endSessionEndpoint: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
profileSigningAlgorithm: string;
prompt: string;
scope: string;
signingAlgorithm: string;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
@@ -56,6 +59,10 @@ export class OAuthRepository {
state,
};
if (config.prompt) {
params.prompt = config.prompt;
}
if (client.serverMetadata().supportsPKCE()) {
params.code_challenge = codeChallenge;
params.code_challenge_method = 'S256';
@@ -71,12 +78,12 @@ export class OAuthRepository {
return client.serverMetadata().end_session_endpoint;
}
async getProfile(
async getProfileAndOAuthSid(
config: OAuthConfig,
url: string,
expectedState: string,
codeVerifier: string,
): Promise<OAuthProfile> {
): Promise<{ profile: OAuthProfile; sid?: string }> {
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
@@ -96,7 +103,15 @@ export class OAuthRepository {
throw new Error('Unexpected profile response, no `sub`');
}
return profile;
let sid: string | undefined;
if (tokens.id_token) {
const claims = tokens.claims();
if (typeof claims?.sid === 'string') {
sid = claims.sid;
}
}
return { profile, sid };
} catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn(
@@ -126,6 +141,59 @@ export class OAuthRepository {
};
}
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performnce
async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> {
const client = await this.getClient(config);
const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256';
let keyOrGetter: Uint8Array | JWTVerifyGetKey;
try {
if (algorithm.startsWith('HS')) {
keyOrGetter = new TextEncoder().encode(config.clientSecret);
} else {
const jwksUri = client.serverMetadata().jwks_uri;
if (!jwksUri) {
throw new Error('Unable to get JWKS URI');
}
if (!this.jwksClients.has(jwksUri)) {
this.jwksClients.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
}
keyOrGetter = this.jwksClients.get(jwksUri) as JWTVerifyGetKey;
}
const { payload } = await jwtVerify(logoutToken, keyOrGetter as any, {
issuer: client.serverMetadata().issuer,
audience: config.clientId,
algorithms: [algorithm],
maxTokenAge: '2m',
clockTolerance: '5s',
});
// Validate specific Logout Token claims (RFC 8963):
// "events" claim must exist and contain the backchannel-logout event
const events = payload.events as Record<string, any> | undefined;
if (!events || !events['http://schemas.openid.net/event/backchannel-logout']) {
throw new Error('Missing backchannel-logout event claim');
}
// "nonce" must not be present
if (payload.nonce) {
throw new Error('Logout token must not contain a nonce');
}
return {
sub: payload.sub,
sid: payload.sid as string | undefined,
};
} catch (error: Error | any) {
this.logger.error(`Error validating JWT logout token: ${error.message}`);
this.logger.error(error);
throw new Error('Error validating JWT logout token', { cause: error });
}
}
private async getClient({
issuerUrl,
clientId,
+2 -2
View File
@@ -9,7 +9,7 @@ import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions {
@@ -418,7 +418,7 @@ export class PersonRepository {
(query as any) = query.with('added_embeddings', (db) => db.insertInto('face_search').values(embeddingsToAdd));
}
await query.selectFrom(sql`(select 1)`.as('dummy')).execute();
await query.selectFrom(dummy).execute();
}
async update(person: Updateable<PersonTable> & { id: string }) {
+23 -1
View File
@@ -102,7 +102,7 @@ export class SessionRepository {
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, excludeId: DummyValue.UUID }] })
async invalidate({ userId, excludeId }: { userId: string; excludeId?: string }) {
async invalidateAll({ userId, excludeId }: { userId: string; excludeId?: string }) {
await this.db
.deleteFrom('session')
.where('userId', '=', userId)
@@ -110,6 +110,28 @@ export class SessionRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.STRING, DummyValue.STRING] })
async invalidateOAuth({ oauthSid, oauthId }: { oauthSid?: string; oauthId?: string }): Promise<string[]> {
let query = this.db.deleteFrom('session').returning('session.id');
if (oauthSid && oauthId) {
query = query
.using('user')
.whereRef('user.id', '=', 'session.userId')
.where('session.oauthSid', '=', oauthSid)
.where('user.oauthId', '=', oauthId);
} else if (!oauthSid && oauthId) {
query = query.using('user').whereRef('user.id', '=', 'session.userId').where('user.oauthId', '=', oauthId);
} else if (oauthSid && !oauthId) {
query = query.where('session.oauthSid', '=', oauthSid);
} else {
throw new Error('Invalid arguments: at least one of oauthSid or oauthId must be present');
}
const deletedRows = await query.execute();
return deletedRows.map((row) => row.id);
}
@GenerateSql({ params: [DummyValue.UUID] })
async lockAll(userId: string) {
await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
@@ -5,7 +5,7 @@ import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkType } from 'src/enum';
import { AlbumUserRole, SharedLinkType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -39,7 +39,15 @@ const withAlbumOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
.selectFrom('user')
.select(columns.user)
.whereRef('user.id', '=', 'album.ownerId')
.where((eb) =>
eb.exists(
eb
.selectFrom('album_user')
.where('album_user.role', '=', sql.lit(AlbumUserRole.Owner))
.whereRef('album_user.albumId', '=', 'album.id')
.whereRef('album_user.userId', '=', 'user.id'),
),
)
.where('user.deletedAt', 'is', null)
.as('owner');
};
+20 -50
View File
@@ -171,10 +171,9 @@ class AlbumSync extends BaseSync {
return this.upsertQuery('album', options)
.distinctOn(['album.id', 'album.updateId'])
.leftJoin('album_user as album_users', 'album.id', 'album_users.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_users.userId', '=', userId)]))
.where('album_users.userId', '=', userId)
.select([
'album.id',
'album.ownerId',
'album.albumName as name',
'album.description',
'album.createdAt',
@@ -186,6 +185,11 @@ class AlbumSync extends BaseSync {
])
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getAlbumUsers(albumId: string) {
return this.db.selectFrom('album_user').select(['userId', 'role']).where('albumId', '=', albumId).execute();
}
}
class AlbumAssetSync extends BaseSync {
@@ -207,9 +211,8 @@ class AlbumAssetSync extends BaseSync {
.select(columns.syncAsset)
.select('asset.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
@@ -220,9 +223,8 @@ class AlbumAssetSync extends BaseSync {
.select('album_asset.updateId')
.innerJoin('asset', 'asset.id', 'album_asset.assetId')
.select(columns.syncAsset)
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
}
@@ -246,9 +248,8 @@ class AlbumAssetExifSync extends BaseSync {
.select(columns.syncAssetExif)
.select('asset_exif.updateId')
.where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send exif updates for assets that the client already knows about
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
@@ -261,7 +262,7 @@ class AlbumAssetExifSync extends BaseSync {
.select(columns.syncAssetExif)
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.where('album_user.userId', '=', userId)
.stream();
}
}
@@ -284,18 +285,7 @@ class AlbumToAssetSync extends BaseSync {
eb(
'albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
),
)
.stream();
@@ -310,9 +300,8 @@ class AlbumToAssetSync extends BaseSync {
const userId = options.userId;
return this.upsertQuery('album_asset', options)
.select(['album_asset.assetId as assetId', 'album_asset.albumId as albumId', 'album_asset.updateId'])
.innerJoin('album', 'album.id', 'album_asset.albumId')
.leftJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.userId', '=', userId)]))
.innerJoin('album_user', 'album_user.albumId', 'album_asset.albumId')
.where('album_user.userId', '=', userId)
.stream();
}
}
@@ -336,18 +325,7 @@ class AlbumUserSync extends BaseSync {
eb(
'albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user')
.select(['album_user.albumId as id'])
.where('album_user.userId', '=', userId),
),
),
eb.selectFrom('album_user').select(['album_user.albumId as id']).where('album_user.userId', '=', userId),
),
)
.stream();
@@ -368,17 +346,9 @@ class AlbumUserSync extends BaseSync {
'album_user.albumId',
'in',
eb
.selectFrom('album')
.select(['id'])
.where('ownerId', '=', userId)
.union((eb) =>
eb.parens(
eb
.selectFrom('album_user as albumUsers')
.select(['albumUsers.albumId as id'])
.where('albumUsers.userId', '=', userId),
),
),
.selectFrom('album_user as albumUsers')
.select(['albumUsers.albumId as id'])
.where('albumUsers.userId', '=', userId),
),
)
.stream();
+6 -1
View File
@@ -1,5 +1,10 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
values: [AlbumUserRole.Owner, AlbumUserRole.Editor, AlbumUserRole.Viewer],
});
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
+2 -14
View File
@@ -29,7 +29,8 @@ export const album_user_after_insert = registerFunction({
body: `
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END`,
});
@@ -119,19 +120,6 @@ export const asset_delete_audit = registerFunction({
END`,
});
export const album_delete_audit = registerFunction({
name: 'album_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});
export const album_asset_delete_audit = registerFunction({
name: 'album_asset_delete_audit',
returnType: 'TRIGGER',
+7 -4
View File
@@ -1,7 +1,11 @@
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,
album_user_role_enum,
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
} from 'src/schema/enums';
import {
album_user_after_insert,
album_user_delete_audit,
asset_delete_audit,
@@ -146,7 +150,6 @@ export class ImmichDatabase {
user_delete_audit,
partner_delete_audit,
asset_delete_audit,
album_delete_audit,
album_user_after_insert,
album_user_delete_audit,
memory_delete_audit,
@@ -158,7 +161,7 @@ export class ImmichDatabase {
asset_face_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
}
export interface Migrations {
@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" ADD "oauthSid" character varying;`.execute(db);
await sql`CREATE INDEX "session_oauthSid_idx" ON "session" ("oauthSid");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "session_oauthSid_idx";`.execute(db);
await sql`ALTER TABLE "session" DROP COLUMN "oauthSid";`.execute(db);
}
@@ -0,0 +1,17 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_id_timeline_notDeleted_idx","sql":"CREATE INDEX \\"asset_id_timeline_notDeleted_idx\\" ON \\"asset\\" (\\"id\\") WHERE (visibility = ''timeline'' AND \\"deletedAt\\" IS NULL);"}'::jsonb WHERE "name" = 'index_asset_id_timeline_notDeleted_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_localDateTime_month_idx","sql":"CREATE INDEX \\"asset_localDateTime_month_idx\\" ON \\"asset\\" (date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text);"}'::jsonb WHERE "name" = 'index_asset_localDateTime_month_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_localDateTime_idx","sql":"CREATE INDEX \\"asset_localDateTime_idx\\" ON \\"asset\\" ((\\"localDateTime\\" at time zone ''UTC'')::date);"}'::jsonb WHERE "name" = 'index_asset_localDateTime_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"activity_like_idx","sql":"CREATE UNIQUE INDEX \\"activity_like_idx\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE ((\\"isLiked\\" = true));"}'::jsonb WHERE "name" = 'index_activity_like_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_localDateTime_month_idx\\" ON \\"asset\\" ((date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text));","name":"asset_localDateTime_month_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_localDateTime_month_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_localDateTime_idx\\" ON \\"asset\\" (((\\"localDateTime\\" at time zone ''UTC'')::date));","name":"asset_localDateTime_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_localDateTime_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"activity_like_idx\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE (\\"isLiked\\" = true);","name":"activity_like_idx","type":"index"}'::jsonb WHERE "name" = 'index_activity_like_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_id_timeline_notDeleted_idx\\" ON \\"asset\\" (\\"id\\") WHERE visibility = ''timeline'' AND \\"deletedAt\\" IS NULL;","name":"asset_id_timeline_notDeleted_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_id_timeline_notDeleted_idx';`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE \\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE;","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
@@ -0,0 +1,92 @@
import { Kysely, sql } from 'kysely';
import { AlbumUserRole } from 'src/enum';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_user_after_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END
$$;`.execute(db);
await sql`DROP TRIGGER "album_delete_audit" ON "album";`.execute(db);
await sql`DROP FUNCTION album_delete_audit;`.execute(db);
await sql`CREATE TYPE "album_user_role_enum" AS ENUM ('owner','editor','viewer');`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE album_user_role_enum USING "role"::album_user_role_enum;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor'::album_user_role_enum;`.execute(db);
await db
.insertInto('album_user')
.expression((eb) =>
eb
.selectFrom('album')
.select(['album.id as albumId', 'album.ownerId as userId', eb.val(AlbumUserRole.Owner).as('role')]),
)
.execute();
await sql`ALTER TABLE "album" DROP CONSTRAINT "album_ownerId_fkey";`.execute(db);
await sql`ALTER TABLE "album" DROP COLUMN "ownerId";`.execute(db);
await sql`CREATE UNIQUE INDEX "album_user_unique_owner" ON "album_user" ("albumId") WHERE (role = 'owner');`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_after_insert","sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT \\"albumId\\" FROM inserted_rows)\\n AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = ''owner'');\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_album_user_unique_owner', '{"type":"index","name":"album_user_unique_owner","sql":"CREATE UNIQUE INDEX \\"album_user_unique_owner\\" ON \\"album_user\\" (\\"albumId\\") WHERE (role = ''owner'');"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_delete_audit';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.album_user_after_insert()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
RETURN NULL;
END
$function$
`.execute(db);
await sql`CREATE OR REPLACE FUNCTION public.album_delete_audit()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$function$
`.execute(db);
await sql`ALTER TABLE "album" ADD "ownerId" uuid NOT NULL;`.execute(db);
await db
.updateTable('album')
.set((eb) =>
({
id: eb.ref('album_user.albumId'),
ownerId: eb.ref('album_user.userId')
})
)
.from('album_user')
.where('album_user.role', '=', AlbumUserRole.Owner)
.execute();
await sql`DROP INDEX "album_user_unique_owner";`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE character varying USING "role"::text;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor';`.execute(db);
await sql`DROP TYPE "album_user_role_enum";`.execute(db);
await sql`CREATE INDEX "album_ownerId_idx" ON "album" ("ownerId");`.execute(db);
await sql`ALTER TABLE "album" ADD CONSTRAINT "album_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_delete_audit"
AFTER DELETE ON "album"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN ((pg_trigger_depth() = 0))
EXECUTE FUNCTION album_delete_audit();`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT DISTINCT \\"albumId\\" FROM inserted_rows);\\n RETURN NULL;\\n END\\n $$;","name":"album_user_after_insert","type":"function"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_delete_audit', '{"sql":"CREATE OR REPLACE FUNCTION album_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"album_delete_audit","type":"function"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_delete_audit', '{"sql":"CREATE OR REPLACE TRIGGER \\"album_delete_audit\\"\\n AFTER DELETE ON \\"album\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION album_delete_audit();","name":"album_delete_audit","type":"trigger"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_album_user_unique_owner';`.execute(db);
}
+9 -1
View File
@@ -5,17 +5,25 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
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' })
@Index({
name: 'album_user_unique_owner',
columns: ['albumId'],
unique: true,
where: `role = 'owner'`,
})
// Pre-existing indices from original album <--> user ManyToMany mapping
@UpdatedAtTrigger('album_user_updatedAt')
@AfterInsertTrigger({
@@ -47,7 +55,7 @@ export class AlbumUserTable {
})
userId!: string;
@Column({ type: 'character varying', default: AlbumUserRole.Editor })
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@CreateIdColumn({ index: true })
-12
View File
@@ -1,5 +1,4 @@
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
DeleteDateColumn,
@@ -12,25 +11,14 @@ import {
} 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')
@AfterDeleteTrigger({
scope: 'statement',
function: album_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AlbumTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: 'Untitled Album' })
albumName!: Generated<string>;
+4 -4
View File
@@ -33,20 +33,20 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
name: ASSET_CHECKSUM_CONSTRAINT,
columns: ['ownerId', 'checksum'],
unique: true,
where: '("libraryId" IS NULL)',
where: '"libraryId" IS NULL',
})
@Index({
columns: ['ownerId', 'libraryId', 'checksum'],
unique: true,
where: '("libraryId" IS NOT NULL)',
where: '"libraryId" IS NOT NULL',
})
@Index({
name: 'asset_localDateTime_idx',
expression: `(("localDateTime" at time zone 'UTC')::date)`,
expression: `("localDateTime" at time zone 'UTC')::date`,
})
@Index({
name: 'asset_localDateTime_month_idx',
expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`,
expression: `date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text`,
})
@Index({ columns: ['originalPath', 'libraryId'] })
@Index({ columns: ['id', 'stackId'] })
@@ -52,4 +52,7 @@ export class SessionTable {
@Column({ type: 'timestamp with time zone', nullable: true })
pinExpiresAt!: Timestamp | null;
@Column({ nullable: true, index: true })
oauthSid!: string | null;
}
+258 -141
View File
@@ -44,7 +44,8 @@ describe(AlbumService.name, () => {
describe('getAll', () => {
it('gets list of albums for auth user', async () => {
const album = AlbumFactory.from().albumUser().build();
const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const sharedWithUserAlbum = AlbumFactory.from().owner(owner).albumUser().build();
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@@ -63,7 +64,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), {});
const result = await sut.getAll(AuthFactory.create(owner), {});
expect(result).toHaveLength(2);
expect(result[0].id).toEqual(album.id);
expect(result[1].id).toEqual(sharedWithUserAlbum.id);
@@ -76,6 +77,7 @@ describe(AlbumService.name, () => {
.asset({}, (builder) => builder.exif())
.asset({}, (builder) => builder.exif())
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@@ -87,7 +89,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), { assetId: album.assets[0].id });
const result = await sut.getAll(AuthFactory.create(owner), { assetId: album.assets[0].id });
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id);
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1);
@@ -95,6 +97,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getShared.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@@ -106,7 +109,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), { shared: true });
const result = await sut.getAll(AuthFactory.create(owner), { shared: true });
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id);
expect(mocks.album.getShared).toHaveBeenCalledTimes(1);
@@ -114,6 +117,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@@ -125,7 +129,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), { shared: false });
const result = await sut.getAll(AuthFactory.create(owner), { shared: false });
expect(result).toHaveLength(1);
expect(result[0].id).toEqual(album.id);
expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1);
@@ -134,6 +138,7 @@ describe(AlbumService.name, () => {
it('counts assets correctly', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
@@ -145,7 +150,7 @@ describe(AlbumService.name, () => {
},
]);
const result = await sut.getAll(AuthFactory.create(album.owner), {});
const result = await sut.getAll(AuthFactory.create(owner), {});
expect(result).toHaveLength(1);
expect(result[0].assetCount).toEqual(1);
expect(mocks.album.getOwned).toHaveBeenCalledTimes(1);
@@ -159,13 +164,14 @@ describe(AlbumService.name, () => {
.asset({ id: assetId }, (asset) => asset.exif())
.albumUser(albumUser)
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user));
mocks.user.getMetadata.mockResolvedValue([]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.create(AuthFactory.create(album.owner), {
await sut.create(AuthFactory.create(owner), {
albumName: 'test',
albumUsers: [albumUser],
description: 'description',
@@ -174,20 +180,28 @@ describe(AlbumService.name, () => {
expect(mocks.album.create).toHaveBeenCalledWith(
{
ownerId: album.owner.id,
albumName: 'test',
description: 'description',
order: album.order,
albumThumbnailAssetId: assetId,
},
[assetId],
[{ userId: albumUser.userId, role: AlbumUserRole.Editor }],
[
{ userId: owner.id, role: AlbumUserRole.Owner },
{ userId: albumUser.userId, role: AlbumUserRole.Editor },
],
owner.id,
);
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId });
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: albumUser.userId,
senderName: owner.name,
});
});
it('creates album with assetOrder from user preferences', async () => {
@@ -197,8 +211,10 @@ describe(AlbumService.name, () => {
.asset({ id: assetId }, (asset) => asset.exif())
.albumUser(albumUser)
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
mocks.albumUser.create.mockResolvedValue(album.albumUsers[0]);
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[1].user));
mocks.user.getMetadata.mockResolvedValue([
{
key: UserMetadataKey.Preferences,
@@ -211,7 +227,7 @@ describe(AlbumService.name, () => {
]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.create(AuthFactory.create(album.owner), {
await sut.create(AuthFactory.create(owner), {
albumName: album.albumName,
albumUsers: [albumUser],
description: album.description,
@@ -220,20 +236,24 @@ describe(AlbumService.name, () => {
expect(mocks.album.create).toHaveBeenCalledWith(
{
ownerId: album.owner.id,
albumName: album.albumName,
description: album.description,
order: 'asc',
albumThumbnailAssetId: assetId,
},
[assetId],
[albumUser],
[{ userId: owner.id, role: AlbumUserRole.Owner }, albumUser],
owner.id,
);
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId });
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: albumUser.userId,
senderName: owner.name,
});
});
it('should require valid userIds', async () => {
@@ -254,12 +274,13 @@ describe(AlbumService.name, () => {
.asset({ id: assetId }, (asset) => asset.exif())
.albumUser()
.build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.getMetadata.mockResolvedValue([]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
await sut.create(AuthFactory.create(album.owner), {
await sut.create(AuthFactory.create(owner), {
albumName: album.albumName,
description: album.description,
assetIds: [assetId, 'asset-2'],
@@ -267,29 +288,26 @@ describe(AlbumService.name, () => {
expect(mocks.album.create).toHaveBeenCalledWith(
{
ownerId: album.owner.id,
albumName: album.albumName,
description: album.description,
order: 'desc',
albumThumbnailAssetId: assetId,
},
[assetId],
[],
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
album.owner.id,
new Set([assetId, 'asset-2']),
false,
[{ userId: owner.id, role: AlbumUserRole.Owner }],
owner.id,
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId, 'asset-2']), false);
});
it('should throw an error if the userId is the ownerId', async () => {
const album = AlbumFactory.create();
mocks.user.get.mockResolvedValue(album.owner);
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.user.get.mockResolvedValue(owner);
await expect(
sut.create(AuthFactory.create(album.owner), {
sut.create(AuthFactory.create(owner), {
albumName: 'Empty album',
albumUsers: [{ userId: album.owner.id, role: AlbumUserRole.Editor }],
albumUsers: [{ userId: owner.id, role: AlbumUserRole.Editor }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.create).not.toHaveBeenCalled();
@@ -312,20 +330,22 @@ describe(AlbumService.name, () => {
it('should prevent updating a not owned album (shared with auth user)', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(
sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }),
sut.update(AuthFactory.create(owner), album.id, { albumName: 'new album name' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require a valid thumbnail asset id', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set());
await expect(
sut.update(AuthFactory.create(album.owner), album.id, { albumThumbnailAssetId: 'not-in-album' }),
sut.update(AuthFactory.create(owner), album.id, { albumThumbnailAssetId: 'not-in-album' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.getAssetIds).toHaveBeenCalledWith(album.id, ['not-in-album']);
@@ -334,43 +354,51 @@ describe(AlbumService.name, () => {
it('should allow the owner to update the album', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.update.mockResolvedValue(getForAlbum(album));
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
await sut.update(AuthFactory.create(owner), album.id, { albumName: 'new album name' });
expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id, albumName: 'new album name' });
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{ id: album.id, albumName: 'new album name' },
owner.id,
);
});
});
describe('delete', () => {
it('should require permissions', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.delete).not.toHaveBeenCalled();
});
it('should not let a shared user delete the album', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.delete).not.toHaveBeenCalled();
});
it('should let the owner delete an album', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await sut.delete(AuthFactory.create(album.owner), album.id);
await sut.delete(AuthFactory.create(owner), album.id);
expect(mocks.album.delete).toHaveBeenCalledTimes(1);
expect(mocks.album.delete).toHaveBeenCalledWith(album.id);
@@ -391,10 +419,11 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId is already added', async () => {
const userId = newUuid();
const album = AlbumFactory.from().albumUser({ userId }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }),
sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId }] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.user.get).not.toHaveBeenCalled();
@@ -402,11 +431,12 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId does not exist', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(void 0);
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.user.get).toHaveBeenCalledWith('unknown-user', {});
@@ -414,11 +444,12 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId is the ownerId', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, {
albumUsers: [{ userId: album.owner.id }],
sut.addUsers(AuthFactory.create(owner), album.id, {
albumUsers: [{ userId: owner.id }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.album.update).not.toHaveBeenCalled();
@@ -427,6 +458,7 @@ describe(AlbumService.name, () => {
it('should add valid shared users', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const user = UserFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
@@ -434,7 +466,7 @@ describe(AlbumService.name, () => {
mocks.user.get.mockResolvedValue(user);
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
await sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: user.id }] });
await sut.addUsers(AuthFactory.create(owner), album.id, { albumUsers: [{ userId: user.id }] });
expect(mocks.albumUser.create).toHaveBeenCalledWith({
userId: user.id,
@@ -443,6 +475,7 @@ describe(AlbumService.name, () => {
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
id: album.id,
userId: user.id,
senderName: owner.name,
});
});
});
@@ -460,15 +493,16 @@ describe(AlbumService.name, () => {
it('should remove a shared user from an owned album', async () => {
const userId = newUuid();
const album = AlbumFactory.from().albumUser({ userId }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.delete.mockResolvedValue();
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined();
await expect(sut.removeUser(AuthFactory.create(owner), album.id, userId)).resolves.toBeUndefined();
expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1);
expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, owner.id);
});
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
@@ -511,9 +545,10 @@ describe(AlbumService.name, () => {
it('should not allow the owner to be removed', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf(
await expect(sut.removeUser(AuthFactory.create(owner), album.id, owner.id)).rejects.toBeInstanceOf(
BadRequestException,
);
@@ -522,9 +557,10 @@ describe(AlbumService.name, () => {
it('should throw an error for a user not in the album', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf(
await expect(sut.removeUser(AuthFactory.create(owner), album.id, 'user-3')).rejects.toBeInstanceOf(
BadRequestException,
);
@@ -536,10 +572,11 @@ describe(AlbumService.name, () => {
it('should update user role', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.albumUser.update.mockResolvedValue();
await sut.updateUser(AuthFactory.create(album.owner), album.id, user.id, { role: AlbumUserRole.Viewer });
await sut.updateUser(AuthFactory.create(owner), album.id, user.id, { role: AlbumUserRole.Viewer });
expect(mocks.albumUser.update).toHaveBeenCalledWith(
{ albumId: album.id, userId: user.id },
@@ -551,6 +588,7 @@ describe(AlbumService.name, () => {
describe('getAlbumInfo', () => {
it('should get a shared album', async () => {
const album = AlbumFactory.from().albumUser().build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getMetadataForIds.mockResolvedValue([
@@ -563,10 +601,10 @@ describe(AlbumService.name, () => {
},
]);
await sut.get(AuthFactory.create(album.owner), album.id);
await sut.get(AuthFactory.create(owner), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id]));
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, owner.id);
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([album.id]));
});
it('should get a shared album via a shared link', async () => {
@@ -586,7 +624,7 @@ describe(AlbumService.name, () => {
const auth = AuthFactory.from().sharedLink().build();
await sut.get(auth, album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, auth.user.id);
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id]));
});
@@ -607,7 +645,7 @@ describe(AlbumService.name, () => {
await sut.get(AuthFactory.create(user), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }, user.id);
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
user.id,
new Set([album.id]),
@@ -631,7 +669,7 @@ describe(AlbumService.name, () => {
describe('addAssets', () => {
it('should allow the owner to add assets', async () => {
const owner = UserFactory.create({ isAdmin: true });
const album = AlbumFactory.from({ ownerId: owner.id }).owner(owner).build();
const album = AlbumFactory.from().owner(owner).build();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
@@ -646,37 +684,47 @@ describe(AlbumService.name, () => {
{ success: true, id: asset3.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
});
it('should not set the thumbnail if the album has one already', async () => {
const [asset1, asset2] = [AssetFactory.create(), AssetFactory.create()];
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
{ success: true, id: asset2.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalled();
});
it('should allow a shared user to add assets', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
@@ -691,15 +739,19 @@ describe(AlbumService.name, () => {
{ success: true, id: asset3.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
user.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album.id,
recipientId: album.ownerId,
recipientId: owner.id,
});
});
@@ -719,33 +771,39 @@ describe(AlbumService.name, () => {
it('should allow adding assets shared via partner sharing', async () => {
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const asset = AssetFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
expect(mocks.album.update).toHaveBeenCalledWith(album.id, {
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
});
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]));
expect(mocks.album.update).toHaveBeenCalledWith(
album.id,
{
id: album.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
},
owner.id,
);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
});
it('should skip duplicate assets', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id]));
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: false, id: asset.id, error: BulkIdErrorReason.DUPLICATE },
]);
@@ -755,16 +813,17 @@ describe(AlbumService.name, () => {
it('should skip assets not shared with user', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: false, id: asset.id, error: BulkIdErrorReason.NO_PERMISSION },
]);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]), false);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]));
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]), false);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]));
});
it('should not allow unauthorized access to the album', async () => {
@@ -797,6 +856,7 @@ describe(AlbumService.name, () => {
describe('addAssetsToAlbums', () => {
it('should allow the owner to add assets', async () => {
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
@@ -805,23 +865,33 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@@ -835,6 +905,7 @@ describe(AlbumService.name, () => {
it('should not set the thumbnail if the album has one already', async () => {
const asset = AssetFactory.create();
const album1 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
@@ -843,23 +914,33 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
},
owner.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@@ -873,7 +954,9 @@ describe(AlbumService.name, () => {
it('should allow a shared user to add assets', async () => {
const user = UserFactory.create();
const album1 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build();
const { user: owner1 } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build();
const { user: owner2 } = album2.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
@@ -888,16 +971,26 @@ describe(AlbumService.name, () => {
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
user.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
user.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@@ -908,11 +1001,11 @@ describe(AlbumService.name, () => {
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album1.id,
recipientId: album1.ownerId,
recipientId: owner1.id,
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album2.id,
recipientId: album2.ownerId,
recipientId: owner2.id,
});
});
@@ -942,6 +1035,7 @@ describe(AlbumService.name, () => {
it('should allow adding assets shared via partner sharing', async () => {
const user = UserFactory.create();
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [
AssetFactory.create({ ownerId: user.id }),
@@ -954,23 +1048,33 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(2);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album1.id, {
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(2, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album1.id,
{
id: album1.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.update).toHaveBeenNthCalledWith(
2,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
@@ -980,7 +1084,7 @@ describe(AlbumService.name, () => {
{ albumId: album2.id, assetId: asset3.id },
]);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
album1.ownerId,
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
);
});
@@ -988,7 +1092,9 @@ describe(AlbumService.name, () => {
it('should skip some duplicate assets', async () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getAssetIds
@@ -997,18 +1103,23 @@ describe(AlbumService.name, () => {
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
).resolves.toEqual({ success: true, error: undefined });
expect(mocks.album.update).toHaveBeenCalledTimes(1);
expect(mocks.album.update).toHaveBeenNthCalledWith(1, album2.id, {
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
});
expect(mocks.album.update).toHaveBeenNthCalledWith(
1,
album2.id,
{
id: album2.id,
updatedAt: expect.any(Date),
albumThumbnailAssetId: asset1.id,
},
owner.id,
);
expect(mocks.album.addAssetIdsToAlbums).toHaveBeenCalledWith([
{ albumId: album2.id, assetId: asset1.id },
{ albumId: album2.id, assetId: asset2.id },
@@ -1019,6 +1130,7 @@ describe(AlbumService.name, () => {
it('should skip all duplicate assets', async () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
mocks.access.album.checkOwnerAccess
.mockResolvedValueOnce(new Set([album1.id]))
@@ -1028,7 +1140,7 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
@@ -1044,6 +1156,7 @@ describe(AlbumService.name, () => {
it('should skip assets not shared with user', async () => {
const user = UserFactory.create();
const album1 = AlbumFactory.create();
const { user: owner } = album1.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [
AssetFactory.create({ ownerId: user.id }),
@@ -1057,7 +1170,7 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
sut.addAssetsToAlbums(AuthFactory.create(owner), {
albumIds: [album1.id, album2.id],
assetIds: [asset1.id, asset2.id, asset3.id],
}),
@@ -1069,12 +1182,12 @@ describe(AlbumService.name, () => {
expect(mocks.album.update).not.toHaveBeenCalled();
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
album1.ownerId,
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
false,
);
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
album1.ownerId,
owner.id,
new Set([asset1.id, asset2.id, asset3.id]),
);
});
@@ -1126,12 +1239,13 @@ describe(AlbumService.name, () => {
it('should allow the owner to remove assets', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
@@ -1141,11 +1255,12 @@ describe(AlbumService.name, () => {
it('should skip assets not in the album', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: false, id: asset.id, error: BulkIdErrorReason.NOT_FOUND },
]);
@@ -1155,11 +1270,12 @@ describe(AlbumService.name, () => {
it('should allow owner to remove all assets from the album', async () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset.id] })).resolves.toEqual([
{ success: true, id: asset.id },
]);
});
@@ -1168,12 +1284,13 @@ describe(AlbumService.name, () => {
const asset1 = AssetFactory.create();
const asset2 = AssetFactory.create();
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id]));
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([
await expect(sut.removeAssets(AuthFactory.create(owner), album.id, { ids: [asset1.id] })).resolves.toEqual([
{ success: true, id: asset1.id },
]);
+55 -44
View File
@@ -15,7 +15,7 @@ import {
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { Permission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -74,10 +74,10 @@ export class AlbumService extends BaseService {
async get(auth: AuthDto, id: string): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
await this.albumRepository.updateThumbnails();
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 1;
const hasSharedLink = album.sharedLinks && album.sharedLinks.length > 0;
const isShared = hasSharedUsers || hasSharedLink;
@@ -126,18 +126,18 @@ export class AlbumService extends BaseService {
const album = await this.albumRepository.create(
{
ownerId: auth.user.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: assetIds[0] || null,
order: getPreferences(userMetadata).albums.defaultAssetOrder,
},
assetIds,
albumUsers,
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
auth.user.id,
);
for (const { userId } of albumUsers) {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId });
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
return mapAlbum(album);
@@ -146,7 +146,7 @@ export class AlbumService extends BaseService {
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] });
const album = await this.findOrFail(id, { withAssets: true });
const album = await this.findOrFail(id, auth.user.id, { withAssets: true });
if (dto.albumThumbnailAssetId) {
const results = await this.albumRepository.getAssetIds(id, [dto.albumThumbnailAssetId]);
@@ -154,14 +154,18 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid album thumbnail');
}
}
const updatedAlbum = await this.albumRepository.update(album.id, {
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
});
const updatedAlbum = await this.albumRepository.update(
album.id,
{
id: album.id,
albumName: dto.albumName,
description: dto.description,
albumThumbnailAssetId: dto.albumThumbnailAssetId,
isActivityEnabled: dto.isActivityEnabled,
order: dto.order,
},
auth.user.id,
);
return mapAlbum({ ...updatedAlbum, assets: album.assets });
}
@@ -172,7 +176,7 @@ export class AlbumService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
const results = await addAssets(
@@ -183,16 +187,18 @@ export class AlbumService extends BaseService {
const { id: firstNewAssetId } = results.find(({ success }) => success) || {};
if (firstNewAssetId) {
await this.albumRepository.update(id, {
await this.albumRepository.update(
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
{
id,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? firstNewAssetId,
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
}
@@ -231,21 +237,23 @@ export class AlbumService extends BaseService {
if (notPresentAssetIds.length === 0) {
continue;
}
const album = await this.findOrFail(albumId, { withAssets: false });
const album = await this.findOrFail(albumId, auth.user.id, { withAssets: false });
results.error = undefined;
results.success = true;
for (const assetId of notPresentAssetIds) {
albumAssetValues.push({ albumId, assetId });
}
await this.albumRepository.update(albumId, {
id: albumId,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
});
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
await this.albumRepository.update(
albumId,
{
id: albumId,
updatedAt: new Date(),
albumThumbnailAssetId: album.albumThumbnailAssetId ?? notPresentAssetIds[0],
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, recipients: allUsersExceptUs });
}
@@ -262,7 +270,7 @@ export class AlbumService extends BaseService {
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const results = await removeAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
@@ -280,11 +288,11 @@ export class AlbumService extends BaseService {
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
for (const { userId, role } of albumUsers) {
if (album.ownerId === userId) {
throw new BadRequestException('Cannot be shared with owner');
if (role === AlbumUserRole.Owner) {
throw new BadRequestException('Cannot add another owner');
}
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
@@ -298,10 +306,10 @@ export class AlbumService extends BaseService {
}
await this.albumUserRepository.create({ userId, albumId: id, role });
await this.eventRepository.emit('AlbumInvite', { id, userId });
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
return this.findOrFail(id, { withAssets: true }).then(mapAlbum);
return this.findOrFail(id, auth.user.id, { withAssets: true }).then(mapAlbum);
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
@@ -309,17 +317,20 @@ export class AlbumService extends BaseService {
userId = auth.user.id;
}
const album = await this.findOrFail(id, { withAssets: false });
if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner');
}
const album = await this.findOrFail(id, auth.user.id, { withAssets: false });
const exists = album.albumUsers.find(({ user: { id } }) => id === userId);
if (!exists) {
throw new BadRequestException('Album not shared with user');
}
if (
exists.role === AlbumUserRole.Owner &&
album.albumUsers.filter(({ role }) => role === AlbumUserRole.Owner).length === 1
) {
throw new BadRequestException('Cannot remove the last album owner');
}
// non-admin can remove themselves
if (auth.user.id !== userId) {
await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] });
@@ -333,8 +344,8 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
private async findOrFail(id: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options);
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options, authUserId);
if (!album) {
throw new BadRequestException('Album not found');
}
@@ -80,6 +80,7 @@ const validImages = [
'.jxl',
'.k25',
'.kdc',
'.mpo',
'.mrw',
'.nef',
'.orf',
+196 -28
View File
@@ -164,6 +164,32 @@ describe(AuthService.name, () => {
});
});
it('should return the custom end session endpoint if provided', async () => {
const auth = AuthFactory.create();
mocks.systemMetadata.get.mockResolvedValue({
oauth: { enabled: true, endSessionEndpoint: 'http://custom-logout-url' },
});
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://custom-logout-url',
});
});
it('should return the auto-discovered end session endpoint if custom endpoint is not provided', async () => {
const auth = AuthFactory.create();
mocks.systemMetadata.get.mockResolvedValue({
oauth: { enabled: true, endSessionEndpoint: '' },
});
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
});
});
it('should return the default redirect', async () => {
const auth = AuthFactory.create();
@@ -196,6 +222,64 @@ describe(AuthService.name, () => {
});
});
describe('backchannelLogout', () => {
const dto = { logout_token: 'fake-jwt-token' };
it('should throw a Bad Request Exception if OAuth is not enabled', async () => {
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow(
'Received backchannel logout request but OAuth is not enabled',
);
});
it('should throw a Bad Request Exception if the logout token validation fails', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockRejectedValue(new Error('Token validation failed'));
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow('Error backchannel logout: token validation failed');
});
it('should throw a Bad Request Exception if there are no claims in the logout token', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockResolvedValue(null);
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow('Invalid logout token: no claims found');
});
it('should throw a Bad Request Exception if there is neither the sub nor the sid claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockResolvedValue({ sub: '', sid: '' });
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow(
'Invalid logout token: it must contain either a sub or a sid claim',
);
});
it('should invalidate the OAuth session(s) if the logout token is valid', async () => {
const claims = { sub: 'fake-sub', sid: 'fake-sid' };
const deletedSessionIds: string[] = ['fake-session-1', 'fake-session-2'];
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockResolvedValue(claims);
mocks.session.invalidateOAuth.mockResolvedValue(deletedSessionIds);
mocks.event.emit.mockResolvedValue(void 0);
mocks.event.emit.mockResolvedValue(void 0);
await sut.backchannelLogout(dto);
expect(mocks.session.invalidateOAuth).toHaveBeenCalledWith({
oauthSid: claims.sid,
oauthId: claims.sub,
});
expect(mocks.event.emit).toHaveBeenCalledWith('SessionDelete', { sessionId: 'fake-session-1' });
expect(mocks.event.emit).toHaveBeenCalledWith('SessionDelete', { sessionId: 'fake-session-2' });
});
});
describe('adminSignUp', () => {
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
@@ -250,6 +334,7 @@ describe(AuthService.name, () => {
user: UserFactory.create(),
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -416,6 +501,7 @@ describe(AuthService.name, () => {
user: UserFactory.create(),
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -444,6 +530,7 @@ describe(AuthService.name, () => {
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -466,6 +553,7 @@ describe(AuthService.name, () => {
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -601,7 +689,7 @@ describe(AuthService.name, () => {
it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
await expect(
sut.callback(
@@ -619,7 +707,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -639,7 +727,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -658,7 +746,7 @@ describe(AuthService.name, () => {
const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -679,7 +767,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
@@ -698,7 +786,7 @@ describe(AuthService.name, () => {
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' } });
await expect(
sut.callback(
@@ -720,12 +808,12 @@ describe(AuthService.name, () => {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
expect(mocks.oauth.getProfile).toHaveBeenCalledWith(
expect(mocks.oauth.getProfileAndOAuthSid).toHaveBeenCalledWith(
expect.objectContaining({}),
'http://mobile-redirect?code=abc123',
'xyz789',
@@ -738,7 +826,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -753,9 +841,9 @@ describe(AuthService.name, () => {
it('should infer name from given and family names', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
@@ -774,7 +862,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
@@ -791,7 +879,9 @@ describe(AuthService.name, () => {
it('should ignore an invalid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 'abc' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -808,7 +898,9 @@ describe(AuthService.name, () => {
it('should ignore a negative quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -825,7 +917,7 @@ describe(AuthService.name, () => {
it('should set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -842,7 +934,7 @@ describe(AuthService.name, () => {
it('should use a valid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 5 }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -862,14 +954,15 @@ describe(AuthService.name, () => {
const fileId = newUuid();
const user = UserFactory.create({ oauthId: 'oauth-id' });
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
const pictureBytes = new Uint8Array([1, 2, 3, 4, 5]);
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue({
contentType: 'image/jpeg',
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
data: pictureBytes.buffer,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -881,23 +974,54 @@ describe(AuthService.name, () => {
);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
profileChangedAt: expect.any(Date),
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
Buffer.from(pictureBytes.buffer),
expect.objectContaining({ format: 'webp', processInvalidImages: false }),
expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
);
});
it('should not update the user when thumbnail processing fails on the OAuth picture', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.oauth.getProfilePicture.mockResolvedValue({
contentType: 'text/html',
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
});
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
mocks.session.create.mockResolvedValue(SessionFactory.create());
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toBeDefined();
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should not sync the profile picture if the user already has one', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({
sub: user.oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
}),
);
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -914,7 +1038,9 @@ describe(AuthService.name, () => {
it('should only allow "admin" and "user" for the role claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'foo' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -932,7 +1058,9 @@ describe(AuthService.name, () => {
it('should create an admin user if the role claim is set to admin', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -951,7 +1079,9 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
});
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -974,7 +1104,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.update.mockResolvedValue(user);
await sut.link(
@@ -986,13 +1116,36 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
});
it('should link an account and update the session with the oauthSid', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
});
it('should not link an already linked oauth.sub', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(
@@ -1015,6 +1168,21 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
it('should unlink an account and remove the oauthSid from the session', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.session.update.mockResolvedValue(session);
mocks.user.update.mockResolvedValue(user);
await sut.unlink(auth);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
});
describe('setupPinCode', () => {
+69 -15
View File
@@ -2,9 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
import { parse } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { join } from 'node:path';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import {
AuthDto,
@@ -12,6 +10,7 @@ import {
ChangePasswordDto,
LoginCredentialDto,
LogoutResponseDto,
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
PinCodeChangeDto,
@@ -22,12 +21,12 @@ import {
mapLoginResponse,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types';
import { generateProfileImage } from 'src/utils/profile-image';
import { getUserAgentDetails } from 'src/utils/request';
export interface LoginDetails {
isSecure: boolean;
@@ -91,6 +90,40 @@ export class AuthService extends BaseService {
};
}
async backchannelLogout(dto: OAuthBackchannelLogoutDto): Promise<void> {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('Received backchannel logout request but OAuth is not enabled');
}
let claims;
try {
claims = await this.oauthRepository.validateLogoutToken(oauth, dto.logout_token);
} catch (error: Error | any) {
this.logger.error(`Error backchannel logout: ${error.message}`);
this.logger.error(error);
throw new BadRequestException('Error backchannel logout: token validation failed');
}
if (!claims) {
throw new BadRequestException('Invalid logout token: no claims found');
}
if (!claims.sub && !claims.sid) {
throw new BadRequestException('Invalid logout token: it must contain either a sub or a sid claim');
}
const deletedSessionIds = await this.sessionRepository.invalidateOAuth({
oauthSid: claims.sid,
oauthId: claims.sub,
});
for (const sessionId of deletedSessionIds) {
await this.eventRepository.emit('SessionDelete', { sessionId });
}
}
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto;
const user = await this.userRepository.getForChangePassword(auth.user.id);
@@ -276,7 +309,12 @@ export class AuthService extends BaseService {
}
const url = this.resolveRedirectUri(oauth, dto.url);
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
const { profile, sid: oauthSid } = await this.oauthRepository.getProfileAndOAuthSid(
oauth,
url,
expectedState,
codeVerifier,
);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
@@ -342,22 +380,22 @@ export class AuthService extends BaseService {
await this.syncProfilePicture(user, profile.picture);
}
return this.createLoginResponse(user, loginDetails);
return this.createLoginResponse(user, loginDetails, oauthSid);
}
private async syncProfilePicture(user: UserAdmin, url: string) {
try {
const oldPath = user.profileImagePath;
const { data } = await this.oauthRepository.getProfilePicture(url);
const { contentType, data } = await this.oauthRepository.getProfilePicture(url);
const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg';
const profileImagePath = join(
StorageCore.getFolderLocation(StorageFolder.Profile, user.id),
`${this.cryptoRepository.randomUUID()}${extensionWithDot}`,
const config = await this.getConfig({ withCache: true });
const profileImagePath = await generateProfileImage(
{ media: this.mediaRepository, crypto: this.cryptoRepository, storageCore: this.storageCore },
config,
user.id,
Buffer.from(data),
);
this.storageCore.ensureFolders(profileImagePath);
await this.storageRepository.createFile(profileImagePath, Buffer.from(data));
await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() });
if (oldPath) {
@@ -380,18 +418,29 @@ export class AuthService extends BaseService {
}
const { oauth } = await this.getConfig({ withCache: false });
const { sub: oauthId } = await this.oauthRepository.getProfile(oauth, dto.url, expectedState, codeVerifier);
const {
profile: { sub: oauthId },
sid,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
}
const user = await this.userRepository.update(auth.user.id, { oauthId });
return mapUserAdmin(user);
}
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
}
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
return mapUserAdmin(user);
}
@@ -406,6 +455,10 @@ export class AuthService extends BaseService {
return LOGIN_URL;
}
if (config.oauth.endSessionEndpoint) {
return config.oauth.endSessionEndpoint;
}
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
}
@@ -548,7 +601,7 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) {
const token = this.cryptoRepository.randomBytesAsText(32);
const hashed = this.cryptoRepository.hashSha256(token);
@@ -558,6 +611,7 @@ export class AuthService extends BaseService {
deviceType: loginDetails.deviceType,
appVersion: loginDetails.appVersion,
userId: user.id,
oauthSid: oauthSid ?? null,
});
return mapLoginResponse(user, token);
@@ -142,6 +142,61 @@ describe(DownloadService.name, () => {
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_123.jpg', 'IMG_123+1.jpg');
});
it.each([
{ input: '../../../../tmp/pwn.jpg', expected: '........tmppwn.jpg' },
{ input: String.raw`C:\temp\abs3.jpg`, expected: 'Ctempabs3.jpg' },
{ input: 'a/../../b.jpg', expected: 'a....b.jpg' },
{ input: String.raw`..\..\win1.jpg`, expected: '....win1.jpg' },
{ input: '/etc/passwd', expected: 'etcpasswd' },
{ input: '..', expected: 'unnamed' },
{ input: '', expected: 'unnamed' },
])('should sanitize unsafe originalFileName "$input" to "$expected"', async ({ input, expected }) => {
const archiveMock = {
addFile: vitest.fn(),
finalize: vitest.fn(),
stream: new Readable(),
};
const asset = AssetFactory.create({ originalFileName: input, originalPath: '/data/library/safe.jpg' });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForOriginals.mockResolvedValue([asset]);
mocks.storage.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id] })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(archiveMock.addFile).toHaveBeenCalledWith('/data/library/safe.jpg', expected);
});
it('should dedupe sanitized duplicate unsafe filenames', async () => {
const archiveMock = {
addFile: vitest.fn(),
finalize: vitest.fn(),
stream: new Readable(),
};
const asset1 = AssetFactory.create({
originalFileName: '../../../tmp/pwn.jpg',
originalPath: '/data/library/a.jpg',
});
const asset2 = AssetFactory.create({
originalFileName: '../../../tmp/pwn.jpg',
originalPath: '/data/library/b.jpg',
});
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]);
mocks.storage.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/a.jpg', '......tmppwn.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/b.jpg', '......tmppwn+1.jpg');
});
it('should resolve symlinks', async () => {
const archiveMock = {
addFile: vitest.fn(),
+3 -2
View File
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { parse } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadArchiveDto, DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
@@ -95,11 +96,11 @@ export class DownloadService extends BaseService {
const { originalPath, editedPath, originalFileName } = asset;
let filename = originalFileName;
let filename = sanitize(originalFileName) || 'unnamed';
const count = paths[filename] || 0;
paths[filename] = count + 1;
if (count !== 0) {
const parsedFilename = parse(originalFileName);
const parsedFilename = parse(filename);
filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`;
}
File diff suppressed because it is too large Load Diff
@@ -168,10 +168,10 @@ describe(NotificationService.name, () => {
describe('onAlbumInviteEvent', () => {
it('should queue notify album invite event', async () => {
await sut.onAlbumInvite({ id: '', userId: '42' });
await sut.onAlbumInvite({ id: '', userId: '42', senderName: 'foo' });
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NotifyAlbumInvite,
data: { id: '', recipientId: '42' },
data: { id: '', recipientId: '42', senderName: 'foo' },
});
});
});
@@ -264,14 +264,18 @@ describe(NotificationService.name, () => {
describe('handleAlbumInvite', () => {
it('should skip if album could not be found', async () => {
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
expect(mocks.user.get).not.toHaveBeenCalled();
});
it('should skip if recipient could not be found', async () => {
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
expect(mocks.job.queue).not.toHaveBeenCalled();
});
@@ -288,7 +292,9 @@ describe(NotificationService.name, () => {
});
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
});
it('should skip if the recipient has email notifications for album invite disabled', async () => {
@@ -304,7 +310,9 @@ describe(NotificationService.name, () => {
});
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Skipped,
);
});
it('should send invite email', async () => {
@@ -322,7 +330,9 @@ describe(NotificationService.name, () => {
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.SendMail,
data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }),
@@ -346,7 +356,9 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
album.albumThumbnailAssetId,
AssetFileType.Thumbnail,
@@ -378,7 +390,9 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetFile]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
album.albumThumbnailAssetId,
AssetFileType.Thumbnail,
@@ -412,7 +426,9 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([asset.files[0]]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '', senderName: 'foo' })).resolves.toBe(
JobStatus.Success,
);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(
album.albumThumbnailAssetId,
AssetFileType.Thumbnail,
@@ -434,7 +450,7 @@ describe(NotificationService.name, () => {
});
it('should skip if owner could not be found', async () => {
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create({ ownerId: 'non-existent' })));
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.from().owner({ id: 'non-existent' }).build()));
await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
@@ -443,7 +459,6 @@ describe(NotificationService.name, () => {
it('should skip recipient that could not be looked up', async () => {
const album = AlbumFactory.from().albumUser({ userId: 'non-existent' }).build();
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValueOnce(album.owner);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
+7 -7
View File
@@ -226,8 +226,8 @@ export class NotificationService extends BaseService {
}
@OnEvent({ name: 'AlbumInvite' })
async onAlbumInvite({ id, userId }: ArgOf<'AlbumInvite'>) {
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId } });
async onAlbumInvite({ id, userId, senderName }: ArgOf<'AlbumInvite'>) {
await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId, senderName } });
}
@OnEvent({ name: 'SessionDelete' })
@@ -303,7 +303,7 @@ export class NotificationService extends BaseService {
}
@OnJob({ name: JobName.NotifyAlbumInvite, queue: QueueName.Notification })
async handleAlbumInvite({ id, recipientId }: JobOf<JobName.NotifyAlbumInvite>) {
async handleAlbumInvite({ id, recipientId, senderName }: JobOf<JobName.NotifyAlbumInvite>) {
const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) {
return JobStatus.Skipped;
@@ -314,7 +314,7 @@ export class NotificationService extends BaseService {
return JobStatus.Skipped;
}
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, album.owner.name);
await this.sendAlbumLocalNotification(album, recipientId, NotificationType.AlbumInvite, senderName);
const { emailNotifications } = getPreferences(recipient.metadata);
@@ -331,7 +331,7 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server),
albumId: album.id,
albumName: album.albumName,
senderName: album.owner.name,
senderName,
recipientName: recipient.name,
cid: attachment ? attachment.cid : undefined,
},
@@ -360,8 +360,8 @@ export class NotificationService extends BaseService {
return JobStatus.Skipped;
}
const owner = await this.userRepository.get(album.ownerId, { withDeleted: false });
if (!owner) {
const recipient = await this.userRepository.get(recipientId, { withDeleted: false });
if (!recipient) {
return JobStatus.Skipped;
}
-5
View File
@@ -109,11 +109,6 @@ export class ServerService extends BaseService {
};
}
async getTheme() {
const { theme } = await this.getConfig({ withCache: false });
return theme;
}
async getSystemConfig(): Promise<ServerConfigDto> {
const { setup } = this.configRepository.getEnv();
const config = await this.getConfig({ withCache: false });
+5 -2
View File
@@ -46,11 +46,14 @@ describe('SessionService', () => {
const currentSession = SessionFactory.create();
const auth = AuthFactory.from().session(currentSession).build();
mocks.session.invalidate.mockResolvedValue();
mocks.session.invalidateAll.mockResolvedValue();
await sut.deleteAll(auth);
expect(mocks.session.invalidate).toHaveBeenCalledWith({ userId: auth.user.id, excludeId: currentSession.id });
expect(mocks.session.invalidateAll).toHaveBeenCalledWith({
userId: auth.user.id,
excludeId: currentSession.id,
});
});
});
+2 -2
View File
@@ -73,7 +73,7 @@ export class SessionService extends BaseService {
async deleteAll(auth: AuthDto): Promise<void> {
const userId = auth.user.id;
const currentSessionId = auth.session?.id;
await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId });
await this.sessionRepository.invalidateAll({ userId, excludeId: currentSessionId });
}
async lock(auth: AuthDto, id: string): Promise<void> {
@@ -83,6 +83,6 @@ export class SessionService extends BaseService {
@OnEvent({ name: 'AuthChangePassword' })
async onAuthChangePassword({ userId, currentSessionId }: ArgOf<'AuthChangePassword'>): Promise<void> {
await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId });
await this.sessionRepository.invalidateAll({ userId, excludeId: currentSessionId });
}
}
+18
View File
@@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import {
SyncAckDeleteDto,
SyncAckSetDto,
syncAlbumV2ToV1,
syncAssetFaceV2ToV1,
SyncAssetV1,
SyncItem,
@@ -60,6 +61,7 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.PartnerStacksV1,
SyncRequestType.AlbumAssetsV1,
SyncRequestType.AlbumsV1,
SyncRequestType.AlbumsV2,
SyncRequestType.AlbumUsersV1,
SyncRequestType.AlbumToAssetsV1,
SyncRequestType.AssetExifsV1,
@@ -165,6 +167,7 @@ export class SyncService extends BaseService {
[SyncRequestType.PartnerAssetExifsV1]: () =>
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
[SyncRequestType.AlbumsV2]: () => this.syncAlbumsV2(options, response, checkpointMap),
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id),
@@ -412,6 +415,21 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.AlbumV1;
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
const albumUsers = await this.syncRepository.album.getAlbumUsers(data.id);
send(response, { type: upsertType, ids: [updateId], data: syncAlbumV2ToV1(data, albumUsers) });
}
}
private async syncAlbumsV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.AlbumDeleteV1;
const deletes = this.syncRepository.album.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AlbumV2;
const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
@@ -138,8 +138,10 @@ const updatedConfig = Object.freeze<SystemConfig>({
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',
+32 -5
View File
@@ -113,20 +113,34 @@ describe(UserService.name, () => {
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
});
it('should delete the previous profile image', async () => {
it('should throw BadRequestException and clean up raw upload when thumbnail processing fails', async () => {
const file = { path: '/profile/path' } as Express.Multer.File;
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
mocks.user.get.mockResolvedValue(user);
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files: [file.path] } }]]);
});
it('should delete the raw upload and the previous profile image', async () => {
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
const file = { path: '/profile/path' } as Express.Multer.File;
const files = [user.profileImagePath];
mocks.user.get.mockResolvedValue(user);
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await sut.createProfileImage(authStub.admin, file);
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files } }]]);
expect(mocks.job.queue.mock.calls).toEqual([
[{ name: JobName.FileDelete, data: { files: [file.path, user.profileImagePath] } }],
]);
});
it('should not delete the profile image if it has not been set', async () => {
it('should delete only the raw upload if no previous profile image is set', async () => {
const file = { path: '/profile/path' } as Express.Multer.File;
mocks.user.get.mockResolvedValue(userStub.admin);
@@ -134,7 +148,7 @@ describe(UserService.name, () => {
await sut.createProfileImage(authStub.admin, file);
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files: [file.path] } }]]);
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
});
@@ -192,6 +206,19 @@ describe(UserService.name, () => {
expect(mocks.user.get).toHaveBeenCalledWith(user.id, {});
});
it('should return the profile picture with the content-type matching the stored file', async () => {
const user = UserFactory.create({ profileImagePath: '/path/to/profile.webp' });
mocks.user.get.mockResolvedValue(user);
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
new ImmichFileResponse({
path: '/path/to/profile.webp',
contentType: 'image/webp',
cacheControl: CacheControl.None,
}),
);
});
});
describe('handleQueueUserDelete', () => {
+21 -6
View File
@@ -16,7 +16,9 @@ import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
import { JobOf, UserMetadataItem } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
import { generateProfileImage } from 'src/utils/profile-image';
@Injectable()
export class UserService extends BaseService {
@@ -91,16 +93,29 @@ export class UserService extends BaseService {
}
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
const { profileImagePath: oldPath } = await this.findOrFail(auth.user.id, { withDeleted: false });
let profileImagePath: string;
try {
const config = await this.getConfig({ withCache: true });
profileImagePath = await generateProfileImage(
{ media: this.mediaRepository, crypto: this.cryptoRepository, storageCore: this.storageCore },
config,
auth.user.id,
file.path,
);
} catch (error) {
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.path] } });
throw new BadRequestException('Unable to process profile image', { cause: error });
}
const user = await this.userRepository.update(auth.user.id, {
profileImagePath: file.path,
profileImagePath,
profileChangedAt: new Date(),
});
if (oldpath !== '') {
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldpath] } });
}
const toDelete = [file.path, ...(oldPath ? [oldPath] : [])];
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: toDelete } });
return {
userId: user.id,
@@ -126,7 +141,7 @@ export class UserService extends BaseService {
return new ImmichFileResponse({
path: user.profileImagePath,
contentType: 'image/jpeg',
contentType: mimeTypes.lookup(user.profileImagePath),
cacheControl: CacheControl.None,
});
}
+1 -5
View File
@@ -65,13 +65,8 @@ export interface DecodeToBufferOptions extends DecodeImageOptions {
}
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> & DecodeToBufferOptions;
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };
export type GenerateThumbhashOptions = DecodeImageOptions;
export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo };
export interface GenerateThumbnailsOptions {
colorspace: string;
preview?: ImageOptions;
@@ -254,6 +249,7 @@ export interface INotifySignupJob extends IEntityJob {
export interface INotifyAlbumInviteJob extends IEntityJob {
recipientId: string;
senderName: string;
}
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
-2
View File
@@ -10,8 +10,6 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { DeepPartial } from 'src/types';
import { getKeysDeep, unsetDeep } from 'src/utils/misc';
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
type RepoDeps = {
configRepo: ConfigRepository;
metadataRepo: SystemMetadataRepository;
+2
View File
@@ -455,3 +455,5 @@ export const updateLockedColumns = <T extends Record<string, unknown> & { locked
exif.lockedProperties = lockableProperties.filter((property) => property in exif);
return exif;
};
export const dummy = sql`(select 1)`.as('dummy');
-49
View File
@@ -1,60 +1,11 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { Server as SocketIO } from 'socket.io';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
import { StorageFolder } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl(
baseUrl: string,
auth: MaintenanceAuthDto,
+120 -85
View File
@@ -91,14 +91,14 @@ export class BaseConfig implements VideoCodecSWConfig {
) {
const options = {
inputOptions: this.getBaseInputOptions(videoStream, format),
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v', 'verbose'],
twoPass: this.eligibleForTwoPass(),
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
} as TranscodeCommand;
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
const filters = this.getFilterOptions(videoStream);
if (filters.length > 0) {
options.outputOptions.push(`-vf ${filters.join(',')}`);
options.outputOptions.push('-vf', filters.join(','));
}
}
@@ -121,36 +121,40 @@ export class BaseConfig implements VideoCodecSWConfig {
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';
const options = [
`-c:v ${videoCodec}`,
`-c:a ${audioCodec}`,
'-c:v',
videoCodec,
'-c:a',
audioCodec,
// Makes a second pass moving the moov atom to the
// beginning of the file for improved playback speed.
'-movflags faststart',
'-fps_mode passthrough',
// explicitly selects the video stream instead of leaving it up to FFmpeg
`-map 0:${videoStream.index}`,
// Strip metadata like capture date, camera, and GPS
'-map_metadata -1',
'-movflags',
'faststart',
'-fps_mode',
'passthrough',
'-map',
`0:${videoStream.index}`,
'-map_metadata',
'-1',
];
if (audioStream) {
options.push(`-map 0:${audioStream.index}`);
options.push('-map', `0:${audioStream.index}`);
}
if (this.getBFrames() > -1) {
options.push(`-bf ${this.getBFrames()}`);
options.push('-bf', `${this.getBFrames()}`);
}
if (this.getRefs() > 0) {
options.push(`-refs ${this.getRefs()}`);
options.push('-refs', `${this.getRefs()}`);
}
if (this.getGopSize() > 0) {
options.push(`-g ${this.getGopSize()}`);
options.push('-g', `${this.getGopSize()}`);
}
if (
this.config.targetVideoCodec === VideoCodec.Hevc &&
(videoCodec !== 'copy' || videoStream.codecName === 'hevc')
) {
options.push('-tag:v hvc1');
options.push('-tag:v', 'hvc1');
}
return options;
@@ -173,26 +177,32 @@ export class BaseConfig implements VideoCodecSWConfig {
}
getPresetOptions() {
return [`-preset ${this.config.preset}`];
return ['-preset', this.config.preset];
}
getBitrateOptions() {
const bitrates = this.getBitrateDistribution();
if (this.eligibleForTwoPass()) {
return [
`-b:v ${bitrates.target}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-minrate',
`${bitrates.min}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
];
} else if (bitrates.max > 0) {
// -bufsize is the peak possible bitrate at any moment, while -maxrate is the max rolling average bitrate
return [
`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.max * 2}${bitrates.unit}`,
`-${this.useCQP() ? 'q:v' : 'crf'}`,
`${this.config.crf}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-bufsize',
`${bitrates.max * 2}${bitrates.unit}`,
];
} else {
return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`];
return [`-${this.useCQP() ? 'q:v' : 'crf'}`, `${this.config.crf}`];
}
}
@@ -204,7 +214,7 @@ export class BaseConfig implements VideoCodecSWConfig {
if (this.config.threads <= 0) {
return [];
}
return [`-threads ${this.config.threads}`];
return ['-threads', `${this.config.threads}`];
}
eligibleForTwoPass() {
@@ -395,8 +405,8 @@ export class ThumbnailConfig extends BaseConfig {
// skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details.
const options =
format?.formatName === 'mpegts'
? ['-sws_flags accurate_rnd+full_chroma_int']
: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'];
? ['-sws_flags', 'accurate_rnd+full_chroma_int']
: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'];
const metadataOverrides = [];
if (videoStream.colorPrimaries === 'reserved') {
@@ -413,14 +423,14 @@ export class ThumbnailConfig extends BaseConfig {
if (metadataOverrides.length > 0) {
// workaround for https://fftrac-bg.ffmpeg.org/ticket/11020
options.push(`-bsf:v ${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`);
options.push('-bsf:v', `${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`);
}
return options;
}
getBaseOutputOptions() {
return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
return ['-fps_mode', 'vfr', '-frames:v', '1', '-update', '1'];
}
getFilterOptions(videoStream: VideoStreamInfo): string[] {
@@ -455,7 +465,7 @@ export class H264Config extends BaseConfig {
getOutputThreadOptions() {
const options = super.getOutputThreadOptions();
if (this.config.threads === 1) {
options.push('-x264-params frame-threads=1:pools=none');
options.push('-x264-params', 'frame-threads=1:pools=none');
}
return options;
@@ -466,7 +476,7 @@ export class HEVCConfig extends BaseConfig {
getOutputThreadOptions() {
const options = super.getOutputThreadOptions();
if (this.config.threads === 1) {
options.push('-x265-params frame-threads=1:pools=none');
options.push('-x265-params', 'frame-threads=1:pools=none');
}
return options;
@@ -477,7 +487,7 @@ export class VP9Config extends BaseConfig {
getPresetOptions() {
const speed = Math.min(this.getPresetIndex(), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
if (speed >= 0) {
return [`-cpu-used ${speed}`];
return ['-cpu-used', `${speed}`];
}
return [];
}
@@ -486,17 +496,20 @@ export class VP9Config extends BaseConfig {
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0 && this.eligibleForTwoPass()) {
return [
`-b:v ${bitrates.target}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-minrate',
`${bitrates.min}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
];
}
return [`-${this.useCQP() ? 'q:v' : 'crf'} ${this.config.crf}`, `-b:v ${bitrates.max}${bitrates.unit}`];
return [`-${this.useCQP() ? 'q:v' : 'crf'}`, `${this.config.crf}`, '-b:v', `${bitrates.max}${bitrates.unit}`];
}
getOutputThreadOptions() {
return ['-row-mt 1', ...super.getOutputThreadOptions()];
return ['-row-mt', '1', ...super.getOutputThreadOptions()];
}
eligibleForTwoPass() {
@@ -512,13 +525,13 @@ export class AV1Config extends BaseConfig {
getPresetOptions() {
const speed = this.getPresetIndex() + 4; // Use 4 as slowest, giving us an effective range of 4-12 which is far more useful than 0-8
if (speed >= 0) {
return [`-preset ${speed}`];
return ['-preset', `${speed}`];
}
return [];
}
getBitrateOptions() {
const options = [`-crf ${this.config.crf}`];
const options = ['-crf', `${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
const svtparams = [];
if (this.config.threads > 0) {
@@ -528,7 +541,7 @@ export class AV1Config extends BaseConfig {
svtparams.push(`mbr=${bitrates.max}${bitrates.unit}`);
}
if (svtparams.length > 0) {
options.push(`-svtav1-params ${svtparams.join(':')}`);
options.push('-svtav1-params', svtparams.join(':'));
}
return options;
}
@@ -552,23 +565,27 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
}
getBaseInputOptions() {
return [`-init_hw_device cuda=cuda:${this.device}`, '-filter_hw_device cuda'];
return ['-init_hw_device', `cuda=cuda:${this.device}`, '-filter_hw_device', 'cuda'];
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = [
// below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
'-tune hq',
'-qmin 0',
'-rc-lookahead 20',
'-i_qfactor 0.75',
'-tune',
'hq',
'-qmin',
'0',
'-rc-lookahead',
'20',
'-i_qfactor',
'0.75',
...super.getBaseOutputOptions(target, videoStream, audioStream),
];
if (this.getBFrames() > 0) {
options.push('-b_ref_mode middle', '-b_qfactor 1.1');
options.push('-b_ref_mode', 'middle', '-b_qfactor', '1.1');
}
if (this.config.temporalAQ) {
options.push('-temporal-aq 1');
options.push('-temporal-aq', '1');
}
return options;
}
@@ -589,26 +606,33 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
return [];
}
presetIndex = 7 - Math.min(6, presetIndex); // map to p1-p7; p7 is the highest quality, so reverse index
return [`-preset p${presetIndex}`];
return ['-preset', `p${presetIndex}`];
}
getBitrateOptions() {
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0 && this.config.twoPass) {
return [
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
'-multipass 2',
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-bufsize',
`${bitrates.target}${bitrates.unit}`,
'-multipass',
'2',
];
} else if (bitrates.max > 0) {
return [
`-cq:v ${this.config.crf}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-bufsize ${bitrates.target}${bitrates.unit}`,
'-cq:v',
`${this.config.crf}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-bufsize',
`${bitrates.target}${bitrates.unit}`,
];
} else {
return [`-cq:v ${this.config.crf}`];
return ['-cq:v', `${this.config.crf}`];
}
}
@@ -627,7 +651,7 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
getBaseInputOptions() {
return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()];
return ['-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda', '-noautorotate', ...this.getInputThreadOptions()];
}
getFilterOptions(videoStream: VideoStreamInfo) {
@@ -664,7 +688,7 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
}
getInputThreadOptions() {
return [`-threads 1`];
return ['-threads', '1'];
}
getOutputThreadOptions() {
@@ -674,14 +698,14 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
export class QsvSwDecodeConfig extends BaseHWConfig {
getBaseInputOptions() {
return [`-init_hw_device qsv=hw,child_device=${this.device}`, '-filter_hw_device hw'];
return ['-init_hw_device', `qsv=hw,child_device=${this.device}`, '-filter_hw_device', 'hw'];
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = super.getBaseOutputOptions(target, videoStream, audioStream);
// VP9 requires enabling low power mode https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/33583803e107b6d532def0f9d949364b01b6ad5a
if (this.config.targetVideoCodec === VideoCodec.Vp9) {
options.push('-low_power 1');
options.push('-low_power', '1');
}
return options;
}
@@ -701,14 +725,14 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
return [];
}
presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
return [`-preset ${presetIndex}`];
return ['-preset', `${presetIndex}`];
}
getBitrateOptions() {
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'} ${this.config.crf}`];
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'}`, `${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
options.push(`-maxrate ${bitrates.max}${bitrates.unit}`, `-bufsize ${bitrates.max * 2}${bitrates.unit}`);
options.push('-maxrate', `${bitrates.max}${bitrates.unit}`, '-bufsize', `${bitrates.max * 2}${bitrates.unit}`);
}
return options;
}
@@ -744,11 +768,15 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
getBaseInputOptions() {
return [
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-hwaccel',
'qsv',
'-hwaccel_output_format',
'qsv',
'-async_depth',
'4',
'-noautorotate',
`-qsv_device ${this.device}`,
'-qsv_device',
this.device,
...this.getInputThreadOptions(),
];
}
@@ -791,13 +819,13 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
}
getInputThreadOptions() {
return [`-threads 1`];
return ['-threads', '1'];
}
}
export class VaapiSwDecodeConfig extends BaseHWConfig {
getBaseInputOptions() {
return [`-init_hw_device vaapi=accel:${this.device}`, '-filter_hw_device accel'];
return ['-init_hw_device', `vaapi=accel:${this.device}`, '-filter_hw_device', 'accel'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
@@ -816,7 +844,7 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
return [];
}
presetIndex = Math.min(6, presetIndex) + 1; // 1 to 7
return [`-compression_level ${presetIndex}`];
return ['-compression_level', `${presetIndex}`];
}
getBitrateOptions() {
@@ -824,21 +852,25 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
const options = [];
if (this.config.targetVideoCodec === VideoCodec.Vp9) {
options.push('-bsf:v vp9_raw_reorder,vp9_superframe');
options.push('-bsf:v', 'vp9_raw_reorder,vp9_superframe');
}
// VAAPI doesn't allow setting both quality and max bitrate
if (bitrates.max > 0) {
options.push(
`-b:v ${bitrates.target}${bitrates.unit}`,
`-maxrate ${bitrates.max}${bitrates.unit}`,
`-minrate ${bitrates.min}${bitrates.unit}`,
'-rc_mode 3',
'-b:v',
`${bitrates.target}${bitrates.unit}`,
'-maxrate',
`${bitrates.max}${bitrates.unit}`,
'-minrate',
`${bitrates.min}${bitrates.unit}`,
'-rc_mode',
'3',
); // variable bitrate
} else if (this.useCQP()) {
options.push(`-qp:v ${this.config.crf}`, `-global_quality:v ${this.config.crf}`, '-rc_mode 1');
options.push('-qp:v', `${this.config.crf}`, '-global_quality:v', `${this.config.crf}`, '-rc_mode', '1');
} else {
options.push(`-global_quality:v ${this.config.crf}`, '-rc_mode 4');
options.push('-global_quality:v', `${this.config.crf}`, '-rc_mode', '4');
}
return options;
@@ -856,10 +888,13 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
getBaseInputOptions() {
return [
'-hwaccel vaapi',
'-hwaccel_output_format vaapi',
'-hwaccel',
'vaapi',
'-hwaccel_output_format',
'vaapi',
'-noautorotate',
`-hwaccel_device ${this.device}`,
'-hwaccel_device',
this.device,
...this.getInputThreadOptions(),
];
}
@@ -902,7 +937,7 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
}
getInputThreadOptions() {
return [`-threads 1`];
return ['-threads', '1'];
}
}
@@ -919,11 +954,11 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
switch (this.config.targetVideoCodec) {
case VideoCodec.H264: {
// from ffmpeg_mpp help, commonly referred to as H264 level 5.1
return ['-level 51'];
return ['-level', '51'];
}
case VideoCodec.Hevc: {
// from ffmpeg_mpp help, commonly referred to as HEVC level 5.1
return ['-level 153'];
return ['-level', '153'];
}
default: {
throw new Error(`Incompatible video codec for RKMPP: ${this.config.targetVideoCodec}`);
@@ -935,10 +970,10 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
const bitrate = this.getMaxBitrateValue();
if (bitrate > 0) {
// -b:v specifies max bitrate, average bitrate is derived automatically...
return ['-rc_mode AVBR', `-b:v ${bitrate}${this.getBitrateUnit()}`];
return ['-rc_mode', 'AVBR', '-b:v', `${bitrate}${this.getBitrateUnit()}`];
}
// use CRF value as QP value
return ['-rc_mode CQP', `-qp_init ${this.config.crf}`];
return ['-rc_mode', 'CQP', '-qp_init', `${this.config.crf}`];
}
getSupportedCodecs() {
@@ -952,7 +987,7 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
getBaseInputOptions() {
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
return ['-hwaccel', 'rkmpp', '-hwaccel_output_format', 'drm_prime', '-afbc', 'rga', '-noautorotate'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
+1
View File
@@ -26,6 +26,7 @@ describe('mimeTypes', () => {
{ mimetype: 'image/jpeg', extension: '.jpe' },
{ mimetype: 'image/jpeg', extension: '.jpeg' },
{ mimetype: 'image/jpeg', extension: '.jpg' },
{ mimetype: 'image/jpeg', extension: '.mpo' },
{ mimetype: 'image/jxl', extension: '.jxl' },
{ mimetype: 'image/k25', extension: '.k25' },
{ mimetype: 'image/kdc', extension: '.kdc' },
+1
View File
@@ -58,6 +58,7 @@ const webUnsupportedImage = {
'.jp2': ['image/jp2'],
'.jpe': ['image/jpeg'],
'.jxl': ['image/jxl'],
'.mpo': ['image/jpeg'],
'.svg': ['image/svg'],
'.tif': ['image/tiff'],
'.tiff': ['image/tiff'],
+40
View File
@@ -0,0 +1,40 @@
import { join } from 'node:path';
import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { StorageFolder } from 'src/enum';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { MediaRepository } from 'src/repositories/media.repository';
type Repos = {
media: MediaRepository;
crypto: CryptoRepository;
storageCore: StorageCore;
};
export const generateProfileImage = async (
{ media, crypto, storageCore }: Repos,
{ image }: SystemConfig,
userId: string,
input: string | Buffer,
): Promise<string> => {
const outputPath = join(
StorageCore.getFolderLocation(StorageFolder.Profile, userId),
`${crypto.randomUUID()}.${image.thumbnail.format}`,
);
storageCore.ensureFolders(outputPath);
await media.generateThumbnail(
input,
{
colorspace: image.colorspace,
format: image.thumbnail.format,
quality: image.thumbnail.quality,
progressive: image.thumbnail.progressive,
size: image.thumbnail.size,
processInvalidImages: false,
},
outputPath,
);
return outputPath;
};
+16
View File
@@ -32,6 +32,22 @@ export function IsIPRange(options?: IsIPRangeOptions) {
.refine((arr) => arr.every((item) => isIPOrRange(item, options)), 'Must be an ip address or ip address range');
}
/**
* Like z.object().partial(), but rejects objects where every field is undefined.
* Use for update/patch DTOs where at least one field must be provided.
*
* @example
* nonEmptyPartial({ name: z.string(), bio: z.string() }).meta({ id: 'UpdateDto' });
*/
export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
return z
.object(shape)
.partial()
.refine((data) => Object.values(data as Record<string, unknown>).some((value) => value !== undefined), {
message: 'At least one field must be provided',
});
}
/**
* Zod schema that validates sibling-exclusion for object schemas.
* Validation passes when the target property is missing, or when none of the sibling properties are present.
+5 -10
View File
@@ -1,5 +1,5 @@
import { Selectable } from 'kysely';
import { AssetOrder } from 'src/enum';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumUserFactory } from 'test/factories/album-user.factory';
@@ -10,15 +10,12 @@ import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AlbumFactory {
#owner: UserFactory;
#owner!: UserFactory;
#sharedLinks: Selectable<SharedLinkTable>[] = [];
#albumUsers: AlbumUserFactory[] = [];
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<AlbumTable>) {
value.ownerId ??= newUuid();
this.#owner = UserFactory.from({ id: value.ownerId });
}
private constructor(private readonly value: Selectable<AlbumTable>) {}
static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build();
@@ -27,7 +24,6 @@ export class AlbumFactory {
static from(dto: AlbumLike = {}) {
return new AlbumFactory({
id: newUuid(),
ownerId: newUuid(),
albumName: 'My Album',
albumThumbnailAssetId: null,
createdAt: newDate(),
@@ -43,7 +39,7 @@ export class AlbumFactory {
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#owner = build(UserFactory.from(dto), builder);
this.value.ownerId = this.#owner.build().id;
this.albumUser({ userId: this.#owner.build().id, role: AlbumUserRole.Owner });
return this;
}
@@ -53,7 +49,7 @@ export class AlbumFactory {
}
albumUser(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumUserFactory>) {
const albumUser = build(AlbumUserFactory.from(dto).album(this.value), builder);
const albumUser = build(AlbumUserFactory.from(dto), builder);
this.#albumUsers.push(albumUser);
return this;
}
@@ -78,7 +74,6 @@ export class AlbumFactory {
build() {
return {
...this.value,
owner: this.#owner.build(),
assets: this.#assets.map((asset) => asset.build()),
albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()),
sharedLinks: this.#sharedLinks,
+1
View File
@@ -25,6 +25,7 @@ export class SessionFactory {
updateId: newUuidV7(),
updatedAt: newDate(),
userId: newUuid(),
oauthSid: newUuid(),
...dto,
});
}
-52
View File
@@ -1,5 +1,4 @@
import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { SharedLinkType } from 'src/enum';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
@@ -70,23 +69,6 @@ export const sharedLinkStub = {
album: null,
slug: null,
}),
readonlyNoExif: Object.freeze({
id: '123',
userId: authStub.admin.user.id,
key: sharedLinkBytes,
type: SharedLinkType.Individual,
createdAt: today,
expiresAt: tomorrow,
allowUpload: false,
allowDownload: false,
showExif: false,
description: null,
password: null,
assets: [],
albumId: null,
album: null,
slug: null,
}),
passwordRequired: Object.freeze({
id: '123',
userId: authStub.admin.user.id,
@@ -105,37 +87,3 @@ export const sharedLinkStub = {
album: null,
}),
};
export const sharedLinkResponseStub = {
valid: Object.freeze<SharedLinkResponseDto>({
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today,
description: null,
password: null,
expiresAt: tomorrow,
id: '123',
key: sharedLinkBytes.toString('base64url'),
showMetadata: true,
type: SharedLinkType.Album,
userId: 'admin_id',
slug: null,
}),
expired: Object.freeze<SharedLinkResponseDto>({
album: undefined,
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today,
description: null,
password: null,
expiresAt: yesterday,
id: '123',
key: sharedLinkBytes.toString('base64url'),
showMetadata: true,
type: SharedLinkType.Album,
userId: 'admin_id',
slug: null,
}),
};
-2
View File
@@ -84,7 +84,6 @@ export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
createdAt: albumUser.createdAt.toISOString(),
user: getDehydrated(albumUser.user),
})),
owner: getDehydrated(album.owner),
sharedLinks: album.sharedLinks.map((sharedLink) => getDehydrated(sharedLink)),
});
@@ -219,7 +218,6 @@ export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build
album: sharedLink.album
? {
...getDehydrated(sharedLink.album),
owner: getDehydrated(sharedLink.album.owner),
assets: sharedLink.album.assets.map((asset) => getDehydrated(asset)),
}
: null,
+9 -4
View File
@@ -222,9 +222,14 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { result };
}
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
async newAlbum({ ownerId, ...dto }: Insertable<AlbumTable> & { ownerId: string }, assetIds?: string[]) {
const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
const result = await this.get(AlbumRepository).create(
album,
assetIds ?? [],
[{ userId: ownerId, role: AlbumUserRole.Owner }],
ownerId,
);
return { album, result };
}
@@ -570,9 +575,9 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
};
};
const albumInsert = (album: Partial<Insertable<AlbumTable>> & { ownerId: string }) => {
const albumInsert = (album: Partial<Insertable<AlbumTable>>) => {
const id = album.id || newUuid();
const defaults: Omit<Insertable<AlbumTable>, 'ownerId'> = {
const defaults: Insertable<AlbumTable> = {
albumName: 'Album',
};
-77
View File
@@ -2,113 +2,36 @@ import { expect } from 'vitest';
export const errorDto = {
unauthorized: {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required',
correlationId: expect.any(String),
},
forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password',
correlationId: expect.any(String),
},
invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token',
correlationId: expect.any(String),
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
correlationId: expect.any(String),
},
invalidSharePassword: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid password',
correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
}),
noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
},
incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password',
correlationId: expect.any(String),
},
alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin',
correlationId: expect.any(String),
},
};
export const signupResponseDto = {
admin: {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.cloud',
storageLabel: 'admin',
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
license: null,
profileChangedAt: expect.any(String),
},
};
export const loginResponseDto = {
admin: {
accessToken: expect.any(String),
name: 'Immich Admin',
isAdmin: true,
profileImagePath: '',
shouldChangePassword: true,
userEmail: 'admin@immich.cloud',
userId: expect.any(String),
},
};
export const deviceDto = {
current: {
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
deviceOS: '',
deviceType: '',
},
};
@@ -25,6 +25,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Owner,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -47,6 +55,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Owner,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -136,6 +152,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album.id,
role: AlbumUserRole.Owner,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -163,6 +187,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@@ -201,6 +226,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
@@ -229,10 +255,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
it('should backfill album users when a user shares an album with you', async () => {
const { auth, ctx } = await setup();
const { user } = await ctx.newUser();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { album: album1 } = await ctx.newAlbum({ ownerId: user1.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user1.id });
const { album: album1 } = await ctx.newAlbum({ ownerId: user.id });
const { album: album2 } = await ctx.newAlbum({ ownerId: user.id });
// backfill album user
await ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.Editor });
await wait(2);
@@ -244,6 +271,15 @@ describe(SyncRequestType.AlbumUsersV1, () => {
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album2.id,
role: AlbumUserRole.Owner,
userId: user.id,
}),
type: SyncEntityType.AlbumUserV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -264,6 +300,15 @@ describe(SyncRequestType.AlbumUsersV1, () => {
// should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
albumId: album1.id,
role: AlbumUserRole.Owner,
userId: user.id,
}),
type: SyncEntityType.AlbumUserBackfillV1,
},
{
ack: expect.any(String),
data: expect.objectContaining({
@@ -30,7 +30,6 @@ describe(SyncRequestType.AlbumsV1, () => {
data: expect.objectContaining({
id: album.id,
name: album.albumName,
ownerId: album.ownerId,
}),
type: SyncEntityType.AlbumV1,
},
-2
View File
@@ -246,8 +246,6 @@ export const factory = {
date: newDate,
responses: {
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
}),
},
-37
View File
@@ -531,43 +531,6 @@ export const mockDuplex =
return duplex;
};
export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => {
const stdoutStream = new Readable({
read() {
this.push(stdout); // write mock data to stdout
this.push(null); // end stream
},
});
return {
stdout: stdoutStream,
stderr: new Readable({
read() {
this.push(stderr); // write mock data to stderr
this.push(null); // end stream
},
}),
stdin: new Writable({
write(chunk, encoding, callback) {
callback();
},
}),
exitCode,
on: vitest.fn((event, callback: any) => {
if (event === 'close') {
stdoutStream.once('end', () => callback(0));
}
if (event === 'error' && error) {
stdoutStream.once('end', () => callback(error));
}
if (event === 'exit') {
stdoutStream.once('end', () => callback(exitCode));
}
}),
kill: vitest.fn(),
} as unknown as ChildProcessWithoutNullStreams;
});
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
for (const item of items) {
await Promise.resolve();
+1
View File
@@ -27,5 +27,6 @@
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"noErrorTruncation": true
},
"include": ["src", "test"],
"exclude": ["dist", "node_modules", "upload"]
}