mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
parent
40e079a247
commit
4b29bccc7c
36 changed files with 47 additions and 47 deletions
287
server/src/cores/access.core.ts
Normal file
287
server/src/cores/access.core.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthDto } from 'src/domain/auth/auth.dto';
|
||||
import { IAccessRepository } from 'src/domain/repositories/access.repository';
|
||||
import { SharedLinkEntity } from 'src/infra/entities/shared-link.entity';
|
||||
import { setDifference, setIsEqual, setUnion } from 'src/utils';
|
||||
|
||||
export enum Permission {
|
||||
ACTIVITY_CREATE = 'activity.create',
|
||||
ACTIVITY_DELETE = 'activity.delete',
|
||||
|
||||
// ASSET_CREATE = 'asset.create',
|
||||
ASSET_READ = 'asset.read',
|
||||
ASSET_UPDATE = 'asset.update',
|
||||
ASSET_DELETE = 'asset.delete',
|
||||
ASSET_RESTORE = 'asset.restore',
|
||||
ASSET_SHARE = 'asset.share',
|
||||
ASSET_VIEW = 'asset.view',
|
||||
ASSET_DOWNLOAD = 'asset.download',
|
||||
ASSET_UPLOAD = 'asset.upload',
|
||||
|
||||
// ALBUM_CREATE = 'album.create',
|
||||
ALBUM_READ = 'album.read',
|
||||
ALBUM_UPDATE = 'album.update',
|
||||
ALBUM_DELETE = 'album.delete',
|
||||
ALBUM_REMOVE_ASSET = 'album.removeAsset',
|
||||
ALBUM_SHARE = 'album.share',
|
||||
ALBUM_DOWNLOAD = 'album.download',
|
||||
|
||||
AUTH_DEVICE_DELETE = 'authDevice.delete',
|
||||
|
||||
ARCHIVE_READ = 'archive.read',
|
||||
|
||||
TIMELINE_READ = 'timeline.read',
|
||||
TIMELINE_DOWNLOAD = 'timeline.download',
|
||||
|
||||
PERSON_READ = 'person.read',
|
||||
PERSON_WRITE = 'person.write',
|
||||
PERSON_MERGE = 'person.merge',
|
||||
PERSON_CREATE = 'person.create',
|
||||
PERSON_REASSIGN = 'person.reassign',
|
||||
|
||||
PARTNER_UPDATE = 'partner.update',
|
||||
}
|
||||
|
||||
let instance: AccessCore | null;
|
||||
|
||||
export class AccessCore {
|
||||
private constructor(private repository: IAccessRepository) {}
|
||||
|
||||
static create(repository: IAccessRepository) {
|
||||
if (!instance) {
|
||||
instance = new AccessCore(repository);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
requireUploadAccess(auth: AuthDto | null): AuthDto {
|
||||
if (!auth || (auth.sharedLink && !auth.sharedLink.allowUpload)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to all ids, for the given permission.
|
||||
* Throws error if user does not have access to any of the ids.
|
||||
*/
|
||||
async requirePermission(auth: AuthDto, permission: Permission, ids: string[] | string) {
|
||||
ids = Array.isArray(ids) ? ids : [ids];
|
||||
const allowedIds = await this.checkAccess(auth, permission, ids);
|
||||
if (!setIsEqual(new Set(ids), allowedIds)) {
|
||||
throw new BadRequestException(`Not found or no ${permission} access`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return ids that user has access to, for the given permission.
|
||||
* Check is done for each id, and only allowed ids are returned.
|
||||
*
|
||||
* @returns Set<string>
|
||||
*/
|
||||
async checkAccess(auth: AuthDto, permission: Permission, ids: Set<string> | string[]) {
|
||||
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
|
||||
if (idSet.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
return this.checkAccessSharedLink(auth.sharedLink, permission, idSet);
|
||||
}
|
||||
|
||||
return this.checkAccessOther(auth, permission, idSet);
|
||||
}
|
||||
|
||||
private async checkAccessSharedLink(sharedLink: SharedLinkEntity, permission: Permission, ids: Set<string>) {
|
||||
const sharedLinkId = sharedLink.id;
|
||||
|
||||
switch (permission) {
|
||||
case Permission.ASSET_READ: {
|
||||
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
|
||||
}
|
||||
|
||||
case Permission.ASSET_VIEW: {
|
||||
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
|
||||
}
|
||||
|
||||
case Permission.ASSET_DOWNLOAD: {
|
||||
return sharedLink.allowDownload
|
||||
? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids)
|
||||
: new Set();
|
||||
}
|
||||
|
||||
case Permission.ASSET_UPLOAD: {
|
||||
return sharedLink.allowUpload ? ids : new Set();
|
||||
}
|
||||
|
||||
case Permission.ASSET_SHARE: {
|
||||
// TODO: fix this to not use sharedLink.userId for access control
|
||||
return await this.repository.asset.checkOwnerAccess(sharedLink.userId, ids);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_READ: {
|
||||
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD: {
|
||||
return sharedLink.allowDownload
|
||||
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
|
||||
: new Set();
|
||||
}
|
||||
|
||||
default: {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) {
|
||||
switch (permission) {
|
||||
// uses album id
|
||||
case Permission.ACTIVITY_CREATE: {
|
||||
return await this.repository.activity.checkCreateAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
// uses activity id
|
||||
case Permission.ACTIVITY_DELETE: {
|
||||
const isOwner = await this.repository.activity.checkOwnerAccess(auth.user.id, ids);
|
||||
const isAlbumOwner = await this.repository.activity.checkAlbumOwnerAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
);
|
||||
return setUnion(isOwner, isAlbumOwner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_READ: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner, isAlbum),
|
||||
);
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_SHARE: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_VIEW: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner, isAlbum),
|
||||
);
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_DOWNLOAD: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner, isAlbum),
|
||||
);
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_UPDATE: {
|
||||
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ASSET_DELETE: {
|
||||
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ASSET_RESTORE: {
|
||||
return await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_READ: {
|
||||
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_UPDATE: {
|
||||
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_DELETE: {
|
||||
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_SHARE: {
|
||||
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD: {
|
||||
const isOwner = await this.repository.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await this.repository.album.checkSharedAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_REMOVE_ASSET: {
|
||||
return await this.repository.album.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ASSET_UPLOAD: {
|
||||
return await this.repository.library.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.ARCHIVE_READ: {
|
||||
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
|
||||
}
|
||||
|
||||
case Permission.AUTH_DEVICE_DELETE: {
|
||||
return await this.repository.authDevice.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.TIMELINE_READ: {
|
||||
const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
|
||||
const isPartner = await this.repository.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isPartner);
|
||||
}
|
||||
|
||||
case Permission.TIMELINE_DOWNLOAD: {
|
||||
return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set();
|
||||
}
|
||||
|
||||
case Permission.PERSON_READ: {
|
||||
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.PERSON_WRITE: {
|
||||
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.PERSON_MERGE: {
|
||||
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.PERSON_CREATE: {
|
||||
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.PERSON_REASSIGN: {
|
||||
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.PARTNER_UPDATE: {
|
||||
return await this.repository.partner.checkUpdateAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
default: {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
316
server/src/cores/storage.core.ts
Normal file
316
server/src/cores/storage.core.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import { dirname, join, resolve } from 'node:path';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { APP_MEDIA_LOCATION } from 'src/domain/domain.constant';
|
||||
import { IAssetRepository } from 'src/domain/repositories/asset.repository';
|
||||
import { ICryptoRepository } from 'src/domain/repositories/crypto.repository';
|
||||
import { IMoveRepository } from 'src/domain/repositories/move.repository';
|
||||
import { IPersonRepository } from 'src/domain/repositories/person.repository';
|
||||
import { IStorageRepository } from 'src/domain/repositories/storage.repository';
|
||||
import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository';
|
||||
import { AssetEntity } from 'src/infra/entities/asset.entity';
|
||||
import { AssetPathType, PathType, PersonPathType } from 'src/infra/entities/move.entity';
|
||||
import { PersonEntity } from 'src/infra/entities/person.entity';
|
||||
import { ImmichLogger } from 'src/infra/logger';
|
||||
|
||||
export enum StorageFolder {
|
||||
ENCODED_VIDEO = 'encoded-video',
|
||||
LIBRARY = 'library',
|
||||
UPLOAD = 'upload',
|
||||
PROFILE = 'profile',
|
||||
THUMBNAILS = 'thumbs',
|
||||
}
|
||||
|
||||
export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS));
|
||||
export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO));
|
||||
|
||||
export interface MoveRequest {
|
||||
entityId: string;
|
||||
pathType: PathType;
|
||||
oldPath: string | null;
|
||||
newPath: string;
|
||||
assetInfo?: {
|
||||
sizeInBytes: number;
|
||||
checksum: Buffer;
|
||||
};
|
||||
}
|
||||
|
||||
type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO;
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
||||
export class StorageCore {
|
||||
private logger = new ImmichLogger(StorageCore.name);
|
||||
private configCore;
|
||||
private constructor(
|
||||
private assetRepository: IAssetRepository,
|
||||
private moveRepository: IMoveRepository,
|
||||
private personRepository: IPersonRepository,
|
||||
private cryptoRepository: ICryptoRepository,
|
||||
private repository: IStorageRepository,
|
||||
systemConfigRepository: ISystemConfigRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(systemConfigRepository);
|
||||
}
|
||||
|
||||
static create(
|
||||
assetRepository: IAssetRepository,
|
||||
moveRepository: IMoveRepository,
|
||||
personRepository: IPersonRepository,
|
||||
cryptoRepository: ICryptoRepository,
|
||||
configRepository: ISystemConfigRepository,
|
||||
repository: IStorageRepository,
|
||||
) {
|
||||
if (!instance) {
|
||||
instance = new StorageCore(
|
||||
assetRepository,
|
||||
moveRepository,
|
||||
personRepository,
|
||||
cryptoRepository,
|
||||
repository,
|
||||
configRepository,
|
||||
);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
static getFolderLocation(folder: StorageFolder, userId: string) {
|
||||
return join(StorageCore.getBaseFolder(folder), userId);
|
||||
}
|
||||
|
||||
static getLibraryFolder(user: { storageLabel: string | null; id: string }) {
|
||||
return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
|
||||
}
|
||||
|
||||
static getBaseFolder(folder: StorageFolder) {
|
||||
return join(APP_MEDIA_LOCATION, folder);
|
||||
}
|
||||
|
||||
static getPersonThumbnailPath(person: PersonEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getLargeThumbnailPath(asset: AssetEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getSmallThumbnailPath(asset: AssetEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: AssetEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
|
||||
static getAndroidMotionPath(asset: AssetEntity, uuid: string) {
|
||||
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${uuid}-MP.mp4`);
|
||||
}
|
||||
|
||||
static isAndroidMotionPath(originalPath: string) {
|
||||
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO));
|
||||
}
|
||||
|
||||
static isImmichPath(path: string) {
|
||||
return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION));
|
||||
}
|
||||
|
||||
static isGeneratedAsset(path: string) {
|
||||
return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR);
|
||||
}
|
||||
|
||||
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
|
||||
const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset;
|
||||
switch (pathType) {
|
||||
case AssetPathType.JPEG_THUMBNAIL: {
|
||||
return this.moveFile({
|
||||
entityId,
|
||||
pathType,
|
||||
oldPath: resizePath,
|
||||
newPath: StorageCore.getLargeThumbnailPath(asset),
|
||||
});
|
||||
}
|
||||
case AssetPathType.WEBP_THUMBNAIL: {
|
||||
return this.moveFile({
|
||||
entityId,
|
||||
pathType,
|
||||
oldPath: webpPath,
|
||||
newPath: StorageCore.getSmallThumbnailPath(asset),
|
||||
});
|
||||
}
|
||||
case AssetPathType.ENCODED_VIDEO: {
|
||||
return this.moveFile({
|
||||
entityId,
|
||||
pathType,
|
||||
oldPath: encodedVideoPath,
|
||||
newPath: StorageCore.getEncodedVideoPath(asset),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
|
||||
const { id: entityId, thumbnailPath } = person;
|
||||
switch (pathType) {
|
||||
case PersonPathType.FACE: {
|
||||
await this.moveFile({
|
||||
entityId,
|
||||
pathType,
|
||||
oldPath: thumbnailPath,
|
||||
newPath: StorageCore.getPersonThumbnailPath(person),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async moveFile(request: MoveRequest) {
|
||||
const { entityId, pathType, oldPath, newPath, assetInfo } = request;
|
||||
if (!oldPath || oldPath === newPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureFolders(newPath);
|
||||
|
||||
let move = await this.moveRepository.getByEntity(entityId, pathType);
|
||||
if (move) {
|
||||
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
|
||||
const oldPathExists = await this.repository.checkFileExists(move.oldPath);
|
||||
const newPathExists = await this.repository.checkFileExists(move.newPath);
|
||||
const newPathCheck = newPathExists ? move.newPath : null;
|
||||
const actualPath = oldPathExists ? move.oldPath : newPathCheck;
|
||||
if (!actualPath) {
|
||||
this.logger.warn('Unable to complete move. File does not exist at either location.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileAtNewLocation = actualPath === move.newPath;
|
||||
this.logger.log(`Found file at ${fileAtNewLocation ? 'new' : 'old'} location`);
|
||||
|
||||
if (
|
||||
fileAtNewLocation &&
|
||||
!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, move.newPath, assetInfo))
|
||||
) {
|
||||
this.logger.fatal(
|
||||
`Skipping move as file verification failed, old file is missing and new file is different to what was expected`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
|
||||
} else {
|
||||
move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
|
||||
}
|
||||
|
||||
if (pathType === AssetPathType.ORIGINAL && !assetInfo) {
|
||||
this.logger.warn(`Unable to complete move. Missing asset info for ${entityId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (move.oldPath !== newPath) {
|
||||
try {
|
||||
this.logger.debug(`Attempting to rename file: ${move.oldPath} => ${newPath}`);
|
||||
await this.repository.rename(move.oldPath, newPath);
|
||||
} catch (error: any) {
|
||||
if (error.code !== 'EXDEV') {
|
||||
this.logger.warn(
|
||||
`Unable to complete move. Error renaming file with code ${error.code} and message: ${error.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.logger.debug(`Unable to rename file. Falling back to copy, verify and delete`);
|
||||
await this.repository.copyFile(move.oldPath, newPath);
|
||||
|
||||
if (!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, newPath, assetInfo))) {
|
||||
this.logger.warn(`Skipping move due to file size mismatch`);
|
||||
await this.repository.unlink(newPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const { atime, mtime } = await this.repository.stat(move.oldPath);
|
||||
await this.repository.utimes(newPath, atime, mtime);
|
||||
|
||||
try {
|
||||
await this.repository.unlink(move.oldPath);
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Unable to delete old file, it will now no longer be tracked by Immich: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.savePath(pathType, entityId, newPath);
|
||||
await this.moveRepository.delete(move);
|
||||
}
|
||||
|
||||
private async verifyNewPathContentsMatchesExpected(
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
assetInfo?: { sizeInBytes: number; checksum: Buffer },
|
||||
) {
|
||||
const oldStat = await this.repository.stat(oldPath);
|
||||
const newStat = await this.repository.stat(newPath);
|
||||
const oldPathSize = assetInfo ? assetInfo.sizeInBytes : oldStat.size;
|
||||
const newPathSize = newStat.size;
|
||||
this.logger.debug(`File size check: ${newPathSize} === ${oldPathSize}`);
|
||||
if (newPathSize !== oldPathSize) {
|
||||
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
|
||||
return false;
|
||||
}
|
||||
const config = await this.configCore.getConfig();
|
||||
if (assetInfo && config.storageTemplate.hashVerificationEnabled) {
|
||||
const { checksum } = assetInfo;
|
||||
const newChecksum = await this.cryptoRepository.hashFile(newPath);
|
||||
if (!newChecksum.equals(checksum)) {
|
||||
this.logger.warn(
|
||||
`Unable to complete move. File checksum mismatch: ${newChecksum.toString('base64')} !== ${checksum.toString(
|
||||
'base64',
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.logger.debug(`File checksum check: ${newChecksum.toString('base64')} === ${checksum.toString('base64')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureFolders(input: string) {
|
||||
this.repository.mkdirSync(dirname(input));
|
||||
}
|
||||
|
||||
removeEmptyDirs(folder: StorageFolder) {
|
||||
return this.repository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.ORIGINAL: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
}
|
||||
case AssetPathType.JPEG_THUMBNAIL: {
|
||||
return this.assetRepository.update({ id, resizePath: newPath });
|
||||
}
|
||||
case AssetPathType.WEBP_THUMBNAIL: {
|
||||
return this.assetRepository.update({ id, webpPath: newPath });
|
||||
}
|
||||
case AssetPathType.ENCODED_VIDEO: {
|
||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
||||
}
|
||||
case AssetPathType.SIDECAR: {
|
||||
return this.assetRepository.update({ id, sidecarPath: newPath });
|
||||
}
|
||||
case PersonPathType.FACE: {
|
||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static getNestedFolder(folder: StorageFolder, ownerId: string, filename: string): string {
|
||||
return join(StorageCore.getFolderLocation(folder, ownerId), filename.slice(0, 2), filename.slice(2, 4));
|
||||
}
|
||||
|
||||
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
|
||||
return join(this.getNestedFolder(folder, ownerId, filename), filename);
|
||||
}
|
||||
}
|
||||
364
server/src/cores/system-config.core.ts
Normal file
364
server/src/cores/system-config.core.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { CronExpression } from '@nestjs/schedule';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { load as loadYaml } from 'js-yaml';
|
||||
import * as _ from 'lodash';
|
||||
import { Subject } from 'rxjs';
|
||||
import { QueueName } from 'src/domain/job/job.constants';
|
||||
import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository';
|
||||
import { SystemConfigDto } from 'src/domain/system-config/dto/system-config.dto';
|
||||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
Colorspace,
|
||||
LogLevel,
|
||||
SystemConfig,
|
||||
SystemConfigEntity,
|
||||
SystemConfigKey,
|
||||
SystemConfigValue,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
} from 'src/infra/entities/system-config.entity';
|
||||
import { ImmichLogger } from 'src/infra/logger';
|
||||
|
||||
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
|
||||
|
||||
export const defaults = Object.freeze<SystemConfig>({
|
||||
ffmpeg: {
|
||||
crf: 23,
|
||||
threads: 0,
|
||||
preset: 'ultrafast',
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
targetAudioCodec: AudioCodec.AAC,
|
||||
acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS],
|
||||
targetResolution: '720',
|
||||
maxBitrate: '0',
|
||||
bframes: -1,
|
||||
refs: 0,
|
||||
gopSize: 0,
|
||||
npl: 0,
|
||||
temporalAQ: false,
|
||||
cqMode: CQMode.AUTO,
|
||||
twoPass: false,
|
||||
preferredHwDevice: 'auto',
|
||||
transcode: TranscodePolicy.REQUIRED,
|
||||
tonemap: ToneMapping.HABLE,
|
||||
accel: TranscodeHWAccel.DISABLED,
|
||||
},
|
||||
job: {
|
||||
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
|
||||
[QueueName.SMART_SEARCH]: { concurrency: 2 },
|
||||
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
|
||||
[QueueName.FACE_DETECTION]: { concurrency: 2 },
|
||||
[QueueName.SEARCH]: { concurrency: 5 },
|
||||
[QueueName.SIDECAR]: { concurrency: 5 },
|
||||
[QueueName.LIBRARY]: { concurrency: 5 },
|
||||
[QueueName.MIGRATION]: { concurrency: 5 },
|
||||
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
|
||||
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
|
||||
},
|
||||
logging: {
|
||||
enabled: true,
|
||||
level: LogLevel.LOG,
|
||||
},
|
||||
machineLearning: {
|
||||
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
|
||||
url: process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003',
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
},
|
||||
facialRecognition: {
|
||||
enabled: true,
|
||||
modelName: 'buffalo_l',
|
||||
minScore: 0.7,
|
||||
maxDistance: 0.5,
|
||||
minFaces: 3,
|
||||
},
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: '',
|
||||
darkStyle: '',
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
},
|
||||
oauth: {
|
||||
autoLaunch: false,
|
||||
autoRegister: true,
|
||||
buttonText: 'Login with OAuth',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
defaultStorageQuota: 0,
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
scope: 'openid email profile',
|
||||
signingAlgorithm: 'RS256',
|
||||
storageLabelClaim: 'preferred_username',
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
},
|
||||
passwordLogin: {
|
||||
enabled: true,
|
||||
},
|
||||
storageTemplate: {
|
||||
enabled: false,
|
||||
hashVerificationEnabled: true,
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
thumbnail: {
|
||||
webpSize: 250,
|
||||
jpegSize: 1440,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
},
|
||||
newVersionCheck: {
|
||||
enabled: true,
|
||||
},
|
||||
trash: {
|
||||
enabled: true,
|
||||
days: 30,
|
||||
},
|
||||
theme: {
|
||||
customCss: '',
|
||||
},
|
||||
library: {
|
||||
scan: {
|
||||
enabled: true,
|
||||
cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT,
|
||||
},
|
||||
watch: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
externalDomain: '',
|
||||
loginPageMessage: '',
|
||||
},
|
||||
user: {
|
||||
deleteDelay: 7,
|
||||
},
|
||||
});
|
||||
|
||||
export enum FeatureFlag {
|
||||
SMART_SEARCH = 'smartSearch',
|
||||
FACIAL_RECOGNITION = 'facialRecognition',
|
||||
MAP = 'map',
|
||||
REVERSE_GEOCODING = 'reverseGeocoding',
|
||||
SIDECAR = 'sidecar',
|
||||
SEARCH = 'search',
|
||||
OAUTH = 'oauth',
|
||||
OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch',
|
||||
PASSWORD_LOGIN = 'passwordLogin',
|
||||
CONFIG_FILE = 'configFile',
|
||||
TRASH = 'trash',
|
||||
}
|
||||
|
||||
export type FeatureFlags = Record<FeatureFlag, boolean>;
|
||||
|
||||
let instance: SystemConfigCore | null;
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigCore {
|
||||
private logger = new ImmichLogger(SystemConfigCore.name);
|
||||
private configCache: SystemConfigEntity<SystemConfigValue>[] | null = null;
|
||||
|
||||
public config$ = new Subject<SystemConfig>();
|
||||
|
||||
private constructor(private repository: ISystemConfigRepository) {}
|
||||
|
||||
static create(repository: ISystemConfigRepository) {
|
||||
if (!instance) {
|
||||
instance = new SystemConfigCore(repository);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
async requireFeature(feature: FeatureFlag) {
|
||||
const hasFeature = await this.hasFeature(feature);
|
||||
if (!hasFeature) {
|
||||
switch (feature) {
|
||||
case FeatureFlag.SMART_SEARCH: {
|
||||
throw new BadRequestException('Smart search is not enabled');
|
||||
}
|
||||
case FeatureFlag.FACIAL_RECOGNITION: {
|
||||
throw new BadRequestException('Facial recognition is not enabled');
|
||||
}
|
||||
case FeatureFlag.SIDECAR: {
|
||||
throw new BadRequestException('Sidecar is not enabled');
|
||||
}
|
||||
case FeatureFlag.SEARCH: {
|
||||
throw new BadRequestException('Search is not enabled');
|
||||
}
|
||||
case FeatureFlag.OAUTH: {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
case FeatureFlag.PASSWORD_LOGIN: {
|
||||
throw new BadRequestException('Password login is not enabled');
|
||||
}
|
||||
case FeatureFlag.CONFIG_FILE: {
|
||||
throw new BadRequestException('Config file is not set');
|
||||
}
|
||||
default: {
|
||||
throw new ForbiddenException(`Missing required feature: ${feature}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async hasFeature(feature: FeatureFlag) {
|
||||
const features = await this.getFeatures();
|
||||
return features[feature] ?? false;
|
||||
}
|
||||
|
||||
async getFeatures(): Promise<FeatureFlags> {
|
||||
const config = await this.getConfig();
|
||||
const mlEnabled = config.machineLearning.enabled;
|
||||
|
||||
return {
|
||||
[FeatureFlag.SMART_SEARCH]: mlEnabled && config.machineLearning.clip.enabled,
|
||||
[FeatureFlag.FACIAL_RECOGNITION]: mlEnabled && config.machineLearning.facialRecognition.enabled,
|
||||
[FeatureFlag.MAP]: config.map.enabled,
|
||||
[FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled,
|
||||
[FeatureFlag.SIDECAR]: true,
|
||||
[FeatureFlag.SEARCH]: true,
|
||||
[FeatureFlag.TRASH]: config.trash.enabled,
|
||||
[FeatureFlag.OAUTH]: config.oauth.enabled,
|
||||
[FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch,
|
||||
[FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled,
|
||||
[FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE,
|
||||
};
|
||||
}
|
||||
|
||||
public getDefaults(): SystemConfig {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
public async getConfig(force = false): Promise<SystemConfig> {
|
||||
const configFilePath = process.env.IMMICH_CONFIG_FILE;
|
||||
const config = _.cloneDeep(defaults);
|
||||
const overrides = configFilePath ? await this.loadFromFile(configFilePath, force) : await this.repository.load();
|
||||
|
||||
for (const { key, value } of overrides) {
|
||||
// set via dot notation
|
||||
_.set(config, key, value);
|
||||
}
|
||||
|
||||
const errors = await validate(plainToInstance(SystemConfigDto, config));
|
||||
if (errors.length > 0) {
|
||||
this.logger.error('Validation error', errors);
|
||||
if (configFilePath) {
|
||||
throw new Error(`Invalid value(s) in file: ${errors}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) {
|
||||
config.ffmpeg.acceptedVideoCodecs.unshift(config.ffmpeg.targetVideoCodec);
|
||||
}
|
||||
|
||||
if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) {
|
||||
config.ffmpeg.acceptedAudioCodecs.unshift(config.ffmpeg.targetAudioCodec);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async updateConfig(newConfig: SystemConfig): Promise<SystemConfig> {
|
||||
if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) {
|
||||
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
|
||||
}
|
||||
|
||||
const updates: SystemConfigEntity[] = [];
|
||||
const deletes: SystemConfigEntity[] = [];
|
||||
|
||||
for (const key of Object.values(SystemConfigKey)) {
|
||||
// get via dot notation
|
||||
const item = { key, value: _.get(newConfig, key) as SystemConfigValue };
|
||||
const defaultValue = _.get(defaults, key);
|
||||
const isMissing = !_.has(newConfig, key);
|
||||
|
||||
if (
|
||||
isMissing ||
|
||||
item.value === null ||
|
||||
item.value === '' ||
|
||||
item.value === defaultValue ||
|
||||
_.isEqual(item.value, defaultValue)
|
||||
) {
|
||||
deletes.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
updates.push(item);
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await this.repository.saveAll(updates);
|
||||
}
|
||||
|
||||
if (deletes.length > 0) {
|
||||
await this.repository.deleteKeys(deletes.map((item) => item.key));
|
||||
}
|
||||
|
||||
const config = await this.getConfig();
|
||||
|
||||
this.config$.next(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public async refreshConfig() {
|
||||
const newConfig = await this.getConfig(true);
|
||||
|
||||
this.config$.next(newConfig);
|
||||
}
|
||||
|
||||
private async loadFromFile(filepath: string, force = false) {
|
||||
if (force || !this.configCache) {
|
||||
try {
|
||||
const file = await this.repository.readFile(filepath);
|
||||
const config = loadYaml(file.toString()) as any;
|
||||
const overrides: SystemConfigEntity<SystemConfigValue>[] = [];
|
||||
|
||||
for (const key of Object.values(SystemConfigKey)) {
|
||||
const value = _.get(config, key);
|
||||
this.unsetDeep(config, key);
|
||||
if (value !== undefined) {
|
||||
overrides.push({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
if (!_.isEmpty(config)) {
|
||||
this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`);
|
||||
}
|
||||
|
||||
this.configCache = overrides;
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to load configuration file: ${filepath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return this.configCache;
|
||||
}
|
||||
|
||||
private unsetDeep(object: object, key: string) {
|
||||
_.unset(object, key);
|
||||
const path = key.split('.');
|
||||
while (path.pop()) {
|
||||
if (!_.isEmpty(_.get(object, path))) {
|
||||
return;
|
||||
}
|
||||
_.unset(object, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
110
server/src/cores/user.core.ts
Normal file
110
server/src/cores/user.core.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { ICryptoRepository } from 'src/domain/repositories/crypto.repository';
|
||||
import { ILibraryRepository } from 'src/domain/repositories/library.repository';
|
||||
import { IUserRepository } from 'src/domain/repositories/user.repository';
|
||||
import { UserResponseDto } from 'src/domain/user/response-dto/user-response.dto';
|
||||
import { LibraryType } from 'src/infra/entities/library.entity';
|
||||
import { UserEntity } from 'src/infra/entities/user.entity';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
let instance: UserCore | null;
|
||||
|
||||
export class UserCore {
|
||||
private constructor(
|
||||
private cryptoRepository: ICryptoRepository,
|
||||
private libraryRepository: ILibraryRepository,
|
||||
private userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
static create(
|
||||
cryptoRepository: ICryptoRepository,
|
||||
libraryRepository: ILibraryRepository,
|
||||
userRepository: IUserRepository,
|
||||
) {
|
||||
if (!instance) {
|
||||
instance = new UserCore(cryptoRepository, libraryRepository, userRepository);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
// TODO: move auth related checks to the service layer
|
||||
async updateUser(user: UserEntity | UserResponseDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
|
||||
if (!user.isAdmin && user.id !== id) {
|
||||
throw new ForbiddenException('You are not allowed to update this user');
|
||||
}
|
||||
|
||||
if (!user.isAdmin) {
|
||||
// Users can never update the isAdmin property.
|
||||
delete dto.isAdmin;
|
||||
delete dto.storageLabel;
|
||||
} else if (dto.isAdmin && user.id !== id) {
|
||||
// Admin cannot create another admin.
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Email already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.storageLabel) {
|
||||
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Storage label already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.password) {
|
||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
if (dto.storageLabel === '') {
|
||||
dto.storageLabel = null;
|
||||
}
|
||||
|
||||
return this.userRepository.update(id, dto);
|
||||
}
|
||||
|
||||
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
|
||||
const user = await this.userRepository.getByEmail(dto.email);
|
||||
if (user) {
|
||||
throw new BadRequestException('User exists');
|
||||
}
|
||||
|
||||
if (!dto.isAdmin) {
|
||||
const localAdmin = await this.userRepository.getAdmin();
|
||||
if (!localAdmin) {
|
||||
throw new BadRequestException('The first registered account must the administrator.');
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Partial<UserEntity> = { ...dto };
|
||||
if (payload.password) {
|
||||
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
|
||||
}
|
||||
if (payload.storageLabel) {
|
||||
payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', ''));
|
||||
}
|
||||
const userEntity = await this.userRepository.create(payload);
|
||||
await this.libraryRepository.create({
|
||||
owner: { id: userEntity.id } as UserEntity,
|
||||
name: 'Default Library',
|
||||
assets: [],
|
||||
type: LibraryType.UPLOAD,
|
||||
importPaths: [],
|
||||
exclusionPatterns: [],
|
||||
isVisible: true,
|
||||
});
|
||||
|
||||
return userEntity;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue