feat: shared link login (#25678)

This commit is contained in:
Jason Rasmussen
2026-02-12 12:08:38 -05:00
committed by GitHub
parent 81c93101a0
commit 72cef8b94b
16 changed files with 411 additions and 48 deletions

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