Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
328ad7d271 feat: shared link login 2026-01-29 11:13:17 -05:00
13 changed files with 405 additions and 42 deletions

View File

@@ -256,6 +256,7 @@ Class | Method | HTTP request | Description
*SharedLinksApi* | [**getSharedLinkById**](doc//SharedLinksApi.md#getsharedlinkbyid) | **GET** /shared-links/{id} | Retrieve a shared link
*SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | Delete a shared link
*SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | Remove assets from a shared link
*SharedLinksApi* | [**sharedLinkLogin**](doc//SharedLinksApi.md#sharedlinklogin) | **POST** /shared-links/login | Shared link login
*SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | Update a shared link
*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | Create a stack
*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | Delete a stack
@@ -552,6 +553,7 @@ Class | Method | HTTP request | Description
- [SetMaintenanceModeDto](doc//SetMaintenanceModeDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkLoginDto](doc//SharedLinkLoginDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
- [SharedLinkType](doc//SharedLinkType.md)
- [SharedLinksResponse](doc//SharedLinksResponse.md)

View File

@@ -292,6 +292,7 @@ part 'model/session_update_dto.dart';
part 'model/set_maintenance_mode_dto.dart';
part 'model/shared_link_create_dto.dart';
part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_login_dto.dart';
part 'model/shared_link_response_dto.dart';
part 'model/shared_link_type.dart';
part 'model/shared_links_response.dart';

View File

@@ -495,6 +495,77 @@ class SharedLinksApi {
return null;
}
/// Shared link login
///
/// Login to a password protected shared link
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [SharedLinkLoginDto] sharedLinkLoginDto (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<Response> sharedLinkLoginWithHttpInfo(SharedLinkLoginDto sharedLinkLoginDto, { String? key, String? slug, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/shared-links/login';
// ignore: prefer_final_locals
Object? postBody = sharedLinkLoginDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Shared link login
///
/// Login to a password protected shared link
///
/// Parameters:
///
/// * [SharedLinkLoginDto] sharedLinkLoginDto (required):
///
/// * [String] key:
///
/// * [String] slug:
Future<SharedLinkResponseDto?> sharedLinkLogin(SharedLinkLoginDto sharedLinkLoginDto, { String? key, String? slug, }) async {
final response = await sharedLinkLoginWithHttpInfo(sharedLinkLoginDto, key: key, slug: slug, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SharedLinkResponseDto',) as SharedLinkResponseDto;
}
return null;
}
/// Update a shared link
///
/// Update an existing shared link by its ID.

View File

@@ -630,6 +630,8 @@ class ApiClient {
return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto':
return SharedLinkEditDto.fromJson(value);
case 'SharedLinkLoginDto':
return SharedLinkLoginDto.fromJson(value);
case 'SharedLinkResponseDto':
return SharedLinkResponseDto.fromJson(value);
case 'SharedLinkType':

View File

@@ -0,0 +1,100 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SharedLinkLoginDto {
/// Returns a new [SharedLinkLoginDto] instance.
SharedLinkLoginDto({
required this.password,
});
/// Shared link password
String password;
@override
bool operator ==(Object other) => identical(this, other) || other is SharedLinkLoginDto &&
other.password == password;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(password.hashCode);
@override
String toString() => 'SharedLinkLoginDto[password=$password]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'password'] = this.password;
return json;
}
/// Returns a new [SharedLinkLoginDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SharedLinkLoginDto? fromJson(dynamic value) {
upgradeDto(value, "SharedLinkLoginDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SharedLinkLoginDto(
password: mapValueOfType<String>(json, r'password')!,
);
}
return null;
}
static List<SharedLinkLoginDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharedLinkLoginDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SharedLinkLoginDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SharedLinkLoginDto> mapFromJson(dynamic json) {
final map = <String, SharedLinkLoginDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SharedLinkLoginDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SharedLinkLoginDto-objects as value to a dart map
static Map<String, List<SharedLinkLoginDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SharedLinkLoginDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SharedLinkLoginDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'password',
};
}

View File

@@ -11224,6 +11224,78 @@
"x-immich-state": "Stable"
}
},
"/shared-links/login": {
"post": {
"description": "Login to a password protected shared link",
"operationId": "sharedLinkLogin",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkLoginDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Shared link login",
"tags": [
"Shared links"
],
"x-immich-history": [
{
"version": "v2.6.0",
"state": "Added"
},
{
"version": "v2.6.0",
"state": "Beta"
}
],
"x-immich-state": "Beta"
}
},
"/shared-links/me": {
"get": {
"description": "Retrieve the current shared link associated with authentication method.",
@@ -21669,6 +21741,19 @@
},
"type": "object"
},
"SharedLinkLoginDto": {
"properties": {
"password": {
"description": "Shared link password",
"example": "password",
"type": "string"
}
},
"required": [
"password"
],
"type": "object"
},
"SharedLinkResponseDto": {
"properties": {
"album": {
@@ -21727,9 +21812,25 @@
"type": "string"
},
"token": {
"deprecated": true,
"description": "Access token",
"nullable": true,
"type": "string"
"type": "string",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Deprecated"
}
],
"x-immich-state": "Deprecated"
},
"type": {
"allOf": [

View File

@@ -2285,6 +2285,10 @@ export type SharedLinkCreateDto = {
/** Shared link type */
"type": SharedLinkType;
};
export type SharedLinkLoginDto = {
/** Shared link password */
password: string;
};
export type SharedLinkEditDto = {
/** Allow downloads */
allowDownload?: boolean;
@@ -5859,6 +5863,26 @@ export function createSharedLink({ sharedLinkCreateDto }: {
body: sharedLinkCreateDto
})));
}
/**
* Shared link login
*/
export function sharedLinkLogin({ key, slug, sharedLinkLoginDto }: {
key?: string;
slug?: string;
sharedLinkLoginDto: SharedLinkLoginDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: SharedLinkResponseDto;
}>(`/shared-links/login${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.json({
...opts,
method: "POST",
body: sharedLinkLoginDto
})));
}
/**
* Retrieve current shared link
*/

View File

@@ -22,21 +22,39 @@ import { AuthDto } from 'src/dtos/auth.dto';
import {
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkLoginDto,
SharedLinkPasswordDto,
SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto';
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { respondWithCookie } from 'src/utils/response';
import { UUIDParamDto } from 'src/validation';
const getAuthTokens = (cookies: Record<string, string> | undefined) => {
return cookies?.[ImmichCookie.SharedLinkToken]?.split(',') || [];
};
const merge = (cookies: Record<string, string> | undefined, token: string) => {
const authTokens = getAuthTokens(cookies);
if (!authTokens.includes(token)) {
authTokens.push(token);
}
return authTokens.join(',');
};
@ApiTags(ApiTag.SharedLinks)
@Controller('shared-links')
export class SharedLinkController {
constructor(private service: SharedLinkService) {}
constructor(
private service: SharedLinkService,
private logger: LoggingRepository,
) {}
@Get()
@Authenticated({ permission: Permission.SharedLinkRead })
@@ -49,6 +67,28 @@ export class SharedLinkController {
return this.service.getAll(auth, dto);
}
@Post('login')
@Authenticated({ sharedLink: true })
@Endpoint({
summary: 'Shared link login',
description: 'Login to a password protected shared link',
history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'),
})
async sharedLinkLogin(
@Auth() auth: AuthDto,
@Body() dto: SharedLinkLoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> {
const { sharedLink, token } = await this.service.login(auth, dto);
return respondWithCookie(res, sharedLink, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.SharedLinkToken, value: merge(req.cookies, token) }],
});
}
@Get('me')
@Authenticated({ sharedLink: true })
@Endpoint({
@@ -59,19 +99,19 @@ export class SharedLinkController {
async getMySharedLink(
@Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto,
@Req() request: Request,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> {
const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken];
if (sharedLinkToken) {
dto.token = sharedLinkToken;
if (dto.password) {
this.logger.deprecate(
'Passing shared link password via query parameters is deprecated and will be removed in the next major release. Please use POST /shared-links/login instead.',
);
return this.sharedLinkLogin(auth, { password: dto.password }, req, res, loginDetails);
}
const body = await this.service.getMine(auth, dto);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [],
});
return this.service.getMine(auth, getAuthTokens(req.cookies));
}
@Get(':id')

View File

@@ -1,11 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { SharedLink } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class SharedLinkSearchDto {
@ValidateUUID({ optional: true, description: 'Filter by album ID' })
@@ -94,6 +94,11 @@ export class SharedLinkEditDto {
changeExpiryTime?: boolean;
}
export class SharedLinkLoginDto {
@ValidateString({ description: 'Shared link password', example: 'password' })
password!: string;
}
export class SharedLinkPasswordDto {
@ApiPropertyOptional({ example: 'password', description: 'Link password' })
@IsString()
@@ -112,7 +117,10 @@ export class SharedLinkResponseDto {
description!: string | null;
@ApiProperty({ description: 'Has password' })
password!: string | null;
@ApiPropertyOptional({ description: 'Access token' })
@Property({
description: 'Access token',
history: new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0'),
})
token?: string | null;
@ApiProperty({ description: 'Owner user ID' })
userId!: string;

View File

@@ -35,14 +35,14 @@ 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(mocks.sharedLink.get).not.toHaveBeenCalled();
});
it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
});
@@ -55,21 +55,22 @@ describe(SharedLinkService.name, () => {
},
});
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
const response = await sut.getMine(authDto, {});
const response = await sut.getMine(authDto, []);
expect(response.assets[0]).toMatchObject({ hasMetadata: false });
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
});
it('should throw an error for an invalid password protected shared link', async () => {
it('should throw an error for a request without a shared link auth token', async () => {
const authDto = authStub.adminSharedLink;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired);
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.getMine(authDto, [])).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
});
it('should allow a correct password on a password protected shared link', async () => {
it('should accept a valid shared link auth token', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined();
mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token');
await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined();
expect(mocks.sharedLink.get).toHaveBeenCalledWith(
authStub.adminSharedLink.user.id,
authStub.adminSharedLink.sharedLink?.id,

View File

@@ -1,6 +1,5 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { PostgresError } from 'postgres';
import { SharedLink } from 'src/database';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -8,7 +7,7 @@ import {
mapSharedLink,
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkPasswordDto,
SharedLinkLoginDto,
SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto';
@@ -24,18 +23,41 @@ export class SharedLinkService extends BaseService {
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
}
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
async login(auth: AuthDto, dto: SharedLinkLoginDto) {
if (!auth.sharedLink) {
throw new ForbiddenException();
}
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
if (sharedLink.password) {
response.token = this.validateAndRefreshToken(sharedLink, dto);
const { id, password } = sharedLink;
if (!password) {
throw new BadRequestException('Shared link is not password protected');
}
return response;
if (password !== dto.password) {
throw new UnauthorizedException('Invalid password');
}
return {
sharedLink: mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }),
token: this.asToken({ id, password }),
};
}
async getMine(auth: AuthDto, authTokens: string[]) {
if (!auth.sharedLink) {
throw new ForbiddenException();
}
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const { id, password } = sharedLink;
if (password && !authTokens.includes(this.asToken({ id, password }))) {
throw new UnauthorizedException('Password required');
}
return mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
}
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
@@ -213,16 +235,7 @@ export class SharedLinkService extends BaseService {
};
}
private validateAndRefreshToken(sharedLink: SharedLink, 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(',');
private asToken(sharedLink: { id: string; password: string }) {
return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
}
}

View File

@@ -8,7 +8,7 @@
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { sharedLinkLogin, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Logo, PasswordInput } from '@immich/ui';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
@@ -39,7 +39,7 @@
const handlePasswordSubmit = async () => {
try {
sharedLink = await getMySharedLink({ password, key, slug });
sharedLink = await sharedLinkLogin({ key, slug, sharedLinkLoginDto: { password } });
setSharedLink(sharedLink);
passwordRequired = false;
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';

View File

@@ -49,7 +49,7 @@ export const loadSharedLink = async ({
},
};
} catch (error) {
if (isHttpError(error) && error.data.message === 'Invalid password') {
if (isHttpError(error) && error.data.message === 'Password required') {
return {
...common,
passwordRequired: true,