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. 253
      src/views/ContactImportView.vue
  4. 2
      src/views/ContactQRScanShowView.vue
  5. 70
      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) } }
Result may be a single contact or it may be { contacts: [ contact, ... ] }
... or an array of such as { contacts: [ contact, ... ] }
*/
export const getContactPayloadFromJwtUrl = (jwtUrlText: string) => {
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.
// @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) {
return didJwt.decodeJWT(jwt);
}

253
src/views/ContactImportView.vue

@ -16,96 +16,135 @@
Contact Import
</h1>
<span
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input type="checkbox" v-model="makeVisible" class="mr-2" />
Make my activity visible to these contacts.
</span>
<div v-if="sameCount > 0">
<span v-if="sameCount == 1"
>One contact is the same as an existing contact</span
>
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
<div v-if="checkingImports" class="text-center">
<fa icon="spinner" class="animate-spin" />
</div>
<div v-else>
<span
v-if="contactsImporting.length > sameCount"
class="flex justify-center"
>
<input type="checkbox" v-model="makeVisible" class="mr-2" />
Make my activity visible to these contacts.
</span>
<!-- Results List -->
<ul
v-if="contactsImporting.length > sameCount"
class="border-t border-slate-300"
>
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
<div v-if="sameCount > 0">
<span v-if="sameCount == 1"
>One contact is the same as an existing contact</span
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div class="font-bold">Field</div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[contact.did]"
:key="contactField"
class="grid grid-cols-3 border"
<span v-else
>{{ sameCount }} contacts are the same as existing contacts</span
>
</div>
<!-- Results List -->
<ul
v-if="contactsImporting.length > sameCount"
class="border-t border-slate-300"
>
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
<div
v-if="
!contactsExisting[contact.did] ||
!R.isEmpty(contactDifferences[contact.did])
"
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
>
<h2 class="text-base font-semibold">
<input type="checkbox" v-model="contactsSelected[index]" />
{{ contact.name || AppString.NO_CONTACT_NAME }}
-
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
>Existing</span
>
<div class="border p-1">{{ contactField }}</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
<span v-else class="text-green-500">New</span>
</h2>
<div class="text-sm truncate">
{{ contact.did }}
</div>
<div v-if="contactDifferences[contact.did]">
<div>
<div class="grid grid-cols-3 gap-2">
<div></div>
<div class="font-bold">Old Value</div>
<div class="font-bold">New Value</div>
</div>
<div
v-for="(value, contactField) in contactDifferences[
contact.did
]"
:key="contactField"
class="grid grid-cols-3 border"
>
<div class="border font-bold p-1">
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
</div>
<div class="border p-1">{{ value.old }}</div>
<div class="border p-1">{{ value.new }}</div>
</div>
</div>
</div>
</div>
</li>
<button
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"
>
Import Selected Contacts
</button>
</ul>
<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>
</li>
<fa icon="spinner" v-if="importing" class="animate-spin" />
<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"
@click="importContacts"
>
Import Selected Contacts
</button>
</ul>
<p v-else>There are no contacts to import.</p>
</div>
</div>
</section>
</template>
<script lang="ts">
import { JWTVerified } from "did-jwt";
import { JWTPayload, JWTVerified } from "did-jwt";
import * as R from "ramda";
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 EntityIcon from "@/components/EntityIcon.vue";
import OfferDialog from "@/components/OfferDialog.vue";
import { AppString, NotificationIface } from "@/constants/app";
import { db, retrieveSettingsForActiveAccount } from "@/db/index";
import { Contact } from "@/db/tables/contacts";
import { APP_SERVER, AppString, NotificationIface } from "@/constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
} from "@/db/index";
import { Contact, ContactMethod } from "@/db/tables/contacts";
import * as libsUtil from "@/libs/util";
import { decodeAndVerifyJwt } from "@/libs/crypto/vc/index";
import { setVisibilityUtil } from "@/libs/endorserServer";
import { decodeAndVerifyJwt } from "@/libs/crypto/vc";
import {
capitalizeAndInsertSpacesBeforeCaps,
errorStringForLog,
setVisibilityUtil,
} from "@/libs/endorserServer";
import { getContactPayloadFromJwtUrl } from "@/libs/crypto";
@Component({
components: { EntityIcon, OfferDialog, QuickNav },
@ -114,6 +153,7 @@ export default class ContactImportView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
AppString = AppString;
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
libsUtil = libsUtil;
R = R;
@ -126,10 +166,14 @@ export default class ContactImportView extends Vue {
string,
Record<
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
importing = false;
checkingImports = false;
inputJwt: string = "";
makeVisible = true;
sameCount = 0;
@ -139,9 +183,8 @@ export default class ContactImportView extends Vue {
this.apiServer = settings.apiServer || "";
// look for any imported contacts from the query parameter
const importedContacts = (this.$route as Router).query[
"contacts"
] as string;
const importedContacts = (this.$route as RouteLocationNormalizedLoaded)
.query["contacts"] as string;
if (importedContacts) {
await this.setContactsSelected(JSON.parse(importedContacts));
}
@ -176,13 +219,13 @@ export default class ContactImportView extends Vue {
const differences: Record<
string,
{
new: string | boolean | undefined;
old: string | boolean | undefined;
new: string | boolean | Array<ContactMethod> | undefined;
old: string | boolean | Array<ContactMethod> | undefined;
}
> = {};
Object.keys(contactIn).forEach((key) => {
// 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] = {
old: existingContact[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() {
this.importing = true;
this.checkingImports = true;
let importedCount = 0,
updatedCount = 0;
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(
{

2
src/views/ContactQRScanShowView.vue

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

70
src/views/ContactsView.vue

@ -69,32 +69,36 @@
<div class="flex justify-between" v-if="contacts.length > 0">
<div class="w-full text-left">
<input
type="checkbox"
v-if="!showGiveNumbers"
:checked="contactsSelected.length === contacts.length"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
data-testId="copySelectedContactsButtonTop"
>
Copy Selections
</button>
<div v-if="!showGiveNumbers">
<input
type="checkbox"
:checked="contactsSelected.length === contacts.length"
@click="
contactsSelected.length === contacts.length
? (contactsSelected = [])
: (contactsSelected = contacts.map((contact) => contact.did))
"
class="align-middle ml-2 h-6 w-6"
data-testId="contactCheckAllTop"
/>
<button
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
:style="
contactsSelected.length > 0
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
: 'background-image: linear-gradient(to bottom, #94a3b8, #374151);'
"
@click="copySelectedContacts()"
v-if="!showGiveNumbers"
data-testId="copySelectedContactsButtonTop"
>
Copy Selections
</button>
<button @click="showCopySelectionsInfo()">
<fa icon="circle-info" class="text-xl text-blue-500 ml-4" />
</button>
</div>
</div>
<div class="w-full text-right">
@ -883,7 +887,7 @@ export default class ContactsView extends Vue {
if (Array.isArray(payload.contacts)) {
// reroute to the ContactsImport
(this.$router as Router).push({
path: "/contacts-import/" + url.substring(url.lastIndexOf("/") + 1),
path: "/contact-import/" + url.substring(url.lastIndexOf("/") + 1),
});
return;
}
@ -1349,5 +1353,17 @@ export default class ContactsView extends Vue {
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>

33
src/views/InviteOneAcceptView.vue

@ -1,14 +1,20 @@
<template>
<QuickNav selected="Invite" />
<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 class="mt-2">
Go back to your invite message and copy the entire text, then paste it
here.
</p>
<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
invite.) If it still complains, you may need the person who invited you
to send a new one.
@ -25,16 +31,9 @@
@click="() => processInvite(inputJwt, true)"
class="ml-2 p-2 bg-blue-500 text-white rounded"
>
Submit
Accept
</button>
</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>
</template>
@ -43,7 +42,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "@/components/QuickNav.vue";
import { NotificationIface } from "@/constants/app";
import { APP_SERVER, NotificationIface } from "@/constants/app";
import {
db,
logConsoleAndDb,
@ -57,7 +56,6 @@ import { generateSaveAndActivateIdentity } from "@/libs/util";
export default class InviteOneAcceptView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
acceptInput: boolean = false;
activeDid: string = "";
apiServer: string = "";
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
// and then extract the JWT from the URL
// (For another approach used with contacts, see getContactPayloadFromJwtUrl)
const urlMatch = jwtInput.match(/(https?:\/\/[^\s]+)/);
if (urlMatch && urlMatch[1]) {
// extract the JWT from the URL, meaning any character except "?"
@ -112,13 +111,12 @@ export default class InviteOneAcceptView extends Vue {
{
group: "alert",
type: "danger",
title: "Missing invite",
text: "There was no invite. Paste the entire text that has the link.",
title: "Missing Invite",
text: "There was no invite. Paste the entire text that has the data.",
},
5000,
);
}
this.acceptInput = true;
} else {
//const payload: JWTPayload =
decodeEndorserJwt(jwt);
@ -144,7 +142,6 @@ export default class InviteOneAcceptView extends Vue {
3000,
);
}
this.acceptInput = true;
}
this.checkingInvite = false;
}
@ -152,6 +149,8 @@ export default class InviteOneAcceptView extends Vue {
// check the invite JWT
async checkInvite(jwtInput: string) {
if (
jwtInput.endsWith(APP_SERVER) ||
jwtInput.endsWith(APP_SERVER + "/") ||
jwtInput.endsWith("invite-one-accept") ||
jwtInput.endsWith("invite-one-accept/")
) {
@ -160,7 +159,7 @@ export default class InviteOneAcceptView extends Vue {
group: "alert",
type: "danger",
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,
);

Loading…
Cancel
Save