Serve hotlink images by single-segment access token URL.
Legacy GET /i/:uploadId/:secret unchanged. Lookup new uploads by access token hash; decrypt with token from path.
This commit is contained in:
+4
-1
@@ -14,7 +14,10 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(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);
|
||||
|
||||
@@ -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<StreamableFile> {
|
||||
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<StreamableFile> {
|
||||
const { body, mimeType } = await this.uploadService.getHotlinkImageByAccessToken(params.accessToken);
|
||||
|
||||
return new StreamableFile(body, { type: mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<HotlinkImageResult> {
|
||||
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<HotlinkImageResult> {
|
||||
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<HotlinkImageResult> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user