Merge pull request 'Slave/hotlink serve' (#10) from slave/hotlink-serve into master
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const AES_GCM_KEY_BYTE_LENGTH = 32;
|
||||
+4
-2
@@ -1,4 +1,4 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
@@ -12,7 +12,9 @@ import { HttpMethod } from './types/HttpMethod';
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [{ path: 'i/:id/:secret', method: RequestMethod.GET }]
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const altchaService = app.get(AltchaService);
|
||||
|
||||
@@ -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,18 @@
|
||||
import { Controller, Get, Header, 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')
|
||||
@Header('X-Content-Type-Options', 'nosniff')
|
||||
@Header('Content-Security-Policy', 'sandbox')
|
||||
@Header('Referrer-Policy', 'no-referrer')
|
||||
async getImage(@Param() params: HotlinkImageParamsDto): Promise<StreamableFile> {
|
||||
const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret);
|
||||
|
||||
return new StreamableFile(body, { type: mimeType });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { IsMimeType, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsImageMimeType } from '../../../validation/decorators/isImageMimeType';
|
||||
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
|
||||
|
||||
export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsMimeType()
|
||||
@IsImageMimeType()
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { createCipheriv, randomBytes } from 'node:crypto';
|
||||
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '../consts/crypto/aesGcmAuthTagByteLength';
|
||||
import { AES_GCM_IV_BYTE_LENGTH } from '../consts/crypto/aesGcmIvByteLength';
|
||||
import { AES_GCM_KEY_BYTE_LENGTH } from '../consts/crypto/aesGcmKeyByteLength';
|
||||
import { decryptAesGcm } from './decryptAesGcm';
|
||||
|
||||
const encryptAesGcmLikeWebCrypto = (plaintext: Buffer, key: Buffer, iv: Buffer): Buffer => {
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([encrypted, authTag]);
|
||||
};
|
||||
|
||||
describe('decryptAesGcm', () => {
|
||||
const key = randomBytes(AES_GCM_KEY_BYTE_LENGTH);
|
||||
const iv = randomBytes(AES_GCM_IV_BYTE_LENGTH);
|
||||
|
||||
it('decrypts Web Crypto style ciphertext (ciphertext || auth tag)', () => {
|
||||
const plaintext = Buffer.from('hotlink-image', 'utf8');
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(plaintext, key, iv);
|
||||
|
||||
expect(decryptAesGcm(ciphertext, key, iv)).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it('round-trips empty plaintext', () => {
|
||||
const plaintext = Buffer.alloc(0);
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(plaintext, key, iv);
|
||||
|
||||
expect(ciphertext).toHaveLength(AES_GCM_AUTH_TAG_BYTE_LENGTH);
|
||||
expect(decryptAesGcm(ciphertext, key, iv)).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it.each([64, 512, 4096])('round-trips %i-byte binary payloads', size => {
|
||||
const plaintext = randomBytes(size);
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(plaintext, key, iv);
|
||||
|
||||
expect(decryptAesGcm(ciphertext, key, iv)).toEqual(plaintext);
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('x'), key, iv);
|
||||
|
||||
it.each([AES_GCM_IV_BYTE_LENGTH - 1, AES_GCM_IV_BYTE_LENGTH + 1])(
|
||||
'rejects IV length %i bytes',
|
||||
ivLength => {
|
||||
expect(() => decryptAesGcm(ciphertext, key, Buffer.alloc(ivLength))).toThrow(
|
||||
`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([AES_GCM_KEY_BYTE_LENGTH - 1, AES_GCM_KEY_BYTE_LENGTH + 1])(
|
||||
'rejects key length %i bytes',
|
||||
keyLength => {
|
||||
expect(() => decryptAesGcm(ciphertext, Buffer.alloc(keyLength), iv)).toThrow(
|
||||
`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects ciphertext shorter than the auth tag', () => {
|
||||
expect(() => decryptAesGcm(Buffer.alloc(AES_GCM_AUTH_TAG_BYTE_LENGTH - 1), key, iv)).toThrow(
|
||||
'AES-GCM ciphertext is too short'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integrity and keying', () => {
|
||||
it('throws when auth tag is invalid', () => {
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('x'), key, iv);
|
||||
|
||||
ciphertext[ciphertext.length - 1] ^= 0xff;
|
||||
|
||||
expect(() => decryptAesGcm(ciphertext, key, iv)).toThrow();
|
||||
});
|
||||
|
||||
it('throws when encrypted bytes are tampered', () => {
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('integrity-checked', 'utf8'), key, iv);
|
||||
|
||||
ciphertext[0] ^= 0xff;
|
||||
|
||||
expect(() => decryptAesGcm(ciphertext, key, iv)).toThrow();
|
||||
});
|
||||
|
||||
it('throws with the wrong key', () => {
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('wrong-key', 'utf8'), key, iv);
|
||||
const otherKey = randomBytes(AES_GCM_KEY_BYTE_LENGTH);
|
||||
|
||||
expect(() => decryptAesGcm(ciphertext, otherKey, iv)).toThrow();
|
||||
});
|
||||
|
||||
it('throws with the wrong IV', () => {
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('wrong-iv', 'utf8'), key, iv);
|
||||
const otherIv = randomBytes(AES_GCM_IV_BYTE_LENGTH);
|
||||
|
||||
expect(() => decryptAesGcm(ciphertext, key, otherIv)).toThrow();
|
||||
});
|
||||
|
||||
it('throws when ciphertext is truncated by one byte', () => {
|
||||
const ciphertext = encryptAesGcmLikeWebCrypto(Buffer.from('truncated', 'utf8'), key, iv);
|
||||
const truncated = ciphertext.subarray(0, ciphertext.length - 1);
|
||||
|
||||
expect(() => decryptAesGcm(truncated, key, iv)).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createDecipheriv } from 'node:crypto';
|
||||
import { AES_GCM_AUTH_TAG_BYTE_LENGTH } from '../consts/crypto/aesGcmAuthTagByteLength';
|
||||
import { AES_GCM_IV_BYTE_LENGTH } from '../consts/crypto/aesGcmIvByteLength';
|
||||
import { AES_GCM_KEY_BYTE_LENGTH } from '../consts/crypto/aesGcmKeyByteLength';
|
||||
|
||||
export const decryptAesGcm = (ciphertext: Buffer, key: Buffer, iv: Buffer): Buffer => {
|
||||
if (iv.length !== AES_GCM_IV_BYTE_LENGTH) {
|
||||
throw new Error(`AES-GCM IV must be ${AES_GCM_IV_BYTE_LENGTH} bytes`);
|
||||
}
|
||||
|
||||
if (key.length !== AES_GCM_KEY_BYTE_LENGTH) {
|
||||
throw new Error(`AES-GCM key must be ${AES_GCM_KEY_BYTE_LENGTH} bytes`);
|
||||
}
|
||||
|
||||
if (ciphertext.length < AES_GCM_AUTH_TAG_BYTE_LENGTH) {
|
||||
throw new Error('AES-GCM ciphertext is too short');
|
||||
}
|
||||
|
||||
const authTag = ciphertext.subarray(ciphertext.length - AES_GCM_AUTH_TAG_BYTE_LENGTH);
|
||||
const encrypted = ciphertext.subarray(0, ciphertext.length - AES_GCM_AUTH_TAG_BYTE_LENGTH);
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { AES_GCM_KEY_BYTE_LENGTH } from '../consts/crypto/aesGcmKeyByteLength';
|
||||
import { fromBase64Url } from './fromBase64Url';
|
||||
|
||||
const toBase64UrlLikeClient = (bytes: Buffer): string => {
|
||||
let base64 = bytes.toString('base64').split('+').join('-').split('/').join('_');
|
||||
|
||||
while (base64.endsWith('=')) {
|
||||
base64 = base64.slice(0, -1);
|
||||
}
|
||||
|
||||
return base64;
|
||||
};
|
||||
|
||||
describe('fromBase64Url', () => {
|
||||
it('decodes unpadded base64url utf8 payloads', () => {
|
||||
expect(fromBase64Url('aGVsbG8')).toEqual(Buffer.from('hello', 'utf8'));
|
||||
});
|
||||
|
||||
it('decodes empty string to an empty buffer', () => {
|
||||
expect(fromBase64Url('')).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('decodes base64url with - and _ instead of + and /', () => {
|
||||
const bytes = Buffer.from([0xfb, 0xff, 0xfe]);
|
||||
const url = bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
expect(fromBase64Url(url)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it('restores padding when the input omits trailing equals', () => {
|
||||
expect(fromBase64Url('AQIDBA')).toEqual(Buffer.from([0x01, 0x02, 0x03, 0x04]));
|
||||
});
|
||||
|
||||
it('decodes when padding characters are already present', () => {
|
||||
expect(fromBase64Url('YQ==')).toEqual(Buffer.from('a', 'utf8'));
|
||||
expect(fromBase64Url('YWI=')).toEqual(Buffer.from('ab', 'utf8'));
|
||||
});
|
||||
|
||||
it('matches Node base64url decoding for random 32-byte keys', () => {
|
||||
const dek = randomBytes(AES_GCM_KEY_BYTE_LENGTH);
|
||||
const secret = dek.toString('base64url');
|
||||
|
||||
expect(fromBase64Url(secret)).toEqual(dek);
|
||||
expect(fromBase64Url(secret)).toEqual(Buffer.from(secret, 'base64url'));
|
||||
});
|
||||
|
||||
describe('round-trip with client-style encoding', () => {
|
||||
it.each([0, 1, 2, 3, 4, 12, 16, 32, 64])('decodes %i-byte payloads', length => {
|
||||
const bytes = randomBytes(length);
|
||||
const encoded = toBase64UrlLikeClient(bytes);
|
||||
|
||||
expect(fromBase64Url(encoded)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it('decodes fixed-byte patterns used in crypto tests', () => {
|
||||
const iv = Buffer.alloc(12, 0x11);
|
||||
const dek = Buffer.alloc(32, 0x42);
|
||||
|
||||
expect(fromBase64Url(toBase64UrlLikeClient(iv))).toEqual(iv);
|
||||
expect(fromBase64Url(toBase64UrlLikeClient(dek))).toEqual(dek);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agrees with Buffer.from(..., "base64url")', () => {
|
||||
const samples = [
|
||||
'aGVsbG8',
|
||||
toBase64UrlLikeClient(Buffer.from([0xff, 0xfe, 0xfd])),
|
||||
randomBytes(48).toString('base64url')
|
||||
];
|
||||
|
||||
it.each(samples)('matches for encoded sample', encoded => {
|
||||
expect(fromBase64Url(encoded)).toEqual(Buffer.from(encoded, 'base64url'));
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes standard base64 alphabet when plus and slash are passed through', () => {
|
||||
const standard = Buffer.from('binary+/', 'utf8').toString('base64');
|
||||
|
||||
expect(fromBase64Url(standard)).toEqual(Buffer.from('binary+/', 'utf8'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const fromBase64Url = (base64Url: string): Buffer => {
|
||||
let base64 = base64Url.split('-').join('+').split('_').join('/');
|
||||
|
||||
const padding = base64.length % 4;
|
||||
|
||||
if (padding > 0) {
|
||||
base64 += '='.repeat(4 - padding);
|
||||
}
|
||||
|
||||
return Buffer.from(base64, 'base64');
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { isImageMimeType } from './isImageMimeType';
|
||||
|
||||
describe('isImageMimeType', () => {
|
||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'IMAGE/PNG'])('accepts %s', mime => {
|
||||
expect(isImageMimeType(mime)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['text/html', 'application/javascript', 'text/plain', 'image', 'image/'])('rejects %s', mime => {
|
||||
expect(isImageMimeType(mime)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const IMAGE_MIME_PREFIX = 'image/';
|
||||
|
||||
export const isImageMimeType = (mimeType: string): boolean => {
|
||||
const lower = mimeType.toLowerCase();
|
||||
|
||||
return lower.startsWith(IMAGE_MIME_PREFIX) && lower.length > IMAGE_MIME_PREFIX.length;
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
import 'reflect-metadata';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { validateSync, type ValidationError } from 'class-validator';
|
||||
import { AES_GCM_KEY_BYTE_LENGTH } from '../../consts/crypto/aesGcmKeyByteLength';
|
||||
import { IsBase64Url } from './isBase64Url';
|
||||
|
||||
/** Matches frontend `bytesToBase64Url` (strip padding, url-safe alphabet). */
|
||||
const toBase64UrlLikeClient = (bytes: Buffer): string => {
|
||||
let base64 = bytes.toString('base64').split('+').join('-').split('/').join('_');
|
||||
|
||||
while (base64.endsWith('=')) {
|
||||
base64 = base64.slice(0, -1);
|
||||
}
|
||||
|
||||
return base64;
|
||||
};
|
||||
|
||||
class UnboundedBase64UrlDto {
|
||||
@IsBase64Url()
|
||||
payload: string;
|
||||
}
|
||||
|
||||
class TwelveByteBase64UrlDto {
|
||||
@IsBase64Url({ byteLength: 12 })
|
||||
iv: string;
|
||||
}
|
||||
|
||||
class ThirtyTwoByteBase64UrlDto {
|
||||
@IsBase64Url({ byteLength: AES_GCM_KEY_BYTE_LENGTH })
|
||||
secret: string;
|
||||
}
|
||||
|
||||
class DualFieldBase64UrlDto {
|
||||
@IsBase64Url({ byteLength: 12 })
|
||||
iv: string;
|
||||
|
||||
@IsBase64Url()
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
const validateField = <T extends object>(DtoClass: new () => T, property: keyof T & string, value: unknown) => {
|
||||
const dto = Object.assign(new DtoClass(), { [property]: value });
|
||||
|
||||
return validateSync(dto);
|
||||
};
|
||||
|
||||
const constraintMessage = (errors: ValidationError[], property: string): string | undefined => {
|
||||
const error = errors.find(e => e.property === property);
|
||||
|
||||
return error?.constraints?.isBase64Url;
|
||||
};
|
||||
|
||||
const accepts = <T extends object>(DtoClass: new () => T, property: keyof T & string, value: unknown) => {
|
||||
expect(validateField(DtoClass, property, value)).toHaveLength(0);
|
||||
};
|
||||
|
||||
const rejects = <T extends object>(DtoClass: new () => T, property: keyof T & string, value: unknown) => {
|
||||
expect(validateField(DtoClass, property, value).length).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
describe('IsBase64Url', () => {
|
||||
describe('without byteLength', () => {
|
||||
it('accepts unpadded base64url for short utf8 payloads', () => {
|
||||
accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(Buffer.from('hello', 'utf8')));
|
||||
});
|
||||
|
||||
it('accepts Node base64url encoding for binary payloads', () => {
|
||||
accepts(UnboundedBase64UrlDto, 'payload', randomBytes(48).toString('base64url'));
|
||||
});
|
||||
|
||||
it('accepts client-style encoding without padding for 32-byte keys', () => {
|
||||
accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(32, 0x42)));
|
||||
});
|
||||
|
||||
it('accepts hyphen and underscore in the alphabet portion', () => {
|
||||
const raw = Buffer.from([0xfb, 0xff, 0xfe, 0xfd, 0xfb, 0xfa, 0xf9, 0xf8]);
|
||||
|
||||
accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(raw));
|
||||
});
|
||||
|
||||
it.each([null, undefined, 0, 1, false, true, {}, [], Symbol('x')])('rejects non-string value %p', value => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', value);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', '');
|
||||
});
|
||||
|
||||
it('rejects standard base64 with plus and slash', () => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', Buffer.from('hello').toString('base64'));
|
||||
});
|
||||
|
||||
it('rejects padding characters', () => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', 'YQ==');
|
||||
rejects(UnboundedBase64UrlDto, 'payload', 'YWI=');
|
||||
});
|
||||
|
||||
it.each(['@', '#', '$', '%', '.', ',', '!', '?', '\\', '"', "'", '+', '/'])(
|
||||
'rejects disallowed character %s',
|
||||
char => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', `YWJj${char}`);
|
||||
}
|
||||
);
|
||||
|
||||
it('rejects whitespace before valid base64url', () => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', ` ${toBase64UrlLikeClient(Buffer.from('a'))}`);
|
||||
});
|
||||
|
||||
it('rejects whitespace after valid base64url', () => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', `${toBase64UrlLikeClient(Buffer.from('a'))} `);
|
||||
});
|
||||
|
||||
it('rejects embedded newline', () => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', 'YQ\n');
|
||||
});
|
||||
|
||||
it('rejects unicode outside base64url alphabet', () => {
|
||||
rejects(UnboundedBase64UrlDto, 'payload', 'YQé');
|
||||
});
|
||||
|
||||
it('returns the generic error message', () => {
|
||||
const errors = validateField(UnboundedBase64UrlDto, 'payload', 'not!!!valid');
|
||||
|
||||
expect(constraintMessage(errors, 'payload')).toBe('payload must be a valid base64url string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with byteLength', () => {
|
||||
const twelveBytes = Buffer.alloc(12, 0x11);
|
||||
const twelveBytesUrl = toBase64UrlLikeClient(twelveBytes);
|
||||
|
||||
it('accepts base64url that decodes to exactly 12 bytes', () => {
|
||||
accepts(TwelveByteBase64UrlDto, 'iv', twelveBytesUrl);
|
||||
});
|
||||
|
||||
it('accepts 32-byte secrets when byteLength is 32', () => {
|
||||
accepts(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.alloc(32, 0x42)));
|
||||
});
|
||||
|
||||
it('accepts hotlink path secrets from Node base64url', () => {
|
||||
const secret = randomBytes(AES_GCM_KEY_BYTE_LENGTH).toString('base64url');
|
||||
|
||||
accepts(ThirtyTwoByteBase64UrlDto, 'secret', secret);
|
||||
});
|
||||
|
||||
it('rejects valid base64url that decodes to 11 bytes when 12 are required', () => {
|
||||
rejects(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.alloc(11, 0x01)));
|
||||
});
|
||||
|
||||
it('rejects valid base64url that decodes to 13 bytes when 12 are required', () => {
|
||||
rejects(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.alloc(13, 0x01)));
|
||||
});
|
||||
|
||||
it('rejects valid base64url that decodes to 1 byte when 32 are required', () => {
|
||||
rejects(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.from('a')));
|
||||
});
|
||||
|
||||
it('rejects valid base64url that decodes to 31 bytes when 32 are required', () => {
|
||||
rejects(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.alloc(31, 0x01)));
|
||||
});
|
||||
|
||||
it('rejects valid base64url that decodes to 33 bytes when 32 are required', () => {
|
||||
rejects(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.alloc(33, 0x01)));
|
||||
});
|
||||
|
||||
it('rejects invalid base64url even when decoded length would have matched', () => {
|
||||
rejects(TwelveByteBase64UrlDto, 'iv', `${twelveBytesUrl}!`);
|
||||
});
|
||||
|
||||
it('rejects empty string when byteLength is set', () => {
|
||||
rejects(TwelveByteBase64UrlDto, 'iv', '');
|
||||
});
|
||||
|
||||
it('rejects literal too-short path segments', () => {
|
||||
rejects(ThirtyTwoByteBase64UrlDto, 'secret', 'too-short');
|
||||
});
|
||||
|
||||
it('returns byteLength-specific error message', () => {
|
||||
const errors = validateField(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.from('short')));
|
||||
|
||||
expect(constraintMessage(errors, 'iv')).toBe('iv must be a base64url-encoded 12-byte value');
|
||||
});
|
||||
|
||||
it('returns byteLength-specific error message for 32-byte constraint', () => {
|
||||
const errors = validateField(ThirtyTwoByteBase64UrlDto, 'secret', toBase64UrlLikeClient(Buffer.from('x')));
|
||||
|
||||
expect(constraintMessage(errors, 'secret')).toBe('secret must be a base64url-encoded 32-byte value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple decorated properties on one DTO', () => {
|
||||
const iv = toBase64UrlLikeClient(Buffer.alloc(12, 0x99));
|
||||
const ciphertext = toBase64UrlLikeClient(Buffer.alloc(64, 0xcd));
|
||||
|
||||
it('accepts when both iv and ciphertext are valid', () => {
|
||||
const dto = Object.assign(new DualFieldBase64UrlDto(), { iv, ciphertext });
|
||||
|
||||
expect(validateSync(dto)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects when only iv is invalid', () => {
|
||||
const dto = Object.assign(new DualFieldBase64UrlDto(), { iv: 'bad', ciphertext });
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('iv');
|
||||
});
|
||||
|
||||
it('rejects when only ciphertext is invalid', () => {
|
||||
const dto = Object.assign(new DualFieldBase64UrlDto(), { iv, ciphertext: '###' });
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('ciphertext');
|
||||
});
|
||||
|
||||
it('reports both properties when both are invalid', () => {
|
||||
const dto = Object.assign(new DualFieldBase64UrlDto(), { iv: '', ciphertext: '' });
|
||||
|
||||
const errors = validateSync(dto);
|
||||
|
||||
expect(errors.map(e => e.property).sort()).toEqual(['ciphertext', 'iv']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with maxByteLength', () => {
|
||||
class MaxByteLengthDto {
|
||||
@IsBase64Url({ maxByteLength: 64 })
|
||||
payload: string;
|
||||
}
|
||||
|
||||
it('accepts base64url up to the decoded byte limit', () => {
|
||||
accepts(MaxByteLengthDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(64, 0x01)));
|
||||
});
|
||||
|
||||
it('rejects base64url that decodes above the limit', () => {
|
||||
rejects(MaxByteLengthDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(65, 0x01)));
|
||||
});
|
||||
|
||||
it('returns maxByteLength-specific error message', () => {
|
||||
const errors = validateField(MaxByteLengthDto, 'payload', toBase64UrlLikeClient(Buffer.alloc(128, 0x01)));
|
||||
|
||||
expect(constraintMessage(errors, 'payload')).toBe(
|
||||
'payload must be a base64url-encoded value of at most 64 bytes'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default options object', () => {
|
||||
class DefaultOptionsDto {
|
||||
@IsBase64Url({})
|
||||
payload: string;
|
||||
}
|
||||
|
||||
it('behaves like unbounded base64url when options is an empty object', () => {
|
||||
accepts(DefaultOptionsDto, 'payload', toBase64UrlLikeClient(Buffer.from('defaults')));
|
||||
});
|
||||
|
||||
it('rejects wrong byte length only when byteLength is explicitly set', () => {
|
||||
rejects(TwelveByteBase64UrlDto, 'iv', toBase64UrlLikeClient(Buffer.from('x')));
|
||||
accepts(UnboundedBase64UrlDto, 'payload', toBase64UrlLikeClient(Buffer.from('x')));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationArguments,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import { fromBase64Url } from '../../utils/fromBase64Url';
|
||||
import type { IsBase64Options } from '../../types/validation/IsBase64Options';
|
||||
|
||||
const BASE64_URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
@ValidatorConstraint({ name: 'isBase64Url' })
|
||||
class IsBase64UrlConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BASE64_URL_PATTERN.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded = fromBase64Url(value);
|
||||
|
||||
if (decoded.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options];
|
||||
|
||||
if (byteLength !== undefined) {
|
||||
return decoded.length === byteLength;
|
||||
}
|
||||
|
||||
if (maxByteLength !== undefined) {
|
||||
return decoded.length <= maxByteLength;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options];
|
||||
|
||||
if (byteLength !== undefined) {
|
||||
return `$property must be a base64url-encoded ${byteLength}-byte value`;
|
||||
}
|
||||
|
||||
if (maxByteLength !== undefined) {
|
||||
return `$property must be a base64url-encoded value of at most ${maxByteLength} bytes`;
|
||||
}
|
||||
|
||||
return '$property must be a valid base64url string';
|
||||
}
|
||||
}
|
||||
|
||||
export const IsBase64Url = (options: IsBase64Options = {}) => Validate(IsBase64UrlConstraint, [options]);
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'reflect-metadata';
|
||||
import { validateSync, type ValidationError } from 'class-validator';
|
||||
import { IsImageMimeType } from './isImageMimeType';
|
||||
|
||||
class ImageMimeDto {
|
||||
@IsImageMimeType()
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
const validateField = (value: unknown) => {
|
||||
const dto = Object.assign(new ImageMimeDto(), { mimeType: value });
|
||||
|
||||
return validateSync(dto);
|
||||
};
|
||||
|
||||
const constraintMessage = (errors: ValidationError[]): string | undefined => errors[0]?.constraints?.isImageMimeType;
|
||||
|
||||
describe('IsImageMimeType', () => {
|
||||
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'IMAGE/PNG'])('accepts %s', mime => {
|
||||
expect(validateField(mime)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(['text/html', 'application/javascript', 'text/plain', 'image', 'image/'])('rejects %s', mime => {
|
||||
expect(validateField(mime).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([null, undefined, 0, false, {}, []])('rejects non-string value %p', value => {
|
||||
expect(validateField(value).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns the image mime error message', () => {
|
||||
const errors = validateField('text/html');
|
||||
|
||||
expect(constraintMessage(errors)).toBe('mimeType must be an image/* type');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationOptions,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import { isImageMimeType } from '../../utils/isImageMimeType';
|
||||
|
||||
@ValidatorConstraint({ name: 'isImageMimeType' })
|
||||
class IsImageMimeTypeConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
return typeof value === 'string' && isImageMimeType(value);
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return '$property must be an image/* type';
|
||||
}
|
||||
}
|
||||
|
||||
export const IsImageMimeType = (validationOptions?: ValidationOptions) =>
|
||||
Validate(IsImageMimeTypeConstraint, validationOptions);
|
||||
@@ -12,8 +12,13 @@ const envInt = (key: string): number => parseInt(env(key), 10);
|
||||
|
||||
const isEnabled = (key: string): boolean => env(key) === 'true';
|
||||
|
||||
const apiBaseUrl = env('VITE_API_BASE_URL');
|
||||
|
||||
export const config = {
|
||||
baseUrl: env('VITE_API_BASE_URL'),
|
||||
api: {
|
||||
baseUrl: apiBaseUrl,
|
||||
baseUrlRoot: apiBaseUrl.split('/api')[0]
|
||||
},
|
||||
shareOrigins: {
|
||||
clearnet: `${env('VITE_CLEARNET_SCHEME')}://${env('VITE_CLEARNET_DOMAIN')}`,
|
||||
onion: `http://${env('VITE_ONION_HOSTNAME')}`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ROUTE_NAMES = {
|
||||
Upload: 'Upload',
|
||||
Viewer: 'Viewer',
|
||||
HotlinkViewer: 'HotlinkViewer',
|
||||
Delete: 'Delete'
|
||||
} as const;
|
||||
|
||||
@@ -2,5 +2,5 @@ import axios from 'axios';
|
||||
import { config } from '@/config';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: config.baseUrl
|
||||
baseURL: config.api.baseUrl
|
||||
});
|
||||
|
||||
@@ -8,19 +8,25 @@ const router = createRouter({
|
||||
path: '/',
|
||||
name: ROUTE_NAMES.Upload,
|
||||
component: () => import('@/views/UploadView.vue'),
|
||||
meta: { title: 'Private Image — Upload' }
|
||||
meta: { title: 'Private Image - Upload' }
|
||||
},
|
||||
{
|
||||
path: '/v/:id',
|
||||
name: ROUTE_NAMES.Viewer,
|
||||
component: () => import('@/views/ViewerView.vue'),
|
||||
meta: { title: 'Private Image — Viewer' }
|
||||
meta: { title: 'Private Image - Viewer' }
|
||||
},
|
||||
{
|
||||
path: '/h/:id/:secret',
|
||||
name: ROUTE_NAMES.HotlinkViewer,
|
||||
component: () => import('@/views/HotlinkViewerView.vue'),
|
||||
meta: { title: 'Private Image - Viewer' }
|
||||
},
|
||||
{
|
||||
path: '/d/:id',
|
||||
name: ROUTE_NAMES.Delete,
|
||||
component: () => import('@/views/DeleteView.vue'),
|
||||
meta: { title: 'Private Image — Delete' }
|
||||
meta: { title: 'Private Image - Delete' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<main class="flex min-h-screen items-center justify-center p-4">
|
||||
<img v-if="imageSrc" :src="imageSrc" class="max-h-[80vh] max-w-full object-contain" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { config } from '@/config';
|
||||
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const imageSrc = computed((): string | null => {
|
||||
const uploadId = route.params.id;
|
||||
const secret = route.params.secret;
|
||||
|
||||
if (typeof uploadId !== 'string' || typeof secret !== 'string' || !uploadId || !secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { baseUrlRoot } = config.api;
|
||||
|
||||
const path = buildHotlinkImagePath(uploadId, secret);
|
||||
|
||||
return `${baseUrlRoot}${path}`;
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user