1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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:
jarvis2f 2023-10-29 09:35:38 +08:00 committed by GitHub
parent b34cbd881a
commit 8a6889529c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 448 additions and 32 deletions

View File

@ -3038,6 +3038,12 @@ export interface SharedLinkCreateDto {
* @memberof SharedLinkCreateDto
*/
'expiresAt'?: string | null;
/**
*
* @type {string}
* @memberof SharedLinkCreateDto
*/
'password'?: string;
/**
*
* @type {boolean}
@ -3089,6 +3095,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto
*/
'expiresAt'?: string | null;
/**
*
* @type {string}
* @memberof SharedLinkEditDto
*/
'password'?: string;
/**
*
* @type {boolean}
@ -3156,12 +3168,24 @@ export interface SharedLinkResponseDto {
* @memberof SharedLinkResponseDto
*/
'key': string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'password': string | null;
/**
*
* @type {boolean}
* @memberof SharedLinkResponseDto
*/
'showMetadata': boolean;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'token'?: string | null;
/**
*
* @type {SharedLinkType}
@ -13690,11 +13714,13 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
},
/**
*
* @param {string} [password]
* @param {string} [token]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMySharedLink: async (key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getMySharedLink: async (password?: string, token?: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/shared-link/me`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -13716,6 +13742,14 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (password !== undefined) {
localVarQueryParameter['password'] = password;
}
if (token !== undefined) {
localVarQueryParameter['token'] = token;
}
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
@ -13959,12 +13993,14 @@ export const SharedLinkApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [password]
* @param {string} [token]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMySharedLink(key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(key, options);
async getMySharedLink(password?: string, token?: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(password, token, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -14053,7 +14089,7 @@ export const SharedLinkApiFactory = function (configuration?: Configuration, bas
* @throws {RequiredError}
*/
getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.getMySharedLink(requestParameters.key, options).then((request) => request(axios, basePath));
return localVarFp.getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(axios, basePath));
},
/**
*
@ -14142,6 +14178,20 @@ export interface SharedLinkApiCreateSharedLinkRequest {
* @interface SharedLinkApiGetMySharedLinkRequest
*/
export interface SharedLinkApiGetMySharedLinkRequest {
/**
*
* @type {string}
* @memberof SharedLinkApiGetMySharedLink
*/
readonly password?: string
/**
*
* @type {string}
* @memberof SharedLinkApiGetMySharedLink
*/
readonly token?: string
/**
*
* @type {string}
@ -14274,7 +14324,7 @@ export class SharedLinkApi extends BaseAPI {
* @memberof SharedLinkApi
*/
public getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig) {
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.key, options).then((request) => request(this.axios, this.basePath));
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -311,6 +311,8 @@
"shared_link_edit_change_expiry": "Change expiration time",
"shared_link_edit_description": "Description",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_password": "Password",
"shared_link_edit_password_hint": "Enter the share password",
"shared_link_edit_show_meta": "Show metadata",
"shared_link_edit_submit_button": "Update link",
"shared_link_empty": "You don't have any shared links",

View File

