mirror of
https://github.com/immich-app/immich.git
synced 2025-06-10 21:38:30 +02:00
feat: locked/private view (#18268)
* feat: locked/private view * feat: locked/private view * pr feedback * fix: redirect loop * pr feedback
This commit is contained in:
parent
4935f3e0bb
commit
b7b0b9b6d8
61 changed files with 1018 additions and 186 deletions
server
src
controllers
database.tsdb.d.tsdtos
enum.tsqueries
repositories
schema
migrations
tables
services
album.service.spec.tsasset-media.service.spec.tsasset.service.spec.tsasset.service.tsauth.service.spec.tsauth.service.tsmetadata.service.spec.tsmetadata.service.tssession.service.spec.tsshared-link.service.spec.ts
utils
test
|
@ -101,4 +101,11 @@ export class AuthController {
|
|||
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
|
||||
return this.service.resetPinCode(auth, dto);
|
||||
}
|
||||
|
||||
@Post('pin-code/verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated()
|
||||
async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
|
||||
return this.service.verifyPinCode(auth, dto);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,7 +66,7 @@ describe(SearchController.name, () => {
|
|||
.send({ visibility: 'immich' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']),
|
||||
errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden, locked']),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -200,6 +200,7 @@ export type Album = Selectable<Albums> & {
|
|||
|
||||
export type AuthSession = {
|
||||
id: string;
|
||||
hasElevatedPermission: boolean;
|
||||
};
|
||||
|
||||
export type Partner = {
|
||||
|
@ -233,6 +234,7 @@ export type Session = {
|
|||
updatedAt: Date;
|
||||
deviceOS: string;
|
||||
deviceType: string;
|
||||
pinExpiresAt: Date | null;
|
||||
};
|
||||
|
||||
export type Exif = Omit<Selectable<DatabaseExif>, 'updatedAt' | 'updateId'>;
|
||||
|
@ -306,7 +308,7 @@ export const columns = {
|
|||
'users.quotaSizeInBytes',
|
||||
],
|
||||
authApiKey: ['api_keys.id', 'api_keys.permissions'],
|
||||
authSession: ['sessions.id', 'sessions.updatedAt'],
|
||||
authSession: ['sessions.id', 'sessions.updatedAt', 'sessions.pinExpiresAt'],
|
||||
authSharedLink: [
|
||||
'shared_links.id',
|
||||
'shared_links.userId',
|
||||
|
|
1
server/src/db.d.ts
vendored
1
server/src/db.d.ts
vendored
|
@ -347,6 +347,7 @@ export interface Sessions {
|
|||
updatedAt: Generated<Timestamp>;
|
||||
updateId: Generated<string>;
|
||||
userId: string;
|
||||
pinExpiresAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface SessionSyncCheckpoints {
|
||||
|
|
|
@ -43,6 +43,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||
isArchived!: boolean;
|
||||
isTrashed!: boolean;
|
||||
isOffline!: boolean;
|
||||
visibility!: AssetVisibility;
|
||||
exifInfo?: ExifResponseDto;
|
||||
tags?: TagResponseDto[];
|
||||
people?: PersonWithFacesResponseDto[];
|
||||
|
@ -184,6 +185,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||
isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false,
|
||||
isArchived: entity.visibility === AssetVisibility.ARCHIVE,
|
||||
isTrashed: !!entity.deletedAt,
|
||||
visibility: entity.visibility,
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
|
|
|
@ -138,4 +138,5 @@ export class OAuthAuthorizeResponseDto {
|
|||
export class AuthStatusResponseDto {
|
||||
pinCode!: boolean;
|
||||
password!: boolean;
|
||||
isElevated!: boolean;
|
||||
}
|
||||
|
|
|
@ -627,4 +627,5 @@ export enum AssetVisibility {
|
|||
* Video part of the LivePhotos and MotionPhotos
|
||||
*/
|
||||
HIDDEN = 'hidden',
|
||||
LOCKED = 'locked',
|
||||
}
|
||||
|
|
|
@ -98,6 +98,7 @@ from
|
|||
where
|
||||
"assets"."id" in ($1)
|
||||
and "assets"."ownerId" = $2
|
||||
and "assets"."visibility" != $3
|
||||
|
||||
-- AccessRepository.asset.checkPartnerAccess
|
||||
select
|
||||
|
|
|
@ -392,6 +392,11 @@ where
|
|||
order by
|
||||
"albums"."createdAt" desc
|
||||
|
||||
-- AlbumRepository.removeAssetsFromAll
|
||||
delete from "albums_assets_assets"
|
||||
where
|
||||
"albums_assets_assets"."assetsId" in ($1)
|
||||
|
||||
-- AlbumRepository.getAssetIds
|
||||
select
|
||||
*
|
||||
|
|
|
@ -12,6 +12,7 @@ where
|
|||
select
|
||||
"sessions"."id",
|
||||
"sessions"."updatedAt",
|
||||
"sessions"."pinExpiresAt",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
|
|
|
@ -168,7 +168,7 @@ class AssetAccess {
|
|||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, assetIds: Set<string>) {
|
||||
async checkOwnerAccess(userId: string, assetIds: Set<string>, hasElevatedPermission: boolean | undefined) {
|
||||
if (assetIds.size === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
@ -178,6 +178,7 @@ class AssetAccess {
|
|||
.select('assets.id')
|
||||
.where('assets.id', 'in', [...assetIds])
|
||||
.where('assets.ownerId', '=', userId)
|
||||
.$if(!hasElevatedPermission, (eb) => eb.where('assets.visibility', '!=', AssetVisibility.LOCKED))
|
||||
.execute()
|
||||
.then((assets) => new Set(assets.map((asset) => asset.id)));
|
||||
}
|
||||
|
|
|
@ -220,8 +220,10 @@ export class AlbumRepository {
|
|||
await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute();
|
||||
}
|
||||
|
||||
async removeAsset(assetId: string): Promise<void> {
|
||||
await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', '=', assetId).execute();
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@Chunked()
|
||||
async removeAssetsFromAll(assetIds: string[]): Promise<void> {
|
||||
await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', 'in', assetIds).execute();
|
||||
}
|
||||
|
||||
@Chunked({ paramIndex: 1 })
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TYPE "asset_visibility_enum" ADD VALUE IF NOT EXISTS 'locked';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// noop
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "sessions" ADD "pinExpiresAt" timestamp with time zone;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "sessions" DROP COLUMN "pinExpiresAt";`.execute(db);
|
||||
}
|
|
@ -36,4 +36,7 @@ export class SessionTable {
|
|||
|
||||
@UpdateIdColumn({ indexName: 'IDX_sessions_update_id' })
|
||||
updateId!: string;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||
pinExpiresAt!: Date | null;
|
||||
}
|
||||
|
|
|
@ -163,7 +163,7 @@ describe(AlbumService.name, () => {
|
|||
);
|
||||
|
||||
expect(mocks.user.get).toHaveBeenCalledWith('user-id', {});
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']), false);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('album.invite', {
|
||||
id: albumStub.empty.id,
|
||||
userId: 'user-id',
|
||||
|
@ -207,6 +207,7 @@ describe(AlbumService.name, () => {
|
|||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2']),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -688,7 +689,11 @@ describe(AlbumService.name, () => {
|
|||
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1']),
|
||||
false,
|
||||
);
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
|
|
|
@ -481,7 +481,11 @@ describe(AssetMediaService.name, () => {
|
|||
it('should require the asset.download permission', async () => {
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1']),
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
@ -512,7 +516,7 @@ describe(AssetMediaService.name, () => {
|
|||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
@ -611,7 +615,7 @@ describe(AssetMediaService.name, () => {
|
|||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
|
|
@ -122,6 +122,7 @@ describe(AssetService.name, () => {
|
|||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([assetStub.image.id]),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
|
||||
|
@ -125,6 +125,10 @@ export class AssetService extends BaseService {
|
|||
options.rating !== undefined
|
||||
) {
|
||||
await this.assetRepository.updateAll(ids, options);
|
||||
|
||||
if (options.visibility === AssetVisibility.LOCKED) {
|
||||
await this.albumRepository.removeAssetsFromAll(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -253,6 +253,7 @@ describe(AuthService.name, () => {
|
|||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
pinExpiresAt: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
|
@ -265,7 +266,7 @@ describe(AuthService.name, () => {
|
|||
}),
|
||||
).resolves.toEqual({
|
||||
user: sessionWithToken.user,
|
||||
session: { id: session.id },
|
||||
session: { id: session.id, hasElevatedPermission: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -376,6 +377,7 @@ describe(AuthService.name, () => {
|
|||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
pinExpiresAt: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
|
@ -388,7 +390,7 @@ describe(AuthService.name, () => {
|
|||
}),
|
||||
).resolves.toEqual({
|
||||
user: sessionWithToken.user,
|
||||
session: { id: session.id },
|
||||
session: { id: session.id, hasElevatedPermission: false },
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -398,6 +400,7 @@ describe(AuthService.name, () => {
|
|||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
pinExpiresAt: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
|
@ -417,6 +420,7 @@ describe(AuthService.name, () => {
|
|||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
pinExpiresAt: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
|
@ -916,13 +920,17 @@ describe(AuthService.name, () => {
|
|||
|
||||
describe('resetPinCode', () => {
|
||||
it('should reset the PIN code', async () => {
|
||||
const currentSession = factory.session();
|
||||
const user = factory.userAdmin();
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||
mocks.session.getByUserId.mockResolvedValue([currentSession]);
|
||||
mocks.session.update.mockResolvedValue(currentSession);
|
||||
|
||||
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null });
|
||||
});
|
||||
|
||||
it('should throw if the PIN code does not match', async () => {
|
||||
|
|
|
@ -126,6 +126,10 @@ export class AuthService extends BaseService {
|
|||
this.resetPinChecks(user, dto);
|
||||
|
||||
await this.userRepository.update(auth.user.id, { pinCode: null });
|
||||
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
|
||||
for (const session of sessions) {
|
||||
await this.sessionRepository.update(session.id, { pinExpiresAt: null });
|
||||
}
|
||||
}
|
||||
|
||||
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
|
||||
|
@ -444,10 +448,25 @@ export class AuthService extends BaseService {
|
|||
await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
// Pin check
|
||||
let hasElevatedPermission = false;
|
||||
|
||||
if (session.pinExpiresAt) {
|
||||
const pinExpiresAt = DateTime.fromJSDate(session.pinExpiresAt);
|
||||
hasElevatedPermission = pinExpiresAt > now;
|
||||
|
||||
if (hasElevatedPermission && now.plus({ minutes: 5 }) > pinExpiresAt) {
|
||||
await this.sessionRepository.update(session.id, {
|
||||
pinExpiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
session: {
|
||||
id: session.id,
|
||||
hasElevatedPermission,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -455,6 +474,23 @@ export class AuthService extends BaseService {
|
|||
throw new UnauthorizedException('Invalid user token');
|
||||
}
|
||||
|
||||
async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise<void> {
|
||||
const user = await this.userRepository.getForPinCode(auth.user.id);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
this.resetPinChecks(user, { pinCode: dto.pinCode });
|
||||
|
||||
if (!auth.session) {
|
||||
throw new BadRequestException('Session is missing');
|
||||
}
|
||||
|
||||
await this.sessionRepository.update(auth.session.id, {
|
||||
pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()),
|
||||
});
|
||||
}
|
||||
|
||||
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
|
||||
const key = this.cryptoRepository.newPassword(32);
|
||||
const token = this.cryptoRepository.hashSha256(key);
|
||||
|
@ -493,6 +529,7 @@ export class AuthService extends BaseService {
|
|||
return {
|
||||
pinCode: !!user.pinCode,
|
||||
password: !!user.password,
|
||||
isElevated: !!auth.session?.hasElevatedPermission,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1310,7 +1310,7 @@ describe(MetadataService.name, () => {
|
|||
expect(mocks.asset.update).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
|
||||
);
|
||||
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
|
||||
expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle not finding a match', async () => {
|
||||
|
@ -1331,7 +1331,7 @@ describe(MetadataService.name, () => {
|
|||
expect(mocks.asset.update).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
|
||||
);
|
||||
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
|
||||
expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should link photo and video', async () => {
|
||||
|
@ -1356,7 +1356,7 @@ describe(MetadataService.name, () => {
|
|||
id: assetStub.livePhotoMotionAsset.id,
|
||||
visibility: AssetVisibility.HIDDEN,
|
||||
});
|
||||
expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
||||
expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
|
||||
});
|
||||
|
||||
it('should notify clients on live photo link', async () => {
|
||||
|
|
|
@ -158,7 +158,7 @@ export class MetadataService extends BaseService {
|
|||
await Promise.all([
|
||||
this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
|
||||
this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }),
|
||||
this.albumRepository.removeAsset(motionAsset.id),
|
||||
this.albumRepository.removeAssetsFromAll([motionAsset.id]),
|
||||
]);
|
||||
|
||||
await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
|
||||
|
|
|
@ -34,6 +34,7 @@ describe('SessionService', () => {
|
|||
token: '420',
|
||||
userId: '42',
|
||||
updateId: 'uuid-v7',
|
||||
pinExpiresAt: null,
|
||||
},
|
||||
]);
|
||||
mocks.session.delete.mockResolvedValue();
|
||||
|
|
|
@ -156,6 +156,7 @@ describe(SharedLinkService.name, () => {
|
|||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([assetStub.image.id]),
|
||||
false,
|
||||
);
|
||||
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
|
@ -186,6 +187,7 @@ describe(SharedLinkService.name, () => {
|
|||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set([assetStub.image.id]),
|
||||
false,
|
||||
);
|
||||
expect(mocks.sharedLink.create).toHaveBeenCalledWith({
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
|
|
|
@ -81,7 +81,7 @@ const checkSharedLinkAccess = async (
|
|||
|
||||
case Permission.ASSET_SHARE: {
|
||||
// TODO: fix this to not use sharedLink.userId for access control
|
||||
return await access.asset.checkOwnerAccess(sharedLink.userId, ids);
|
||||
return await access.asset.checkOwnerAccess(sharedLink.userId, ids, false);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_READ: {
|
||||
|
@ -119,38 +119,38 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
|||
}
|
||||
|
||||
case Permission.ASSET_READ: {
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
|
||||
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_SHARE: {
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
|
||||
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_VIEW: {
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
|
||||
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_DOWNLOAD: {
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
|
||||
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_UPDATE: {
|
||||
return await access.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
|
||||
}
|
||||
|
||||
case Permission.ASSET_DELETE: {
|
||||
return await access.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
|
||||
}
|
||||
|
||||
case Permission.ALBUM_READ: {
|
||||
|
|
6
server/test/fixtures/auth.stub.ts
vendored
6
server/test/fixtures/auth.stub.ts
vendored
|
@ -1,4 +1,4 @@
|
|||
import { Session } from 'src/database';
|
||||
import { AuthSession } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
|
||||
const authUser = {
|
||||
|
@ -26,7 +26,7 @@ export const authStub = {
|
|||
user: authUser.user1,
|
||||
session: {
|
||||
id: 'token-id',
|
||||
} as Session,
|
||||
} as AuthSession,
|
||||
}),
|
||||
user2: Object.freeze<AuthDto>({
|
||||
user: {
|
||||
|
@ -39,7 +39,7 @@ export const authStub = {
|
|||
},
|
||||
session: {
|
||||
id: 'token-id',
|
||||
} as Session,
|
||||
} as AuthSession,
|
||||
}),
|
||||
adminSharedLink: Object.freeze({
|
||||
user: authUser.admin,
|
||||
|
|
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
|
@ -70,6 +70,7 @@ const assetResponse: AssetResponseDto = {
|
|||
isTrashed: false,
|
||||
libraryId: 'library-id',
|
||||
hasMetadata: true,
|
||||
visibility: AssetVisibility.TIMELINE,
|
||||
};
|
||||
|
||||
const assetResponseWithoutMetadata = {
|
||||
|
|
|
@ -58,7 +58,7 @@ const authFactory = ({
|
|||
}
|
||||
|
||||
if (session) {
|
||||
auth.session = { id: session.id };
|
||||
auth.session = { id: session.id, hasElevatedPermission: false };
|
||||
}
|
||||
|
||||
if (sharedLink) {
|
||||
|
@ -127,6 +127,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
|
|||
deviceType: 'mobile',
|
||||
token: 'abc123',
|
||||
userId: newUuid(),
|
||||
pinExpiresAt: newDate(),
|
||||
...session,
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue