Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { DataSourceBase } from "resource://gre/modules/megalist/aggregator/datasources/DataSourceBase.sys.mjs";
import { CreditCardRecord } from "resource://gre/modules/shared/CreditCardRecord.sys.mjs";
import { formAutofillStorage } from "resource://autofill/FormAutofillStorage.sys.mjs";
import { OSKeyStore } from "resource://gre/modules/OSKeyStore.sys.mjs";
async function decryptCard(card) {
if (card["cc-number-encrypted"] && !card["cc-number-decrypted"]) {
try {
card["cc-number-decrypted"] = await OSKeyStore.decrypt(
card["cc-number-encrypted"],
false
);
card["cc-number"] = card["cc-number-decrypted"];
} catch (e) {
console.error(e);
}
}
}
async function updateCard(card, field, value) {
try {
await decryptCard(card);
const newCard = {
...card,
[field]: value ?? "",
};
formAutofillStorage.INTERNAL_FIELDS.forEach(name => delete newCard[name]);
formAutofillStorage.creditCards.VALID_COMPUTED_FIELDS.forEach(
name => delete newCard[name]
);
delete newCard["cc-number-decrypted"];
CreditCardRecord.normalizeFields(newCard);
if (card.guid) {
await formAutofillStorage.creditCards.update(card.guid, newCard);
} else {
await formAutofillStorage.creditCards.add(newCard);
}
} catch (error) {
//todo
console.error("failed to modify credit card", error);
return false;
}
return true;
}
/**
* Data source for Bank Cards.
*
* Each card is represented by 3 lines: card number, expiration date and holder name.
*
* Protypes are used to reduce memory need because for different records
* similar lines will differ in values only.
*/
export class BankCardDataSource extends DataSourceBase {
#cardNumberPrototype;
#expirationPrototype;
#holderNamePrototype;
#cardsDisabledMessage;
#enabled;
#header;
constructor(...args) {
super(...args);
// Wait for Fluent to provide strings before loading data
this.localizeStrings({
headerLabel: { id: "payments-section-label" },
expandSection: { id: "payments-expand-section-tooltip" },
collapseSection: { id: "payments-collapse-section-tooltip" },
numberLabel: { id: "card-number-label" },
expirationLabel: { id: "card-expiration-label" },
holderLabel: { id: "card-holder-label" },
cardsDisabled: { id: "payments-disabled" },
}).then(strings => {
const copyCommand = { id: "Copy", label: "command-copy" };
const editCommand = { id: "Edit", label: "command-edit", verify: true };
const deleteCommand = {
id: "Delete",
label: "command-delete",
verify: true,
};
this.#cardsDisabledMessage = strings.cardsDisabled;
const tooltip = {
expand: strings.expandSection,
collapse: strings.collapseSection,
};
this.#header = this.createHeaderLine(strings.headerLabel, tooltip);
this.#header.commands.push({
id: "Create",
label: "payments-command-create",
});
this.#cardNumberPrototype = this.prototypeDataLine({
label: { value: strings.numberLabel },
concealed: { value: true, writable: true },
start: { value: true },
value: {
async get() {
if (this.isEditing()) {
return this.editingValue;
}
if (this.concealed) {
return (
"••••••••" +
this.record["cc-number"].replaceAll("*", "").substr(-4)
);
}
await decryptCard(this.record);
return this.record["cc-number-decrypted"];
},
},
valueIcon: {
get() {
const typeToImage = {
amex: "third-party/cc-logo-amex.png",
cartebancaire: "third-party/cc-logo-cartebancaire.png",
diners: "third-party/cc-logo-diners.svg",
discover: "third-party/cc-logo-discover.png",
jcb: "third-party/cc-logo-jcb.svg",
mastercard: "third-party/cc-logo-mastercard.svg",
mir: "third-party/cc-logo-mir.svg",
unionpay: "third-party/cc-logo-unionpay.svg",
visa: "third-party/cc-logo-visa.svg",
};
return (
(typeToImage[this.record["cc-type"]] ??
"icon-credit-card-generic.svg")
);
},
},
commands: {
*value() {
if (this.concealed) {
yield { id: "Reveal", label: "command-reveal", verify: true };
} else {
yield { id: "Conceal", label: "command-conceal" };
}
yield { ...copyCommand, verify: true };
yield editCommand;
yield "-";
yield deleteCommand;
},
},
executeReveal: {
value() {
this.concealed = false;
this.refreshOnScreen();
},
},
executeConceal: {
value() {
this.concealed = true;
this.refreshOnScreen();
},
},
executeCopy: {
async value() {
await decryptCard(this.record);
this.copyToClipboard(this.record["cc-number-decrypted"]);
},
},
executeEdit: {
async value() {
await decryptCard(this.record);
this.editingValue = this.record["cc-number-decrypted"] ?? "";
this.refreshOnScreen();
},
},
executeSave: {
async value(value) {
if (updateCard(this.record, "cc-number", value)) {
this.executeCancel();
}
},
},
});
this.#expirationPrototype = this.prototypeDataLine({
label: { value: strings.expirationLabel },
value: {
get() {
return `${this.record["cc-exp-month"]}/${this.record["cc-exp-year"]}`;
},
},
commands: {
value: [copyCommand, editCommand, "-", deleteCommand],
},
});
this.#holderNamePrototype = this.prototypeDataLine({
label: { value: strings.holderLabel },
end: { value: true },
value: {
get() {
return this.editingValue ?? this.record["cc-name"];
},
},
commands: {
value: [copyCommand, editCommand, "-", deleteCommand],
},
executeEdit: {
value() {
this.editingValue = this.record["cc-name"] ?? "";
this.refreshOnScreen();
},
},
executeSave: {
async value(value) {
if (updateCard(this.record, "cc-name", value)) {
this.executeCancel();
}
},
},
});
Services.obs.addObserver(this, "formautofill-storage-changed");
Services.prefs.addObserver(
"extensions.formautofill.creditCards.enabled",
this
);
this.#reloadDataSource();
});
}
/**
* Enumerate all the lines provided by this data source.
*
* @param {string} searchText used to filter data
*/
*enumerateLines(searchText) {
if (this.#enabled === undefined) {
// Async Fluent API makes it possible to have data source waiting
// for the localized strings, which can be detected by undefined in #enabled.
return;
}
yield this.#header;
if (this.#header.collapsed || !this.#enabled) {
return;
}
const stats = { count: 0, total: 0 };
searchText = searchText.toUpperCase();
yield* this.enumerateLinesForMatchingRecords(
searchText,
stats,
card =>
(card["cc-number-decrypted"] || card["cc-number"])
.toUpperCase()
.includes(searchText) ||
`${card["cc-exp-month"]}/${card["cc-exp-year"]}`
.toUpperCase()
.includes(searchText) ||
card["cc-name"]?.toUpperCase().includes(searchText)
);
this.formatMessages({
id:
stats.count == stats.total
? "payments-count"
: "payments-filtered-count",
args: stats,
}).then(([headerLabel]) => {
this.#header.value = headerLabel;
});
}
/**
* Sync lines array with the actual data source.
* This function reads all cards from the storage, adds or updates lines and
* removes lines for the removed cards.
*/
async #reloadDataSource() {
this.#enabled = Services.prefs.getBoolPref(
"extensions.formautofill.creditCards.enabled"
);
if (!this.#enabled) {
this.#reloadEmptyDataSource();
return;
}
await formAutofillStorage.initialize();
const cards = await formAutofillStorage.creditCards.getAll();
this.beforeReloadingDataSource();
cards.forEach(card => {
const lineId = `${card["cc-name"]}:${card.guid}`;
this.addOrUpdateLine(card, lineId + "0", this.#cardNumberPrototype);
this.addOrUpdateLine(card, lineId + "1", this.#expirationPrototype);
this.addOrUpdateLine(card, lineId + "2", this.#holderNamePrototype);
});
this.afterReloadingDataSource();
}
#reloadEmptyDataSource() {
this.lines.length = 0;
//todo: user can enable credit cards by activating header line
this.#header.value = this.#cardsDisabledMessage;
this.refreshAllLinesOnScreen();
}
observe(_subj, topic, message) {
if (
topic == "formautofill-storage-changed" ||
message == "extensions.formautofill.creditCards.enabled"
) {
this.#reloadDataSource();
}
}
}