diff --git a/backend/package.json b/backend/package.json index 5c83a44..b3d4e3b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { 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/database/utils/drop.ts b/backend/src/database/utils/drop.ts new file mode 100644 index 0000000..cb3917c --- /dev/null +++ b/backend/src/database/utils/drop.ts @@ -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( + `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( + `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); + } +})(); diff --git a/backend/src/main.ts b/backend/src/main.ts index ecce0c9..a8ddc85 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -14,7 +14,10 @@ async function bootstrap() { const app = await NestFactory.create(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); diff --git a/backend/src/modules/upload/controllers/HotlinkImageController.ts b/backend/src/modules/upload/controllers/HotlinkImageController.ts index f63d11a..4e87966 100644 --- a/backend/src/modules/upload/controllers/HotlinkImageController.ts +++ b/backend/src/modules/upload/controllers/HotlinkImageController.ts @@ -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 { 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 { + const { body, mimeType } = await this.uploadService.getHotlinkImageByAccessToken(params.accessToken); + + return new StreamableFile(body, { type: mimeType }); + } } 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/dto/HotlinkAccessTokenParamsDto.ts b/backend/src/modules/upload/dto/HotlinkAccessTokenParamsDto.ts new file mode 100644 index 0000000..d404cf3 --- /dev/null +++ b/backend/src/modules/upload/dto/HotlinkAccessTokenParamsDto.ts @@ -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; +} 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..724d94a 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); @@ -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'); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts index fa2a9ef..6e597c4 100644 --- a/backend/src/modules/upload/services/UploadService.ts +++ b/backend/src/modules/upload/services/UploadService.ts @@ -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 { 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 { 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 { + 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 { 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, diff --git a/backend/src/types/database/InformationSchemaTableRow.ts b/backend/src/types/database/InformationSchemaTableRow.ts new file mode 100644 index 0000000..4eba891 --- /dev/null +++ b/backend/src/types/database/InformationSchemaTableRow.ts @@ -0,0 +1,3 @@ +export type InformationSchemaTableRow = { + table_name: string; +}; diff --git a/backend/src/types/database/PgEnumTypeRow.ts b/backend/src/types/database/PgEnumTypeRow.ts new file mode 100644 index 0000000..efbe1d6 --- /dev/null +++ b/backend/src/types/database/PgEnumTypeRow.ts @@ -0,0 +1,3 @@ +export type PgEnumTypeRow = { + typname: string; +}; diff --git a/backend/src/utils/getErrorMessage.ts b/backend/src/utils/getErrorMessage.ts new file mode 100644 index 0000000..84ba131 --- /dev/null +++ b/backend/src/utils/getErrorMessage.ts @@ -0,0 +1 @@ +export const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error)); diff --git a/backend/src/utils/hashHotlinkAccessToken.spec.ts b/backend/src/utils/hashHotlinkAccessToken.spec.ts new file mode 100644 index 0000000..1259032 --- /dev/null +++ b/backend/src/utils/hashHotlinkAccessToken.spec.ts @@ -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}$/); + }); +}); 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'); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 80a41ac..7db6924 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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' } diff --git a/frontend/src/types/upload/CreateHotlinkUploadRequest.ts b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts index c53a50e..b665581 100644 --- a/frontend/src/types/upload/CreateHotlinkUploadRequest.ts +++ b/frontend/src/types/upload/CreateHotlinkUploadRequest.ts @@ -4,4 +4,5 @@ export type CreateHotlinkUploadRequest = UploadOptionsRequest & { iv: string; ciphertext: string; mimeType: string; + accessTokenHash: string; }; diff --git a/frontend/src/utils/crypto/hashHotlinkAccessToken.spec.ts b/frontend/src/utils/crypto/hashHotlinkAccessToken.spec.ts new file mode 100644 index 0000000..7968f2b --- /dev/null +++ b/frontend/src/utils/crypto/hashHotlinkAccessToken.spec.ts @@ -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)); + }); +}); diff --git a/frontend/src/utils/crypto/hashHotlinkAccessToken.ts b/frontend/src/utils/crypto/hashHotlinkAccessToken.ts new file mode 100644 index 0000000..d513106 --- /dev/null +++ b/frontend/src/utils/crypto/hashHotlinkAccessToken.ts @@ -0,0 +1,9 @@ +export const hashHotlinkAccessToken = async (accessToken: string): Promise => { + 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(''); +}; diff --git a/frontend/src/utils/share/buildHotlinkImagePath.spec.ts b/frontend/src/utils/share/buildHotlinkImagePath.spec.ts index 3f1eda5..d309d55 100644 --- a/frontend/src/utils/share/buildHotlinkImagePath.spec.ts +++ b/frontend/src/utils/share/buildHotlinkImagePath.spec.ts @@ -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 the access token in a single path segment', () => { + expect(buildHotlinkImagePath('url-safe-access-token')).toBe('/i/url-safe-access-token'); + }); +}); + +describe('buildLegacyHotlinkImagePath', () => { it('places upload id and secret in the path segments', () => { - const uploadId = 'upload-uuid'; - const secret = 'url-safe-secret'; - - expect(buildHotlinkImagePath(uploadId, secret)).toBe(`/i/${uploadId}/${secret}`); + expect(buildLegacyHotlinkImagePath('upload-uuid', 'url-safe-secret')).toBe( + '/i/upload-uuid/url-safe-secret' + ); }); }); diff --git a/frontend/src/utils/share/buildHotlinkImagePath.ts b/frontend/src/utils/share/buildHotlinkImagePath.ts index 0c4e6ce..548ed44 100644 --- a/frontend/src/utils/share/buildHotlinkImagePath.ts +++ b/frontend/src/utils/share/buildHotlinkImagePath.ts @@ -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}`; diff --git a/frontend/src/utils/share/buildHotlinkViewerPath.spec.ts b/frontend/src/utils/share/buildHotlinkViewerPath.spec.ts index 110ee63..88e93c4 100644 --- a/frontend/src/utils/share/buildHotlinkViewerPath.spec.ts +++ b/frontend/src/utils/share/buildHotlinkViewerPath.spec.ts @@ -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' + ); }); }); diff --git a/frontend/src/utils/share/buildHotlinkViewerPath.ts b/frontend/src/utils/share/buildHotlinkViewerPath.ts index acb369c..38e069e 100644 --- a/frontend/src/utils/share/buildHotlinkViewerPath.ts +++ b/frontend/src/utils/share/buildHotlinkViewerPath.ts @@ -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}`; diff --git a/frontend/src/utils/share/buildUploadedShareUrls.spec.ts b/frontend/src/utils/share/buildUploadedShareUrls.spec.ts index 12fd42a..f3b92ae 100644 --- a/frontend/src/utils/share/buildUploadedShareUrls.spec.ts +++ b/frontend/src/utils/share/buildUploadedShareUrls.spec.ts @@ -54,17 +54,17 @@ describe('buildUploadedShareUrls', () => { 'a & "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`, diff --git a/frontend/src/utils/share/buildUploadedShareUrls.ts b/frontend/src/utils/share/buildUploadedShareUrls.ts index 1112dd8..97325e8 100644 --- a/frontend/src/utils/share/buildUploadedShareUrls.ts +++ b/frontend/src/utils/share/buildUploadedShareUrls.ts @@ -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), diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts index 0453cab..16296e2 100644 --- a/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.spec.ts @@ -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); }); }); diff --git a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts index 5135adf..a96d0fc 100644 --- a/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts +++ b/frontend/src/utils/upload/prepareHotlinkUploadRequest.ts @@ -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 => { 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 } }; }; diff --git a/frontend/src/views/HotlinkViewerView.vue b/frontend/src/views/HotlinkViewerView.vue index 6fefeba..fea1ca8 100644 --- a/frontend/src/views/HotlinkViewerView.vue +++ b/frontend/src/views/HotlinkViewerView.vue @@ -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('loading'); const imageSrc = ref(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}`; });