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 { 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');
|
||||||
Reference in New Issue
Block a user