Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/api/cryptify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,12 +459,14 @@ export function createUploadStream(
onProgress?: (uploaded: number, last: boolean) => void;
abortSignal?: AbortSignal;
retry?: RetryOptions;
onUploadInit?: (info: { uuid: string; recoveryToken: string }) => void;
}
): UploadStream {
let state: FileState = { token: '', uuid: '', recoveryToken: '' };
let processed = 0;
const signal = options.abortSignal;
const onProgress = options.onProgress;
const onUploadInit = options.onUploadInit;
const retry = resolveRetryOptions(options.retry);
const queuingStrategy = new CountQueuingStrategy({ highWaterMark: 1 });

Expand All @@ -473,6 +475,7 @@ export function createUploadStream(
async start(c) {
try {
state = await initUpload(cryptifyUrl, { ...options, signal });
onUploadInit?.({ uuid: state.uuid, recoveryToken: state.recoveryToken });
onProgress?.(processed, false);
if (signal?.aborted) throw new Error('Abort signaled during initFile.');
} catch (e) {
Expand Down
4 changes: 4 additions & 0 deletions src/crypto/encrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ export interface EncryptPipelineOptions {
signingKeys?: SigningKeys;
/** Retry behaviour for chunk uploads. See PostGuardConfig.retry. */
retry?: RetryOptions;
/** Fires once, after `upload_init` succeeds and before any chunk PUT,
* with `{uuid, recoveryToken}`. See `UploadOptions.onUploadInit`. */
onUploadInit?: (info: { uuid: string; recoveryToken: string }) => void;
}

/** Full encryption pipeline: sign -> policy -> ZIP -> seal -> upload */
Expand Down Expand Up @@ -97,6 +100,7 @@ export async function encryptPipeline(options: EncryptPipelineOptions): Promise<
apiKey: cryptifyApiKey,
abortSignal: effectiveSignal,
retry: options.retry,
onUploadInit: options.onUploadInit,
onProgress: (uploaded, last) => {
if (onProgress) {
const pct = totalSize > 0 ? Math.min(100, Math.round((uploaded / totalSize) * 100)) : 0;
Expand Down
5 changes: 3 additions & 2 deletions src/email/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const DEFAULT_WEBSITE_URL = 'https://postguard.eu';
* Outlook's 1 M-char setAsync limit and was redundant with the
* attachment / fragment link. */
export async function createEnvelope(options: CreateEnvelopeOptions): Promise<EnvelopeResult> {
const { sealed, from, unencryptedMessage, senderAttributes, notify } = options;
const { sealed, from, unencryptedMessage, senderAttributes, notify, onUploadInit } = options;
const websiteUrl = options.websiteUrl ?? DEFAULT_WEBSITE_URL;
const uploadToCryptify = options.uploadToCryptify ?? true;
const logoUrl = `${websiteUrl}/pg_logo.png`;
Expand All @@ -59,7 +59,8 @@ export async function createEnvelope(options: CreateEnvelopeOptions): Promise<En

if (tryUpload) {
try {
const result = await sealed.upload(notify ? { notify } : undefined);
const uploadOpts = notify || onUploadInit ? { notify, onUploadInit } : undefined;
const result = await sealed.upload(uploadOpts);
uploadUuid = result.uuid;
} catch {
// Network / CORS / Cryptify-unavailable. Fall through to
Expand Down
1 change: 1 addition & 0 deletions src/sealed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export class Sealed {
headers: this.config.headers,
signingKeys,
retry: this.config.retry,
onUploadInit: opts?.onUploadInit,
});
}

Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export interface UploadOptions {
/** Notification email template language. Default 'EN'. */
language?: 'EN' | 'NL';
};
/** Called once, after `upload_init` succeeds, before any chunk PUT.
* Persist `{uuid, recoveryToken}` here to enable cross-restart resume
* via `resumeUpload`. The callback fires from inside the upload
* stream's `start` handler — keep it short and synchronous; a throw
* will error the upload stream. */
onUploadInit?: (info: { uuid: string; recoveryToken: string }) => void;
}

// --- New API: Open/decrypt input ---
Expand Down Expand Up @@ -236,6 +242,12 @@ export interface CreateEnvelopeOptions {
* Has no effect on Tier 1 (no upload happens) or when
* `uploadToCryptify: false` already skipped the upload. */
notify?: UploadOptions['notify'];
/** Forwarded to the underlying `Sealed.upload` call. Called once after
* `upload_init` succeeds and before any chunk PUT, with the Cryptify
* `uuid` and `recoveryToken`. Persist this pair to enable cross-restart
* resume via `resumeUpload`. No-op when no upload happens (tier 1, or
* `uploadToCryptify: false`). */
onUploadInit?: UploadOptions['onUploadInit'];
}

/** Which tier the envelope falls into based on encrypted payload size.
Expand Down
59 changes: 59 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
finalizeUpload,
downloadFile,
downloadFileWithRetry,
createUploadStream,
} from '../src/api/cryptify.js';
import { resolveRetryOptions } from '../src/util/retry.js';

Expand Down Expand Up @@ -971,4 +972,62 @@ describe('Cryptify API', () => {
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});

describe('createUploadStream onUploadInit', () => {
it('fires once with {uuid, recoveryToken} after upload_init, before any chunk PUT', async () => {
mockFetch.mockImplementation((_url: string, init: RequestInit) => {
if (init?.method === 'PUT') {
return Promise.resolve({
ok: true,
status: 200,
text: () => Promise.resolve(''),
headers: new Headers({ cryptifytoken: 'tok-next' }),
});
}
// POST: init or finalize
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ uuid: 'u-1', recovery_token: 'rec-abc' }),
text: () => Promise.resolve(''),
headers: new Headers({ cryptifytoken: 'tok-0' }),
});
});

const calls: Array<{ uuid: string; recoveryToken: string }> = [];
let initSeenBeforeFirstPut = false;

const stream = createUploadStream('https://cryptify.example.com', {
recipient: 'a@b.com',
onUploadInit: (info) => {
calls.push(info);
// No PUT should have happened yet — init is the only fetch.
initSeenBeforeFirstPut =
mockFetch.mock.calls.length === 1 &&
mockFetch.mock.calls[0][0].endsWith('/fileupload/init');
},
});

const writer = stream.writable.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.close();

expect(calls).toEqual([{ uuid: 'u-1', recoveryToken: 'rec-abc' }]);
expect(initSeenBeforeFirstPut).toBe(true);
});

it('is not called when upload_init fails', async () => {
mockFetch.mockResolvedValueOnce(errorResponse(400, 'bad'));

const onUploadInit = vi.fn();
const stream = createUploadStream('https://cryptify.example.com', {
recipient: 'a@b.com',
onUploadInit,
});

const writer = stream.writable.getWriter();
await expect(writer.write(new Uint8Array([1]))).rejects.toBeDefined();
expect(onUploadInit).not.toHaveBeenCalled();
});
});
});
Loading