forked from trent_larson/crowd-funder-for-time-pwa
Remove CORS headers to enable universal image support and fix local API server settings. ## Changes **Remove CORS Headers** - Remove Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers - Enables images from any domain (Facebook, Medium, arbitrary websites) - Database falls back to IndexedDB mode (minimal performance impact) **Fix Local Development Configuration** - Set LOCAL_ENDORSER_API_SERVER to http://127.0.0.1:3000 (was "/api") - Create .env.development with local API server config - Fix ensureCorrectApiServer() method in HomeView.vue - "Use Local" button now sets proper localhost address **Fix Settings Cache Issues** - Add PlatformServiceMixin to AccountViewView.vue - Disable settings caching to prevent stale data - Settings changes now apply immediately without browser refresh ## Impact **Tradeoffs:** - Lost: ~2x SharedArrayBuffer database performance - Gained: Universal image support from any domain - Result: Better user experience, database still fast via IndexedDB **Files Modified:** - Configuration: vite.config.*.mts, index.html, .env.development - Source: constants/app.ts, libs/util.ts, views/*.vue, utils/PlatformServiceMixin.ts ## Rationale For a community platform, universal image support is more critical than marginal database performance gains. Users share images from arbitrary websites, making CORS restrictions incompatible with Time Safari's core mission.
950 lines
29 KiB
TypeScript
950 lines
29 KiB
TypeScript
// many of these are also found in endorser-mobile utility.ts
|
|
|
|
import axios, { AxiosResponse } from "axios";
|
|
import { Buffer } from "buffer";
|
|
import * as R from "ramda";
|
|
import { useClipboard } from "@vueuse/core";
|
|
|
|
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
|
|
import { Account, AccountEncrypted } from "../db/tables/accounts";
|
|
import { Contact, ContactWithJsonStrings } from "../db/tables/contacts";
|
|
import * as databaseUtil from "../db/databaseUtil";
|
|
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
|
|
import {
|
|
arrayBufferToBase64,
|
|
base64ToArrayBuffer,
|
|
deriveAddress,
|
|
generateSeed,
|
|
newIdentifier,
|
|
simpleDecrypt,
|
|
simpleEncrypt,
|
|
} from "../libs/crypto";
|
|
import * as serverUtil from "../libs/endorserServer";
|
|
import { containsHiddenDid } from "../libs/endorserServer";
|
|
import {
|
|
GenericCredWrapper,
|
|
GenericVerifiableCredential,
|
|
KeyMetaWithPrivate,
|
|
} from "../interfaces/common";
|
|
import { GiveSummaryRecord } from "../interfaces/records";
|
|
import { OfferClaim } from "../interfaces/claims";
|
|
import { createPeerDid } from "../libs/crypto/vc/didPeer";
|
|
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
|
|
import { logger } from "../utils/logger";
|
|
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
|
import { IIdentifier } from "@veramo/core";
|
|
import { parseJsonField } from "../db/databaseUtil";
|
|
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
|
|
|
|
export interface GiverReceiverInputInfo {
|
|
did?: string;
|
|
name?: string;
|
|
}
|
|
|
|
export enum OnboardPage {
|
|
Home = "HOME",
|
|
Discover = "DISCOVER",
|
|
Create = "CREATE",
|
|
Contact = "CONTACT",
|
|
Account = "ACCOUNT",
|
|
}
|
|
|
|
export const PRIVACY_MESSAGE =
|
|
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
|
|
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";
|
|
|
|
/* eslint-disable prettier/prettier */
|
|
export const UNIT_SHORT: Record<string, string> = {
|
|
"BTC": "BTC",
|
|
"BX": "BX",
|
|
"ETH": "ETH",
|
|
"HUR": "Hours",
|
|
"USD": "US $",
|
|
};
|
|
/* eslint-enable prettier/prettier */
|
|
|
|
/* eslint-disable prettier/prettier */
|
|
export const UNIT_LONG: Record<string, string> = {
|
|
"BTC": "Bitcoin",
|
|
"BX": "Buxbe",
|
|
"ETH": "Ethereum",
|
|
"HUR": "hours",
|
|
"USD": "dollars",
|
|
};
|
|
/* eslint-enable prettier/prettier */
|
|
|
|
const UNIT_CODES: Record<
|
|
string,
|
|
{ name: string; faIcon: string; decimals: number }
|
|
> = {
|
|
BTC: {
|
|
name: "Bitcoin",
|
|
faIcon: "bitcoin-sign",
|
|
decimals: 4,
|
|
},
|
|
HUR: {
|
|
name: "hours",
|
|
faIcon: "clock",
|
|
decimals: 0,
|
|
},
|
|
USD: {
|
|
name: "US Dollars",
|
|
faIcon: "dollar",
|
|
decimals: 2,
|
|
},
|
|
};
|
|
|
|
export function iconForUnitCode(unitCode: string) {
|
|
return UNIT_CODES[unitCode]?.faIcon || "question";
|
|
}
|
|
|
|
export function formattedAmount(amount: number, unitCode: string) {
|
|
const unit = UNIT_CODES[unitCode];
|
|
const amountStr = amount.toFixed(unit?.decimals ?? 4);
|
|
const unitName = unit?.name || "?";
|
|
return amountStr + " " + unitName;
|
|
}
|
|
|
|
// from https://stackoverflow.com/a/175787/845494
|
|
// ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places
|
|
//
|
|
export function isNumeric(str: string): boolean {
|
|
// This ignore commentary is because typescript complains when you pass a string to isNaN.
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
return !isNaN(str) && !isNaN(parseFloat(str));
|
|
}
|
|
|
|
export function numberOrZero(str: string): number {
|
|
return isNumeric(str) ? +str : 0;
|
|
}
|
|
|
|
/**
|
|
* from https://tools.ietf.org/html/rfc3986#section-3
|
|
* also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition
|
|
**/
|
|
export const isGlobalUri = (uri: string) => {
|
|
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
|
|
};
|
|
|
|
export const isGiveClaimType = (claimType?: string) => {
|
|
return claimType === "GiveAction";
|
|
};
|
|
|
|
export const isGiveAction = (
|
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
|
) => {
|
|
return isGiveClaimType(veriClaim.claimType);
|
|
};
|
|
|
|
export const shortDid = (did: string) => {
|
|
if (did.startsWith("did:peer:")) {
|
|
return (
|
|
did.substring(0, "did:peer:".length + 2) +
|
|
"..." +
|
|
did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
|
|
"..."
|
|
);
|
|
} else if (did.startsWith("did:ethr:")) {
|
|
return did.substring(0, "did:ethr:".length + 9) + "...";
|
|
} else {
|
|
return did.substring(0, did.indexOf(":", 4) + 7) + "...";
|
|
}
|
|
};
|
|
|
|
export const nameForDid = (
|
|
activeDid: string,
|
|
contacts: Array<Contact>,
|
|
did: string,
|
|
): string => {
|
|
if (did === activeDid) {
|
|
return "you";
|
|
}
|
|
const contact = R.find((con) => con.did == did, contacts);
|
|
return nameForContact(contact);
|
|
};
|
|
|
|
export const nameForContact = (
|
|
contact?: Contact,
|
|
capitalize?: boolean,
|
|
): string => {
|
|
return (
|
|
(contact?.name as string) ||
|
|
(capitalize ? "This" : "this") + " unnamed user"
|
|
);
|
|
};
|
|
|
|
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
|
|
fn();
|
|
useClipboard()
|
|
.copy(text)
|
|
.then(() => setTimeout(fn, 2000));
|
|
};
|
|
|
|
export interface ConfirmerData {
|
|
confirmerIdList: string[];
|
|
confsVisibleToIdList: string[];
|
|
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
|
|
*/
|
|
export async function retrieveConfirmerIdList(
|
|
apiServer: string,
|
|
claimId: string,
|
|
claimIssuerId: string,
|
|
userDid: string,
|
|
): Promise<ConfirmerData | undefined> {
|
|
const confirmUrl =
|
|
apiServer +
|
|
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
|
|
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
|
|
const confirmHeaders = await serverUtil.getHeaders(userDid);
|
|
const response = await axios.get(confirmUrl, {
|
|
headers: confirmHeaders,
|
|
});
|
|
if (response.status === 200) {
|
|
const resultList1 = response.data.result || [];
|
|
//const publicUrls = resultList.publicUrls || [];
|
|
delete resultList1.publicUrls;
|
|
// exclude hidden DIDs
|
|
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
|
|
// exclude the issuer
|
|
const resultList3 = R.reject(
|
|
(did: string) => did === claimIssuerId,
|
|
resultList2,
|
|
);
|
|
const confirmerIdList = resultList3;
|
|
let numConfsNotVisible = resultList1.length - resultList2.length;
|
|
if (resultList3.length === resultList2.length) {
|
|
// the issuer was not in the "visible" list so they must be hidden
|
|
// so subtract them from the non-visible confirmers count
|
|
numConfsNotVisible = numConfsNotVisible - 1;
|
|
}
|
|
const confsVisibleToIdList = response.data.result.resultVisibleToDids || [];
|
|
const result: ConfirmerData = {
|
|
confirmerIdList,
|
|
confsVisibleToIdList,
|
|
numConfsNotVisible,
|
|
};
|
|
return result;
|
|
} else {
|
|
logger.error(
|
|
"Bad response status of",
|
|
response.status,
|
|
"for confirmers:",
|
|
response,
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns true if the user can confirm the claim
|
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
|
*/
|
|
export function isGiveRecordTheUserCanConfirm(
|
|
isRegistered: boolean,
|
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
|
activeDid: string,
|
|
confirmerIdList: string[] = [],
|
|
): boolean {
|
|
return (
|
|
isRegistered &&
|
|
isGiveAction(veriClaim) &&
|
|
!confirmerIdList.includes(activeDid) &&
|
|
veriClaim.issuer !== activeDid &&
|
|
!containsHiddenDid(veriClaim.claim)
|
|
);
|
|
}
|
|
|
|
export function notifyWhyCannotConfirm(
|
|
notifyFun: (notification: NotificationIface, timeout: number) => void,
|
|
isRegistered: boolean,
|
|
claimType: string | undefined,
|
|
giveDetails: GiveSummaryRecord | undefined,
|
|
activeDid: string,
|
|
confirmerIdList: string[] = [],
|
|
) {
|
|
if (!isRegistered) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Not Registered",
|
|
text: "Someone needs to register you before you can confirm.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (!isGiveClaimType(claimType)) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Not A Give",
|
|
text: "This is not a giving action to confirm.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (confirmerIdList.includes(activeDid)) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Already Confirmed",
|
|
text: "You already confirmed this claim.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (giveDetails?.issuerDid == activeDid) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Cannot Confirm",
|
|
text: "You cannot confirm this because you issued this claim.",
|
|
},
|
|
3000,
|
|
);
|
|
} else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Cannot Confirm",
|
|
text: "You cannot confirm this because some people are hidden.",
|
|
},
|
|
3000,
|
|
);
|
|
} else {
|
|
notifyFun(
|
|
{
|
|
group: "alert",
|
|
type: "info",
|
|
title: "Cannot Confirm",
|
|
text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.",
|
|
},
|
|
3000,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function blobToBase64(blob: Blob): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
|
|
// Extract the content type and the Base64 data
|
|
const [metadata, base64] = base64DataUrl.split(",");
|
|
const contentTypeMatch = metadata.match(/data:(.*?);base64/);
|
|
const contentType = contentTypeMatch ? contentTypeMatch[1] : "";
|
|
|
|
const byteCharacters = atob(base64);
|
|
const byteArrays = [];
|
|
|
|
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
|
const slice = byteCharacters.slice(offset, offset + sliceSize);
|
|
|
|
const byteNumbers = new Array(slice.length);
|
|
for (let i = 0; i < slice.length; i++) {
|
|
byteNumbers[i] = slice.charCodeAt(i);
|
|
}
|
|
|
|
const byteArray = new Uint8Array(byteNumbers);
|
|
byteArrays.push(byteArray);
|
|
}
|
|
return new Blob(byteArrays, { type: contentType });
|
|
}
|
|
|
|
/**
|
|
* @returns the DID of the person who offered, or undefined if hidden
|
|
* @param veriClaim is expected to have fields: claim and issuer
|
|
*/
|
|
export function offerGiverDid(
|
|
veriClaim: GenericCredWrapper<OfferClaim>,
|
|
): string | undefined {
|
|
const innerClaim = veriClaim.claim as OfferClaim;
|
|
let giver: string | undefined = undefined;
|
|
|
|
giver = innerClaim.offeredBy?.identifier;
|
|
if (giver && !serverUtil.isHiddenDid(giver)) {
|
|
return giver;
|
|
}
|
|
|
|
giver = veriClaim.issuer;
|
|
if (giver && !serverUtil.isHiddenDid(giver)) {
|
|
return giver;
|
|
}
|
|
return giver;
|
|
}
|
|
|
|
/**
|
|
* @returns true if the user can fulfill the offer
|
|
* @param veriClaim is expected to have fields: claim, claimType, and issuer
|
|
*/
|
|
export const canFulfillOffer = (
|
|
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
|
|
isRegistered: boolean,
|
|
) => {
|
|
return (
|
|
isRegistered &&
|
|
veriClaim.claimType === "Offer" &&
|
|
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferClaim>)
|
|
);
|
|
};
|
|
|
|
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
|
|
export function findAllVisibleToDids(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
input: any,
|
|
humanReadable = false,
|
|
): Record<string, Array<string>> {
|
|
if (Array.isArray(input)) {
|
|
const result: Record<string, Array<string>> = {};
|
|
for (let i = 0; i < input.length; i++) {
|
|
const inside = findAllVisibleToDids(input[i], humanReadable);
|
|
for (const key in inside) {
|
|
const pathKey = humanReadable
|
|
? "#" + (i + 1) + " " + key
|
|
: "[" + i + "]" + key;
|
|
result[pathKey] = inside[key];
|
|
}
|
|
}
|
|
return result;
|
|
} else if (input instanceof Object) {
|
|
// regular map (non-array) object
|
|
const result: Record<string, Array<string>> = {};
|
|
for (const key in input) {
|
|
if (key.endsWith("VisibleToDids")) {
|
|
const newKey = key.slice(0, -"VisibleToDids".length);
|
|
const pathKey = humanReadable ? newKey : "." + newKey;
|
|
result[pathKey] = input[key];
|
|
} else {
|
|
const inside = findAllVisibleToDids(input[key], humanReadable);
|
|
for (const insideKey in inside) {
|
|
const pathKey = humanReadable
|
|
? key + "'s " + insideKey
|
|
: "." + key + insideKey;
|
|
result[pathKey] = inside[insideKey];
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
} else {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test findAllVisibleToDids
|
|
*
|
|
|
|
pkgx +deno.land sh
|
|
|
|
deno
|
|
|
|
import * as R from 'ramda';
|
|
//import { findAllVisibleToDids } from './src/libs/util'; // doesn't work because other dependencies fail so gotta copy-and-paste function
|
|
|
|
console.log(R.equals(findAllVisibleToDids(null), {}));
|
|
console.log(R.equals(findAllVisibleToDids(9), {}));
|
|
console.log(R.equals(findAllVisibleToDids([]), {}));
|
|
console.log(R.equals(findAllVisibleToDids({}), {}));
|
|
console.log(R.equals(findAllVisibleToDids({ issuer: "abc" }), {}));
|
|
console.log(R.equals(findAllVisibleToDids({ issuerVisibleToDids: ["abc"] }), { ".issuer": ["abc"] }));
|
|
console.log(R.equals(findAllVisibleToDids([{ issuerVisibleToDids: ["abc"] }]), { "[0].issuer": ["abc"] }));
|
|
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] } }]), { "[1].fluff.issuer": ["abc"] }));
|
|
console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] }, stuff: [ { did: "HIDDEN", agentDidVisibleToDids: ["def", "ghi"] } ] }]), { "[1].fluff.issuer": ["abc"], "[1].stuff[0].agentDid": ["def", "ghi"] }));
|
|
|
|
*
|
|
**/
|
|
|
|
export type AccountKeyInfo = Account & KeyMetaWithPrivate;
|
|
|
|
export const retrieveAccountCount = async (): Promise<number> => {
|
|
let result = 0;
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const dbResult = await platformService.dbQuery(
|
|
`SELECT COUNT(*) FROM accounts`,
|
|
);
|
|
if (dbResult?.values?.[0]?.[0]) {
|
|
result = dbResult.values[0][0] as number;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
export const retrieveAccountDids = async (): Promise<string[]> => {
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const dbAccounts = await platformService.dbQuery(`SELECT did FROM accounts`);
|
|
const allDids =
|
|
databaseUtil
|
|
.mapQueryResultToValues(dbAccounts)
|
|
?.map((row) => row[0] as string) || [];
|
|
return allDids;
|
|
};
|
|
|
|
/**
|
|
* This is provided and recommended when the full key is not necessary so that
|
|
* future work could separate this info from the sensitive key material.
|
|
*
|
|
* If you need the private key data, use retrieveFullyDecryptedAccount instead.
|
|
*/
|
|
export const retrieveAccountMetadata = async (
|
|
activeDid: string,
|
|
): Promise<Account | undefined> => {
|
|
let result: Account | undefined = undefined;
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const dbAccount = await platformService.dbQuery(
|
|
`SELECT * FROM accounts WHERE did = ?`,
|
|
[activeDid],
|
|
);
|
|
const account = databaseUtil.mapQueryResultToValues(dbAccount)[0] as Account;
|
|
if (account) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { identity, mnemonic, ...metadata } = account;
|
|
result = metadata;
|
|
} else {
|
|
result = undefined;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* This contains sensitive data. If possible, use retrieveAccountMetadata instead.
|
|
*
|
|
* @param activeDid
|
|
* @returns account info with private key data decrypted
|
|
*/
|
|
export const retrieveFullyDecryptedAccount = async (
|
|
activeDid: string,
|
|
): Promise<Account | undefined> => {
|
|
let result: Account | undefined = undefined;
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const dbSecrets = await platformService.dbQuery(
|
|
`SELECT secretBase64 from secret`,
|
|
);
|
|
if (
|
|
!dbSecrets ||
|
|
dbSecrets.values.length === 0 ||
|
|
dbSecrets.values[0].length === 0
|
|
) {
|
|
throw new Error(
|
|
"No secret found. We recommend you clear your data and start over.",
|
|
);
|
|
}
|
|
const secretBase64 = dbSecrets.values[0][0] as string;
|
|
const secret = base64ToArrayBuffer(secretBase64);
|
|
const dbAccount = await platformService.dbQuery(
|
|
`SELECT * FROM accounts WHERE did = ?`,
|
|
[activeDid],
|
|
);
|
|
if (
|
|
!dbAccount ||
|
|
dbAccount.values.length === 0 ||
|
|
dbAccount.values[0].length === 0
|
|
) {
|
|
throw new Error("Account not found.");
|
|
}
|
|
const fullAccountData = databaseUtil.mapQueryResultToValues(
|
|
dbAccount,
|
|
)[0] as AccountEncrypted;
|
|
const identityEncr = base64ToArrayBuffer(fullAccountData.identityEncrBase64);
|
|
const mnemonicEncr = base64ToArrayBuffer(fullAccountData.mnemonicEncrBase64);
|
|
fullAccountData.identity = await simpleDecrypt(identityEncr, secret);
|
|
fullAccountData.mnemonic = await simpleDecrypt(mnemonicEncr, secret);
|
|
result = fullAccountData;
|
|
|
|
return result;
|
|
};
|
|
|
|
export const retrieveAllAccountsMetadata = async (): Promise<
|
|
AccountEncrypted[]
|
|
> => {
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
|
|
const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
|
|
const result = accounts.map((account) => {
|
|
return account as AccountEncrypted;
|
|
});
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Saves a new identity to both SQL and Dexie databases
|
|
*/
|
|
export async function saveNewIdentity(
|
|
identity: IIdentifier,
|
|
mnemonic: string,
|
|
derivationPath: string,
|
|
): Promise<void> {
|
|
try {
|
|
// add to the new sql db
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
const secrets = await platformService.dbQuery(
|
|
`SELECT secretBase64 FROM secret`,
|
|
);
|
|
if (!secrets?.values?.length || !secrets.values[0]?.length) {
|
|
throw new Error(
|
|
"No initial encryption supported. We recommend you clear your data and start over.",
|
|
);
|
|
}
|
|
const secretBase64 = secrets.values[0][0] as string;
|
|
const secret = base64ToArrayBuffer(secretBase64);
|
|
const identityStr = JSON.stringify(identity);
|
|
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
|
|
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
|
|
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
|
|
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
|
|
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
|
|
VALUES (?, ?, ?, ?, ?, ?)`;
|
|
const params = [
|
|
new Date().toISOString(),
|
|
derivationPath,
|
|
identity.did,
|
|
encryptedIdentityBase64,
|
|
encryptedMnemonicBase64,
|
|
identity.keys[0].publicKeyHex,
|
|
];
|
|
await platformService.dbExec(sql, params);
|
|
await databaseUtil.updateDefaultSettings({ activeDid: identity.did });
|
|
await databaseUtil.insertDidSpecificSettings(identity.did);
|
|
} catch (error) {
|
|
logger.error("Failed to update default settings:", error);
|
|
throw new Error(
|
|
"Failed to set default settings. Please try again or restart the app.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a new identity, saves it to the database, and sets it as the active identity.
|
|
* @return {Promise<string>} with the DID of the new identity
|
|
*/
|
|
export const generateSaveAndActivateIdentity = async (): Promise<string> => {
|
|
const mnemonic = generateSeed();
|
|
// address is 0x... ETH address, without "did:eth:"
|
|
const [address, privateHex, publicHex, derivationPath] =
|
|
deriveAddress(mnemonic);
|
|
|
|
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
|
|
|
await saveNewIdentity(newId, mnemonic, derivationPath);
|
|
await databaseUtil.updateDidSpecificSettings(newId.did, {
|
|
isRegistered: false,
|
|
});
|
|
return newId.did;
|
|
};
|
|
|
|
export const registerAndSavePasskey = async (
|
|
keyName: string,
|
|
): Promise<Account> => {
|
|
const cred = await registerCredential(keyName);
|
|
const publicKeyBytes = cred.publicKeyBytes;
|
|
const did = createPeerDid(publicKeyBytes as Uint8Array);
|
|
const passkeyCredIdHex = cred.credIdHex as string;
|
|
|
|
const account = {
|
|
dateCreated: new Date().toISOString(),
|
|
did,
|
|
passkeyCredIdHex,
|
|
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
|
|
};
|
|
const insertStatement = databaseUtil.generateInsertStatement(
|
|
account,
|
|
"accounts",
|
|
);
|
|
await PlatformServiceFactory.getInstance().dbExec(
|
|
insertStatement.sql,
|
|
insertStatement.params,
|
|
);
|
|
return account;
|
|
};
|
|
|
|
export const registerSaveAndActivatePasskey = async (
|
|
keyName: string,
|
|
): Promise<Account> => {
|
|
const account = await registerAndSavePasskey(keyName);
|
|
await databaseUtil.updateDefaultSettings({ activeDid: account.did });
|
|
await databaseUtil.updateDidSpecificSettings(account.did, {
|
|
isRegistered: false,
|
|
});
|
|
return account;
|
|
};
|
|
|
|
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
|
|
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
|
return (
|
|
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
|
|
60
|
|
);
|
|
};
|
|
|
|
// These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
|
|
export const DAILY_CHECK_TITLE = "DAILY_CHECK";
|
|
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
|
|
export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";
|
|
|
|
export const sendTestThroughPushServer = async (
|
|
subscriptionJSON: PushSubscriptionJSON,
|
|
skipFilter: boolean,
|
|
): Promise<AxiosResponse> => {
|
|
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
|
|
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
|
|
if (settings?.webPushServer) {
|
|
pushUrl = settings.webPushServer;
|
|
}
|
|
|
|
const newPayload = {
|
|
...subscriptionJSON,
|
|
// ... overridden with the following
|
|
// eslint-disable-next-line prettier/prettier
|
|
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
|
|
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
|
|
};
|
|
logger.log("Sending a test web push message:", newPayload);
|
|
const payloadStr = JSON.stringify(newPayload);
|
|
const response = await axios.post(
|
|
pushUrl + "/web-push/send-test",
|
|
payloadStr,
|
|
{
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
);
|
|
|
|
logger.log("Got response from web push server:", response);
|
|
return response;
|
|
};
|
|
|
|
/**
|
|
* Converts a Contact object to a CSV line string following the established format.
|
|
* The format matches CONTACT_CSV_HEADER: "name,did,pubKeyBase64,seesMe,registered,contactMethods"
|
|
* where contactMethods is stored as a stringified JSON array.
|
|
*
|
|
* @param contact - The Contact object to convert
|
|
* @returns A CSV-formatted string representing the contact
|
|
* @throws {Error} If the contact object is missing required fields
|
|
*/
|
|
export const contactToCsvLine = (contact: Contact): string => {
|
|
if (!contact.did) {
|
|
throw new Error("Contact must have a did field");
|
|
}
|
|
|
|
// Escape fields that might contain commas or quotes
|
|
const escapeField = (field: string | boolean | undefined): string => {
|
|
if (field === undefined) return "";
|
|
const str = String(field);
|
|
if (str.includes(",") || str.includes('"')) {
|
|
return `"${str.replace(/"/g, '""')}"`;
|
|
}
|
|
return str;
|
|
};
|
|
|
|
// Handle contactMethods array by stringifying it
|
|
const contactMethodsStr = contact.contactMethods
|
|
? escapeField(JSON.stringify(parseJsonField(contact.contactMethods, [])))
|
|
: "";
|
|
|
|
const fields = [
|
|
escapeField(contact.name),
|
|
escapeField(contact.did),
|
|
escapeField(contact.publicKeyBase64),
|
|
escapeField(contact.seesMe),
|
|
escapeField(contact.registered),
|
|
contactMethodsStr,
|
|
];
|
|
|
|
return fields.join(",");
|
|
};
|
|
|
|
/**
|
|
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
|
|
* @param lineRaw - The CSV line to parse
|
|
* @returns A Contact object
|
|
*/
|
|
export const csvLineToContact = (lineRaw: string): Contact => {
|
|
// Note that Endorser Mobile puts name first, then did, etc.
|
|
let line = lineRaw.trim();
|
|
let did, publicKeyInput, seesMe, registered;
|
|
let name;
|
|
let commaPos1 = -1;
|
|
if (line.startsWith('"')) {
|
|
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
|
|
if (doubleDoubleQuotePos === -1) {
|
|
doubleDoubleQuotePos = 1;
|
|
}
|
|
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
|
|
if (quote2Pos > -1) {
|
|
commaPos1 = line.indexOf(",", quote2Pos);
|
|
name = line.substring(1, quote2Pos).trim();
|
|
name = name.replace(/""/g, '"');
|
|
} else {
|
|
// something is weird with one " to start, so ignore it and start after "
|
|
line = line.substring(1);
|
|
commaPos1 = line.indexOf(",");
|
|
name = line.substring(0, commaPos1).trim();
|
|
}
|
|
} else {
|
|
commaPos1 = line.indexOf(",");
|
|
name = line.substring(0, commaPos1).trim();
|
|
}
|
|
if (commaPos1 > -1) {
|
|
did = line.substring(commaPos1 + 1).trim();
|
|
const commaPos2 = line.indexOf(",", commaPos1 + 1);
|
|
if (commaPos2 > -1) {
|
|
did = line.substring(commaPos1 + 1, commaPos2).trim();
|
|
publicKeyInput = line.substring(commaPos2 + 1).trim();
|
|
const commaPos3 = line.indexOf(",", commaPos2 + 1);
|
|
if (commaPos3 > -1) {
|
|
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
|
|
seesMe = line.substring(commaPos3 + 1).trim() == "true";
|
|
const commaPos4 = line.indexOf(",", commaPos3 + 1);
|
|
if (commaPos4 > -1) {
|
|
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
|
|
registered = line.substring(commaPos4 + 1).trim() == "true";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// help with potential mistakes while this sharing requires copy-and-paste
|
|
let publicKeyBase64 = publicKeyInput;
|
|
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
|
|
// it must be all hex (compressed public key), so convert
|
|
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
|
|
}
|
|
const newContact: Contact = {
|
|
did: did || "",
|
|
name,
|
|
publicKeyBase64,
|
|
seesMe,
|
|
registered,
|
|
};
|
|
return newContact;
|
|
};
|
|
|
|
/**
|
|
* Interface for the JSON export format of database tables
|
|
*/
|
|
export interface TableExportData {
|
|
tableName: string;
|
|
rows: Array<Record<string, unknown>>;
|
|
}
|
|
|
|
/**
|
|
* Interface for the complete database export format
|
|
*/
|
|
export interface DatabaseExport {
|
|
data: {
|
|
data: Array<TableExportData>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Converts an array of contacts to the standardized database export JSON format.
|
|
* This format is used for data migration and backup purposes.
|
|
*
|
|
* @param contacts - Array of Contact objects to convert
|
|
* @returns DatabaseExport object in the standardized format
|
|
*/
|
|
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
|
|
// Convert each contact to a plain object and ensure all fields are included
|
|
const rows = contacts.map((contact) => {
|
|
const exContact: ContactWithJsonStrings = R.omit(
|
|
["contactMethods"],
|
|
contact,
|
|
);
|
|
exContact.contactMethods = contact.contactMethods
|
|
? JSON.stringify(contact.contactMethods, [])
|
|
: undefined;
|
|
return exContact;
|
|
});
|
|
|
|
return {
|
|
data: {
|
|
data: [
|
|
{
|
|
tableName: "contacts",
|
|
rows,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Imports an account from a mnemonic phrase
|
|
* @param mnemonic - The seed phrase to import from
|
|
* @param derivationPath - The derivation path to use (defaults to DEFAULT_ROOT_DERIVATION_PATH)
|
|
* @param shouldErase - Whether to erase existing accounts before importing
|
|
* @returns Promise that resolves when import is complete
|
|
* @throws Error if mnemonic is invalid or import fails
|
|
*/
|
|
export async function importFromMnemonic(
|
|
mnemonic: string,
|
|
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
|
|
shouldErase: boolean = false,
|
|
): Promise<void> {
|
|
const mne: string = mnemonic.trim().toLowerCase();
|
|
|
|
// Derive address and keys from mnemonic
|
|
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
|
|
|
|
// Create new identifier
|
|
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
|
|
|
|
// Handle erasures
|
|
if (shouldErase) {
|
|
const platformService = PlatformServiceFactory.getInstance();
|
|
await platformService.dbExec("DELETE FROM accounts");
|
|
}
|
|
|
|
// Save the new identity
|
|
await saveNewIdentity(newId, mne, derivationPath);
|
|
}
|
|
|
|
/**
|
|
* Legacy function name maintained for compatibility.
|
|
* Now simply returns the original URL since CORS headers have been disabled
|
|
* to allow images from any domain.
|
|
*
|
|
* @param imageUrl - The original image URL
|
|
* @returns The original image URL unchanged
|
|
*
|
|
* @deprecated CORS restrictions have been removed - images load directly from any domain
|
|
*/
|
|
export function transformImageUrlForCors(
|
|
imageUrl: string | undefined | null,
|
|
): string {
|
|
if (!imageUrl) return "";
|
|
|
|
// CORS headers disabled - all images load directly from any domain
|
|
// This enables the community nature of Time Safari where users can
|
|
// share images from any website without restrictions
|
|
return imageUrl;
|
|
}
|