web: fix account recovery

This commit is contained in:
Abdullah Atta
2026-02-09 12:27:36 +05:00
parent 4d407bd646
commit 122c3a96c7
7 changed files with 99 additions and 181 deletions

View File

@@ -48,15 +48,23 @@ async function initializeDatabase(persistence: DatabasePersistence) {
await useKeyStore.getState().setValue("databaseKey", databaseKey);
}
// db.host({
// API_HOST: "https://api.notesnook.com",
// AUTH_HOST: "https://auth.streetwriters.co",
// SSE_HOST: "https://events.streetwriters.co",
// ISSUES_HOST: "https://issues.streetwriters.co",
// SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
// MONOGRAPH_HOST: "https://monogr.ph",
// NOTESNOOK_HOST: "https://notesnook.com",
// ...Config.get("serverUrls", {})
// });
const base = `http://localhost`;
db.host({
API_HOST: "https://api.notesnook.com",
AUTH_HOST: "https://auth.streetwriters.co",
SSE_HOST: "https://events.streetwriters.co",
ISSUES_HOST: "https://issues.streetwriters.co",
SUBSCRIPTIONS_HOST: "https://subscriptions.streetwriters.co",
MONOGRAPH_HOST: "https://monogr.ph",
NOTESNOOK_HOST: "https://notesnook.com",
...Config.get("serverUrls", {})
API_HOST: `${base}:5264`,
AUTH_HOST: `${base}:8264`,
SSE_HOST: `${base}:7264`,
ISSUES_HOST: `${base}:2624`,
SUBSCRIPTIONS_HOST: `${base}:9264`
});
const storage = new NNStorage(
@@ -72,7 +80,7 @@ async function initializeDatabase(persistence: DatabasePersistence) {
dialect: (name, init) =>
createDialect({
name: persistence === "memory" ? ":memory:" : name,
encrypted: true,
encrypted: persistence !== "memory",
async: !isFeatureSupported("opfs"),
init,
multiTab
@@ -87,7 +95,10 @@ async function initializeDatabase(persistence: DatabasePersistence) {
synchronous: "normal",
pageSize: 8192,
cacheSize: -32000,
password: Buffer.from(databaseKey).toString("hex"),
password:
persistence === "memory"
? undefined
: Buffer.from(databaseKey).toString("hex"),
skipInitialization: !IS_DESKTOP_APP && multiTab
},
storage: storage,

View File

@@ -167,6 +167,8 @@ class _SQLiteWorker {
sql: string,
parameters?: SQLiteCompatibleType[]
): Promise<QueryResult<R>> {
if (!this.encrypted && !this.initialized) await this.initialize();
if (this.encrypted && !sql.startsWith("PRAGMA key")) {
await this.waitForDatabase();
}

View File

@@ -146,13 +146,19 @@ class KeyStore extends BaseStore<KeyStore> {
activeCredentials = () => this.get().credentials.filter((c) => c.active);
init = async () => {
init = async (
config: { persistence: "memory" | "db" } = { persistence: "db" }
) => {
this.#metadataStore =
isFeatureSupported("indexedDB") && isFeatureSupported("clonableCryptoKey")
isFeatureSupported("indexedDB") &&
isFeatureSupported("clonableCryptoKey") &&
config.persistence !== "memory"
? new IndexedDBKVStore(`${this.dbName}-metadata`, "metadata")
: new MemoryKVStore();
this.#secretStore =
isFeatureSupported("indexedDB") && isFeatureSupported("clonableCryptoKey")
isFeatureSupported("indexedDB") &&
isFeatureSupported("clonableCryptoKey") &&
config.persistence !== "memory"
? new IndexedDBKVStore(`${this.dbName}-secrets`, "secrets")
: new MemoryKVStore();

View File

@@ -54,7 +54,12 @@ export async function startApp(children?: React.ReactNode) {
try {
const { Component, props, path } = await init();
await useKeyStore.getState().init();
const persistence =
(path !== "/sessionexpired" && path !== "/account/recovery") ||
Config.get("sessionExpired", false)
? "db"
: "memory";
await useKeyStore.getState().init({ persistence });
root.render(
<>
@@ -70,6 +75,7 @@ export async function startApp(children?: React.ReactNode) {
Component={Component}
path={path}
routeProps={props}
persistence={persistence}
/>
</AppLock>
{children}
@@ -96,9 +102,10 @@ function RouteWrapper(props: {
Component: (props: AuthProps) => JSX.Element;
path: Routes;
routeProps: AuthProps | null;
persistence?: "db" | "memory";
}) {
const [isMigrating, setIsMigrating] = useState(false);
const { Component, path, routeProps } = props;
const { Component, path, routeProps, persistence } = props;
useEffect(() => {
EV.subscribe(EVENTS.migrationStarted, (name) =>
@@ -112,12 +119,8 @@ function RouteWrapper(props: {
const result = usePromise(async () => {
performance.mark("load:database");
await loadDatabase(
path !== "/sessionexpired" || Config.get("sessionExpired", false)
? "db"
: "memory"
);
}, [path]);
await loadDatabase(persistence);
}, [path, persistence]);
if (result.status === "rejected") {
throw result.reason instanceof Error

View File

@@ -34,11 +34,11 @@ export function isTransferableStreamsSupported() {
controller.close();
}
});
window.postMessage(readable, [readable]);
window.postMessage(readable, window.location.origin, [readable]);
FEATURE_CHECKS.transferableStreams = true;
return true;
} catch {
console.log("Transferable streams not supported");
} catch (e) {
console.log("Transferable streams not supported", e);
FEATURE_CHECKS.transferableStreams = false;
return false;
}

View File

@@ -25,7 +25,6 @@ import { Loader } from "../components/loader";
import { showToast } from "../utils/toast";
import AuthContainer from "../components/auth-container";
import { AuthField, SubmitButton } from "./auth";
import { createBackup, restoreBackupFile, selectBackupFile } from "../common";
import Config from "../utils/config";
import { ErrorText } from "../components/error-text";
import { EVENTS, User } from "@notesnook/core";
@@ -33,18 +32,14 @@ import { RecoveryKeyDialog } from "../dialogs/recovery-key-dialog";
import { strings } from "@notesnook/intl";
import { useKeyStore } from "../interfaces/key-store";
type RecoveryMethodType = "key" | "backup" | "reset";
type RecoveryMethodType = "key" | "reset";
type RecoveryMethodsFormData = Record<string, unknown>;
type RecoveryKeyFormData = {
recoveryKey: string;
};
type BackupFileFormData = {
backupFile: File;
};
type NewPasswordFormData = BackupFileFormData & {
type NewPasswordFormData = {
userResetRequired?: boolean;
password: string;
confirmPassword: string;
@@ -53,9 +48,7 @@ type NewPasswordFormData = BackupFileFormData & {
type RecoveryFormData = {
methods: RecoveryMethodsFormData;
"method:key": RecoveryKeyFormData;
"method:backup": BackupFileFormData;
"method:reset": NewPasswordFormData;
backup: RecoveryMethodsFormData;
new: NewPasswordFormData;
final: RecoveryMethodsFormData;
};
@@ -73,9 +66,7 @@ type BaseRecoveryComponentProps<TRoute extends RecoveryRoutes> = {
type RecoveryRoutes =
| "methods"
| "method:key"
| "method:backup"
| "method:reset"
| "backup"
| "new"
| "final";
type RecoveryProps = { route: RecoveryRoutes };
@@ -92,10 +83,6 @@ function getRouteComponent<TRoute extends RecoveryRoutes>(
return RecoveryMethods as RecoveryComponent<TRoute>;
case "method:key":
return RecoveryKeyMethod as RecoveryComponent<TRoute>;
case "method:backup":
return BackupFileMethod as RecoveryComponent<TRoute>;
case "backup":
return BackupData as RecoveryComponent<TRoute>;
case "method:reset":
case "new":
return NewPassword as RecoveryComponent<TRoute>;
@@ -108,9 +95,7 @@ function getRouteComponent<TRoute extends RecoveryRoutes>(
const routePaths: Record<RecoveryRoutes, string> = {
methods: "/account/recovery/methods",
"method:key": "/account/recovery/method/key",
"method:backup": "/account/recovery/method/backup",
"method:reset": "/account/recovery/method/reset",
backup: "/account/recovery/backup",
new: "/account/recovery/new",
final: "/account/recovery/final"
};
@@ -240,12 +225,6 @@ const recoveryMethods: RecoveryMethod[] = [
title: () => strings.recoveryKeyMethod(),
description: () => strings.recoveryKeyMethodDesc()
},
{
type: "backup",
testId: "step-backup",
title: () => strings.backupFileMethod(),
description: () => strings.backupFileMethodDesc()
},
{
type: "reset",
testId: "step-reset-account",
@@ -356,8 +335,7 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
await useKeyStore
.getState()
.setValue("userEncryptionKey", form.recoveryKey);
await db.sync({ type: "fetch", force: true });
navigate("backup");
navigate("new", {});
}}
>
<AuthField
@@ -383,86 +361,6 @@ function RecoveryKeyMethod(props: BaseRecoveryComponentProps<"method:key">) {
);
}
function BackupFileMethod(props: BaseRecoveryComponentProps<"method:backup">) {
const { navigate } = props;
const [backupFile, setBackupFile] =
useState<BackupFileFormData["backupFile"]>();
useEffect(() => {
if (!backupFile) return;
const backupFileInput = document.getElementById("backupFile");
if (!(backupFileInput instanceof HTMLInputElement)) return;
backupFileInput.value = backupFile?.name;
}, [backupFile]);
return (
<RecoveryForm
testId="step-backup-file"
type="method:backup"
title={strings.accountRecovery()}
subtitle={
<ErrorText
sx={{ fontSize: "body" }}
error={strings.backupFileRecoveryError()}
/>
}
onSubmit={async () => {
navigate("new", { backupFile, userResetRequired: true });
}}
>
<AuthField
id="backupFile"
type="text"
label={strings.selectBackupFile()}
helpText={strings.backupFileHelpText()}
autoComplete="none"
autoFocus
disabled
action={{
component: <Text variant={"body"}>{strings.browse()}</Text>,
onClick: async () => {
setBackupFile(await selectBackupFile());
}
}}
/>
<SubmitButton text={strings.startAccountRecovery()} />
<Button
type="button"
mt={4}
variant={"anchor"}
onClick={() => navigate("methods")}
sx={{ color: "paragraph" }}
>
{strings.dontHaveBackupFile()}
</Button>
</RecoveryForm>
);
}
function BackupData(props: BaseRecoveryComponentProps<"backup">) {
const { navigate } = props;
return (
<RecoveryForm
testId="step-backup-data"
type="backup"
title={strings.backupYourData()}
subtitle={strings.backupYourDataDesc()}
loading={{
title: strings.backingUpData() + "...",
subtitle: strings.backingUpDataWait()
}}
onSubmit={async () => {
await createBackup({ rescueMode: true, mode: "full" });
navigate("new");
}}
>
<SubmitButton text={strings.downloadBackupFile()} />
</RecoveryForm>
);
}
function NewPassword(props: BaseRecoveryComponentProps<"new">) {
const { navigate, formData } = props;
const [progress, setProgress] = useState(0);
@@ -498,11 +396,6 @@ function NewPassword(props: BaseRecoveryComponentProps<"new">) {
if (!(await db.user.resetPassword(form.password)))
throw new Error("Could not reset account password.");
if (formData?.backupFile) {
await restoreBackupFile(formData?.backupFile);
await db.sync({ type: "full", force: true });
}
navigate("final");
}}
>

View File

@@ -628,58 +628,61 @@ class UserManager {
if (!new_password) throw new Error("New password is required.");
data.encryptionKey = data.encryptionKey || (await this.getMasterKey());
if (!data.encryptionKey)
throw new Error("User encryption key not generated.");
const newMasterKey = await this.db
.storage()
.generateCryptoKey(new_password, salt);
const updateUserPayload: Partial<User> = {};
if (user.attachmentsKey) {
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
user.attachmentsKey,
data.encryptionKey,
newMasterKey
);
}
if (user.monographPasswordsKey) {
updateUserPayload.monographPasswordsKey = await this.keyManager.rewrapKey(
user.monographPasswordsKey,
data.encryptionKey,
newMasterKey
);
}
if (user.inboxKeys) {
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
user.inboxKeys,
data.encryptionKey,
newMasterKey
);
}
if (user.legacyDataEncryptionKey)
updateUserPayload.legacyDataEncryptionKey =
await this.keyManager.rewrapKey(
user.legacyDataEncryptionKey,
console.log(
"Has encryption key",
!!data.encryptionKey,
await this.getMasterKey()
);
if (data.encryptionKey) {
const newMasterKey = await this.db
.storage()
.generateCryptoKey(new_password, salt);
if (user.attachmentsKey) {
updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
user.attachmentsKey,
data.encryptionKey,
newMasterKey
);
if (user.dataEncryptionKey)
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
user.dataEncryptionKey,
data.encryptionKey,
newMasterKey
);
else {
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
await this.db.crypto().generateRandomKey(),
newMasterKey
);
updateUserPayload.legacyDataEncryptionKey = await this.keyManager.wrapKey(
data.encryptionKey,
newMasterKey
);
}
if (user.monographPasswordsKey) {
updateUserPayload.monographPasswordsKey =
await this.keyManager.rewrapKey(
user.monographPasswordsKey,
data.encryptionKey,
newMasterKey
);
}
if (user.inboxKeys) {
updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
user.inboxKeys,
data.encryptionKey,
newMasterKey
);
}
if (user.legacyDataEncryptionKey)
updateUserPayload.legacyDataEncryptionKey =
await this.keyManager.rewrapKey(
user.legacyDataEncryptionKey,
data.encryptionKey,
newMasterKey
);
if (user.dataEncryptionKey)
updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
user.dataEncryptionKey,
data.encryptionKey,
newMasterKey
);
else {
updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
await this.db.crypto().generateRandomKey(),
newMasterKey
);
updateUserPayload.legacyDataEncryptionKey =
await this.keyManager.wrapKey(data.encryptionKey, newMasterKey);
}
}
await http.patch.json(