Compare commits

...
13 Commits
Author SHA1 Message Date
nobswebdev 1b36fbaeba Show ExifTool errors in metadata UI with short queue labels.
Store detailMessage from worker failures; inspector shows full text, queue keeps fixed summary messages.
2026-11-03 19:10:44 +01:00
nobswebdev 0de7a87938 Share axios download percent helper for ExifTool WASM.
Use entry.message for metadata initializing status instead of hardcoded copy.
2026-11-03 18:19:20 +01:00
nobswebdev 2290040242 Improve ZK and hotlink viewer image layout and add open-full-image link.
Share ViewerImageDisplay with viewport-aware sizing so wide panoramas are not capped at max-w-lg.
2026-11-03 17:41:33 +01:00
nobswebdev af3109c564 Merge pull request 'Slave/shorten hotlink urls' (#15) from slave/shorten-hotlink-urls into master
Reviewed-on: #15
2026-11-02 14:18:06 +00:00
nobswebdev 6effab12eb Quote enum type names in dev truncate-db DROP TYPE statements.
Matches DROP TABLE identifier quoting so reserved or mixed-case names drop safely.
2026-11-02 14:40:32 +01:00
nobswebdev 0d8d682990 Emit single-segment hotlink share and viewer URLs.
Legacy /i and /h two-segment routes remain supported in the router and viewer.
2026-11-02 14:20:12 +01:00
nobswebdev 2396c5d16d Send hotlink access token hash on upload; keep secret client-side only. 2026-11-02 01:46:51 +01:00
nobswebdev 0ef4993ad9 Serve hotlink images by single-segment access token URL.
Legacy GET /i/:uploadId/:secret unchanged. Lookup new uploads by access token hash; decrypt with token from path.
2026-11-02 00:24:02 +01:00
nobswebdev c97303850a Store hotlink access token hash on upload for short URL lookup. 2026-11-01 23:17:29 +01:00
nobswebdev a82fc6d6c0 Add setup-fresh-db script to reset dev database and run migrations. 2026-11-01 22:59:28 +01:00
nobswebdev fb482a1052 Sniff image MIME for ZK viewer blobs so save-as gets a real extension. 2026-11-01 15:32:25 +01:00
nobswebdev 402e9df255 Move About page static images out of /about to fix nginx 403.
Renaming public/about to about-media avoids try_files serving a directory on GET /about so the SPA route loads in new tabs.
2026-10-31 05:33:49 +01:00
nobswebdev da2d020a45 Show ExifTool WASM download progress on upload queue rows.
Stream progress from the worker via axios, expose percent in the exiftool store, and format it on initializing metadata lines.
2026-10-31 04:14:55 +01:00
48 changed files with 744 additions and 80 deletions
+2
View File
@@ -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"`);
}
}
+58
View File
@@ -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
View File
@@ -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;
};
+1
View File
@@ -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');
+140
View File
@@ -16,6 +16,7 @@
"dayjs": "^1.11.23",
"escape-html": "^1.0.3",
"hash-wasm": "^4.12.0",
"image-type": "^6.0.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
"tailwindcss": "^4.3.3",
@@ -86,6 +87,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@borewit/text-codec": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.3.tgz",
"integrity": "sha512-49CycTz1aAZmvFcwkeFyhud1HUmRIB5Tabf7ALV+VA4Iu85MTfz2cUwzPagMFAdVBvrWdkCxG67I30tUwwpKsw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1701,6 +1712,30 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
"integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"fflate": "^0.8.2",
"token-types": "^6.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -2509,6 +2544,30 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-type": {
"version": "20.5.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-20.5.0.tgz",
"integrity": "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==",
"license": "MIT",
"dependencies": {
"@tokenizer/inflate": "^0.2.6",
"strtok3": "^10.2.0",
"token-types": "^6.0.0",
"uint8array-extras": "^1.4.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -2688,6 +2747,41 @@
"node": ">= 6"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/image-type": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/image-type/-/image-type-6.0.0.tgz",
"integrity": "sha512-efpcYd/E9A7a+oanft11ceIbO9Aw0iszfJ7Qfh4QLWl2Ulsth9nnllV/L1TmzKwlQ2O5FuT08vy5zxLnGxZe8w==",
"license": "MIT",
"dependencies": {
"file-type": "^20.5.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -3388,6 +3482,22 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strtok3": {
"version": "10.6.3",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.3.tgz",
"integrity": "sha512-MD8mENgyXMr2mDaDfzP3RWYDSyYQxIIou7HhCiCJiBekrsoDtUG0id3vYnjDfY9ndOEbazFXe13Qw6u/1AB8ew==",
"license": "MIT",
"dependencies": {
"@tokenizer/token": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -3467,6 +3577,24 @@
"node": ">=14.0.0"
}
},
"node_modules/token-types": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT",
"dependencies": {
"@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -3488,6 +3616,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/uint8array-extras": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz",
"integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"dayjs": "^1.11.23",
"escape-html": "^1.0.3",
"hash-wasm": "^4.12.0",
"image-type": "^6.0.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
"tailwindcss": "^4.3.3",

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+1
View File
@@ -28,5 +28,6 @@ declare module 'vue' {
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
UploadShareResults: typeof import('./components/upload/UploadShareResults.vue')['default']
ViewerImageDisplay: typeof import('./components/viewer/ViewerImageDisplay.vue')['default']
}
}
@@ -10,7 +10,7 @@
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
<template v-else-if="entry.status === 'error' && !entry.output">
<p class="text-sm text-destructive">{{ entry.message }}</p>
<p class="whitespace-pre-wrap text-sm text-destructive">{{ entry.detailMessage ?? entry.message }}</p>
</template>
<template v-else-if="showMetadataTabs">
@@ -18,7 +18,7 @@
<MetadataOutputPanel v-if="tabBody?.kind === 'output'">{{ tabBody.text }}</MetadataOutputPanel>
<p v-else-if="tabBody?.kind === 'error'" class="text-sm text-destructive">{{ tabBody.text }}</p>
<p v-else-if="tabBody?.kind === 'error'" class="whitespace-pre-wrap text-sm text-destructive">{{ tabBody.text }}</p>
</template>
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
@@ -83,7 +83,7 @@ const tabBody = computed((): MetadataTabBody | null => {
}
if (entry.status === 'error') {
return { kind: 'error', text: entry.message };
return { kind: 'error', text: entry.detailMessage ?? entry.message };
}
return null;
@@ -14,11 +14,7 @@
loading="lazy"
decoding="async"
/>
<span
v-else
class="h-10 w-10 shrink-0 rounded border border-border bg-muted/60"
aria-hidden="true"
/>
<span v-else class="h-10 w-10 shrink-0 rounded border border-border bg-muted/60" aria-hidden="true" />
<div class="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-baseline justify-between gap-2">
<span class="min-w-0 truncate text-sm font-medium">{{ row.file.name }}</span>
@@ -28,7 +24,7 @@
</div>
<div class="flex min-w-0 flex-col gap-0.5 text-xs">
<span class="min-w-0 truncate" :class="metadataStatusClass(row.metadata)">
{{ row.metadata.message }}
{{ formatMetadataPreviewMessage(row.metadata) }}
</span>
<span class="min-w-0 truncate" :class="uploadStatusClass(row.upload)">
{{ row.upload.message }}
@@ -41,13 +37,19 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { type PropType } from 'vue';
import { useExiftoolStore } from '@/stores/exiftool';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import type { UploadQueueRow } from '@/types/upload/UploadQueueRow';
import prettyBytes from 'pretty-bytes';
const exiftoolStore = useExiftoolStore();
const { wasmDownloadPercent } = storeToRefs(exiftoolStore);
const props = defineProps({
rows: {
type: Array as PropType<UploadQueueRow[]>,
@@ -89,4 +91,18 @@ const uploadStatusClass = (entry: UploadQueueEntry): string => {
return 'text-muted-foreground';
};
const formatMetadataPreviewMessage = (entry: MetadataPreviewEntry): string => {
if (entry.status === 'initializing') {
const percent = wasmDownloadPercent.value;
if (percent === null) {
return entry.message;
}
return `${entry.message} ${percent}%`;
}
return entry.message;
};
</script>
@@ -0,0 +1,40 @@
<template>
<div class="flex w-full min-h-0 flex-col items-center justify-center gap-3 text-center">
<img
:src="src"
decoding="async"
class="mx-auto block h-auto w-auto max-h-[min(calc(100dvh-12rem),calc(100vh-12rem))] max-w-[min(100%,calc(100vw-2rem))] object-contain"
:class="{ hidden: visuallyHidden }"
@load="emit('load')"
@error="emit('error')"
/>
<a
v-if="!visuallyHidden"
:href="src"
class="text-sm text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
rel="noopener noreferrer"
target="_blank"
>
Open full image
</a>
</div>
</template>
<script setup lang="ts">
defineProps({
src: {
type: String,
required: true
},
visuallyHidden: {
type: Boolean,
default: false
}
});
const emit = defineEmits<{
load: [];
error: [];
}>();
</script>
+5
View File
@@ -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' }
+11
View File
@@ -6,6 +6,7 @@ import type { ExiftoolWorkerOutMessage } from '@/types/workers/exiftoolWorkerOut
export const useExiftoolStore = defineStore('exiftool', () => {
const initStatus = ref<ExiftoolInitStatus>('idle');
const initErrorMessage = ref<string | null>(null);
const wasmDownloadPercent = ref<number | null>(null);
const worker = ref<Worker | null>(null);
@@ -35,6 +36,7 @@ export const useExiftoolStore = defineStore('exiftool', () => {
if (initStatus.value === 'idle') {
initStatus.value = 'loading';
initErrorMessage.value = null;
wasmDownloadPercent.value = 0;
initPromise = new Promise<void>((resolve, reject) => {
initPromiseResolve = resolve;
initPromiseReject = reject;
@@ -69,8 +71,15 @@ export const useExiftoolStore = defineStore('exiftool', () => {
const handleWorkerMessage = (event: MessageEvent<ExiftoolWorkerOutMessage>): void => {
const message = event.data;
if (message.type === 'wasm-download-progress') {
wasmDownloadPercent.value = message.percent;
return;
}
if (message.type === 'init-done') {
initStatus.value = 'ready';
wasmDownloadPercent.value = null;
initPromiseResolve?.();
initPromiseResolve = null;
initPromiseReject = null;
@@ -81,6 +90,7 @@ export const useExiftoolStore = defineStore('exiftool', () => {
if (message.type === 'init-error') {
console.error('[exiftool] init-error', message.message);
initStatus.value = 'error';
wasmDownloadPercent.value = null;
initErrorMessage.value = message.message;
initPromiseReject?.(new Error(message.message));
initPromiseResolve = null;
@@ -181,6 +191,7 @@ export const useExiftoolStore = defineStore('exiftool', () => {
return {
initStatus,
initErrorMessage,
wasmDownloadPercent,
initializeExiftool,
readMetadata,
stripAllMetadata
+20 -10
View File
@@ -3,9 +3,10 @@ import { ref } from 'vue';
import { useExiftoolStore } from '@/stores/exiftool';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata...';
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool. It may take a minute on slow networks...';
const EXIFTOOL_INITIALIZING_MESSAGE = 'Downloading ExifTool…';
const METADATA_PROCESSING_MESSAGE = 'Stripping metadata...';
const METADATA_READY_MESSAGE = 'Metadata stripped';
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
@@ -17,11 +18,6 @@ const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
message: METADATA_IDLE_MESSAGE
};
const METADATA_LOAD_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
@@ -96,9 +92,15 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read failed', { file: file.name, error });
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE,
detailMessage: resolveErrorMessage(error, METADATA_LOAD_ERROR_MESSAGE)
};
return METADATA_LOAD_ERROR_PREVIEW;
setPreviewEntry(key, entry);
return entry;
}
let strippedBlob: Blob;
@@ -116,6 +118,7 @@ export const useMetadataStore = defineStore('metadata', () => {
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_STRIP_ERROR_MESSAGE,
detailMessage: resolveErrorMessage(error, METADATA_STRIP_ERROR_MESSAGE),
output
};
@@ -136,9 +139,16 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read after strip failed', { file: file.name, error });
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE,
detailMessage: resolveErrorMessage(error, METADATA_LOAD_ERROR_MESSAGE),
output
};
return METADATA_LOAD_ERROR_PREVIEW;
setPreviewEntry(key, entry);
return entry;
}
setStrippedBlob(key, strippedBlob);
+4 -1
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue';
import { api } from '@/plugins/axios';
import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { detectImageMimeType } from '@/utils/file/detectImageMimeType';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
@@ -67,7 +68,9 @@ export const useZkViewStore = defineStore('zkView', () => {
throw new Error('Could not build image blob');
}
imageObjectUrl.value = URL.createObjectURL(new Blob([buffer]));
const mimeType = await detectImageMimeType(plaintext);
imageObjectUrl.value = URL.createObjectURL(new Blob([buffer], { type: mimeType }));
};
return {
@@ -3,4 +3,4 @@ export type MetadataPreviewEntry =
| { status: 'initializing'; message: string }
| { status: 'processing'; message: string }
| { status: 'ready'; message: string; output: string; afterStripOutput: string }
| { status: 'error'; message: string; output?: string };
| { status: 'error'; message: string; output?: string; detailMessage?: string };
@@ -4,4 +4,5 @@ export type CreateHotlinkUploadRequest = UploadOptionsRequest & {
iv: string;
ciphertext: string;
mimeType: string;
accessTokenHash: string;
};
@@ -1,4 +1,5 @@
export type ExiftoolWorkerOutMessage =
| { type: 'wasm-download-progress'; percent: number }
| { type: 'init-done' }
| { type: 'init-error'; message: string }
| { type: 'read-metadata-done'; id: string; output: 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('');
};
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { detectImageMimeType } from '@/utils/file/detectImageMimeType';
const padToMinDetectLength = (header: number[]): Uint8Array => {
const out = new Uint8Array(4100);
out.set(header);
return out;
};
describe('detectImageMimeType', () => {
it('detects PNG', async () => {
const png = padToMinDetectLength([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await expect(detectImageMimeType(png)).resolves.toBe('image/png');
});
it('detects JPEG', async () => {
const jpeg = padToMinDetectLength([0xff, 0xd8, 0xff, 0xe0]);
await expect(detectImageMimeType(jpeg)).resolves.toBe('image/jpeg');
});
it('falls back for unknown bytes', async () => {
await expect(detectImageMimeType(new Uint8Array(4100))).resolves.toBe(
'application/octet-stream'
);
});
});
@@ -0,0 +1,7 @@
import imageType from 'image-type';
export const detectImageMimeType = async (data: Uint8Array): Promise<string> => {
const result = await imageType(data);
return result?.mime ?? 'application/octet-stream';
};
@@ -0,0 +1,26 @@
import type { AxiosProgressEvent } from 'axios';
import { describe, expect, it } from 'vitest';
import { percentFromAxiosProgress } from '@/utils/percentFromAxiosProgress';
const progress = (loaded: number, total?: number): AxiosProgressEvent =>
({
loaded,
total,
bytes: 0,
lengthComputable: total !== undefined
}) as AxiosProgressEvent;
describe('percentFromAxiosProgress', () => {
it('returns null when total is missing', () => {
expect(percentFromAxiosProgress(progress(50))).toBeNull();
});
it('returns rounded percent from loaded and total', () => {
expect(percentFromAxiosProgress(progress(250, 1000))).toBe(25);
expect(percentFromAxiosProgress(progress(1, 3))).toBe(33.33);
});
it('caps at 100', () => {
expect(percentFromAxiosProgress(progress(200, 100))).toBe(100);
});
});
@@ -0,0 +1,11 @@
import type { AxiosProgressEvent } from 'axios';
export const percentFromAxiosProgress = (progressEvent: AxiosProgressEvent): number | null => {
if (!progressEvent.total) {
return null;
}
const percent = Math.min(100, (progressEvent.loaded / progressEvent.total) * 100);
return parseFloat(percent.toFixed(2));
};
@@ -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
}
};
};
+2 -2
View File
@@ -78,7 +78,7 @@
</p>
<ExpandableImage
class="mt-6"
src="/about/zk-flow.jpg"
src="/about-media/zk-flow.jpg"
alt="Zero knowledge upload flow: select image, remove metadata with ExifTool, encrypt in browser, upload ciphertext, store encrypted on server, recipient decrypts locally"
:width="1024"
:height="512"
@@ -113,7 +113,7 @@
</p>
<ExpandableImage
class="mt-6"
src="/about/hotlink-flow.jpg"
src="/about-media/hotlink-flow.jpg"
alt="Hotlink upload flow: select image, remove metadata with ExifTool, encrypt in browser, upload, store encrypted at rest, server decrypts on request for display or embedding"
:width="1024"
:height="512"
+24 -12
View File
@@ -1,7 +1,5 @@
<template>
<main
class="mx-auto flex w-full max-w-lg flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center sm:px-6 sm:py-8"
>
<main class="flex min-h-0 w-full flex-1 flex-col items-center justify-center px-4 py-4 sm:px-6">
<p v-if="state === 'invalidLink'" class="text-sm text-muted-foreground">
This viewing link is incomplete. Use the hotlink URL from your upload results.
</p>
@@ -11,11 +9,10 @@
<template v-else>
<p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p>
<img
<ViewerImageDisplay
v-if="imageSrc"
:src="imageSrc"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
:class="{ hidden: state === 'loading' }"
:visually-hidden="state === 'loading'"
@load="onImageLoad"
@error="onImageError"
/>
@@ -28,7 +25,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 +38,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}`;
});
+2 -5
View File
@@ -1,7 +1,5 @@
<template>
<main
class="mx-auto flex w-full max-w-lg flex-1 flex-col items-center justify-center gap-3 px-4 py-6 text-center sm:px-6 sm:py-8"
>
<main class="flex min-h-0 w-full flex-1 flex-col items-center justify-center px-4 py-4 sm:px-6">
<p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p>
<p v-else-if="state === 'invalidLink'" class="text-sm text-muted-foreground">
@@ -37,10 +35,9 @@
</button>
</form>
<img
<ViewerImageDisplay
v-else-if="state === 'image' && imageObjectUrl"
:src="imageObjectUrl"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
/>
</main>
</template>
+13 -4
View File
@@ -1,8 +1,10 @@
import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url';
import { parseMetadata, writeMetadata } from '@uswriting/exiftool';
import axios from 'axios';
import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg';
import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage';
import type { ExiftoolWorkerJob } from '@/types/workers/exiftoolWorkerJob';
import { percentFromAxiosProgress } from '@/utils/percentFromAxiosProgress';
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
import { toArrayBuffer } from '@/utils/toArrayBuffer';
@@ -14,13 +16,20 @@ let cachedZeroperlWasm: ArrayBuffer | null = null;
*/
const fetchZeroperlWasm = async (): Promise<Response> => {
if (!cachedZeroperlWasm) {
const response = await fetch(zeroperlWasmUrl);
const { data } = await axios.get<ArrayBuffer>(zeroperlWasmUrl, {
responseType: 'arraybuffer',
onDownloadProgress: progressEvent => {
const percent = percentFromAxiosProgress(progressEvent);
if (!response.ok) {
throw new Error(`Failed to load zeroperl.wasm: HTTP ${response.status}`);
if (percent === null) {
return;
}
cachedZeroperlWasm = await response.arrayBuffer();
self.postMessage({ type: 'wasm-download-progress', percent });
}
});
cachedZeroperlWasm = data;
}
return new Response(cachedZeroperlWasm, {