feat: locked/private view ()

* 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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 1018 additions and 186 deletions

View file

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

View file

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

View file

@ -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
View file

@ -347,6 +347,7 @@ export interface Sessions {
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
userId: string;
pinExpiresAt: Timestamp | null;
}
export interface SessionSyncCheckpoints {

View file

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

View file

@ -138,4 +138,5 @@ export class OAuthAuthorizeResponseDto {
export class AuthStatusResponseDto {
pinCode!: boolean;
password!: boolean;
isElevated!: boolean;
}

View file

@ -627,4 +627,5 @@ export enum AssetVisibility {
* Video part of the LivePhotos and MotionPhotos
*/
HIDDEN = 'hidden',
LOCKED = 'locked',
}

View file

@ -98,6 +98,7 @@ from
where
"assets"."id" in ($1)
and "assets"."ownerId" = $2
and "assets"."visibility" != $3
-- AccessRepository.asset.checkPartnerAccess
select

View file

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

View file

@ -12,6 +12,7 @@ where
select
"sessions"."id",
"sessions"."updatedAt",
"sessions"."pinExpiresAt",
(
select
to_json(obj)

View file

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

View file

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

View file

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

View file

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

View file

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

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,

View file

@ -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: {

View file

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

View file

@ -70,6 +70,7 @@ const assetResponse: AssetResponseDto = {
isTrashed: false,
libraryId: 'library-id',
hasMetadata: true,
visibility: AssetVisibility.TIMELINE,
};
const assetResponseWithoutMetadata = {

View file

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