feat: readonly album sharing (#8720)

* rename albums_shared_users_users to album_permissions and add readonly column

* disable synchronize on the original join table

* remove unnecessary FK names

* set readonly=true as default for new album shares

* separate and implement album READ and WRITE permission

* expose albumPermissions on the API, deprecate sharedUsers

* generate openapi

* create readonly view on frontend

* ??? move slideshow button out from ellipsis menu so that non-owners can have access too

* correct sharedUsers joins

* add album permission repository

* remove a log

* fix assetCount getting reset when adding users

* fix lint

* add set permission endpoint and UI

* sort users

* remove log

* Revert "??? move slideshow button out from ellipsis menu so that non-owners can have access too"

This reverts commit 1343bfa311.

* rename stuff

* fix db schema annotations

* sql generate

* change readonly default to follow migration

* fix deprecation notice

* change readonly boolean to role enum

* fix joincolumn as primary key

* rename albumUserRepository in album service

* clean up userId and albumId

* add write access to shared link

* fix existing tests

* switch to vitest

* format and fix tests on web

* add new test

* fix one e2e test

* rename new API field to albumUsers

* capitalize serverside enum

* remove unused ReadWrite type

* missed rename from previous commit

* rename to albumUsers in album entity as well

* remove outdated Equals calls

* unnecessary relation

* rename to updateUser in album service

* minor renamery

* move sorting to backend

* rename and separate ALBUM_WRITE as ADD_ASSET and REMOVE_ASSET

* fix tests

* fix "should migrate single moving picture" test failing on European system timezone

* generated changes after merge

* lint fix

* fix correct page to open after removing user from album

* fix e2e tests and some bugs

* rename updateAlbumUser rest endpoint

* add new e2e tests for updateAlbumUser endpoint

* small optimizations

* refactor album e2e test, add new album shared with viewer

* add new test to check if viewer can see the album

* add new e2e tests for readonly share

* failing test: User delete doesn't cascade to UserAlbum entity

* fix: handle deleted users

* use lodash for sort

* add role to addUsersToAlbum endpoint

* add UI for adding editors

* lint fixes

* change role back to editor as DB default

* fix server tests

* redesign user selection modal editor selector

* style tweaks

* fix type error

* Revert "style tweaks"

This reverts commit ab604f4c8f.

* Revert "redesign user selection modal editor selector"

This reverts commit e6f344856c.

* chore: cleanup and improve add user modal

* chore: open api

* small styling

---------

Co-authored-by: mgabor <>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
mgabor
2024-04-25 06:19:49 +02:00
committed by GitHub
parent 0b3373c552
commit 2943f93098
56 changed files with 1778 additions and 370 deletions

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { ActivityEntity } from 'src/entities/activity.entity';
import { AlbumUserRole } from 'src/entities/album-user.entity';
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
@@ -81,12 +82,13 @@ class ActivityAccess implements IActivityAccess {
return this.albumRepository
.createQueryBuilder('album')
.select('album.id')
.leftJoin('album.sharedUsers', 'sharedUsers')
.leftJoin('album.albumUsers', 'album_albumUsers_users')
.leftJoin('album_albumUsers_users.user', 'albumUsers')
.where('album.id IN (:...albumIds)', { albumIds: [...albumIds] })
.andWhere('album.isActivityEnabled = true')
.andWhere(
new Brackets((qb) => {
qb.where('album.ownerId = :userId', { userId }).orWhere('sharedUsers.id = :userId', { userId });
qb.where('album.ownerId = :userId', { userId }).orWhere('albumUsers.id = :userId', { userId });
}),
)
.getMany()
@@ -120,7 +122,7 @@ class AlbumAccess implements IAlbumAccess {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkSharedAlbumAccess(userId: string, albumIds: Set<string>): Promise<Set<string>> {
async checkSharedAlbumAccess(userId: string, albumIds: Set<string>, access: AlbumUserRole): Promise<Set<string>> {
if (albumIds.size === 0) {
return new Set();
}
@@ -130,8 +132,11 @@ class AlbumAccess implements IAlbumAccess {
select: { id: true },
where: {
id: In([...albumIds]),
sharedUsers: {
id: userId,
albumUsers: {
user: { id: userId },
// If editor access is needed we check for it, otherwise both are accepted
role:
access === AlbumUserRole.EDITOR ? AlbumUserRole.EDITOR : In([AlbumUserRole.EDITOR, AlbumUserRole.VIEWER]),
},
},
})
@@ -177,7 +182,8 @@ class AssetAccess implements IAssetAccess {
return this.albumRepository
.createQueryBuilder('album')
.innerJoin('album.assets', 'asset')
.leftJoin('album.sharedUsers', 'sharedUsers')
.leftJoin('album.albumUsers', 'album_albumUsers_users')
.leftJoin('album_albumUsers_users.user', 'albumUsers')
.select('asset.id', 'assetId')
.addSelect('asset.livePhotoVideoId', 'livePhotoVideoId')
.where('array["asset"."id", "asset"."livePhotoVideoId"] && array[:...assetIds]::uuid[]', {
@@ -185,7 +191,7 @@ class AssetAccess implements IAssetAccess {
})
.andWhere(
new Brackets((qb) => {
qb.where('album.ownerId = :userId', { userId }).orWhere('sharedUsers.id = :userId', { userId });
qb.where('album.ownerId = :userId', { userId }).orWhere('albumUsers.id = :userId', { userId });
}),
)
.getRawMany()

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AlbumPermissionId, IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class AlbumUserRepository implements IAlbumUserRepository {
constructor(@InjectRepository(AlbumUserEntity) private repository: Repository<AlbumUserEntity>) {}
async create(albumUser: Partial<AlbumUserEntity>): Promise<AlbumUserEntity> {
const { userId, albumId } = await this.repository.save(albumUser);
return this.repository.findOneOrFail({ where: { userId, albumId } });
}
async update({ userId, albumId }: AlbumPermissionId, dto: Partial<AlbumUserEntity>): Promise<AlbumUserEntity> {
await this.repository.update({ userId, albumId }, dto);
return this.repository.findOneOrFail({
where: { userId, albumId },
});
}
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
await this.repository.delete({ userId, albumId });
}
}

View File

@@ -10,6 +10,13 @@ import { Instrumentation } from 'src/utils/instrumentation';
import { setUnion } from 'src/utils/set';
import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
const withoutDeletedUsers = <T extends AlbumEntity | null>(album: T) => {
if (album) {
album.albumUsers = album.albumUsers.filter((albumUser) => albumUser.user && !albumUser.user.deletedAt);
}
return album;
};
@Instrumentation()
@Injectable()
export class AlbumRepository implements IAlbumRepository {
@@ -20,10 +27,10 @@ export class AlbumRepository implements IAlbumRepository {
) {}
@GenerateSql({ params: [DummyValue.UUID, {}] })
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> {
async getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null> {
const relations: FindOptionsRelations<AlbumEntity> = {
owner: true,
sharedUsers: true,
albumUsers: { user: true },
assets: false,
sharedLinks: true,
};
@@ -40,33 +47,38 @@ export class AlbumRepository implements IAlbumRepository {
};
}
return this.repository.findOne({ where: { id }, relations, order });
const album = await this.repository.findOne({ where: { id }, relations, order });
return withoutDeletedUsers(album);
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByIds(ids: string[]): Promise<AlbumEntity[]> {
return this.repository.find({
async getByIds(ids: string[]): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
where: {
id: In(ids),
},
relations: {
owner: true,
sharedUsers: true,
albumUsers: { user: true },
},
});
return albums.map((album) => withoutDeletedUsers(album));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
return this.repository.find({
async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
where: [
{ ownerId, assets: { id: assetId } },
{ sharedUsers: { id: ownerId }, assets: { id: assetId } },
{ albumUsers: { userId: ownerId }, assets: { id: assetId } },
],
relations: { owner: true, sharedUsers: true },
relations: { owner: true, albumUsers: { user: true } },
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@@ -127,40 +139,46 @@ export class AlbumRepository implements IAlbumRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getOwned(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
async getOwned(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
relations: { albumUsers: { user: true }, sharedLinks: true, owner: true },
where: { ownerId },
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
/**
* Get albums shared with and shared by owner.
*/
@GenerateSql({ params: [DummyValue.UUID] })
getShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
async getShared(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
relations: { albumUsers: { user: true }, sharedLinks: true, owner: true },
where: [
{ sharedUsers: { id: ownerId } },
{ albumUsers: { userId: ownerId } },
{ sharedLinks: { userId: ownerId } },
{ ownerId, sharedUsers: { id: Not(IsNull()) } },
{ ownerId, albumUsers: { user: Not(IsNull()) } },
],
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
/**
* Get albums of owner that are _not_ shared
*/
@GenerateSql({ params: [DummyValue.UUID] })
getNotShared(ownerId: string): Promise<AlbumEntity[]> {
return this.repository.find({
relations: { sharedUsers: true, sharedLinks: true, owner: true },
where: { ownerId, sharedUsers: { id: IsNull() }, sharedLinks: { id: IsNull() } },
async getNotShared(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
relations: { albumUsers: true, sharedLinks: true, owner: true },
where: { ownerId, albumUsers: { user: IsNull() }, sharedLinks: { id: IsNull() } },
order: { createdAt: 'DESC' },
});
return albums.map((album) => withoutDeletedUsers(album));
}
async restoreAll(userId: string): Promise<void> {
@@ -282,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository {
where: { id },
relations: {
owner: true,
sharedUsers: true,
albumUsers: { user: true },
sharedLinks: true,
assets: true,
},

View File

@@ -1,5 +1,6 @@
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
@@ -31,6 +32,7 @@ import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
@@ -65,6 +67,7 @@ export const repositories = [
{ provide: IActivityRepository, useClass: ActivityRepository },
{ provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
{ provide: IAssetStackRepository, useClass: AssetStackRepository },