Browse Source

add sanity checks for importing bulk contacts, eg. when there is a truncated link

master
Trent Larson 2 weeks ago
parent
commit
07c4e58e87
  1. 5
      src/libs/crypto/index.ts
  2. 2
      src/libs/crypto/vc/index.ts
  3. 137
      src/views/ContactImportView.vue
  4. 2
      src/views/ContactQRScanShowView.vue
  5. 20
      src/views/ContactsView.vue
  6. 33
      src/views/InviteOneAcceptView.vue

5
src/libs/crypto/index.ts

@ -103,10 +103,9 @@ export const accessToken = async (did?: string) => {
}; };
/** /**
@return payload of JWT pulled out of the URL and decoded: @return payload of JWT pulled out of any recognized URL path (if any) and decoded:
{ iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } } { iat: number, iss: string (DID), own: { name, publicEncKey (base64-encoded key) } }
... or an array of such as { contacts: [ contact, ... ] }
Result may be a single contact or it may be { contacts: [ contact, ... ] }
*/ */
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => { export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText; let jwtText = jwtUrlText;

2
src/libs/crypto/vc/index.ts

@ -124,7 +124,7 @@ function bytesToHex(b: Uint8Array): string {
} }
// We should be calling 'verify' in more places, showing warnings if it fails. // We should be calling 'verify' in more places, showing warnings if it fails.
// @returns JWTDecoded with { header: JWTHeader, payload: string, signature: string, data: string } (but doesn't verify the signature) // @returns JWTDecoded with { header: JWTHeader, payload: any, signature: string, data: string } (but doesn't verify the signature)
export function decodeEndorserJwt(jwt: string) { export function decodeEndorserJwt(jwt: string) {
return didJwt.decodeJWT(jwt); return didJwt.decodeJWT(jwt);
} }

137
src/views/ContactImportView.vue

