feat(user): self-service email change + per-extension editor memory

#11 — email change flow: POST /user/setting/email validates the new
address (format, domain filter, uniqueness) and current password, stores
the pending address in KV, and mails a signed confirmation link to the
new mailbox. GET /user/activate_email/:id verifies the signature and
applies the change once (KV entry consumed). New settings dialog on the
profile page plus a /session/activate_email confirmation page.

#7 — Monaco code viewer remembers charset and text-type per extension
(code_charset_/code_language_ session keys) and re-applies them on the
next open of the same file type; the charset menu now shows the active
encoding.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 606ee032d3
commit 1914fcbcdf

@ -42,6 +42,8 @@
"signUpSuccess": "Sign up successful", "signUpSuccess": "Sign up successful",
"activateSuccess": "Sign up completed", "activateSuccess": "Sign up completed",
"accountActivated": "Your account has been successfully activated.", "accountActivated": "Your account has been successfully activated.",
"emailChangedTitle": "Email changed",
"emailChanged": "Your email address has been changed successfully.",
"title": "Sign in to {{title}}", "title": "Sign in to {{title}}",
"sinUpTitle": "Sign up to {{title}}", "sinUpTitle": "Sign up to {{title}}",
"activateTitle": "Activate your account", "activateTitle": "Activate your account",
@ -858,6 +860,11 @@
"revokeOAuthGrant": "Revoke Authorization", "revokeOAuthGrant": "Revoke Authorization",
"revokeOAuthGrantConfirm": "Are you sure you want to revoke this app's authorization? Once revoked, this app will no longer be able to access your account.", "revokeOAuthGrantConfirm": "Are you sure you want to revoke this app's authorization? Once revoked, this app will no longer be able to access your account.",
"nickNameDes": "This is your public display name. It can be your real name or a pseudonym.", "nickNameDes": "This is your public display name. It can be your real name or a pseudonym.",
"changeEmail": "Change",
"changeEmailDes": "A confirmation link will be sent to the new address. Your current email ({{email}}) stays active until confirmed.",
"newEmail": "New email address",
"changeEmailPasswordDes": "Enter your current password to confirm.",
"emailChangeSent": "Confirmation email sent to the new address.",
"cropAvatar": "Crop avatar", "cropAvatar": "Crop avatar",
"preference": "Preference", "preference": "Preference",
"accountCreatedAt": "Created at <0></0>", "accountCreatedAt": "Created at <0></0>",

@ -42,6 +42,8 @@
"signUpSuccess": "注册成功", "signUpSuccess": "注册成功",
"activateSuccess": "激活成功", "activateSuccess": "激活成功",
"accountActivated": "您的账号已被成功激活", "accountActivated": "您的账号已被成功激活",
"emailChangedTitle": "邮箱已更换",
"emailChanged": "您的邮箱地址已成功更换。",
"title": "登录 {{title}}", "title": "登录 {{title}}",
"sinUpTitle": "注册 {{title}}", "sinUpTitle": "注册 {{title}}",
"activateTitle": "邮件激活", "activateTitle": "邮件激活",
@ -858,6 +860,11 @@
"revokeOAuthGrant": "撤销授权", "revokeOAuthGrant": "撤销授权",
"revokeOAuthGrantConfirm": "确定要撤销此应用的授权吗?撤销后,该应用将无法再访问你的账户。", "revokeOAuthGrantConfirm": "确定要撤销此应用的授权吗?撤销后,该应用将无法再访问你的账户。",
"nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称", "nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称",
"changeEmail": "更换",
"changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。",
"newEmail": "新邮箱地址",
"changeEmailPasswordDes": "请输入当前密码以确认操作。",
"emailChangeSent": "确认邮件已发送至新邮箱。",
"cropAvatar": "裁剪头像", "cropAvatar": "裁剪头像",
"preference": "偏好", "preference": "偏好",
"accountCreatedAt": "创建于 <0></0>", "accountCreatedAt": "创建于 <0></0>",

