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:
Jed-Giblin
2025-07-28 14:16:55 -04:00
committed by GitHub
parent 16b14b390f
commit 9b3718120b
65 changed files with 947 additions and 432 deletions

View File

@@ -192,6 +192,7 @@ export type SharedLink = {
showExif: boolean;
type: SharedLinkType;
userId: string;
slug: string | null;
};
export type Album = Selectable<AlbumTable> & {

View File

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

View File

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

View File

@@ -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);

View File

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

View File

@@ -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[] }) {

View File

@@ -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);
}

View File

@@ -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;
}

View File

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

View File

@@ -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');
});
});

View File

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

View File

@@ -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'],
});
});

View File

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