Skip to content

Commit e7bf4c2

Browse files
feat: support password protected pdf
1 parent bafef92 commit e7bf4c2

File tree

6 files changed

+260
-6
lines changed

6 files changed

+260
-6
lines changed

extension/chrome/elements/attachment_preview.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,7 @@ View.run(
6262
this.attachmentPreviewContainer.html(`<div class="attachment-preview-txt">${Xss.escape(result.toUtfStr()).replace(/\n/g, '<br>')}</div>`); // xss-escaped
6363
} else if (attachmentType === 'pdf') {
6464
// PDF
65-
// .slice() is used to copy attachment data https://github.com/FlowCrypt/flowcrypt-browser/issues/5408
66-
const pdf = await pdfjsLib.getDocument({ data: result.slice() }).promise;
67-
const previewPdf = new AttachmentPreviewPdf(this.attachmentPreviewContainer, pdf);
68-
await previewPdf.render();
65+
await this.renderPdf(result);
6966
}
7067
} else {
7168
// no preview available, download button
@@ -122,5 +119,48 @@ View.run(
122119
}
123120
throw new DecryptionError(result as DecryptError);
124121
};
122+
123+
private renderPdf = async (data: Uint8Array, password?: string) => {
124+
try {
125+
// .slice() is used to copy attachment data https://github.com/FlowCrypt/flowcrypt-browser/issues/5408
126+
const loadingOptions: { data: Uint8Array; password?: string } = { data: data.slice() };
127+
if (password) {
128+
loadingOptions.password = password;
129+
}
130+
const pdf = await pdfjsLib.getDocument(loadingOptions).promise;
131+
const previewPdf = new AttachmentPreviewPdf(this.attachmentPreviewContainer, pdf);
132+
await previewPdf.render();
133+
} catch (e) {
134+
if (e instanceof Error && e.name === 'PasswordException') {
135+
const error = e as Error & { code: number };
136+
const needsPassword = error.code === 1; // PasswordException.NEED_PASSWORD
137+
const incorrectPassword = error.code === 2; // PasswordException.INCORRECT_PASSWORD
138+
139+
if (needsPassword || incorrectPassword) {
140+
// Show password prompt with appropriate message
141+
const message = incorrectPassword
142+
? 'Incorrect password. Please enter the correct password:'
143+
: 'This PDF is password protected. Please enter the password:';
144+
const userPassword = await this.promptForPdfPassword(message);
145+
146+
if (userPassword) {
147+
// User provided a password, try again
148+
await this.renderPdf(data, userPassword);
149+
} else {
150+
// User cancelled the password prompt
151+
this.attachmentPreviewContainer.html('<div class="attachment-preview-unavailable">PDF is password protected. Password required to view.</div>');
152+
$('#attachment-preview-download').appendTo('.attachment-preview-unavailable').css('display', 'flex');
153+
$('#attachment-preview-filename').text(this.origNameBasedOnFilename);
154+
}
155+
}
156+
} else {
157+
throw e;
158+
}
159+
}
160+
};
161+
162+
private promptForPdfPassword = async (message?: string): Promise<string | undefined> => {
163+
return await Ui.modal.passwordInput('Password Required', message || 'This PDF is password protected. Please enter the password:');
164+
};
125165
}
126166
);

