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:
2026-10-27 13:25:53 +00:00
24 changed files with 823 additions and 11 deletions
@@ -0,0 +1 @@
export const AES_GCM_KEY_BYTE_LENGTH = 32;
+4 -2
View File
@@ -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);
+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,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;
};
+107
View File
@@ -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();
});
});
});
+27
View File
@@ -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()]);
};
+82
View File
@@ -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'));
});
});
+11
View File
@@ -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');
};
+11
View File
@@ -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);
});
});
+7
View File
@@ -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);
+6 -1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
export const ROUTE_NAMES = {
Upload: 'Upload',
Viewer: 'Viewer',
HotlinkViewer: 'HotlinkViewer',
Delete: 'Delete'
} as const;
+1 -1
View File
@@ -2,5 +2,5 @@ import axios from 'axios';
import { config } from '@/config';
export const api = axios.create({
baseURL: config.baseUrl
baseURL: config.api.baseUrl
});
+9 -3
View File
@@ -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' }
}
]
});
+29
View File
@@ -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>