@ -16,6 +16,10 @@
Contact Import Contact Import
</h1> </h1>
<div v-if="checkingImports" class="text-center">
<fa icon="spinner" class="animate-spin" />
</div>
<div v-else>
<span <span
v-if="contactsImporting.length > sameCount" v-if="contactsImporting.length > sameCount"
class="flex justify-center" class="flex justify-center"
@ -61,16 +65,20 @@
<div v-if="contactDifferences[contact.did]"> <div v-if="contactDifferences[contact.did]">
<div> <div>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-3 gap-2">
<div class="font-bold">Field</div> <div></div>
<div class="font-bold">Old Value</div> <div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div> <div class="font-bold">New Value</div>
</div> </div>
<div <div
v-for="(value, contactField) in contactDifferences[contact.did]" v-for="(value, contactField) in contactDifferences[
contact.did
]"
:key="contactField" :key="contactField"
class="grid grid-cols-3 border" class="grid grid-cols-3 border"
> >
<div class="border p-1">{{ contactField }}</div> <div class="border font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div>
<div class="border p-1">{{ value.old }}</div> <div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div> <div class="border p-1">{{ value.new }}</div>
</div> </div>
@ -78,34 +86,65 @@
</div> </div>
</div> </div>
</li> </li>
<fa icon="spinner" v-if="importing" class="animate-spin" />
<button <button
v-else
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
@click="importContacts" @click="importContacts"
> >
Import Selected Contacts Import Selected Contacts
</button> </button>
</ul> </ul>
<p v-else>There are no contacts to import.</p> <p v-else-if="contactsImporting.length > 0">
All those contacts are already in your list with the same information.
</p>
<div v-else>
There are no contacts in that import. If some were sent, try again to
get the full text and paste it. (Note that iOS cuts off data in text
messages.) Ask the person to send the data a different way, eg. email.
<div class="mt-4 text-center">
<textarea
v-model="inputJwt"
placeholder="Contact-import data"
class="mt-4 border-2 border-gray-300 p-2 rounded"
cols="30"
@input="() => checkContactJwt(inputJwt)"
/>
<br />
<button
@click="() => processContactJwt(inputJwt)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Check Import
</button>
</div>
</div>
</div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { JWTVerified } from "did-jwt"; import { JWTPayload, JWTVerified } from "did-jwt";
import * as R from "ramda"; import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import EntityIcon from "@/components/EntityIcon.vue"; import EntityIcon from "@/components/EntityIcon.vue";
import OfferDialog from "@/components/OfferDialog.vue"; import OfferDialog from "@/components/OfferDialog.vue";
import { AppString, NotificationIface } from "@/constants/app"; import { APP_SERVER, AppString, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index"; import {
import { Contact } from "@/db/tables/contacts"; db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact, ContactMethod } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util"; import * as libsUtil from "@/libs/util";
import { decodeAndVerifyJwt } from "@/libs/crypto/vc/index"; import { decodeAndVerifyJwt } from "@/libs/crypto/vc";
import { setVisibilityUtil } from "@/libs/endorserServer"; import {
capitalizeAndInsertSpacesBeforeCaps,
errorStringForLog,
setVisibilityUtil,
} from "@/libs/endorserServer";
import { getContactPayloadFromJwtUrl } from "@/libs/crypto";
@Component({ @Component({
components: { EntityIcon, OfferDialog, QuickNav }, components: { EntityIcon, OfferDialog, QuickNav },
@ -114,6 +153,7 @@ export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString; AppString = AppString;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
libsUtil = libsUtil; libsUtil = libsUtil;
R = R; R = R;
@ -126,10 +166,14 @@ export default class ContactImportView extends Vue {
string, string,
Record< Record<
string, string,
{ new: string | boolean | undefined; old: string | boolean | undefined } {
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
> >
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key > = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
importing = false; checkingImports = false;
inputJwt: string = "";
makeVisible = true; makeVisible = true;
sameCount = 0; sameCount = 0;
@ -139,9 +183,8 @@ export default class ContactImportView extends Vue {
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
// look for any imported contacts from the query parameter // look for any imported contacts from the query parameter
const importedContacts = (this.$route as Router).query[ const importedContacts = (this.$route as RouteLocationNormalizedLoaded)
"contacts" .query["contacts"] as string;
] as string;
if (importedContacts) { if (importedContacts) {
await this.setContactsSelected(JSON.parse(importedContacts)); await this.setContactsSelected(JSON.parse(importedContacts));
} }
@ -176,13 +219,13 @@ export default class ContactImportView extends Vue {
const differences: Record< const differences: Record<
string, string,
{ {
new: string | boolean | undefined; new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | undefined; old: string | boolean | Array<ContactMethod> | undefined;
} }
> = {}; > = {};
Object.keys(contactIn).forEach((key) => { Object.keys(contactIn).forEach((key) => {
// eslint-disable-next-line prettier/prettier // eslint-disable-next-line prettier/prettier
if (contactIn[key as keyof Contact] !== existingContact[key as keyof Contact]) { if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
differences[key] = { differences[key] = {
old: existingContact[key as keyof Contact], old: existingContact[key as keyof Contact],
new: contactIn[key as keyof Contact], new: contactIn[key as keyof Contact],
@ -200,8 +243,56 @@ export default class ContactImportView extends Vue {
} }
} }
// check the contact-import JWT
async checkContactJwt(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("contact-import") ||
jwtInput.endsWith("contact-import/")
) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "That is only part of the contact-import data; it's missing data at the end. Try another way to get the full data.",
},
5000,
);
}
}
// process the invite JWT and/or text message containing the URL with the JWT
async processContactJwt(jwtInput: string) {
this.checkingImports = true;
try {
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
const payload: JWTPayload = getContactPayloadFromJwtUrl(jwtInput);
if (Array.isArray(payload.contacts)) {
await this.setContactsSelected(payload.contacts);
} else {
throw new Error("Invalid contact-import JWT or URL: " + jwtInput);
}
} catch (error) {
const fullError = "Error importing contacts: " + errorStringForLog(error);
logConsoleAndDb(fullError, true);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error processing the contact-import data.",
},
3000,
);
}
this.checkingImports = false;
}
async importContacts() { async importContacts() {
this.importing = true; this.checkingImports = true;
let importedCount = 0, let importedCount = 0,
updatedCount = 0; updatedCount = 0;
for (let i = 0; i < this.contactsImporting.length; i++) { for (let i = 0; i < this.contactsImporting.length; i++) {
@ -253,7 +344,7 @@ export default class ContactImportView extends Vue {
} }
} }
this.importing = false; this.checkingImports = false;
this.$notify( this.$notify(
{ {

2
src/views/ContactQRScanShowView.vue

@ -196,7 +196,7 @@ export default class ContactQRScanShow extends Vue {
if (Array.isArray(payload.contacts)) { if (Array.isArray(payload.contacts)) {
// reroute to the ContactsImport // reroute to the ContactsImport
(this.$router as Router).push({ (this.$router as Router).push({
path: "/contacts-import/" + url.substring(url.lastIndexOf("/") + 1), path: "/contact-import/" + url.substring(url.lastIndexOf("/") + 1),
}); });
return; return;
} }

20
src/views/ContactsView.vue

@ -69,9 +69,9 @@
<div class="flex justify-between" v-if="contacts.length > 0"> <div class="flex justify-between" v-if="contacts.length > 0">
<div class="w-full text-left"> <div class="w-full text-left">
<div v-if="!showGiveNumbers">
<input <input
type="checkbox" type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.length === contacts.length" :checked="contactsSelected.length === contacts.length"
@click=" @click="
contactsSelected.length === contacts.length contactsSelected.length === contacts.length
@ -95,6 +95,10 @@
> >
Copy Selections Copy Selections
</button> </button>
<button @click="showCopySelectionsInfo()">
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</button>
</div>
</div> </div>
<div class="w-full text-right"> <div class="w-full text-right">
@ -883,7 +887,7 @@ export default class ContactsView extends Vue {
if (Array.isArray(payload.contacts)) { if (Array.isArray(payload.contacts)) {
// reroute to the ContactsImport // reroute to the ContactsImport
(this.$router as Router).push({ (this.$router as Router).push({
path: "/contacts-import/" + url.substring(url.lastIndexOf("/") + 1), path: "/contact-import/" + url.substring(url.lastIndexOf("/") + 1),
}); });
return; return;
} }
@ -1349,5 +1353,17 @@ export default class ContactsView extends Vue {
return did.substring(0, did.indexOf(":", 4) + 7) + "..."; return did.substring(0, did.indexOf(":", 4) + 7) + "...";
} }
} }
private showCopySelectionsInfo() {
this.$notify(
{
group: "alert",
type: "info",
title: "Copying Contacts",
text: "Contact info will include name, ID, profile image, and public key.",
},
5000,
);
}
} }
</script> </script>

33
src/views/InviteOneAcceptView.vue

@ -1,14 +1,20 @@
<template> <template>
<QuickNav selected="Invite" /> <QuickNav selected="Invite" />
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto"> <section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<div v-if="acceptInput" class="text-center mt-4"> <div
v-if="checkingInvite"
class="text-lg text-center font-light relative px-7"
>
<fa icon="spinner" class="fa-spin-pulse" />
</div>
<div v-else class="text-center mt-4">
<p>That invitation did not work.</p> <p>That invitation did not work.</p>
<p class="mt-2"> <p class="mt-2">
Go back to your invite message and copy the entire text, then paste it Go back to your invite message and copy the entire text, then paste it
here. here.
</p> </p>
<p class="mt-2"> <p class="mt-2">
If the link looks correct, try Chrome. (For example, iOS may have cut If the data looks correct, try Chrome. (For example, iOS may have cut
off the invite data, or it may have shown a preview that stole your off the invite data, or it may have shown a preview that stole your
invite.) If it still complains, you may need the person who invited you invite.) If it still complains, you may need the person who invited you
to send a new one. to send a new one.
@ -25,16 +31,9 @@
@click="() => processInvite(inputJwt, true)" @click="() => processInvite(inputJwt, true)"
class="ml-2 p-2 bg-blue-500 text-white rounded" class="ml-2 p-2 bg-blue-500 text-white rounded"
> >
Submit Accept
</button> </button>
</div> </div>
<div
v-if="checkingInvite"
class="text-lg text-center font-light relative px-7"
>
<fa icon="spinner" class="fa-spin-pulse" />
Loading&hellip;
</div>
</section> </section>
</template> </template>
@ -43,7 +42,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue"; import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app"; import { APP_SERVER, NotificationIface } from "@/constants/app";
import { import {
db, db,
logConsoleAndDb, logConsoleAndDb,
@ -57,7 +56,6 @@ import { generateSaveAndActivateIdentity } from "@/libs/util";
export default class InviteOneAcceptView extends Vue { export default class InviteOneAcceptView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
acceptInput: boolean = false;
activeDid: string = ""; activeDid: string = "";
apiServer: string = ""; apiServer: string = "";
checkingInvite: boolean = true; checkingInvite: boolean = true;
@ -91,6 +89,7 @@ export default class InviteOneAcceptView extends Vue {
// parse the string: extract the URL or JWT if surrounded by spaces // parse the string: extract the URL or JWT if surrounded by spaces
// and then extract the JWT from the URL // and then extract the JWT from the URL
// (For another approach used with contacts, see getContactPayloadFromJwtUrl)
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/); const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) { if (urlMatch && urlMatch[1]) {
// extract the JWT from the URL, meaning any character except "?" // extract the JWT from the URL, meaning any character except "?"
@ -112,13 +111,12 @@ export default class InviteOneAcceptView extends Vue {
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Missing invite", title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the link.", text: "There was no invite. Paste the entire text that has the data.",
}, },
5000, 5000,
); );
} }
this.acceptInput = true;
} else { } else {
//const payload: JWTPayload = //const payload: JWTPayload =
decodeEndorserJwt(jwt); decodeEndorserJwt(jwt);
@ -144,7 +142,6 @@ export default class InviteOneAcceptView extends Vue {
3000, 3000,
); );
} }
this.acceptInput = true;
} }
this.checkingInvite = false; this.checkingInvite = false;
} }
@ -152,6 +149,8 @@ export default class InviteOneAcceptView extends Vue {
// check the invite JWT // check the invite JWT
async checkInvite(jwtInput: string) { async checkInvite(jwtInput: string) {
if ( if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("invite-one-accept") || jwtInput.endsWith("invite-one-accept") ||
jwtInput.endsWith("invite-one-accept/") jwtInput.endsWith("invite-one-accept/")
) { ) {
@ -160,7 +159,7 @@ export default class InviteOneAcceptView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "That is only part of the invite link; it's missing data at the end. Try another way to get the full link.", text: "That is only part of the invite data; it's missing some at the end. Try another way to get the full data.",
}, },
5000, 5000,
); );

Loading…
Cancel
Save