mirror of
https://github.com/immich-app/immich.git
synced 2025-12-22 17:24:56 +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:
@@ -192,6 +192,7 @@ export type SharedLink = {
|
||||
showExif: boolean;
|
||||
type: SharedLinkType;
|
||||
userId: string;
|
||||
slug: string | null;
|
||||
};
|
||||
|
||||
export type Album = Selectable<AlbumTable> & {
|
||||
|
||||
@@ -22,13 +22,17 @@ export class SharedLinkCreateDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
albumId?: string;
|
||||
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
@Optional()
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
@Optional()
|
||||
password?: string;
|
||||
password?: string | null;
|
||||
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
slug?: string | null;
|
||||
|
||||
@ValidateDate({ optional: true, nullable: true })
|
||||
expiresAt?: Date | null = null;
|
||||
@@ -44,16 +48,22 @@ export class SharedLinkCreateDto {
|
||||
}
|
||||
|
||||
export class SharedLinkEditDto {
|
||||
@Optional()
|
||||
description?: string;
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@Optional()
|
||||
password?: string;
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
password?: string | null;
|
||||
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
slug?: string | null;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@Optional()
|
||||
@ValidateBoolean({ optional: true })
|
||||
allowUpload?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@@ -99,6 +109,8 @@ export class SharedLinkResponseDto {
|
||||
|
||||
allowDownload!: boolean;
|
||||
showMetadata!: boolean;
|
||||
|
||||
slug!: string | null;
|
||||
}
|
||||
|
||||
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
|
||||
@@ -118,6 +130,7 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showMetadata: sharedLink.showExif,
|
||||
slug: sharedLink.slug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,5 +154,6 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLink
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showMetadata: sharedLink.showExif,
|
||||
slug: sharedLink.slug,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,12 +17,14 @@ export enum ImmichHeader {
|
||||
UserToken = 'x-immich-user-token',
|
||||
SessionToken = 'x-immich-session-token',
|
||||
SharedLinkKey = 'x-immich-share-key',
|
||||
SharedLinkSlug = 'x-immich-share-slug',
|
||||
Checksum = 'x-immich-checksum',
|
||||
Cid = 'x-immich-cid',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
SharedLinkKey = 'key',
|
||||
SharedLinkSlug = 'slug',
|
||||
ApiKey = 'apiKey',
|
||||
SessionKey = 'sessionKey',
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator =
|
||||
];
|
||||
|
||||
if ((options as SharedLinkRoute)?.sharedLink) {
|
||||
decorators.push(ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false }));
|
||||
decorators.push(
|
||||
ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false }),
|
||||
ApiQuery({ name: ImmichQuery.SharedLinkSlug, type: String, required: false }),
|
||||
);
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
|
||||
@@ -188,9 +188,47 @@ from
|
||||
"shared_link"
|
||||
left join "album" on "album"."id" = "shared_link"."albumId"
|
||||
where
|
||||
"shared_link"."key" = $1
|
||||
and "album"."deletedAt" is null
|
||||
"album"."deletedAt" is null
|
||||
and (
|
||||
"shared_link"."type" = $2
|
||||
"shared_link"."type" = $1
|
||||
or "album"."id" is not null
|
||||
)
|
||||
and "shared_link"."key" = $2
|
||||
|
||||
-- SharedLinkRepository.getBySlug
|
||||
select
|
||||
"shared_link"."id",
|
||||
"shared_link"."userId",
|
||||
"shared_link"."expiresAt",
|
||||
"shared_link"."showExif",
|
||||
"shared_link"."allowUpload",
|
||||
"shared_link"."allowDownload",
|
||||
"shared_link"."password",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"user"."id",
|
||||
"user"."name",
|
||||
"user"."email",
|
||||
"user"."isAdmin",
|
||||
"user"."quotaUsageInBytes",
|
||||
"user"."quotaSizeInBytes"
|
||||
from
|
||||
"user"
|
||||
where
|
||||
"user"."id" = "shared_link"."userId"
|
||||
) as obj
|
||||
) as "user"
|
||||
from
|
||||
"shared_link"
|
||||
left join "album" on "album"."id" = "shared_link"."albumId"
|
||||
where
|
||||
"album"."deletedAt" is null
|
||||
and (
|
||||
"shared_link"."type" = $1
|
||||
or "album"."id" is not null
|
||||
)
|
||||
and "shared_link"."slug" = $2
|
||||
|
||||
@@ -173,10 +173,18 @@ export class SharedLinkRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.BUFFER] })
|
||||
async getByKey(key: Buffer) {
|
||||
getByKey(key: Buffer) {
|
||||
return this.authBuilder().where('shared_link.key', '=', key).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.BUFFER] })
|
||||
getBySlug(slug: string) {
|
||||
return this.authBuilder().where('shared_link.slug', '=', slug).executeTakeFirst();
|
||||
}
|
||||
|
||||
private authBuilder() {
|
||||
return this.db
|
||||
.selectFrom('shared_link')
|
||||
.where('shared_link.key', '=', key)
|
||||
.leftJoin('album', 'album.id', 'shared_link.albumId')
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.select((eb) => [
|
||||
@@ -185,8 +193,7 @@ export class SharedLinkRepository {
|
||||
eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'),
|
||||
).as('user'),
|
||||
])
|
||||
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
||||
.executeTakeFirst();
|
||||
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]));
|
||||
}
|
||||
|
||||
async create(entity: Insertable<SharedLinkTable> & { assetIds?: string[] }) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "shared_link" ADD "slug" character varying;`.execute(db);
|
||||
await sql`ALTER TABLE "shared_link" ADD CONSTRAINT "shared_link_slug_uq" UNIQUE ("slug");`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "shared_link" DROP CONSTRAINT "shared_link_slug_uq";`.execute(db);
|
||||
await sql`ALTER TABLE "shared_link" DROP COLUMN "slug";`.execute(db);
|
||||
}
|
||||
@@ -48,4 +48,7 @@ export class SharedLinkTable {
|
||||
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
password!: string | null;
|
||||
|
||||
@Column({ type: 'character varying', nullable: true, unique: true })
|
||||
slug!: string | null;
|
||||
}
|
||||
|
||||
@@ -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