import { BadRequestException } from '@nestjs/common'; import { StorageCore } from 'src/cores/storage.core'; import { AssetFile, Exif } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types'; import { checkAccess } from 'src/utils/access'; export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => { return files.find((file) => file.type === type && file.isEdited === isEdited); }; export const getAssetFiles = (files: AssetFile[]) => ({ fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }), previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }), sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }), editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }), editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }), }); export const addAssets = async ( auth: AuthDto, repositories: { access: AccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[] }, ) => { const { access, bulk } = repositories; const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id)); const allowedAssetIds = await checkAccess(access, { auth, permission: Permission.AssetShare, ids: notPresentAssetIds, }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { const hasAsset = existingAssetIds.has(assetId); if (hasAsset) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE }); continue; } const hasAccess = allowedAssetIds.has(assetId); if (!hasAccess) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; } existingAssetIds.add(assetId); results.push({ id: assetId, success: true }); } const newAssetIds = results.filter(({ success }) => success).map(({ id }) => id); if (newAssetIds.length > 0) { await bulk.addAssetIds(dto.parentId, newAssetIds); } return results; }; export const removeAssets = async ( auth: AuthDto, repositories: { access: AccessRepository; bulk: IBulkAsset }, dto: { parentId: string; assetIds: string[]; canAlwaysRemove: Permission }, ) => { const { access, bulk } = repositories; // check if the user can always remove from the parent album, memory, etc. const canAlwaysRemove = await checkAccess(access, { auth, permission: dto.canAlwaysRemove, ids: [dto.parentId] }); const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const allowedAssetIds = canAlwaysRemove.has(dto.parentId) ? existingAssetIds : await checkAccess(access, { auth, permission: Permission.AssetShare, ids: existingAssetIds }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { const hasAsset = existingAssetIds.has(assetId); if (!hasAsset) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND }); continue; } const hasAccess = allowedAssetIds.has(assetId); if (!hasAccess) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; } existingAssetIds.delete(assetId); results.push({ id: assetId, success: true }); } const removedIds = results.filter(({ success }) => success).map(({ id }) => id); if (removedIds.length > 0) { await bulk.removeAssetIds(dto.parentId, removedIds); } return results; }; export type PartnerIdOptions = { userId: string; repository: PartnerRepository; /** only include partners with `inTimeline: true` */ timelineEnabled?: boolean; }; export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: PartnerIdOptions) => { const partnerIds = new Set(); const partners = await repository.getAll(userId); for (const partner of partners) { // ignore deleted users if (!partner.sharedBy || !partner.sharedWith) { continue; } // wrong direction if (partner.sharedWithId !== userId) { continue; } if (timelineEnabled && !partner.inTimeline) { continue; } partnerIds.add(partner.sharedById); } return [...partnerIds]; }; export type AssetHookRepositories = { asset: AssetRepository; event: EventRepository }; export const onBeforeLink = async ( { asset: assetRepository, event: eventRepository }: AssetHookRepositories, { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, ) => { const motionAsset = await assetRepository.getById(livePhotoVideoId); if (!motionAsset) { throw new BadRequestException('Live photo video not found'); } if (motionAsset.type !== AssetType.Video) { throw new BadRequestException('Live photo video must be a video'); } if (motionAsset.ownerId !== userId) { throw new BadRequestException('Live photo video does not belong to the user'); } if (motionAsset && motionAsset.visibility === AssetVisibility.Timeline) { await assetRepository.update({ id: livePhotoVideoId, visibility: AssetVisibility.Hidden }); await eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId }); } }; export const onBeforeUnlink = async ( { asset: assetRepository }: AssetHookRepositories, { livePhotoVideoId }: { livePhotoVideoId: string }, ) => { const motion = await assetRepository.getById(livePhotoVideoId); if (!motion) { return null; } if (StorageCore.isAndroidMotionPath(motion.originalPath)) { throw new BadRequestException('Cannot unlink Android motion photos'); } return motion; }; export const onAfterUnlink = async ( { asset: assetRepository, event: eventRepository }: AssetHookRepositories, { userId, livePhotoVideoId, visibility }: { userId: string; livePhotoVideoId: string; visibility: AssetVisibility }, ) => { await assetRepository.update({ id: livePhotoVideoId, visibility }); await eventRepository.emit('AssetShow', { assetId: livePhotoVideoId, userId }); }; export function mapToUploadFile(file: ImmichFile): UploadFile { return { uuid: file.uuid, checksum: file.checksum, originalPath: file.path, originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'), size: file.size, }; } export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => { return { auth: request.user || null, body: request.body, fieldName: file.fieldname as UploadFieldName, file: mapToUploadFile(file as ImmichFile), }; }; const isFlipped = (orientation?: string | null) => { const value = Number(orientation); return value && [5, 6, 7, 8, -90, 90].includes(value); }; export const getDimensions = (exifInfo: ExifResponseDto | Exif) => { const { exifImageWidth: width, exifImageHeight: height } = exifInfo; if (!width || !height) { return { width: 0, height: 0 }; } if (isFlipped(exifInfo.orientation)) { return { width: height, height: width }; } return { width, height }; }; export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => { return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); };