feat(server,web,mobile): Add optional password option for share links. (#4655)

* feat(server,web,mobile): Add optional password option for share links.

Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com>

* feat(server,web): Update shared-link.controller and page.svelte for improved cookie handling and metadata updates.

Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com>

---------

Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com>
This commit is contained in:
jarvis2f
2023-10-29 09:35:38 +08:00
committed by GitHub
parent b34cbd881a
commit 8a6889529c
33 changed files with 556 additions and 41 deletions

View File

@@ -4,6 +4,7 @@ export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
export const IMMICH_API_KEY_NAME = 'api_key';
export const IMMICH_API_KEY_HEADER = 'x-api-key';
export const IMMICH_SHARED_LINK_ACCESS_COOKIE = 'immich_shared_link_token';
export enum AuthType {
PASSWORD = 'password',
OAUTH = 'oauth',

View File

@@ -7,6 +7,8 @@ import { AssetResponseDto, mapAsset } from '../asset';
export class SharedLinkResponseDto {
id!: string;
description!: string | null;
password!: string | null;
token?: string | null;
userId!: string;
key!: string;
@@ -31,6 +33,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
return {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'),
type: sharedLink.type,
@@ -53,6 +56,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
return {
id: sharedLink.id,
description: sharedLink.description,
password: sharedLink.password,
userId: sharedLink.userId,
key: sharedLink.key.toString('base64url'),
type: sharedLink.type,

View File

@@ -19,6 +19,10 @@ export class SharedLinkCreateDto {
@Optional()
description?: string;
@IsString()
@Optional()
password?: string;
@IsDate()
@Type(() => Date)
@Optional({ nullable: true })
@@ -41,6 +45,9 @@ export class SharedLinkEditDto {
@Optional()
description?: string;
@Optional()
password?: string;
@Optional({ nullable: true })
expiresAt?: Date | null;
@@ -62,3 +69,14 @@ export class SharedLinkEditDto {
@IsBoolean()
changeExpiryTime?: boolean;
}
export class SharedLinkPasswordDto {
@IsString()
@Optional()
@ApiProperty({ example: 'password' })
password?: string;
@IsString()
@Optional()
token?: string;
}

View File

@@ -1,5 +1,5 @@
import { SharedLinkType } from '@app/infra/entities';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import {
IAccessRepositoryMock,
albumStub,
@@ -48,21 +48,28 @@ describe(SharedLinkService.name, () => {
describe('getMine', () => {
it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin)).rejects.toBeInstanceOf(ForbiddenException);
await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink;
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.valid);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
it('should not return metadata', async () => {
const authDto = authStub.adminSharedLinkNoExif;
shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
it('should throw an error for an password protected shared link', async () => {
const authDto = authStub.adminSharedLink;
shareMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
});

View File

@@ -1,11 +1,11 @@
import { AssetEntity, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { AccessCore, Permission } from '../access';
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
import { AuthUserDto } from '../auth';
import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories';
import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto';
import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from './shared-link.dto';
@Injectable()
export class SharedLinkService {
@@ -23,7 +23,7 @@ export class SharedLinkService {
return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
}
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
async getMine(authUser: AuthUserDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
const { sharedLinkId: id, isPublicUser, isShowMetadata: isShowExif } = authUser;
if (!isPublicUser || !id) {
@@ -32,7 +32,15 @@ export class SharedLinkService {
const sharedLink = await this.findOrFail(authUser, id);
return this.map(sharedLink, { withExif: isShowExif ?? true });
let newToken;
if (sharedLink.password) {
newToken = this.validateAndRefreshToken(sharedLink, dto);
}
return {
...this.map(sharedLink, { withExif: isShowExif ?? true }),
token: newToken,
};
}
async get(authUser: AuthUserDto, id: string): Promise<SharedLinkResponseDto> {
@@ -66,6 +74,7 @@ export class SharedLinkService {
albumId: dto.albumId || null,
assets: (dto.assetIds || []).map((id) => ({ id }) as AssetEntity),
description: dto.description || null,
password: dto.password,
expiresAt: dto.expiresAt || null,
allowUpload: dto.allowUpload ?? true,
allowDownload: dto.allowDownload ?? true,
@@ -81,6 +90,7 @@ export class SharedLinkService {
id,
userId: authUser.id,
description: dto.description,
password: dto.password,
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
allowUpload: dto.allowUpload,
allowDownload: dto.allowDownload,
@@ -159,4 +169,17 @@ export class SharedLinkService {
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
}
private validateAndRefreshToken(sharedLink: SharedLinkEntity, dto: SharedLinkPasswordDto): string {
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
const sharedLinkTokens = dto.token?.split(',') || [];
if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) {
throw new UnauthorizedException('Invalid password');
}
if (!sharedLinkTokens.includes(token)) {
sharedLinkTokens.push(token);
}
return sharedLinkTokens.join(',');
}
}

View File

@@ -2,13 +2,16 @@ import {
AssetIdsDto,
AssetIdsResponseDto,
AuthUserDto,
IMMICH_SHARED_LINK_ACCESS_COOKIE,
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkPasswordDto,
SharedLinkResponseDto,
SharedLinkService,
} from '@app/domain';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
@@ -27,8 +30,25 @@ export class SharedLinkController {
@SharedLinkRoute()
@Get('me')
getMySharedLink(@AuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
return this.service.getMine(authUser);
async getMySharedLink(
@AuthUser() authUser: AuthUserDto,
@Query() dto: SharedLinkPasswordDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<SharedLinkResponseDto> {
const sharedLinkToken = req.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE];
if (sharedLinkToken) {
dto.token = sharedLinkToken;
}
const sharedLinkResponse = await this.service.getMine(authUser, dto);
if (sharedLinkResponse.token) {
res.cookie(IMMICH_SHARED_LINK_ACCESS_COOKIE, sharedLinkResponse.token, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24),
httpOnly: true,
sameSite: 'lax',
});
}
return sharedLinkResponse;
}
@Get(':id')

View File

@@ -21,6 +21,9 @@ export class SharedLinkEntity {
@Column({ type: 'varchar', nullable: true })
description!: string | null;
@Column({ type: 'varchar', nullable: true })
password!: string | null;
@Column()
userId!: string;

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddPasswordToSharedLinks1698290827089 implements MigrationInterface {
name = 'AddPasswordToSharedLinks1698290827089'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" ADD "password" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "password"`);
}
}