diff --git a/backend/src/main.ts b/backend/src/main.ts index ecce0c9..a8ddc85 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -14,7 +14,10 @@ async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: false }); app.setGlobalPrefix('api', { - exclude: [{ path: 'i/:id/:secret', method: RequestMethod.GET }] + exclude: [ + { path: 'i/:id/:secret', method: RequestMethod.GET }, + { path: 'i/:accessToken', method: RequestMethod.GET } + ] }); const configService = app.get(ConfigService); diff --git a/backend/src/modules/upload/controllers/HotlinkImageController.ts b/backend/src/modules/upload/controllers/HotlinkImageController.ts index f63d11a..4e87966 100644 --- a/backend/src/modules/upload/controllers/HotlinkImageController.ts +++ b/backend/src/modules/upload/controllers/HotlinkImageController.ts @@ -1,4 +1,5 @@ import { Controller, Get, Param, StreamableFile } from '@nestjs/common'; +import { HotlinkAccessTokenParamsDto } from '../dto/HotlinkAccessTokenParamsDto'; import { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto'; import { UploadService } from '../services/UploadService'; @@ -6,10 +7,18 @@ import { UploadService } from '../services/UploadService'; export class HotlinkImageController { constructor(private readonly uploadService: UploadService) {} + /** Legacy route `GET /i/:uploadId/:secret` for hotlink uploads created before single-segment access-token URLs. */ @Get(':id/:secret') async getImage(@Param() params: HotlinkImageParamsDto): Promise { const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret); return new StreamableFile(body, { type: mimeType }); } + + @Get(':accessToken') + async getImageByAccessToken(@Param() params: HotlinkAccessTokenParamsDto): Promise { + const { body, mimeType } = await this.uploadService.getHotlinkImageByAccessToken(params.accessToken); + + return new StreamableFile(body, { type: mimeType }); + } } diff --git a/backend/src/modules/upload/dto/HotlinkAccessTokenParamsDto.ts b/backend/src/modules/upload/dto/HotlinkAccessTokenParamsDto.ts new file mode 100644 index 0000000..d404cf3 --- /dev/null +++ b/backend/src/modules/upload/dto/HotlinkAccessTokenParamsDto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty } from 'class-validator'; +import { AES_GCM_KEY_BYTE_LENGTH } from '../../../consts/crypto/aesGcmKeyByteLength'; +import { IsBase64Url } from '../../../validation/decorators/isBase64Url'; + +export class HotlinkAccessTokenParamsDto { + @IsNotEmpty() + @IsBase64Url({ byteLength: AES_GCM_KEY_BYTE_LENGTH }) + accessToken: string; +} diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 2227e9b..724d94a 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -516,6 +516,62 @@ describe('UploadService', () => { }); }); + describe('getHotlinkImageByAccessToken', () => { + const dek = randomBytes(32); + const accessToken = dek.toString('base64url'); + const accessTokenHash = hashHotlinkAccessToken(accessToken); + const iv = Buffer.alloc(AES_GCM_IV_BYTE_LENGTH, 0x11); + const plaintext = Buffer.from('hotlink-plaintext'); + const ciphertext = encryptHotlinkCiphertext(plaintext, dek, iv); + + it('returns decrypted body and mime type for valid access token', async () => { + uploadRepository.findOne.mockResolvedValue({ + id: fixedId, + storagePath: fixedId, + iv, + hotlink: { mimeType: 'image/png', accessTokenHash } + }); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getHotlinkImageByAccessToken(accessToken)).resolves.toEqual({ + body: plaintext, + mimeType: 'image/png' + }); + + expect(uploadRepository.findOne).toHaveBeenCalledWith({ + where: { + mode: UploadMode.Hotlink, + hotlink: { accessTokenHash } + }, + relations: ['hotlink'] + }); + expect(reserveViewExecute).toHaveBeenCalled(); + }); + + it('throws NotFoundException when no upload matches the token hash', async () => { + uploadRepository.findOne.mockResolvedValue(null); + + await expect(service.getHotlinkImageByAccessToken(accessToken)).rejects.toThrow(NotFoundException); + expect(storageService.read).not.toHaveBeenCalled(); + expect(reserveViewExecute).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when access token does not decrypt the ciphertext', async () => { + const wrongToken = Buffer.alloc(32, 0x99).toString('base64url'); + + uploadRepository.findOne.mockResolvedValue({ + id: fixedId, + storagePath: fixedId, + iv, + hotlink: { mimeType: 'image/png', accessTokenHash: hashHotlinkAccessToken(wrongToken) } + }); + storageService.read.mockResolvedValue(ciphertext); + + await expect(service.getHotlinkImageByAccessToken(wrongToken)).rejects.toThrow(NotFoundException); + expect(reserveViewExecute).not.toHaveBeenCalled(); + }); + }); + describe('transaction rollback on storage failure', () => { it('propagates storage write errors from the transaction', async () => { storageService.write.mockRejectedValue(new Error('disk full')); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index d7e8ef8..6e597c4 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -8,12 +8,14 @@ import { decryptAesGcm } from '../../../utils/decryptAesGcm'; import { fromBase64 } from '../../../utils/fromBase64'; import { fromBase64Url } from '../../../utils/fromBase64Url'; import { hashDeleteToken } from '../../../utils/hashDeleteToken'; +import { hashHotlinkAccessToken } from '../../../utils/hashHotlinkAccessToken'; import { isErrorEnoent } from '../../../utils/isErrorEnoent'; import { toBase64 } from '../../../utils/toBase64'; import { StorageService } from '../../storage/services/StorageService'; import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; import { Upload } from '../entities/Upload'; +import { UploadHotlink } from '../entities/UploadHotlink'; import { CreatedUploadResult } from '../types/CreatedUploadResult'; import { UploadMode } from '../types/UploadMode'; import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult'; @@ -171,12 +173,44 @@ export class UploadService { return result; } + /** + * Hotlink fetch by upload UUID and URL secret (`GET /i/:uploadId/:secret`). + * Backward compatibility only; new links use {@link getHotlinkImageByAccessToken}. + */ async getHotlinkImage(id: string, secret: string): Promise { const upload = await this.uploadRepository.findOne({ where: { id, mode: UploadMode.Hotlink }, relations: ['hotlink'] }); + this.validateHotlinkViewAllowed(upload); + + const dek = fromBase64Url(secret); + + return this.deliverHotlinkImage(upload, dek); + } + + async getHotlinkImageByAccessToken(accessToken: string): Promise { + const accessTokenHash = hashHotlinkAccessToken(accessToken); + + const upload = await this.uploadRepository.findOne({ + where: { + mode: UploadMode.Hotlink, + hotlink: { accessTokenHash } + }, + relations: ['hotlink'] + }); + + this.validateHotlinkViewAllowed(upload); + + const dek = fromBase64Url(accessToken); + + return this.deliverHotlinkImage(upload, dek); + } + + private validateHotlinkViewAllowed( + upload: (Upload & { hotlink: UploadHotlink | null }) | null + ): asserts upload is Upload & { hotlink: UploadHotlink } { if (!upload?.hotlink) { throw new NotFoundException(); } @@ -188,7 +222,12 @@ export class UploadService { if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) { throw new NotFoundException(); } + } + private async deliverHotlinkImage( + upload: Upload & { hotlink: UploadHotlink }, + dek: Buffer + ): Promise { let ciphertextBuffer: Buffer; try { @@ -204,14 +243,12 @@ export class UploadService { let body: Buffer; try { - const dek = fromBase64Url(secret); - body = decryptAesGcm(ciphertextBuffer, dek, upload.iv); } catch { throw new NotFoundException(); } - await this.reserveViewSlot(id); + await this.reserveViewSlot(upload.id); return { body,