Slave/shorten hotlink urls #15
@@ -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 { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
|
||||
|
||||
export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsHash('sha256')
|
||||
accessTokenHash: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@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';
|
||||
|
||||
@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;
|
||||
|
||||
@@ -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<Pick<DataSource, 'transaction'>>;
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ export class UploadService {
|
||||
ciphertext,
|
||||
expiresAt,
|
||||
maxViews,
|
||||
mimeType
|
||||
mimeType,
|
||||
accessTokenHash
|
||||
}: CreateHotlinkUploadDto): Promise<CreatedUploadResult> {
|
||||
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);
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user