Add GET /i/:id/:secret hotlink image decrypt stream.
This commit is contained in:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user