Compare commits
17
Commits
a5f6afdc22
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b36fbaeba | ||
|
|
0de7a87938 | ||
|
|
2290040242 | ||
|
|
af3109c564 | ||
|
|
6effab12eb | ||
|
|
0d8d682990 | ||
|
|
2396c5d16d | ||
|
|
0ef4993ad9 | ||
|
|
c97303850a | ||
|
|
a82fc6d6c0 | ||
|
|
fb482a1052 | ||
|
|
402e9df255 | ||
|
|
da2d020a45 | ||
|
|
9696c85a8e | ||
|
|
c95af9576f | ||
|
|
5562ad95cc | ||
|
|
32cd1bdb9e |
@@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import dataSource from '../DataSource';
|
||||||
|
import { getAppConfig } from '../../config';
|
||||||
|
import { getErrorMessage } from '../../utils/getErrorMessage';
|
||||||
|
import type { InformationSchemaTableRow } from '../../types/database/InformationSchemaTableRow';
|
||||||
|
import type { PgEnumTypeRow } from '../../types/database/PgEnumTypeRow';
|
||||||
|
import { NodeEnv } from '../../types/NodeEnv';
|
||||||
|
|
||||||
|
const {
|
||||||
|
nodeEnv
|
||||||
|
} = getAppConfig();
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
if (nodeEnv === NodeEnv.Development) {
|
||||||
|
console.info('Connecting to the database...');
|
||||||
|
|
||||||
|
const initializedDataSource = await dataSource.initialize();
|
||||||
|
|
||||||
|
console.info('Connected to the database.');
|
||||||
|
|
||||||
|
const tables = await initializedDataSource.query<InformationSchemaTableRow[]>(
|
||||||
|
`SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public';`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
try {
|
||||||
|
await initializedDataSource.query(`DROP TABLE IF EXISTS "${table.table_name}" CASCADE;`);
|
||||||
|
|
||||||
|
console.info(`Dropped table: ${table.table_name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to drop table: ${table.table_name}: ${getErrorMessage(error)}`);
|
||||||
|
|
||||||
|
return process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enums = await initializedDataSource.query<PgEnumTypeRow[]>(
|
||||||
|
`SELECT typname FROM pg_type WHERE typtype = 'e';`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const enumType of enums) {
|
||||||
|
try {
|
||||||
|
await initializedDataSource.query(`DROP TYPE IF EXISTS "${enumType.typname}" CASCADE;`);
|
||||||
|
|
||||||
|
console.info(`Dropped enum: ${enumType.typname}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to drop enum: ${enumType.typname}: ${getErrorMessage(error)}`);
|
||||||
|
|
||||||
|
return process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info('All tables and enums have been dropped.');
|
||||||
|
|
||||||
|
return process.exit(0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
+4
-1
@@ -14,7 +14,10 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
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;
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
Generated
+140
@@ -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",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"optimize-dist-wasm": "node scripts/optimize-dist-wasm.mjs",
|
||||||
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
|
"postinstall": "node patches/patch-exiftool-stderr.mjs && node patches/patch-zeroperl-worker-fetch.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,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 |
@@ -0,0 +1,32 @@
|
|||||||
|
import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
|
||||||
|
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const distAssetsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../dist/assets');
|
||||||
|
|
||||||
|
const brotliOptions = {
|
||||||
|
params: {
|
||||||
|
[zlibConstants.BROTLI_PARAM_QUALITY]: 11,
|
||||||
|
[zlibConstants.BROTLI_PARAM_LGWIN]: 24
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const wasmFiles = readdirSync(distAssetsDir).filter(name => name.endsWith('.wasm'));
|
||||||
|
|
||||||
|
if (wasmFiles.length === 0) {
|
||||||
|
console.error('optimize-dist-wasm: no .wasm files in dist/assets');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('optimize-dist-wasm: brotli sidecars for', wasmFiles.length, 'file(s)');
|
||||||
|
|
||||||
|
for (const fileName of wasmFiles) {
|
||||||
|
const wasmPath = path.join(distAssetsDir, fileName);
|
||||||
|
console.log('optimize-dist-wasm: brotli', wasmPath);
|
||||||
|
|
||||||
|
const compressed = brotliCompressSync(readFileSync(wasmPath), brotliOptions);
|
||||||
|
console.log('optimize-dist-wasm: compressed', compressed.length);
|
||||||
|
|
||||||
|
writeFileSync(`${wasmPath}.br`, compressed);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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>
|
||||||
@@ -26,9 +22,9 @@
|
|||||||
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-w-0 flex-col gap-0.5 text-xs sm:flex-row sm:flex-wrap sm:gap-x-3">
|
<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>
|
||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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...';
|
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);
|
||||||
|
|||||||
@@ -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 the access token in a single path segment', () => {
|
||||||
|
expect(buildHotlinkImagePath('url-safe-access-token')).toBe('/i/url-safe-access-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildLegacyHotlinkImagePath', () => {
|
||||||
it('places upload id and secret in the path segments', () => {
|
it('places upload id and secret in the path segments', () => {
|
||||||
const uploadId = 'upload-uuid';
|
expect(buildLegacyHotlinkImagePath('upload-uuid', 'url-safe-secret')).toBe(
|
||||||
const secret = 'url-safe-secret';
|
'/i/upload-uuid/url-safe-secret'
|
||||||
|
);
|
||||||
expect(buildHotlinkImagePath(uploadId, secret)).toBe(`/i/${uploadId}/${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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,13 +1,45 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
let cachedZeroperlWasm: ArrayBuffer | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load `zeroperl.wasm` once per worker lifetime. Tor/Firefox often skip HTTP cache for
|
||||||
|
* worker fetches and may not store responses above the memory-cache entry size limit.
|
||||||
|
*/
|
||||||
|
const fetchZeroperlWasm = async (): Promise<Response> => {
|
||||||
|
if (!cachedZeroperlWasm) {
|
||||||
|
const { data } = await axios.get<ArrayBuffer>(zeroperlWasmUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
onDownloadProgress: progressEvent => {
|
||||||
|
const percent = percentFromAxiosProgress(progressEvent);
|
||||||
|
|
||||||
|
if (percent === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage({ type: 'wasm-download-progress', percent });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cachedZeroperlWasm = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(cachedZeroperlWasm, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/wasm' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */
|
/** Custom fetch function for `@uswriting/exiftool` to load `zeroperl.wasm` from the worker. */
|
||||||
const exiftoolParseOptions = { fetch: () => fetch(zeroperlWasmUrl) };
|
const exiftoolParseOptions = { fetch: fetchZeroperlWasm };
|
||||||
|
|
||||||
const jobQueue: ExiftoolWorkerJob[] = [];
|
const jobQueue: ExiftoolWorkerJob[] = [];
|
||||||
let drainPromise: Promise<void> | null = null;
|
let drainPromise: Promise<void> | null = null;
|
||||||
@@ -137,11 +169,7 @@ const processJob = async (job: ExiftoolWorkerJob): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
await initializeWorker();
|
await initializeWorker();
|
||||||
|
|
||||||
const result = await writeMetadata(
|
const result = await writeMetadata(job.file, {}, { args: ['-all='], ...exiftoolParseOptions });
|
||||||
job.file,
|
|
||||||
{},
|
|
||||||
{ args: ['-all='], ...exiftoolParseOptions }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const message = result.error ?? 'Could not strip image metadata';
|
const message = result.error ?? 'Could not strip image metadata';
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
|||||||
VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=$VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX \
|
VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=$VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX \
|
||||||
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=$VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES
|
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=$VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build && node scripts/optimize-dist-wasm.mjs
|
||||||
|
|
||||||
FROM nginx:1.29.4-alpine
|
FROM alpine:3.23
|
||||||
|
|
||||||
RUN apk add --no-cache gettext
|
RUN apk add --no-cache \
|
||||||
|
nginx=1.28.3-r7 \
|
||||||
|
nginx-mod-http-brotli=1.28.3-r7 \
|
||||||
|
gettext
|
||||||
|
|
||||||
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
|
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ render_server() {
|
|||||||
mkdir -p /etc/nginx/snippets
|
mkdir -p /etc/nginx/snippets
|
||||||
|
|
||||||
render_locations
|
render_locations
|
||||||
render_server /etc/nginx/templates/conf.d/clearnet.conf.template /etc/nginx/conf.d/clearnet.conf clearnet
|
render_server /etc/nginx/templates/conf.d/clearnet.conf.template /etc/nginx/http.d/clearnet.conf clearnet
|
||||||
render_server /etc/nginx/templates/conf.d/onion.conf.template /etc/nginx/conf.d/onion.conf onion
|
render_server /etc/nginx/templates/conf.d/onion.conf.template /etc/nginx/http.d/onion.conf onion
|
||||||
|
|
||||||
exec nginx -g 'daemon off;'
|
exec nginx -g 'daemon off;'
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ location /pgadmin {
|
|||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
brotli_static on;
|
||||||
|
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
${ONION_LOCATION_HEADER}
|
${ONION_LOCATION_HEADER}
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
|||||||
Reference in New Issue
Block a user