From 6ffbcfa9a10b44b9d045fdd6cc1dae6c402c13fc Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Tue, 10 Dec 2024 20:02:49 -0700 Subject: [PATCH] catch more errors if something catastrophic happens to encrypted data --- src/App.vue | 22 +++++ src/components/PushNotificationPermission.vue | 10 +-- src/components/QuickNav.vue | 6 ++ src/libs/endorserServer.ts | 82 ++++++++++++++----- src/libs/util.ts | 24 ++++++ src/router/index.ts | 4 + src/views/AccountViewView.vue | 4 +- src/views/ClaimView.vue | 25 +++++- src/views/ContactsView.vue | 9 +- src/views/HelpNotificationsView.vue | 37 ++++++--- src/views/HelpView.vue | 18 ++-- src/views/HomeView.vue | 33 ++++++-- src/views/IdentitySwitcherView.vue | 1 + src/views/ProjectViewView.vue | 25 +++++- src/views/ProjectsView.vue | 2 +- src/views/SeedBackupView.vue | 4 +- 16 files changed, 237 insertions(+), 69 deletions(-) diff --git a/src/App.vue b/src/App.vue index 2ec9239..50249b2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -309,6 +309,28 @@ + +
+
+
+

+ Something has gone very wrong. We'd appreciate if you'd + contact us and let us know how you got here. Thank you! +

