Merge pull request 'Slave/shorten hotlink urls' (#15) from slave/shorten-hotlink-urls into master
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
"typeorm:generate-migration": "npm run typeorm -- -d ./src/database/DataSource.ts migration:generate ./src/database/migrations/$npm_config_name",
|
||||
"typeorm:revert-migration": "npm run typeorm -- -d ./src/database/DataSource.ts migration:revert",
|
||||
"typeorm:create-migration": "npm run typeorm -- migration:create ./src/database/migrations/$npm_config_name",
|
||||
"truncate-db": "ts-node -r tsconfig-paths/register ./src/database/utils/drop",
|
||||
"setup-fresh-db": "npm run truncate-db && npm run typeorm:run-migrations",
|
||||
"test": "jest"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import dataSource from '../DataSource';
|
||||
import { getAppConfig } from '../../config';
|
||||
import { getErrorMessage } from '../../utils/getErrorMessage';
|
||||
import type { InformationSchemaTableRow } from '../../types/database/InformationSchemaTableRow';
|
||||
import type { PgEnumTypeRow } from '../../types/database/PgEnumTypeRow';
|
||||
import { NodeEnv } from '../../types/NodeEnv';
|
||||
|
||||
const {
|
||||
nodeEnv
|
||||
} = getAppConfig();
|
||||
|
||||
void (async () => {
|
||||
if (nodeEnv === NodeEnv.Development) {
|
||||
console.info('Connecting to the database...');
|
||||
|
||||
const initializedDataSource = await dataSource.initialize();
|
||||
|
||||
console.info('Connected to the database.');
|
||||
|
||||
const tables = await initializedDataSource.query<InformationSchemaTableRow[]>(
|
||||
`SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public';`
|
||||
);
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
await initializedDataSource.query(`DROP TABLE IF EXISTS "${table.table_name}" CASCADE;`);
|
||||
|
||||
console.info(`Dropped table: ${table.table_name}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to drop table: ${table.table_name}: ${getErrorMessage(error)}`);
|
||||
|
||||
return process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const enums = await initializedDataSource.query<PgEnumTypeRow[]>(
|
||||
`SELECT typname FROM pg_type WHERE typtype = 'e';`
|
||||
);
|
||||
|
||||
for (const enumType of enums) {
|
||||
try {
|
||||
await initializedDataSource.query(`DROP TYPE IF EXISTS "${enumType.typname}" CASCADE;`);
|
||||
|
||||
console.info(`Dropped enum: ${enumType.typname}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to drop enum: ${enumType.typname}: ${getErrorMessage(error)}`);
|
||||
|
||||
return process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.info('All tables and enums have been dropped.');
|
||||
|
||||
return process.exit(0);
|
||||
}
|
||||
})();
|
||||
+4
-1
@@ -14,7 +14,10 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||
|
||||
app.setGlobalPrefix('api', {
|
||||
exclude: [{ path: 'i/:id/:secret', method: RequestMethod.GET }]
|
||||
exclude: [
|
||||
{ path: 'i/:id/:secret', method: RequestMethod.GET },
|
||||
{ path: 'i/:accessToken', method: RequestMethod.GET }
|
||||
]
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get, Param, StreamableFile } from '@nestjs/common';
|
||||
import { HotlinkAccessTokenParamsDto } from '../dto/HotlinkAccessTokenParamsDto';
|
||||
import { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto';
|
||||
import { UploadService } from '../services/UploadService';
|
||||
|
||||
@@ -6,10 +7,18 @@ import { UploadService } from '../services/UploadService';
|
||||
export class HotlinkImageController {
|
||||
constructor(private readonly uploadService: UploadService) {}
|
||||
|
||||
/** Legacy route `GET /i/:uploadId/:secret` for hotlink uploads created before single-segment access-token URLs. */
|
||||
@Get(':id/:secret')
|
||||
async getImage(@Param() params: HotlinkImageParamsDto): Promise<StreamableFile> {
|
||||
const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret);
|
||||
|
||||
return new StreamableFile(body, { type: mimeType });
|
||||
}
|
||||
|
||||
@Get(':accessToken')
|
||||
async getImageByAccessToken(@Param() params: HotlinkAccessTokenParamsDto): Promise<StreamableFile> {
|
||||
const { body, mimeType } = await this.uploadService.getHotlinkImageByAccessToken(params.accessToken);
|
||||
|
||||
return new StreamableFile(body, { type: mimeType });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { AES_GCM_KEY_BYTE_LENGTH } from '../../../consts/crypto/aesGcmKeyByteLength';
|
||||
import { IsBase64Url } from '../../../validation/decorators/isBase64Url';
|
||||
|
||||
export class HotlinkAccessTokenParamsDto {
|
||||
@IsNotEmpty()
|
||||
@IsBase64Url({ byteLength: AES_GCM_KEY_BYTE_LENGTH })
|
||||
accessToken: string;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -510,6 +516,62 @@ describe('UploadService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHotlinkImageByAccessToken', () => {
|
||||
const dek = randomBytes(32);
|
||||
const accessToken = dek.toString('base64url');
|
||||
const accessTokenHash = hashHotlinkAccessToken(accessToken);
|
||||
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 access token', async () => {
|
||||
uploadRepository.findOne.mockResolvedValue({
|
||||
id: fixedId,
|
||||
storagePath: fixedId,
|
||||
iv,
|
||||
hotlink: { mimeType: 'image/png', accessTokenHash }
|
||||
});
|
||||
storageService.read.mockResolvedValue(ciphertext);
|
||||
|
||||
await expect(service.getHotlinkImageByAccessToken(accessToken)).resolves.toEqual({
|
||||
body: plaintext,
|
||||
mimeType: 'image/png'
|
||||
});
|
||||
|
||||
expect(uploadRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
mode: UploadMode.Hotlink,
|
||||
hotlink: { accessTokenHash }
|
||||
},
|
||||
relations: ['hotlink']
|
||||
});
|
||||
expect(reserveViewExecute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when no upload matches the token hash', async () => {
|
||||
uploadRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getHotlinkImageByAccessToken(accessToken)).rejects.toThrow(NotFoundException);
|
||||
expect(storageService.read).not.toHaveBeenCalled();
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when access token does not decrypt the ciphertext', async () => {
|
||||
const wrongToken = Buffer.alloc(32, 0x99).toString('base64url');
|
||||
|
||||
uploadRepository.findOne.mockResolvedValue({
|
||||
id: fixedId,
|
||||
storagePath: fixedId,
|
||||
iv,
|
||||
hotlink: { mimeType: 'image/png', accessTokenHash: hashHotlinkAccessToken(wrongToken) }
|
||||
});
|
||||
storageService.read.mockResolvedValue(ciphertext);
|
||||
|
||||
await expect(service.getHotlinkImageByAccessToken(wrongToken)).rejects.toThrow(NotFoundException);
|
||||
expect(reserveViewExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transaction rollback on storage failure', () => {
|
||||
it('propagates storage write errors from the transaction', async () => {
|
||||
storageService.write.mockRejectedValue(new Error('disk full'));
|
||||
@@ -520,7 +582,8 @@ describe('UploadService', () => {
|
||||
ciphertext: ciphertextBase64(),
|
||||
expiresAt: null,
|
||||
maxViews: null,
|
||||
mimeType: 'image/png'
|
||||
mimeType: 'image/png',
|
||||
accessTokenHash: fixedAccessTokenHash
|
||||
})
|
||||
).rejects.toThrow('disk full');
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import { decryptAesGcm } from '../../../utils/decryptAesGcm';
|
||||
import { fromBase64 } from '../../../utils/fromBase64';
|
||||
import { fromBase64Url } from '../../../utils/fromBase64Url';
|
||||
import { hashDeleteToken } from '../../../utils/hashDeleteToken';
|
||||
import { hashHotlinkAccessToken } from '../../../utils/hashHotlinkAccessToken';
|
||||
import { isErrorEnoent } from '../../../utils/isErrorEnoent';
|
||||
import { toBase64 } from '../../../utils/toBase64';
|
||||
import { StorageService } from '../../storage/services/StorageService';
|
||||
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
|
||||
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
|
||||
import { Upload } from '../entities/Upload';
|
||||
import { UploadHotlink } from '../entities/UploadHotlink';
|
||||
import { CreatedUploadResult } from '../types/CreatedUploadResult';
|
||||
import { UploadMode } from '../types/UploadMode';
|
||||
import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult';
|
||||
@@ -83,7 +85,8 @@ export class UploadService {
|
||||
ciphertext,
|
||||
expiresAt,
|
||||
maxViews,
|
||||
mimeType
|
||||
mimeType,
|
||||
accessTokenHash
|
||||
}: CreateHotlinkUploadDto): Promise<CreatedUploadResult> {
|
||||
const ciphertextBuffer = fromBase64(ciphertext);
|
||||
|
||||
@@ -104,7 +107,7 @@ export class UploadService {
|
||||
maxViews,
|
||||
deleteTokenHash: hashDeleteToken(deleteToken),
|
||||
expiresAt,
|
||||
hotlink: { mimeType }
|
||||
hotlink: { mimeType, accessTokenHash }
|
||||
});
|
||||
|
||||
await uploadRepository.save(upload);
|
||||
@@ -170,12 +173,44 @@ export class UploadService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hotlink fetch by upload UUID and URL secret (`GET /i/:uploadId/:secret`).
|
||||
* Backward compatibility only; new links use {@link getHotlinkImageByAccessToken}.
|
||||
*/
|
||||
async getHotlinkImage(id: string, secret: string): Promise<HotlinkImageResult> {
|
||||
const upload = await this.uploadRepository.findOne({
|
||||
where: { id, mode: UploadMode.Hotlink },
|
||||
relations: ['hotlink']
|
||||
});
|
||||
|
||||
this.validateHotlinkViewAllowed(upload);
|
||||
|
||||
const dek = fromBase64Url(secret);
|
||||
|
||||
return this.deliverHotlinkImage(upload, dek);
|
||||
}
|
||||
|
||||
async getHotlinkImageByAccessToken(accessToken: string): Promise<HotlinkImageResult> {
|
||||
const accessTokenHash = hashHotlinkAccessToken(accessToken);
|
||||
|
||||
const upload = await this.uploadRepository.findOne({
|
||||
where: {
|
||||
mode: UploadMode.Hotlink,
|
||||
hotlink: { accessTokenHash }
|
||||
},
|
||||
relations: ['hotlink']
|
||||
});
|
||||
|
||||
this.validateHotlinkViewAllowed(upload);
|
||||
|
||||
const dek = fromBase64Url(accessToken);
|
||||
|
||||
return this.deliverHotlinkImage(upload, dek);
|
||||
}
|
||||
|
||||
private validateHotlinkViewAllowed(
|
||||
upload: (Upload & { hotlink: UploadHotlink | null }) | null
|
||||
): asserts upload is Upload & { hotlink: UploadHotlink } {
|
||||
if (!upload?.hotlink) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@@ -187,7 +222,12 @@ export class UploadService {
|
||||
if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverHotlinkImage(
|
||||
upload: Upload & { hotlink: UploadHotlink },
|
||||
dek: Buffer
|
||||
): Promise<HotlinkImageResult> {
|
||||
let ciphertextBuffer: Buffer;
|
||||
|
||||
try {
|
||||
@@ -203,14 +243,12 @@ export class UploadService {
|
||||
let body: Buffer;
|
||||
|
||||
try {
|
||||
const dek = fromBase64Url(secret);
|
||||
|
||||
body = decryptAesGcm(ciphertextBuffer, dek, upload.iv);
|
||||
} catch {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.reserveViewSlot(id);
|
||||
await this.reserveViewSlot(upload.id);
|
||||
|
||||
return {
|
||||
body,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type InformationSchemaTableRow = {
|
||||
table_name: string;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export type PgEnumTypeRow = {
|
||||
typname: string;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { hashHotlinkAccessToken } from './hashHotlinkAccessToken';
|
||||
|
||||
describe('hashHotlinkAccessToken', () => {
|
||||
it('returns 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')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 64 lowercase hex characters', () => {
|
||||
const hash = hashHotlinkAccessToken('ZWc6ttO-r2qssA_9gdWe6u14X2W0_j7xhRBSVQvfMKI');
|
||||
|
||||
expect(hash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const hashHotlinkAccessToken = (accessToken: string): string =>
|
||||
createHash('sha256').update(accessToken, 'utf8').digest('hex');
|
||||
@@ -35,6 +35,11 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/h/:id/:secret',
|
||||
component: () => import('@/views/HotlinkViewerView.vue'),
|
||||
meta: { title: 'Private Image - Hotlink Viewer' }
|
||||
},
|
||||
{
|
||||
path: '/h/:accessToken',
|
||||
name: ROUTE_NAMES.HotlinkViewer,
|
||||
component: () => import('@/views/HotlinkViewerView.vue'),
|
||||
meta: { title: 'Private Image - Hotlink Viewer' }
|
||||
|
||||
@@ -4,4 +4,5 @@ export type CreateHotlinkUploadRequest = UploadOptionsRequest & {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
mimeType: string;
|
||||
accessTokenHash: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken';
|
||||
|
||||
const hashAccessTokenLikeBackend = (accessToken: string): string =>
|
||||
createHash('sha256').update(accessToken, 'utf8').digest('hex');
|
||||
|
||||
const hotlinkAccessTokenHashVectors = [
|
||||
{
|
||||
accessToken: 'test-hotlink-access-token_base64url',
|
||||
accessTokenHash: '8defaf078afdd0ddabb77c73af4aa76ac78be24b6a6deffba2c6cfde43e20187'
|
||||
},
|
||||
{
|
||||
accessToken: 'ZWc6ttO-r2qssA_9gdWe6u14X2W0_j7xhRBSVQvfMKI',
|
||||
accessTokenHash: 'cdb772980ea3e958cf74b62b70a2f310eafbb9bf3d2302356b602b4830e89bc6'
|
||||
}
|
||||
] as const;
|
||||
|
||||
describe('hashHotlinkAccessToken', () => {
|
||||
it.each(hotlinkAccessTokenHashVectors)(
|
||||
'matches fixed SHA-256 hex for $accessToken',
|
||||
async ({ accessToken, accessTokenHash }) => {
|
||||
await expect(hashHotlinkAccessToken(accessToken)).resolves.toBe(accessTokenHash);
|
||||
}
|
||||
);
|
||||
|
||||
it('matches backend hashHotlinkAccessToken (Node crypto) for arbitrary tokens', async () => {
|
||||
const accessToken = 'another_base64url-style_secret-ABC123';
|
||||
|
||||
await expect(hashHotlinkAccessToken(accessToken)).resolves.toBe(hashAccessTokenLikeBackend(accessToken));
|
||||
});
|
||||
|
||||
it('returns 64 lowercase hex characters (SHA-256 digest)', async () => {
|
||||
const hash = await hashHotlinkAccessToken(hotlinkAccessTokenHashVectors[0].accessToken);
|
||||
|
||||
expect(hash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('changes when the access token string changes', async () => {
|
||||
const [first, second] = hotlinkAccessTokenHashVectors;
|
||||
|
||||
expect(first.accessTokenHash).not.toBe(second.accessTokenHash);
|
||||
});
|
||||
|
||||
it('hashes the UTF-8 string, not base64url-decoded bytes', async () => {
|
||||
const accessToken = 'AQ';
|
||||
|
||||
await expect(hashHotlinkAccessToken(accessToken)).resolves.toBe(hashAccessTokenLikeBackend(accessToken));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export const hashHotlinkAccessToken = async (accessToken: string): Promise<string> => {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const data = encoder.encode(accessToken);
|
||||
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
@@ -1,11 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||
import {
|
||||
buildHotlinkImagePath,
|
||||
buildLegacyHotlinkImagePath
|
||||
} from '@/utils/share/buildHotlinkImagePath';
|
||||
|
||||
describe('buildHotlinkImagePath', () => {
|
||||
it('places upload id and secret in the path segments', () => {
|
||||
const uploadId = 'upload-uuid';
|
||||
const secret = 'url-safe-secret';
|
||||
it('places the access token in a single path segment', () => {
|
||||
expect(buildHotlinkImagePath('url-safe-access-token')).toBe('/i/url-safe-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
expect(buildHotlinkImagePath(uploadId, secret)).toBe(`/i/${uploadId}/${secret}`);
|
||||
describe('buildLegacyHotlinkImagePath', () => {
|
||||
it('places upload id and secret in the path segments', () => {
|
||||
expect(buildLegacyHotlinkImagePath('upload-uuid', 'url-safe-secret')).toBe(
|
||||
'/i/upload-uuid/url-safe-secret'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const buildHotlinkImagePath = (uploadId: string, secret: string): string => `/i/${uploadId}/${secret}`;
|
||||
export const buildHotlinkImagePath = (accessToken: string): string => `/i/${accessToken}`;
|
||||
|
||||
export const buildLegacyHotlinkImagePath = (uploadId: string, secret: string): string => `/i/${uploadId}/${secret}`;
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildHotlinkViewerPath } from '@/utils/share/buildHotlinkViewerPath';
|
||||
import {
|
||||
buildHotlinkViewerPath,
|
||||
buildLegacyHotlinkViewerPath
|
||||
} from '@/utils/share/buildHotlinkViewerPath';
|
||||
|
||||
describe('buildHotlinkViewerPath', () => {
|
||||
it('places the access token in a single viewer path segment', () => {
|
||||
expect(buildHotlinkViewerPath('url-safe-access-token')).toBe('/h/url-safe-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLegacyHotlinkViewerPath', () => {
|
||||
it('places upload id and secret in the viewer path', () => {
|
||||
expect(buildHotlinkViewerPath('upload-uuid', 'url-safe-secret')).toBe('/h/upload-uuid/url-safe-secret');
|
||||
expect(buildLegacyHotlinkViewerPath('upload-uuid', 'url-safe-secret')).toBe(
|
||||
'/h/upload-uuid/url-safe-secret'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const buildHotlinkViewerPath = (uploadId: string, secret: string): string => `/h/${uploadId}/${secret}`;
|
||||
export const buildHotlinkViewerPath = (accessToken: string): string => `/h/${accessToken}`;
|
||||
|
||||
export const buildLegacyHotlinkViewerPath = (uploadId: string, secret: string): string => `/h/${uploadId}/${secret}`;
|
||||
|
||||
@@ -54,17 +54,17 @@ describe('buildUploadedShareUrls', () => {
|
||||
'a <photo> & "shot"'
|
||||
);
|
||||
|
||||
const directClearnet = `${clearnet}/i/upload-id/path_secret`;
|
||||
const directClearnet = `${clearnet}/i/path_secret`;
|
||||
|
||||
expect(urls).toEqual({
|
||||
mode: 'hotlink',
|
||||
viewer: {
|
||||
clearnet: `${clearnet}/h/upload-id/path_secret`,
|
||||
onion: `${onion}/h/upload-id/path_secret`
|
||||
clearnet: `${clearnet}/h/path_secret`,
|
||||
onion: `${onion}/h/path_secret`
|
||||
},
|
||||
direct: {
|
||||
clearnet: directClearnet,
|
||||
onion: `${onion}/i/upload-id/path_secret`
|
||||
onion: `${onion}/i/path_secret`
|
||||
},
|
||||
delete: {
|
||||
clearnet: `${clearnet}/d/upload-id?token=a%2Bb%2Fc`,
|
||||
|
||||
@@ -30,8 +30,8 @@ export const buildUploadedShareUrls = (
|
||||
};
|
||||
|
||||
if (upload.mode === 'hotlink') {
|
||||
const viewerPath = buildHotlinkViewerPath(upload.uploadId, upload.urlSecret);
|
||||
const directPath = buildHotlinkImagePath(upload.uploadId, upload.urlSecret);
|
||||
const viewerPath = buildHotlinkViewerPath(upload.urlSecret);
|
||||
const directPath = buildHotlinkImagePath(upload.urlSecret);
|
||||
|
||||
const viewer = {
|
||||
clearnet: buildSiteShareUrl(clearnet, viewerPath),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
|
||||
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
|
||||
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
|
||||
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
|
||||
import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken';
|
||||
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||
import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
|
||||
|
||||
@@ -22,10 +23,30 @@ describe('prepareHotlinkUploadRequest', () => {
|
||||
...options,
|
||||
mimeType: 'image/png',
|
||||
iv: request.iv,
|
||||
ciphertext: request.ciphertext
|
||||
ciphertext: request.ciphertext,
|
||||
accessTokenHash: request.accessTokenHash
|
||||
});
|
||||
expect(request.accessTokenHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(request).not.toHaveProperty('secret');
|
||||
expect(Object.keys(request).sort()).toEqual(['ciphertext', 'expiresAt', 'iv', 'maxViews', 'mimeType']);
|
||||
expect(Object.keys(request).sort()).toEqual([
|
||||
'accessTokenHash',
|
||||
'ciphertext',
|
||||
'expiresAt',
|
||||
'iv',
|
||||
'maxViews',
|
||||
'mimeType'
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes accessTokenHash derived from secret without sending secret in the request', async () => {
|
||||
const { request, secret } = await prepareHotlinkUploadRequest(
|
||||
new Uint8Array([9]),
|
||||
'image/png',
|
||||
options
|
||||
);
|
||||
|
||||
await expect(hashHotlinkAccessToken(secret)).resolves.toBe(request.accessTokenHash);
|
||||
expect(JSON.stringify(request)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('roundtrips plaintext using request ciphertext and path secret', async () => {
|
||||
@@ -43,17 +64,15 @@ describe('prepareHotlinkUploadRequest', () => {
|
||||
expect(recovered).toEqual(plaintext);
|
||||
});
|
||||
|
||||
it('wires secret into the hotlink path segment', async () => {
|
||||
const uploadId = 'hotlink-upload-id';
|
||||
it('wires secret into the hotlink direct path', async () => {
|
||||
const { request, secret } = await prepareHotlinkUploadRequest(
|
||||
new Uint8Array([1]),
|
||||
'image/png',
|
||||
options
|
||||
);
|
||||
const path = buildHotlinkImagePath(uploadId, secret);
|
||||
const path = buildHotlinkImagePath(secret);
|
||||
|
||||
expect(path).toBe(`/i/${uploadId}/${secret}`);
|
||||
expect(path).toBe(`/i/${secret}`);
|
||||
expect(JSON.stringify(request)).not.toContain(secret);
|
||||
expect(path.endsWith(`/${secret}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
|
||||
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
|
||||
import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken';
|
||||
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
|
||||
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
|
||||
|
||||
@@ -9,6 +10,7 @@ export const prepareHotlinkUploadRequest = async (
|
||||
options: UploadOptionsRequest
|
||||
): Promise<PreparedHotlinkUploadRequest> => {
|
||||
const encrypted = await encryptHotlinkUpload(plaintext);
|
||||
const accessTokenHash = await hashHotlinkAccessToken(encrypted.secret);
|
||||
|
||||
return {
|
||||
secret: encrypted.secret,
|
||||
@@ -16,7 +18,8 @@ export const prepareHotlinkUploadRequest = async (
|
||||
...options,
|
||||
iv: bytesToBase64(encrypted.iv),
|
||||
ciphertext: bytesToBase64(encrypted.ciphertext),
|
||||
mimeType
|
||||
mimeType,
|
||||
accessTokenHash
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,7 +28,12 @@ import { onBeforeMount, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { config } from '@/config';
|
||||
import type { HotlinkViewerViewState } from '@/types/view/HotlinkViewerViewState';
|
||||
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
|
||||
import {
|
||||
buildHotlinkImagePath,
|
||||
buildLegacyHotlinkImagePath
|
||||
} from '@/utils/share/buildHotlinkImagePath';
|
||||
|
||||
const { baseUrlRoot } = config.api;
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -36,19 +41,29 @@ const state = ref<HotlinkViewerViewState>('loading');
|
||||
const imageSrc = ref<string | null>(null);
|
||||
|
||||
onBeforeMount(() => {
|
||||
const accessToken = route.params.accessToken;
|
||||
const uploadId = route.params.id;
|
||||
const secret = route.params.secret;
|
||||
|
||||
if (typeof uploadId !== 'string' || typeof secret !== 'string' || !uploadId || !secret) {
|
||||
let path: string | null = null;
|
||||
|
||||
if (typeof accessToken === 'string' && accessToken) {
|
||||
path = buildHotlinkImagePath(accessToken);
|
||||
} else if (
|
||||
typeof uploadId === 'string' &&
|
||||
typeof secret === 'string' &&
|
||||
uploadId &&
|
||||
secret
|
||||
) {
|
||||
path = buildLegacyHotlinkImagePath(uploadId, secret);
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
state.value = 'invalidLink';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { baseUrlRoot } = config.api;
|
||||
|
||||
const path = buildHotlinkImagePath(uploadId, secret);
|
||||
|
||||
imageSrc.value = `${baseUrlRoot}${path}`;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user