feat: sharing permissions

This commit is contained in:
Daniel Dietzler
2026-04-01 13:10:22 +02:00
parent f88fdae048
commit 6f7821541a
84 changed files with 2382 additions and 347 deletions
+1 -1
View File
@@ -37,7 +37,7 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@immich/sql-tools": "^0.5.1",
"@immich/sql-tools": "^0.5.2",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
@@ -11,6 +11,7 @@ import {
GetAlbumsDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
UpdateSharingPermissionsDto as UpdateSharingOptionsDto,
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -165,6 +166,33 @@ export class AlbumController {
return this.service.addUsers(auth, id, dto);
}
@Get(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@Endpoint({
summary: 'Get own sharing permissions',
description: 'Get the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
getOwnAlbumUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.getSelf(auth, id);
}
@Put(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update own sharing permissions',
description: 'Change the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
updateOwnAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateSharingOptionsDto,
): Promise<void> {
return this.service.updateSelf(auth, id, dto);
}
@Put(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
+2 -2
View File
@@ -19,7 +19,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceUpdateDto,
MergePersonDto,
MergeFaceClusterDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
@@ -182,7 +182,7 @@ export class PersonController {
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: MergePersonDto,
@Body() dto: MergeFaceClusterDto,
): Promise<BulkIdResponseDto[]> {
return this.service.mergePerson(auth, id, dto);
}
+4 -1
View File
@@ -11,6 +11,7 @@ import {
PluginContext,
PluginTriggerType,
SharedLinkType,
SharingPermission,
SourceType,
UserAvatarColor,
UserStatus,
@@ -213,6 +214,7 @@ export type Partner = {
updatedAt: Date;
updateId: string;
inTimeline: boolean;
permissions: SharingPermission[];
};
export type Place = {
@@ -256,6 +258,7 @@ export type Person = {
faceAssetId: string | null;
isHidden: boolean;
thumbnailPath: string;
faceClusterId: string | null;
};
export type AssetFace = {
@@ -268,7 +271,7 @@ export type AssetFace = {
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
personId: string | null;
faceClusterId: string | null;
sourceType: SourceType;
person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date;
+12 -2
View File
@@ -3,8 +3,8 @@ import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { mapUser, UserResponseSchema } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema, SharingPermissionSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { asDateString } from 'src/utils/date';
import { stringToBool } from 'src/validation';
@@ -63,6 +63,14 @@ const UpdateAlbumSchema = z
})
.meta({ id: 'UpdateAlbumDto' });
const UpdateSharingOptionsSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'UpdateSharingOptionsDto' });
const SharingOptionsResponseSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'SharingOptionsResponseDto' });
const GetAlbumsSchema = z
.object({
isOwned: stringToBool
@@ -147,6 +155,8 @@ export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {}
export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {}
export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {}
export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {}
export class UpdateSharingPermissionsDto extends createZodDto(UpdateSharingOptionsSchema) {}
export class SharingPermissionsResponseDto extends createZodDto(SharingOptionsResponseSchema) {}
export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {}
class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {}
+16 -1
View File
@@ -15,6 +15,8 @@ import {
AssetVisibility,
AssetVisibilitySchema,
ChecksumAlgorithm,
SharingPermission,
SharingPermissionSchema,
} from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -45,6 +47,7 @@ const SanitizedAssetResponseSchema = z
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
permissions: z.array(SharingPermissionSchema),
})
.meta({ id: 'SanitizedAssetResponseDto' });
@@ -113,6 +116,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
.boolean()
.describe('Is edited')
.meta(new HistoryBuilder().added('v2.5.0').beta('v2.5.0').getExtensions()),
permissions: z.array(SharingPermissionSchema),
}).shape,
).meta({ id: 'AssetResponseDto' });
@@ -154,6 +158,7 @@ export type MapAsset = {
width: number | null;
height: number | null;
isEdited: boolean;
permissions?: { permission: SharingPermission }[];
};
export type AssetMapOptions = {
@@ -192,8 +197,16 @@ const mapStack = (entity: { stack?: Stack | null }) => {
export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
const permissions =
options.auth?.user.id === entity.ownerId
? [SharingPermission.All]
: (entity.permissions?.map(({ permission }) => permission) ?? []);
if (stripMetadata) {
if (
stripMetadata ||
(entity.permissions &&
!(permissions.includes(SharingPermission.All) || permissions.includes(SharingPermission.ExifRead)))
) {
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
@@ -205,6 +218,7 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
hasMetadata: false,
width: entity.width,
height: entity.height,
permissions,
};
return sanitizedAssetResponse as AssetResponseDto;
}
@@ -242,5 +256,6 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
width: entity.width,
height: entity.height,
isEdited: entity.isEdited,
permissions,
};
}
+6 -4
View File
@@ -40,11 +40,11 @@ const PeopleUpdateSchema = z
})
.meta({ id: 'PeopleUpdateDto' });
const MergePersonSchema = z
const MergeFaceClusterSchema = z
.object({
ids: z.array(z.uuidv4()).describe('Person IDs to merge'),
ids: z.array(z.uuidv4()).describe('Face cluster IDs to merge'),
})
.meta({ id: 'MergePersonDto' });
.meta({ id: 'MergeFaceClusterDto' });
const PersonSearchSchema = z
.object({
@@ -81,13 +81,14 @@ export const PersonResponseSchema = z
.optional()
.describe('Person color (hex)')
.meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()),
faceClusterId: z.string().nullable().describe('Face cluster ID'),
})
.meta({ id: 'PersonResponseDto' });
export class PersonCreateDto extends createZodDto(PersonCreateSchema) {}
export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {}
export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {}
export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class MergeFaceClusterDto extends createZodDto(MergeFaceClusterSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
@@ -179,6 +180,7 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: asDateString(person.updatedAt),
faceClusterId: person.faceClusterId,
};
}
+7 -4
View File
@@ -374,10 +374,13 @@ const SyncAssetFaceV1Schema = z
})
.meta({ id: 'SyncAssetFaceV1' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
isVisible: z.boolean().describe('Is the face visible in the asset'),
}).meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.omit({ personId: true })
.extend({
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
isVisible: z.boolean().describe('Is the face visible in the asset'),
faceClusterId: z.string().nullable().describe('Person ID'),
})
.meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceDeleteV1Schema = z
.object({ assetFaceId: z.string().describe('Asset face ID') })
+24
View File
@@ -306,6 +306,28 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export enum SharingPermission {
All = 'all',
AssetRead = 'asset.read',
AssetUpdate = 'asset.update',
AssetEdit = 'asset.edit',
AssetDelete = 'asset.delete',
AssetShare = 'asset.share',
ExifRead = 'exif.read',
PersonRead = 'person.read',
PersonUpdate = 'person.update',
PersonMerge = 'person.merge',
PersonDelete = 'person.delete',
}
export const SharingPermissionSchema = z
.enum(SharingPermission)
.describe('Sharing permission schema')
.meta({ id: 'SharingPermission' });
export enum SharedLinkType {
Album = 'ALBUM',
@@ -404,6 +426,7 @@ export enum ManualJobName {
MemoryCleanup = 'memory-cleanup',
MemoryCreate = 'memory-create',
BackupDatabase = 'backup-database',
PersonGroupMerge = 'person-group-merge',
}
export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' });
@@ -810,6 +833,7 @@ export enum JobName {
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
FacialRecognition = 'FacialRecognition',
FacialRecognitionMerge = 'FacialRecognitionMerge',
FileDelete = 'FileDelete',
FileMigrationQueueAll = 'FileMigrationQueueAll',
+34
View File
@@ -149,6 +149,40 @@ where
"albumAssets"."livePhotoVideoId"
] && array[$2]::uuid[]
-- AccessRepository.asset.checkSharedAccess
select
"album_asset"."assetId"
from
"album_asset"
inner join "album_user" on "album_asset"."albumId" = "album_user"."albumId"
and "album_user"."userId" = $1
where
"album_asset"."assetId" in ($2)
and "album_asset"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
(
"album_user"."permissions" @> $3::sharing_permission_enum[]
or $4 = any ("album_user"."permissions")
)
)
union
select
"asset"."id" as "assetId"
from
"partner"
inner join "asset" on "asset"."ownerId" = "partner"."sharedById"
and "asset"."id" in ($5)
where
"partner"."sharedWithId" = $6
and (
"partner"."permissions" @> $7::sharing_permission_enum[]
or $8 = any ("partner"."permissions")
)
-- AccessRepository.authDevice.checkOwnerAccess
select
"session"."id"
+52 -14
View File
@@ -182,18 +182,25 @@ select
from
(
select
"asset_face".*,
"person" as "person"
(
select
to_json(obj)
from
(
select
"person".*
from
"face_cluster"
inner join "person" on "person"."faceClusterId" = "face_cluster"."id"
where
"face_cluster"."id" = "asset_face"."faceClusterId"
limit
$1
) as obj
) as "person",
"asset_face".*
from
"asset_face"
left join lateral (
select
"person".*
from
"person"
where
"asset_face"."personId" = "person"."id"
) as "person" on true
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
@@ -224,7 +231,7 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = any ($1::uuid[])
"asset"."id" = any ($2::uuid[])
-- AssetRepository.deleteAll
delete from "asset"
@@ -290,13 +297,44 @@ limit
-- AssetRepository.getById
select
"asset".*
"asset".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select distinct
unnest("album_user"."permissions") as "permission"
from
"album_user"
inner join "album_asset" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."userId" = "asset"."ownerId"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $1
)
union
select distinct
unnest("partner"."permissions") as "permission"
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
) as agg
) as "permissions"
from
"asset"
where
"asset"."id" = $1::uuid
"asset"."id" = $3::uuid
limit
$2
$4
-- AssetRepository.updateAll
update "asset"
+2 -2
View File
@@ -47,7 +47,7 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
@@ -86,7 +86,7 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."id" = "asset_face"."personId"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
+162 -45
View File
@@ -3,9 +3,6 @@
-- PersonRepository.reassignFaces
update "asset_face"
set
"personId" = $1
where
"asset_face"."personId" = $2
-- PersonRepository.delete
delete from "person"
@@ -24,27 +21,64 @@ limit
3
-- PersonRepository.getAllForUser
select
"person".*
select distinct
on ("person"."faceClusterId") "person".*
from
"person"
inner join "asset_face" on "asset_face"."personId" = "person"."id"
inner join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
"person"."ownerId" = $1
(
"person"."ownerId" = $1
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $5
)
and "album_user"."userId" = "person"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "person"."isHidden" = $2
and "person"."isHidden" = $8
group by
"person"."id"
having
(
"person"."name" != $3
or count("asset_face"."assetId") >= $4
"person"."name" != $9
or count("asset_face"."assetId") >= $10
)
order by
"person"."faceClusterId",
"person"."ownerId" = $11 desc,
"person"."isHidden" asc,
"person"."isFavorite" desc,
NULLIF(person.name, '') is null asc,
@@ -52,16 +86,16 @@ order by
NULLIF(person.name, '') asc nulls last,
"person"."createdAt"
limit
$5
$12
offset
$6
$13
-- PersonRepository.getAllWithoutFaces
select
"person".*
from
"person"
left join "asset_face" on "asset_face"."personId" = "person"."id"
left join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
@@ -72,20 +106,7 @@ having
-- PersonRepository.getFaces
select
"asset_face".*,
(
select
to_json(obj)
from
(
select
"person".*
from
"person"
where
"person"."id" = "asset_face"."personId"
) as obj
) as "person"
"asset_face".*
from
"asset_face"
where
@@ -108,19 +129,30 @@ select
from
"person"
where
"person"."id" = "asset_face"."personId"
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."id" = $1
"asset_face"."id" = $2
and "asset_face"."deletedAt" is null
-- PersonRepository.getFaceForFacialRecognitionJob
select
"asset_face"."id",
"asset_face"."personId",
"asset_face"."faceClusterId",
"asset_face"."sourceType",
(
select
@@ -180,7 +212,7 @@ select
) as "previewPath"
from
"person"
inner join "asset_face" on "asset_face"."id" = "person"."faceAssetId"
inner join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
where
@@ -190,7 +222,7 @@ where
-- PersonRepository.reassignFace
update "asset_face"
set
"personId" = $1
"faceClusterId" = $1
where
"asset_face"."id" = $2
@@ -209,9 +241,10 @@ where
"person"."ownerId" = $1
and f_unaccent ("person"."name") %> f_unaccent ($2)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3)
f_unaccent ("person"."name") <->>> f_unaccent ($3),
"person"."ownerId" = $4 desc
limit
$4
$5
-- PersonRepository.getDistinctNames
select distinct
@@ -234,9 +267,45 @@ from
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
"asset_face"."deletedAt" is null
(
"asset"."ownerId" = $1
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $5
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."personId" = $1
and "asset_face"."faceClusterId" = $8
-- PersonRepository.getNumberOfPeople
select
@@ -256,7 +325,7 @@ where
from
"asset_face"
where
"asset_face"."personId" = "person"."id"
"asset_face"."faceClusterId" = "person"."faceClusterId"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and exists (
@@ -269,7 +338,42 @@ where
and "asset"."deletedAt" is null
)
)
and "person"."ownerId" = $3
and (
"person"."ownerId" = $3
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $4
and (
$5 = any ("partner"."permissions")
or "partner"."permissions" @> $6
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $7
)
and "album_user"."userId" = "person"."ownerId"
and (
$8 = any ("album_user"."permissions")
or "album_user"."permissions" @> $9
)
)
)
)
-- PersonRepository.refreshFaces
with
@@ -299,14 +403,26 @@ select
from
"person"
where
"person"."id" = "asset_face"."personId"
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
) as obj
) as "person"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
where
"asset_face"."assetId" in ($1)
and "asset_face"."personId" in ($2)
"person"."id" in ($2)
and "asset_face"."assetId" in ($3)
and "asset_face"."deletedAt" is null
-- PersonRepository.getRandomFace
@@ -315,7 +431,7 @@ select
from
"asset_face"
where
"asset_face"."personId" = $1
"asset_face"."faceClusterId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
@@ -351,8 +467,9 @@ select
"asset_face"."id"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
and "person"."id" = $1
inner join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."isOffline" = $1
and "asset"."isOffline" = $2
where
"asset_face"."assetId" = $2
and "asset_face"."personId" = $3
"asset_face"."assetId" = $3
+222 -22
View File
@@ -10,15 +10,52 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" desc
limit
$6
$14
offset
$7
$15
-- SearchRepository.searchStatistics
select
@@ -30,8 +67,45 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
@@ -44,13 +118,50 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null
order by
random()
limit
$6
$14
-- SearchRepository.searchLargeAssets
select
@@ -63,14 +174,51 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $6
and "asset_exif"."fileSizeInByte" > $14
order by
"asset_exif"."fileSizeInByte" desc
limit
$7
$15
-- SearchRepository.searchSmart
begin
@@ -86,15 +234,52 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."deletedAt" is null
order by
smart_search.embedding <=> $6
smart_search.embedding <=> $14
limit
$7
$15
offset
$8
$16
commit
-- SearchRepository.getEmbedding
@@ -113,15 +298,30 @@ with
"cte" as (
select
"asset_face"."id",
"asset_face"."personId",
face_search.embedding <=> $1 as "distance"
"asset_face"."faceClusterId",
face_search.embedding <=> $1 as "distance",
"asset"."ownerId"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
left join "person" on "person"."id" = "asset_face"."personId"
left join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
where
"asset"."ownerId" = any ($2::uuid[])
"asset"."ownerId" in (
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" in (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = any ($2::uuid[])
)
)
and "asset"."deletedAt" is null
order by
"distance"
+1 -1
View File
@@ -527,7 +527,7 @@ order by
select
"asset_face"."id",
"assetId",
"personId",
"faceClusterId",
"imageWidth",
"imageHeight",
"boundingBoxX1",
+70
View File
@@ -397,3 +397,73 @@ set
where
"user"."deletedAt" is null
and "user"."id" = $2::uuid
-- UserRepository.getInSameTrustedGroup
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
-- UserRepository.mergeTrustedGroups
update "user"
set
"trustedGroupId" = "u"."trustedGroupId"
from
"user" as "u"
where
"u"."id" = $1
and "user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $2
and "user"."trustedGroupId" != "u"."trustedGroupId"
)
-- UserRepository.updateTrustedGroups
update "user"
set
"trustedGroupId" = uuid_generate_v4 ()
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
and "user"."id" != $2
and "user"."id" not in (
select
"partner"."sharedById" as "userId"
from
"partner"
where
"sharedWithId" = $3
union
select
"album_user"."userId"
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $4
)
)
+74 -1
View File
@@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { AlbumUserRole, AssetVisibility, SharingPermission } from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { hasPermissions } from 'src/repositories/person.repository';
import { DB } from 'src/schema';
import { asUuid } from 'src/utils/database';
@@ -273,6 +275,46 @@ class AssetAccess {
return allowedIds;
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET, [SharingPermission.All]] })
async checkSharedAccess(userId: string, assetIds: Set<string>, permissions: SharingPermission[]) {
const ids = await this.db
.selectFrom('album_asset')
.select('album_asset.assetId')
.where('album_asset.assetId', 'in', [...assetIds])
.where('album_asset.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where((eb) =>
eb.or([
eb('album_user.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
]),
),
)
.innerJoin('album_user', (join) =>
join.onRef('album_asset.albumId', '=', 'album_user.albumId').on('album_user.userId', '=', userId),
)
.union((eb) =>
eb
.selectFrom('partner')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb('partner.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
]),
)
.innerJoin('asset', (join) =>
join.onRef('asset.ownerId', '=', 'partner.sharedById').on('asset.id', 'in', [...assetIds]),
)
.select('asset.id as assetId'),
)
.execute();
return new Set(ids.map(({ assetId }) => assetId));
}
}
class AuthDeviceAccess {
@@ -452,6 +494,37 @@ class PersonAccess {
.execute()
.then((faces) => new Set(faces.map((face) => face.id)));
}
async checkSharedAccess(userId: string, personIds: Set<string>, permissions: SharingPermission[]) {
if (personIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('person')
.select('person.id')
.where('person.id', 'in', [...personIds])
.where(hasPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
async checkSharedFaceAccess(userId: string, faceIds: Set<string>, permissions: SharingPermission[]) {
if (faceIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('asset_face')
.select('asset_face.id')
.leftJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId'))
.where('asset_face.id', 'in', [...faceIds])
.where(hasAssetPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
}
class PartnerAccess {
@@ -38,4 +38,13 @@ export class AlbumUserRepository {
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
}
get({ userId, albumId }: AlbumPermissionId) {
return this.db
.selectFrom('album_user')
.select(['permissions', 'inTimeline'])
.where('userId', '=', userId)
.where('albumId', '=', albumId)
.executeTakeFirstOrThrow();
}
}
+65 -5
View File
@@ -17,7 +17,15 @@ import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import {
AssetFileType,
AssetOrder,
AssetOrderBy,
AssetStatus,
AssetType,
AssetVisibility,
SharingPermission,
} from 'src/enum';
import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -41,6 +49,7 @@ import {
withFiles,
withLibrary,
withOwner,
withPermissions,
withSmartSearch,
withTagId,
withTags,
@@ -165,6 +174,47 @@ const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T
);
};
export const hasAssetPermissions =
(userId: string, permissions: SharingPermission[], ignoreTimelineVisibility: boolean = false) =>
(eb: ExpressionBuilder<DB, 'asset'>) =>
eb.or([
eb('asset.ownerId', '=', userId),
eb.exists(
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)),
),
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album_asset.albumId').on('album_user.userId', '=', userId),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true))
.where('album_user.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
),
]);
@Injectable()
export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -556,17 +606,22 @@ export class AssetRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
@GenerateSql({ params: [DummyValue.UUID, {}, DummyValue.UUID] })
getById(
id: string,
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {},
userId?: string,
) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.where('asset.id', '=', asUuid(id))
.$if(!!exifInfo, withExif)
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>())
.$if(!!faces, (qb) =>
qb
.select(faces?.person ? (eb) => withFacesAndPeople(eb, { userId }) : withFaces)
.$narrowType<{ faces: NotNull }>(),
)
.$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner))
.$if(!!smartSearch, withSmartSearch)
@@ -602,6 +657,7 @@ export class AssetRepository {
.$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags))
.$if(!!edits, (qb) => qb.select(withEdits))
.$if(!!userId, (qb) => qb.select(withPermissions(userId!)))
.limit(1)
.executeTakeFirst();
}
@@ -744,7 +800,9 @@ export class AssetRepository {
)
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
)
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(!!options.userIds, (qb) =>
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) =>
@@ -829,7 +887,9 @@ export class AssetRepository {
),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(!!options.userIds, (qb) =>
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) =>
qb
+10 -7
View File
@@ -15,7 +15,7 @@ import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/mis
type JobMapItem = {
jobName: JobName;
queueName: QueueName;
handler: (job: JobOf<any>) => Promise<JobStatus>;
handler: (job?: JobOf<any>) => Promise<JobStatus>;
label: string;
};
@@ -95,14 +95,17 @@ export class JobRepository {
}
}
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
async run(job: JobItem) {
const item = this.handlers[job.name];
if (!item) {
this.logger.warn(`Skipping unknown job: "${name}"`);
this.logger.warn(`Skipping unknown job: "${job.name}"`);
return JobStatus.Skipped;
}
return item.handler(data);
if ('data' in job) {
return item.handler(job.data);
}
return item.handler();
}
setConcurrency(queueName: QueueName, concurrency: number) {
@@ -167,13 +170,13 @@ export class JobRepository {
const queueName = this.getQueueName(item.name);
const job = {
name: item.name,
data: item.data || {},
data: ('data' in item ? item.data : undefined) || {},
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
if (job.options?.jobId) {
// need to use add() instead of addBulk() for jobId deduplication
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
promises.push(this.getQueue(queueName).add(item.name, job.data, job.options));
} else {
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
itemsByQueue[queueName].push(job);
+1 -1
View File
@@ -73,7 +73,7 @@ export class MemoryRepository implements IBulkAsset {
eb.exists(
eb
.selectFrom('asset_face')
.innerJoin('person', 'person.id', 'asset_face.personId')
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.select((eb) => eb.val(1).as('one'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('person.isHidden', '=', true),
+93 -28
View File
@@ -4,7 +4,8 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { AssetFileType, AssetVisibility, SharingPermission, SourceType } from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
@@ -33,9 +34,9 @@ export interface AssetFaceId {
}
export interface UpdateFacesData {
oldPersonId?: string;
oldFaceClusterId?: string;
faceIds?: string[];
newPersonId: string;
newFaceClusterId: string;
}
export interface PersonStatistics {
@@ -54,7 +55,7 @@ export interface GetAllPeopleOptions {
}
export interface GetAllFacesOptions {
personId?: string | null;
faceClusterId?: string | null;
assetId?: string;
sourceType?: SourceType;
}
@@ -65,7 +66,20 @@ export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId')
.orderBy(
(eb) =>
eb(
'person.ownerId',
'=',
eb.selectFrom('asset').select('asset.ownerId').whereRef('asset.id', '=', 'asset_face.assetId'),
),
'desc',
)
.limit(1),
).as('person');
};
@@ -75,16 +89,47 @@ const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
).as('faceSearch');
};
export const hasPermissions =
(userId: string, permissions: SharingPermission[]) => (eb: ExpressionBuilder<DB, 'person'>) =>
eb.or([
eb.exists((eb) =>
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'person.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
),
),
eb.exists((eb) =>
eb
.selectFrom('album_user')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.whereRef('album_user.userId', '=', 'person.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
]);
@Injectable()
export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ personId: newPersonId })
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
.set({ faceClusterId: newFaceClusterId })
.$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.executeTakeFirst();
@@ -94,7 +139,7 @@ export class PersonRepository {
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db
.updateTable('asset_face')
.set({ personId: null })
.set({ faceClusterId: null })
.where('asset_face.sourceType', '=', sourceType)
.execute();
}
@@ -117,8 +162,8 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null))
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!))
.$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null))
.$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.where('asset_face.deletedAt', 'is', null)
@@ -153,16 +198,20 @@ export class PersonRepository {
const items = await this.db
.selectFrom('person')
.selectAll('person')
.innerJoin('asset_face', 'asset_face.personId', 'person.id')
.innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.innerJoin('asset', (join) =>
join
.onRef('asset_face.assetId', '=', 'asset.id')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where('person.ownerId', '=', userId)
.where((eb) =>
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.orderBy('person.faceClusterId')
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
@@ -171,6 +220,7 @@ export class PersonRepository {
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1),
]),
)
.distinctOn('person.faceClusterId')
.groupBy('person.id')
.$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) =>
@@ -209,7 +259,7 @@ export class PersonRepository {
return this.db
.selectFrom('person')
.selectAll('person')
.leftJoin('asset_face', 'asset_face.personId', 'person.id')
.leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
@@ -224,7 +274,6 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson)
.where('asset_face.assetId', '=', assetId)
.where('asset_face.deletedAt', 'is', null)
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
@@ -248,7 +297,7 @@ export class PersonRepository {
getFaceForFacialRecognitionJob(id: string) {
return this.db
.selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType'])
.select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType'])
.select((eb) =>
jsonObjectFrom(
eb
@@ -267,7 +316,7 @@ export class PersonRepository {
getDataForThumbnailGenerationJob(id: string) {
return this.db
.selectFrom('person')
.innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId')
.innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.innerJoin('asset', 'asset_face.assetId', 'asset.id')
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.select([
@@ -289,10 +338,10 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ personId: newPersonId })
.set({ faceClusterId: newFaceClusterId })
.where('asset_face.id', '=', assetFaceId)
.executeTakeFirst();
@@ -318,6 +367,7 @@ export class PersonRepository {
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute();
@@ -335,7 +385,7 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> {
async getStatistics(userId: string, faceClusterId: string): Promise<PersonStatistics> {
const result = await this.db
.selectFrom('asset_face')
.leftJoin('asset', (join) =>
@@ -344,10 +394,11 @@ export class PersonRepository {
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where(hasAssetPermissions(userId, [SharingPermission.AssetRead], true))
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.where('asset_face.personId', '=', personId)
.where('asset_face.faceClusterId', '=', faceClusterId)
.executeTakeFirst();
return {
@@ -364,7 +415,7 @@ export class PersonRepository {
eb.exists((eb) =>
eb
.selectFrom('asset_face')
.whereRef('asset_face.personId', '=', 'person.id')
.whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.where((eb) =>
@@ -378,13 +429,20 @@ export class PersonRepository {
),
),
)
.where('person.ownerId', '=', userId)
.where((eb) =>
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden'))
.executeTakeFirstOrThrow();
}
create(person: Insertable<PersonTable>) {
async create(person: Insertable<PersonTable>) {
if (!person.faceClusterId) {
const { id } = await this.db.insertInto('face_cluster').defaultValues().returning('id').executeTakeFirstOrThrow();
person.faceClusterId = id;
}
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
}
@@ -475,18 +533,19 @@ export class PersonRepository {
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson)
.innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId'))
.where('person.id', 'in', personIds)
.where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)
.where('asset_face.deletedAt', 'is', null)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getRandomFace(personId: string) {
getRandomFace(faceClusterId: string) {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.where('asset_face.personId', '=', personId)
.where('asset_face.faceClusterId', '=', faceClusterId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.executeTakeFirst();
@@ -573,8 +632,14 @@ export class PersonRepository {
.selectFrom('asset_face')
.select('asset_face.id')
.where('asset_face.assetId', '=', assetId)
.where('asset_face.personId', '=', personId)
.innerJoin('person', (join) =>
join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId),
)
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
.executeTakeFirst();
}
getByFaceClusterId(faceClusterId: string) {
return this.db.selectFrom('person').selectAll().where('person.faceClusterId', '=', faceClusterId).execute();
}
}
+12 -4
View File
@@ -325,15 +325,23 @@ export class SearchRepository {
.selectFrom('asset_face')
.select([
'asset_face.id',
'asset_face.personId',
'asset_face.faceClusterId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
])
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.select('asset.ownerId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.leftJoin('person', 'person.id', 'asset_face.personId')
.where('asset.ownerId', '=', anyUuid(userIds))
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('asset.ownerId', 'in', (eb) =>
eb
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', 'in', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', anyUuid(userIds)),
),
)
.where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
.$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null))
.$if(!!minBirthDate, (qb) =>
qb.where((eb) =>
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
+1 -1
View File
@@ -443,7 +443,7 @@ class AssetFaceSync extends BaseSync {
.select([
'asset_face.id',
'assetId',
'personId',
'faceClusterId',
'imageWidth',
'imageHeight',
'boundingBoxX1',
@@ -325,4 +325,61 @@ export class UserRepository {
await query.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getInSameTrustedGroup(userId: string) {
return this.db
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.execute()
.then((result) => result.map(({ id }) => id));
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, userIdToMerge: DummyValue.UUID }] })
async mergeTrustedGroups({ userId, userIdToMerge }: { userId: string; userIdToMerge: string }) {
return this.db
.updateTable('user')
.from('user as u')
.where('u.id', '=', userId)
.where('user.trustedGroupId', '=', (eb) =>
eb
.selectFrom('user')
.select('user.trustedGroupId')
.where('user.id', '=', userIdToMerge)
.whereRef('user.trustedGroupId', '!=', 'u.trustedGroupId'),
)
.set((eb) => ({
trustedGroupId: eb.ref('u.trustedGroupId'),
}))
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async updateTrustedGroups(userId: string) {
return this.db
.updateTable('user')
.set((eb) => ({ trustedGroupId: eb.fn('uuid_generate_v4') }))
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.where('user.id', '!=', userId)
.where('user.id', 'not in', (eb) =>
eb
.selectFrom('partner')
.select('partner.sharedById as userId')
.where('sharedWithId', '=', userId)
.union((eb) =>
eb
.selectFrom('album_user')
.select('album_user.userId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
),
),
)
.executeTakeFirst();
}
}
+6
View File
@@ -4,6 +4,7 @@ import {
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SharingPermission,
SourceType,
VideoSegmentCodec,
} from 'src/enum';
@@ -37,3 +38,8 @@ export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: Object.values(VideoSegmentCodec),
});
export const sharing_permission_enum = registerEnum({
name: 'sharing_permission_enum',
values: Object.values(SharingPermission),
});
+11 -1
View File
@@ -4,6 +4,7 @@ import {
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
sharing_permission_enum,
} from 'src/schema/enums';
import {
album_user_after_insert,
@@ -45,6 +46,7 @@ import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
@@ -108,6 +110,7 @@ export class ImmichDatabase {
AssetTable,
AssetFileTable,
AssetExifTable,
FaceClusterTable,
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
@@ -170,7 +173,13 @@ export class ImmichDatabase {
asset_face_audit,
];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
enum = [
album_user_role_enum,
assets_status_enum,
asset_face_source_type,
asset_visibility_enum,
sharing_permission_enum,
];
}
export interface Migrations {
@@ -211,6 +220,7 @@ export interface DB {
ocr_search: OcrSearchTable;
face_search: FaceSearchTable;
face_cluster: FaceClusterTable;
geodata_places: GeodataPlacesTable;
@@ -0,0 +1,17 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "sharing_permission_enum" AS ENUM ('all','asset.read','asset.update','asset.edit','asset.delete','asset.share','exif.read','person.read','person.update','person.merge','person.delete');`.execute(db);
await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
await sql`ALTER TABLE "album_user" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{asset.read,exif.read}';`.execute(db);
await sql`ALTER TABLE "album_user" ADD "inTimeline" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`ALTER TABLE "partner" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{all}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TYPE "sharing_permission_enum";`.execute(db);
await sql`ALTER TABLE "partner" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "inTimeline";`.execute(db);
}
@@ -0,0 +1,51 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "faceClusterId";`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_idx" ON "asset_face" ("faceClusterId", "assetId");`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("faceClusterId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_faceClusterId_idx" ON "asset_face" ("assetId", "faceClusterId");`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db);
await sql`CREATE TABLE "face_cluster" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "face_cluster_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db);
await sql`ALTER TABLE "person" ADD "faceClusterId" uuid;`.execute(db);
await sql`CREATE INDEX "person_faceClusterId_idx" ON "person" ("faceClusterId");`.execute(db);
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "face_cluster_updateId_idx" ON "face_cluster" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt"
BEFORE UPDATE ON "face_cluster"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_face_cluster_updatedAt', '{"type":"trigger","name":"face_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"face_cluster\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"faceClusterId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "person" DROP COLUMN "faceClusterId";`.execute(db);
await sql`DROP INDEX "person_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceClusterId_fkey";`.execute(db);
await sql`ALTER TABLE "asset_face" RENAME COLUMN "faceClusterId" TO "personId";`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ((("deletedAt" IS NULL) AND ("isVisible" IS TRUE)));`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_faceClusterId_fkey";`.execute(db);
await sql`DROP TABLE "face_cluster";`.execute(db);
await sql`DROP TRIGGER "face_cluster_updatedAt" ON "face_cluster";`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
+12 -2
View File
@@ -11,8 +11,8 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
import { AlbumUserRole, SharingPermission } from 'src/enum';
import { album_user_role_enum, sharing_permission_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -69,4 +69,14 @@ export class AlbumUserTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({
array: true,
enum: sharing_permission_enum,
default: [SharingPermission.AssetRead, SharingPermission.ExifRead],
})
permissions!: Generated<SharingPermission[]>;
@Column({ type: 'boolean', default: false })
inTimeline!: Generated<boolean>;
}
+8 -8
View File
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
@Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt')
@@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table';
when: 'pg_trigger_depth() = 0',
})
// schemaFromDatabase does not preserve column order
@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] })
@Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] })
@Index({
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
columns: ['personId', 'assetId'],
name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx',
columns: ['faceClusterId', 'assetId'],
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
})
@Index({ columns: ['personId', 'assetId'] })
@Index({ columns: ['faceClusterId', 'assetId'] })
export class AssetFaceTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -45,14 +45,14 @@ export class AssetFaceTable {
})
assetId!: string;
@ForeignKeyColumn(() => PersonTable, {
@ForeignKeyColumn(() => FaceClusterTable, {
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
nullable: true,
// [personId, assetId] makes this redundant
// [faceClusterId, assetId] makes this redundant
index: false,
})
personId!: string | null;
faceClusterId!: string | null;
@Column({ default: 0, type: 'integer' })
imageWidth!: Generated<number>;
@@ -0,0 +1,25 @@
import {
CreateDateColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
@Table('face_cluster')
@UpdatedAtTrigger('face_cluster_updatedAt')
export class FaceClusterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
@@ -9,6 +9,8 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SharingPermission } from 'src/enum';
import { sharing_permission_enum } from 'src/schema/enums';
import { partner_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
@@ -46,4 +48,7 @@ export class PartnerTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ array: true, enum: sharing_permission_enum, default: [SharingPermission.All] })
permissions!: Generated<SharingPermission[]>;
}
+7 -3
View File
@@ -14,6 +14,7 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@@ -43,9 +44,6 @@ export class PersonTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: Generated<string>;
@Column({ default: '' })
thumbnailPath!: Generated<string>;
@@ -55,6 +53,9 @@ export class PersonTable {
@Column({ type: 'date', nullable: true })
birthDate!: Timestamp | null;
@Column({ default: '' })
name!: Generated<string>;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@@ -66,4 +67,7 @@ export class PersonTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
faceClusterId!: string | null;
}
+4
View File
@@ -4,6 +4,7 @@ import {
CreateDateColumn,
DeleteDateColumn,
Generated,
GeneratedColumn,
Index,
PrimaryGeneratedColumn,
Table,
@@ -82,4 +83,7 @@ export class UserTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@GeneratedColumn('uuid')
trustedGroupId!: Generated<string>;
}
+32 -2
View File
@@ -8,13 +8,15 @@ import {
CreateAlbumDto,
GetAlbumsDto,
mapAlbum,
SharingPermissionsResponseDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
UpdateSharingPermissionsDto,
} from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -137,6 +139,11 @@ export class AlbumService extends BaseService {
);
for (const { userId } of albumUsers) {
await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: userId,
});
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
@@ -306,7 +313,17 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid user');
}
await this.albumUserRepository.create({ userId, albumId: id, role });
await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: userId,
});
await this.albumUserRepository.create({
userId,
albumId: id,
role,
permissions: [SharingPermission.AssetRead, SharingPermission.ExifRead],
});
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
@@ -345,6 +362,19 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
async updateSelf(auth: AuthDto, albumId: string, dto: UpdateSharingPermissionsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
await this.albumUserRepository.update(
{ albumId, userId: auth.user.id },
{ permissions: dto.permissions, inTimeline: dto.inTimeline },
);
}
async getSelf(auth: AuthDto, albumId: string): Promise<SharingPermissionsResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
return this.albumUserRepository.get({ userId: auth.user.id, albumId });
}
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options, authUserId);
if (!album) {
+15 -10
View File
@@ -32,10 +32,11 @@ import {
JobStatus,
Permission,
QueueName,
SharingPermission,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
import { hasPermissions, requireElevatedPermission } from 'src/utils/access';
import {
getAssetFiles,
getDimensions,
@@ -62,14 +63,18 @@ export class AssetService extends BaseService {
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const asset = await this.assetRepository.getById(id, {
exifInfo: true,
owner: true,
faces: { person: true },
stack: { assets: true },
edits: true,
tags: true,
});
const asset = await this.assetRepository.getById(
id,
{
exifInfo: true,
owner: true,
faces: { person: true },
stack: { assets: true },
edits: true,
tags: true,
},
auth.user.id,
);
if (!asset) {
throw new BadRequestException('Asset not found');
@@ -85,7 +90,7 @@ export class AssetService extends BaseService {
delete data.owner;
}
if (data.ownerId !== auth.user.id || auth.sharedLink) {
if (!hasPermissions(data, SharingPermission.PersonRead)) {
data.people = [];
}
+5 -1
View File
@@ -85,7 +85,11 @@ export class NotificationService extends BaseService {
return;
}
this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data));
this.logger.error(
`Unable to run job handler (${job.name}): ${error}`,
error?.stack,
'data' in job ? JSON.stringify(job.data) : {},
);
switch (job.name) {
case JobName.DatabaseBackup: {
+14 -2
View File
@@ -3,7 +3,7 @@ import { Partner } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto';
import { Permission } from 'src/enum';
import { JobName, Permission, SharingPermission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository';
import { BaseService } from 'src/services/base.service';
@@ -16,7 +16,15 @@ export class PartnerService extends BaseService {
throw new BadRequestException(`Partner already exists`);
}
const partner = await this.partnerRepository.create(partnerId);
const { numUpdatedRows } = await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: sharedWithId,
});
const partner = await this.partnerRepository.create({ ...partnerId, permissions: [SharingPermission.All] });
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionMerge, data: { id: sharedWithId } });
}
return this.mapPartner(partner, PartnerDirection.SharedBy);
}
@@ -28,6 +36,10 @@ export class PartnerService extends BaseService {
}
await this.partnerRepository.remove(partnerId);
const { numUpdatedRows } = await this.userRepository.updateTrustedGroups(auth.user.id);
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force: true } });
}
}
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
+101 -16
View File
@@ -13,7 +13,7 @@ import {
FaceDto,
mapFaces,
mapPerson,
MergePersonDto,
MergeFaceClusterDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
@@ -159,7 +159,7 @@ export class PersonService extends BaseService {
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return this.personRepository.getStatistics(id);
return this.personRepository.getStatistics(auth.user.id, id);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
@@ -438,7 +438,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString();
const facePagination = this.personRepository.getAllFaces(
force ? undefined : { personId: null, sourceType: SourceType.MachineLearning },
force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning },
);
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
@@ -481,8 +481,8 @@ export class PersonService extends BaseService {
return JobStatus.Failed;
}
if (face.personId) {
this.logger.debug(`Face ${id} already has a person assigned`);
if (face.faceClusterId) {
this.logger.debug(`Face ${id} already belongs to a face cluster`);
return JobStatus.Skipped;
}
@@ -511,8 +511,8 @@ export class PersonService extends BaseService {
return JobStatus.Skipped;
}
let personId = matches.find((match) => match.personId)?.personId;
if (!personId) {
let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId;
if (!faceClusterId) {
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
@@ -523,20 +523,100 @@ export class PersonService extends BaseService {
});
if (matchWithPerson.length > 0) {
personId = matchWithPerson[0].personId;
faceClusterId = matchWithPerson[0].faceClusterId;
}
}
if (isCore && !personId) {
if (isCore && !faceClusterId) {
this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
personId = newPerson.id;
faceClusterId = newPerson.faceClusterId;
}
if (personId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
if (faceClusterId) {
this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
}
return JobStatus.Success;
}
@OnJob({ name: JobName.FacialRecognitionMerge, queue: QueueName.FacialRecognition })
async mergeClusters({ id: userId }: JobOf<JobName.FacialRecognitionMerge>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.Skipped;
}
const faces = this.personRepository.getAllFaces({ sourceType: SourceType.MachineLearning });
for await (const { id } of faces) {
const face = await this.personRepository.getFaceForFacialRecognitionJob(id);
if (!face?.faceSearch || !face.asset) {
this.logger.warn(`Face ${id} does not have an embedding`);
return JobStatus.Failed;
}
let faceClusterId: string | null = null;
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 10,
hasPerson: true,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
if (matchWithPerson.length > 0) {
// favor a person that's not owned by us to merge people with a newly shared with user
// probably do smarter stuff here like pick the person with a name, if both have a name set aliases or whatever
const match = matchWithPerson.find((match) => match.ownerId !== userId) ?? matchWithPerson[0];
if (match.faceClusterId && face.asset.ownerId !== match.ownerId) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const person = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } });
}
}
faceClusterId = match.faceClusterId;
}
if (!faceClusterId) {
const matches = await this.searchRepository.searchFaces({
userIds: [userId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
const match = matches.find((match) => match.faceClusterId);
if (match && match.faceClusterId && face.asset.ownerId !== match.ownerId) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const person = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } });
}
}
faceClusterId = match?.faceClusterId ?? null;
}
if (faceClusterId) {
this.logger.log(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
}
}
return JobStatus.Success;
@@ -554,7 +634,7 @@ export class PersonService extends BaseService {
return JobStatus.Success;
}
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids;
if (mergeIds.includes(id)) {
throw new BadRequestException('Cannot merge a person into themselves');
@@ -600,7 +680,7 @@ export class PersonService extends BaseService {
}
const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
@@ -613,6 +693,7 @@ export class PersonService extends BaseService {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
@@ -682,8 +763,12 @@ export class PersonService extends BaseService {
dto.imageHeight = originalDimensions.height;
}
if (!person?.faceClusterId) {
throw new Error('Person must already have some recognized faces and belong to a face cluster');
}
await this.personRepository.createAssetFace({
personId: dto.personId,
faceClusterId: person.faceClusterId,
assetId: dto.assetId,
imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth,
+1
View File
@@ -208,6 +208,7 @@ export class SearchService extends BaseService {
repository: this.partnerRepository,
timelineEnabled: true,
});
console.log(auth.user.id, partnerIds);
return [auth.user.id, ...partnerIds];
}
+4 -1
View File
@@ -202,7 +202,9 @@ export type ConcurrentQueueName = Exclude<
| QueueName.BackupDatabase
>;
export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] };
export type Jobs = {
[K in JobItem['name']]: 'data' extends keyof (JobItem & { name: K }) ? (JobItem & { name: K })['data'] : never;
};
export type JobOf<T extends JobName> = Jobs[T];
export interface IBaseJob {
@@ -360,6 +362,7 @@ export type JobItem =
| { name: JobName.AssetDetectFaces; data: IEntityJob }
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
| { name: JobName.FacialRecognition; data: IDeferrableJob }
| { name: JobName.FacialRecognitionMerge; data: IEntityJob }
| { name: JobName.PersonGenerateThumbnail; data: IEntityJob }
// Smart Search
+82 -22
View File
@@ -1,7 +1,7 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthSharedLink } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set';
@@ -115,37 +115,41 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
case Permission.AssetRead: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]);
return setUnion(isOwner, isShared);
}
case Permission.AssetShare: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetShare]);
return setUnion(isOwner, isShared);
}
case Permission.AssetView: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]);
return setUnion(isOwner, isShared);
}
case Permission.AssetDownload: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [
SharingPermission.AssetRead,
SharingPermission.ExifRead,
]);
return setUnion(isOwner, isShared);
}
case Permission.AssetUpdate: {
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetUpdate]);
return setUnion(isOwner, isShared);
}
case Permission.AssetDelete: {
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetDelete]);
return setUnion(isOwner, isShared);
}
case Permission.AssetCopy: {
@@ -153,15 +157,21 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
case Permission.AssetEditGet: {
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
}
case Permission.AssetEditCreate: {
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
}
case Permission.AssetEditDelete: {
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
}
case Permission.AlbumRead: {
@@ -246,7 +256,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
case Permission.FaceDelete: {
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
const isOwner = await access.person.checkFaceOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedFaceAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.AssetUpdate,
]);
return setUnion(isOwner, isShared);
}
case Permission.NotificationRead:
@@ -288,11 +302,40 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PersonRead:
case Permission.PersonUpdate:
case Permission.PersonDelete:
case Permission.PersonRead: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonRead,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonMerge: {
return await access.person.checkOwnerAccess(auth.user.id, ids);
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonMerge,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonUpdate: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonUpdate,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonDelete: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonDelete,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonReassign: {
@@ -339,3 +382,20 @@ export const requireElevatedPermission = (auth: AuthDto) => {
throw new UnauthorizedException('Elevated permission is required');
}
};
export const hasPermissions = (
assetLike: { permissions: SharingPermission[] },
...permissions: SharingPermission[]
) => {
if (assetLike.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!assetLike.permissions.includes(permission)) {
return false;
}
}
return true;
};
+6 -1
View File
@@ -4,7 +4,7 @@ import { AssetFile } 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 { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
import { AssetFileType, AssetType, AssetVisibility, Permission, SharingPermission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
@@ -134,6 +134,11 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
continue;
}
const permissions = [SharingPermission.All, SharingPermission.AssetRead];
if (!permissions.some((permission) => partner.permissions.includes(permission))) {
continue;
}
partnerIds.add(partner.sharedById);
}
+48 -12
View File
@@ -15,9 +15,17 @@ import {
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { columns, lockableProperties, LockableProperty } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
import {
AssetFileType,
AssetOrderBy,
AssetVisibility,
DatabaseExtension,
ExifOrientation,
SharingPermission,
} from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -212,19 +220,22 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
export function withFacesAndPeople(
eb: ExpressionBuilder<DB, 'asset'>,
withHidden?: boolean,
withDeletedFace?: boolean,
{ withHidden, withDeletedFace, userId: _ }: { withHidden?: boolean; withDeletedFace?: boolean; userId?: string } = {},
) {
return jsonArrayFrom(
eb
.selectFrom('asset_face')
.leftJoinLateral(
(eb) =>
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
(join) => join.onTrue(),
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('face_cluster')
.whereRef('face_cluster.id', '=', 'asset_face.faceClusterId')
.innerJoin('person', 'person.faceClusterId', 'face_cluster.id')
.selectAll('person')
.limit(1),
).as('person'),
)
.selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
@@ -237,11 +248,12 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds:
eb
.selectFrom('asset_face')
.select('assetId')
.where('personId', '=', anyUuid(personIds!))
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('person.id', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null)
.where('isVisible', 'is', true)
.groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length)
.as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
);
@@ -302,6 +314,30 @@ export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
}
export function withPermissions(userId: string) {
return (eb: ExpressionBuilder<DB, 'asset'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.select((eb) => eb.fn<SharingPermission>('unnest', ['album_user.permissions']).as('permission'))
.distinct()
.innerJoin('album_asset', 'album_user.albumId', 'album_asset.albumId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.union(
eb
.selectFrom('partner')
.select((eb) => eb.fn<SharingPermission>('unnest', ['partner.permissions']).as('permission'))
.distinct()
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId),
),
).as('permissions');
}
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
return qb.where((eb) =>
eb.exists(
@@ -428,7 +464,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(!!options.userIds, (qb) => qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead])))
.$if(!!options.encodedVideoPath, (qb) =>
qb
.innerJoin('asset_file', (join) =>
+1
View File
@@ -38,6 +38,7 @@ const createAsset = (
fileSizeInByte !== null || Object.keys(exifFields).length > 0
? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields })
: undefined,
permissions: [],
});
describe('duplicate utils', () => {
+13 -4
View File
@@ -1,4 +1,3 @@
import { AssetFace } from 'src/database';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageDimensions } from 'src/types';
@@ -31,11 +30,21 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
};
};
export const checkFaceVisibility = (
faces: AssetFace[],
export const checkFaceVisibility = <
T extends {
isVisible: boolean;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
},
>(
faces: T[],
originalAssetDimensions: ImageDimensions,
crop?: BoundingBox,
): { visible: AssetFace[]; hidden: AssetFace[] } => {
): { visible: T[]; hidden: T[] } => {
if (!crop) {
return {
visible: faces.filter((face) => !face.isVisible),
@@ -28,6 +28,8 @@ export class AlbumUserFactory {
createdAt: newDate(),
updateId: newUuidV7(),
updatedAt: newDate(),
permissions: [],
inTimeline: false,
...dto,
});
}
+1
View File
@@ -26,6 +26,7 @@ export class PartnerFactory {
sharedWithId,
updatedAt: newDate(),
updateId: newUuidV7(),
permissions: [],
...dto,
})
.sharedBy({ id: sharedById })
+1
View File
@@ -35,6 +35,7 @@ export class UserFactory {
status: UserStatus.Active,
profileChangedAt: newDate(),
updateId: newUuidV7(),
trustedGroupId: newUuid(),
...dto,
});
}
@@ -21,6 +21,7 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()),
checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
},
album: {
@@ -48,6 +49,8 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
person: {
checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedFaceAccess: vitest.fn().mockResolvedValue(new Set()),
},
partner: {