[web/desktop] Allow self-hosters to set custom endpoints (#2271)

This commit is contained in:
Manav Rathi
2024-06-24 16:08:05 +05:30
committed by GitHub
44 changed files with 812 additions and 417 deletions

View File

@@ -1,4 +1,5 @@
import { isDevBuild } from "@/next/env";
import { apiOrigin } from "@/next/origins";
import { clientPackageName } from "@/next/types/app";
import { TwoFactorAuthorizationResponse } from "@/next/types/credentials";
import { ensure } from "@/utils/ensure";
@@ -8,7 +9,6 @@ import {
toB64URLSafeNoPadding,
toB64URLSafeNoPaddingString,
} from "@ente/shared/crypto/internal/libsodium";
import { apiOrigin } from "@ente/shared/network/api";
import { z } from "zod";
/** Return true if the user's browser supports WebAuthn (Passkeys). */
@@ -354,14 +354,6 @@ export const isWhitelistedRedirect = (redirectURL: URL) =>
? _isWhitelistedRedirect(redirectURL)
: true;
const _isWhitelistedRedirect = (redirectURL: URL) =>
(isDevBuild && redirectURL.hostname.endsWith("localhost")) ||
redirectURL.host.endsWith(".ente.io") ||
redirectURL.host.endsWith(".ente.sh") ||
redirectURL.protocol == "ente:" ||
redirectURL.protocol == "enteauth:" ||
redirectURL.protocol == "ente-cli:";
export const shouldRestrictToWhitelistedRedirect = () => {
// host includes port, hostname is sans port
const hostname = new URL(window.location.origin).hostname;
@@ -372,6 +364,14 @@ export const shouldRestrictToWhitelistedRedirect = () => {
);
};
const _isWhitelistedRedirect = (redirectURL: URL) =>
(isDevBuild && redirectURL.hostname.endsWith("localhost")) ||
redirectURL.host.endsWith(".ente.io") ||
redirectURL.host.endsWith(".ente.sh") ||
redirectURL.protocol == "ente:" ||
redirectURL.protocol == "enteauth:" ||
redirectURL.protocol == "ente-cli:";
export interface BeginPasskeyAuthenticationResponse {
/**
* An identifier for this authentication ceremony / session.

View File

@@ -1,15 +1,13 @@
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { ApiError, CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { getActualKey } from "@ente/shared/user";
import { HttpStatusCode } from "axios";
import { codeFromURIString, type Code } from "services/code";
const ENDPOINT = getEndpoint();
export const getAuthCodes = async (): Promise<Code[]> => {
const masterKey = await getActualKey();
try {
@@ -83,7 +81,7 @@ interface AuthKey {
export const getAuthKey = async (): Promise<AuthKey> => {
try {
const resp = await HTTPService.get(
`${ENDPOINT}/authenticator/key`,
`${apiOrigin()}/authenticator/key`,
{},
{
"X-Auth-Token": getToken(),
@@ -110,7 +108,7 @@ export const getDiff = async (
): Promise<AuthEntity[]> => {
try {
const resp = await HTTPService.get(
`${ENDPOINT}/authenticator/entity/diff`,
`${apiOrigin()}/authenticator/entity/diff`,
{
sinceTime,
limit,

View File

@@ -11,13 +11,13 @@ import { heicToJPEG } from "@/media/heic-convert";
import { decodeLivePhoto } from "@/media/live-photo";
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
import { apiOrigin, customAPIOrigin } from "@/next/origins";
import { shuffled } from "@/utils/array";
import { ensure } from "@/utils/ensure";
import { wait } from "@/utils/promise";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { ApiError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { apiOrigin, customAPIOrigin } from "@ente/shared/network/api";
import type { AxiosResponse } from "axios";
import type { CastData } from "services/cast-data";
import { detectMediaMIMEType } from "services/detect-type";

View File

@@ -21,7 +21,6 @@
"exifr": "^7.1.3",
"fast-srp-hap": "^2.0.4",
"ffmpeg-wasm": "file:./thirdparty/ffmpeg-wasm",
"formik": "^2.1.5",
"hdbscan": "0.0.1-alpha.5",
"idb": "^8",
"leaflet": "^1.9.4",

View File

@@ -1,5 +1,6 @@
import log from "@/next/log";
import { savedLogs } from "@/next/log-web";
import { customAPIHost } from "@/next/origins";
import { openAccountsManagePasskeysPage } from "@ente/accounts/services/passkey";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
@@ -685,6 +686,8 @@ const DebugSection: React.FC = () => {
electron?.appVersion().then((v) => setAppVersion(v));
});
const host = customAPIHost();
const confirmLogDownload = () =>
appContext.setDialogMessage({
title: t("DOWNLOAD_LOGS"),
@@ -707,21 +710,6 @@ const DebugSection: React.FC = () => {
return (
<>
<EnteMenuItem
onClick={confirmLogDownload}
variant="mini"
label={t("DOWNLOAD_UPLOAD_LOGS")}
/>
{appVersion && (
<Typography
py={"14px"}
px={"16px"}
color="text.muted"
variant="mini"
>
{appVersion}
</Typography>
)}
{isInternalUserViaEmailCheck() && (
<EnteMenuItem
variant="secondary"
@@ -729,6 +717,17 @@ const DebugSection: React.FC = () => {
label={"Test Upload"}
/>
)}
<EnteMenuItem
onClick={confirmLogDownload}
variant="mini"
label={t("DOWNLOAD_UPLOAD_LOGS")}
/>
<Stack py={"14px"} px={"16px"} gap={"24px"} color="text.muted">
{appVersion && (
<Typography variant="mini">{appVersion}</Typography>
)}
{host && <Typography variant="mini">{host}</Typography>}
</Stack>
</>
);
};

View File

@@ -1,4 +0,0 @@
import { getPaymentsURL } from "@ente/shared/network/api";
export const getDesktopRedirectURL = () =>
`${getPaymentsURL()}/desktop-redirect`;

View File

@@ -1,19 +1,21 @@
import { DevSettings } from "@/new/photos/components/DevSettings";
import log from "@/next/log";
import { albumsAppOrigin, customAPIHost } from "@/next/origins";
import { Login } from "@ente/accounts/components/Login";
import { SignUp } from "@ente/accounts/components/SignUp";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages";
import { saveKeyInSessionStore } from "@ente/shared/crypto/helpers";
import { getAlbumsURL } from "@ente/shared/network/api";
import localForage from "@ente/shared/storage/localForage";
import { getData, LS_KEYS } from "@ente/shared/storage/localStorage";
import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { getKey, SESSION_KEYS } from "@ente/shared/storage/sessionStorage";
import { SESSION_KEYS, getKey } from "@ente/shared/storage/sessionStorage";
import {
Box,
Button,
styled,
Typography,
styled,
type TypographyProps,
} from "@mui/material";
import { t } from "i18next";
@@ -26,14 +28,20 @@ import { useAppContext } from "./_app";
export default function LandingPage() {
const { appName, showNavBar, setDialogMessage } = useAppContext();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [showLogin, setShowLogin] = useState(true);
// This is kept as state because it can change as a result of user action
// while we're on this page (there currently isn't an event listener we can
// attach to for observing changes to local storage by the same window).
const [host, setHost] = useState(customAPIHost());
const router = useRouter();
useEffect(() => {
showNavBar(false);
const currentURL = new URL(window.location.href);
const albumsURL = new URL(getAlbumsURL());
const albumsURL = new URL(albumsAppOrigin());
currentURL.pathname = router.pathname;
if (
currentURL.host === albumsURL.host &&
@@ -45,6 +53,8 @@ export default function LandingPage() {
}
}, []);
const handleMaybeChangeHost = () => setHost(customAPIHost());
const handleAlbumsRedirect = async (currentURL: URL) => {
const end = currentURL.hash.lastIndexOf("&");
const hash = currentURL.hash.slice(1, end !== -1 ? end : undefined);
@@ -107,7 +117,7 @@ export default function LandingPage() {
const redirectToLoginPage = () => router.push(PAGES.LOGIN);
return (
<Container>
<TappableContainer onMaybeChangeHost={handleMaybeChangeHost}>
{loading ? (
<EnteSpinner />
) : (
@@ -129,23 +139,81 @@ export default function LandingPage() {
<Button size="large" onClick={redirectToLoginPage}>
{t("EXISTING_USER")}
</Button>
<MobileBoxFooter {...{ host }} />
</MobileBox>
<DesktopBox>
<SideBox>
{showLogin ? (
<Login {...{ signUp, appName }} />
<Login {...{ signUp, appName, host }} />
) : (
<SignUp {...{ router, appName, login }} />
<SignUp {...{ router, appName, login, host }} />
)}
</SideBox>
</DesktopBox>
</>
)}
</Container>
</TappableContainer>
);
}
const Container = styled("div")`
interface TappableContainerProps {
/**
* Called when the user closes the dialog to set a custom server.
*
* This is our chance to re-read the value of the custom API origin from
* local storage since the user might've changed it.
*/
onMaybeChangeHost: () => void;
}
const TappableContainer: React.FC<
React.PropsWithChildren<TappableContainerProps>
> = ({ onMaybeChangeHost, children }) => {
// [Note: Configuring custom server]
//
// Allow the user to tap 7 times anywhere on the onboarding screen to bring
// up a page where they can configure the endpoint that the app should
// connect to.
//
// See: https://help.ente.io/self-hosting/guides/custom-server/
const [tapCount, setTapCount] = useState(0);
const [showDevSettings, setShowDevSettings] = useState(false);
const handleClick: React.MouseEventHandler = (event) => {
// Don't allow this when running on (e.g.) web.ente.io.
if (!shouldAllowChangingAPIOrigin()) return;
// Ignore clicks on buttons when counting up towards 7.
if (event.target instanceof HTMLButtonElement) return;
// Ignore clicks when the dialog is already open.
if (showDevSettings) return;
// Otherwise increase the tap count,
setTapCount(tapCount + 1);
// And show the dev settings dialog when it reaches 7.
if (tapCount + 1 == 7) {
setTapCount(0);
setShowDevSettings(true);
}
};
const handleClose = () => {
setShowDevSettings(false);
onMaybeChangeHost();
};
return (
<TappableContainer_ onClick={handleClick}>
<>
<DevSettings open={showDevSettings} onClose={handleClose} />
{children}
</>
</TappableContainer_>
);
};
const TappableContainer_ = styled("div")`
display: flex;
flex: 1;
align-items: center;
@@ -157,6 +225,15 @@ const Container = styled("div")`
}
`;
/**
* Disable the ability to set the custom server when we're running on our own
* production deployment.
*/
const shouldAllowChangingAPIOrigin = () => {
const hostname = new URL(window.location.origin).hostname;
return !(hostname.endsWith(".ente.io") || hostname.endsWith(".ente.sh"));
};
const SlideContainer = styled("div")`
flex: 1;
display: flex;
@@ -174,6 +251,35 @@ const Logo_ = styled("div")`
margin-block-end: 64px;
`;
const MobileBox = styled("div")`
display: none;
@media (max-width: 1024px) {
max-width: 375px;
width: 100%;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
`;
interface MobileBoxFooterProps {
host: string | undefined;
}
const MobileBoxFooter: React.FC<MobileBoxFooterProps> = ({ host }) => {
return (
<Box pt={4} textAlign="center">
{host && (
<Typography variant="mini" color="text.faint">
{host}
</Typography>
)}
</Box>
);
};
const DesktopBox = styled("div")`
flex: 1;
height: 100%;
@@ -188,19 +294,6 @@ const DesktopBox = styled("div")`
}
`;
const MobileBox = styled("div")`
display: none;
@media (max-width: 1024px) {
max-width: 375px;
width: 100%;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
`;
const SideBox = styled("div")`
display: flex;
flex-direction: column;

View File

@@ -1,19 +1,16 @@
import log from "@/next/log";
import { apiOrigin, paymentsAppOrigin } from "@/next/origins";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint, getPaymentsURL } from "@ente/shared/network/api";
import {
LS_KEYS,
removeData,
setData,
} from "@ente/shared/storage/localStorage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { getDesktopRedirectURL } from "constants/billing";
import isElectron from "is-electron";
import { Plan, Subscription } from "types/billing";
import { getPaymentToken } from "./userService";
const ENDPOINT = getEndpoint();
enum PaymentActionType {
Buy = "buy",
Update = "update",
@@ -36,11 +33,11 @@ class billingService {
let response;
if (!token) {
response = await HTTPService.get(
`${ENDPOINT}/billing/plans/v2`,
`${apiOrigin()}/billing/plans/v2`,
);
} else {
response = await HTTPService.get(
`${ENDPOINT}/billing/user-plans`,
`${apiOrigin()}/billing/user-plans`,
null,
{
"X-Auth-Token": getToken(),
@@ -56,7 +53,7 @@ class billingService {
public async syncSubscription() {
try {
const response = await HTTPService.get(
`${ENDPOINT}/billing/subscription`,
`${apiOrigin()}/billing/subscription`,
null,
{
"X-Auth-Token": getToken(),
@@ -100,7 +97,7 @@ class billingService {
public async cancelSubscription() {
try {
const response = await HTTPService.post(
`${ENDPOINT}/billing/stripe/cancel-subscription`,
`${apiOrigin()}/billing/stripe/cancel-subscription`,
null,
null,
{
@@ -118,7 +115,7 @@ class billingService {
public async activateSubscription() {
try {
const response = await HTTPService.post(
`${ENDPOINT}/billing/stripe/activate-subscription`,
`${apiOrigin()}/billing/stripe/activate-subscription`,
null,
null,
{
@@ -142,7 +139,7 @@ class billingService {
return;
}
const response = await HTTPService.post(
`${ENDPOINT}/billing/verify-subscription`,
`${apiOrigin()}/billing/verify-subscription`,
{
paymentProvider: "stripe",
productID: null,
@@ -167,9 +164,14 @@ class billingService {
return;
}
try {
await HTTPService.delete(`${ENDPOINT}/family/leave`, null, null, {
"X-Auth-Token": getToken(),
});
await HTTPService.delete(
`${apiOrigin()}/family/leave`,
null,
null,
{
"X-Auth-Token": getToken(),
},
);
removeData(LS_KEYS.FAMILY_DATA);
} catch (e) {
log.error("/family/leave failed", e);
@@ -184,7 +186,7 @@ class billingService {
) {
try {
const redirectURL = this.getRedirectURL();
window.location.href = `${getPaymentsURL()}?productID=${productID}&paymentToken=${paymentToken}&action=${action}&redirectURL=${redirectURL}`;
window.location.href = `${paymentsAppOrigin()}?productID=${productID}&paymentToken=${paymentToken}&action=${action}&redirectURL=${redirectURL}`;
} catch (e) {
log.error("unable to get payments url", e);
throw e;
@@ -195,7 +197,7 @@ class billingService {
try {
const redirectURL = this.getRedirectURL();
const response = await HTTPService.get(
`${ENDPOINT}/billing/stripe/customer-portal`,
`${apiOrigin()}/billing/stripe/customer-portal`,
{ redirectURL },
{
"X-Auth-Token": getToken(),
@@ -210,7 +212,7 @@ class billingService {
public getRedirectURL() {
if (isElectron()) {
return getDesktopRedirectURL();
return `${paymentsAppOrigin()}/desktop-redirect`;
} else {
return `${window.location.origin}/gallery`;
}

View File

@@ -1,11 +1,10 @@
import { getEndpoint } from "@ente/shared/network/api";
import localForage from "@ente/shared/storage/localForage";
import { getData, LS_KEYS } from "@ente/shared/storage/localStorage";
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import localForage from "@ente/shared/storage/localForage";
import { getData, LS_KEYS } from "@ente/shared/storage/localStorage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { getActualKey } from "@ente/shared/user";
import type { User } from "@ente/shared/user/types";
@@ -77,7 +76,6 @@ import {
import { getLocalFiles } from "./fileService";
import { getPublicKey } from "./userService";
const ENDPOINT = getEndpoint();
const COLLECTION_TABLE = "collections";
const COLLECTION_UPDATION_TIME = "collection-updation-time";
const HIDDEN_COLLECTION_IDS = "hidden-collection-ids";
@@ -183,7 +181,7 @@ const getCollections = async (
): Promise<Collection[]> => {
try {
const resp = await HTTPService.get(
`${ENDPOINT}/collections/v2`,
`${apiOrigin()}/collections/v2`,
{
sinceTime,
},
@@ -330,7 +328,7 @@ export const getCollection = async (
return;
}
const resp = await HTTPService.get(
`${ENDPOINT}/collections/${collectionID}`,
`${apiOrigin()}/collections/${collectionID}`,
null,
{ "X-Auth-Token": token },
);
@@ -474,7 +472,7 @@ const postCollection = async (
): Promise<EncryptedCollection> => {
try {
const response = await HTTPService.post(
`${ENDPOINT}/collections`,
`${apiOrigin()}/collections`,
collectionData,
null,
{ "X-Auth-Token": token },
@@ -529,7 +527,7 @@ export const addToCollection = async (
files: fileKeysEncryptedWithNewCollection,
};
await HTTPService.post(
`${ENDPOINT}/collections/add-files`,
`${apiOrigin()}/collections/add-files`,
requestBody,
null,
{
@@ -559,7 +557,7 @@ export const restoreToCollection = async (
files: fileKeysEncryptedWithNewCollection,
};
await HTTPService.post(
`${ENDPOINT}/collections/restore-files`,
`${apiOrigin()}/collections/restore-files`,
requestBody,
null,
{
@@ -590,7 +588,7 @@ export const moveToCollection = async (
files: fileKeysEncryptedWithNewCollection,
};
await HTTPService.post(
`${ENDPOINT}/collections/move-files`,
`${apiOrigin()}/collections/move-files`,
requestBody,
null,
{
@@ -736,7 +734,7 @@ export const removeNonUserFiles = async (
};
await HTTPService.post(
`${ENDPOINT}/collections/v3/remove-files`,
`${apiOrigin()}/collections/v3/remove-files`,
request,
null,
{ "X-Auth-Token": token },
@@ -763,7 +761,7 @@ export const deleteCollection = async (
const token = getToken();
await HTTPService.delete(
`${ENDPOINT}/collections/v3/${collectionID}`,
`${apiOrigin()}/collections/v3/${collectionID}`,
null,
{ collectionID, keepFiles },
{ "X-Auth-Token": token },
@@ -779,7 +777,7 @@ export const leaveSharedAlbum = async (collectionID: number) => {
const token = getToken();
await HTTPService.post(
`${ENDPOINT}/collections/leave/${collectionID}`,
`${apiOrigin()}/collections/leave/${collectionID}`,
null,
null,
{ "X-Auth-Token": token },
@@ -817,7 +815,7 @@ export const updateCollectionMagicMetadata = async (
};
await HTTPService.put(
`${ENDPOINT}/collections/magic-metadata`,
`${apiOrigin()}/collections/magic-metadata`,
reqBody,
null,
{
@@ -861,7 +859,7 @@ export const updateSharedCollectionMagicMetadata = async (
};
await HTTPService.put(
`${ENDPOINT}/collections/sharee-magic-metadata`,
`${apiOrigin()}/collections/sharee-magic-metadata`,
reqBody,
null,
{
@@ -905,7 +903,7 @@ export const updatePublicCollectionMagicMetadata = async (
};
await HTTPService.put(
`${ENDPOINT}/collections/public-magic-metadata`,
`${apiOrigin()}/collections/public-magic-metadata`,
reqBody,
null,
{
@@ -940,7 +938,7 @@ export const renameCollection = async (
nameDecryptionNonce,
};
await HTTPService.post(
`${ENDPOINT}/collections/rename`,
`${apiOrigin()}/collections/rename`,
collectionRenameRequest,
null,
{
@@ -969,7 +967,7 @@ export const shareCollection = async (
encryptedKey,
};
await HTTPService.post(
`${ENDPOINT}/collections/share`,
`${apiOrigin()}/collections/share`,
shareCollectionRequest,
null,
{
@@ -993,7 +991,7 @@ export const unshareCollection = async (
email: withUserEmail,
};
await HTTPService.post(
`${ENDPOINT}/collections/unshare`,
`${apiOrigin()}/collections/unshare`,
shareCollectionRequest,
null,
{
@@ -1015,7 +1013,7 @@ export const createShareableURL = async (collection: Collection) => {
collectionID: collection.id,
};
const resp = await HTTPService.post(
`${ENDPOINT}/collections/share-url`,
`${apiOrigin()}/collections/share-url`,
createPublicAccessTokenRequest,
null,
{
@@ -1036,7 +1034,7 @@ export const deleteShareableURL = async (collection: Collection) => {
return null;
}
await HTTPService.delete(
`${ENDPOINT}/collections/share-url/${collection.id}`,
`${apiOrigin()}/collections/share-url/${collection.id}`,
null,
null,
{
@@ -1058,7 +1056,7 @@ export const updateShareableURL = async (
return null;
}
const res = await HTTPService.put(
`${ENDPOINT}/collections/share-url`,
`${apiOrigin()}/collections/share-url`,
request,
null,
{

View File

@@ -2,13 +2,11 @@ import { hasFileHash } from "@/media/file";
import { FILE_TYPE } from "@/media/file-type";
import type { Metadata } from "@/media/types/file";
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { EnteFile } from "types/file";
const ENDPOINT = getEndpoint();
interface DuplicatesResponse {
duplicates: Array<{
fileIDs: number[];
@@ -148,7 +146,7 @@ function groupDupesByFileHashes(dupe: Duplicate) {
async function fetchDuplicateFileIDs() {
try {
const response = await HTTPService.get(
`${ENDPOINT}/files/duplicates`,
`${apiOrigin()}/files/duplicates`,
null,
{
"X-Auth-Token": getToken(),

View File

@@ -1,6 +1,6 @@
import { customAPIOrigin } from "@/next/origins";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { customAPIOrigin } from "@ente/shared/network/api";
import { retryAsyncFunction } from "@ente/shared/utils";
import { DownloadClient } from "services/download";
import { EnteFile } from "types/file";

View File

@@ -1,6 +1,6 @@
import { customAPIOrigin } from "@/next/origins";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { customAPIOrigin } from "@ente/shared/network/api";
import { retryAsyncFunction } from "@ente/shared/utils";
import { DownloadClient } from "services/download";
import { EnteFile } from "types/file";

View File

@@ -1,10 +1,10 @@
import { inWorker } from "@/next/env";
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import { workerBridge } from "@/next/worker/worker-bridge";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import localForage from "@ente/shared/storage/localForage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import type {
@@ -285,7 +285,7 @@ export const getEmbeddingsDiff = async (
return;
}
const response = await HTTPService.get(
`${getEndpoint()}/embeddings/diff`,
`${apiOrigin()}/embeddings/diff`,
{
sinceTime,
limit: DIFF_LIMIT,
@@ -314,7 +314,7 @@ export const putEmbedding = async (
throw Error(CustomError.TOKEN_MISSING);
}
const resp = await HTTPService.put(
`${getEndpoint()}/embeddings`,
`${apiOrigin()}/embeddings`,
putEmbeddingReq,
null,
{

View File

@@ -1,7 +1,7 @@
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import localForage from "@ente/shared/storage/localForage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { getActualKey } from "@ente/shared/user";
@@ -15,8 +15,6 @@ import {
} from "types/entity";
import { getLatestVersionEntities } from "utils/entity";
const ENDPOINT = getEndpoint();
const DIFF_LIMIT = 500;
const ENTITY_TABLES: Record<EntityType, string> = {
@@ -60,7 +58,7 @@ const getEntityKey = async (type: EntityType) => {
return;
}
const resp = await HTTPService.get(
`${ENDPOINT}/user-entity/key`,
`${apiOrigin()}/user-entity/key`,
{
type,
},
@@ -175,7 +173,7 @@ const getEntityDiff = async (
return;
}
const resp = await HTTPService.get(
`${ENDPOINT}/user-entity/entity/diff`,
`${apiOrigin()}/user-entity/entity/diff`,
{
sinceTime: time,
type,

View File

@@ -1,8 +1,8 @@
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { Events, eventBus } from "@ente/shared/events";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import localForage from "@ente/shared/storage/localForage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { REQUEST_BATCH_SIZE } from "constants/api";
@@ -28,7 +28,6 @@ import {
setCollectionLastSyncTime,
} from "./collectionService";
const ENDPOINT = getEndpoint();
const FILES_TABLE = "files";
const HIDDEN_FILES_TABLE = "hidden-files";
@@ -118,7 +117,7 @@ export const getFiles = async (
break;
}
resp = await HTTPService.get(
`${ENDPOINT}/collections/v2/diff`,
`${apiOrigin()}/collections/v2/diff`,
{
collectionID: collection.id,
sinceTime: time,
@@ -187,7 +186,7 @@ export const trashFiles = async (filesToTrash: EnteFile[]) => {
})),
};
await HTTPService.post(
`${ENDPOINT}/files/trash`,
`${apiOrigin()}/files/trash`,
trashRequest,
null,
{
@@ -211,7 +210,7 @@ export const deleteFromTrash = async (filesToDelete: number[]) => {
for (const batch of batchedFilesToDelete) {
await HTTPService.post(
`${ENDPOINT}/trash/delete`,
`${apiOrigin()}/trash/delete`,
{ fileIDs: batch },
null,
{
@@ -253,9 +252,14 @@ export const updateFileMagicMetadata = async (
},
});
}
await HTTPService.put(`${ENDPOINT}/files/magic-metadata`, reqBody, null, {
"X-Auth-Token": token,
});
await HTTPService.put(
`${apiOrigin()}/files/magic-metadata`,
reqBody,
null,
{
"X-Auth-Token": token,
},
);
return fileWithUpdatedMagicMetadataList.map(
({ file, updatedMagicMetadata }): EnteFile => ({
...file,
@@ -296,7 +300,7 @@ export const updateFilePublicMagicMetadata = async (
});
}
await HTTPService.put(
`${ENDPOINT}/files/public-magic-metadata`,
`${apiOrigin()}/files/public-magic-metadata`,
reqBody,
null,
{

View File

@@ -1,15 +1,14 @@
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { CustomError, parseSharingErrorCodes } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import localForage from "@ente/shared/storage/localForage";
import { Collection, CollectionPublicMagicMetadata } from "types/collection";
import { EncryptedEnteFile, EnteFile } from "types/file";
import { LocalSavedPublicCollectionFiles } from "types/publicCollection";
import { decryptFile, mergeMetadata, sortFiles } from "utils/file";
const ENDPOINT = getEndpoint();
const PUBLIC_COLLECTION_FILES_TABLE = "public-collection-files";
const PUBLIC_COLLECTIONS_TABLE = "public-collections";
const PUBLIC_REFERRAL_CODE = "public-referral-code";
@@ -253,7 +252,7 @@ const getPublicFiles = async (
break;
}
resp = await HTTPService.get(
`${ENDPOINT}/public-collection/diff`,
`${apiOrigin()}/public-collection/diff`,
{
sinceTime: time,
},
@@ -308,7 +307,7 @@ export const getPublicCollection = async (
return;
}
const resp = await HTTPService.get(
`${ENDPOINT}/public-collection/info`,
`${apiOrigin()}/public-collection/info`,
null,
{ "Cache-Control": "no-cache", "X-Auth-Access-Token": token },
);
@@ -358,7 +357,7 @@ export const verifyPublicCollectionPassword = async (
): Promise<string> => {
try {
const resp = await HTTPService.post(
`${ENDPOINT}/public-collection/verify-password`,
`${apiOrigin()}/public-collection/verify-password`,
{ passHash: passwordHash },
null,
{ "Cache-Control": "no-cache", "X-Auth-Access-Token": token },

View File

@@ -1,6 +1,6 @@
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import localForage from "@ente/shared/storage/localForage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { Collection } from "types/collection";
@@ -14,8 +14,6 @@ const TRASH = "file-trash";
const TRASH_TIME = "trash-time";
const DELETED_COLLECTION = "deleted-collection";
const ENDPOINT = getEndpoint();
async function getLocalTrash() {
const trash = (await localForage.getItem<Trash>(TRASH)) || [];
return trash;
@@ -91,7 +89,7 @@ export const updateTrash = async (
break;
}
resp = await HTTPService.get(
`${ENDPOINT}/trash/v2/diff`,
`${apiOrigin()}/trash/v2/diff`,
{
sinceTime: time,
},
@@ -160,7 +158,7 @@ export const emptyTrash = async () => {
const lastUpdatedAt = await getLastSyncTime();
await HTTPService.post(
`${ENDPOINT}/trash/empty`,
`${apiOrigin()}/trash/empty`,
{ lastUpdatedAt },
null,
{

View File

@@ -1,13 +1,11 @@
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import { CustomError, handleUploadError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { EnteFile } from "types/file";
import { retryHTTPCall } from "./uploadHttpClient";
import { MultipartUploadURLs, UploadFile, UploadURL } from "./uploadService";
const ENDPOINT = getEndpoint();
const MAX_URL_REQUESTS = 50;
class PublicUploadHttpClient {
@@ -25,7 +23,7 @@ class PublicUploadHttpClient {
const response = await retryHTTPCall(
() =>
HTTPService.post(
`${ENDPOINT}/public-collection/file`,
`${apiOrigin()}/public-collection/file`,
uploadFile,
null,
{
@@ -57,7 +55,7 @@ class PublicUploadHttpClient {
throw Error(CustomError.TOKEN_MISSING);
}
this.uploadURLFetchInProgress = HTTPService.get(
`${ENDPOINT}/public-collection/upload-urls`,
`${apiOrigin()}/public-collection/upload-urls`,
{
count: Math.min(MAX_URL_REQUESTS, count * 2),
},
@@ -93,7 +91,7 @@ class PublicUploadHttpClient {
throw Error(CustomError.TOKEN_MISSING);
}
const response = await HTTPService.get(
`${ENDPOINT}/public-collection/multipart-upload-urls`,
`${apiOrigin()}/public-collection/multipart-upload-urls`,
{
count,
},

View File

@@ -1,15 +1,12 @@
import log from "@/next/log";
import { apiOrigin, uploaderOrigin } from "@/next/origins";
import { wait } from "@/utils/promise";
import { CustomError, handleUploadError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint, getUploadEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { EnteFile } from "types/file";
import { MultipartUploadURLs, UploadFile, UploadURL } from "./uploadService";
const ENDPOINT = getEndpoint();
const UPLOAD_ENDPOINT = getUploadEndpoint();
const MAX_URL_REQUESTS = 50;
class UploadHttpClient {
@@ -23,7 +20,7 @@ class UploadHttpClient {
}
const response = await retryHTTPCall(
() =>
HTTPService.post(`${ENDPOINT}/files`, uploadFile, null, {
HTTPService.post(`${apiOrigin()}/files`, uploadFile, null, {
"X-Auth-Token": token,
}),
handleUploadError,
@@ -44,7 +41,7 @@ class UploadHttpClient {
return;
}
this.uploadURLFetchInProgress = HTTPService.get(
`${ENDPOINT}/files/upload-urls`,
`${apiOrigin()}/files/upload-urls`,
{
count: Math.min(MAX_URL_REQUESTS, count * 2),
},
@@ -74,7 +71,7 @@ class UploadHttpClient {
return;
}
const response = await HTTPService.get(
`${ENDPOINT}/files/multipart-upload-urls`,
`${apiOrigin()}/files/multipart-upload-urls`,
{
count,
},
@@ -122,7 +119,7 @@ class UploadHttpClient {
try {
await retryHTTPCall(() =>
HTTPService.put(
`${UPLOAD_ENDPOINT}/file-upload`,
`${uploaderOrigin()}/file-upload`,
file,
null,
{
@@ -178,7 +175,7 @@ class UploadHttpClient {
try {
const response = await retryHTTPCall(async () => {
const resp = await HTTPService.put(
`${UPLOAD_ENDPOINT}/multipart-upload`,
`${uploaderOrigin()}/multipart-upload`,
filePart,
null,
{
@@ -219,7 +216,7 @@ class UploadHttpClient {
try {
await retryHTTPCall(() =>
HTTPService.post(
`${UPLOAD_ENDPOINT}/multipart-complete`,
`${uploaderOrigin()}/multipart-complete`,
reqBody,
null,
{

View File

@@ -1,8 +1,8 @@
import log from "@/next/log";
import { apiOrigin, customAPIOrigin, familyAppOrigin } from "@/next/origins";
import { putAttributes } from "@ente/accounts/api/user";
import { ApiError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint, getFamilyPortalURL } from "@ente/shared/network/api";
import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
import {
getToken,
@@ -17,15 +17,13 @@ import {
} from "types/user";
import { getLocalFamilyData, isPartOfFamily } from "utils/user/family";
const ENDPOINT = getEndpoint();
const HAS_SET_KEYS = "hasSetKeys";
export const getPublicKey = async (email: string) => {
const token = getToken();
const resp = await HTTPService.get(
`${ENDPOINT}/users/public-key`,
`${apiOrigin()}/users/public-key`,
{ email },
{
"X-Auth-Token": token,
@@ -38,7 +36,7 @@ export const getPaymentToken = async () => {
const token = getToken();
const resp = await HTTPService.get(
`${ENDPOINT}/users/payment-token`,
`${apiOrigin()}/users/payment-token`,
null,
{
"X-Auth-Token": token,
@@ -52,7 +50,7 @@ export const getFamiliesToken = async () => {
const token = getToken();
const resp = await HTTPService.get(
`${ENDPOINT}/users/families-token`,
`${apiOrigin()}/users/families-token`,
null,
{
"X-Auth-Token": token,
@@ -70,7 +68,7 @@ export const getRoadmapRedirectURL = async () => {
const token = getToken();
const resp = await HTTPService.get(
`${ENDPOINT}/users/roadmap/v2`,
`${apiOrigin()}/users/roadmap/v2`,
null,
{
"X-Auth-Token": token,
@@ -86,7 +84,7 @@ export const getRoadmapRedirectURL = async () => {
export const isTokenValid = async (token: string) => {
try {
const resp = await HTTPService.get(
`${ENDPOINT}/users/session-validity/v2`,
`${apiOrigin()}/users/session-validity/v2`,
null,
{
"X-Auth-Token": token,
@@ -125,7 +123,7 @@ export const isTokenValid = async (token: string) => {
export const getTwoFactorStatus = async () => {
const resp = await HTTPService.get(
`${ENDPOINT}/users/two-factor/status`,
`${apiOrigin()}/users/two-factor/status`,
null,
{
"X-Auth-Token": getToken(),
@@ -139,7 +137,7 @@ export const getUserDetailsV2 = async (): Promise<UserDetails> => {
const token = getToken();
const resp = await HTTPService.get(
`${ENDPOINT}/users/details/v2`,
`${apiOrigin()}/users/details/v2`,
null,
{
"X-Auth-Token": token,
@@ -156,7 +154,7 @@ export const getFamilyPortalRedirectURL = async () => {
try {
const jwtToken = await getFamiliesToken();
const isFamilyCreated = isPartOfFamily(getLocalFamilyData());
return `${getFamilyPortalURL()}?token=${jwtToken}&isFamilyCreated=${isFamilyCreated}&redirectURL=${
return `${familyAppOrigin()}?token=${jwtToken}&isFamilyCreated=${isFamilyCreated}&redirectURL=${
window.location.origin
}/gallery`;
} catch (e) {
@@ -170,7 +168,7 @@ export const getAccountDeleteChallenge = async () => {
const token = getToken();
const resp = await HTTPService.get(
`${ENDPOINT}/users/delete-challenge`,
`${apiOrigin()}/users/delete-challenge`,
null,
{
"X-Auth-Token": token,
@@ -195,7 +193,7 @@ export const deleteAccount = async (
}
await HTTPService.delete(
`${ENDPOINT}/users/delete`,
`${apiOrigin()}/users/delete`,
{ challenge, reason, feedback },
null,
{
@@ -213,7 +211,7 @@ export const getFaceSearchEnabledStatus = async () => {
const token = getToken();
const resp: AxiosResponse<GetRemoteStoreValueResponse> =
await HTTPService.get(
`${ENDPOINT}/remote-store`,
`${apiOrigin()}/remote-store`,
{
key: "faceSearchEnabled",
defaultValue: false,
@@ -233,7 +231,7 @@ export const updateFaceSearchEnabledStatus = async (newStatus: boolean) => {
try {
const token = getToken();
await HTTPService.post(
`${ENDPOINT}/remote-store/update`,
`${apiOrigin()}/remote-store/update`,
{
key: "faceSearchEnabled",
value: newStatus.toString(),
@@ -264,7 +262,7 @@ export const getMapEnabledStatus = async () => {
const token = getToken();
const resp: AxiosResponse<GetRemoteStoreValueResponse> =
await HTTPService.get(
`${ENDPOINT}/remote-store`,
`${apiOrigin()}/remote-store`,
{
key: "mapEnabled",
defaultValue: false,
@@ -284,7 +282,7 @@ export const updateMapEnabledStatus = async (newStatus: boolean) => {
try {
const token = getToken();
await HTTPService.post(
`${ENDPOINT}/remote-store/update`,
`${apiOrigin()}/remote-store/update`,
{
key: "mapEnabled",
value: newStatus.toString(),
@@ -314,10 +312,13 @@ export const updateMapEnabledStatus = async (newStatus: boolean) => {
* rename this to say getUseDirectUpload).
*/
export async function getDisableCFUploadProxyFlag(): Promise<boolean> {
// If NEXT_PUBLIC_ENTE_ENDPOINT is set, that means we're not running a
// production deployment. Disable the Cloudflare upload proxy, and instead
// just directly use the upload URLs that museum gives us.
if (process.env.NEXT_PUBLIC_ENTE_ENDPOINT) return true;
// If a custom origin is set, that means we're not running a production
// deployment (maybe we're running locally, or being self-hosted).
//
// In such cases, disable the Cloudflare upload proxy (which won't work for
// self-hosters), and instead just directly use the upload URLs that museum
// gives us.
if (customAPIOrigin()) return true;
try {
const featureFlags = (

View File

@@ -46,9 +46,7 @@ The root `package.json` also has a convenience dev dependency:
- [concurrently](https://github.com/open-cli-tools/concurrently) for spawning
parallel tasks when we invoke various yarn scripts.
## Utils
### Crypto
## Crypto
We use [libsodium](https://libsodium.gitbook.io/doc/) for encryption, key
generation etc. Specifically, we use its WebAssembly and JS wrappers made using
@@ -70,6 +68,27 @@ builds (See this [issue](https://github.com/jedisct1/libsodium.js/issues/326)).
Updating it is not a big problem, it is just a pending chore - we want to test a
bit more exhaustively when changing the crypto layer.
## Meta frameworks
### Next.js
[Next.js](https://nextjs.org) ("next") provides the meta framework for both the
Photos and the Auth app, and also for some of the sidecar apps like accounts and
cast.
We use a limited subset of Next. The main thing we get out of it is a reasonable
set of defaults for bundling our app into a static export which we can then
deploy to our webserver. In addition, the Next.js page router is convenient.
Apart from this, while we use a few tidbits from Next.js here and there, overall
our apps are regular React SPAs, and are not particularly tied to Next.
### Vite
For some of our newer code, we have started to use [Vite](https://vitejs.dev).
It is more lower level than Next, but the bells and whistles it doesn't have are
the bells and whistles (and the accompanying complexity) that we don't need in
some cases.
## UI
### React
@@ -77,13 +96,21 @@ bit more exhaustively when changing the crypto layer.
[React](https://react.dev) ("react") is our core framework. It also has a
sibling "react-dom" package that renders JSX to the DOM.
### MUI and Emotion
### MUI and Material Icons
We use [MUI](https://mui.com) ("@mui/material"), which is a React component
library, to get a base set of components.
We use [MUI](https://mui.com)'s
- [@mui/material](https://mui.com/material-ui/getting-started/installation/),
which is a React component library, to get a base set of components; and
- [@mui/material-icons](https://mui.com/material-ui/material-icons/). which
provides Material icons exported as React components (a `SvgIcon`).
### Emotion
MUI uses [Emotion](https://emotion.sh/) (a styled-component variant) as its
preferred CSS-in-JS library.
preferred CSS-in-JS library, and we use the same in our code too to reduce
moving parts.
Emotion itself comes in many parts, of which we need the following:
@@ -133,28 +160,13 @@ with Next.js.
For more details, see [translations.md](translations.md).
## Meta frameworks
### Others
### Next.js
- [formik](https://github.com/jaredpalmer/formik) provides an easier to use
abstraction for dealing with form state, validation and submission states
when using React.
[Next.js](https://nextjs.org) ("next") provides the meta framework for both the
Photos and the Auth app, and also for some of the sidecar apps like accounts and
cast.
We use a limited subset of Next. The main thing we get out of it is a reasonable
set of defaults for bundling our app into a static export which we can then
deploy to our webserver. In addition, the Next.js page router is convenient.
Apart from this, while we use a few tidbits from Next.js here and there, overall
our apps are regular React SPAs, and are not particularly tied to Next.
### Vite
For some of our newer code, we have started to use [Vite](https://vitejs.dev).
It is more lower level than Next, but the bells and whistles it doesn't have are
the bells and whistles (and the accompanying complexity) that we don't need in
some cases.
## General
## Infrastructure
- [comlink](https://github.com/GoogleChromeLabs/comlink) provides a minimal
layer on top of Web Workers to make them more easier to use.
@@ -182,6 +194,8 @@ some cases.
## Photos app specific
### General
- [react-dropzone](https://github.com/react-dropzone/react-dropzone/) is a
React hook to create a drag-and-drop input zone.
@@ -189,7 +203,7 @@ some cases.
for converting arbitrary strings into strings that are suitable for being
used as filenames.
## Face search
### Face search
- [transformation-matrix](https://github.com/chrvadala/transformation-matrix)
is used for performing 2D affine transformations using transformation

View File

@@ -1,7 +1,5 @@
import log from "@/next/log";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { apiOrigin } from "@/next/origins";
import type {
CompleteSRPSetupRequest,
CompleteSRPSetupResponse,
@@ -15,17 +13,19 @@ import type {
UpdateSRPAndKeysResponse,
} from "@ente/accounts/types/srp";
import { ApiError, CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { HttpStatusCode } from "axios";
const ENDPOINT = getEndpoint();
export const getSRPAttributes = async (
email: string,
): Promise<SRPAttributes | null> => {
try {
const resp = await HTTPService.get(`${ENDPOINT}/users/srp/attributes`, {
email,
});
const resp = await HTTPService.get(
`${apiOrigin()}/users/srp/attributes`,
{
email,
},
);
return (resp.data as GetSRPAttributesResponse).attributes;
} catch (e) {
log.error("failed to get SRP attributes", e);
@@ -39,7 +39,7 @@ export const startSRPSetup = async (
): Promise<SetupSRPResponse> => {
try {
const resp = await HTTPService.post(
`${ENDPOINT}/users/srp/setup`,
`${apiOrigin()}/users/srp/setup`,
setupSRPRequest,
undefined,
{
@@ -60,7 +60,7 @@ export const completeSRPSetup = async (
) => {
try {
const resp = await HTTPService.post(
`${ENDPOINT}/users/srp/complete`,
`${apiOrigin()}/users/srp/complete`,
completeSRPSetupRequest,
undefined,
{
@@ -77,7 +77,7 @@ export const completeSRPSetup = async (
export const createSRPSession = async (srpUserID: string, srpA: string) => {
try {
const resp = await HTTPService.post(
`${ENDPOINT}/users/srp/create-session`,
`${apiOrigin()}/users/srp/create-session`,
{
srpUserID,
srpA,
@@ -97,7 +97,7 @@ export const verifySRPSession = async (
) => {
try {
const resp = await HTTPService.post(
`${ENDPOINT}/users/srp/verify-session`,
`${apiOrigin()}/users/srp/verify-session`,
{
sessionID,
srpUserID,
@@ -125,7 +125,7 @@ export const updateSRPAndKeys = async (
): Promise<UpdateSRPAndKeysResponse> => {
try {
const resp = await HTTPService.post(
`${ENDPOINT}/users/srp/update`,
`${apiOrigin()}/users/srp/update`,
updateSRPAndKeyRequest,
undefined,
{

View File

@@ -1,3 +1,4 @@
import { apiOrigin } from "@/next/origins";
import type { AppName } from "@/next/types/app";
import type {
RecoveryKey,
@@ -9,15 +10,12 @@ import type {
import type { B64EncryptionResult } from "@ente/shared/crypto/types";
import { ApiError, CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import type { KeyAttributes } from "@ente/shared/user/types";
import { HttpStatusCode } from "axios";
const ENDPOINT = getEndpoint();
export const sendOtt = (appName: AppName, email: string) => {
return HTTPService.post(`${ENDPOINT}/users/ott`, {
return HTTPService.post(`${apiOrigin()}/users/ott`, {
email,
client: appName == "auth" ? "totp" : "web",
});
@@ -25,7 +23,7 @@ export const sendOtt = (appName: AppName, email: string) => {
export const verifyOtt = (email: string, ott: string, referral: string) => {
const cleanedReferral = `web:${referral?.trim() || ""}`;
return HTTPService.post(`${ENDPOINT}/users/verify-email`, {
return HTTPService.post(`${apiOrigin()}/users/verify-email`, {
email,
ott,
source: cleanedReferral,
@@ -34,7 +32,7 @@ export const verifyOtt = (email: string, ott: string, referral: string) => {
export const putAttributes = (token: string, keyAttributes: KeyAttributes) =>
HTTPService.put(
`${ENDPOINT}/users/attributes`,
`${apiOrigin()}/users/attributes`,
{ keyAttributes },
undefined,
{
@@ -45,7 +43,7 @@ export const putAttributes = (token: string, keyAttributes: KeyAttributes) =>
export const logout = async () => {
try {
const token = getToken();
await HTTPService.post(`${ENDPOINT}/users/logout`, null, undefined, {
await HTTPService.post(`${apiOrigin()}/users/logout`, null, undefined, {
"X-Auth-Token": token,
});
} catch (e) {
@@ -65,10 +63,13 @@ export const logout = async () => {
};
export const verifyTwoFactor = async (code: string, sessionID: string) => {
const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/verify`, {
code,
sessionID,
});
const resp = await HTTPService.post(
`${apiOrigin()}/users/two-factor/verify`,
{
code,
sessionID,
},
);
return resp.data as UserVerificationResponse;
};
@@ -79,10 +80,13 @@ export const recoverTwoFactor = async (
sessionID: string,
twoFactorType: TwoFactorType,
) => {
const resp = await HTTPService.get(`${ENDPOINT}/users/two-factor/recover`, {
sessionID,
twoFactorType,
});
const resp = await HTTPService.get(
`${apiOrigin()}/users/two-factor/recover`,
{
sessionID,
twoFactorType,
},
);
return resp.data as TwoFactorRecoveryResponse;
};
@@ -91,17 +95,20 @@ export const removeTwoFactor = async (
secret: string,
twoFactorType: TwoFactorType,
) => {
const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/remove`, {
sessionID,
secret,
twoFactorType,
});
const resp = await HTTPService.post(
`${apiOrigin()}/users/two-factor/remove`,
{
sessionID,
secret,
twoFactorType,
},
);
return resp.data as TwoFactorVerificationResponse;
};
export const changeEmail = async (email: string, ott: string) => {
await HTTPService.post(
`${ENDPOINT}/users/change-email`,
`${apiOrigin()}/users/change-email`,
{
email,
ott,
@@ -114,7 +121,7 @@ export const changeEmail = async (email: string, ott: string) => {
};
export const sendOTTForEmailChange = async (email: string) => {
await HTTPService.post(`${ENDPOINT}/users/ott`, {
await HTTPService.post(`${apiOrigin()}/users/ott`, {
email,
client: "web",
purpose: "change",
@@ -123,7 +130,7 @@ export const sendOTTForEmailChange = async (email: string) => {
export const setupTwoFactor = async () => {
const resp = await HTTPService.post(
`${ENDPOINT}/users/two-factor/setup`,
`${apiOrigin()}/users/two-factor/setup`,
null,
undefined,
{
@@ -138,7 +145,7 @@ export const enableTwoFactor = async (
recoveryEncryptedTwoFactorSecret: B64EncryptionResult,
) => {
await HTTPService.post(
`${ENDPOINT}/users/two-factor/enable`,
`${apiOrigin()}/users/two-factor/enable`,
{
code,
encryptedTwoFactorSecret:
@@ -154,13 +161,18 @@ export const enableTwoFactor = async (
};
export const setRecoveryKey = (token: string, recoveryKey: RecoveryKey) =>
HTTPService.put(`${ENDPOINT}/users/recovery-key`, recoveryKey, undefined, {
"X-Auth-Token": token,
});
HTTPService.put(
`${apiOrigin()}/users/recovery-key`,
recoveryKey,
undefined,
{
"X-Auth-Token": token,
},
);
export const disableTwoFactor = async () => {
await HTTPService.post(
`${ENDPOINT}/users/two-factor/disable`,
`${apiOrigin()}/users/two-factor/disable`,
null,
undefined,
{

View File

@@ -7,7 +7,7 @@ import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
import { LS_KEYS, setData } from "@ente/shared/storage/localStorage";
import { Input } from "@mui/material";
import { Input, Stack, Typography } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import { getSRPAttributes } from "../api/srp";
@@ -17,9 +17,11 @@ import { PAGES } from "../constants/pages";
interface LoginProps {
signUp: () => void;
appName: AppName;
/** Reactive value of {@link customAPIHost}. */
host: string | undefined;
}
export function Login({ appName, signUp }: LoginProps) {
export function Login({ appName, signUp, host }: LoginProps) {
const router = useRouter();
const loginUser: SingleInputFormProps["callback"] = async (
@@ -63,7 +65,17 @@ export function Login({ appName, signUp }: LoginProps) {
/>
<FormPaperFooter>
<LinkButton onClick={signUp}>{t("NO_ACCOUNT")}</LinkButton>
<Stack gap={4}>
<LinkButton onClick={signUp}>{t("NO_ACCOUNT")}</LinkButton>
<Typography
variant="mini"
color="text.faint"
minHeight={"32px"}
>
{host ?? "" /* prevent layout shift with a minHeight */}
</Typography>
</Stack>
</FormPaperFooter>
</>
);

View File

@@ -31,6 +31,7 @@ import {
IconButton,
InputAdornment,
Link,
Stack,
TextField,
Tooltip,
Typography,
@@ -53,9 +54,11 @@ interface SignUpProps {
router: NextRouter;
login: () => void;
appName: AppName;
/** Reactive value of {@link customAPIHost}. */
host: string | undefined;
}
export function SignUp({ router, appName, login }: SignUpProps) {
export function SignUp({ router, appName, login, host }: SignUpProps) {
const [acceptTerms, setAcceptTerms] = useState(false);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -310,7 +313,19 @@ export function SignUp({ router, appName, login }: SignUpProps) {
</Formik>
<FormPaperFooter>
<LinkButton onClick={login}>{t("ACCOUNT_EXISTS")}</LinkButton>
<Stack gap={4}>
<LinkButton onClick={login}>
{t("ACCOUNT_EXISTS")}
</LinkButton>
<Typography
variant="mini"
color="text.faint"
minHeight={"32px"}
>
{host ?? "" /* prevent layout shift with a minHeight */}
</Typography>
</Stack>
</FormPaperFooter>
</>
);

View File

@@ -1,13 +1,11 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
import { ensure } from "@/utils/ensure";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer";
import LinkButton from "@ente/shared/components/LinkButton";
import {
ConnectionDetails,
LoginFlowFormFooter,
PasswordHeader,
VerifyingPasskey,
} from "@ente/shared/components/LoginComponents";
@@ -42,6 +40,7 @@ import {
setKey,
} from "@ente/shared/storage/sessionStorage";
import type { KeyAttributes, User } from "@ente/shared/user/types";
import { Stack } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
@@ -321,16 +320,16 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
srpAttributes={srpAttributes}
/>
<FormPaperFooter style={{ justifyContent: "space-between" }}>
<LinkButton onClick={() => router.push(PAGES.RECOVER)}>
{t("FORGOT_PASSWORD")}
</LinkButton>
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</FormPaperFooter>
{isDevBuild && <ConnectionDetails />}
<LoginFlowFormFooter>
<Stack direction="row" justifyContent="space-between">
<LinkButton onClick={() => router.push(PAGES.RECOVER)}>
{t("FORGOT_PASSWORD")}
</LinkButton>
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</Stack>
</LoginFlowFormFooter>
</FormPaper>
</VerticallyCentered>
);

View File

@@ -1,3 +1,4 @@
import { customAPIHost } from "@/next/origins";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import FormPaper from "@ente/shared/components/Form/FormPaper";
@@ -15,6 +16,8 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const router = useRouter();
const host = customAPIHost();
useEffect(() => {
const user = getData(LS_KEYS.USER);
if (user?.email) {
@@ -24,7 +27,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
showNavBar(true);
}, []);
const register = () => {
const signUp = () => {
router.push(PAGES.SIGNUP);
};
@@ -35,7 +38,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
) : (
<VerticallyCentered>
<FormPaper>
<Login signUp={register} appName={appName} />
<Login {...{ appName, signUp, host }} />
</FormPaper>
</VerticallyCentered>
);

View File

@@ -1,3 +1,4 @@
import { customAPIHost } from "@/next/origins";
import { PAGES } from "@ente/accounts/constants/pages";
import { LS_KEYS, getData } from "@ente/shared//storage/localStorage";
import { VerticallyCentered } from "@ente/shared/components/Container";
@@ -15,6 +16,8 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const router = useRouter();
const host = customAPIHost();
useEffect(() => {
const user = getData(LS_KEYS.USER);
if (user?.email) {
@@ -34,7 +37,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
<EnteSpinner />
) : (
<FormPaper>
<SignUp login={login} router={router} appName={appName} />
<SignUp {...{ appName, login, router, host }} />
</FormPaper>
)}
</VerticallyCentered>

View File

@@ -3,10 +3,12 @@ import type { UserVerificationResponse } from "@ente/accounts/types/user";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import FormPaperFooter from "@ente/shared/components/Form/FormPaper/Footer";
import FormPaperTitle from "@ente/shared/components/Form/FormPaper/Title";
import LinkButton from "@ente/shared/components/LinkButton";
import { VerifyingPasskey } from "@ente/shared/components/LoginComponents";
import {
LoginFlowFormFooter,
VerifyingPasskey,
} from "@ente/shared/components/LoginComponents";
import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
@@ -20,7 +22,7 @@ import {
} from "@ente/shared/storage/localStorage/helpers";
import { clearKeys } from "@ente/shared/storage/sessionStorage";
import type { KeyAttributes, User } from "@ente/shared/user/types";
import { Box, Typography } from "@mui/material";
import { Box, Stack, Typography } from "@mui/material";
import { HttpStatusCode } from "axios";
import { t } from "i18next";
import { useRouter } from "next/router";
@@ -225,18 +227,20 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
callback={onSubmit}
/>
<FormPaperFooter style={{ justifyContent: "space-between" }}>
{resend === 0 && (
<LinkButton onClick={resendEmail}>
{t("RESEND_MAIL")}
<LoginFlowFormFooter>
<Stack direction="row" justifyContent="space-between">
{resend === 0 && (
<LinkButton onClick={resendEmail}>
{t("RESEND_MAIL")}
</LinkButton>
)}
{resend === 1 && <span>{t("SENDING")}</span>}
{resend === 2 && <span>{t("SENT")}</span>}
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
)}
{resend === 1 && <span>{t("SENDING")}</span>}
{resend === 2 && <span>{t("SENT")}</span>}
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</FormPaperFooter>
</Stack>
</LoginFlowFormFooter>
</FormPaper>
</VerticallyCentered>
);

View File

@@ -1,5 +1,6 @@
import { clientPackageHeaderIfPresent } from "@/next/http";
import log from "@/next/log";
import { accountsAppOrigin, apiOrigin } from "@/next/origins";
import type { AppName } from "@/next/types/app";
import { clientPackageName } from "@/next/types/app";
import { TwoFactorAuthorizationResponse } from "@/next/types/credentials";
@@ -12,7 +13,6 @@ import {
} from "@ente/shared/crypto/internal/libsodium";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { accountsAppURL, apiOrigin } from "@ente/shared/network/api";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
@@ -48,7 +48,7 @@ export const passkeyVerificationRedirectURL = (
redirect,
...recoverOption,
});
return `${accountsAppURL()}/passkeys/verify?${params.toString()}`;
return `${accountsAppOrigin()}/passkeys/verify?${params.toString()}`;
};
interface OpenPasskeyVerificationURLOptions {
@@ -131,7 +131,7 @@ export const openAccountsManagePasskeysPage = async () => {
const token = await getAccountsToken();
const params = new URLSearchParams({ token });
window.open(`${accountsAppURL()}/passkeys?${params.toString()}`);
window.open(`${accountsAppOrigin()}/passkeys?${params.toString()}`);
};
export const isPasskeyRecoveryEnabled = async () => {

View File

@@ -6,6 +6,7 @@
"@/next": "*",
"@/utils": "*",
"@ente/shared": "*",
"formik": "^2.4",
"zod": "^3"
},
"devDependencies": {}

View File

@@ -0,0 +1,182 @@
import log from "@/next/log";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
InputAdornment,
Link,
TextField,
useMediaQuery,
type ModalProps,
} from "@mui/material";
import { useFormik } from "formik";
import { t } from "i18next";
import React from "react";
import { z } from "zod";
import { FocusVisibleButton } from "./FocusVisibleButton";
import { SlideTransition } from "./SlideTransition";
interface DevSettingsProps {
/** If `true`, then the dialog is shown. */
open: boolean;
/** Called when the dialog wants to be closed. */
onClose: () => void;
}
/**
* A dialog allowing the user to set the API origin that the app connects to.
* See: [Note: Configuring custom server].
*/
export const DevSettings: React.FC<DevSettingsProps> = ({ open, onClose }) => {
const fullScreen = useMediaQuery("(max-width: 428px)");
const handleDialogClose: ModalProps["onClose"] = (_, reason: string) => {
// Don't close on backdrop clicks.
if (reason != "backdropClick") onClose();
};
const form = useFormik({
initialValues: {
apiOrigin: localStorage.getItem("apiOrigin") ?? "",
},
validate: ({ apiOrigin }) => {
try {
apiOrigin && new URL(apiOrigin);
} catch {
return { apiOrigin: "Invalid endpoint" };
}
return {};
},
onSubmit: async (values, { setSubmitting, setErrors }) => {
try {
await updateAPIOrigin(values.apiOrigin);
} catch (e) {
// The person using this functionality is likely a developer and
// might be helped more by the original error instead of a
// friendlier but less specific message.
setErrors({
apiOrigin: e instanceof Error ? e.message : String(e),
});
return;
}
setSubmitting(false);
onClose();
},
});
return (
<Dialog
{...{ open, fullScreen }}
onClose={handleDialogClose}
TransitionComponent={SlideTransition}
maxWidth="xs"
>
<form onSubmit={form.handleSubmit}>
<DialogTitle>{t("developer_settings")}</DialogTitle>
<DialogContent
sx={{
"&&": {
paddingBlock: "8px",
},
}}
>
<TextField
fullWidth
autoFocus
id="apiOrigin"
name="apiOrigin"
label={t("server_endpoint")}
placeholder="http://localhost:8080"
value={form.values.apiOrigin}
onChange={form.handleChange}
onBlur={form.handleBlur}
error={
form.touched.apiOrigin && !!form.errors.apiOrigin
}
helperText={
(form.touched.apiOrigin && form.errors.apiOrigin) ??
" " /* always show an empty string to prevent a layout shift */
}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Link
href="https://help.ente.io/self-hosting/guides/custom-server/"
target="_blank"
rel="noopener"
>
<IconButton
aria-label={t("more_information")}
color="secondary"
edge="end"
>
<InfoOutlinedIcon />
</IconButton>
</Link>
</InputAdornment>
),
}}
/>
</DialogContent>
<DialogActions>
<FocusVisibleButton
type="submit"
color="accent"
fullWidth
disabled={form.isSubmitting}
disableRipple
>
{t("save")}
</FocusVisibleButton>
<FocusVisibleButton
onClick={onClose}
color="secondary"
fullWidth
disableRipple
>
{t("CANCEL")}
</FocusVisibleButton>
</DialogActions>
</form>
</Dialog>
);
};
/**
* Save {@link origin} to local storage after verifying it with a ping.
*
* The given {@link origin} will be verifying by making an API call to the
* `/ping` endpoint. If that succeeds, then it will be saved to local storage,
* and all subsequent API calls will use it as the {@link apiOrigin}.
*
* See: [Note: Configuring custom server].
*
* @param origin The new API origin to use. Pass an empty string to clear the
* previously saved API origin (if any).
*/
const updateAPIOrigin = async (origin: string) => {
if (!origin) {
localStorage.removeItem("apiOrigin");
return;
}
const url = `${origin}/ping`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
try {
PingResponse.parse(await res.json());
} catch (e) {
log.error("Invalid response", e);
throw new Error("Invalid response");
}
localStorage.setItem("apiOrigin", origin);
};
const PingResponse = z.object({
message: z.enum(["pong"]),
});

View File

@@ -0,0 +1,10 @@
import { Button, styled } from "@mui/material";
/** A MUI {@link Button} that shows a keyboard focus indicator. */
export const FocusVisibleButton = styled(Button)`
/* Show an outline when the button gains keyboard focus, e.g. when the user
tabs to it. */
&.Mui-focusVisible {
outline: 1px solid #aaa;
}
`;

View File

@@ -0,0 +1,16 @@
import Slide from "@mui/material/Slide";
import type { TransitionProps } from "@mui/material/transitions";
import React from "react";
/**
* A React component that can be passed as the `TransitionComponent` props to a
* MUI {@link Dialog} to get it to use a slide transition (default is fade).
*/
export const SlideTransition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});

View File

@@ -1,6 +1,6 @@
import { ut } from "@/next/i18n";
import ArrowForward from "@mui/icons-material/ArrowForward";
import {
Button,
Dialog,
DialogActions,
DialogContent,
@@ -10,21 +10,21 @@ import {
styled,
useMediaQuery,
} from "@mui/material";
import Slide from "@mui/material/Slide";
import type { TransitionProps } from "@mui/material/transitions";
import React, { useEffect } from "react";
import { didShowWhatsNew } from "../services/changelog";
import { FocusVisibleButton } from "./FocusVisibleButton";
import { SlideTransition } from "./SlideTransition";
interface WhatsNewProps {
/** If `true`, then the dialog is shown. */
open: boolean;
/** Callback to invoke when the dialog wants to be closed. */
/** Called when the dialog wants to be closed. */
onClose: () => void;
}
/**
* Show a dialog showing a short summary of interesting-for-the-user things
* since the last time this dialog was shown.
* A dialog showing a short summary of interesting-for-the-user things since the
* last time this dialog was shown.
*/
export const WhatsNew: React.FC<WhatsNewProps> = ({ open, onClose }) => {
const fullScreen = useMediaQuery("(max-width: 428px)");
@@ -39,36 +39,27 @@ export const WhatsNew: React.FC<WhatsNewProps> = ({ open, onClose }) => {
TransitionComponent={SlideTransition}
maxWidth="xs"
>
<DialogTitle>{"What's new"}</DialogTitle>
<DialogTitle>{ut("What's new")}</DialogTitle>
<DialogContent>
<DialogContentText>
<ChangelogContent />
</DialogContentText>
</DialogContent>
<DialogActions>
<StyledButton
<FocusVisibleButton
onClick={onClose}
color="accent"
fullWidth
disableRipple
endIcon={<ArrowForward />}
>
<ButtonContents>{"Continue"}</ButtonContents>
</StyledButton>
<ButtonContents>{ut("Continue")}</ButtonContents>
</FocusVisibleButton>
</DialogActions>
</Dialog>
);
};
const SlideTransition = React.forwardRef(function Transition(
props: TransitionProps & {
children: React.ReactElement;
},
ref: React.Ref<unknown>,
) {
return <Slide direction="up" ref={ref} {...props} />;
});
const ChangelogContent: React.FC = () => {
// NOTE: Remember to update changelogVersion when changing the content
// below.
@@ -78,16 +69,19 @@ const ChangelogContent: React.FC = () => {
<li>
<Typography>
<Typography color="primary">
Support for Passkeys
{ut("Support for Passkeys")}
</Typography>
Passkeys can now be used as a second factor authentication
mechanism.
{ut(
"Passkeys can now be used as a second factor authentication mechanism.",
)}
</Typography>
</li>
<li>
<Typography color="primary">Window size</Typography>
<Typography color="primary">{ut("Window size")}</Typography>
<Typography>
{"The app's window will remember its size and position."}
{ut(
"The app's window will remember its size and position.",
)}
</Typography>
</li>
</StyledUL>
@@ -102,14 +96,6 @@ const StyledUL = styled("ul")`
}
`;
const StyledButton = styled(Button)`
/* Show an outline when the button gains keyboard focus, e.g. when the user
tabs to it. */
&.Mui-focusVisible {
outline: 1px solid #aaa;
}
`;
const ButtonContents = styled("div")`
/* Make the button text fill the entire space so the endIcon shows at the
trailing edge of the button. */

View File

@@ -2,8 +2,8 @@ import { isDevBuild } from "@/next/env";
import { authenticatedRequestHeaders } from "@/next/http";
import { localUser } from "@/next/local-user";
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import { nullToUndefined } from "@/utils/transform";
import { apiOrigin } from "@ente/shared/network/api";
import { z } from "zod";
let _fetchTimeout: ReturnType<typeof setTimeout> | undefined;

View File

@@ -261,3 +261,15 @@ export const setLocaleInUse = async (locale: SupportedLocale) => {
localStorage.setItem("locale", locale);
return i18n.changeLanguage(locale);
};
/**
* A no-op marker for strings that, for various reasons, are not translated.
*
* This function does nothing, it just returns back the passed it string
* verbatim. It is only kept as a way for us to keep track of strings that are
* not translated (and for some reason, are currently not meant to be), but
* still are user visible.
*
* It is the sibling of the {@link t} function provided by i18next.
*/
export const ut = (s: string) => s;

View File

@@ -639,5 +639,9 @@
"redirect_close_instructions": "You can close this window after the app opens.",
"redirect_again": "Redirect again",
"autogenerated_first_album_name": "My First Album",
"autogenerated_default_album_name": "New Album"
"autogenerated_default_album_name": "New Album",
"developer_settings": "Developer settings",
"server_endpoint": "Server endpoint",
"more_information": "More information",
"save": "Save"
}

View File

@@ -0,0 +1,87 @@
import { nullToUndefined } from "@/utils/transform";
/**
* Return the origin (scheme, host, port triple) that should be used for making
* API requests to museum.
*
* This defaults "https://api.ente.io", Ente's production API servers. but can
* be overridden when self hosting or developing (see {@link customAPIOrigin}).
*/
export const apiOrigin = () => customAPIOrigin() ?? "https://api.ente.io";
/**
* Return the overridden API origin, if one is defined by either (in priority
* order):
*
* - Setting the custom server on the landing page (See: [Note: Configuring
* custom server]); or by
*
* - Setting the `NEXT_PUBLIC_ENTE_ENDPOINT` environment variable.
*
* Otherwise return undefined.
*/
export const customAPIOrigin = () =>
nullToUndefined(localStorage.getItem("apiOrigin")) ??
process.env.NEXT_PUBLIC_ENTE_ENDPOINT ??
undefined;
/**
* A convenience wrapper over {@link customAPIOrigin} that returns the only the
* host part of the custom origin (if any).
*
* This is useful in places where we indicate the custom origin in the UI.
*/
export const customAPIHost = () => {
const origin = customAPIOrigin();
return origin ? new URL(origin).host : undefined;
};
/**
* Return the origin that should be used for uploading files.
*
* This defaults to `https://uploader.ente.io`, serviced by a Cloudflare worker
* (see infra/workers/uploader). But if a {@link customAPIOrigin} is set then
* this value is set to the {@link customAPIOrigin} itself, effectively
* bypassing the Cloudflare worker for non-Ente deployments.
*/
export const uploaderOrigin = () =>
customAPIOrigin() ?? "https://uploader.ente.io";
/**
* Return the origin that serves the accounts app.
*
* Defaults to our production instance, "https://accounts.ente.io", but can be
* overridden by setting the `NEXT_PUBLIC_ENTE_ACCOUNTS_URL` environment
* variable.
*/
export const accountsAppOrigin = () =>
process.env.NEXT_PUBLIC_ENTE_ACCOUNTS_URL ?? `https://accounts.ente.io`;
/**
* Return the origin that serves public albums.
*
* Defaults to our production instance, "https://albums.ente.io", but can be
* overridden by setting the `NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT` environment
* variable.
*/
export const albumsAppOrigin = () =>
process.env.NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT ?? "https://albums.ente.io";
/**
* Return the origin that serves the family dashboard which can be used to
* create or manage family plans..
*
* Defaults to our production instance, "https://family.ente.io", but can be
* overridden by setting the `NEXT_PUBLIC_ENTE_FAMILY_URL` environment variable.
*/
export const familyAppOrigin = () =>
process.env.NEXT_PUBLIC_ENTE_FAMILY_URL ?? "https://family.ente.io";
/**
* Return the origin that serves the payments app.
*
* Defaults to our production instance, "https://payments.ente.io", but can be
* overridden by setting the `NEXT_PUBLIC_ENTE_PAYMENTS_URL` environment variable.
*/
export const paymentsAppOrigin = () =>
process.env.NEXT_PUBLIC_ENTE_PAYMENTS_URL ?? "https://payments.ente.io";

View File

@@ -1,5 +1,5 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
import { customAPIHost } from "@/next/origins";
import type { BaseAppContextT } from "@/next/types/app";
import {
checkPasskeyVerificationStatus,
@@ -7,8 +7,7 @@ import {
saveCredentialsAndNavigateTo,
} from "@ente/accounts/services/passkey";
import EnteButton from "@ente/shared/components/EnteButton";
import { apiOrigin } from "@ente/shared/network/api";
import { CircularProgress, Typography, styled } from "@mui/material";
import { CircularProgress, Stack, Typography, styled } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import React, { useState } from "react";
@@ -46,22 +45,26 @@ const Header_ = styled("div")`
gap: 8px;
`;
export const ConnectionDetails: React.FC = () => {
const host = new URL(apiOrigin()).host;
export const LoginFlowFormFooter: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const host = customAPIHost();
return (
<ConnectionDetails_>
<Typography variant="small" color="text.faint">
{host}
</Typography>
</ConnectionDetails_>
<FormPaperFooter>
<Stack gap="16px" width="100%" textAlign="start">
{children}
{host && (
<Typography variant="small" color="text.faint">
{host}
</Typography>
)}
</Stack>
</FormPaperFooter>
);
};
const ConnectionDetails_ = styled("div")`
margin-block-start: 1rem;
`;
interface VerifyingPasskeyProps {
/** ID of the current passkey verification session. */
passkeySessionID: string;
@@ -161,16 +164,16 @@ export const VerifyingPasskey: React.FC<VerifyingPasskeyProps> = ({
</ButtonStack>
</VerifyingPasskeyMiddle>
<FormPaperFooter style={{ justifyContent: "space-between" }}>
<LinkButton onClick={handleRecover}>
{t("RECOVER_ACCOUNT")}
</LinkButton>
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</FormPaperFooter>
{isDevBuild && <ConnectionDetails />}
<LoginFlowFormFooter>
<Stack direction="row" justifyContent="space-between">
<LinkButton onClick={handleRecover}>
{t("RECOVER_ACCOUNT")}
</LinkButton>
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</Stack>
</LoginFlowFormFooter>
</FormPaper>
</VerticallyCentered>
);

View File

@@ -1,70 +0,0 @@
/**
* Return the origin (scheme, host, port triple) that should be used for making
* API requests to museum.
*
* This defaults to "https://api.ente.io", Ente's own servers, but can be
* overridden when self hosting or developing by setting the
* `NEXT_PUBLIC_ENTE_ENDPOINT` environment variable.
*/
export const apiOrigin = () => customAPIOrigin() ?? "https://api.ente.io";
/**
* Return the overridden API origin, if one is defined by setting the
* `NEXT_PUBLIC_ENTE_ENDPOINT` environment variable.
*
* Otherwise return undefined.
*/
export const customAPIOrigin = () =>
process.env.NEXT_PUBLIC_ENTE_ENDPOINT ?? undefined;
/** Deprecated, use {@link apiOrigin} instead. */
export const getEndpoint = apiOrigin;
export const getUploadEndpoint = () => {
const endpoint = process.env.NEXT_PUBLIC_ENTE_ENDPOINT;
if (endpoint) {
return endpoint;
}
return `https://uploader.ente.io`;
};
/**
* Return the URL of the Ente Accounts app.
*
* Defaults to our production instance, "https://accounts.ente.io", but can be
* overridden by setting the `NEXT_PUBLIC_ENTE_ACCOUNTS_URL` environment
* variable.
*/
export const accountsAppURL = () =>
process.env.NEXT_PUBLIC_ENTE_ACCOUNTS_URL ?? `https://accounts.ente.io`;
export const getAlbumsURL = () => {
const albumsURL = process.env.NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT;
if (albumsURL) {
return albumsURL;
}
return `https://albums.ente.io`;
};
/**
* Return the URL for the family dashboard which can be used to create or manage
* family plans.
*/
export const getFamilyPortalURL = () => {
const familyURL = process.env.NEXT_PUBLIC_ENTE_FAMILY_URL;
if (familyURL) {
return familyURL;
}
return `https://family.ente.io`;
};
/**
* Return the URL for the host that handles payment related functionality.
*/
export const getPaymentsURL = () => {
const paymentsURL = process.env.NEXT_PUBLIC_ENTE_PAYMENTS_URL;
if (paymentsURL) {
return paymentsURL;
}
return `https://payments.ente.io`;
};

View File

@@ -1,8 +1,8 @@
import log from "@/next/log";
import { apiOrigin } from "@/next/origins";
import { ApiError } from "../error";
import { getToken } from "../storage/localStorage/helpers";
import HTTPService from "./HTTPService";
import { getEndpoint } from "./api";
class CastGateway {
constructor() {}
@@ -11,7 +11,7 @@ class CastGateway {
let resp;
try {
resp = await HTTPService.get(
`${getEndpoint()}/cast/cast-data/${code}`,
`${apiOrigin()}/cast/cast-data/${code}`,
);
} catch (e) {
log.error("failed to getCastData", e);
@@ -24,7 +24,7 @@ class CastGateway {
try {
const token = getToken();
await HTTPService.delete(
getEndpoint() + "/cast/revoke-all-tokens/",
apiOrigin() + "/cast/revoke-all-tokens/",
undefined,
undefined,
{
@@ -42,7 +42,7 @@ class CastGateway {
try {
const token = getToken();
resp = await HTTPService.get(
`${getEndpoint()}/cast/device-info/${code}`,
`${apiOrigin()}/cast/device-info/${code}`,
undefined,
{
"X-Auth-Token": token,
@@ -60,7 +60,7 @@ class CastGateway {
public async registerDevice(publicKey: string): Promise<string> {
const resp = await HTTPService.post(
getEndpoint() + "/cast/device-info/",
apiOrigin() + "/cast/device-info/",
{
publicKey: publicKey,
},
@@ -76,7 +76,7 @@ class CastGateway {
) {
const token = getToken();
await HTTPService.post(
getEndpoint() + "/cast/cast-data/",
apiOrigin() + "/cast/cast-data/",
{
deviceCode: `${code}`,
encPayload: castPayload,

View File

@@ -18,7 +18,6 @@ export enum LS_KEYS {
COLLECTION_SORT_BY = "collectionSortBy",
THEME = "theme",
WAIT_TIME = "waitTime",
API_ENDPOINT = "apiEndpoint",
// Moved to the new wrapper @/next/local-storage
// LOCALE = 'locale',
MAP_ENABLED = "mapEnabled",

View File

@@ -2611,10 +2611,10 @@ form-data@^4.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
formik@^2.1.5:
version "2.4.5"
resolved "https://registry.yarnpkg.com/formik/-/formik-2.4.5.tgz#f899b5b7a6f103a8fabb679823e8fafc7e0ee1b4"
integrity sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==
formik@^2.4:
version "2.4.6"
resolved "https://registry.yarnpkg.com/formik/-/formik-2.4.6.tgz#4da75ca80f1a827ab35b08fd98d5a76e928c9686"
integrity sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==
dependencies:
"@types/hoist-non-react-statics" "^3.3.1"
deepmerge "^2.1.1"
@@ -4375,7 +4375,16 @@ streamsearch@^1.1.0:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -4495,7 +4504,14 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -4904,7 +4920,16 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==