@ -9,6 +9,7 @@ class SharedLink {
final bool allowUpload;
final String? thumbAssetId;
final String? description;
final String? password;
final DateTime? expiresAt;
final String key;
final bool showMetadata;
@ -21,6 +22,7 @@ class SharedLink {
required this.allowUpload,
required this.thumbAssetId,
required this.description,
required this.password,
required this.expiresAt,
required this.key,
required this.showMetadata,
@ -34,6 +36,7 @@ class SharedLink {
bool? allowDownload,
bool? allowUpload,
String? description,
String? password,
DateTime? expiresAt,
String? key,
bool? showMetadata,
@ -46,6 +49,7 @@ class SharedLink {
allowDownload: allowDownload ?? this.allowDownload,
allowUpload: allowUpload ?? this.allowUpload,
description: description ?? this.description,
password: password ?? this.password,
expiresAt: expiresAt ?? this.expiresAt,
key: key ?? this.key,
showMetadata: showMetadata ?? this.showMetadata,
@ -58,6 +62,7 @@ class SharedLink {
allowDownload = dto.allowDownload,
allowUpload = dto.allowUpload,
description = dto.description,
password = dto.password,
expiresAt = dto.expiresAt,
key = dto.key,
showMetadata = dto.showMetadata,
@ -75,7 +80,7 @@ class SharedLink {
@override
String toString() =>
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
@override
bool operator ==(Object other) =>
@ -87,6 +92,7 @@ class SharedLink {
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
other.description == description &&
other.password == password &&
other.expiresAt == expiresAt &&
other.key == key &&
other.showMetadata == showMetadata &&
@ -100,6 +106,7 @@ class SharedLink {
allowDownload.hashCode ^
allowUpload.hashCode ^
description.hashCode ^
password.hashCode ^
expiresAt.hashCode ^
key.hashCode ^
showMetadata.hashCode ^

View File

@ -40,6 +40,7 @@ class SharedLinkService {
required bool allowDownload,
required bool allowUpload,
String? description,
String? password,
String? albumId,
List<String>? assetIds,
DateTime? expiresAt,
@ -57,6 +58,7 @@ class SharedLinkService {
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
);
} else if (assetIds != null) {
dto = SharedLinkCreateDto(
@ -66,6 +68,7 @@ class SharedLinkService {
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
assetIds: assetIds,
);
}
@ -90,6 +93,7 @@ class SharedLinkService {
required bool? allowUpload,
bool? changeExpiry = false,
String? description,
String? password,
DateTime? expiresAt,
}) async {
try {
@ -101,6 +105,7 @@ class SharedLinkService {
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
changeExpiryTime: changeExpiry,
),
);

View File

@ -30,6 +30,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
final descriptionController =
useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final passwordController =
useTextEditingController(text: existingLink?.password ?? "");
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
@ -113,6 +115,31 @@ class SharedLinkEditPage extends HookConsumerWidget {
);
}
Widget buildPasswordField() {
return TextField(
controller: passwordController,
enabled: newShareLink.value.isEmpty,
autofocus: false,
decoration: InputDecoration(
labelText: 'shared_link_edit_password'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: themeData.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_password_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
),
),
);
}
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
@ -229,7 +256,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
void copyLinkToClipboard() {
Clipboard.setData(
ClipboardData(
text: newShareLink.value,
text: passwordController.text.isEmpty
? newShareLink.value
: "Link: ${newShareLink.value}\nPassword: ${passwordController.text}",
),
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
@ -302,6 +331,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
description: descriptionController.text.isEmpty
? null
: descriptionController.text,
password: passwordController.text.isEmpty
? null
: passwordController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
@ -324,6 +356,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? upload;
bool? meta;
String? desc;
String? password;
DateTime? expiry;
bool? changeExpiry;
@ -343,6 +376,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
desc = descriptionController.text;
}
if (passwordController.text != existingLink!.password) {
password = passwordController.text;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
changeExpiry = true;
@ -354,6 +391,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
allowDownload: download,
allowUpload: upload,
description: desc,
password: password,
expiresAt: expiry,
changeExpiry: changeExpiry,
);
@ -385,6 +423,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
padding: const EdgeInsets.all(padding),
child: buildDescriptionField(),
),
Padding(
padding: const EdgeInsets.all(padding),
child: buildPasswordField(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

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"`);
}
}

View File

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

View File

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

View File

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

View File

@ -3038,6 +3038,12 @@ export interface SharedLinkCreateDto {
* @memberof SharedLinkCreateDto
*/
'expiresAt'?: string | null;
/**
*
* @type {string}
* @memberof SharedLinkCreateDto
*/
'password'?: string;
/**
*
* @type {boolean}
@ -3089,6 +3095,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto
*/
'expiresAt'?: string | null;
/**
*
* @type {string}
* @memberof SharedLinkEditDto
*/
'password'?: string;
/**
*
* @type {boolean}
@ -3156,12 +3168,24 @@ export interface SharedLinkResponseDto {
* @memberof SharedLinkResponseDto
*/
'key': string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'password': string | null;
/**
*
* @type {boolean}
* @memberof SharedLinkResponseDto
*/
'showMetadata': boolean;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'token'?: string | null;
/**
*
* @type {SharedLinkType}
@ -13690,11 +13714,13 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
},
/**
*
* @param {string} [password]
* @param {string} [token]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMySharedLink: async (key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getMySharedLink: async (password?: string, token?: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/shared-link/me`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -13716,6 +13742,14 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (password !== undefined) {
localVarQueryParameter['password'] = password;
}
if (token !== undefined) {
localVarQueryParameter['token'] = token;
}
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
@ -13959,12 +13993,14 @@ export const SharedLinkApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [password]
* @param {string} [token]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMySharedLink(key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(key, options);
async getMySharedLink(password?: string, token?: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(password, token, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -14053,7 +14089,7 @@ export const SharedLinkApiFactory = function (configuration?: Configuration, bas
* @throws {RequiredError}
*/
getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.getMySharedLink(requestParameters.key, options).then((request) => request(axios, basePath));
return localVarFp.getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(axios, basePath));
},
/**
*
@ -14142,6 +14178,20 @@ export interface SharedLinkApiCreateSharedLinkRequest {
* @interface SharedLinkApiGetMySharedLinkRequest
*/
export interface SharedLinkApiGetMySharedLinkRequest {
/**
*
* @type {string}
* @memberof SharedLinkApiGetMySharedLink
*/
readonly password?: string
/**
*
* @type {string}
* @memberof SharedLinkApiGetMySharedLink
*/
readonly token?: string
/**
*
* @type {string}
@ -14274,7 +14324,7 @@ export class SharedLinkApi extends BaseAPI {
* @memberof SharedLinkApi
*/
public getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig) {
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.key, options).then((request) => request(this.axios, this.basePath));
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**

View File

@ -24,6 +24,7 @@
let allowUpload = false;
let showMetadata = true;
let expirationTime = '';
let password = '';
let shouldChangeExpirationTime = false;
let canCopyImagesToClipboard = true;
const dispatch = createEventDispatcher();
@ -40,6 +41,9 @@
if (editingLink.description) {
description = editingLink.description;
}
if (editingLink.password) {
password = editingLink.password;
}
allowUpload = editingLink.allowUpload;
allowDownload = editingLink.allowDownload;
showMetadata = editingLink.showMetadata;
@ -66,6 +70,7 @@
expiresAt: expirationDate,
allowUpload,
description,
password,
allowDownload,
showMetadata,
},
@ -81,7 +86,7 @@
return;
}
await copyToClipboard(sharedLink);
await copyToClipboard(password ? `Link: ${sharedLink}\nPassword: ${password}` : sharedLink);
};
const getExpirationTimeInMillisecond = () => {
@ -119,6 +124,7 @@
id: editingLink.id,
sharedLinkEditDto: {
description,
password,
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload,
allowDownload,
@ -178,12 +184,16 @@
<div class="mb-2 mt-4">
<p class="text-xs">LINK OPTIONS</p>
</div>
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40">
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 max-h-[330px] overflow-y-scroll">
<div class="flex flex-col">
<div class="mb-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label="Description" bind:value={description} />
</div>
<div class="mb-2">
<SettingInputField inputType={SettingInputFieldType.TEXT} label="Password" bind:value={password} />
</div>
<div class="my-3">
<SettingSwitch bind:checked={showMetadata} title={'Show metadata'} />
</div>

View File

@ -2,12 +2,14 @@ import featurePanelUrl from '$lib/assets/feature-panel.png';
import { api as clientApi, ThumbnailFormat } from '@api';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { AxiosError } from 'axios';
export const load = (async ({ params, locals: { api } }) => {
export const load = (async ({ params, locals: { api }, cookies }) => {
const { key } = params;
const token = cookies.get('immich_shared_link_token');
try {
const { data: sharedLink } = await api.sharedLinkApi.getMySharedLink({ key });
const { data: sharedLink } = await api.sharedLinkApi.getMySharedLink({ key, token });
const assetCount = sharedLink.assets.length;
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
@ -23,6 +25,17 @@ export const load = (async ({ params, locals: { api } }) => {
},
};
} catch (e) {
// handle unauthorized error
if ((e as AxiosError).response?.status === 401) {
return {
passwordRequired: true,
sharedLinkKey: key,
meta: {
title: 'Password Required',
},
};
}
throw error(404, {
message: 'Invalid shared link',
});

View File

@ -1,20 +1,79 @@
<script lang="ts">
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import { SharedLinkType } from '@api';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import { api, SharedLinkType } from '@api';
import type { PageData } from './$types';
import { handleError } from '$lib/utils/handle-error';
export let data: PageData;
const { sharedLink } = data;
let { sharedLink, passwordRequired, sharedLinkKey: key } = data;
let { title, description } = data.meta;
let isOwned = data.user ? data.user.id === sharedLink.userId : false;
let isOwned = data.user ? data.user.id === sharedLink?.userId : false;
let password = '';
const handlePasswordSubmit = async () => {
try {
const result = await api.sharedLinkApi.getMySharedLink({ password, key });
passwordRequired = false;
sharedLink = result.data;
isOwned = data.user ? data.user.id === sharedLink.userId : false;
title = (sharedLink.album ? sharedLink.album.albumName : 'Public Share') + ' - Immich';
description = sharedLink.description || `${sharedLink.assets.length} shared photos & videos.`;
} catch (error) {
handleError(error, 'Failed to get shared link');
}
};
</script>
{#if sharedLink.type == SharedLinkType.Album}
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
</svelte:head>
{#if passwordRequired}
<header>
<ControlAppBar showBackButton={false}>
<svelte:fragment slot="leading">
<a
data-sveltekit-preload-data="hover"
class="ml-6 flex place-items-center gap-2 hover:cursor-pointer"
href="https://immich.app"
>
<ImmichLogo height={30} width={30} />
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">IMMICH</h1>
</a>
</svelte:fragment>
<svelte:fragment slot="trailing">
<ThemeButton />
</svelte:fragment>
</ControlAppBar>
</header>
<main
class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
>
<div class="flex flex-col items-center justify-center mt-20">
<div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">Password Required</div>
<div class="mt-4 text-lg text-immich-primary dark:text-immich-dark-primary">
Please enter the password to view this page.
</div>
<div class="mt-4">
<input type="password" class="immich-form-input mr-2" placeholder="Password" bind:value={password} />
<Button on:click={handlePasswordSubmit}>Submit</Button>
</div>
</div>
</main>
{/if}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album}
<AlbumViewer {sharedLink} />
{/if}
{#if sharedLink.type == SharedLinkType.Individual}
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual}
<div class="immich-scrollbar">
<IndividualSharedViewer {sharedLink} {isOwned} />
</div>