@ -1352,6 +1352,40 @@ export function sendEmailActivate(id: string, sign: string): ThunkResponse<User>
}; };
} }
export function sendRequestEmailChange(newEmail: string, password: string): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/user/setting/email`,
{
method: "POST",
data: { new_email: newEmail, password },
},
{
...defaultOpts,
},
),
);
};
}
export function sendEmailChangeActivate(id: string, sign: string): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/user/activate_email/${id}?sign=${encodeURIComponent(sign)}`,
{
method: "GET",
},
{
...defaultOpts,
noCredential: true,
},
),
);
};
}
export function sendResetEmail(req: SendResetEmailService): ThunkResponse<User> { export function sendResetEmail(req: SendResetEmailService): ThunkResponse<User> {
return async (dispatch, _getState) => { return async (dispatch, _getState) => {
return await dispatch( return await dispatch(

@ -0,0 +1,82 @@
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { useNavigate } from "react-router-dom";
import { useQuery } from "../../../util";
import React, { useEffect, useState } from "react";
import { Box, Button, Typography } from "@mui/material";
import PageTitle from "../../../router/PageTitle.tsx";
import CheckmarkCircle from "../../Icons/CheckmarkCircle.tsx";
import { setHeadlessFrameLoading } from "../../../redux/globalStateSlice.ts";
import { sendEmailChangeActivate } from "../../../api/api.ts";
const ActivateEmailChange = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const query = useQuery();
const [success, setSuccess] = useState(true);
useEffect(() => {
const sign = query.get("sign");
const id = query.get("id");
if (!sign || !id) {
setSuccess(false);
navigate("/session");
return;
}
dispatch(setHeadlessFrameLoading(true));
dispatch(sendEmailChangeActivate(id, decodeURIComponent(sign)))
.then(() => {
setSuccess(true);
})
.catch(() => {
navigate("/session");
})
.finally(() => {
dispatch(setHeadlessFrameLoading(false));
});
}, []);
return (
<Box>
<PageTitle title={t("login.emailChangedTitle")} />
<Box sx={{ overflow: "hidden" }}>
{success && (
<>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
py: 7,
}}
>
<CheckmarkCircle fontSize={"large"} color={"success"} />
<Typography
variant={"h6"}
sx={{
color: (theme) => theme.palette.success.main,
mt: 1,
}}
>
{t("application:login.emailChanged")}
</Typography>
</Box>
<Button
onClick={() => navigate("/session")}
sx={{ mt: 2 }}
variant="contained"
color="primary"
>
{t("login.backToSingIn")}
</Button>
</>
)}
</Box>
</Box>
);
};
export default ActivateEmailChange;

@ -0,0 +1,80 @@
import { useTranslation } from "react-i18next";
import { useSnackbar } from "notistack";
import { useAppDispatch } from "../../../redux/hooks.ts";
import React, { useState } from "react";
import { DialogContent, FormControl, FormHelperText, Typography } from "@mui/material";
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
import { DenseFilledTextField } from "../../Common/StyledComponents.tsx";
import { sendRequestEmailChange } from "../../../api/api.ts";
export interface ChangeEmailDialogProps {
open?: boolean;
onClose: () => void;
currentEmail?: string;
}
const ChangeEmailDialog = ({ open, onClose, currentEmail }: ChangeEmailDialogProps) => {
const { t } = useTranslation();
const { enqueueSnackbar } = useSnackbar();
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const submit = () => {
setLoading(true);
dispatch(sendRequestEmailChange(email, password))
.then(() => {
enqueueSnackbar({ message: t("setting.emailChangeSent"), variant: "success" });
setEmail("");
setPassword("");
onClose();
})
.finally(() => setLoading(false));
};
return (
<DraggableDialog
title={t("setting.changeEmail")}
showCancel
onAccept={submit}
loading={loading}
dialogProps={{
open: !!open,
onClose: onClose,
fullWidth: true,
maxWidth: "xs",
}}
>
<DialogContent>
<Typography variant="body2" sx={{ mb: 2 }}>
{t("setting.changeEmailDes", { email: currentEmail })}
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<DenseFilledTextField
required
type="email"
label={t("setting.newEmail")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</FormControl>
<FormControl fullWidth>
<DenseFilledTextField
required
type="password"
label={t("login.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
<FormHelperText>{t("setting.changeEmailPasswordDes")}</FormHelperText>
</FormControl>
</DialogContent>
</DraggableDialog>
);
};
export default ChangeEmailDialog;

@ -1,5 +1,5 @@
import { LoadingButton } from "@mui/lab"; import { LoadingButton } from "@mui/lab";
import { Collapse, FormHelperText, Grid2, ListItemText, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material"; import { Button, Collapse, FormHelperText, Grid2, ListItemText, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material";
import { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; import { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -12,6 +12,7 @@ import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import TimeBadge from "../../Common/TimeBadge.tsx"; import TimeBadge from "../../Common/TimeBadge.tsx";
import CaretDown from "../../Icons/CaretDown.tsx"; import CaretDown from "../../Icons/CaretDown.tsx";
import AvatarSetting from "./AvatarSetting.tsx"; import AvatarSetting from "./AvatarSetting.tsx";
import ChangeEmailDialog from "./ChangeEmailDialog.tsx";
import ProfileSettingPopover, { useProfileSettingSummary } from "./ProfileSettingPopover.tsx"; import ProfileSettingPopover, { useProfileSettingSummary } from "./ProfileSettingPopover.tsx";
import SettingForm from "./SettingForm.tsx"; import SettingForm from "./SettingForm.tsx";
@ -43,6 +44,7 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
const [nick, setNick] = useState(user?.user.nickname); const [nick, setNick] = useState(user?.user.nickname);
const [nickLoading, setNickLoading] = useState(false); const [nickLoading, setNickLoading] = useState(false);
const [profileSettingLoading, setProfileSettingLoading] = useState(false); const [profileSettingLoading, setProfileSettingLoading] = useState(false);
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
const profileSettingPopup = usePopupState({ const profileSettingPopup = usePopupState({
variant: "popover", variant: "popover",
@ -102,7 +104,12 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
<Grid2 spacing={3} sx={{ flexGrow: 1, width: "100%" }} size={{ md: 6, xs: 12 }}> <Grid2 spacing={3} sx={{ flexGrow: 1, width: "100%" }} size={{ md: 6, xs: 12 }}>
<Stack spacing={3}> <Stack spacing={3}>
<SettingForm title={t("login.email")} noContainer lgWidth={12}> <SettingForm title={t("login.email")} noContainer lgWidth={12}>
<DenseFilledTextField disabled fullWidth value={user?.user.email} /> <Stack direction="row" spacing={1}>
<DenseFilledTextField disabled fullWidth value={user?.user.email} />
<Button variant="outlined" onClick={() => setEmailDialogOpen(true)} sx={{ flexShrink: 0 }}>
{t("setting.changeEmail")}
</Button>
</Stack>
</SettingForm> </SettingForm>
<SettingForm title={t("setting.nickname")} noContainer lgWidth={12}> <SettingForm title={t("setting.nickname")} noContainer lgWidth={12}>
<DenseFilledTextField <DenseFilledTextField
@ -192,6 +199,7 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
</Stack> </Stack>
</Grid2> </Grid2>
</Grid2> </Grid2>
<ChangeEmailDialog open={emailDialogOpen} onClose={() => setEmailDialogOpen(false)} currentEmail={user?.user.email} />
</Stack> </Stack>
); );
}; };

@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { confirmOperation } from "../../../redux/thunks/dialog.ts"; import { confirmOperation } from "../../../redux/thunks/dialog.ts";
import { getEntityContent } from "../../../redux/thunks/file.ts"; import { getEntityContent } from "../../../redux/thunks/file.ts";
import { saveCode } from "../../../redux/thunks/viewer.ts"; import { saveCode } from "../../../redux/thunks/viewer.ts";
import SessionManager, { UserSettings } from "../../../session/index.ts";
import { fileExtension } from "../../../util"; import { fileExtension } from "../../../util";
import { CascadingSubmenu } from "../../FileManager/ContextMenu/CascadingMenu.tsx"; import { CascadingSubmenu } from "../../FileManager/ContextMenu/CascadingMenu.tsx";
import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
@ -107,6 +108,7 @@ const CodeViewer = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [optionAnchorEl, setOptionAnchorEl] = useState<null | HTMLElement>(null); const [optionAnchorEl, setOptionAnchorEl] = useState<null | HTMLElement>(null);
const [language, setLng] = useState<string | null>(null); const [language, setLng] = useState<string | null>(null);
const [charsetState, setCharsetState] = useState<string | undefined>(undefined);
const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off"); const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off");
const [eol, setEol] = useState<"LF" | "CRLF">("LF"); const [eol, setEol] = useState<"LF" | "CRLF">("LF");
const saveFunction = useRef<() => void>(() => {}); const saveFunction = useRef<() => void>(() => {});
@ -138,6 +140,7 @@ const CodeViewer = () => {
return; return;
} }
setCharsetState(charset);
setLoaded(false); setLoaded(false);
setOptionAnchorEl(null); setOptionAnchorEl(null);
dispatch(getEntityContent(viewerState.file, viewerState.version)) dispatch(getEntityContent(viewerState.file, viewerState.version))
@ -156,15 +159,37 @@ const CodeViewer = () => {
[viewerState, closeViewer], [viewerState, closeViewer],
); );
// Remember charset/language picks per extension (#2249).
const currentExt = fileExtension(viewerState?.file?.name ?? "") ?? "";
const pickCharset = useCallback(
(charset: string) => {
SessionManager.set(UserSettings.CodeCharsetPrefix + currentExt, charset);
loadContent(charset);
},
[currentExt, loadContent],
);
const pickLanguage = useCallback(
(l: string) => {
SessionManager.set(UserSettings.CodeLanguagePrefix + currentExt, l);
setLng(l);
},
[currentExt],
);
useEffect(() => { useEffect(() => {
if (!viewerState || !viewerState.open) { if (!viewerState || !viewerState.open) {
return; return;
} }
const ext = fileExtension(viewerState.file.name) ?? "";
const rememberedCharset = SessionManager.get(UserSettings.CodeCharsetPrefix + ext);
const rememberedLng = SessionManager.get(UserSettings.CodeLanguagePrefix + ext);
setLng(null); setLng(null);
setSaved(true); setSaved(true);
setLng(codePreviewSuffix[fileExtension(viewerState.file.name) ?? ""] ?? ""); setLng(rememberedLng ?? codePreviewSuffix[ext] ?? "");
loadContent(); loadContent(rememberedCharset);
}, [viewerState?.open]); }, [viewerState?.open]);
const openMore = useCallback( const openMore = useCallback(
@ -286,14 +311,19 @@ const CodeViewer = () => {
> >
<CascadingSubmenu popupId={"lng"} title={t("application:fileManager.charset")}> <CascadingSubmenu popupId={"lng"} title={t("application:fileManager.charset")}>
{allCharsets.map((charset) => ( {allCharsets.map((charset) => (
<SquareMenuItem key={charset} onClick={() => loadContent(charset)}> <SquareMenuItem key={charset} onClick={() => pickCharset(charset)}>
<ListItemText>{charset}</ListItemText> <ListItemText>{charset}</ListItemText>
{charset == (charsetState ?? "UTF-8") && (
<ListItemIcon>
<Checkmark />
</ListItemIcon>
)}
</SquareMenuItem> </SquareMenuItem>
))} ))}
</CascadingSubmenu> </CascadingSubmenu>
<CascadingSubmenu popupId={"lng"} title={t("application:fileManager.textType")}> <CascadingSubmenu popupId={"lng"} title={t("application:fileManager.textType")}>
{allLng.map((l) => ( {allLng.map((l) => (
<SquareMenuItem key={l} onClick={() => setLng(l)}> <SquareMenuItem key={l} onClick={() => pickLanguage(l)}>
<ListItemText>{l}</ListItemText> <ListItemText>{l}</ListItemText>
{l == language && ( {l == language && (
<ListItemIcon> <ListItemIcon>

@ -4,6 +4,7 @@ import ErrorBoundary from "../component/Common/ErrorBoundary.tsx";
import HeadlessFrame from "../component/Frame/HeadlessFrame.tsx"; import HeadlessFrame from "../component/Frame/HeadlessFrame.tsx";
import { HomeRedirect } from "../component/Pages/HomeRedirect.tsx"; import { HomeRedirect } from "../component/Pages/HomeRedirect.tsx";
import Activate from "../component/Pages/Login/Activate.tsx"; import Activate from "../component/Pages/Login/Activate.tsx";
import ActivateEmailChange from "../component/Pages/Login/ActivateEmailChange.tsx";
import Authorize from "../component/Pages/Login/Authorize.tsx"; import Authorize from "../component/Pages/Login/Authorize.tsx";
import Reset from "../component/Pages/Login/Reset.tsx"; import Reset from "../component/Pages/Login/Reset.tsx";
import SessionIntro from "../component/Pages/Login/SessionIntro.tsx"; import SessionIntro from "../component/Pages/Login/SessionIntro.tsx";
@ -41,6 +42,10 @@ export const router = createBrowserRouter([
path: "activate", path: "activate",
element: <Activate />, element: <Activate />,
}, },
{
path: "activate_email",
element: <ActivateEmailChange />,
},
{ {
path: "reset", path: "reset",
element: <Reset />, element: <Reset />,

@ -25,6 +25,8 @@ export enum UserSettings {
BookLocationPrefix = "book_location_", BookLocationPrefix = "book_location_",
MusicVolume = "music_volume", MusicVolume = "music_volume",
OpenWithPrefix = "open_with_", OpenWithPrefix = "open_with_",
CodeCharsetPrefix = "code_charset_",
CodeLanguagePrefix = "code_language_",
ConcurrentLimit = "concurrent_limit", ConcurrentLimit = "concurrent_limit",
UseAvgSpeed = "use_avg_speed", UseAvgSpeed = "use_avg_speed",
TaskFilter = "task_filter", TaskFilter = "task_filter",

@ -18,15 +18,17 @@ const (
) )
var ( var (
masterPing *url.URL masterPing *url.URL
masterUserActivate *url.URL masterUserActivate *url.URL
masterUserReset *url.URL masterUserActivateEmail *url.URL
masterHome *url.URL masterUserReset *url.URL
masterHome *url.URL
) )
func init() { func init() {
masterPing, _ = url.Parse(constants.APIPrefix + "/site/ping") masterPing, _ = url.Parse(constants.APIPrefix + "/site/ping")
masterUserActivate, _ = url.Parse("/session/activate") masterUserActivate, _ = url.Parse("/session/activate")
masterUserActivateEmail, _ = url.Parse("/session/activate_email")
masterUserReset, _ = url.Parse("/session/reset") masterUserReset, _ = url.Parse("/session/reset")
} }
@ -62,6 +64,15 @@ func MasterUserActivateUrl(base *url.URL) *url.URL {
return base.ResolveReference(masterUserActivate) return base.ResolveReference(masterUserActivate)
} }
func MasterUserActivateEmailAPIUrl(base *url.URL, uid string) *url.URL {
route, _ := url.Parse(constants.APIPrefix + "/user/activate_email/" + uid)
return base.ResolveReference(route)
}
func MasterUserActivateEmailUrl(base *url.URL) *url.URL {
return base.ResolveReference(masterUserActivateEmail)
}
func MasterUserResetUrl(base *url.URL) *url.URL { func MasterUserResetUrl(base *url.URL) *url.URL {
return base.ResolveReference(masterUserReset) return base.ResolveReference(masterUserReset)
} }

@ -170,6 +170,30 @@ func UserActivate(c *gin.Context) {
c.JSON(200, user.ActivateUser(c)) c.JSON(200, user.ActivateUser(c))
} }
// UserRequestEmailChange starts the self-service email change flow.
func UserRequestEmailChange(c *gin.Context) {
service := ParametersFromContext[*user.RequestEmailChangeService](c, user.RequestEmailChangeParamCtx{})
if err := service.Request(c); err != nil {
c.JSON(200, serializer.Err(c, err))
c.Abort()
return
}
c.JSON(200, serializer.Response{})
}
// UserActivateEmailChange applies the pending email change from the signed
// confirmation link.
func UserActivateEmailChange(c *gin.Context) {
if err := user.ActivateEmailChange(c); err != nil {
c.JSON(200, serializer.Err(c, err))
c.Abort()
return
}
c.JSON(200, serializer.Response{})
}
// UserSignOut 用户退出登录 // UserSignOut 用户退出登录
func UserSignOut(c *gin.Context) { func UserSignOut(c *gin.Context) {
service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{}) service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{})

@ -431,6 +431,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
middleware.HashID(hashid.UserID), middleware.HashID(hashid.UserID),
controllers.UserActivate, controllers.UserActivate,
) )
// 邮箱更换确认 Done
user.GET("activate_email/:id",
middleware.SignRequired(dep.GeneralAuth()),
middleware.HashID(hashid.UserID),
controllers.UserActivateEmailChange,
)
// 获取用户头像 // 获取用户头像
user.GET("avatar/:id", user.GET("avatar/:id",
middleware.HashID(hashid.UserID), middleware.HashID(hashid.UserID),
@ -1346,6 +1352,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
) )
// 获得二步验证初始化信息 // 获得二步验证初始化信息
setting.GET("2fa", controllers.UserInit2FA) setting.GET("2fa", controllers.UserInit2FA)
// 请求更换邮箱(向新地址发送确认链接)
setting.POST("email",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
middleware.RateLimitByIP("email_change", 5, time.Hour),
controllers.FromJSON[usersvc.RequestEmailChangeService](usersvc.RequestEmailChangeParamCtx{}),
controllers.UserRequestEmailChange,
)
} }
} }

@ -0,0 +1,123 @@
package user
import (
"fmt"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/email"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
)
const (
emailChangeSessionKey = "email_change_"
emailChangeTTL = 24 * 3600
)
type (
// RequestEmailChangeService starts the email-change flow: validates the new
// address and the current password, stores the pending address in KV, and
// sends a signed confirmation link to the new mailbox.
RequestEmailChangeService struct {
NewEmail string `json:"new_email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
RequestEmailChangeParamCtx struct{}
)
func (s *RequestEmailChangeService) Request(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
userClient := dep.UserClient()
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return err
}
if err := inventory.CheckPassword(u, s.Password); err != nil {
return serializer.NewError(serializer.CodeIncorrectPassword, "Incorrect password", err)
}
if err := CheckEmailAllowed(dep.SettingProvider().EmailFilter(c), s.NewEmail); err != nil {
return err
}
if _, err := userClient.GetByEmail(c, s.NewEmail); err == nil {
return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil)
}
// The pending address lives in KV — the signed link carries only the user
// id, so the target address can't be tampered with.
kv := dep.KV()
if err := kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), s.NewEmail, emailChangeTTL); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to store email change session", err)
}
base := dep.SettingProvider().SiteURL(c)
userID := hashid.EncodeUserID(dep.HashIDEncoder(), u.ID)
ttl := time.Now().Add(emailChangeTTL * time.Second)
activateURL, err := auth.SignURI(c, dep.GeneralAuth(), routes.MasterUserActivateEmailAPIUrl(base, userID).String(), &ttl)
if err != nil {
return serializer.NewError(serializer.CodeEncryptError, "Failed to sign the activation link", err)
}
credential := activateURL.Query().Get("sign")
finalURL := routes.MasterUserActivateEmailUrl(base)
queries := finalURL.Query()
queries.Add("id", userID)
queries.Add("sign", credential)
finalURL.RawQuery = queries.Encode()
title, body, err := email.NewActivationEmail(c, dep.SettingProvider(), u, finalURL.String())
if err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err)
}
if err := dep.EmailClient(c).Send(c, s.NewEmail, title, body); err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err)
}
return nil
}
// ActivateEmailChange applies a pending email change once the signed link
// from the confirmation mail is opened. The target address comes from KV so a
// valid signature alone can't pick an arbitrary address.
func ActivateEmailChange(c *gin.Context) error {
uid := hashid.FromContext(c)
dep := dependency.FromContext(c)
userClient := dep.UserClient()
kv := dep.KV()
pending, ok := kv.Get(fmt.Sprintf("%s%d", emailChangeSessionKey, uid))
if !ok {
return serializer.NewError(serializer.CodeParamErr, "No pending email change", nil)
}
newEmail, ok := pending.(string)
if !ok {
return serializer.NewError(serializer.CodeInternalSetting, "Invalid pending email change", nil)
}
target, err := userClient.GetByID(c, uid)
if err != nil {
return serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
if _, err := userClient.GetByEmail(c, newEmail); err == nil {
return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil)
}
target.Email = newEmail
if _, err := userClient.Upsert(c, target, "", ""); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update email", err)
}
kv.Delete("", fmt.Sprintf("%s%d", emailChangeSessionKey, uid))
return nil
}

@ -0,0 +1,77 @@
package user
import (
"context"
"fmt"
"net/http/httptest"
"testing"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestActivateEmailChange verifies the signed-link confirmation path: pending
// address comes from KV, the change applies once, and replays/concurrent
// claims are rejected.
func TestActivateEmailChange(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
u := client.User.Create().
SetEmail("old@example.com").
SetNick("change").
SetStatus("active").
SetGroup(group).
SetSettings(&types.UserSetting{}).
SaveX(ctx)
client.User.Create().
SetEmail("taken@example.com").
SetNick("taken").
SetStatus("active").
SetGroup(group).
SetSettings(&types.UserSetting{}).
SaveX(ctx)
kv := cache.NewMemoStore("", nil)
dep := dependency.NewDependency(
dependency.WithUserClient(inventory.NewUserClient(client)),
dependency.WithKV(kv),
)
newCtx := func(uid int) *gin.Context {
engine := gin.New()
engine.ContextWithFallback = true
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
c.Request = httptest.NewRequest("GET", "/", nil)
util.WithValue(c, dependency.DepCtx{}, dep)
util.WithValue(c, hashid.ObjectIDCtx{}, uid)
return c
}
// No pending change → rejected.
require.Error(t, ActivateEmailChange(newCtx(u.ID)))
// Pending change applies.
require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "new@example.com", 60))
require.NoError(t, ActivateEmailChange(newCtx(u.ID)))
require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email)
// Replay after success is rejected (KV entry consumed).
require.Error(t, ActivateEmailChange(newCtx(u.ID)))
// Address claimed between request and confirm is rejected.
require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "taken@example.com", 60))
require.Error(t, ActivateEmailChange(newCtx(u.ID)))
require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email)
}
Loading…
Cancel
Save