extension/css/cryptup.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ span.gray {
170170
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-info-confirm,
171171
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-confirm-confirm,
172172
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-confirm-checkbox-confirm,
173+
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-password-input-confirm,
173174
.button.green {
174175
background-color: #31a217;
175176
color: white;
@@ -179,6 +180,7 @@ button.btn_disabled,
179180
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-warning-confirm,
180181
.swal2-popup .swal2-styled.swal2-cancel.ui-modal-confirm-cancel,
181182
.swal2-popup .swal2-styled.swal2-cancel.ui-modal-confirm-checkbox-cancel,
183+
.swal2-popup .swal2-styled.swal2-cancel.ui-modal-password-input-cancel,
182184
.button.gray {
183185
background-color: #b7b7b7;
184186
background-image: linear-gradient(#c5c5c5, #b7b7b7);

extension/js/common/browser/ui.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,29 @@ export class Ui {
261261
const { dismiss } = await userResponsePromise;
262262
return typeof dismiss === 'undefined';
263263
},
264+
passwordInput: async (title: string, text: string): Promise<string | undefined> => {
265+
const result = await Ui.swal().fire({
266+
title,
267+
text,
268+
input: 'password',
269+
inputPlaceholder: 'Enter password',
270+
showCancelButton: true,
271+
confirmButtonText: 'Submit',
272+
cancelButtonText: 'Cancel',
273+
allowOutsideClick: false,
274+
inputAttributes: {
275+
autocapitalize: 'off',
276+
autocorrect: 'off',
277+
},
278+
customClass: {
279+
popup: 'ui-modal-password-input',
280+
confirmButton: 'ui-modal-password-input-confirm',
281+
cancelButton: 'ui-modal-password-input-cancel',
282+
input: 'ui-modal-password-input-field',
283+
},
284+
});
285+
return result.isConfirmed && result.value ? (result.value as string) : undefined;
286+
},
264287
page: async (htmlUrl: string, replaceNewlines = false): Promise<void> => {
265288
let html = await (await fetch(htmlUrl)).text();
266289
html = Xss.htmlSanitize(html);

test/source/mock/google/exported-messages/message-export-19983afe57200dcd.json

Lines changed: 118 additions & 0 deletions
Large diffs are not rendered by default.

test/source/tests/decrypt.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2296,5 +2296,69 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw==
22962296
expect(Object.keys(downloadedFile8)[0]).to.match(/sample.*\.bat/);
22972297
})
22982298
);
2299+
2300+
test(
2301+
'decrypt - password protected PDF attachment',
2302+
testWithBrowser(async (t, browser) => {
2303+
const threadId = '19983afe57200dcd';
2304+
const { acctEmail } = await BrowserRecipe.setupCommonAcctWithAttester(t, browser, 'ci.tests.gmail');
2305+
const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId}`);
2306+
await inboxPage.waitForSelTestState('ready');
2307+
await inboxPage.waitAll('iframe');
2308+
2309+
// Check that the attachment frame is present
2310+
const attachmentFrame = await inboxPage.getFrame(['attachment.htm']);
2311+
await attachmentFrame.waitForSelTestState('ready');
2312+
2313+
// Click on the attachment to preview it
2314+
await attachmentFrame.waitAndClick('@attachment-container');
2315+
2316+
// Wait for attachment preview frame
2317+
const attachmentPreviewPage = await inboxPage.getFrame(['attachment_preview.htm']);
2318+
2319+
// Wait for password modal and enter the correct password
2320+
await PageRecipe.waitForModalAndRespond(attachmentPreviewPage, 'password-input', {
2321+
contentToCheck: 'This PDF is password protected. Please enter the password:',
2322+
inputValue: '123456',
2323+
clickOn: 'confirm',
2324+
});
2325+
2326+
// Wait for the PDF to render
2327+
await attachmentPreviewPage.waitAny('.attachment-preview-pdf');
2328+
2329+
// Verify PDF content is displayed
2330+
const pdfCanvas = await attachmentPreviewPage.waitAny('.attachment-preview-pdf-page');
2331+
expect(pdfCanvas).to.not.be.null;
2332+
2333+
// Test incorrect password scenario
2334+
await inboxPage.close();
2335+
const inboxPage2 = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId}`);
2336+
await inboxPage2.waitForSelTestState('ready');
2337+
await inboxPage2.waitAll('iframe');
2338+
2339+
const attachmentFrame2 = await inboxPage2.getFrame(['attachment.htm']);
2340+
await attachmentFrame2.waitForSelTestState('ready');
2341+
await attachmentFrame2.waitAndClick('@attachment-container');
2342+
2343+
const attachmentPreviewPage2 = await inboxPage2.getFrame(['attachment_preview.htm']);
2344+
2345+
// Enter wrong password first
2346+
await PageRecipe.waitForModalAndRespond(attachmentPreviewPage2, 'password-input', {
2347+
contentToCheck: 'This PDF is password protected. Please enter the password:',
2348+
inputValue: 'wrongpassword',
2349+
clickOn: 'confirm',
2350+
});
2351+
2352+
// Should get incorrect password modal
2353+
await PageRecipe.waitForModalAndRespond(attachmentPreviewPage2, 'password-input', {
2354+
contentToCheck: 'Incorrect password. Please enter the correct password:',
2355+
inputValue: '123456',
2356+
clickOn: 'confirm',
2357+
});
2358+
2359+
// Verify PDF renders after correct password
2360+
await attachmentPreviewPage2.waitAny('.attachment-preview-pdf');
2361+
})
2362+
);
22992363
}
23002364
};

test/source/tests/page-recipe/abstract-page-recipe.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ type ModalOpts = {
1111
clickOn?: 'confirm' | 'cancel';
1212
getTriggeredPage?: boolean;
1313
timeout?: number;
14+
inputValue?: string;
1415
};
15-
type ModalType = 'confirm' | 'error' | 'info' | 'warning';
16+
type ModalType = 'confirm' | 'error' | 'info' | 'warning' | 'password-input';
1617

1718
export abstract class PageRecipe {
1819
public static getElementPropertyJson = async (elem: ElementHandle, property: string) => {
@@ -23,7 +24,7 @@ export abstract class PageRecipe {
2324
return await elem.evaluate((el, attribute) => el.getAttribute(attribute), attribute);
2425
};
2526

26-
public static waitForModalAndRespond = async (controllable: Controllable, type: ModalType, { contentToCheck, clickOn, timeout }: ModalOpts) => {
27+
public static waitForModalAndRespond = async (controllable: Controllable, type: ModalType, { contentToCheck, clickOn, timeout, inputValue }: ModalOpts) => {
2728
const modalContainer = await controllable.waitAny(`.ui-modal-${type}`, { timeout });
2829
if (typeof contentToCheck !== 'undefined') {
2930
const contentElement = await modalContainer.$('.swal2-html-container');
@@ -33,6 +34,12 @@ export abstract class PageRecipe {
3334
throw new Error(`Expected modal to contain "${contentToCheck}" but contained "${actualContent}"`);
3435
}
3536
}
37+
if (inputValue) {
38+
const inputElement = await modalContainer.$('input.swal2-input');
39+
if (inputElement) {
40+
await inputElement.type(inputValue);
41+
}
42+
}
3643
if (clickOn) {
3744
const button = await modalContainer.$(`button.ui-modal-${type}-${clickOn}`);
3845
await button?.click();

0 commit comments

Comments
 (0)