Merge remote-tracking branch 'origin/main' into custom_domain_handling
This commit is contained in:
12
.github/workflows/web-deploy.yml
vendored
12
.github/workflows/web-deploy.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("&");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user