Store hotlink access token hash on upload for short URL lookup.

This commit is contained in:
2026-11-01 23:17:29 +01:00
parent a82fc6d6c0
commit c97303850a
7 changed files with 60 additions and 8 deletions
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUploadHotlinkAccessTokenHash1791230839248 implements MigrationInterface {
name = 'AddUploadHotlinkAccessTokenHash1791230839248';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "upload_hotlink" DROP CONSTRAINT "UQ_upload_hotlink_accessTokenHash"`
);
await queryRunner.query(`ALTER TABLE "upload_hotlink" DROP COLUMN "accessTokenHash"`);
}
}
@@ -1,8 +1,13 @@
import { IsNotEmpty, IsString } from 'class-validator'; import { IsHash, IsNotEmpty, IsString } from 'class-validator';
import { IsImageMimeType } from '../../../validation/decorators/isImageMimeType'; import { IsImageMimeType } from '../../../validation/decorators/isImageMimeType';
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto { export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto {
@IsNotEmpty()
@IsString()
@IsHash('sha256')
accessTokenHash: string;
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@IsImageMimeType() @IsImageMimeType()
@@ -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'; import { Upload } from './Upload';
@Entity('upload_hotlink') @Entity('upload_hotlink')
@Unique('UQ_upload_hotlink_accessTokenHash', ['accessTokenHash'])
export class UploadHotlink { export class UploadHotlink {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@@ -9,6 +10,9 @@ export class UploadHotlink {
@Column() @Column()
mimeType: string; mimeType: string;
@Column({ type: 'varchar', length: 64, nullable: true })
accessTokenHash: string | null;
@OneToOne(() => Upload, upload => upload.hotlink, { onDelete: 'CASCADE' }) @OneToOne(() => Upload, upload => upload.hotlink, { onDelete: 'CASCADE' })
@JoinColumn() @JoinColumn()
upload: Upload; upload: Upload;
@@ -1,6 +1,7 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { createCipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, randomBytes } from 'node:crypto';
import { hashDeleteToken } from '../../../utils/hashDeleteToken'; import { hashDeleteToken } from '../../../utils/hashDeleteToken';
import { hashHotlinkAccessToken } from '../../../utils/hashHotlinkAccessToken';
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength'; import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { Upload } from '../entities/Upload'; 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()]); return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
}; };
const fixedAccessToken = Buffer.alloc(32, 0xcd).toString('base64url');
const fixedAccessTokenHash = hashHotlinkAccessToken(fixedAccessToken);
describe('UploadService', () => { describe('UploadService', () => {
let service: UploadService; let service: UploadService;
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>; let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
@@ -111,7 +115,8 @@ describe('UploadService', () => {
ciphertext, ciphertext,
expiresAt: null, expiresAt: null,
maxViews: null, maxViews: null,
mimeType: 'image/png' mimeType: 'image/png',
accessTokenHash: fixedAccessTokenHash
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -128,7 +133,7 @@ describe('UploadService', () => {
maxViews: null, maxViews: null,
deleteTokenHash: hashDeleteToken(deleteToken), deleteTokenHash: hashDeleteToken(deleteToken),
expiresAt: null, expiresAt: null,
hotlink: { mimeType: 'image/png' } hotlink: { mimeType: 'image/png', accessTokenHash: fixedAccessTokenHash }
}); });
expect(uploadRepository.save).toHaveBeenCalledTimes(1); expect(uploadRepository.save).toHaveBeenCalledTimes(1);
expect(storageService.write).toHaveBeenCalledWith(fixedId, ciphertextBuffer); expect(storageService.write).toHaveBeenCalledWith(fixedId, ciphertextBuffer);
@@ -202,7 +207,8 @@ describe('UploadService', () => {
ciphertext: ciphertextBase64(0xff), ciphertext: ciphertextBase64(0xff),
expiresAt: null, expiresAt: null,
maxViews: null, maxViews: null,
mimeType: 'image/jpeg' mimeType: 'image/jpeg',
accessTokenHash: fixedAccessTokenHash
}) })
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
@@ -520,7 +526,8 @@ describe('UploadService', () => {
ciphertext: ciphertextBase64(), ciphertext: ciphertextBase64(),
expiresAt: null, expiresAt: null,
maxViews: null, maxViews: null,
mimeType: 'image/png' mimeType: 'image/png',
accessTokenHash: fixedAccessTokenHash
}) })
).rejects.toThrow('disk full'); ).rejects.toThrow('disk full');
@@ -83,7 +83,8 @@ export class UploadService {
ciphertext, ciphertext,
expiresAt, expiresAt,
maxViews, maxViews,
mimeType mimeType,
accessTokenHash
}: CreateHotlinkUploadDto): Promise<CreatedUploadResult> { }: CreateHotlinkUploadDto): Promise<CreatedUploadResult> {
const ciphertextBuffer = fromBase64(ciphertext); const ciphertextBuffer = fromBase64(ciphertext);
@@ -104,7 +105,7 @@ export class UploadService {
maxViews, maxViews,
deleteTokenHash: hashDeleteToken(deleteToken), deleteTokenHash: hashDeleteToken(deleteToken),
expiresAt, expiresAt,
hotlink: { mimeType } hotlink: { mimeType, accessTokenHash }
}); });
await uploadRepository.save(upload); await uploadRepository.save(upload);
@@ -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')
);
});
});
@@ -0,0 +1,4 @@
import { createHash } from 'node:crypto';
export const hashHotlinkAccessToken = (accessToken: string): string =>
createHash('sha256').update(accessToken, 'utf8').digest('hex');