Add GET /i/:id/:secret hotlink image decrypt stream.

This commit is contained in:
2026-10-26 21:47:30 +01:00
parent 4517fe906a
commit 5db90f02c5
14 changed files with 696 additions and 4 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StorageModule } from '../storage/StorageModule';
import { HotlinkImageController } from './controllers/HotlinkImageController';
import { UploadController } from './controllers/UploadController';
import { Upload } from './entities/Upload';
import { UploadHotlink } from './entities/UploadHotlink';
@@ -9,7 +10,7 @@ import { UploadService } from './services/UploadService';
@Module({
imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule],
controllers: [UploadController],
controllers: [UploadController, HotlinkImageController],
providers: [UploadService]
})
export class UploadModule {}
@@ -0,0 +1,15 @@
import { Controller, Get, Param, StreamableFile } from '@nestjs/common';
import { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto';
import { UploadService } from '../services/UploadService';
@Controller('i')
export class HotlinkImageController {
constructor(private readonly uploadService: UploadService) {}
@Get(':id/:secret')
async getImage(@Param() params: HotlinkImageParamsDto): Promise<StreamableFile> {
const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret);
return new StreamableFile(body, { type: mimeType });
}
}
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
import { AES_GCM_KEY_BYTE_LENGTH } from '../../../consts/crypto/aesGcmKeyByteLength';
import { IsBase64Url } from '../../../validation/decorators/isBase64Url';
export class HotlinkImageParamsDto {
@IsNotEmpty()
@IsUUID()
id: string;
@IsNotEmpty()
@IsBase64Url({ byteLength: AES_GCM_KEY_BYTE_LENGTH })
secret: string;
}
@@ -1,5 +1,6 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { Upload } from '../entities/Upload';
import { fileTypeFromBuffer } from 'file-type';
@@ -40,6 +41,12 @@ const wrapFieldBase64 = (length: number): string => Buffer.alloc(length, 0x22).t
const hashDeleteToken = (token: string): string => createHash('sha256').update(token, 'utf8').digest('hex');
const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): Buffer => {
const cipher = createCipheriv('aes-256-gcm', key, iv);
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
};
describe('UploadService', () => {
let service: UploadService;
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
@@ -283,6 +290,64 @@ describe('UploadService', () => {
});
});
describe('getHotlinkImage', () => {
const dek = randomBytes(32);
const secret = dek.toString('base64url');
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 secret', async () => {
uploadRepository.findOne.mockResolvedValue({
storagePath: fixedId,
iv,
hotlink: { mimeType: 'image/png' }
});
storageService.read.mockResolvedValue(ciphertext);
await expect(service.getHotlinkImage(fixedId, secret)).resolves.toEqual({
body: plaintext,
mimeType: 'image/png'
});
expect(uploadRepository.findOne).toHaveBeenCalledWith({
where: { id: fixedId, mode: UploadMode.Hotlink },
relations: ['hotlink']
});
});
it('throws NotFoundException when upload is missing or not hotlink', async () => {
uploadRepository.findOne.mockResolvedValue(null);
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
expect(storageService.read).not.toHaveBeenCalled();
});
it('throws NotFoundException when secret is wrong but well-formed', async () => {
uploadRepository.findOne.mockResolvedValue({
storagePath: fixedId,
iv,
hotlink: { mimeType: 'image/png' }
});
storageService.read.mockResolvedValue(ciphertext);
const wrongSecret = Buffer.alloc(32, 0x99).toString('base64url');
await expect(service.getHotlinkImage(fixedId, wrongSecret)).rejects.toThrow(NotFoundException);
});
it('throws NotFoundException when ciphertext file is missing', async () => {
uploadRepository.findOne.mockResolvedValue({
storagePath: fixedId,
iv,
hotlink: { mimeType: 'image/png' }
});
storageService.read.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
await expect(service.getHotlinkImage(fixedId, secret)).rejects.toThrow(NotFoundException);
});
});
describe('transaction rollback on storage failure', () => {
it('propagates storage write errors from the transaction', async () => {
storageService.write.mockRejectedValue(new Error('disk full'));
@@ -3,7 +3,9 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { fileTypeFromBuffer } from 'file-type';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { DataSource, Repository } from 'typeorm';
import { decryptAesGcm } from '../../../utils/decryptAesGcm';
import { fromBase64 } from '../../../utils/fromBase64';
import { fromBase64Url } from '../../../utils/fromBase64Url';
import { isErrorEnoent } from '../../../utils/isErrorEnoent';
import { toBase64 } from '../../../utils/toBase64';
import { StorageService } from '../../storage/services/StorageService';
@@ -13,6 +15,7 @@ import { Upload } from '../entities/Upload';
import { CreatedUploadResult } from '../types/CreatedUploadResult';
import { UploadMode } from '../types/UploadMode';
import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult';
import { HotlinkImageResult } from '../types/HotlinkImageResult';
@Injectable()
export class UploadService {
@@ -155,6 +158,44 @@ export class UploadService {
return result;
}
async getHotlinkImage(id: string, secret: string): Promise<HotlinkImageResult> {
const upload = await this.uploadRepository.findOne({
where: { id, mode: UploadMode.Hotlink },
relations: ['hotlink']
});
if (!upload?.hotlink) {
throw new NotFoundException();
}
let ciphertextBuffer: Buffer;
try {
ciphertextBuffer = await this.storageService.read(upload.storagePath);
} catch (error: unknown) {
if (isErrorEnoent(error)) {
throw new NotFoundException();
}
throw error;
}
let body: Buffer;
try {
const dek = fromBase64Url(secret);
body = decryptAesGcm(ciphertextBuffer, dek, upload.iv);
} catch {
throw new NotFoundException();
}
return {
body,
mimeType: upload.hotlink.mimeType
};
}
private hashDeleteToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
@@ -0,0 +1,4 @@
export type HotlinkImageResult = {
body: Buffer;
mimeType: string;
};