mirror of
https://github.com/immich-app/immich.git
synced 2025-12-29 17:25:00 +03: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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user