mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/mobile-ocr
This commit is contained in:
+1
-1
@@ -52,7 +52,7 @@ FROM builder AS plugins
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise
|
||||
COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./plugins/mise.toml ./plugins/
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.5.6",
|
||||
"version": "2.6.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -24,7 +24,7 @@
|
||||
"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 generate",
|
||||
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations create",
|
||||
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
|
||||
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
|
||||
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
|
||||
|
||||
@@ -169,6 +169,7 @@ export type AuthSharedLink = {
|
||||
id: string;
|
||||
expiresAt: Date | null;
|
||||
userId: string;
|
||||
albumId: string | null;
|
||||
showExif: boolean;
|
||||
allowUpload: boolean;
|
||||
allowDownload: boolean;
|
||||
@@ -357,15 +358,6 @@ export const columns = {
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
authSharedLink: [
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
'shared_link.expiresAt',
|
||||
'shared_link.showExif',
|
||||
'shared_link.allowUpload',
|
||||
'shared_link.allowDownload',
|
||||
'shared_link.password',
|
||||
],
|
||||
user: userColumns,
|
||||
userWithPrefix: userWithPrefixColumns,
|
||||
userAdmin: [
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ImmichCookie } from 'src/enum';
|
||||
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
@@ -52,6 +52,11 @@ export class MaintenanceWorkerController {
|
||||
return this.service.getSystemConfig();
|
||||
}
|
||||
|
||||
@Get('server/ping')
|
||||
pingServer(): ServerPingResponse {
|
||||
return this.service.ping();
|
||||
}
|
||||
|
||||
@Get('server/version')
|
||||
getServerVersion(): ServerVersionResponseDto {
|
||||
return this.service.getVersion();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
@@ -121,6 +121,10 @@ export class MaintenanceWorkerService {
|
||||
return ServerVersionResponseDto.fromSemVer(serverVersion);
|
||||
}
|
||||
|
||||
ping(): ServerPingResponse {
|
||||
return { res: 'pong' };
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _ApiService.ssr}
|
||||
*/
|
||||
|
||||
@@ -3,13 +3,16 @@ import { PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
||||
import { NextFunction, RequestHandler } from 'express';
|
||||
import multer, { StorageEngine, diskStorage } from 'multer';
|
||||
import multer from 'multer';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { pipeline } from 'node:stream';
|
||||
import { Observable } from 'rxjs';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { RouteKey } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
@@ -26,8 +29,6 @@ export function getFiles(files: UploadFiles) {
|
||||
};
|
||||
}
|
||||
|
||||
type DiskStorageCallback = (error: Error | null, result: string) => void;
|
||||
|
||||
type ImmichMulterFile = Express.Multer.File & { uuid: string };
|
||||
|
||||
interface Callback<T> {
|
||||
@@ -35,34 +36,21 @@ interface Callback<T> {
|
||||
(error: null, result: T): void;
|
||||
}
|
||||
|
||||
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
|
||||
try {
|
||||
return callback(null, target());
|
||||
} catch (error: Error | any) {
|
||||
return callback(error);
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadInterceptor implements NestInterceptor {
|
||||
private handlers: {
|
||||
userProfile: RequestHandler;
|
||||
assetUpload: RequestHandler;
|
||||
};
|
||||
private defaultStorage: StorageEngine;
|
||||
|
||||
constructor(
|
||||
private reflect: Reflector,
|
||||
private assetService: AssetMediaService,
|
||||
private storageRepository: StorageRepository,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
this.logger.setContext(FileUploadInterceptor.name);
|
||||
|
||||
this.defaultStorage = diskStorage({
|
||||
filename: this.filename.bind(this),
|
||||
destination: this.destination.bind(this),
|
||||
});
|
||||
|
||||
const instance = multer({
|
||||
fileFilter: this.fileFilter.bind(this),
|
||||
storage: {
|
||||
@@ -99,60 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
||||
}
|
||||
|
||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
try {
|
||||
callback(null, this.assetService.canUploadFile(asUploadRequest(request, file)));
|
||||
} catch (error: Error | any) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
(file as ImmichMulterFile).uuid = randomUUID();
|
||||
|
||||
request.on('error', (error) => {
|
||||
this.logger.warn('Request error while uploading file, cleaning up', error);
|
||||
this.assetService.onUploadError(request, file).catch(this.logger.error);
|
||||
});
|
||||
|
||||
if (!this.isAssetUploadFile(file)) {
|
||||
this.defaultStorage._handleFile(request, file, callback);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(file as ImmichMulterFile).uuid = randomUUID();
|
||||
|
||||
const hash = createHash('sha1');
|
||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
||||
this.defaultStorage._handleFile(request, file, (error, info) => {
|
||||
if (error) {
|
||||
hash.destroy();
|
||||
callback(error);
|
||||
} else {
|
||||
callback(null, { ...info, checksum: hash.digest() });
|
||||
}
|
||||
});
|
||||
const uploadRequest = asUploadRequest(request, file);
|
||||
|
||||
const path = join(
|
||||
this.assetService.getUploadFolder(uploadRequest),
|
||||
this.assetService.getUploadFilename(uploadRequest),
|
||||
);
|
||||
|
||||
const writeStream = this.storageRepository.createWriteStream(path);
|
||||
const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null;
|
||||
|
||||
let size = 0;
|
||||
|
||||
file.stream.on('data', (chunk) => {
|
||||
hash?.update(chunk);
|
||||
size += chunk.length;
|
||||
});
|
||||
|
||||
pipeline(file.stream, writeStream, (error) => {
|
||||
if (error) {
|
||||
hash?.destroy();
|
||||
return callback(error);
|
||||
}
|
||||
callback(null, {
|
||||
path,
|
||||
size,
|
||||
checksum: hash?.digest(),
|
||||
});
|
||||
});
|
||||
} catch (error: Error | any) {
|
||||
callback(error);
|
||||
}
|
||||
}
|
||||
|
||||
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
||||
this.defaultStorage._removeFile(request, file, callback);
|
||||
}
|
||||
|
||||
private isAssetUploadFile(file: Express.Multer.File) {
|
||||
switch (file.fieldname as UploadFieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
||||
this.storageRepository
|
||||
.unlink(file.path)
|
||||
.then(() => callback(null))
|
||||
.catch(callback);
|
||||
}
|
||||
|
||||
private getHandler(route: RouteKey) {
|
||||
|
||||
@@ -173,6 +173,7 @@ order by
|
||||
select
|
||||
"shared_link"."id",
|
||||
"shared_link"."userId",
|
||||
"shared_link"."albumId",
|
||||
"shared_link"."expiresAt",
|
||||
"shared_link"."showExif",
|
||||
"shared_link"."allowUpload",
|
||||
@@ -211,6 +212,7 @@ where
|
||||
select
|
||||
"shared_link"."id",
|
||||
"shared_link"."userId",
|
||||
"shared_link"."albumId",
|
||||
"shared_link"."expiresAt",
|
||||
"shared_link"."showExif",
|
||||
"shared_link"."allowUpload",
|
||||
|
||||
@@ -330,6 +330,7 @@ export class AlbumRepository {
|
||||
await db
|
||||
.insertInto('album_asset')
|
||||
.values(assetIds.map((assetId) => ({ albumId, assetId })))
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,12 @@ export class MetadataRepository {
|
||||
}
|
||||
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
// If exiftool assigns a field with ^= instead of =, empty values will be written too.
|
||||
// Since exiftool-vendored doesn't support an option for this, we append the ^ to the name of the tag instead.
|
||||
// https://exiftool.org/exiftool_pod.html#:~:text=is%20used%20to%20write%20an%20empty%20string
|
||||
const tagsToWrite = Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key}^`, value]));
|
||||
try {
|
||||
await this.exiftool.write(path, tags);
|
||||
await this.exiftool.write(path, tagsToWrite);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||
}
|
||||
|
||||
@@ -202,7 +202,14 @@ export class SharedLinkRepository {
|
||||
.leftJoin('album', 'album.id', 'shared_link.albumId')
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select((eb) => [
|
||||
...columns.authSharedLink,
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
'shared_link.albumId',
|
||||
'shared_link.expiresAt',
|
||||
'shared_link.showExif',
|
||||
'shared_link.allowUpload',
|
||||
'shared_link.allowDownload',
|
||||
'shared_link.password',
|
||||
jsonObjectFrom(
|
||||
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
|
||||
).as('user'),
|
||||
|
||||
@@ -63,7 +63,7 @@ export class StorageRepository {
|
||||
}
|
||||
|
||||
createWriteStream(filepath: string): Writable {
|
||||
return createWriteStream(filepath, { flags: 'w' });
|
||||
return createWriteStream(filepath, { flags: 'w', flush: true });
|
||||
}
|
||||
|
||||
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
DELETE FROM "shared_link_asset"
|
||||
USING "shared_link"
|
||||
WHERE "shared_link_asset"."sharedLinkId" = "shared_link"."id" AND "shared_link"."type" = 'ALBUM';
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// noop
|
||||
}
|
||||
@@ -64,8 +64,9 @@ export class UserTable {
|
||||
@Column({ unique: true, nullable: true, default: null })
|
||||
storageLabel!: string | null;
|
||||
|
||||
// TODO remove default, make nullable, and convert empty spaces to null
|
||||
@Column({ default: '' })
|
||||
name!: Generated<string>;
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
quotaSizeInBytes!: ColumnType<number> | null;
|
||||
|
||||
@@ -165,6 +165,12 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
|
||||
);
|
||||
}
|
||||
|
||||
const album = await this.findOrFail(id, { withAssets: false });
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [id] });
|
||||
|
||||
@@ -195,6 +201,12 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded to a shared link are automatically added and calling this endpoint is no longer necessary. It will be removed in the next major release.',
|
||||
);
|
||||
}
|
||||
|
||||
const results: AlbumsAddAssetsResponseDto = {
|
||||
success: false,
|
||||
error: BulkIdErrorReason.DUPLICATE,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotAcceptableException } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -72,6 +72,13 @@ export class ApiService {
|
||||
return next();
|
||||
}
|
||||
|
||||
const responseType = request.accepts('text/html');
|
||||
if (!responseType) {
|
||||
throw new NotAcceptableException(
|
||||
`The route ${request.path} was requested as ${request.header('accept')}, but only returns text/html`,
|
||||
);
|
||||
}
|
||||
|
||||
let status = 200;
|
||||
let html = index;
|
||||
|
||||
@@ -105,7 +112,7 @@ export class ApiService {
|
||||
html = render(index, meta);
|
||||
}
|
||||
|
||||
res.status(status).type('text/html').header('Cache-Control', 'no-store').send(html);
|
||||
res.status(status).type(responseType).header('Cache-Control', 'no-store').send(html);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset } from 'src/database';
|
||||
import { Asset, AuthSharedLink } from 'src/database';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
@@ -152,7 +152,7 @@ export class AssetMediaService extends BaseService {
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
|
||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
@@ -326,6 +326,12 @@ export class AssetMediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) {
|
||||
await (sharedLink.albumId
|
||||
? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId])
|
||||
: this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]));
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
@@ -347,7 +353,7 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AuthService } from 'src/services/auth.service';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { OAuthProfileFactory } from 'test/factories/oauth-profile.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
@@ -15,31 +16,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const oauthResponse = ({
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
profileImagePath,
|
||||
}: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
profileImagePath?: string;
|
||||
}) => ({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: id,
|
||||
userEmail: email,
|
||||
name,
|
||||
profileImagePath,
|
||||
isAdmin: false,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: false,
|
||||
});
|
||||
|
||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
const loginDetails = {
|
||||
isSecure: true,
|
||||
clientIp: '127.0.0.1',
|
||||
@@ -48,11 +25,9 @@ const loginDetails = {
|
||||
appVersion: null,
|
||||
};
|
||||
|
||||
const fixtures = {
|
||||
login: {
|
||||
email,
|
||||
password: 'password',
|
||||
},
|
||||
const dto = {
|
||||
email,
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
describe(AuthService.name, () => {
|
||||
@@ -63,7 +38,6 @@ describe(AuthService.name, () => {
|
||||
({ sut, mocks } = newTestService(AuthService));
|
||||
|
||||
mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' });
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
|
||||
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
|
||||
});
|
||||
|
||||
@@ -75,13 +49,13 @@ describe(AuthService.name, () => {
|
||||
it('should throw an error if password login is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should check the user exists', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -89,7 +63,7 @@ describe(AuthService.name, () => {
|
||||
it('should check the user has a password', async () => {
|
||||
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
|
||||
|
||||
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -100,7 +74,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(session);
|
||||
|
||||
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
|
||||
await expect(sut.login(dto, loginDetails)).resolves.toEqual({
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
@@ -624,6 +598,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());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -638,31 +613,31 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should not link to a user with a different oauth sub', async () => {
|
||||
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -677,35 +652,30 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
|
||||
expect(mocks.user.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
||||
const user = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
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, email: undefined });
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -725,10 +695,9 @@ describe(AuthService.name, () => {
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
]) {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
const user = UserFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
@@ -743,135 +712,136 @@ describe(AuthService.name, () => {
|
||||
}
|
||||
|
||||
it('should use the default quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
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.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
|
||||
});
|
||||
|
||||
it('should fallback to email when no username is provided', async () => {
|
||||
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(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' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should ignore a negative quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
|
||||
});
|
||||
|
||||
it('should set quota for 0 quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
isAdmin: false,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: 0,
|
||||
storageLabel: null,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
|
||||
});
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
||||
mocks.oauth.getProfile.mockResolvedValue(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);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
isAdmin: false,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: 5_368_709_120,
|
||||
storageLabel: null,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
|
||||
});
|
||||
|
||||
it('should sync the profile picture', async () => {
|
||||
const fileId = newUuid();
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
||||
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: pictureUrl,
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||
mocks.oauth.getProfilePicture.mockResolvedValue({
|
||||
@@ -881,131 +851,96 @@ describe(AuthService.name, () => {
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
|
||||
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
|
||||
profileChangedAt: expect.any(Date),
|
||||
});
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
|
||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
|
||||
});
|
||||
|
||||
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({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue(
|
||||
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());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
|
||||
mocks.oauth.getProfile.mockResolvedValue(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);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: false,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
|
||||
});
|
||||
|
||||
it('should create an admin user if the role claim is set to admin', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: true,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
||||
});
|
||||
|
||||
it('should accept a custom role claim', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, my_role: 'admin' });
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
),
|
||||
).resolves.toEqual(oauthResponse(user));
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.create).toHaveBeenCalledWith({
|
||||
email: user.email,
|
||||
name: ' ',
|
||||
oauthId: user.oauthId,
|
||||
quotaSizeInBytes: null,
|
||||
storageLabel: null,
|
||||
isAdmin: true,
|
||||
});
|
||||
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1013,8 +948,10 @@ describe(AuthService.name, () => {
|
||||
it('should link an account', async () => {
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.link(
|
||||
@@ -1023,7 +960,7 @@ describe(AuthService.name, () => {
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
@@ -1032,6 +969,7 @@ describe(AuthService.name, () => {
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -261,6 +261,11 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
|
||||
const expectedState = dto.state ?? this.getCookieOauthState(headers);
|
||||
if (!expectedState?.length) {
|
||||
throw new BadRequestException('OAuth state is missing');
|
||||
@@ -271,7 +276,6 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException('OAuth code verifier is missing');
|
||||
}
|
||||
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
|
||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
|
||||
@@ -298,7 +302,8 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
if (!profile.email) {
|
||||
const email = profile.email;
|
||||
if (!email) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
|
||||
@@ -320,10 +325,13 @@ export class AuthService extends BaseService {
|
||||
isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
|
||||
});
|
||||
|
||||
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
|
||||
user = await this.createUser({
|
||||
name: userName,
|
||||
email: profile.email,
|
||||
name:
|
||||
profile.name ||
|
||||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
||||
profile.preferred_username ||
|
||||
email,
|
||||
email,
|
||||
oauthId: profile.sub,
|
||||
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
|
||||
storageLabel: storageLabel || null,
|
||||
|
||||
@@ -330,7 +330,7 @@ describe(MetadataService.name, () => {
|
||||
duration: null,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
localDateTime: asset.fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
@@ -360,7 +360,7 @@ describe(MetadataService.name, () => {
|
||||
duration: null,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
localDateTime: asset.localDateTime,
|
||||
localDateTime: asset.fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
|
||||
@@ -467,7 +467,7 @@ export class MetadataService extends BaseService {
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
Rating: rating,
|
||||
TagsList: tags?.length ? tags : undefined,
|
||||
TagsList: tags,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { defaults } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
@@ -130,6 +131,32 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onConfigUpdate', () => {
|
||||
it('should queue a version check job when newVersionCheck is enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} });
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck is disabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: false } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not queue a version check job when newVersionCheck was already enabled', async () => {
|
||||
await sut.onConfigUpdate({
|
||||
oldConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
newConfig: { ...defaults, newVersionCheck: { enabled: true } },
|
||||
});
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onWebsocketConnection', () => {
|
||||
it('should send on_server_version client event', async () => {
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
|
||||
@@ -55,6 +55,13 @@ export class VersionService extends BaseService {
|
||||
return this.versionRepository.getAll();
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigUpdate' })
|
||||
async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) {
|
||||
if (!oldConfig.newVersionCheck.enabled && newConfig.newVersionCheck.enabled) {
|
||||
await this.handleQueueVersionCheck();
|
||||
}
|
||||
}
|
||||
|
||||
async handleQueueVersionCheck() {
|
||||
await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} });
|
||||
}
|
||||
|
||||
@@ -190,7 +190,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumUpdate: {
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AlbumDelete: {
|
||||
@@ -198,7 +204,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumShare: {
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AlbumDownload: {
|
||||
|
||||
@@ -155,6 +155,33 @@ describe('transformFaceBoundingBox', () => {
|
||||
expect(result.boundingBoxX2).toBe(50);
|
||||
expect(result.boundingBoxY2).toBe(50);
|
||||
});
|
||||
|
||||
it('should always return whole numbers', () => {
|
||||
const edits: AssetEditActionItem[] = [
|
||||
{ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 250, height: 250 } },
|
||||
];
|
||||
|
||||
expect(transformFaceBoundingBox(baseFace, edits, { width: 1000, height: 400 })).toMatchObject({
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 50,
|
||||
});
|
||||
|
||||
expect(transformFaceBoundingBox(baseFace, edits, { width: 1001, height: 401 })).toMatchObject({
|
||||
boundingBoxX1: 50,
|
||||
boundingBoxY1: 0,
|
||||
boundingBoxX2: 150,
|
||||
boundingBoxY2: 50,
|
||||
});
|
||||
|
||||
expect(transformFaceBoundingBox(baseFace, edits, { width: 999, height: 399 })).toMatchObject({
|
||||
boundingBoxX1: 49,
|
||||
boundingBoxY1: -0,
|
||||
boundingBoxX2: 149,
|
||||
boundingBoxY2: 49,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,10 +179,10 @@ export const transformFaceBoundingBox = (
|
||||
// Ensure x1,y1 is top-left and x2,y2 is bottom-right
|
||||
const [p1, p2] = transformedPoints;
|
||||
return {
|
||||
boundingBoxX1: Math.min(p1.x, p2.x),
|
||||
boundingBoxY1: Math.min(p1.y, p2.y),
|
||||
boundingBoxX2: Math.max(p1.x, p2.x),
|
||||
boundingBoxY2: Math.max(p1.y, p2.y),
|
||||
boundingBoxX1: Math.trunc(Math.min(p1.x, p2.x)),
|
||||
boundingBoxY1: Math.trunc(Math.min(p1.y, p2.y)),
|
||||
boundingBoxX2: Math.trunc(Math.max(p1.x, p2.x)),
|
||||
boundingBoxY2: Math.trunc(Math.max(p1.y, p2.y)),
|
||||
imageWidth: currentWidth,
|
||||
imageHeight: currentHeight,
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
UserLike,
|
||||
} from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
import { newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class AssetFactory {
|
||||
#owner!: UserFactory;
|
||||
@@ -43,10 +43,12 @@ export class AssetFactory {
|
||||
|
||||
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
|
||||
|
||||
let now = Date.now();
|
||||
|
||||
return new AssetFactory({
|
||||
id,
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
createdAt: new Date(now++),
|
||||
updatedAt: new Date(now++),
|
||||
deletedAt: null,
|
||||
updateId: newUuidV7(),
|
||||
status: AssetStatus.Active,
|
||||
@@ -55,14 +57,14 @@ export class AssetFactory {
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
fileCreatedAt: new Date(now++),
|
||||
fileModifiedAt: new Date(now++),
|
||||
isExternal: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: newDate(),
|
||||
localDateTime: new Date(now),
|
||||
originalFileName,
|
||||
originalPath: `/data/library/${originalFileName}`,
|
||||
ownerId: newUuid(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { OAuthProfileLike } from 'test/factories/types';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
|
||||
export class OAuthProfileFactory {
|
||||
private constructor(private value: OAuthProfile) {}
|
||||
|
||||
static create(dto: OAuthProfileLike = {}) {
|
||||
return OAuthProfileFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: OAuthProfileLike = {}) {
|
||||
const sub = newUuid();
|
||||
return new OAuthProfileFactory({
|
||||
sub,
|
||||
name: 'Name',
|
||||
given_name: 'Given',
|
||||
family_name: 'Family',
|
||||
email: `oauth-${sub}@immich.cloud`,
|
||||
email_verified: true,
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
@@ -34,3 +35,4 @@ export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||
export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
||||
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||
export type OAuthProfileLike = Partial<OAuthProfile>;
|
||||
|
||||
Vendored
+1
@@ -48,6 +48,7 @@ export const authStub = {
|
||||
showExif: true,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
albumId: null,
|
||||
expiresAt: null,
|
||||
password: null,
|
||||
userId: '42',
|
||||
|
||||
@@ -220,9 +220,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
return { result };
|
||||
}
|
||||
|
||||
async newAlbum(dto: Insertable<AlbumTable>) {
|
||||
async newAlbum(dto: Insertable<AlbumTable>, assetIds?: string[]) {
|
||||
const album = mediumFactory.albumInsert(dto);
|
||||
const result = await this.get(AlbumRepository).create(album, [], []);
|
||||
const result = await this.get(AlbumRepository).create(album, assetIds ?? [], []);
|
||||
return { album, result };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { newDate } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let database: Kysely<DB>;
|
||||
|
||||
const setup = () => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { ctx, sut: ctx.get(MetadataRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(MetadataRepository.name, () => {
|
||||
describe('writeTags', () => {
|
||||
it('should write an empty description', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, { Description: '' });
|
||||
expect(readFileSync(sidecarFile).toString()).toEqual(expect.stringContaining('rdf:Description'));
|
||||
});
|
||||
|
||||
it('should write an empty tags list', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, { TagsList: [] });
|
||||
const fileContent = readFileSync(sidecarFile).toString();
|
||||
expect(fileContent).toEqual(expect.stringContaining('digiKam:TagsList'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<rdf:li/>'));
|
||||
});
|
||||
});
|
||||
|
||||
it('should write tags', async () => {
|
||||
const { sut } = setup();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
|
||||
const sidecarFile = join(dir, 'sidecar.xmp');
|
||||
|
||||
await sut.writeTags(sidecarFile, {
|
||||
Description: 'my-description',
|
||||
ImageDescription: 'my-image-description',
|
||||
DateTimeOriginal: newDate().toISOString(),
|
||||
GPSLatitude: 42,
|
||||
GPSLongitude: 69,
|
||||
Rating: 3,
|
||||
TagsList: ['tagA'],
|
||||
});
|
||||
|
||||
const fileContent = readFileSync(sidecarFile).toString();
|
||||
expect(fileContent).toEqual(expect.stringContaining('my-description'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('my-image-description'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('exif:DateTimeOriginal'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLatitude>42,0.0N</exif:GPSLatitude>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLongitude>69,0.0E</exif:GPSLongitude>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('<xmp:Rating>3</xmp:Rating>'));
|
||||
expect(fileContent).toEqual(expect.stringContaining('tagA'));
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaSize } from 'src/dtos/asset-media.dto';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { AssetFileType, SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { DB } from 'src/schema';
|
||||
@@ -22,7 +25,7 @@ let defaultDatabase: Kysely<DB>;
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(AssetMediaService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AccessRepository, AssetRepository, UserRepository],
|
||||
real: [AccessRepository, AlbumRepository, AssetRepository, SharedLinkRepository, UserRepository],
|
||||
mock: [EventRepository, LoggingRepository, JobRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
@@ -44,7 +47,6 @@ describe(AssetService.name, () => {
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const file = mediumFactory.uploadFile();
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
@@ -56,7 +58,7 @@ describe(AssetService.name, () => {
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
},
|
||||
file,
|
||||
mediumFactory.uploadFile(),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
id: expect.any(String),
|
||||
@@ -99,6 +101,168 @@ describe(AssetService.name, () => {
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add to a shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Individual,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const file = mediumFactory.uploadFile();
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, file);
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
|
||||
|
||||
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
|
||||
const assets = update!.assets;
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatchObject({ id: response.id });
|
||||
});
|
||||
|
||||
it('should handle adding a duplicate asset to a shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Individual,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
|
||||
|
||||
const update = await sharedLinkRepo.get(user.id, sharedLink.id);
|
||||
const assets = update!.assets;
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toMatchObject({ id: response.id });
|
||||
});
|
||||
|
||||
it('should add to an album shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile());
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.CREATED });
|
||||
|
||||
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
|
||||
const assets = [...result];
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toEqual(response.id);
|
||||
});
|
||||
|
||||
it('should handle adding a duplicate asset to an album shared link', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
ctx.getMock(StorageRepository).utimes.mockResolvedValue();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]);
|
||||
// await ctx.newExif({ assetId: asset.id, fileSizeInByte: 12_345 });
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(50),
|
||||
type: SharedLinkType.Album,
|
||||
albumId: album.id,
|
||||
description: 'Shared link description',
|
||||
userId: user.id,
|
||||
allowDownload: true,
|
||||
allowUpload: true,
|
||||
});
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, sharedLink });
|
||||
const uploadDto = {
|
||||
deviceId: 'some-id',
|
||||
deviceAssetId: 'some-id',
|
||||
fileModifiedAt: new Date(),
|
||||
fileCreatedAt: new Date(),
|
||||
assetData: Buffer.from('some data'),
|
||||
};
|
||||
|
||||
const response = await sut.uploadAsset(auth, uploadDto, mediumFactory.uploadFile({ checksum: asset.checksum }));
|
||||
expect(response).toEqual({ id: expect.any(String), status: AssetMediaStatus.DUPLICATE });
|
||||
|
||||
const result = await ctx.get(AlbumRepository).getAssetIds(album.id, [response.id]);
|
||||
const assets = [...result];
|
||||
expect(assets).toHaveLength(1);
|
||||
expect(assets[0]).toEqual(response.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewThumbnail', () => {
|
||||
|
||||
@@ -591,10 +591,10 @@ describe(PersonService.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({ id: person.id }),
|
||||
boundingBoxX1: expect.closeTo(25, 1),
|
||||
boundingBoxY1: expect.closeTo(50, 1),
|
||||
boundingBoxX2: expect.closeTo(100, 1),
|
||||
boundingBoxY2: expect.closeTo(100, 1),
|
||||
boundingBoxX1: 25,
|
||||
boundingBoxY1: 49,
|
||||
boundingBoxX2: 99,
|
||||
boundingBoxY2: 100,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -47,15 +47,15 @@ describe(UserService.name, () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
const user = mediumFactory.userInsert();
|
||||
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
|
||||
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
|
||||
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
|
||||
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('User exists');
|
||||
});
|
||||
|
||||
it('should not return password', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(EventRepository).emit.mockResolvedValue();
|
||||
const dto = mediumFactory.userInsert({ password: 'password' });
|
||||
const user = await sut.createUser({ email: dto.email, password: 'password' });
|
||||
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
|
||||
expect((user as any).password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,12 +63,22 @@ const authSharedLinkFactory = (sharedLink: Partial<AuthSharedLink> = {}) => {
|
||||
expiresAt = null,
|
||||
userId = newUuid(),
|
||||
showExif = true,
|
||||
albumId = null,
|
||||
allowUpload = false,
|
||||
allowDownload = true,
|
||||
password = null,
|
||||
} = sharedLink;
|
||||
|
||||
return { id, expiresAt, userId, showExif, allowUpload, allowDownload, password };
|
||||
return {
|
||||
id,
|
||||
albumId,
|
||||
expiresAt,
|
||||
userId,
|
||||
showExif,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
password,
|
||||
};
|
||||
};
|
||||
|
||||
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
|
||||
|
||||
Reference in New Issue
Block a user