+ +
+
+
diff --git a/src/components/PushNotificationPermission.vue b/src/components/PushNotificationPermission.vue index dd9d283..54af4f8 100644 --- a/src/components/PushNotificationPermission.vue +++ b/src/components/PushNotificationPermission.vue @@ -9,7 +9,7 @@ >
{ - logConsoleAndDb( - "Requesting permission for notifications: " + JSON.stringify(navigator), - ); + // console.log( + // "Requesting permission for notifications: " + JSON.stringify(navigator), + // ); if ( !("serviceWorker" in navigator && navigator.serviceWorker?.controller) ) { @@ -344,7 +344,7 @@ export default class PushNotificationPermission extends Vue { }, -1, ); - throw new Error("We weren't granted permission."); + throw new Error("Permission was not granted to this app."); } return permission; }, diff --git a/src/components/QuickNav.vue b/src/components/QuickNav.vue index 58b02c2..ade2161 100644 --- a/src/components/QuickNav.vue +++ b/src/components/QuickNav.vue @@ -90,6 +90,12 @@ >
+ profile
diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index fedf436..ed44c39 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -4,10 +4,10 @@ import { sha256 } from "ethereum-cryptography/sha256"; import { LRUCache } from "lru-cache"; import * as R from "ramda"; -import { DEFAULT_IMAGE_API_SERVER } from "@/constants/app"; +import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "@/constants/app"; import { Contact } from "@/db/tables/contacts"; import { accessToken, deriveAddress, nextDerivationPath } from "@/libs/crypto"; -import { NonsensitiveDexie } from "@/db/index"; +import { logConsoleAndDb, NonsensitiveDexie } from "@/db/index"; import { retrieveAccountMetadata, retrieveFullyDecryptedAccount, @@ -501,35 +501,72 @@ export function tokenExpiryTimeDescription() { /** * Get the headers for a request, potentially including Authorization */ -export async function getHeaders(did?: string) { +export async function getHeaders( + did?: string, + $notify?: (notification: NotificationIface, timeout?: number) => void, + failureMessage?: string, +) { const headers: { "Content-Type": string; Authorization?: string } = { "Content-Type": "application/json", }; if (did) { - let token; - const account = await retrieveAccountMetadata(did); - if (account?.passkeyCredIdHex) { - if ( - passkeyAccessToken && - passkeyTokenExpirationEpochSeconds > Date.now() / 1000 - ) { - // there's an active current passkey token - token = passkeyAccessToken; + try { + let token; + const account = await retrieveAccountMetadata(did); + if (account?.passkeyCredIdHex) { + if ( + passkeyAccessToken && + passkeyTokenExpirationEpochSeconds > Date.now() / 1000 + ) { + // there's an active current passkey token + token = passkeyAccessToken; + } else { + // there's no current passkey token or it's expired + token = await accessToken(did); + + passkeyAccessToken = token; + const passkeyExpirationSeconds = await getPasskeyExpirationSeconds(); + passkeyTokenExpirationEpochSeconds = + Date.now() / 1000 + passkeyExpirationSeconds; + } } else { - // there's no current passkey token or it's expired token = await accessToken(did); - - passkeyAccessToken = token; - const passkeyExpirationSeconds = await getPasskeyExpirationSeconds(); - passkeyTokenExpirationEpochSeconds = - Date.now() / 1000 + passkeyExpirationSeconds; } - } else { - token = await accessToken(did); + headers["Authorization"] = "Bearer " + token; + } catch (error) { + // This rarely happens: we've seen it when they have account info but the + // encryption secret got lost. But in most cases we want users to at + // least see their feed -- and anything else that returns results for + // anonymous users. + + // We'll continue with an anonymous request... still want to show feed and other things, but ideally let them know. + logConsoleAndDb( + "Something failed in getHeaders call (will proceed anonymously" + + ($notify ? " and notify user" : "") + + "): " + + // IntelliJ type system complains about getCircularReplacer() with: Argument of type '(obj: any, key: string, value: any) => any' is not assignable to parameter of type '(this: any, key: string, value: any) => any'. + //JSON.stringify(error, getCircularReplacer()), // JSON.stringify(error) on a Dexie error throws another error about: Converting circular structure to JSON + error, + true, + ); + if ($notify) { + // remember: only want to do this if they supplied a DID, expecting personal results + const notifyMessage = + failureMessage || + "Showing anonymous data. See the Help page for help with personal data."; + $notify( + { + group: "alert", + type: "danger", + title: "Personal Data Error", + text: notifyMessage, + }, + 3000, + ); + } } - headers["Authorization"] = "Bearer " + token; } else { - // it's often OK to request without auth; we assume necessary checks are done earlier + // it's usually OK to request without auth; we assume we're only here when allowed } return headers; } @@ -611,6 +648,7 @@ export async function getNewOffersToUser( url += "&beforeId=" + beforeOfferJwtId; } const headers = await getHeaders(activeDid); + console.log("Using headers: ", headers); const response = await axios.get(url, { headers }); return response.data; } diff --git a/src/libs/util.ts b/src/libs/util.ts index f449cbe..653e784 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -147,6 +147,23 @@ export interface ConfirmerData { numConfsNotVisible: number; } +// // This is meant to be a second argument to JSON.stringify to avoid circular references. +// // Usage: JSON.stringify(error, getCircularReplacer()) +// // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed +// function getCircularReplacer() { +// const seen = new WeakSet(); +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// return (obj: any, key: string, value: any): any => { +// if (typeof value === "object" && value !== null) { +// if (seen.has(value)) { +// return "[circular ref]"; +// } +// seen.add(value); +// } +// return value; +// }; +// } + /** * @return only confirmers, excluding the issuer and hidden DIDs */ @@ -423,11 +440,13 @@ export function findAllVisibleToDids( export interface AccountKeyInfo extends Account, KeyMeta {} export const retrieveAccountCount = async (): Promise => { + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; return await accountsDB.accounts.count(); }; export const retrieveAccountDids = async (): Promise => { + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const allAccounts = await accountsDB.accounts.toArray(); const allDids = allAccounts.map((acc) => acc.did); @@ -439,6 +458,7 @@ export const retrieveAccountDids = async (): Promise => { export const retrieveAccountMetadata = async ( activeDid: string, ): Promise => { + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const account = (await accountsDB.accounts .where("did") @@ -454,6 +474,7 @@ export const retrieveAccountMetadata = async ( }; export const retrieveAllAccountsMetadata = async (): Promise => { + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const array = await accountsDB.accounts.toArray(); return array.map((account) => { @@ -466,6 +487,7 @@ export const retrieveAllAccountsMetadata = async (): Promise => { export const retrieveFullyDecryptedAccount = async ( activeDid: string, ): Promise => { + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const account = (await accountsDB.accounts .where("did") @@ -496,6 +518,7 @@ export const generateSaveAndActivateIdentity = async (): Promise => { const newId = newIdentifier(address, publicHex, privateHex, derivationPath); const identity = JSON.stringify(newId); + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; await accountsDB.accounts.add({ dateCreated: new Date().toISOString(), @@ -527,6 +550,7 @@ export const registerAndSavePasskey = async ( passkeyCredIdHex, publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"), }; + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; await accountsDB.accounts.add(account); return account; diff --git a/src/router/index.ts b/src/router/index.ts index efd96bb..bffa5b4 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -18,6 +18,7 @@ const enterOrStart = async ( from: RouteLocationNormalized, next: NavigationGuardNext, ) => { + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; const num_accounts = await accountsDB.accounts.count(); if (num_accounts > 0) { @@ -263,6 +264,9 @@ const errorHandler = ( ) => { // Handle the error here console.error("Caught in top level error handler:", error, to, from); + alert( + "Something is very wrong. We'd love if you contacted us and let us know how you got here. Thank you!", + ); // You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page }; diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index b992359..1298f97 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -951,8 +951,8 @@ export default class AccountViewView extends Vue { { group: "alert", type: "danger", - title: "Error Loading Account", - text: "Clear your cache and start over (after data backup).", + title: "Error Loading Profile", + text: "See the Help page about errors with your personal data.", }, -1, ); diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index ce7898a..1e593e3 100644 --- a/src/views/ClaimView.vue +++ b/src/views/ClaimView.vue @@ -484,7 +484,11 @@ import { useClipboard } from "@vueuse/core"; import GiftedDialog from "@/components/GiftedDialog.vue"; import QuickNav from "@/components/QuickNav.vue"; import { NotificationIface } from "@/constants/app"; -import { db, retrieveSettingsForActiveAccount } from "@/db/index"; +import { + db, + logConsoleAndDb, + retrieveSettingsForActiveAccount, +} from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import * as serverUtil from "@/libs/endorserServer"; import { @@ -559,7 +563,24 @@ export default class ClaimView extends Vue { this.allContacts = await db.contacts.toArray(); this.isRegistered = settings.isRegistered || false; - this.allMyDids = await libsUtil.retrieveAccountDids(); + try { + this.allMyDids = await libsUtil.retrieveAccountDids(); + } catch (error) { + // continue because we want to see claims, even anonymously + logConsoleAndDb( + "Error retrieving all account DIDs on home page:" + error, + true, + ); + this.$notify( + { + group: "alert", + type: "danger", + title: "Error Loading Profile", + text: "See the Help page for problems with your personal data.", + }, + -1, + ); + } const pathParam = window.location.pathname.substring("/claim/".length); let claimId; diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index 408fa4d..5bb378d 100644 --- a/src/views/ContactsView.vue +++ b/src/views/ContactsView.vue @@ -171,8 +171,9 @@ - {{ shortDid(contact.did) }}...{{ + shortDid(contact.did) + }}
@@ -425,7 +426,7 @@ export default class ContactsView extends Vue { group: "alert", type: "warning", title: "Blank Invite", - text: "The invite was not included. This can happen when your device cuts off the link, so you might try pasting the full link into a browser.", + text: "The invite was not included, which can happen when your iOS device cuts off the link. Try pasting the full link into a browser.", }, 7000, ); @@ -601,7 +602,7 @@ export default class ContactsView extends Vue { }; try { - const headers = await getHeaders(this.activeDid); + const headers = await getHeaders(this.activeDid, this.$notify); const givenByUrl = this.apiServer + "/api/v2/report/gives?agentDid=" + diff --git a/src/views/HelpNotificationsView.vue b/src/views/HelpNotificationsView.vue index 63bde2b..e2c32c7 100644 --- a/src/views/HelpNotificationsView.vue +++ b/src/views/HelpNotificationsView.vue @@ -75,6 +75,7 @@ +

@@ -193,14 +194,18 @@

Reinstall

- If all else fails, uninstall the app, ensure all the browser tabs with - it are closed, and clear out caches and storage. + If all else fails, it's best to start over.

Of course, you'll want to back up all your data first -- all seeds as - well as the contacts & settings -- on the Account + well as the contacts & settings -- on the Profile page.

+

+ Here are instructions to uninstall the app and clear out caches and storage. + Note that you should first ensure check that the browser tabs with Time Safari are closed. + (If any are open then that will interfere with your refresh.) +

  • Clear cache. @@ -304,9 +309,12 @@ import { Component, Vue } from "vue-facing-decorator"; import QuickNav from "@/components/QuickNav.vue"; import { NotificationIface } from "@/constants/app"; -import { sendTestThroughPushServer } from "@/libs/util"; +import { DIRECT_PUSH_TITLE, sendTestThroughPushServer } from "@/libs/util"; +import PushNotificationPermission from "@/components/PushNotificationPermission.vue"; +import { db } from "@/db/index"; +import { MASTER_SETTINGS_KEY } from "@/db/tables/settings"; -@Component({ components: { QuickNav } }) +@Component({ components: { PushNotificationPermission, QuickNav } }) export default class HelpNotificationsView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; @@ -407,14 +415,19 @@ export default class HelpNotificationsView extends Vue { } showNotificationChoice() { - this.$notify( - { - group: "modal", - type: "notification-permission", - title: "", // unused, only here to satisfy type check - text: "", // unused, only here to satisfy type check + (this.$refs.pushNotificationPermission as PushNotificationPermission).open( + DIRECT_PUSH_TITLE, + async (success: boolean, timeText: string, message?: string) => { + if (success) { + await db.settings.update(MASTER_SETTINGS_KEY, { + notifyingReminderMessage: message, + notifyingReminderTime: timeText, + }); + this.notifyingReminder = true; + this.notifyingReminderMessage = message || ""; + this.notifyingReminderTime = timeText; + } }, - -1, ); } } diff --git a/src/views/HelpView.vue b/src/views/HelpView.vue index b9bf4f0..dbbe344 100644 --- a/src/views/HelpView.vue +++ b/src/views/HelpView.vue @@ -383,7 +383,7 @@ How do I access even more functionality?

    - There is an "Advanced" section at the bottom of the Account + There is an "Advanced" section at the bottom of the Profile page.

    @@ -422,19 +422,19 @@

    - My app is misbehaving, like showing me a blank screen or failing to show a feed. + This app is misbehaving, like showing me a blank screen or failing to show my personal data. What can I do?

    First, note that clearing the cache will clear all your identity and contact info, - so we recommend doing other things first (unless you know you have your backups ready). + so we recommend doing other things first -- and only clearing when have your backups ready.

    • Drag down on the screen to refresh it; do that multiple times, because - it sometimes takes multiple tries for the app to refresh to the current version. + it sometimes takes multiple tries for the app to refresh to the latest version. You can see the version information at the bottom of this page; the best - way to determine the current version is to open this page in an incognito + way to determine the latest version is to open this page in an incognito/private browser window and look at the version there.
    • @@ -498,7 +498,7 @@
      For notifications, this service stores push token data; that can be revoked at any time - by disabling notifications on the Account page. + by disabling notifications on the Profile page.
      For all other claim data, @@ -520,9 +520,9 @@ class="text-blue-500 ml-2" > bc1q90v4ted6cpt63tjfh2lvd5xzfc67sd4g9w8xma - + + - Copied You can donate online via Patreon here. For other donations, contact us. @@ -541,7 +541,7 @@

      {{ package.version }} ({{ commitHash }})

      - I have other questions or feedback, like getting a new account or removing my data or requesting an improvement. + I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.

      Contact us at diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index bb9bd1c..8e4de44 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -389,6 +389,7 @@ import { } from "@/constants/app"; import { db, + logConsoleAndDb, retrieveSettingsForActiveAccount, updateAccountSettings, } from "@/db/index"; @@ -486,12 +487,21 @@ export default class HomeView extends Vue { async mounted() { try { - this.allMyDids = await retrieveAccountDids(); - if (this.allMyDids.length === 0) { - this.isCreatingIdentifier = true; - const newDid = await generateSaveAndActivateIdentity(); - this.isCreatingIdentifier = false; - this.allMyDids = [newDid]; + try { + this.allMyDids = await retrieveAccountDids(); + if (this.allMyDids.length === 0) { + this.isCreatingIdentifier = true; + const newDid = await generateSaveAndActivateIdentity(); + this.isCreatingIdentifier = false; + this.allMyDids = [newDid]; + } + } catch (error) { + // continue because we want the feed to work, even anonymously + logConsoleAndDb( + "Error retrieving all account DIDs on home page:" + error, + true, + ); + // some other piece will display an error about personal info } const settings = await retrieveSettingsForActiveAccount(); @@ -546,6 +556,7 @@ export default class HomeView extends Vue { this.activeDid, this.lastAckedOfferToUserJwtId, ); + console.log("offersToUserData", offersToUserData); this.numNewOffersToUser = offersToUserData.data.length; this.newOffersToUserHitLimit = offersToUserData.hitLimit; } @@ -563,7 +574,7 @@ export default class HomeView extends Vue { // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { - console.error("Error retrieving settings or feed.", err); + logConsoleAndDb("Error retrieving settings or feed: " + err, true); this.$notify( { group: "alert", @@ -760,13 +771,19 @@ export default class HomeView extends Vue { */ async retrieveGives(endorserApiServer: string, beforeId?: string) { const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId; + const doNotShowErrorAgain = !!beforeId; // don't show error again if we're loading more + const headers = await getHeaders( + this.activeDid, + doNotShowErrorAgain ? undefined : this.$notify, + ); + // retrieve headers for this user, but if an error happens then report it but proceed with the fetch with no header const response = await fetch( endorserApiServer + "/api/v2/report/gives?giftNotTrade=true" + beforeQuery, { method: "GET", - headers: await getHeaders(this.activeDid), + headers: headers, }, ); diff --git a/src/views/IdentitySwitcherView.vue b/src/views/IdentitySwitcherView.vue index bfefd94..4392203 100644 --- a/src/views/IdentitySwitcherView.vue +++ b/src/views/IdentitySwitcherView.vue @@ -170,6 +170,7 @@ export default class IdentitySwitcherView extends Vue { title: "Delete Identity?", text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)", onYes: async () => { + // one of the few times we use accountsDBPromise directly; try to avoid more usage const accountsDB = await accountsDBPromise; await accountsDB.accounts.delete(id); this.otherIdentities = this.otherIdentities.filter( diff --git a/src/views/ProjectViewView.vue b/src/views/ProjectViewView.vue index 685e88f..787d7fb 100644 --- a/src/views/ProjectViewView.vue +++ b/src/views/ProjectViewView.vue @@ -489,7 +489,11 @@ import QuickNav from "@/components/QuickNav.vue"; import EntityIcon from "@/components/EntityIcon.vue"; import ProjectIcon from "@/components/ProjectIcon.vue"; import { NotificationIface } from "@/constants/app"; -import { db, retrieveSettingsForActiveAccount } from "@/db/index"; +import { + db, + logConsoleAndDb, + retrieveSettingsForActiveAccount, +} from "@/db/index"; import { Contact } from "@/db/tables/contacts"; import * as libsUtil from "@/libs/util"; import { @@ -557,7 +561,24 @@ export default class ProjectViewView extends Vue { this.allContacts = await db.contacts.toArray(); this.isRegistered = !!settings.isRegistered; - this.allMyDids = await retrieveAccountDids(); + try { + this.allMyDids = await retrieveAccountDids(); + } catch (error) { + // continue because we want to see claims, even anonymously + logConsoleAndDb( + "Error retrieving all account DIDs on home page:" + error, + true, + ); + this.$notify( + { + group: "alert", + type: "danger", + title: "Error Loading Profile", + text: "See the Help page to fix problems with your personal data.", + }, + -1, + ); + } const pathParam = window.location.pathname.substring("/project/".length); if (pathParam) { diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue index 32fea4e..d151813 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -355,7 +355,7 @@ export default class ProjectsView extends Vue { **/ async projectDataLoader(url: string) { try { - const headers = await getHeaders(this.activeDid); + const headers = await getHeaders(this.activeDid, this.$notify); this.isLoading = true; const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); if (resp.status === 200 && resp.data.data) { diff --git a/src/views/SeedBackupView.vue b/src/views/SeedBackupView.vue index 9604b01..a991a79 100644 --- a/src/views/SeedBackupView.vue +++ b/src/views/SeedBackupView.vue @@ -94,7 +94,7 @@

-
You do not have an active identifier.
+
You do not have an active identity.
@@ -135,7 +135,7 @@ export default class SeedBackupView extends Vue { { group: "alert", type: "danger", - title: "Error Loading Account", + title: "Error Loading Profile", text: "Got an error loading your seed data.", }, -1,