Merge remote-tracking branch 'origin/main' into custom_domain_handling

This commit is contained in:
Neeraj Gupta
2025-08-12 13:50:26 +05:30
6 changed files with 286 additions and 5 deletions

View File

@@ -54,6 +54,18 @@ jobs:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: pages deploy --project-name=ente --commit-dirty=true --branch=deploy/photos web/apps/photos/out
- name: Build custom-albums
run: yarn build:photos
env:
NEXT_PUBLIC_ENTE_ONLY_SERVE_ALBUMS_APP: 1
- name: Publish custom-albums
uses: cloudflare/wrangler-action@v3
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: pages deploy --project-name=ente --commit-dirty=true --branch=deploy/custom-albums web/apps/photos/out
- name: Build accounts
run: yarn build:accounts

View File

@@ -58,6 +58,7 @@ import type {
import { type CollectionUser } from "ente-media/collection";
import type { RemotePullOpts } from "ente-new/photos/components/gallery";
import { PublicLinkCreated } from "ente-new/photos/components/share/PublicLinkCreated";
import { useSettingsSnapshot } from "ente-new/photos/components/utils/use-snapshot";
import { avatarTextColor } from "ente-new/photos/services/avatar";
import {
createPublicURL,
@@ -1105,6 +1106,8 @@ const PublicShare: React.FC<PublicShareProps> = ({
setBlockingLoad,
onRemotePull,
}) => {
const { customDomain } = useSettingsSnapshot();
const {
show: showPublicLinkCreated,
props: publicLinkCreatedVisibilityProps,
@@ -1126,11 +1129,15 @@ const PublicShare: React.FC<PublicShareProps> = ({
void appendCollectionKeyToShareURL(
publicURL.url,
collection.key,
).then((url) => setResolvedURL(url));
).then((url) =>
setResolvedURL(
substituteCustomDomainIfNeeded(url, customDomain),
),
);
} else {
setResolvedURL(undefined);
}
}, [collection.key, publicURL]);
}, [collection.key, publicURL, customDomain]);
const handleCopyLink = () => {
if (resolvedURL) void navigator.clipboard.writeText(resolvedURL);
@@ -1164,6 +1171,16 @@ const PublicShare: React.FC<PublicShareProps> = ({
);
};
const substituteCustomDomainIfNeeded = (
url: string,
customDomain: string | undefined,
) => {
if (!customDomain) return url;
const u = new URL(url);
u.host = customDomain;
return u.href;
};
type EnablePublicShareOptionsProps = {
setPublicURL: (value: PublicURL) => void;
onLinkCreated: () => void;

View File

@@ -17,6 +17,7 @@ import {
Skeleton,
Stack,
styled,
TextField,
Tooltip,
useColorScheme,
} from "@mui/material";
@@ -38,6 +39,7 @@ import {
import { SpacedRow } from "ente-base/components/containers";
import { DialogCloseIconButton } from "ente-base/components/mui/DialogCloseIconButton";
import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton";
import { LoadingButton } from "ente-base/components/mui/LoadingButton";
import {
SidebarDrawer,
TitledNestedSidebarDrawer,
@@ -49,8 +51,10 @@ import {
type ModalVisibilityProps,
} from "ente-base/components/utils/modal";
import { useBaseContext } from "ente-base/context";
import { isHTTPErrorWithStatus } from "ente-base/http";
import {
getLocaleInUse,
pt,
setLocaleInUse,
supportedLocales,
ut,
@@ -88,6 +92,7 @@ import {
isDevBuildAndUser,
pullSettings,
updateCFProxyDisabledPreference,
updateCustomDomain,
updateMapEnabled,
} from "ente-new/photos/services/settings";
import {
@@ -110,6 +115,7 @@ import {
import { usePhotosAppContext } from "ente-new/photos/types/context";
import { initiateEmail, openURL } from "ente-new/photos/utils/web";
import { wait } from "ente-utils/promise";
import { useFormik } from "formik";
import { t } from "i18next";
import { useRouter } from "next/router";
import React, {
@@ -776,6 +782,8 @@ const Preferences: React.FC<NestedSidebarDrawerVisibilityProps> = ({
onClose,
onRootClose,
}) => {
const { show: showDomainSettings, props: domainSettingsVisibilityProps } =
useModalVisibility();
const { show: showMapSettings, props: mapSettingsVisibilityProps } =
useModalVisibility();
const {
@@ -816,6 +824,50 @@ const Preferences: React.FC<NestedSidebarDrawerVisibilityProps> = ({
/>
</RowButtonGroup>
)}
{
/* TODO: CD */ process.env.NEXT_PUBLIC_ENTE_WIP_CD && (
<RowButton
label={pt("Custom domains")}
endIcon={
<Stack
direction="row"
sx={{
alignSelf: "stretch",
alignItems: "center",
}}
>
<Box
sx={{
width: "8px",
bgcolor: "stroke.faint",
alignSelf: "stretch",
mr: 0.5,
}}
/>
<Box
sx={{
width: "8px",
bgcolor: "stroke.muted",
alignSelf: "stretch",
mr: 0.5,
}}
/>
<Box
sx={{
width: "8px",
bgcolor: "stroke.base",
alignSelf: "stretch",
opacity: 0.3,
mr: 1.5,
}}
/>
<ChevronRightIcon />
</Stack>
}
onClick={showDomainSettings}
/>
)
}
<RowButton
endIcon={<ChevronRightIcon />}
label={t("map")}
@@ -836,6 +888,10 @@ const Preferences: React.FC<NestedSidebarDrawerVisibilityProps> = ({
</RowButtonGroup>
)}
</Stack>
<DomainSettings
{...domainSettingsVisibilityProps}
onRootClose={onRootClose}
/>
<MapSettings
{...mapSettingsVisibilityProps}
onRootClose={onRootClose}
@@ -954,6 +1010,152 @@ const ThemeSelector = () => {
);
};
const DomainSettings: React.FC<NestedSidebarDrawerVisibilityProps> = ({
open,
onClose,
onRootClose,
}) => {
const handleRootClose = () => {
onClose();
onRootClose();
};
return (
<TitledNestedSidebarDrawer
{...{ open, onClose }}
onRootClose={handleRootClose}
// TODO: CD: Translations
title={pt("Custom domains")}
// caption={pt("Your albums, your domain")}
caption="Use your own domain when sharing"
>
<DomainSettingsContents />
</TitledNestedSidebarDrawer>
);
};
// Separate component to reset state on back.
const DomainSettingsContents: React.FC = () => {
const { customDomain, customDomainCNAME } = useSettingsSnapshot();
const formik = useFormik({
initialValues: { domain: customDomain ?? "" },
onSubmit: async (values, { setFieldError }) => {
const domain = values.domain;
const setValueFieldError = (message: string) =>
setFieldError("domain", message);
try {
await updateCustomDomain(domain);
} catch (e) {
log.error(`Failed to submit input ${domain}`, e);
if (isHTTPErrorWithStatus(e, 400)) {
setValueFieldError(pt("Invalid domain"));
} else if (isHTTPErrorWithStatus(e, 409)) {
setValueFieldError(pt("Domain already linked by a user"));
} else {
setValueFieldError(t("generic_error"));
}
}
},
});
// TODO: CD: help
return (
<Stack sx={{ px: 2, py: "12px" }}>
<DomainItem title={pt("Link your domain")} ordinal={pt("1")}>
<form onSubmit={formik.handleSubmit}>
<TextField
name="domain"
value={formik.values.domain}
onChange={formik.handleChange}
type={"text"}
fullWidth
autoFocus={true}
margin="dense"
disabled={formik.isSubmitting}
error={!!formik.errors.domain}
helperText={
formik.errors.domain ??
pt("Any domain or subdomain you own")
}
label={t("Domain")}
placeholder={ut("photos.example.org")}
sx={{ mb: 2 }}
/>
<LoadingButton
fullWidth
type="submit"
loading={formik.isSubmitting}
color="accent"
>
{customDomain ? pt("Update") : pt("Save")}
</LoadingButton>
</form>
</DomainItem>
<Divider sx={{ mt: 4, mb: 2, opacity: 0.5 }} />
<DomainItem title={pt("Add DNS entry")} ordinal={pt("2")}>
<Typography sx={{ color: "text.muted" }}>
On your DNS provider, add a CNAME from your domain to{" "}
<Typography
component="span"
sx={{ fontWeight: "bold", color: "text.base" }}
>
{customDomainCNAME}
</Typography>
</Typography>
</DomainItem>
<Divider sx={{ mt: 5, mb: 2, opacity: 0.5 }} />
<DomainItem title={ut("🎉")} ordinal={pt("3")} isEmoji>
<Typography sx={{ color: "text.muted", mt: 2 }}>
Within 1 hour, your public albums will be accessible via
your domain!
</Typography>
<Typography sx={{ color: "text.muted", mt: 3 }}>
For more information, see
<Typography component="span" sx={{ color: "accent.main" }}>
{" help "}
</Typography>
</Typography>
</DomainItem>
</Stack>
);
};
interface DomainSectionProps {
title: string;
ordinal: string;
isEmoji?: boolean;
}
const DomainItem: React.FC<React.PropsWithChildren<DomainSectionProps>> = ({
title,
ordinal,
isEmoji,
children,
}) => (
<Stack>
<Stack
direction="row"
sx={{ alignItems: "center", justifyContent: "space-between" }}
>
<Typography variant={isEmoji ? "h3" : "h6"}>{title}</Typography>
<Typography
variant="h1"
sx={{
minWidth: "28px",
textAlign: "center",
color: "stroke.faint",
}}
>
{ordinal}
</Typography>
</Stack>
{children}
</Stack>
);
const MapSettings: React.FC<NestedSidebarDrawerVisibilityProps> = ({
open,
onClose,

View File

@@ -7,7 +7,11 @@ import { EnteLogo } from "ente-base/components/EnteLogo";
import { ActivityIndicator } from "ente-base/components/mui/ActivityIndicator";
import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton";
import { useBaseContext } from "ente-base/context";
import { albumsAppOrigin, customAPIHost } from "ente-base/origins";
import {
albumsAppOrigin,
customAPIHost,
shouldOnlyServeAlbumsApp,
} from "ente-base/origins";
import {
masterKeyFromSession,
updateSessionFromElectronSafeStorageIfNeeded,
@@ -41,7 +45,8 @@ const Page: React.FC = () => {
const albumsURL = new URL(albumsAppOrigin());
currentURL.pathname = router.pathname;
if (
currentURL.host == albumsURL.host &&
(shouldOnlyServeAlbumsApp ||
currentURL.host == albumsURL.host) &&
currentURL.pathname != "/shared-albums"
) {
const end = currentURL.hash.lastIndexOf("&");

View File

@@ -97,3 +97,9 @@ export const isCustomAlbumsAppOrigin =
*/
export const albumsAppOrigin = () =>
process.env.NEXT_PUBLIC_ENTE_ALBUMS_ENDPOINT ?? "https://albums.ente.io";
/**
* Return true if this build is meant to only serve public albums.
*/
export const shouldOnlyServeAlbumsApp =
!!process.env.NEXT_PUBLIC_ENTE_ONLY_SERVE_ALBUMS_APP;

View File

@@ -8,7 +8,11 @@ import log from "ente-base/log";
import { updateShouldDisableCFUploadProxy } from "ente-gallery/services/upload";
import { nullToUndefined } from "ente-utils/transform";
import { z } from "zod/v4";
import { fetchFeatureFlags, updateRemoteFlag } from "./remote-store";
import {
fetchFeatureFlags,
updateRemoteFlag,
updateRemoteValue,
} from "./remote-store";
/**
* In-memory flags that tracks various settings.
@@ -66,6 +70,24 @@ export interface Settings {
* Default: "https://cast.ente.io"
*/
castURL: string;
/**
* Set to the domain (host, e.g. "photos.example.org") that the user wishes
* to use for sharing their public albums.
*
* An empty string is treated as `undefined`.
*/
customDomain?: string;
/**
* The URL we should ask the user to CNAME their {@link customDomain} to
* for wiring up their domain to the public albums app.
*
* See also `apps.custom-domain.cname` in `server/local.yaml`.
*
* Default: "my.ente.io"
*/
customDomainCNAME: string;
}
const createDefaultSettings = (): Settings => ({
@@ -73,6 +95,7 @@ const createDefaultSettings = (): Settings => ({
mapEnabled: false,
cfUploadProxyDisabled: false,
castURL: "https://cast.ente.io",
customDomainCNAME: "my.ente.io",
});
/**
@@ -147,6 +170,8 @@ const FeatureFlags = z.object({
betaUser: z.boolean().nullish().transform(nullToUndefined),
mapEnabled: z.boolean().nullish().transform(nullToUndefined),
castUrl: z.string().nullish().transform(nullToUndefined),
customDomain: z.string().nullish().transform(nullToUndefined),
customDomainCNAME: z.string().nullish().transform(nullToUndefined),
});
type FeatureFlags = z.infer<typeof FeatureFlags>;
@@ -158,6 +183,9 @@ const syncSettingsSnapshotWithLocalStorage = () => {
settings.mapEnabled = flags?.mapEnabled || false;
settings.cfUploadProxyDisabled = savedCFProxyDisabled();
if (flags?.castUrl) settings.castURL = flags.castUrl;
if (flags?.customDomain) settings.customDomain = flags.customDomain;
if (flags?.customDomainCNAME)
settings.customDomainCNAME = flags.customDomainCNAME;
setSettingsSnapshot(settings);
};
@@ -198,6 +226,17 @@ export const isDevBuildAndUser = () => isDevBuild && isDevUserViaEmail();
const isDevUserViaEmail = () =>
!!savedPartialLocalUser()?.email?.endsWith("@ente.io");
/**
* Persist the user's custom domain preference both locally and on remote.
*
* Setting the value to a blank string is equivalent to deleting the custom
* domain value altogether.
*/
export const updateCustomDomain = async (customDomain: string) => {
await updateRemoteValue("customDomain", customDomain);
return pullSettings();
};
/**
* Persist the user's map enabled preference both locally and on remote.
*/