merge main
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { setDifference, setUnion } from '../domain.util';
|
||||
import { IAccessRepository } from '../repositories';
|
||||
|
||||
export enum Permission {
|
||||
@@ -68,40 +69,66 @@ export class AccessCore {
|
||||
return authUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to all ids, for the given permission.
|
||||
* Throws error if user does not have access to any of the ids.
|
||||
*/
|
||||
async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
|
||||
const hasAccess = await this.hasPermission(authUser, permission, ids);
|
||||
if (!hasAccess) {
|
||||
ids = Array.isArray(ids) ? ids : [ids];
|
||||
const allowedIds = await this.checkAccess(authUser, permission, ids);
|
||||
if (new Set(ids).size !== allowedIds.size) {
|
||||
throw new BadRequestException(`Not found or no ${permission} access`);
|
||||
}
|
||||
}
|
||||
|
||||
async hasAny(authUser: AuthUserDto, permissions: Array<{ permission: Permission; id: string }>) {
|
||||
for (const { permission, id } of permissions) {
|
||||
const hasAccess = await this.hasPermission(authUser, permission, id);
|
||||
if (hasAccess) {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Return ids that user has access to, for the given permission.
|
||||
* Check is done for each id, and only allowed ids are returned.
|
||||
*
|
||||
* @returns Set<string>
|
||||
*/
|
||||
async checkAccess(authUser: AuthUserDto, permission: Permission, ids: Set<string> | string[]) {
|
||||
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
|
||||
if (idSet.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
|
||||
ids = Array.isArray(ids) ? ids : [ids];
|
||||
|
||||
const isSharedLink = authUser.isPublicUser ?? false;
|
||||
|
||||
for (const id of ids) {
|
||||
const hasAccess = isSharedLink
|
||||
? await this.hasSharedLinkAccess(authUser, permission, id)
|
||||
: await this.hasOtherAccess(authUser, permission, id);
|
||||
if (!hasAccess) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
return isSharedLink
|
||||
? await this.checkAccessSharedLink(authUser, permission, idSet)
|
||||
: await this.checkAccessOther(authUser, permission, idSet);
|
||||
}
|
||||
|
||||
private async checkAccessSharedLink(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
|
||||
const sharedLinkId = authUser.sharedLinkId;
|
||||
if (!sharedLinkId) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
switch (permission) {
|
||||
case Permission.ASSET_UPLOAD:
|
||||
return authUser.isAllowUpload ? ids : new Set();
|
||||
|
||||
case Permission.ALBUM_READ:
|
||||
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD:
|
||||
return !!authUser.isAllowDownload
|
||||
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
|
||||
: new Set();
|
||||
}
|
||||
|
||||
const allowedIds = new Set();
|
||||
for (const id of ids) {
|
||||
const hasAccess = await this.hasSharedLinkAccess(authUser, permission, id);
|
||||
if (hasAccess) {
|
||||
allowedIds.add(id);
|
||||
}
|
||||
}
|
||||
return allowedIds;
|
||||
}
|
||||
|
||||
// TODO: Migrate logic to checkAccessSharedLink to evaluate permissions in bulk.
|
||||
private async hasSharedLinkAccess(authUser: AuthUserDto, permission: Permission, id: string) {
|
||||
const sharedLinkId = authUser.sharedLinkId;
|
||||
if (!sharedLinkId) {
|
||||
@@ -118,24 +145,95 @@ export class AccessCore {
|
||||
case Permission.ASSET_DOWNLOAD:
|
||||
return !!authUser.isAllowDownload && (await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id));
|
||||
|
||||
case Permission.ASSET_UPLOAD:
|
||||
return authUser.isAllowUpload;
|
||||
|
||||
case Permission.ASSET_SHARE:
|
||||
// TODO: fix this to not use authUser.id for shared link access control
|
||||
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ALBUM_READ:
|
||||
return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD:
|
||||
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
|
||||
switch (permission) {
|
||||
case Permission.ALBUM_READ: {
|
||||
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_UPDATE:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_DELETE:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_SHARE:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD: {
|
||||
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_REMOVE_ASSET:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ASSET_UPLOAD:
|
||||
return this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ARCHIVE_READ:
|
||||
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
|
||||
|
||||
case Permission.AUTH_DEVICE_DELETE:
|
||||
return this.repository.authDevice.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.TIMELINE_READ: {
|
||||
const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set<string>();
|
||||
const isPartner = await this.repository.timeline.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isPartner);
|
||||
}
|
||||
|
||||
case Permission.TIMELINE_DOWNLOAD:
|
||||
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
|
||||
|
||||
case Permission.LIBRARY_READ: {
|
||||
const isOwner = await this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
const isPartner = await this.repository.library.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isPartner);
|
||||
}
|
||||
|
||||
case Permission.LIBRARY_UPDATE:
|
||||
return this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.LIBRARY_DELETE:
|
||||
return this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_READ:
|
||||
return this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_WRITE:
|
||||
return this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_MERGE:
|
||||
return this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PARTNER_UPDATE:
|
||||
return this.repository.partner.checkUpdateAccess(authUser.id, ids);
|
||||
}
|
||||
|
||||
const allowedIds = new Set();
|
||||
for (const id of ids) {
|
||||
const hasAccess = await this.hasOtherAccess(authUser, permission, id);
|
||||
if (hasAccess) {
|
||||
allowedIds.add(id);
|
||||
}
|
||||
}
|
||||
return allowedIds;
|
||||
}
|
||||
|
||||
// TODO: Migrate logic to checkAccessOther to evaluate permissions in bulk.
|
||||
private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) {
|
||||
switch (permission) {
|
||||
// uses album id
|
||||
@@ -184,69 +282,6 @@ export class AccessCore {
|
||||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ALBUM_READ:
|
||||
return (
|
||||
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ALBUM_UPDATE:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ALBUM_DELETE:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ALBUM_SHARE:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD:
|
||||
return (
|
||||
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ASSET_UPLOAD:
|
||||
return this.repository.library.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ALBUM_REMOVE_ASSET:
|
||||
return this.repository.album.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ARCHIVE_READ:
|
||||
return authUser.id === id;
|
||||
|
||||
case Permission.AUTH_DEVICE_DELETE:
|
||||
return this.repository.authDevice.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.TIMELINE_READ:
|
||||
return authUser.id === id || (await this.repository.timeline.hasPartnerAccess(authUser.id, id));
|
||||
|
||||
case Permission.TIMELINE_DOWNLOAD:
|
||||
return authUser.id === id;
|
||||
|
||||
case Permission.LIBRARY_READ:
|
||||
return (
|
||||
(await this.repository.library.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.library.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.LIBRARY_UPDATE:
|
||||
return this.repository.library.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.LIBRARY_DELETE:
|
||||
return this.repository.library.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.PERSON_READ:
|
||||
return this.repository.person.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.PERSON_WRITE:
|
||||
return this.repository.person.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.PERSON_MERGE:
|
||||
return this.repository.person.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.PARTNER_UPDATE:
|
||||
return this.repository.partner.hasUpdateAccess(authUser.id, id);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get all', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]);
|
||||
@@ -37,7 +37,7 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should filter by type=like', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
@@ -52,7 +52,7 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should filter by type=comment', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
@@ -70,7 +70,7 @@ describe(ActivityService.name, () => {
|
||||
describe('getStatistics', () => {
|
||||
it('should get the comment count', async () => {
|
||||
activityMock.getStatistics.mockResolvedValue(1);
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId]));
|
||||
await expect(
|
||||
sut.getStatistics(authStub.admin, {
|
||||
assetId: 'asset-id',
|
||||
@@ -82,7 +82,6 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('addComment', () => {
|
||||
it('should require access to the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.create(authStub.admin, {
|
||||
albumId: 'album-id',
|
||||
@@ -114,7 +113,7 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail because activity is disabled for the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(false);
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
@@ -148,7 +147,7 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if like exists', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
|
||||
activityMock.search.mockResolvedValue([activityStub.liked]);
|
||||
|
||||
|
||||
@@ -58,9 +58,9 @@ describe(AlbumService.name, () => {
|
||||
describe('getAll', () => {
|
||||
it('gets list of albums for auth user', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.empty.id, assetCount: 0 },
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0 },
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
@@ -72,7 +72,14 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('gets list of albums that have a specific asset', async () => {
|
||||
albumMock.getByAssetId.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
|
||||
@@ -83,7 +90,9 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('gets list of albums that are shared', async () => {
|
||||
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.sharedWithUser.id, assetCount: 0 }]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: true });
|
||||
@@ -94,7 +103,9 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('gets list of albums that are NOT shared', async () => {
|
||||
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.empty.id, assetCount: 0 }]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: false });
|
||||
@@ -106,7 +117,14 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('counts assets correctly', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAsset]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([{ albumId: albumStub.oneAsset.id, assetCount: 1 }]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
@@ -118,8 +136,13 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('updates the album thumbnail by listing all albums', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.oneAssetInvalidThumbnail.id, assetCount: 1 },
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAssetInvalidThumbnail.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
|
||||
@@ -134,8 +157,13 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('removes the thumbnail for an empty album', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
|
||||
albumMock.getAssetCountForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.emptyWithInvalidThumbnail.id, assetCount: 1 },
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.emptyWithInvalidThumbnail.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
|
||||
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
||||
@@ -204,7 +232,6 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should prevent updating a not owned album (shared with auth user)', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.update(authStub.admin, albumStub.sharedWithAdmin.id, {
|
||||
albumName: 'new album name',
|
||||
@@ -213,7 +240,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should require a valid thumbnail asset id', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.hasAsset.mockResolvedValue(false);
|
||||
@@ -229,7 +256,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow the owner to update the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-4']));
|
||||
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAsset);
|
||||
@@ -252,7 +279,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw an error for an album not found', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
@@ -263,7 +290,6 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should not let a shared user delete the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
|
||||
await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
|
||||
@@ -274,7 +300,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should let the owner delete an album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.empty.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.empty);
|
||||
|
||||
await sut.delete(authStub.admin, albumStub.empty.id);
|
||||
@@ -286,7 +312,6 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('addUsers', () => {
|
||||
it('should throw an error if the auth user is not the owner', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -294,7 +319,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the userId is already added', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
await expect(
|
||||
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
|
||||
@@ -303,7 +328,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the userId does not exist', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(
|
||||
@@ -313,7 +338,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should add valid shared users', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
|
||||
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
|
||||
userMock.get.mockResolvedValue(userStub.user2);
|
||||
@@ -328,14 +353,14 @@ describe(AlbumService.name, () => {
|
||||
|
||||
describe('removeUser', () => {
|
||||
it('should require a valid album id', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1']));
|
||||
albumMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove a shared user from an owned album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithUser.id]));
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithUser);
|
||||
|
||||
await expect(
|
||||
@@ -352,7 +377,6 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple);
|
||||
|
||||
await expect(
|
||||
@@ -360,7 +384,10 @@ describe(AlbumService.name, () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(albumMock.update).not.toHaveBeenCalled();
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, albumStub.sharedWithMultiple.id);
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.user1.id,
|
||||
new Set([albumStub.sharedWithMultiple.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow a shared user to remove themselves', async () => {
|
||||
@@ -413,51 +440,75 @@ describe(AlbumService.name, () => {
|
||||
describe('getAlbumInfo', () => {
|
||||
it('should get a shared album', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
await sut.get(authStub.admin, albumStub.oneAsset.id, {});
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id, { withAssets: true });
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.id,
|
||||
new Set([albumStub.oneAsset.id]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get a shared album via a shared link', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
await sut.get(authStub.adminSharedLink, 'album-123', {});
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
'album-123',
|
||||
new Set(['album-123']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get a shared album via shared with user', async () => {
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAsset.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
|
||||
await sut.get(authStub.user1, 'album-123', {});
|
||||
|
||||
expect(albumMock.getById).toHaveBeenCalledWith('album-123', { withAssets: true });
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123');
|
||||
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['album-123']));
|
||||
});
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.get(authStub.admin, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-123']));
|
||||
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-123']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
@@ -482,7 +533,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
@@ -500,8 +551,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow a shared user to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
@@ -526,9 +576,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow a shared link user to add assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
@@ -551,14 +599,14 @@ describe(AlbumService.name, () => {
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
});
|
||||
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
'album-123',
|
||||
new Set(['album-123']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow adding assets shared via partner sharing', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
@@ -577,7 +625,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip duplicate assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
@@ -590,7 +638,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip assets not shared with user', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
@@ -605,33 +653,31 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should not allow unauthorized access to the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.checkSharedAlbumAccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow unauthorized shared link access to the album', async () => {
|
||||
accessMock.album.hasSharedLinkAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalled();
|
||||
expect(accessMock.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should allow the owner to remove assets', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123']));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
|
||||
@@ -644,7 +690,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip assets not in the album', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
@@ -656,7 +702,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip assets without user permission to remove', async () => {
|
||||
accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
|
||||
@@ -672,7 +718,8 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('should reset the thumbnail if it is removed', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['album-123']));
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AccessCore, Permission } from '../access';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { setUnion } from '../domain.util';
|
||||
import { JobName } from '../job';
|
||||
import {
|
||||
AlbumAssetCount,
|
||||
AlbumInfoOptions,
|
||||
IAccessRepository,
|
||||
IAlbumRepository,
|
||||
@@ -68,11 +70,19 @@ export class AlbumService {
|
||||
|
||||
// Get asset count for each album. Then map the result to an object:
|
||||
// { [albumId]: assetCount }
|
||||
const albumsAssetCount = await this.albumRepository.getAssetCountForIds(albums.map((album) => album.id));
|
||||
const albumsAssetCountObj = albumsAssetCount.reduce((obj: Record<string, number>, { albumId, assetCount }) => {
|
||||
obj[albumId] = assetCount;
|
||||
return obj;
|
||||
}, {});
|
||||
const albumMetadataForIds = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
|
||||
const albumMetadataForIdsObj: Record<string, AlbumAssetCount> = albumMetadataForIds.reduce(
|
||||
(obj: Record<string, AlbumAssetCount>, { albumId, assetCount, startDate, endDate }) => {
|
||||
obj[albumId] = {
|
||||
albumId,
|
||||
assetCount,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
return obj;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return Promise.all(
|
||||
albums.map(async (album) => {
|
||||
@@ -80,7 +90,9 @@ export class AlbumService {
|
||||
return {
|
||||
...mapAlbumWithoutAssets(album),
|
||||
sharedLinks: undefined,
|
||||
assetCount: albumsAssetCountObj[album.id],
|
||||
startDate: albumMetadataForIdsObj[album.id].startDate,
|
||||
endDate: albumMetadataForIdsObj[album.id].endDate,
|
||||
assetCount: albumMetadataForIdsObj[album.id].assetCount,
|
||||
lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
|
||||
};
|
||||
}),
|
||||
@@ -90,7 +102,16 @@ export class AlbumService {
|
||||
async get(authUser: AuthUserDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
await this.albumRepository.updateThumbnails();
|
||||
return mapAlbum(await this.findOrFail(id, { withAssets: true }), !dto.withoutAssets);
|
||||
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
|
||||
const album = await this.findOrFail(id, { withAssets });
|
||||
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
|
||||
|
||||
return {
|
||||
...mapAlbum(album, withAssets),
|
||||
startDate: albumMetadataForIds.startDate,
|
||||
endDate: albumMetadataForIds.endDate,
|
||||
assetCount: albumMetadataForIds.assetCount,
|
||||
};
|
||||
}
|
||||
|
||||
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
|
||||
@@ -153,6 +174,8 @@ export class AlbumService {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
|
||||
const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids);
|
||||
const notPresentAssetIds = dto.ids.filter((id) => !existingAssetIds.has(id));
|
||||
const allowedAssetIds = await this.access.checkAccess(authUser, Permission.ASSET_SHARE, notPresentAssetIds);
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
for (const assetId of dto.ids) {
|
||||
@@ -162,7 +185,7 @@ export class AlbumService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId);
|
||||
const hasAccess = allowedAssetIds.has(assetId);
|
||||
if (!hasAccess) {
|
||||
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
@@ -190,6 +213,9 @@ export class AlbumService {
|
||||
await this.access.requirePermission(authUser, Permission.ALBUM_READ, id);
|
||||
|
||||
const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids);
|
||||
const canRemove = await this.access.checkAccess(authUser, Permission.ALBUM_REMOVE_ASSET, existingAssetIds);
|
||||
const canShare = await this.access.checkAccess(authUser, Permission.ASSET_SHARE, existingAssetIds);
|
||||
const allowedAssetIds = setUnion(canRemove, canShare);
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
for (const assetId of dto.ids) {
|
||||
@@ -199,10 +225,7 @@ export class AlbumService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.access.hasAny(authUser, [
|
||||
{ permission: Permission.ALBUM_REMOVE_ASSET, id: assetId },
|
||||
{ permission: Permission.ASSET_SHARE, id: assetId },
|
||||
]);
|
||||
const hasAccess = allowedAssetIds.has(assetId);
|
||||
if (!hasAccess) {
|
||||
results.push({ id: assetId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
|
||||
@@ -347,14 +347,14 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('getTimeBucket', () => {
|
||||
it('should return the assets for a album time bucket if user has album.read', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
assetMock.getTimeBucket.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await expect(
|
||||
sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
|
||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-id');
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-id']));
|
||||
expect(assetMock.getTimeBucket).toBeCalledWith('bucket', {
|
||||
size: TimeBucketSize.DAY,
|
||||
timeBucket: 'bucket',
|
||||
@@ -546,7 +546,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should return a list of archives (albumId)', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1']));
|
||||
assetMock.getByAlbumId.mockResolvedValue({
|
||||
items: [assetStub.image, assetStub.video],
|
||||
hasNextPage: false,
|
||||
@@ -554,12 +554,12 @@ describe(AssetService.name, () => {
|
||||
|
||||
await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse);
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-1');
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['album-1']));
|
||||
expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1');
|
||||
});
|
||||
|
||||
it('should return a list of archives (userId)', async () => {
|
||||
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id]));
|
||||
assetMock.getByUserId.mockResolvedValue({
|
||||
items: [assetStub.image, assetStub.video],
|
||||
hasNextPage: false,
|
||||
@@ -575,7 +575,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should split archives by size', async () => {
|
||||
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id]));
|
||||
|
||||
assetMock.getByUserId.mockResolvedValue({
|
||||
items: [
|
||||
@@ -1067,4 +1067,18 @@ describe(AssetService.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('get assets by device id', async () => {
|
||||
const assets = [assetStub.image, assetStub.image1];
|
||||
|
||||
assetMock.getAllByDeviceId.mockImplementation(() =>
|
||||
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
|
||||
);
|
||||
|
||||
const deviceId = 'device-id';
|
||||
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
|
||||
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,6 +386,10 @@ export class AssetService {
|
||||
return assets.map((a) => mapAsset(a));
|
||||
}
|
||||
|
||||
async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
||||
return this.assetRepository.getAllByDeviceId(authUser.id, deviceId);
|
||||
}
|
||||
|
||||
async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
|
||||
|
||||
|
||||
@@ -395,11 +395,11 @@ describe('AuthService', () => {
|
||||
|
||||
describe('logoutDevice', () => {
|
||||
it('should logout the device', async () => {
|
||||
accessMock.authDevice.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
|
||||
|
||||
await sut.logoutDevice(authStub.user1, 'token-1');
|
||||
|
||||
expect(accessMock.authDevice.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
|
||||
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['token-1']));
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith('token-1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,3 +150,23 @@ export function Optional({ nullable, ...validationOptions }: OptionalOptions = {
|
||||
|
||||
return ValidateIf((obj: any, v: any) => v !== undefined, validationOptions);
|
||||
}
|
||||
|
||||
// NOTE: The following Set utils have been added here, to easily determine where they are used.
|
||||
// They should be replaced with native Set operations, when they are added to the language.
|
||||
// Proposal reference: https://github.com/tc39/proposal-set-methods
|
||||
|
||||
export const setUnion = <T>(setA: Set<T>, setB: Set<T>): Set<T> => {
|
||||
const union = new Set(setA);
|
||||
for (const elem of setB) {
|
||||
union.add(elem);
|
||||
}
|
||||
return union;
|
||||
};
|
||||
|
||||
export const setDifference = <T>(setA: Set<T>, setB: Set<T>): Set<T> => {
|
||||
const difference = new Set(setA);
|
||||
for (const elem of setB) {
|
||||
difference.delete(elem);
|
||||
}
|
||||
return difference;
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ describe(LibraryService.name, () => {
|
||||
ctime: new Date('2023-01-01'),
|
||||
} as Stats);
|
||||
|
||||
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id]));
|
||||
|
||||
sut = new LibraryService(
|
||||
accessMock,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetType, CitiesFile, ExifEntity, SystemConfigKey } from '@app/infra/entities';
|
||||
import { AssetType, ExifEntity, SystemConfigKey } from '@app/infra/entities';
|
||||
import {
|
||||
assetStub,
|
||||
newAlbumRepositoryMock,
|
||||
@@ -15,7 +15,7 @@ import { randomBytes } from 'crypto';
|
||||
import { Stats } from 'fs';
|
||||
import { constants } from 'fs/promises';
|
||||
import { when } from 'jest-when';
|
||||
import { JobName, QueueName } from '../job';
|
||||
import { JobName } from '../job';
|
||||
import {
|
||||
IAlbumRepository,
|
||||
IAssetRepository,
|
||||
@@ -78,10 +78,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
describe('init', () => {
|
||||
beforeEach(async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true },
|
||||
{ key: SystemConfigKey.REVERSE_GEOCODING_CITIES_FILE_OVERRIDE, value: CitiesFile.CITIES_500 },
|
||||
]);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]);
|
||||
|
||||
await sut.init();
|
||||
});
|
||||
@@ -90,42 +87,10 @@ describe(MetadataService.name, () => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: false }]);
|
||||
|
||||
await sut.init();
|
||||
expect(metadataMock.deleteCache).not.toHaveBeenCalled();
|
||||
expect(jobMock.pause).toHaveBeenCalledTimes(1);
|
||||
expect(metadataMock.init).toHaveBeenCalledTimes(1);
|
||||
expect(jobMock.resume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return if deleteCache is false and the cities precision has not changed', async () => {
|
||||
await sut.init();
|
||||
|
||||
expect(metadataMock.deleteCache).not.toHaveBeenCalled();
|
||||
expect(jobMock.pause).toHaveBeenCalledTimes(1);
|
||||
expect(metadataMock.init).toHaveBeenCalledTimes(1);
|
||||
expect(jobMock.resume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should re-init if deleteCache is false but the cities precision has changed', async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.REVERSE_GEOCODING_CITIES_FILE_OVERRIDE, value: CitiesFile.CITIES_1000 },
|
||||
]);
|
||||
|
||||
await sut.init();
|
||||
|
||||
expect(metadataMock.deleteCache).not.toHaveBeenCalled();
|
||||
expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_1000 });
|
||||
expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
});
|
||||
|
||||
it('should re-init and delete cache if deleteCache is true', async () => {
|
||||
await sut.init(true);
|
||||
|
||||
expect(metadataMock.deleteCache).toHaveBeenCalled();
|
||||
expect(jobMock.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
expect(metadataMock.init).toHaveBeenCalledWith({ citiesFileOverride: CitiesFile.CITIES_500 });
|
||||
expect(jobMock.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleLivePhotoLinking', () => {
|
||||
|
||||
@@ -98,31 +98,24 @@ export class MetadataService {
|
||||
this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository);
|
||||
}
|
||||
|
||||
async init(deleteCache = false) {
|
||||
async init() {
|
||||
if (!this.subscription) {
|
||||
this.subscription = this.configCore.config$.subscribe(() => this.init());
|
||||
}
|
||||
|
||||
const { reverseGeocoding } = await this.configCore.getConfig();
|
||||
const { citiesFileOverride } = reverseGeocoding;
|
||||
const { enabled } = reverseGeocoding;
|
||||
|
||||
if (!reverseGeocoding.enabled) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (deleteCache) {
|
||||
await this.repository.deleteCache();
|
||||
} else if (this.oldCities && this.oldCities === citiesFileOverride) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.jobRepository.pause(QueueName.METADATA_EXTRACTION);
|
||||
await this.repository.init({ citiesFileOverride });
|
||||
await this.repository.init();
|
||||
await this.jobRepository.resume(QueueName.METADATA_EXTRACTION);
|
||||
|
||||
this.logger.log(`Initialized local reverse geocoder with ${citiesFileOverride}`);
|
||||
this.oldCities = citiesFileOverride;
|
||||
this.logger.log(`Initialized local reverse geocoder`);
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to initialize reverse geocoding: ${error}`, error?.stack);
|
||||
}
|
||||
@@ -292,8 +285,11 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
try {
|
||||
const { city, state, country } = await this.repository.reverseGeocode({ latitude, longitude });
|
||||
Object.assign(exifData, { city, state, country });
|
||||
const reverseGeocode = await this.repository.reverseGeocode({ latitude, longitude });
|
||||
if (!reverseGeocode) {
|
||||
return;
|
||||
}
|
||||
Object.assign(exifData, reverseGeocode);
|
||||
} catch (error: Error | any) {
|
||||
this.logger.warn(
|
||||
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
||||
|
||||
@@ -183,105 +183,101 @@ describe(PersonService.name, () => {
|
||||
describe('getById', () => {
|
||||
it('should require person.read permission', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.withName);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw a bad request when person is not found', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should get a person by id', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.withName);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
|
||||
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThumbnail', () => {
|
||||
it('should require person.read permission', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw an error when person has no thumbnail', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noThumbnail);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should serve the thumbnail', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await sut.getThumbnail(authStub.admin, 'person-1');
|
||||
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAssets', () => {
|
||||
it('should require person.read permission', async () => {
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.getAssets(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(personMock.getAssets).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it("should return a person's assets", async () => {
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await sut.getAssets(authStub.admin, 'person-1');
|
||||
expect(personMock.getAssets).toHaveBeenCalledWith('person-1');
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should require person.write permission', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it("should update a person's name", async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
||||
|
||||
@@ -291,14 +287,14 @@ describe(PersonService.name, () => {
|
||||
name: JobName.SEARCH_INDEX_ASSET,
|
||||
data: { ids: [assetStub.image.id] },
|
||||
});
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it("should update a person's date of birth", async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noBirthDate);
|
||||
personMock.update.mockResolvedValue(personStub.withBirthDate);
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
||||
id: 'person-1',
|
||||
@@ -311,14 +307,14 @@ describe(PersonService.name, () => {
|
||||
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should update a person visibility', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.hidden);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
|
||||
|
||||
@@ -328,7 +324,7 @@ describe(PersonService.name, () => {
|
||||
name: JobName.SEARCH_INDEX_ASSET,
|
||||
data: { ids: [assetStub.image.id] },
|
||||
});
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it("should update a person's thumbnailPath", async () => {
|
||||
@@ -336,7 +332,7 @@ describe(PersonService.name, () => {
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
|
||||
@@ -351,31 +347,31 @@ describe(PersonService.name, () => {
|
||||
},
|
||||
]);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } });
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw an error when the face feature assetId is invalid', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.withName);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAll', () => {
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(
|
||||
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
|
||||
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -652,7 +648,6 @@ describe(PersonService.name, () => {
|
||||
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([]);
|
||||
personMock.delete.mockResolvedValue(personStub.mergePerson);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -663,7 +658,7 @@ describe(PersonService.name, () => {
|
||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||
|
||||
expect(personMock.delete).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should merge two people', async () => {
|
||||
@@ -671,7 +666,8 @@ describe(PersonService.name, () => {
|
||||
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([]);
|
||||
personMock.delete.mockResolvedValue(personStub.mergePerson);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: true },
|
||||
@@ -691,14 +687,15 @@ describe(PersonService.name, () => {
|
||||
name: JobName.PERSON_DELETE,
|
||||
data: { id: personStub.mergePerson.id },
|
||||
});
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should delete conflicting faces before merging', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: true },
|
||||
@@ -713,25 +710,26 @@ describe(PersonService.name, () => {
|
||||
name: JobName.SEARCH_REMOVE_FACE,
|
||||
data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
|
||||
});
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw an error when the primary person is not found', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(personMock.delete).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should handle invalid merge ids', async () => {
|
||||
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
personMock.getById.mockResolvedValueOnce(null);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
||||
@@ -740,7 +738,7 @@ describe(PersonService.name, () => {
|
||||
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
|
||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||
expect(personMock.delete).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should handle an error reassigning faces', async () => {
|
||||
@@ -748,14 +746,15 @@ describe(PersonService.name, () => {
|
||||
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
||||
personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
|
||||
]);
|
||||
|
||||
expect(personMock.delete).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -763,16 +762,15 @@ describe(PersonService.name, () => {
|
||||
it('should get correct number of person', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
personMock.getStatistics.mockResolvedValue(statistics);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should require person.read permission', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,10 +375,11 @@ export class PersonService {
|
||||
|
||||
const results: BulkIdResponseDto[] = [];
|
||||
|
||||
for (const mergeId of mergeIds) {
|
||||
const hasPermission = await this.access.hasPermission(authUser, Permission.PERSON_MERGE, mergeId);
|
||||
const allowedIds = await this.access.checkAccess(authUser, Permission.PERSON_MERGE, mergeIds);
|
||||
|
||||
if (!hasPermission) {
|
||||
for (const mergeId of mergeIds) {
|
||||
const hasAccess = allowedIds.has(mergeId);
|
||||
if (!hasAccess) {
|
||||
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -14,29 +14,29 @@ export interface IAccessRepository {
|
||||
};
|
||||
|
||||
authDevice: {
|
||||
hasOwnerAccess(userId: string, deviceId: string): Promise<boolean>;
|
||||
checkOwnerAccess(userId: string, deviceIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
album: {
|
||||
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
hasSharedAlbumAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
hasSharedLinkAccess(sharedLinkId: string, albumId: string): Promise<boolean>;
|
||||
checkOwnerAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>;
|
||||
checkSharedAlbumAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>;
|
||||
checkSharedLinkAccess(sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
library: {
|
||||
hasOwnerAccess(userId: string, libraryId: string): Promise<boolean>;
|
||||
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
|
||||
checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>;
|
||||
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
timeline: {
|
||||
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
|
||||
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
person: {
|
||||
hasOwnerAccess(userId: string, personId: string): Promise<boolean>;
|
||||
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
partner: {
|
||||
hasUpdateAccess(userId: string, partnerId: string): Promise<boolean>;
|
||||
checkUpdateAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export const IAlbumRepository = 'IAlbumRepository';
|
||||
export interface AlbumAssetCount {
|
||||
albumId: string;
|
||||
assetCount: number;
|
||||
startDate: Date | undefined;
|
||||
endDate: Date | undefined;
|
||||
}
|
||||
|
||||
export interface AlbumInfoOptions {
|
||||
@@ -30,7 +32,7 @@ export interface IAlbumRepository {
|
||||
hasAsset(asset: AlbumAsset): Promise<boolean>;
|
||||
removeAsset(assetId: string): Promise<void>;
|
||||
removeAssets(assets: AlbumAssets): Promise<void>;
|
||||
getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]>;
|
||||
getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
|
||||
getInvalidThumbnail(): Promise<string[]>;
|
||||
getOwned(ownerId: string): Promise<AlbumEntity[]>;
|
||||
getShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||
|
||||
@@ -162,6 +162,7 @@ export interface IAssetRepository {
|
||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
|
||||
deleteAll(ownerId: string): Promise<void>;
|
||||
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
|
||||
updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
|
||||
save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
|
||||
remove(asset: AssetEntity): Promise<void>;
|
||||
|
||||
@@ -20,6 +20,7 @@ export * from './shared-link.repository';
|
||||
export * from './smart-info.repository';
|
||||
export * from './storage.repository';
|
||||
export * from './system-config.repository';
|
||||
export * from './system-metadata.repository';
|
||||
export * from './tag.repository';
|
||||
export * from './user-token.repository';
|
||||
export * from './user.repository';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tags } from 'exiftool-vendored';
|
||||
import { InitOptions } from 'local-reverse-geocoder';
|
||||
|
||||
export const IMetadataRepository = 'IMetadataRepository';
|
||||
|
||||
@@ -31,10 +30,9 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration'> {
|
||||
}
|
||||
|
||||
export interface IMetadataRepository {
|
||||
init(options: Partial<InitOptions>): Promise<void>;
|
||||
init(): Promise<void>;
|
||||
teardown(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||
deleteCache(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
|
||||
readTags(path: string): Promise<ImmichTags | null>;
|
||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SystemMetadata } from '@app/infra/entities';
|
||||
|
||||
export const ISystemMetadataRepository = 'ISystemMetadataRepository';
|
||||
|
||||
export interface ISystemMetadataRepository {
|
||||
get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null>;
|
||||
set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void>;
|
||||
}
|
||||
@@ -97,7 +97,6 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
it('should not allow non-owners to create album shared links', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -117,12 +116,15 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
it('should create an album shared link', async () => {
|
||||
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
|
||||
|
||||
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
|
||||
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.id,
|
||||
new Set([albumStub.oneAsset.id]),
|
||||
);
|
||||
expect(shareMock.create).toHaveBeenCalledWith({
|
||||
type: SharedLinkType.ALBUM,
|
||||
userId: authStub.admin.id,
|
||||
|
||||
@@ -119,15 +119,19 @@ export class SharedLinkService {
|
||||
throw new BadRequestException('Invalid shared link type');
|
||||
}
|
||||
|
||||
const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id));
|
||||
const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId));
|
||||
const allowedAssetIds = await this.access.checkAccess(authUser, Permission.ASSET_SHARE, notPresentAssetIds);
|
||||
|
||||
const results: AssetIdsResponseDto[] = [];
|
||||
for (const assetId of dto.assetIds) {
|
||||
const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
|
||||
const hasAsset = existingAssetIds.has(assetId);
|
||||
if (hasAsset) {
|
||||
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId);
|
||||
const hasAccess = allowedAssetIds.has(assetId);
|
||||
if (!hasAccess) {
|
||||
results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION });
|
||||
continue;
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { CitiesFile } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsEnum } from 'class-validator';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class SystemConfigReverseGeocodingDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsEnum(CitiesFile)
|
||||
@ApiProperty({ enum: CitiesFile, enumName: 'CitiesFile' })
|
||||
citiesFileOverride!: CitiesFile;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AudioCodec,
|
||||
CitiesFile,
|
||||
Colorspace,
|
||||
CQMode,
|
||||
SystemConfig,
|
||||
@@ -85,7 +84,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
citiesFileOverride: CitiesFile.CITIES_500,
|
||||
},
|
||||
oauth: {
|
||||
enabled: false,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AudioCodec,
|
||||
CitiesFile,
|
||||
Colorspace,
|
||||
CQMode,
|
||||
SystemConfig,
|
||||
@@ -85,7 +84,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
citiesFileOverride: CitiesFile.CITIES_500,
|
||||
},
|
||||
oauth: {
|
||||
autoLaunch: true,
|
||||
|
||||
@@ -79,7 +79,7 @@ export class SystemConfigService {
|
||||
return this.repository.fetchStyle(styleUrl);
|
||||
}
|
||||
|
||||
return JSON.parse(await this.repository.readFile(`./assets/style-${theme}.json`));
|
||||
return JSON.parse(await this.repository.readFile(`./resources/style-${theme}.json`));
|
||||
}
|
||||
|
||||
async getCustomCss(): Promise<string> {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
UseInterceptors,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Response as Res } from 'express';
|
||||
import { AuthUser, Authenticated, SharedLinkRoute } from '../../app.guard';
|
||||
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
|
||||
@@ -147,9 +147,10 @@ export class AssetController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all asset of a device that are in the database, ID only.
|
||||
* @deprecated Use /asset/device/:deviceId instead - Remove at 1.92 release
|
||||
*/
|
||||
@Get('/:deviceId')
|
||||
@ApiOperation({ deprecated: true, summary: 'Use /asset/device/:deviceId instead - Remove in 1.92 release' })
|
||||
getUserAssetsByDeviceId(@AuthUser() authUser: AuthUserDto, @Param() { deviceId }: DeviceIdDto) {
|
||||
return this.assetService.getUserAssetsByDeviceId(authUser, deviceId);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('AssetService', () => {
|
||||
const dto = _getCreateAssetDto();
|
||||
|
||||
assetRepositoryMock.create.mockResolvedValue(assetEntity);
|
||||
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('AssetService', () => {
|
||||
|
||||
assetRepositoryMock.create.mockRejectedValue(error);
|
||||
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
|
||||
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('AssetService', () => {
|
||||
|
||||
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
|
||||
|
||||
await expect(
|
||||
sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class DeviceIdDto {
|
||||
@IsNotEmpty()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { DeviceIdDto } from '../api-v1/asset/dto/device-id.dto';
|
||||
import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard';
|
||||
import { UseValidation, asStreamableFile } from '../app.utils';
|
||||
import { Route } from '../interceptors';
|
||||
@@ -100,6 +101,14 @@ export class AssetController {
|
||||
return this.service.downloadFile(authUser, id).then(asStreamableFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all asset of a device that are in the database, ID only.
|
||||
*/
|
||||
@Get('/device/:deviceId')
|
||||
getAllUserAssetsByDeviceId(@AuthUser() authUser: AuthUserDto, @Param() { deviceId }: DeviceIdDto) {
|
||||
return this.service.getUserAssetsByDeviceId(authUser, deviceId);
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
getAssetStatistics(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
|
||||
return this.service.getStatistics(authUser, dto);
|
||||
|
||||
@@ -19,11 +19,6 @@ import { UseValidation } from '../app.utils';
|
||||
export class SearchController {
|
||||
constructor(private service: SearchService) {}
|
||||
|
||||
@Get('person')
|
||||
searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
|
||||
return this.service.searchPerson(authUser, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
search(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
|
||||
return this.service.search(authUser, dto);
|
||||
@@ -33,4 +28,9 @@ export class SearchController {
|
||||
getExploreData(@AuthUser() authUser: AuthUserDto): Promise<SearchExploreResponseDto[]> {
|
||||
return this.service.getExploreData(authUser) as Promise<SearchExploreResponseDto[]>;
|
||||
}
|
||||
|
||||
@Get('person')
|
||||
searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
|
||||
return this.service.searchPerson(authUser, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { AssetEntity } from './asset.entity';
|
||||
import { PersonEntity } from './person.entity';
|
||||
|
||||
@Entity('asset_faces')
|
||||
@Index(['personId', 'assetId'])
|
||||
export class AssetFaceEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@@ -33,6 +33,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum';
|
||||
@Index('IDX_day_of_month', { synchronize: false })
|
||||
@Index('IDX_month', { synchronize: false })
|
||||
@Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId'])
|
||||
@Index(['stackParentId'])
|
||||
// For all assets, each originalpath must be unique per user and library
|
||||
export class AssetEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('geodata_admin1')
|
||||
export class GeodataAdmin1Entity {
|
||||
@PrimaryColumn({ type: 'varchar' })
|
||||
key!: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('geodata_admin2')
|
||||
export class GeodataAdmin2Entity {
|
||||
@PrimaryColumn({ type: 'varchar' })
|
||||
key!: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity';
|
||||
import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
|
||||
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('geodata_places', { synchronize: false })
|
||||
export class GeodataPlacesEntity {
|
||||
@PrimaryColumn({ type: 'integer' })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'float' })
|
||||
longitude!: number;
|
||||
|
||||
@Column({ type: 'float' })
|
||||
latitude!: number;
|
||||
|
||||
// @Column({
|
||||
// generatedType: 'STORED',
|
||||
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
|
||||
// type: 'earth',
|
||||
// })
|
||||
earthCoord!: unknown;
|
||||
|
||||
@Column({ type: 'char', length: 2 })
|
||||
countryCode!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
admin1Code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||
admin2Code!: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
generatedType: 'STORED',
|
||||
asExpression: `"countryCode" || '.' || "admin1Code"`,
|
||||
nullable: true,
|
||||
})
|
||||
admin1Key!: string;
|
||||
|
||||
@ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
|
||||
admin1!: GeodataAdmin1Entity;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
generatedType: 'STORED',
|
||||
asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`,
|
||||
nullable: true,
|
||||
})
|
||||
admin2Key!: string;
|
||||
|
||||
@ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
|
||||
admin2!: GeodataAdmin2Entity;
|
||||
|
||||
@Column({ type: 'date' })
|
||||
modificationDate!: Date;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
|
||||
import { ActivityEntity } from './activity.entity';
|
||||
import { AlbumEntity } from './album.entity';
|
||||
import { APIKeyEntity } from './api-key.entity';
|
||||
@@ -6,6 +7,8 @@ import { AssetJobStatusEntity } from './asset-job-status.entity';
|
||||
import { AssetEntity } from './asset.entity';
|
||||
import { AuditEntity } from './audit.entity';
|
||||
import { ExifEntity } from './exif.entity';
|
||||
import { GeodataAdmin1Entity } from './geodata-admin1.entity';
|
||||
import { GeodataPlacesEntity } from './geodata-places.entity';
|
||||
import { LibraryEntity } from './library.entity';
|
||||
import { MoveEntity } from './move.entity';
|
||||
import { PartnerEntity } from './partner.entity';
|
||||
@@ -13,6 +16,7 @@ import { PersonEntity } from './person.entity';
|
||||
import { SharedLinkEntity } from './shared-link.entity';
|
||||
import { SmartInfoEntity } from './smart-info.entity';
|
||||
import { SystemConfigEntity } from './system-config.entity';
|
||||
import { SystemMetadataEntity } from './system-metadata.entity';
|
||||
import { TagEntity } from './tag.entity';
|
||||
import { UserTokenEntity } from './user-token.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
@@ -25,6 +29,9 @@ export * from './asset-job-status.entity';
|
||||
export * from './asset.entity';
|
||||
export * from './audit.entity';
|
||||
export * from './exif.entity';
|
||||
export * from './geodata-admin1.entity';
|
||||
export * from './geodata-admin2.entity';
|
||||
export * from './geodata-places.entity';
|
||||
export * from './library.entity';
|
||||
export * from './move.entity';
|
||||
export * from './partner.entity';
|
||||
@@ -32,6 +39,7 @@ export * from './person.entity';
|
||||
export * from './shared-link.entity';
|
||||
export * from './smart-info.entity';
|
||||
export * from './system-config.entity';
|
||||
export * from './system-metadata.entity';
|
||||
export * from './tag.entity';
|
||||
export * from './user-token.entity';
|
||||
export * from './user.entity';
|
||||
@@ -45,12 +53,16 @@ export const databaseEntities = [
|
||||
AssetJobStatusEntity,
|
||||
AuditEntity,
|
||||
ExifEntity,
|
||||
GeodataPlacesEntity,
|
||||
GeodataAdmin1Entity,
|
||||
GeodataAdmin2Entity,
|
||||
MoveEntity,
|
||||
PartnerEntity,
|
||||
PersonEntity,
|
||||
SharedLinkEntity,
|
||||
SmartInfoEntity,
|
||||
SystemConfigEntity,
|
||||
SystemMetadataEntity,
|
||||
TagEntity,
|
||||
UserEntity,
|
||||
UserTokenEntity,
|
||||
|
||||
@@ -66,7 +66,6 @@ export enum SystemConfigKey {
|
||||
MAP_DARK_STYLE = 'map.darkStyle',
|
||||
|
||||
REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
|
||||
REVERSE_GEOCODING_CITIES_FILE_OVERRIDE = 'reverseGeocoding.citiesFileOverride',
|
||||
|
||||
NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
|
||||
|
||||
@@ -145,13 +144,6 @@ export enum Colorspace {
|
||||
P3 = 'p3',
|
||||
}
|
||||
|
||||
export enum CitiesFile {
|
||||
CITIES_15000 = 'cities15000',
|
||||
CITIES_5000 = 'cities5000',
|
||||
CITIES_1000 = 'cities1000',
|
||||
CITIES_500 = 'cities500',
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
ffmpeg: {
|
||||
crf: number;
|
||||
@@ -200,7 +192,6 @@ export interface SystemConfig {
|
||||
};
|
||||
reverseGeocoding: {
|
||||
enabled: boolean;
|
||||
citiesFileOverride: CitiesFile;
|
||||
};
|
||||
oauth: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('system_metadata')
|
||||
export class SystemMetadataEntity {
|
||||
@PrimaryColumn()
|
||||
key!: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: '{}', transformer: { to: JSON.stringify, from: JSON.parse } })
|
||||
value!: { [key: string]: unknown };
|
||||
}
|
||||
|
||||
export enum SystemMetadataKey {
|
||||
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
|
||||
}
|
||||
|
||||
export interface SystemMetadata extends Record<SystemMetadataKey, { [key: string]: unknown }> {
|
||||
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
|
||||
}
|
||||
@@ -74,6 +74,3 @@ function parseTypeSenseConfig(): ConfigurationOptions {
|
||||
}
|
||||
|
||||
export const typesenseConfig: ConfigurationOptions = parseTypeSenseConfig();
|
||||
|
||||
export const REVERSE_GEOCODING_DUMP_DIRECTORY =
|
||||
process.env.REVERSE_GEOCODING_DUMP_DIRECTORY || process.cwd() + '/.reverse-geocoding-dump/';
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ISmartInfoRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
ISystemMetadataRepository,
|
||||
ITagRepository,
|
||||
IUserRepository,
|
||||
IUserTokenRepository,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
SharedLinkRepository,
|
||||
SmartInfoRepository,
|
||||
SystemConfigRepository,
|
||||
SystemMetadataRepository,
|
||||
TagRepository,
|
||||
TypesenseRepository,
|
||||
UserRepository,
|
||||
@@ -84,6 +86,7 @@ const providers: Provider[] = [
|
||||
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },
|
||||
{ provide: IStorageRepository, useClass: FilesystemProvider },
|
||||
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
|
||||
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
|
||||
{ provide: ITagRepository, useClass: TagRepository },
|
||||
{ provide: IMediaRepository, useClass: MediaRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class SystemMetadata1700345818045 implements MigrationInterface {
|
||||
name = 'SystemMetadata1700345818045'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "system_metadata" ("key" character varying NOT NULL, "value" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_fa94f6857470fb5b81ec6084465" PRIMARY KEY ("key"))`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "system_metadata"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class Geodata1700362016675 implements MigrationInterface {
|
||||
name = 'Geodata1700362016675'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS cube`)
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS earthdistance`)
|
||||
await queryRunner.query(`CREATE TABLE "geodata_admin2" ("key" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key"))`);
|
||||
await queryRunner.query(`CREATE TABLE "geodata_admin1" ("key" character varying NOT NULL, "name" character varying NOT NULL, CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key"))`);
|
||||
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["immich","public","geodata_places","GENERATED_COLUMN","admin1Key","\"countryCode\" || '.' || \"admin1Code\""]);
|
||||
await queryRunner.query(`INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES ($1, $2, $3, $4, $5, $6)`, ["immich","public","geodata_places","GENERATED_COLUMN","admin2Key","\"countryCode\" || '.' || \"admin1Code\" || '.' || \"admin2Code\""]);
|
||||
await queryRunner.query(`CREATE TABLE "geodata_places" ("id" integer NOT NULL, "name" character varying(200) NOT NULL, "longitude" double precision NOT NULL, "latitude" double precision NOT NULL, "countryCode" character(2) NOT NULL, "admin1Code" character varying(20), "admin2Code" character varying(80), "admin1Key" character varying GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED, "admin2Key" character varying GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED, "modificationDate" date NOT NULL, CONSTRAINT "PK_c29918988912ef4036f3d7fbff4" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`ALTER TABLE "geodata_places" ADD "earthCoord" earth GENERATED ALWAYS AS (ll_to_earth(latitude, longitude)) STORED`)
|
||||
await queryRunner.query(`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" USING gist ("earthCoord");`)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_geodata_gist_earthcoord"`);
|
||||
await queryRunner.query(`DROP TABLE "geodata_places"`);
|
||||
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","admin2Key","immich","public","geodata_places"]);
|
||||
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "database" = $3 AND "schema" = $4 AND "table" = $5`, ["GENERATED_COLUMN","admin1Key","immich","public","geodata_places"]);
|
||||
await queryRunner.query(`DROP TABLE "geodata_admin1"`);
|
||||
await queryRunner.query(`DROP TABLE "geodata_admin2"`);
|
||||
await queryRunner.query(`DROP EXTENSION cube`);
|
||||
await queryRunner.query(`DROP EXTENSION earthdistance`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddAssetFaceIndicies1700752078178 implements MigrationInterface {
|
||||
name = 'AddAssetFaceIndicies1700752078178'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_bf339a24070dac7e71304ec530" ON "asset_faces" ("personId", "assetId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_b463c8edb01364bf2beba08ef1" ON "assets" ("stackParentId") `);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_b463c8edb01364bf2beba08ef1"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_bf339a24070dac7e71304ec530"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IAccessRepository } from '@app/domain';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import {
|
||||
ActivityEntity,
|
||||
AlbumEntity,
|
||||
@@ -62,33 +62,52 @@ export class AccessRepository implements IAccessRepository {
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
library = {
|
||||
hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => {
|
||||
return this.libraryRepository.exist({
|
||||
where: {
|
||||
id: libraryId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
checkOwnerAccess: async (userId: string, libraryIds: Set<string>): Promise<Set<string>> => {
|
||||
if (libraryIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.libraryRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...libraryIds]),
|
||||
ownerId: userId,
|
||||
},
|
||||
})
|
||||
.then((libraries) => new Set(libraries.map((library) => library.id)));
|
||||
},
|
||||
hasPartnerAccess: (userId: string, partnerId: string): Promise<boolean> => {
|
||||
return this.partnerRepository.exist({
|
||||
where: {
|
||||
sharedWithId: userId,
|
||||
sharedById: partnerId,
|
||||
},
|
||||
});
|
||||
|
||||
checkPartnerAccess: async (userId: string, partnerIds: Set<string>): Promise<Set<string>> => {
|
||||
if (partnerIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.partnerRepository
|
||||
.createQueryBuilder('partner')
|
||||
.select('partner.sharedById')
|
||||
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
|
||||
.andWhere('partner.sharedWithId = :userId', { userId })
|
||||
.getMany()
|
||||
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
|
||||
},
|
||||
};
|
||||
|
||||
timeline = {
|
||||
hasPartnerAccess: (userId: string, partnerId: string): Promise<boolean> => {
|
||||
return this.partnerRepository.exist({
|
||||
where: {
|
||||
sharedWithId: userId,
|
||||
sharedById: partnerId,
|
||||
},
|
||||
});
|
||||
checkPartnerAccess: async (userId: string, partnerIds: Set<string>): Promise<Set<string>> => {
|
||||
if (partnerIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.partnerRepository
|
||||
.createQueryBuilder('partner')
|
||||
.select('partner.sharedById')
|
||||
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
|
||||
.andWhere('partner.sharedWithId = :userId', { userId })
|
||||
.getMany()
|
||||
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -198,66 +217,109 @@ export class AccessRepository implements IAccessRepository {
|
||||
};
|
||||
|
||||
authDevice = {
|
||||
hasOwnerAccess: (userId: string, deviceId: string): Promise<boolean> => {
|
||||
return this.tokenRepository.exist({
|
||||
where: {
|
||||
userId,
|
||||
id: deviceId,
|
||||
},
|
||||
});
|
||||
checkOwnerAccess: async (userId: string, deviceIds: Set<string>): Promise<Set<string>> => {
|
||||
if (deviceIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.tokenRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
userId,
|
||||
id: In([...deviceIds]),
|
||||
},
|
||||
})
|
||||
.then((tokens) => new Set(tokens.map((token) => token.id)));
|
||||
},
|
||||
};
|
||||
|
||||
album = {
|
||||
hasOwnerAccess: (userId: string, albumId: string): Promise<boolean> => {
|
||||
return this.albumRepository.exist({
|
||||
where: {
|
||||
id: albumId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
checkOwnerAccess: async (userId: string, albumIds: Set<string>): Promise<Set<string>> => {
|
||||
if (albumIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
hasSharedAlbumAccess: (userId: string, albumId: string): Promise<boolean> => {
|
||||
return this.albumRepository.exist({
|
||||
where: {
|
||||
id: albumId,
|
||||
sharedUsers: {
|
||||
id: userId,
|
||||
return this.albumRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...albumIds]),
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
.then((albums) => new Set(albums.map((album) => album.id)));
|
||||
},
|
||||
|
||||
hasSharedLinkAccess: (sharedLinkId: string, albumId: string): Promise<boolean> => {
|
||||
return this.sharedLinkRepository.exist({
|
||||
where: {
|
||||
id: sharedLinkId,
|
||||
albumId,
|
||||
},
|
||||
});
|
||||
checkSharedAlbumAccess: async (userId: string, albumIds: Set<string>): Promise<Set<string>> => {
|
||||
if (albumIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.albumRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...albumIds]),
|
||||
sharedUsers: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((albums) => new Set(albums.map((album) => album.id)));
|
||||
},
|
||||
|
||||
checkSharedLinkAccess: async (sharedLinkId: string, albumIds: Set<string>): Promise<Set<string>> => {
|
||||
if (albumIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.sharedLinkRepository
|
||||
.find({
|
||||
select: { albumId: true },
|
||||
where: {
|
||||
id: sharedLinkId,
|
||||
albumId: In([...albumIds]),
|
||||
},
|
||||
})
|
||||
.then(
|
||||
(sharedLinks) =>
|
||||
new Set(sharedLinks.flatMap((sharedLink) => (!!sharedLink.albumId ? [sharedLink.albumId] : []))),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
person = {
|
||||
hasOwnerAccess: (userId: string, personId: string): Promise<boolean> => {
|
||||
return this.personRepository.exist({
|
||||
where: {
|
||||
id: personId,
|
||||
ownerId: userId,
|
||||
},
|
||||
});
|
||||
checkOwnerAccess: async (userId: string, personIds: Set<string>): Promise<Set<string>> => {
|
||||
if (personIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.personRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...personIds]),
|
||||
ownerId: userId,
|
||||
},
|
||||
})
|
||||
.then((persons) => new Set(persons.map((person) => person.id)));
|
||||
},
|
||||
};
|
||||
|
||||
partner = {
|
||||
hasUpdateAccess: (userId: string, partnerId: string): Promise<boolean> => {
|
||||
return this.partnerRepository.exist({
|
||||
where: {
|
||||
sharedById: partnerId,
|
||||
sharedWithId: userId,
|
||||
},
|
||||
});
|
||||
checkUpdateAccess: async (userId: string, partnerIds: Set<string>): Promise<Set<string>> => {
|
||||
if (partnerIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.partnerRepository
|
||||
.createQueryBuilder('partner')
|
||||
.select('partner.sharedById')
|
||||
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
|
||||
.andWhere('partner.sharedWithId = :userId', { userId })
|
||||
.getMany()
|
||||
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,25 +59,30 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getAssetCountForIds(ids: string[]): Promise<AlbumAssetCount[]> {
|
||||
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
|
||||
// Guard against running invalid query when ids list is empty.
|
||||
if (!ids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Only possible with query builder because of GROUP BY.
|
||||
const countByAlbums = await this.repository
|
||||
const albumMetadatas = await this.repository
|
||||
.createQueryBuilder('album')
|
||||
.select('album.id')
|
||||
.addSelect('COUNT(albums_assets.assetsId)', 'asset_count')
|
||||
.leftJoin('albums_assets_assets', 'albums_assets', 'albums_assets.albumsId = album.id')
|
||||
.addSelect('MIN(assets.fileCreatedAt)', 'start_date')
|
||||
.addSelect('MAX(assets.fileCreatedAt)', 'end_date')
|
||||
.addSelect('COUNT(album_assets.assetsId)', 'asset_count')
|
||||
.leftJoin('albums_assets_assets', 'album_assets', 'album_assets.albumsId = album.id')
|
||||
.leftJoin('assets', 'assets', 'assets.id = album_assets.assetsId')
|
||||
.where('album.id IN (:...ids)', { ids })
|
||||
.groupBy('album.id')
|
||||
.getRawMany();
|
||||
|
||||
return countByAlbums.map<AlbumAssetCount>((albumCount) => ({
|
||||
albumId: albumCount['album_id'],
|
||||
assetCount: Number(albumCount['asset_count']),
|
||||
return albumMetadatas.map<AlbumAssetCount>((metadatas) => ({
|
||||
albumId: metadatas['album_id'],
|
||||
assetCount: Number(metadatas['asset_count']),
|
||||
startDate: metadatas['end_date'] ? new Date(metadatas['start_date']) : undefined,
|
||||
endDate: metadatas['end_date'] ? new Date(metadatas['end_date']) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +326,27 @@ export class AssetRepository implements IAssetRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by device's Id on the database
|
||||
* @param ownerId
|
||||
* @param deviceId
|
||||
*
|
||||
* @returns Promise<string[]> - Array of assetIds belong to the device
|
||||
*/
|
||||
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
|
||||
const items = await this.repository.find({
|
||||
select: { deviceAssetId: true },
|
||||
where: {
|
||||
ownerId,
|
||||
deviceId,
|
||||
isVisible: true,
|
||||
},
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
return items.map((asset) => asset.deviceAssetId);
|
||||
}
|
||||
|
||||
getById(id: string): Promise<AssetEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from './server-info.repository';
|
||||
export * from './shared-link.repository';
|
||||
export * from './smart-info.repository';
|
||||
export * from './system-config.repository';
|
||||
export * from './system-metadata.repository';
|
||||
export * from './tag.repository';
|
||||
export * from './typesense.repository';
|
||||
export * from './user-token.repository';
|
||||
|
||||
@@ -1,77 +1,182 @@
|
||||
import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from '@app/domain';
|
||||
import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DefaultReadTaskOptions, Tags, exiftool } from 'exiftool-vendored';
|
||||
import { readdir, rm } from 'fs/promises';
|
||||
import {
|
||||
GeoPoint,
|
||||
IMetadataRepository,
|
||||
ImmichTags,
|
||||
ISystemMetadataRepository,
|
||||
ReverseGeocodeResult,
|
||||
} from '@app/domain';
|
||||
import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
|
||||
import { DatabaseLock } from '@app/infra/utils/database-locks';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import * as geotz from 'geo-tz';
|
||||
import { getName } from 'i18n-iso-countries';
|
||||
import geocoder, { AddressObject, InitOptions } from 'local-reverse-geocoder';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
import * as readLine from 'readline';
|
||||
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
|
||||
|
||||
export interface AdminCode {
|
||||
name: string;
|
||||
asciiName: string;
|
||||
geoNameId: string;
|
||||
}
|
||||
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
|
||||
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
|
||||
|
||||
export type GeoData = AddressObject & {
|
||||
admin1Code?: AdminCode | string;
|
||||
admin2Code?: AdminCode | string;
|
||||
};
|
||||
const CITIES_FILE = 'cities500.txt';
|
||||
|
||||
const lookup = promisify<GeoPoint[], number, AddressObject[][]>(geocoder.lookUp).bind(geocoder);
|
||||
|
||||
@Injectable()
|
||||
export class MetadataRepository implements IMetadataRepository {
|
||||
constructor(
|
||||
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
||||
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>,
|
||||
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>,
|
||||
@Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private logger = new Logger(MetadataRepository.name);
|
||||
|
||||
async init(options: Partial<InitOptions>): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
geocoder.init(
|
||||
{
|
||||
load: {
|
||||
admin1: true,
|
||||
admin2: true,
|
||||
admin3And4: false,
|
||||
alternateNames: false,
|
||||
},
|
||||
countries: [],
|
||||
dumpDirectory: REVERSE_GEOCODING_DUMP_DIRECTORY,
|
||||
...options,
|
||||
},
|
||||
resolve,
|
||||
);
|
||||
async init(): Promise<void> {
|
||||
this.logger.log('Initializing metadata repository');
|
||||
const geodataDate = await readFile('/usr/src/resources/geodata-date.txt', 'utf8');
|
||||
|
||||
await this.geodataPlacesRepository.query('SELECT pg_advisory_lock($1)', [DatabaseLock.GeodataImport]);
|
||||
|
||||
const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
|
||||
|
||||
if (geocodingMetadata?.lastUpdate === geodataDate) {
|
||||
await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('Importing geodata to database from file');
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
|
||||
try {
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
await this.loadCities500(queryRunner);
|
||||
await this.loadAdmin1(queryRunner);
|
||||
await this.loadAdmin2(queryRunner);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (e) {
|
||||
this.logger.fatal('Error importing geodata', e);
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw e;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
|
||||
lastUpdate: geodataDate,
|
||||
lastImportFileName: CITIES_FILE,
|
||||
});
|
||||
|
||||
await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]);
|
||||
this.logger.log('Geodata import completed');
|
||||
}
|
||||
|
||||
private async loadGeodataToTableFromFile<T extends GeoEntity>(
|
||||
queryRunner: QueryRunner,
|
||||
lineToEntityMapper: (lineSplit: string[]) => T,
|
||||
filePath: string,
|
||||
entity: GeoEntityClass,
|
||||
) {
|
||||
if (!existsSync(filePath)) {
|
||||
this.logger.error(`Geodata file ${filePath} not found`);
|
||||
throw new Error(`Geodata file ${filePath} not found`);
|
||||
}
|
||||
await queryRunner.manager.clear(entity);
|
||||
|
||||
const input = createReadStream(filePath);
|
||||
let buffer: DeepPartial<T>[] = [];
|
||||
const lineReader = readLine.createInterface({ input: input });
|
||||
|
||||
for await (const line of lineReader) {
|
||||
const lineSplit = line.split('\t');
|
||||
buffer.push(lineToEntityMapper(lineSplit));
|
||||
if (buffer.length > 1000) {
|
||||
await queryRunner.manager.save(buffer);
|
||||
buffer = [];
|
||||
}
|
||||
}
|
||||
await queryRunner.manager.save(buffer);
|
||||
}
|
||||
|
||||
private async loadCities500(queryRunner: QueryRunner) {
|
||||
await this.loadGeodataToTableFromFile<GeodataPlacesEntity>(
|
||||
queryRunner,
|
||||
(lineSplit: string[]) =>
|
||||
this.geodataPlacesRepository.create({
|
||||
id: parseInt(lineSplit[0]),
|
||||
name: lineSplit[1],
|
||||
latitude: parseFloat(lineSplit[4]),
|
||||
longitude: parseFloat(lineSplit[5]),
|
||||
countryCode: lineSplit[8],
|
||||
admin1Code: lineSplit[10],
|
||||
admin2Code: lineSplit[11],
|
||||
modificationDate: lineSplit[18],
|
||||
}),
|
||||
`/usr/src/resources/${CITIES_FILE}`,
|
||||
GeodataPlacesEntity,
|
||||
);
|
||||
}
|
||||
|
||||
private async loadAdmin1(queryRunner: QueryRunner) {
|
||||
await this.loadGeodataToTableFromFile<GeodataAdmin1Entity>(
|
||||
queryRunner,
|
||||
(lineSplit: string[]) =>
|
||||
this.geodataAdmin1Repository.create({
|
||||
key: lineSplit[0],
|
||||
name: lineSplit[1],
|
||||
}),
|
||||
'/usr/src/resources/admin1CodesASCII.txt',
|
||||
GeodataAdmin1Entity,
|
||||
);
|
||||
}
|
||||
|
||||
private async loadAdmin2(queryRunner: QueryRunner) {
|
||||
await this.loadGeodataToTableFromFile<GeodataAdmin2Entity>(
|
||||
queryRunner,
|
||||
(lineSplit: string[]) =>
|
||||
this.geodataAdmin2Repository.create({
|
||||
key: lineSplit[0],
|
||||
name: lineSplit[1],
|
||||
}),
|
||||
'/usr/src/resources/admin2Codes.txt',
|
||||
GeodataAdmin2Entity,
|
||||
);
|
||||
}
|
||||
|
||||
async teardown() {
|
||||
await exiftool.end();
|
||||
}
|
||||
|
||||
async deleteCache() {
|
||||
const dumpDirectory = REVERSE_GEOCODING_DUMP_DIRECTORY;
|
||||
if (dumpDirectory) {
|
||||
// delete contents
|
||||
const items = await readdir(dumpDirectory, { withFileTypes: true });
|
||||
const folders = items.filter((item) => item.isDirectory());
|
||||
for (const { name } of folders) {
|
||||
await rm(path.join(dumpDirectory, name), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null> {
|
||||
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
|
||||
|
||||
const [address] = await lookup([point], 1);
|
||||
this.logger.verbose(`Raw: ${JSON.stringify(address, null, 2)}`);
|
||||
const response = await this.geodataPlacesRepository
|
||||
.createQueryBuilder('geoplaces')
|
||||
.leftJoinAndSelect('geoplaces.admin1', 'admin1')
|
||||
.leftJoinAndSelect('geoplaces.admin2', 'admin2')
|
||||
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
|
||||
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
|
||||
.limit(1)
|
||||
.getOne();
|
||||
|
||||
const { countryCode, name: city, admin1Code, admin2Code } = address[0] as GeoData;
|
||||
if (!response) {
|
||||
this.logger.warn(
|
||||
`Response from database for reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
|
||||
|
||||
const { countryCode, name: city, admin1, admin2 } = response;
|
||||
const country = getName(countryCode, 'en') ?? null;
|
||||
const stateParts = [(admin2Code as AdminCode)?.name, (admin1Code as AdminCode)?.name].filter((name) => !!name);
|
||||
const stateParts = [admin2?.name, admin1?.name].filter((name) => !!name);
|
||||
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
|
||||
this.logger.debug(`Normalized: ${JSON.stringify({ country, state, city })}`);
|
||||
|
||||
return { country, state, city };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ISystemMetadataRepository } from '@app/domain/repositories/system-metadata.repository';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { SystemMetadata, SystemMetadataEntity } from '../entities';
|
||||
|
||||
export class SystemMetadataRepository implements ISystemMetadataRepository {
|
||||
constructor(
|
||||
@InjectRepository(SystemMetadataEntity)
|
||||
private repository: Repository<SystemMetadataEntity>,
|
||||
) {}
|
||||
async get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null> {
|
||||
const metadata = await this.repository.findOne({ where: { key } });
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return metadata.value as SystemMetadata[T];
|
||||
}
|
||||
|
||||
async set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void> {
|
||||
await this.repository.upsert({ key, value }, { conflictPaths: { key: true } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum DatabaseLock {
|
||||
GeodataImport = 100,
|
||||
}
|
||||
@@ -93,16 +93,6 @@ export class AppService {
|
||||
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
||||
});
|
||||
|
||||
process.on('uncaughtException', async (error: Error | any) => {
|
||||
const isCsvError = error.code === 'CSV_RECORD_INCONSISTENT_FIELDS_LENGTH';
|
||||
if (!isCsvError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.warn('Geocoding csv parse error, trying again without cache...');
|
||||
await this.metadataService.init(true);
|
||||
});
|
||||
|
||||
await this.metadataService.init();
|
||||
await this.searchService.init();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user