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: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: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", "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" "test": "jest"
}, },
"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 }); const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
app.setGlobalPrefix('api', { 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); const configService = app.get(ConfigService);
@@ -1,4 +1,5 @@
import { Controller, Get, Param, StreamableFile } from '@nestjs/common'; import { Controller, Get, Param, StreamableFile } from '@nestjs/common';
import { HotlinkAccessTokenParamsDto } from '../dto/HotlinkAccessTokenParamsDto';
import { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto'; import { HotlinkImageParamsDto } from '../dto/HotlinkImageParamsDto';
import { UploadService } from '../services/UploadService'; import { UploadService } from '../services/UploadService';
@@ -6,10 +7,18 @@ import { UploadService } from '../services/UploadService';
export class HotlinkImageController { export class HotlinkImageController {
constructor(private readonly uploadService: UploadService) {} constructor(private readonly uploadService: UploadService) {}
/** Legacy route `GET /i/:uploadId/:secret` for hotlink uploads created before single-segment access-token URLs. */
@Get(':id/:secret') @Get(':id/:secret')
async getImage(@Param() params: HotlinkImageParamsDto): Promise<StreamableFile> { async getImage(@Param() params: HotlinkImageParamsDto): Promise<StreamableFile> {
const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret); const { body, mimeType } = await this.uploadService.getHotlinkImage(params.id, params.secret);
return new StreamableFile(body, { type: mimeType }); 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 { IsImageMimeType } from '../../../validation/decorators/isImageMimeType';
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto { export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto {
@IsNotEmpty()
@IsString()
@IsHash('sha256')
accessTokenHash: string;
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@IsImageMimeType() @IsImageMimeType()
@@ -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'; import { Upload } from './Upload';
@Entity('upload_hotlink') @Entity('upload_hotlink')
@Unique('UQ_upload_hotlink_accessTokenHash', ['accessTokenHash'])
export class UploadHotlink { export class UploadHotlink {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@@ -9,6 +10,9 @@ export class UploadHotlink {
@Column() @Column()
mimeType: string; mimeType: string;
@Column({ type: 'varchar', length: 64, nullable: true })
accessTokenHash: string | null;
@OneToOne(() => Upload, upload => upload.hotlink, { onDelete: 'CASCADE' }) @OneToOne(() => Upload, upload => upload.hotlink, { onDelete: 'CASCADE' })
@JoinColumn() @JoinColumn()
upload: Upload; upload: Upload;
@@ -1,6 +1,7 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { createCipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, randomBytes } from 'node:crypto';
import { hashDeleteToken } from '../../../utils/hashDeleteToken'; import { hashDeleteToken } from '../../../utils/hashDeleteToken';
import { hashHotlinkAccessToken } from '../../../utils/hashHotlinkAccessToken';
import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength'; import { AES_GCM_IV_BYTE_LENGTH } from '../../../consts/crypto/aesGcmIvByteLength';
import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { Upload } from '../entities/Upload'; import type { Upload } from '../entities/Upload';
@@ -46,6 +47,9 @@ const encryptHotlinkCiphertext = (plaintext: Buffer, key: Buffer, iv: Buffer): B
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
}; };
const fixedAccessToken = Buffer.alloc(32, 0xcd).toString('base64url');
const fixedAccessTokenHash = hashHotlinkAccessToken(fixedAccessToken);
describe('UploadService', () => { describe('UploadService', () => {
let service: UploadService; let service: UploadService;
let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>; let dataSource: jest.Mocked<Pick<DataSource, 'transaction'>>;
@@ -111,7 +115,8 @@ describe('UploadService', () => {
ciphertext, ciphertext,
expiresAt: null, expiresAt: null,
maxViews: null, maxViews: null,
mimeType: 'image/png' mimeType: 'image/png',
accessTokenHash: fixedAccessTokenHash
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -128,7 +133,7 @@ describe('UploadService', () => {
maxViews: null, maxViews: null,
deleteTokenHash: hashDeleteToken(deleteToken), deleteTokenHash: hashDeleteToken(deleteToken),
expiresAt: null, expiresAt: null,
hotlink: { mimeType: 'image/png' } hotlink: { mimeType: 'image/png', accessTokenHash: fixedAccessTokenHash }
}); });
expect(uploadRepository.save).toHaveBeenCalledTimes(1); expect(uploadRepository.save).toHaveBeenCalledTimes(1);
expect(storageService.write).toHaveBeenCalledWith(fixedId, ciphertextBuffer); expect(storageService.write).toHaveBeenCalledWith(fixedId, ciphertextBuffer);
@@ -202,7 +207,8 @@ describe('UploadService', () => {
ciphertext: ciphertextBase64(0xff), ciphertext: ciphertextBase64(0xff),
expiresAt: null, expiresAt: null,
maxViews: null, maxViews: null,
mimeType: 'image/jpeg' mimeType: 'image/jpeg',
accessTokenHash: fixedAccessTokenHash
}) })
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
@@ -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', () => { describe('transaction rollback on storage failure', () => {
it('propagates storage write errors from the transaction', async () => { it('propagates storage write errors from the transaction', async () => {
storageService.write.mockRejectedValue(new Error('disk full')); storageService.write.mockRejectedValue(new Error('disk full'));
@@ -520,7 +582,8 @@ describe('UploadService', () => {
ciphertext: ciphertextBase64(), ciphertext: ciphertextBase64(),
expiresAt: null, expiresAt: null,
maxViews: null, maxViews: null,
mimeType: 'image/png' mimeType: 'image/png',
accessTokenHash: fixedAccessTokenHash
}) })
).rejects.toThrow('disk full'); ).rejects.toThrow('disk full');
@@ -8,12 +8,14 @@ import { decryptAesGcm } from '../../../utils/decryptAesGcm';
import { fromBase64 } from '../../../utils/fromBase64'; import { fromBase64 } from '../../../utils/fromBase64';
import { fromBase64Url } from '../../../utils/fromBase64Url'; import { fromBase64Url } from '../../../utils/fromBase64Url';
import { hashDeleteToken } from '../../../utils/hashDeleteToken'; import { hashDeleteToken } from '../../../utils/hashDeleteToken';
import { hashHotlinkAccessToken } from '../../../utils/hashHotlinkAccessToken';
import { isErrorEnoent } from '../../../utils/isErrorEnoent'; import { isErrorEnoent } from '../../../utils/isErrorEnoent';
import { toBase64 } from '../../../utils/toBase64'; import { toBase64 } from '../../../utils/toBase64';
import { StorageService } from '../../storage/services/StorageService'; import { StorageService } from '../../storage/services/StorageService';
import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto';
import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; import { CreateZkUploadDto } from '../dto/CreateZkUploadDto';
import { Upload } from '../entities/Upload'; import { Upload } from '../entities/Upload';
import { UploadHotlink } from '../entities/UploadHotlink';
import { CreatedUploadResult } from '../types/CreatedUploadResult'; import { CreatedUploadResult } from '../types/CreatedUploadResult';
import { UploadMode } from '../types/UploadMode'; import { UploadMode } from '../types/UploadMode';
import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult'; import { GetZkUploadCiphertextResult } from '../types/GetZkUploadCiphertextResult';
@@ -83,7 +85,8 @@ export class UploadService {
ciphertext, ciphertext,
expiresAt, expiresAt,
maxViews, maxViews,
mimeType mimeType,
accessTokenHash
}: CreateHotlinkUploadDto): Promise<CreatedUploadResult> { }: CreateHotlinkUploadDto): Promise<CreatedUploadResult> {
const ciphertextBuffer = fromBase64(ciphertext); const ciphertextBuffer = fromBase64(ciphertext);
@@ -104,7 +107,7 @@ export class UploadService {
maxViews, maxViews,
deleteTokenHash: hashDeleteToken(deleteToken), deleteTokenHash: hashDeleteToken(deleteToken),
expiresAt, expiresAt,
hotlink: { mimeType } hotlink: { mimeType, accessTokenHash }
}); });
await uploadRepository.save(upload); await uploadRepository.save(upload);
@@ -170,12 +173,44 @@ export class UploadService {
return result; 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> { async getHotlinkImage(id: string, secret: string): Promise<HotlinkImageResult> {
const upload = await this.uploadRepository.findOne({ const upload = await this.uploadRepository.findOne({
where: { id, mode: UploadMode.Hotlink }, where: { id, mode: UploadMode.Hotlink },
relations: ['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) { if (!upload?.hotlink) {
throw new NotFoundException(); throw new NotFoundException();
} }
@@ -187,7 +222,12 @@ export class UploadService {
if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) { if (typeof upload.maxViews === 'number' && upload.viewCount >= upload.maxViews) {
throw new NotFoundException(); throw new NotFoundException();
} }
}
private async deliverHotlinkImage(
upload: Upload & { hotlink: UploadHotlink },
dek: Buffer
): Promise<HotlinkImageResult> {
let ciphertextBuffer: Buffer; let ciphertextBuffer: Buffer;
try { try {
@@ -203,14 +243,12 @@ export class UploadService {
let body: Buffer; let body: Buffer;
try { try {
const dek = fromBase64Url(secret);
body = decryptAesGcm(ciphertextBuffer, dek, upload.iv); body = decryptAesGcm(ciphertextBuffer, dek, upload.iv);
} catch { } catch {
throw new NotFoundException(); throw new NotFoundException();
} }
await this.reserveViewSlot(id); await this.reserveViewSlot(upload.id);
return { return {
body, 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", "dayjs": "^1.11.23",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"image-type": "^6.0.0",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"pretty-bytes": "^7.2.0", "pretty-bytes": "^7.2.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
@@ -86,6 +87,16 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "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" "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": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "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": { "node_modules/follow-redirects": {
"version": "1.16.0", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -2688,6 +2747,41 @@
"node": ">= 6" "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": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -3388,6 +3482,22 @@
"url": "https://github.com/sponsors/antfu" "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": { "node_modules/tailwindcss": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@@ -3467,6 +3577,24 @@
"node": ">=14.0.0" "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": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -3488,6 +3616,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"image-type": "^6.0.0",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"pretty-bytes": "^7.2.0", "pretty-bytes": "^7.2.0",
"tailwindcss": "^4.3.3", "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'] UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default'] UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
UploadShareResults: typeof import('./components/upload/UploadShareResults.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> <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"> <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>
<template v-else-if="showMetadataTabs"> <template v-else-if="showMetadataTabs">
@@ -18,7 +18,7 @@
<MetadataOutputPanel v-if="tabBody?.kind === 'output'">{{ tabBody.text }}</MetadataOutputPanel> <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> </template>
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p> <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') { if (entry.status === 'error') {
return { kind: 'error', text: entry.message }; return { kind: 'error', text: entry.detailMessage ?? entry.message };
} }
return null; return null;
@@ -14,11 +14,7 @@
loading="lazy" loading="lazy"
decoding="async" decoding="async"
/> />
<span <span v-else class="h-10 w-10 shrink-0 rounded border border-border bg-muted/60" aria-hidden="true" />
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="min-w-0 flex-1 space-y-1">
<div class="flex min-w-0 items-baseline justify-between gap-2"> <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> <span class="min-w-0 truncate text-sm font-medium">{{ row.file.name }}</span>
@@ -28,7 +24,7 @@
</div> </div>
<div class="flex min-w-0 flex-col gap-0.5 text-xs"> <div class="flex min-w-0 flex-col gap-0.5 text-xs">
<span class="min-w-0 truncate" :class="metadataStatusClass(row.metadata)"> <span class="min-w-0 truncate" :class="metadataStatusClass(row.metadata)">
{{ row.metadata.message }} {{ formatMetadataPreviewMessage(row.metadata) }}
</span> </span>
<span class="min-w-0 truncate" :class="uploadStatusClass(row.upload)"> <span class="min-w-0 truncate" :class="uploadStatusClass(row.upload)">
{{ row.upload.message }} {{ row.upload.message }}
@@ -41,13 +37,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia';
import { type PropType } from 'vue'; import { type PropType } from 'vue';
import { useExiftoolStore } from '@/stores/exiftool';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile'; import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry'; import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import type { UploadQueueRow } from '@/types/upload/UploadQueueRow'; import type { UploadQueueRow } from '@/types/upload/UploadQueueRow';
import prettyBytes from 'pretty-bytes'; import prettyBytes from 'pretty-bytes';
const exiftoolStore = useExiftoolStore();
const { wasmDownloadPercent } = storeToRefs(exiftoolStore);
const props = defineProps({ const props = defineProps({
rows: { rows: {
type: Array as PropType<UploadQueueRow[]>, type: Array as PropType<UploadQueueRow[]>,
@@ -89,4 +91,18 @@ const uploadStatusClass = (entry: UploadQueueEntry): string => {
return 'text-muted-foreground'; 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> </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', path: '/h/:id/:secret',
component: () => import('@/views/HotlinkViewerView.vue'),
meta: { title: 'Private Image - Hotlink Viewer' }
},
{
path: '/h/:accessToken',
name: ROUTE_NAMES.HotlinkViewer, name: ROUTE_NAMES.HotlinkViewer,
component: () => import('@/views/HotlinkViewerView.vue'), component: () => import('@/views/HotlinkViewerView.vue'),
meta: { title: 'Private Image - Hotlink Viewer' } 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', () => { export const useExiftoolStore = defineStore('exiftool', () => {
const initStatus = ref<ExiftoolInitStatus>('idle'); const initStatus = ref<ExiftoolInitStatus>('idle');
const initErrorMessage = ref<string | null>(null); const initErrorMessage = ref<string | null>(null);
const wasmDownloadPercent = ref<number | null>(null);
const worker = ref<Worker | null>(null); const worker = ref<Worker | null>(null);
@@ -35,6 +36,7 @@ export const useExiftoolStore = defineStore('exiftool', () => {
if (initStatus.value === 'idle') { if (initStatus.value === 'idle') {
initStatus.value = 'loading'; initStatus.value = 'loading';
initErrorMessage.value = null; initErrorMessage.value = null;
wasmDownloadPercent.value = 0;
initPromise = new Promise<void>((resolve, reject) => { initPromise = new Promise<void>((resolve, reject) => {
initPromiseResolve = resolve; initPromiseResolve = resolve;
initPromiseReject = reject; initPromiseReject = reject;
@@ -69,8 +71,15 @@ export const useExiftoolStore = defineStore('exiftool', () => {
const handleWorkerMessage = (event: MessageEvent<ExiftoolWorkerOutMessage>): void => { const handleWorkerMessage = (event: MessageEvent<ExiftoolWorkerOutMessage>): void => {
const message = event.data; const message = event.data;
if (message.type === 'wasm-download-progress') {
wasmDownloadPercent.value = message.percent;
return;
}
if (message.type === 'init-done') { if (message.type === 'init-done') {
initStatus.value = 'ready'; initStatus.value = 'ready';
wasmDownloadPercent.value = null;
initPromiseResolve?.(); initPromiseResolve?.();
initPromiseResolve = null; initPromiseResolve = null;
initPromiseReject = null; initPromiseReject = null;
@@ -81,6 +90,7 @@ export const useExiftoolStore = defineStore('exiftool', () => {
if (message.type === 'init-error') { if (message.type === 'init-error') {
console.error('[exiftool] init-error', message.message); console.error('[exiftool] init-error', message.message);
initStatus.value = 'error'; initStatus.value = 'error';
wasmDownloadPercent.value = null;
initErrorMessage.value = message.message; initErrorMessage.value = message.message;
initPromiseReject?.(new Error(message.message)); initPromiseReject?.(new Error(message.message));
initPromiseResolve = null; initPromiseResolve = null;
@@ -181,6 +191,7 @@ export const useExiftoolStore = defineStore('exiftool', () => {
return { return {
initStatus, initStatus,
initErrorMessage, initErrorMessage,
wasmDownloadPercent,
initializeExiftool, initializeExiftool,
readMetadata, readMetadata,
stripAllMetadata stripAllMetadata
+20 -10
View File
@@ -3,9 +3,10 @@ import { ref } from 'vue';
import { useExiftoolStore } from '@/stores/exiftool'; import { useExiftoolStore } from '@/stores/exiftool';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview'; import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile'; import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata...'; 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_PROCESSING_MESSAGE = 'Stripping metadata...';
const METADATA_READY_MESSAGE = 'Metadata stripped'; const METADATA_READY_MESSAGE = 'Metadata stripped';
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata'; const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
@@ -17,11 +18,6 @@ const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
message: METADATA_IDLE_MESSAGE message: METADATA_IDLE_MESSAGE
}; };
const METADATA_LOAD_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = { const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error', status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE message: EXIFTOOL_PREPARE_ERROR_MESSAGE
@@ -96,9 +92,15 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) { } catch (error) {
console.error('[metadata] read failed', { file: file.name, 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; let strippedBlob: Blob;
@@ -116,6 +118,7 @@ export const useMetadataStore = defineStore('metadata', () => {
const entry: MetadataPreviewEntry = { const entry: MetadataPreviewEntry = {
status: 'error', status: 'error',
message: METADATA_STRIP_ERROR_MESSAGE, message: METADATA_STRIP_ERROR_MESSAGE,
detailMessage: resolveErrorMessage(error, METADATA_STRIP_ERROR_MESSAGE),
output output
}; };
@@ -136,9 +139,16 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) { } catch (error) {
console.error('[metadata] read after strip failed', { file: file.name, 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); setStrippedBlob(key, strippedBlob);
+4 -1
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue';
import { api } from '@/plugins/axios'; import { api } from '@/plugins/axios';
import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult'; import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { detectImageMimeType } from '@/utils/file/detectImageMimeType';
import { toArrayBuffer } from '@/utils/toArrayBuffer'; import { toArrayBuffer } from '@/utils/toArrayBuffer';
import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload'; import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload';
import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; import { decryptZkUpload } from '@/utils/upload/decryptZkUpload';
@@ -67,7 +68,9 @@ export const useZkViewStore = defineStore('zkView', () => {
throw new Error('Could not build image blob'); 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 { return {
@@ -3,4 +3,4 @@ export type MetadataPreviewEntry =
| { status: 'initializing'; message: string } | { status: 'initializing'; message: string }
| { status: 'processing'; message: string } | { status: 'processing'; message: string }
| { status: 'ready'; message: string; output: string; afterStripOutput: 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; iv: string;
ciphertext: string; ciphertext: string;
mimeType: string; mimeType: string;
accessTokenHash: string;
}; };
@@ -1,4 +1,5 @@
export type ExiftoolWorkerOutMessage = export type ExiftoolWorkerOutMessage =
| { type: 'wasm-download-progress'; percent: number }
| { type: 'init-done' } | { type: 'init-done' }
| { type: 'init-error'; message: string } | { type: 'init-error'; message: string }
| { type: 'read-metadata-done'; id: string; output: 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 { describe, expect, it } from 'vitest';
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath'; import {
buildHotlinkImagePath,
buildLegacyHotlinkImagePath
} from '@/utils/share/buildHotlinkImagePath';
describe('buildHotlinkImagePath', () => { describe('buildHotlinkImagePath', () => {
it('places upload id and secret in the path segments', () => { it('places the access token in a single path segment', () => {
const uploadId = 'upload-uuid'; expect(buildHotlinkImagePath('url-safe-access-token')).toBe('/i/url-safe-access-token');
const secret = 'url-safe-secret'; });
});
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 { describe, expect, it } from 'vitest';
import { buildHotlinkViewerPath } from '@/utils/share/buildHotlinkViewerPath'; import {
buildHotlinkViewerPath,
buildLegacyHotlinkViewerPath
} from '@/utils/share/buildHotlinkViewerPath';
describe('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', () => { 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"' 'a <photo> & "shot"'
); );
const directClearnet = `${clearnet}/i/upload-id/path_secret`; const directClearnet = `${clearnet}/i/path_secret`;
expect(urls).toEqual({ expect(urls).toEqual({
mode: 'hotlink', mode: 'hotlink',
viewer: { viewer: {
clearnet: `${clearnet}/h/upload-id/path_secret`, clearnet: `${clearnet}/h/path_secret`,
onion: `${onion}/h/upload-id/path_secret` onion: `${onion}/h/path_secret`
}, },
direct: { direct: {
clearnet: directClearnet, clearnet: directClearnet,
onion: `${onion}/i/upload-id/path_secret` onion: `${onion}/i/path_secret`
}, },
delete: { delete: {
clearnet: `${clearnet}/d/upload-id?token=a%2Bb%2Fc`, clearnet: `${clearnet}/d/upload-id?token=a%2Bb%2Fc`,
@@ -30,8 +30,8 @@ export const buildUploadedShareUrls = (
}; };
if (upload.mode === 'hotlink') { if (upload.mode === 'hotlink') {
const viewerPath = buildHotlinkViewerPath(upload.uploadId, upload.urlSecret); const viewerPath = buildHotlinkViewerPath(upload.urlSecret);
const directPath = buildHotlinkImagePath(upload.uploadId, upload.urlSecret); const directPath = buildHotlinkImagePath(upload.urlSecret);
const viewer = { const viewer = {
clearnet: buildSiteShareUrl(clearnet, viewerPath), clearnet: buildSiteShareUrl(clearnet, viewerPath),
@@ -4,6 +4,7 @@ import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm';
import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey';
import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; import { base64ToBytes } from '@/utils/encoding/base64ToBytes';
import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes'; import { base64UrlToBytes } from '@/utils/encoding/base64UrlToBytes';
import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken';
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath'; import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest'; import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
@@ -22,10 +23,30 @@ describe('prepareHotlinkUploadRequest', () => {
...options, ...options,
mimeType: 'image/png', mimeType: 'image/png',
iv: request.iv, 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(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 () => { it('roundtrips plaintext using request ciphertext and path secret', async () => {
@@ -43,17 +64,15 @@ describe('prepareHotlinkUploadRequest', () => {
expect(recovered).toEqual(plaintext); expect(recovered).toEqual(plaintext);
}); });
it('wires secret into the hotlink path segment', async () => { it('wires secret into the hotlink direct path', async () => {
const uploadId = 'hotlink-upload-id';
const { request, secret } = await prepareHotlinkUploadRequest( const { request, secret } = await prepareHotlinkUploadRequest(
new Uint8Array([1]), new Uint8Array([1]),
'image/png', 'image/png',
options 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(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 { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest'; import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
import { hashHotlinkAccessToken } from '@/utils/crypto/hashHotlinkAccessToken';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64'; import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload'; import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
@@ -9,6 +10,7 @@ export const prepareHotlinkUploadRequest = async (
options: UploadOptionsRequest options: UploadOptionsRequest
): Promise<PreparedHotlinkUploadRequest> => { ): Promise<PreparedHotlinkUploadRequest> => {
const encrypted = await encryptHotlinkUpload(plaintext); const encrypted = await encryptHotlinkUpload(plaintext);
const accessTokenHash = await hashHotlinkAccessToken(encrypted.secret);
return { return {
secret: encrypted.secret, secret: encrypted.secret,
@@ -16,7 +18,8 @@ export const prepareHotlinkUploadRequest = async (
...options, ...options,
iv: bytesToBase64(encrypted.iv), iv: bytesToBase64(encrypted.iv),
ciphertext: bytesToBase64(encrypted.ciphertext), ciphertext: bytesToBase64(encrypted.ciphertext),
mimeType mimeType,
accessTokenHash
} }
}; };
}; };
+2 -2
View File
@@ -78,7 +78,7 @@
</p> </p>
<ExpandableImage <ExpandableImage
class="mt-6" 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" 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" :width="1024"
:height="512" :height="512"
@@ -113,7 +113,7 @@
</p> </p>
<ExpandableImage <ExpandableImage
class="mt-6" 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" 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" :width="1024"
:height="512" :height="512"
+24 -12
View File
@@ -1,7 +1,5 @@
<template> <template>
<main <main class="flex min-h-0 w-full flex-1 flex-col items-center justify-center px-4 py-4 sm:px-6">
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"
>
<p v-if="state === 'invalidLink'" class="text-sm text-muted-foreground"> <p v-if="state === 'invalidLink'" class="text-sm text-muted-foreground">
This viewing link is incomplete. Use the hotlink URL from your upload results. This viewing link is incomplete. Use the hotlink URL from your upload results.
</p> </p>
@@ -11,11 +9,10 @@
<template v-else> <template v-else>
<p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p> <p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p>
<img <ViewerImageDisplay
v-if="imageSrc" v-if="imageSrc"
:src="imageSrc" :src="imageSrc"
class="max-h-[min(80vh,100dvh)] w-full object-contain" :visually-hidden="state === 'loading'"
:class="{ hidden: state === 'loading' }"
@load="onImageLoad" @load="onImageLoad"
@error="onImageError" @error="onImageError"
/> />
@@ -28,7 +25,12 @@ import { onBeforeMount, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { config } from '@/config'; import { config } from '@/config';
import type { HotlinkViewerViewState } from '@/types/view/HotlinkViewerViewState'; 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(); const route = useRoute();
@@ -36,19 +38,29 @@ const state = ref<HotlinkViewerViewState>('loading');
const imageSrc = ref<string | null>(null); const imageSrc = ref<string | null>(null);
onBeforeMount(() => { onBeforeMount(() => {
const accessToken = route.params.accessToken;
const uploadId = route.params.id; const uploadId = route.params.id;
const secret = route.params.secret; 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'; state.value = 'invalidLink';
return; return;
} }
const { baseUrlRoot } = config.api;
const path = buildHotlinkImagePath(uploadId, secret);
imageSrc.value = `${baseUrlRoot}${path}`; imageSrc.value = `${baseUrlRoot}${path}`;
}); });
+2 -5
View File
@@ -1,7 +1,5 @@
<template> <template>
<main <main class="flex min-h-0 w-full flex-1 flex-col items-center justify-center px-4 py-4 sm:px-6">
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"
>
<p v-if="state === 'loading'" class="text-sm text-muted-foreground sm:text-base">Loading image...</p> <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"> <p v-else-if="state === 'invalidLink'" class="text-sm text-muted-foreground">
@@ -37,10 +35,9 @@
</button> </button>
</form> </form>
<img <ViewerImageDisplay
v-else-if="state === 'image' && imageObjectUrl" v-else-if="state === 'image' && imageObjectUrl"
:src="imageObjectUrl" :src="imageObjectUrl"
class="max-h-[min(80vh,100dvh)] w-full object-contain"
/> />
</main> </main>
</template> </template>
+13 -4
View File
@@ -1,8 +1,10 @@
import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url'; import zeroperlWasmUrl from '@6over3/zeroperl-ts/zeroperl.wasm?url';
import { parseMetadata, writeMetadata } from '@uswriting/exiftool'; import { parseMetadata, writeMetadata } from '@uswriting/exiftool';
import axios from 'axios';
import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg'; import { EXIFTOOL_WORKER_INIT_JPEG } from '@/consts/exiftoolWorkerInitJpeg';
import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage'; import type { ExiftoolWorkerInMessage } from '@/types/workers/exiftoolWorkerInMessage';
import type { ExiftoolWorkerJob } from '@/types/workers/exiftoolWorkerJob'; import type { ExiftoolWorkerJob } from '@/types/workers/exiftoolWorkerJob';
import { percentFromAxiosProgress } from '@/utils/percentFromAxiosProgress';
import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
import { toArrayBuffer } from '@/utils/toArrayBuffer'; import { toArrayBuffer } from '@/utils/toArrayBuffer';
@@ -14,13 +16,20 @@ let cachedZeroperlWasm: ArrayBuffer | null = null;
*/ */
const fetchZeroperlWasm = async (): Promise<Response> => { const fetchZeroperlWasm = async (): Promise<Response> => {
if (!cachedZeroperlWasm) { 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) { if (percent === null) {
throw new Error(`Failed to load zeroperl.wasm: HTTP ${response.status}`); return;
} }
cachedZeroperlWasm = await response.arrayBuffer(); self.postMessage({ type: 'wasm-download-progress', percent });
}
});
cachedZeroperlWasm = data;
} }
return new Response(cachedZeroperlWasm, { return new Response(cachedZeroperlWasm, {