mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 20:29:05 +00:00
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:
@@ -4263,6 +4263,23 @@
|
||||
"get": {
|
||||
"operationId": "getMySharedLink",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "password",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"example": "password",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
@@ -7910,6 +7927,9 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"showMetadata": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
@@ -7943,6 +7963,9 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"showMetadata": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -7985,9 +8008,17 @@
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"showMetadata": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/components/schemas/SharedLinkType"
|
||||
},
|
||||
@@ -7999,6 +8030,7 @@
|
||||
"type",
|
||||
"id",
|
||||
"description",
|
||||
"password",
|
||||
"userId",
|
||||
"key",
|
||||
"createdAt",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -111,6 +111,34 @@ describe(`${PartnerController.name} (e2e)`, () => {
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidShareKey);
|
||||
});
|
||||
|
||||
it('should return unauthorized for password protected link', async () => {
|
||||
const passwordProtectedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: album.id,
|
||||
password: 'foo',
|
||||
});
|
||||
|
||||
const { status, body } = await request(server).get('/shared-link/me').query({ key: passwordProtectedLink.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidSharePassword);
|
||||
});
|
||||
|
||||
it('should get data for correct password protected link', async () => {
|
||||
const passwordProtectedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: album.id,
|
||||
password: 'foo',
|
||||
});
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: passwordProtectedLink.key, password: 'foo' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /shared-link/:id', () => {
|
||||
|
||||
5
server/test/fixtures/error.stub.ts
vendored
5
server/test/fixtures/error.stub.ts
vendored
@@ -24,6 +24,11 @@ export const errorStub = {
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
},
|
||||
invalidSharePassword: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid password',
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
|
||||
23
server/test/fixtures/shared-link.stub.ts
vendored
23
server/test/fixtures/shared-link.stub.ts
vendored
@@ -132,6 +132,7 @@ export const sharedLinkStub = {
|
||||
album: undefined,
|
||||
albumId: null,
|
||||
description: null,
|
||||
password: null,
|
||||
assets: [],
|
||||
} as SharedLinkEntity),
|
||||
expired: Object.freeze({
|
||||
@@ -146,6 +147,7 @@ export const sharedLinkStub = {
|
||||
allowDownload: true,
|
||||
showExif: true,
|
||||
description: null,
|
||||
password: null,
|
||||
albumId: null,
|
||||
assets: [],
|
||||
} as SharedLinkEntity),
|
||||
@@ -161,6 +163,7 @@ export const sharedLinkStub = {
|
||||
allowDownload: false,
|
||||
showExif: false,
|
||||
description: null,
|
||||
password: null,
|
||||
assets: [],
|
||||
albumId: 'album-123',
|
||||
album: {
|
||||
@@ -254,6 +257,22 @@ export const sharedLinkStub = {
|
||||
],
|
||||
},
|
||||
}),
|
||||
passwordRequired: Object.freeze<SharedLinkEntity>({
|
||||
id: '123',
|
||||
userId: authStub.admin.id,
|
||||
user: userStub.admin,
|
||||
key: sharedLinkBytes,
|
||||
type: SharedLinkType.ALBUM,
|
||||
createdAt: today,
|
||||
expiresAt: tomorrow,
|
||||
allowUpload: true,
|
||||
allowDownload: true,
|
||||
showExif: true,
|
||||
description: null,
|
||||
password: 'password',
|
||||
assets: [],
|
||||
albumId: null,
|
||||
}),
|
||||
};
|
||||
|
||||
export const sharedLinkResponseStub = {
|
||||
@@ -263,6 +282,7 @@ export const sharedLinkResponseStub = {
|
||||
assets: [],
|
||||
createdAt: today,
|
||||
description: null,
|
||||
password: null,
|
||||
expiresAt: tomorrow,
|
||||
id: '123',
|
||||
key: sharedLinkBytes.toString('base64url'),
|
||||
@@ -277,6 +297,7 @@ export const sharedLinkResponseStub = {
|
||||
assets: [],
|
||||
createdAt: today,
|
||||
description: null,
|
||||
password: null,
|
||||
expiresAt: yesterday,
|
||||
id: '123',
|
||||
key: sharedLinkBytes.toString('base64url'),
|
||||
@@ -292,6 +313,7 @@ export const sharedLinkResponseStub = {
|
||||
createdAt: today,
|
||||
expiresAt: tomorrow,
|
||||
description: null,
|
||||
password: null,
|
||||
allowUpload: false,
|
||||
allowDownload: false,
|
||||
showMetadata: true,
|
||||
@@ -306,6 +328,7 @@ export const sharedLinkResponseStub = {
|
||||
createdAt: today,
|
||||
expiresAt: tomorrow,
|
||||
description: null,
|
||||
password: null,
|
||||
allowUpload: false,
|
||||
allowDownload: false,
|
||||
showMetadata: false,
|
||||
|
||||
Reference in New Issue
Block a user