mirror of
https://github.com/immich-app/immich.git
synced 2025-12-27 01:11:42 +03:00
feat: shared links custom URL (#19999)
* feat: custom url for shared links * feat: use a separate route and query param --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
@@ -80,7 +80,7 @@ export class ApiService {
|
||||
if (shareMatches) {
|
||||
try {
|
||||
const key = shareMatches[1];
|
||||
const auth = await this.authService.validateSharedLink(key);
|
||||
const auth = await this.authService.validateSharedLinkKey(key);
|
||||
const meta = await this.sharedLinkService.getMetadataTags(
|
||||
auth,
|
||||
request.host ? `${request.protocol}://${request.host}` : undefined,
|
||||
|
||||
@@ -322,15 +322,18 @@ describe(AuthService.name, () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
const buffer = sharedLink.key;
|
||||
const key = buffer.toString('base64url');
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': sharedLink.key.toString('base64url') },
|
||||
headers: { 'x-immich-share-key': key },
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||
}),
|
||||
).resolves.toEqual({ user, sharedLink });
|
||||
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(buffer);
|
||||
});
|
||||
|
||||
it('should accept a hex key', async () => {
|
||||
@@ -340,15 +343,50 @@ describe(AuthService.name, () => {
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
const buffer = sharedLink.key;
|
||||
const key = buffer.toString('hex');
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-key': sharedLink.key.toString('hex') },
|
||||
headers: { 'x-immich-share-key': key },
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||
}),
|
||||
).resolves.toEqual({ user, sharedLink });
|
||||
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key);
|
||||
expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(buffer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - shared link slug', () => {
|
||||
it('should not accept a non-existent slug', async () => {
|
||||
mocks.sharedLink.getBySlug.mockResolvedValue(void 0);
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-slug': 'slug' },
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept a valid slug', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any;
|
||||
|
||||
mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
headers: { 'x-immich-share-slug': 'slug-123' },
|
||||
queryParams: {},
|
||||
metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' },
|
||||
}),
|
||||
).resolves.toEqual({ user, sharedLink });
|
||||
|
||||
expect(mocks.sharedLink.getBySlug).toHaveBeenCalledWith('slug-123');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IncomingHttpHeaders } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { UserAdmin } from 'src/database';
|
||||
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
import {
|
||||
AuthDto,
|
||||
AuthStatusResponseDto,
|
||||
@@ -196,6 +196,7 @@ export class AuthService extends BaseService {
|
||||
|
||||
private async validate({ headers, queryParams }: Omit<ValidateRequest, 'metadata'>): Promise<AuthDto> {
|
||||
const shareKey = (headers[ImmichHeader.SharedLinkKey] || queryParams[ImmichQuery.SharedLinkKey]) as string;
|
||||
const shareSlug = (headers[ImmichHeader.SharedLinkSlug] || queryParams[ImmichQuery.SharedLinkSlug]) as string;
|
||||
const session = (headers[ImmichHeader.UserToken] ||
|
||||
headers[ImmichHeader.SessionToken] ||
|
||||
queryParams[ImmichQuery.SessionKey] ||
|
||||
@@ -204,7 +205,11 @@ export class AuthService extends BaseService {
|
||||
const apiKey = (headers[ImmichHeader.ApiKey] || queryParams[ImmichQuery.ApiKey]) as string;
|
||||
|
||||
if (shareKey) {
|
||||
return this.validateSharedLink(shareKey);
|
||||
return this.validateSharedLinkKey(shareKey);
|
||||
}
|
||||
|
||||
if (shareSlug) {
|
||||
return this.validateSharedLinkSlug(shareSlug);
|
||||
}
|
||||
|
||||
if (session) {
|
||||
@@ -403,18 +408,33 @@ export class AuthService extends BaseService {
|
||||
return cookies[ImmichCookie.OAuthCodeVerifier] || null;
|
||||
}
|
||||
|
||||
async validateSharedLink(key: string | string[]): Promise<AuthDto> {
|
||||
async validateSharedLinkKey(key: string | string[]): Promise<AuthDto> {
|
||||
key = Array.isArray(key) ? key[0] : key;
|
||||
|
||||
const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
|
||||
const sharedLink = await this.sharedLinkRepository.getByKey(bytes);
|
||||
if (sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) {
|
||||
return {
|
||||
user: sharedLink.user,
|
||||
sharedLink,
|
||||
};
|
||||
if (!this.isValidSharedLink(sharedLink)) {
|
||||
throw new UnauthorizedException('Invalid share key');
|
||||
}
|
||||
throw new UnauthorizedException('Invalid share key');
|
||||
|
||||
return { user: sharedLink.user, sharedLink };
|
||||
}
|
||||
|
||||
async validateSharedLinkSlug(slug: string | string[]): Promise<AuthDto> {
|
||||
slug = Array.isArray(slug) ? slug[0] : slug;
|
||||
|
||||
const sharedLink = await this.sharedLinkRepository.getBySlug(slug);
|
||||
if (!this.isValidSharedLink(sharedLink)) {
|
||||
throw new UnauthorizedException('Invalid share slug');
|
||||
}
|
||||
|
||||
return { user: sharedLink.user, sharedLink };
|
||||
}
|
||||
|
||||
private isValidSharedLink(
|
||||
sharedLink?: AuthSharedLink & { user: AuthUser | null },
|
||||
): sharedLink is AuthSharedLink & { user: AuthUser } {
|
||||
return !!sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date());
|
||||
}
|
||||
|
||||
private async validateApiKey(key: string): Promise<AuthDto> {
|
||||
|
||||
@@ -136,6 +136,7 @@ describe(SharedLinkService.name, () => {
|
||||
allowUpload: true,
|
||||
description: null,
|
||||
expiresAt: null,
|
||||
slug: null,
|
||||
showExif: true,
|
||||
key: Buffer.from('random-bytes', 'utf8'),
|
||||
});
|
||||
@@ -163,6 +164,7 @@ describe(SharedLinkService.name, () => {
|
||||
userId: authStub.admin.user.id,
|
||||
albumId: null,
|
||||
allowDownload: true,
|
||||
slug: null,
|
||||
allowUpload: true,
|
||||
assetIds: [assetStub.image.id],
|
||||
description: null,
|
||||
@@ -199,6 +201,7 @@ describe(SharedLinkService.name, () => {
|
||||
description: null,
|
||||
expiresAt: null,
|
||||
showExif: false,
|
||||
slug: null,
|
||||
key: Buffer.from('random-bytes', 'utf8'),
|
||||
});
|
||||
});
|
||||
@@ -223,6 +226,7 @@ describe(SharedLinkService.name, () => {
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
|
||||
expect(mocks.sharedLink.update).toHaveBeenCalledWith({
|
||||
id: sharedLinkStub.valid.id,
|
||||
slug: null,
|
||||
userId: authStub.user1.user.id,
|
||||
allowDownload: false,
|
||||
});
|
||||
@@ -277,6 +281,7 @@ describe(SharedLinkService.name, () => {
|
||||
expect(mocks.sharedLink.update).toHaveBeenCalled();
|
||||
expect(mocks.sharedLink.update).toHaveBeenCalledWith({
|
||||
...sharedLinkStub.individual,
|
||||
slug: null,
|
||||
assetIds: ['asset-3'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +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';
|
||||
@@ -64,36 +65,53 @@ export class SharedLinkService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
const sharedLink = await this.sharedLinkRepository.create({
|
||||
key: this.cryptoRepository.randomBytes(50),
|
||||
userId: auth.user.id,
|
||||
type: dto.type,
|
||||
albumId: dto.albumId || null,
|
||||
assetIds: dto.assetIds,
|
||||
description: dto.description || null,
|
||||
password: dto.password,
|
||||
expiresAt: dto.expiresAt || null,
|
||||
allowUpload: dto.allowUpload ?? true,
|
||||
allowDownload: dto.showMetadata === false ? false : (dto.allowDownload ?? true),
|
||||
showExif: dto.showMetadata ?? true,
|
||||
});
|
||||
try {
|
||||
const sharedLink = await this.sharedLinkRepository.create({
|
||||
key: this.cryptoRepository.randomBytes(50),
|
||||
userId: auth.user.id,
|
||||
type: dto.type,
|
||||
albumId: dto.albumId || null,
|
||||
assetIds: dto.assetIds,
|
||||
description: dto.description || null,
|
||||
password: dto.password,
|
||||
expiresAt: dto.expiresAt || null,
|
||||
allowUpload: dto.allowUpload ?? true,
|
||||
allowDownload: dto.showMetadata === false ? false : (dto.allowDownload ?? true),
|
||||
showExif: dto.showMetadata ?? true,
|
||||
slug: dto.slug || null,
|
||||
});
|
||||
|
||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: unknown): never {
|
||||
if ((error as PostgresError).constraint_name === 'shared_link_slug_uq') {
|
||||
throw new BadRequestException('Shared link with this slug already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) {
|
||||
await this.findOrFail(auth.user.id, id);
|
||||
const sharedLink = await this.sharedLinkRepository.update({
|
||||
id,
|
||||
userId: auth.user.id,
|
||||
description: dto.description,
|
||||
password: dto.password,
|
||||
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
|
||||
allowUpload: dto.allowUpload,
|
||||
allowDownload: dto.allowDownload,
|
||||
showExif: dto.showMetadata,
|
||||
});
|
||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
||||
try {
|
||||
const sharedLink = await this.sharedLinkRepository.update({
|
||||
id,
|
||||
userId: auth.user.id,
|
||||
description: dto.description,
|
||||
password: dto.password,
|
||||
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
|
||||
allowUpload: dto.allowUpload,
|
||||
allowDownload: dto.allowDownload,
|
||||
showExif: dto.showMetadata,
|
||||
slug: dto.slug || null,
|
||||
});
|
||||
return this.mapToSharedLink(sharedLink, { withExif: true });
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(auth: AuthDto, id: string): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user