diff --git a/backend/src/database/migrations/1791230839248-add-upload-hotlink-access-token-hash.ts b/backend/src/database/migrations/1791230839248-add-upload-hotlink-access-token-hash.ts new file mode 100644 index 0000000..923b5d2 --- /dev/null +++ b/backend/src/database/migrations/1791230839248-add-upload-hotlink-access-token-hash.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUploadHotlinkAccessTokenHash1791230839248 implements MigrationInterface { + name = 'AddUploadHotlinkAccessTokenHash1791230839248'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "upload_hotlink" ADD "accessTokenHash" character varying(64)`); + await queryRunner.query( + `ALTER TABLE "upload_hotlink" ADD CONSTRAINT "UQ_upload_hotlink_accessTokenHash" UNIQUE ("accessTokenHash")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "upload_hotlink" DROP CONSTRAINT "UQ_upload_hotlink_accessTokenHash"` + ); + await queryRunner.query(`ALTER TABLE "upload_hotlink" DROP COLUMN "accessTokenHash"`); + } +} diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts index 12da3ae..e53b618 100644 --- a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts +++ b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts @@ -1,8 +1,13 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsHash, IsNotEmpty, IsString } from 'class-validator'; import { IsImageMimeType } from '../../../validation/decorators/isImageMimeType'; import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto { + @IsNotEmpty() + @IsString() + @IsHash('sha256') + accessTokenHash: string; + @IsNotEmpty() @IsString() @IsImageMimeType() diff --git a/backend/src/modules/upload/entities/UploadHotlink.ts b/backend/src/modules/upload/entities/UploadHotlink.ts index 3b7bf74..3661c8e 100644 --- a/backend/src/modules/upload/entities/UploadHotlink.ts +++ b/backend/src/modules/upload/entities/UploadHotlink.ts @@ -1,7 +1,8 @@ -import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { Upload } from './Upload'; @Entity('upload_hotlink') +@Unique('UQ_upload_hotlink_accessTokenHash', ['accessTokenHash']) export class UploadHotlink { @PrimaryGeneratedColumn('uuid') id: string; @@ -9,6 +10,9 @@ export class UploadHotlink { @Column() mimeType: string; + @Column({ type: 'varchar', length: 64, nullable: true }) + accessTokenHash: string | null; + @OneToOne(() => Upload, upload => upload.hotlink, { onDelete: 'CASCADE' }) @JoinColumn() upload: Upload; diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts index 299d8bf..2227e9b 100644 --- a/backend/src/modules/upload/services/UploadService.spec.ts +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { createCipheriv, randomBytes } from 'node:crypto'; import { hashDeleteToken } from '../../../utils/hashDeleteToken'; +import { hashHotlinkAccessToken } from '../../../utils/hashHotlinkAccessToken'; import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength'; import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { Upload } from '../entities/Upload'; @@ -46,6 +47,9 @@ const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): B return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); }; +const fixedAccessToken = Buffer.alloc(32, 0xcd).toString('base64url'); +const fixedAccessTokenHash = hashHotlinkAccessToken(fixedAccessToken); + describe('UploadService', () => { let service: UploadService; let dataSource: jest.Mocked>; @@ -111,7 +115,8 @@ describe('UploadService', () => { ciphertext, expiresAt: null, maxViews: null, - mimeType: 'image/png' + mimeType: 'image/png', + accessTokenHash: fixedAccessTokenHash }); expect(result).toEqual({ @@ -128,7 +133,7 @@ describe('UploadService', () => { maxViews: null, deleteTokenHash: hashDeleteToken(deleteToken), expiresAt: null, - hotlink: { mimeType: 'image/png' } + hotlink: { mimeType: 'image/png', accessTokenHash: fixedAccessTokenHash } }); expect(uploadRepository.save).toHaveBeenCalledTimes(1); expect(storageService.write).toHaveBeenCalledWith(fixedId, ciphertextBuffer); @@ -202,7 +207,8 @@ describe('UploadService', () => { ciphertext: ciphertextBase64(0xff), expiresAt: null, maxViews: null, - mimeType: 'image/jpeg' + mimeType: 'image/jpeg', + accessTokenHash: fixedAccessTokenHash }) ).rejects.toThrow(BadRequestException); @@ -520,7 +526,8 @@ describe('UploadService', () => { ciphertext: ciphertextBase64(), expiresAt: null, maxViews: null, - mimeType: 'image/png' + mimeType: 'image/png', + accessTokenHash: fixedAccessTokenHash }) ).rejects.toThrow('disk full'); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index fa2a9ef..d7e8ef8 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -83,7 +83,8 @@ export class UploadService { ciphertext, expiresAt, maxViews, - mimeType + mimeType, + accessTokenHash }: CreateHotlinkUploadDto): Promise { const ciphertextBuffer = fromBase64(ciphertext); @@ -104,7 +105,7 @@ export class UploadService { maxViews, deleteTokenHash: hashDeleteToken(deleteToken), expiresAt, - hotlink: { mimeType } + hotlink: { mimeType, accessTokenHash } }); await uploadRepository.save(upload); diff --git a/backend/src/utils/hashHotlinkAccessToken.spec.ts b/backend/src/utils/hashHotlinkAccessToken.spec.ts new file mode 100644 index 0000000..2b57b46 --- /dev/null +++ b/backend/src/utils/hashHotlinkAccessToken.spec.ts @@ -0,0 +1,12 @@ +import { createHash } from 'node:crypto'; +import { hashHotlinkAccessToken } from './hashHotlinkAccessToken'; + +describe('hashHotlinkAccessToken', () => { + it('matches sha256 hex digest of the utf8 access token string', () => { + const accessToken = 'test-hotlink-access-token_base64url'; + + expect(hashHotlinkAccessToken(accessToken)).toBe( + createHash('sha256').update(accessToken, 'utf8').digest('hex') + ); + }); +}); diff --git a/backend/src/utils/hashHotlinkAccessToken.ts b/backend/src/utils/hashHotlinkAccessToken.ts new file mode 100644 index 0000000..9365775 --- /dev/null +++ b/backend/src/utils/hashHotlinkAccessToken.ts @@ -0,0 +1,4 @@ +import { createHash } from 'node:crypto'; + +export const hashHotlinkAccessToken = (accessToken: string): string => + createHash('sha256').update(accessToken, 'utf8').digest('hex');