[web] General code improvements (#6222)

This commit is contained in:
Manav Rathi
2025-06-09 13:01:30 +05:30
committed by GitHub
8 changed files with 307 additions and 181 deletions

View File

@@ -54,6 +54,7 @@ import type {
import { type CollectionUser } from "ente-media/collection";
import { PublicLinkCreated } from "ente-new/photos/components/share/PublicLinkCreated";
import { avatarTextColor } from "ente-new/photos/services/avatar";
import { deleteShareURL } from "ente-new/photos/services/collection";
import type { CollectionSummary } from "ente-new/photos/services/collection/ui";
import { usePhotosAppContext } from "ente-new/photos/types/context";
import { CustomError, parseSharingErrorCodes } from "ente-shared/error";
@@ -65,7 +66,6 @@ import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import { Trans } from "react-i18next";
import {
createShareableURL,
deleteShareableURL,
shareCollection,
unshareCollection,
updateShareableURL,
@@ -1222,16 +1222,16 @@ const ManagePublicShareOptions: React.FC<ManagePublicShareOptionsProps> = ({
galleryContext.setBlockingLoad(false);
}
};
const disablePublicSharing = async () => {
const handleRemovePublicLink = async () => {
try {
galleryContext.setBlockingLoad(true);
await deleteShareableURL(collection);
await deleteShareURL(collection.id);
setPublicShareProp(null);
galleryContext.syncWithRemote(false, true);
onClose();
} catch (e) {
const errorMessage = handleSharingErrors(e);
setSharableLinkError(errorMessage);
log.error("Failed to remove public link", e);
setSharableLinkError(t("generic_error"));
} finally {
galleryContext.setBlockingLoad(false);
}
@@ -1293,7 +1293,7 @@ const ManagePublicShareOptions: React.FC<ManagePublicShareOptionsProps> = ({
<RowButton
color="critical"
startIcon={<RemoveCircleOutlineIcon />}
onClick={disablePublicSharing}
onClick={handleRemovePublicLink}
label={t("remove_link")}
/>
</RowButtonGroup>

View File

@@ -571,24 +571,6 @@ export const createShareableURL = async (collection: Collection) => {
}
};
export const deleteShareableURL = async (collection: Collection) => {
try {
const token = getToken();
if (!token) {
return null;
}
await HTTPService.delete(
await apiURL(`/collections/share-url/${collection.id}`),
null,
null,
{ "X-Auth-Token": token },
);
} catch (e) {
log.error("deleteShareableURL failed ", e);
throw e;
}
};
export const updateShareableURL = async (
request: UpdatePublicURL,
): Promise<PublicURL> => {

View File

@@ -34,11 +34,11 @@ export interface RecoverPageProps {
const Page: React.FC<RecoverPageProps> = ({ twoFactorType }) => {
const { logout, showMiniDialog } = useBaseContext();
const [sessionID, setSessionID] = useState<string | null>(null);
const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] = useState<{
encryptedData: string;
nonce: string;
} | null>(null);
const [sessionID, setSessionID] = useState<string | null>(null);
const [doesHaveEncryptedRecoveryKey, setDoesHaveEncryptedRecoveryKey] =
useState(false);

View File

@@ -1,16 +1,16 @@
import { Paper, Stack, styled, Typography } from "@mui/material";
import { CodeBlock } from "ente-accounts/components/CodeBlock";
import { Verify2FACodeForm } from "ente-accounts/components/Verify2FACodeForm";
import { getUserRecoveryKey } from "ente-accounts/services/recovery-key";
import { appHomeRoute } from "ente-accounts/services/redirect";
import type { TwoFactorSecret } from "ente-accounts/services/user";
import { enableTwoFactor, setupTwoFactor } from "ente-accounts/services/user";
import {
setupTwoFactor,
setupTwoFactorFinish,
} from "ente-accounts/services/user";
import { CenteredFill } from "ente-base/components/containers";
import { LinkButton } from "ente-base/components/LinkButton";
import { ActivityIndicator } from "ente-base/components/mui/ActivityIndicator";
import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton";
import { encryptBox } from "ente-base/crypto";
import { getData, setLSUser } from "ente-shared/storage/localStorage";
import { t } from "i18next";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
@@ -27,16 +27,7 @@ const Page: React.FC = () => {
}, []);
const handleSubmit = async (otp: string) => {
const box = await encryptBox(
twoFactorSecret!.secretCode,
await getUserRecoveryKey(),
);
await enableTwoFactor({
code: otp,
encryptedTwoFactorSecret: box.encryptedData,
twoFactorSecretDecryptionNonce: box.nonce,
});
await setLSUser({ ...getData("user"), isTwoFactorEnabled: true });
await setupTwoFactorFinish(twoFactorSecret!.secretCode, otp);
await router.push(appHomeRoute);
};

View File

@@ -38,10 +38,20 @@ const Page: React.FC = () => {
const handleSubmit = async (otp: string) => {
try {
const resp = await verifyTwoFactor(otp, sessionID);
const { keyAttributes, encryptedToken, token, id } = resp;
await setLSUser({ ...getData("user"), token, encryptedToken, id });
setData("keyAttributes", keyAttributes!);
const { keyAttributes, encryptedToken, id } = await verifyTwoFactor(
otp,
sessionID,
);
await setLSUser({
...getData("user"),
id,
// The original code was parsing an token which is never going
// to be present in the response, so effectively was always
// setting token to undefined. So this works, but is it needed?
token: undefined,
encryptedToken,
});
setData("keyAttributes", keyAttributes);
await router.push(unstashRedirect() ?? "/credentials");
} catch (e) {
if (e instanceof HTTPError && e.res.status == 404) {

View File

@@ -252,7 +252,7 @@ export const saveCredentialsAndNavigateTo = async (
const { id, encryptedToken, keyAttributes } = response;
await setLSUser({ ...getData("user"), encryptedToken, id });
setData("keyAttributes", keyAttributes!);
setData("keyAttributes", keyAttributes);
return unstashRedirect() ?? "/credentials";
};

View File

@@ -1,3 +1,4 @@
import { encryptBox } from "ente-base/crypto";
import {
authenticatedRequestHeaders,
ensureOk,
@@ -5,9 +6,10 @@ import {
} from "ente-base/http";
import { apiURL } from "ente-base/origins";
import HTTPService from "ente-shared/network/HTTPService";
import { getToken } from "ente-shared/storage/localStorage/helpers";
import { getData, setLSUser } from "ente-shared/storage/localStorage";
import { nullToUndefined } from "ente-utils/transform";
import { z } from "zod/v4";
import { getUserRecoveryKey } from "./recovery-key";
export interface User {
id: number;
@@ -18,18 +20,25 @@ export interface User {
twoFactorSessionID: string;
}
// TODO: During login the only field present is email. Which makes this
// optionality indicated by these types incorrect.
/**
* The local storage data about the user after they've logged in.
*/
const LocalUser = z.object({
/** The user's ID. */
/**
* The user's ID.
*/
id: z.number(),
/** The user's email. */
/**
* The user's email.
*/
email: z.string(),
/**
* The user's (plaintext) auth token.
*
* It is used for making API calls on their behalf, by passing this token as
* the value of the X-Auth-Token header in the HTTP request.
*
* Deprecated, use `getAuthToken()` instead (which fetches it from IDB).
*/
token: z.string(),
});
@@ -37,9 +46,30 @@ const LocalUser = z.object({
/** Locally available data for the logged in user */
export type LocalUser = z.infer<typeof LocalUser>;
/**
* The local storage data about the user before login or signup is complete.
*
* During login or signup, the user object exists in various partial states in
* local storage.
*
* - Initially, there is no user object in local storage.
*
* - When the user enters their email, the email property of the stored object
* is set, but nothing else.
*
* - If they have second factor verification set, then after entering their
* password {@link isTwoFactorEnabled} and {@link twoFactorSessionID} will
* also get filled in.
*
* - Once they verify their TOTP based second factor, their {@link id} and
* {@link encryptedToken} will also get filled in.
*/
// TODO: Start using me.
export const PreLoginLocalUser = LocalUser.partial();
/**
* Return the logged-in user, if someone is indeed logged in. Otherwise return
* `undefined`.
* `undefined` (TODO: That's not what it is doing...).
*
* The user's data is stored in the browser's localStorage. Thus, this function
* only works from the main thread, not from web workers (local storage is not
@@ -250,6 +280,44 @@ export const savedKeyAttributes = (): KeyAttributes | undefined => {
export const ensureSavedKeyAttributes = (): KeyAttributes =>
ensureExpectedLoggedInValue(savedKeyAttributes());
/**
* Update or set the user's {@link KeyAttributes} on remote.
*/
export const putUserKeyAttributes = async (keyAttributes: KeyAttributes) =>
ensureOk(
await fetch(await apiURL("/users/attributes"), {
method: "PUT",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify({ keyAttributes }),
}),
);
export interface RecoveryKeyAttributes {
masterKeyEncryptedWithRecoveryKey: string;
masterKeyDecryptionNonce: string;
recoveryKeyEncryptedWithMasterKey: string;
recoveryKeyDecryptionNonce: string;
}
/**
* Update the encrypted recovery key attributes for the logged in user.
*
* In practice, this is not expected to be called and is meant as a rare
* fallback for very old accounts created prior to recovery key related
* attributes being assigned on account setup. Even for these, it'll be called
* only once.
*/
export const putUserRecoveryKeyAttributes = async (
recoveryKeyAttributes: RecoveryKeyAttributes,
) =>
ensureOk(
await fetch(await apiURL("/users/recovery-key"), {
method: "PUT",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify(recoveryKeyAttributes),
}),
);
export interface UserVerificationResponse {
id: number;
keyAttributes?: KeyAttributes | undefined;
@@ -274,33 +342,6 @@ export interface UserVerificationResponse {
srpM2?: string | undefined;
}
export interface TwoFactorVerificationResponse {
id: number;
keyAttributes: KeyAttributes;
encryptedToken?: string;
token?: string;
}
const TwoFactorSecret = z.object({
secretCode: z.string(),
qrCode: z.string(),
});
export type TwoFactorSecret = z.infer<typeof TwoFactorSecret>;
export interface TwoFactorRecoveryResponse {
encryptedSecret: string;
secretDecryptionNonce: string;
}
export interface UpdatedKey {
kekSalt: string;
encryptedKey: string;
keyDecryptionNonce: string;
memLimit: number;
opsLimit: number;
}
/**
* Ask remote to send a OTP / OTT to the given email to verify that the user has
* access to it. Subsequent the app will pass this OTT back via the
@@ -387,34 +428,6 @@ export const EmailOrSRPAuthorizationResponse = z.object({
srpM2: z.string().nullish().transform(nullToUndefined),
});
/**
* The result of a successful two factor verification (totp or passkey).
*/
export const TwoFactorAuthorizationResponse = z.object({
id: z.number(),
/** TODO: keyAttributes is guaranteed to be returned by museum, update the
* types to reflect that. */
keyAttributes: RemoteKeyAttributes.nullish().transform(nullToUndefined),
/** TODO: encryptedToken is guaranteed to be returned by museum, update the
* types to reflect that. */
encryptedToken: z.string().nullish().transform(nullToUndefined),
});
export type TwoFactorAuthorizationResponse = z.infer<
typeof TwoFactorAuthorizationResponse
>;
/**
* Update or set the user's {@link KeyAttributes} on remote.
*/
export const putUserKeyAttributes = async (keyAttributes: KeyAttributes) =>
ensureOk(
await fetch(await apiURL("/users/attributes"), {
method: "PUT",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify({ keyAttributes }),
}),
);
/**
* Log the user out on remote, if possible and needed.
*/
@@ -441,32 +454,216 @@ export const remoteLogoutIfNeeded = async () => {
ensureOk(res);
};
export const verifyTwoFactor = async (code: string, sessionID: string) => {
export interface UpdatedKey {
kekSalt: string;
encryptedKey: string;
keyDecryptionNonce: string;
memLimit: number;
opsLimit: number;
}
/**
* Change the email associated with the user's account on remote.
*
* @param email The new email.
*
* @param ott The verification code that was sent to the new email.
*/
export const changeEmail = async (email: string, ott: string) =>
ensureOk(
await fetch(await apiURL("/users/change-email"), {
method: "POST",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify({ email, ott }),
}),
);
const TwoFactorSecret = z.object({
/**
* The 2FA secret code.
*/
secretCode: z.string(),
/**
* A base64 encoded "image/png".
*/
qrCode: z.string(),
});
export type TwoFactorSecret = z.infer<typeof TwoFactorSecret>;
/**
* Start a TOTP based two factor setup process by fetching a secret code (and
* the corresponding QR code) from remote.
*
* Once the user provides us with a TOTP generated using the provided secret, we
* can finish the setup with {@link setupTwoFactorFinish}.
*/
export const setupTwoFactor = async (): Promise<TwoFactorSecret> => {
const res = await fetch(await apiURL("/users/two-factor/setup"), {
method: "POST",
headers: await authenticatedRequestHeaders(),
});
ensureOk(res);
return TwoFactorSecret.parse(await res.json());
};
/**
* Finish the TOTP based two factor setup by provided a previously obtained
* secret (using {@link setupTwoFactor}) and the current TOTP generated using
* that secret.
*
* This updates both the state both locally and on remote.
*
* @param secretCode The value of {@link secretCode} from the
* {@link TwoFactorSecret} obtained by {@link setupTwoFactor}.
*
* @param totp The current TOTP corresponding to {@link secretCode}.
*/
export const setupTwoFactorFinish = async (
secretCode: string,
totp: string,
) => {
const box = await encryptBox(secretCode, await getUserRecoveryKey());
await enableTwoFactor({
code: totp,
encryptedTwoFactorSecret: box.encryptedData,
twoFactorSecretDecryptionNonce: box.nonce,
});
await setLSUser({ ...getData("user"), isTwoFactorEnabled: true });
};
interface EnableTwoFactorRequest {
/**
* The current value of the TOTP corresponding to the two factor {@link
* secretCode} obtained from a previous call to {@link setupTwoFactor}.
*/
code: string;
/**
* The {@link secretCode} encrypted with the user's recovery key.
*
* This is used in the case of second factor recovery.
*/
encryptedTwoFactorSecret: string;
/**
* The nonce that was used when encrypting {@link encryptedTwoFactorSecret}.
*/
twoFactorSecretDecryptionNonce: string;
}
/**
* Enable the TOTP based two factor for the user by providing the current 2FA
* code corresponding the two factor secret, and encrypted secrets for future
* recovery (if needed).
*/
const enableTwoFactor = async (req: EnableTwoFactorRequest) =>
ensureOk(
await fetch(await apiURL("/users/two-factor/enable"), {
method: "POST",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify(req),
}),
);
/**
* The result of a successful two factor verification (TOTP or passkey),
* recovery removal (TOTP) or recovery bypass (passkey).
*/
export const TwoFactorAuthorizationResponse = z.object({
/**
* The user's ID.
*/
id: z.number(),
/**
* The user's key attributes.
*/
keyAttributes: RemoteKeyAttributes,
/**
* A encrypted auth token.
*/
encryptedToken: z.string(),
});
export type TwoFactorAuthorizationResponse = z.infer<
typeof TwoFactorAuthorizationResponse
>;
export const verifyTwoFactor = async (
code: string,
sessionID: string,
): Promise<TwoFactorAuthorizationResponse> => {
const res = await fetch(await apiURL("/users/two-factor/verify"), {
method: "POST",
headers: publicRequestHeaders(),
body: JSON.stringify({ code, sessionID }),
});
ensureOk(res);
const json = await res.json();
// TODO: Use zod here
return json as UserVerificationResponse;
return TwoFactorAuthorizationResponse.parse(await res.json());
};
/** The type of the second factor we're trying to act on */
export type TwoFactorType = "totp" | "passkey";
const TwoFactorRecoveryResponse = z.object({
/**
* The recovery secret, encrypted using the user's recovery key.
*/
encryptedSecret: z.string(),
/**
* The nonce used during encryption of {@link encryptedSecret}.
*/
secretDecryptionNonce: z.string(),
});
type TwoFactorRecoveryResponse = z.infer<typeof TwoFactorRecoveryResponse>;
/**
* Initiate second factor reset or bypass by requesting the encrypted second
* factor recovery secret (and nonce) from remote. The user can then decrypt
* these using their recovery key to reset or bypass their second factor.
*
* @param sessionID A two factor session ID ({@link twoFactorSessionID} or
* {@link passkeySessionID}) for the user.
*
* @param twoFactorType The type of second factor to reset or bypass.
*
* [Note: Second factor recovery]
*
* 1. When setting up a TOTP based second factor, client sends a (encrypted 2fa
* recovery secret, nonce) pair to remote. This is a randomly generated
* secret (and nonce) encrypted using the user's recovery key.
*
* 2. Similarly, when setting up a passkey as the second factor, the client
* sends a encrypted recovery secret (see {@link configurePasskeyRecovery}).
*
* 3. When the user wishes to reset or bypass their second factor, the client
* asks remote for these encrypted secrets (using {@link recoverTwoFactor}).
*
* 4. User then enters their recovery key, which the client uses to decrypt the
* recovery secret and provide it back to remote for verification (using
* {@link removeTwoFactor}).
*
* 5. If the recovery secret matches, then remote resets (TOTP based) or bypass
* (passkey based) the user's second factor.
*/
export const recoverTwoFactor = async (
sessionID: string,
twoFactorType: TwoFactorType,
) => {
const resp = await HTTPService.get(
await apiURL("/users/two-factor/recover"),
{ sessionID, twoFactorType },
): Promise<TwoFactorRecoveryResponse> => {
const res = await fetch(
await apiURL("/users/two-factor/recover", { sessionID, twoFactorType }),
{ headers: publicRequestHeaders() },
);
return resp.data as TwoFactorRecoveryResponse;
ensureOk(res);
return TwoFactorRecoveryResponse.parse(await res.json());
};
export interface TwoFactorVerificationResponse {
id: number;
keyAttributes: KeyAttributes;
encryptedToken?: string;
token?: string;
}
export const removeTwoFactor = async (
sessionID: string,
secret: string,
@@ -478,70 +675,3 @@ export const removeTwoFactor = async (
);
return resp.data as TwoFactorVerificationResponse;
};
export const changeEmail = async (email: string, ott: string) => {
await HTTPService.post(
await apiURL("/users/change-email"),
{ email, ott },
undefined,
{ "X-Auth-Token": getToken() },
);
};
/**
* Start the two factor setup process by fetching a secret code (and the
* corresponding QR code) from remote.
*/
export const setupTwoFactor = async () => {
const res = await fetch(await apiURL("/users/two-factor/setup"), {
method: "POST",
headers: await authenticatedRequestHeaders(),
});
ensureOk(res);
return TwoFactorSecret.parse(await res.json());
};
interface EnableTwoFactorRequest {
code: string;
encryptedTwoFactorSecret: string;
twoFactorSecretDecryptionNonce: string;
}
/**
* Enable two factor for the user by providing the 2FA code and the encrypted
* secret from a previous call to {@link setupTwoFactor}.
*/
export const enableTwoFactor = async (req: EnableTwoFactorRequest) =>
ensureOk(
await fetch(await apiURL("/users/two-factor/enable"), {
method: "POST",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify(req),
}),
);
export interface RecoveryKeyAttributes {
masterKeyEncryptedWithRecoveryKey: string;
masterKeyDecryptionNonce: string;
recoveryKeyEncryptedWithMasterKey: string;
recoveryKeyDecryptionNonce: string;
}
/**
* Update the encrypted recovery key attributes for the logged in user.
*
* In practice, this is not expected to be called and is meant as a rare
* fallback for very old accounts created prior to recovery key related
* attributes being assigned on account setup. Even for these, it'll be called
* only once.
*/
export const putUserRecoveryKeyAttributes = async (
recoveryKeyAttributes: RecoveryKeyAttributes,
) =>
ensureOk(
await fetch(await apiURL("/users/recovery-key"), {
method: "PUT",
headers: await authenticatedRequestHeaders(),
body: JSON.stringify(recoveryKeyAttributes),
}),
);

View File

@@ -263,3 +263,16 @@ export const deleteFromTrash = async (fileIDs: number[]) => {
);
}
};
/**
* Delete the public link for the collection with given {@link collectionID}.
*
* Does not modify local state.
*/
export const deleteShareURL = async (collectionID: number) =>
ensureOk(
await fetch(await apiURL(`/collections/share-url/${collectionID}`), {
method: "DELETE",
headers: await authenticatedRequestHeaders(),
}),
);