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:
Alex
2025-05-15 09:35:21 -06:00
committed by GitHub
parent 4935f3e0bb
commit b7b0b9b6d8
61 changed files with 1018 additions and 186 deletions

View File

@@ -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']));
});

View File

@@ -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']));
});

View File

@@ -122,6 +122,7 @@ describe(AssetService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
undefined,
);
});

View File

@@ -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);
}
}
}

View File

@@ -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 () => {

View File

@@ -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,
};
}
}

View File

@@ -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 () => {

View File

@@ -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 });

View File

@@ -34,6 +34,7 @@ describe('SessionService', () => {
token: '420',
userId: '42',
updateId: 'uuid-v7',
pinExpiresAt: null,
},
]);
mocks.session.delete.mockResolvedValue();

View File

@@ -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,