Add single-image zk and hotlink upload API with service tests.

Replace batch DTOs with per-mode endpoints, persist ciphertext with file-type
checks, and return a hashed delete token for future revocation.
This commit is contained in:
2026-10-12 20:18:40 +02:00
parent 7f99256863
commit d4529376c6
15 changed files with 412 additions and 92 deletions
@@ -0,0 +1,123 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { fileTypeFromBuffer } from 'file-type';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { DataSource } from 'typeorm';
import { fromBase64 } from '../../../utils/fromBase64';
import { StorageService } from '../../storage/services/StorageService';
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
import { Upload } from '../entities/Upload';
import { CreatedUploadResult } from '../types/CreatedUploadResult';
import { UploadMode } from '../types/UploadMode';
@Injectable()
export class UploadService {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly storageService: StorageService
) {}
async createZkUpload({
iv,
ciphertext,
expiresAt,
maxViews,
passwordProtected,
wrappedDek,
wrapIv,
salt
}: CreateZkUploadDto): Promise<CreatedUploadResult> {
const ciphertextBuffer = fromBase64(ciphertext);
await this.throwIfUnencryptedImage(ciphertextBuffer);
const deleteToken = randomBytes(32).toString('base64url');
const id = randomUUID();
const storagePath = id;
await this.dataSource.transaction(async manager => {
const uploadRepository = manager.getRepository(Upload);
const upload = uploadRepository.create({
id,
mode: UploadMode.Zk,
storagePath,
iv: fromBase64(iv),
maxViews,
deleteTokenHash: this.hashDeleteToken(deleteToken),
expiresAt,
zk: {
passwordProtected,
wrappedDek: passwordProtected && wrappedDek ? fromBase64(wrappedDek) : null,
wrapIv: passwordProtected && wrapIv ? fromBase64(wrapIv) : null,
salt: passwordProtected && salt ? fromBase64(salt) : null
}
});
await uploadRepository.save(upload);
await this.storageService.write(storagePath, ciphertextBuffer);
});
return {
id,
mode: UploadMode.Zk,
deleteToken
};
}
async createHotlinkUpload({
iv,
ciphertext,
expiresAt,
maxViews,
mimeType
}: CreateHotlinkUploadDto): Promise<CreatedUploadResult> {
const ciphertextBuffer = fromBase64(ciphertext);
await this.throwIfUnencryptedImage(ciphertextBuffer);
const deleteToken = randomBytes(32).toString('base64url');
const id = randomUUID();
const storagePath = id;
await this.dataSource.transaction(async manager => {
const uploadRepository = manager.getRepository(Upload);
const upload = uploadRepository.create({
id,
mode: UploadMode.Hotlink,
storagePath,
iv: fromBase64(iv),
maxViews,
deleteTokenHash: this.hashDeleteToken(deleteToken),
expiresAt,
hotlink: { mimeType }
});
await uploadRepository.save(upload);
await this.storageService.write(storagePath, ciphertextBuffer);
});
return {
id,
mode: UploadMode.Hotlink,
deleteToken
};
}
private hashDeleteToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
private async throwIfUnencryptedImage(ciphertext: Buffer): Promise<void> {
const detected = await fileTypeFromBuffer(ciphertext);
if (detected?.mime.startsWith('image/')) {
throw new BadRequestException('Ciphertext must not be a plaintext image');
}
}
}