From 1914fcbcdf20174b799c17769ae3a9b31541f235 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Sat, 19 Sep 2026 02:21:39 +0200 Subject: [PATCH] feat(user): self-service email change + per-extension editor memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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> --- .../public/locales/en-US/application.json | 7 + .../public/locales/zh-CN/application.json | 7 + frontend/src/api/api.ts | 34 +++++ .../Pages/Login/ActivateEmailChange.tsx | 82 ++++++++++++ .../Pages/Setting/ChangeEmailDialog.tsx | 80 ++++++++++++ .../Pages/Setting/ProfileSetting.tsx | 12 +- .../Viewers/CodeViewer/CodeViewer.tsx | 38 +++++- frontend/src/router/index.tsx | 5 + frontend/src/session/index.ts | 2 + pkg/cluster/routes/routes.go | 19 ++- routers/controllers/user.go | 24 ++++ routers/router.go | 13 ++ service/user/email_change.go | 123 ++++++++++++++++++ service/user/email_change_test.go | 77 +++++++++++ 14 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 frontend/src/component/Pages/Login/ActivateEmailChange.tsx create mode 100644 frontend/src/component/Pages/Setting/ChangeEmailDialog.tsx create mode 100644 service/user/email_change.go create mode 100644 service/user/email_change_test.go diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index c922e07e..607fcffa 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -42,6 +42,8 @@ "signUpSuccess": "Sign up successful", "activateSuccess": "Sign up completed", "accountActivated": "Your account has been successfully activated.", + "emailChangedTitle": "Email changed", + "emailChanged": "Your email address has been changed successfully.", "title": "Sign in to {{title}}", "sinUpTitle": "Sign up to {{title}}", "activateTitle": "Activate your account", @@ -858,6 +860,11 @@ "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.", "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", "preference": "Preference", "accountCreatedAt": "Created at <0>", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index ca9d0fe7..b4e2a969 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -42,6 +42,8 @@ "signUpSuccess": "注册成功", "activateSuccess": "激活成功", "accountActivated": "您的账号已被成功激活", + "emailChangedTitle": "邮箱已更换", + "emailChanged": "您的邮箱地址已成功更换。", "title": "登录 {{title}}", "sinUpTitle": "注册 {{title}}", "activateTitle": "邮件激活", @@ -858,6 +860,11 @@ "revokeOAuthGrant": "撤销授权", "revokeOAuthGrantConfirm": "确定要撤销此应用的授权吗?撤销后,该应用将无法再访问你的账户。", "nickNameDes": "用于公开展示的名字,可使用真实姓名或昵称", + "changeEmail": "更换", + "changeEmailDes": "确认链接将发送到新邮箱。在确认之前,当前邮箱({{email}})仍然有效。", + "newEmail": "新邮箱地址", + "changeEmailPasswordDes": "请输入当前密码以确认操作。", + "emailChangeSent": "确认邮件已发送至新邮箱。", "cropAvatar": "裁剪头像", "preference": "偏好", "accountCreatedAt": "创建于 <0>", diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index f992197b..8ab5f525 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1352,6 +1352,40 @@ export function sendEmailActivate(id: string, sign: string): ThunkResponse }; } +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 { return async (dispatch, _getState) => { return await dispatch( diff --git a/frontend/src/component/Pages/Login/ActivateEmailChange.tsx b/frontend/src/component/Pages/Login/ActivateEmailChange.tsx new file mode 100644 index 00000000..bbadd745 --- /dev/null +++ b/frontend/src/component/Pages/Login/ActivateEmailChange.tsx @@ -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 ( + + + + {success && ( + <> + + + theme.palette.success.main, + mt: 1, + }} + > + {t("application:login.emailChanged")} + + + + + )} + + + ); +}; + +export default ActivateEmailChange; diff --git a/frontend/src/component/Pages/Setting/ChangeEmailDialog.tsx b/frontend/src/component/Pages/Setting/ChangeEmailDialog.tsx new file mode 100644 index 00000000..df8bb44f --- /dev/null +++ b/frontend/src/component/Pages/Setting/ChangeEmailDialog.tsx @@ -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 ( + + + + {t("setting.changeEmailDes", { email: currentEmail })} + + + setEmail(e.target.value)} + autoComplete="email" + /> + + + setPassword(e.target.value)} + autoComplete="current-password" + /> + {t("setting.changeEmailPasswordDes")} + + + + ); +}; + +export default ChangeEmailDialog; diff --git a/frontend/src/component/Pages/Setting/ProfileSetting.tsx b/frontend/src/component/Pages/Setting/ProfileSetting.tsx index 95873bea..f38c7924 100644 --- a/frontend/src/component/Pages/Setting/ProfileSetting.tsx +++ b/frontend/src/component/Pages/Setting/ProfileSetting.tsx @@ -1,5 +1,5 @@ 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 { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -12,6 +12,7 @@ import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; import TimeBadge from "../../Common/TimeBadge.tsx"; import CaretDown from "../../Icons/CaretDown.tsx"; import AvatarSetting from "./AvatarSetting.tsx"; +import ChangeEmailDialog from "./ChangeEmailDialog.tsx"; import ProfileSettingPopover, { useProfileSettingSummary } from "./ProfileSettingPopover.tsx"; import SettingForm from "./SettingForm.tsx"; @@ -43,6 +44,7 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => { const [nick, setNick] = useState(user?.user.nickname); const [nickLoading, setNickLoading] = useState(false); const [profileSettingLoading, setProfileSettingLoading] = useState(false); + const [emailDialogOpen, setEmailDialogOpen] = useState(false); const profileSettingPopup = usePopupState({ variant: "popover", @@ -102,7 +104,12 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => { - + + + + { + setEmailDialogOpen(false)} currentEmail={user?.user.email} /> ); }; diff --git a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx index 377677fe..294710e6 100644 --- a/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx +++ b/frontend/src/component/Viewers/CodeViewer/CodeViewer.tsx @@ -8,6 +8,7 @@ import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { confirmOperation } from "../../../redux/thunks/dialog.ts"; import { getEntityContent } from "../../../redux/thunks/file.ts"; import { saveCode } from "../../../redux/thunks/viewer.ts"; +import SessionManager, { UserSettings } from "../../../session/index.ts"; import { fileExtension } from "../../../util"; import { CascadingSubmenu } from "../../FileManager/ContextMenu/CascadingMenu.tsx"; import { DenseDivider, SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx"; @@ -107,6 +108,7 @@ const CodeViewer = () => { const [anchorEl, setAnchorEl] = useState(null); const [optionAnchorEl, setOptionAnchorEl] = useState(null); const [language, setLng] = useState(null); + const [charsetState, setCharsetState] = useState(undefined); const [wordWrap, setWordWrap] = useState<"off" | "on" | "wordWrapColumn" | "bounded">("off"); const [eol, setEol] = useState<"LF" | "CRLF">("LF"); const saveFunction = useRef<() => void>(() => {}); @@ -138,6 +140,7 @@ const CodeViewer = () => { return; } + setCharsetState(charset); setLoaded(false); setOptionAnchorEl(null); dispatch(getEntityContent(viewerState.file, viewerState.version)) @@ -156,15 +159,37 @@ const CodeViewer = () => { [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(() => { if (!viewerState || !viewerState.open) { return; } + const ext = fileExtension(viewerState.file.name) ?? ""; + const rememberedCharset = SessionManager.get(UserSettings.CodeCharsetPrefix + ext); + const rememberedLng = SessionManager.get(UserSettings.CodeLanguagePrefix + ext); setLng(null); setSaved(true); - setLng(codePreviewSuffix[fileExtension(viewerState.file.name) ?? ""] ?? ""); - loadContent(); + setLng(rememberedLng ?? codePreviewSuffix[ext] ?? ""); + loadContent(rememberedCharset); }, [viewerState?.open]); const openMore = useCallback( @@ -286,14 +311,19 @@ const CodeViewer = () => { > {allCharsets.map((charset) => ( - loadContent(charset)}> + pickCharset(charset)}> {charset} + {charset == (charsetState ?? "UTF-8") && ( + + + + )} ))} {allLng.map((l) => ( - setLng(l)}> + pickLanguage(l)}> {l} {l == language && ( diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index d3270cdb..93cc7d55 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -4,6 +4,7 @@ import ErrorBoundary from "../component/Common/ErrorBoundary.tsx"; import HeadlessFrame from "../component/Frame/HeadlessFrame.tsx"; import { HomeRedirect } from "../component/Pages/HomeRedirect.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 Reset from "../component/Pages/Login/Reset.tsx"; import SessionIntro from "../component/Pages/Login/SessionIntro.tsx"; @@ -41,6 +42,10 @@ export const router = createBrowserRouter([ path: "activate", element: , }, + { + path: "activate_email", + element: , + }, { path: "reset", element: , diff --git a/frontend/src/session/index.ts b/frontend/src/session/index.ts index 2d68e9cc..e6b37600 100644 --- a/frontend/src/session/index.ts +++ b/frontend/src/session/index.ts @@ -25,6 +25,8 @@ export enum UserSettings { BookLocationPrefix = "book_location_", MusicVolume = "music_volume", OpenWithPrefix = "open_with_", + CodeCharsetPrefix = "code_charset_", + CodeLanguagePrefix = "code_language_", ConcurrentLimit = "concurrent_limit", UseAvgSpeed = "use_avg_speed", TaskFilter = "task_filter", diff --git a/pkg/cluster/routes/routes.go b/pkg/cluster/routes/routes.go index f4dc2640..11b2d6cc 100644 --- a/pkg/cluster/routes/routes.go +++ b/pkg/cluster/routes/routes.go @@ -18,15 +18,17 @@ const ( ) var ( - masterPing *url.URL - masterUserActivate *url.URL - masterUserReset *url.URL - masterHome *url.URL + masterPing *url.URL + masterUserActivate *url.URL + masterUserActivateEmail *url.URL + masterUserReset *url.URL + masterHome *url.URL ) func init() { masterPing, _ = url.Parse(constants.APIPrefix + "/site/ping") masterUserActivate, _ = url.Parse("/session/activate") + masterUserActivateEmail, _ = url.Parse("/session/activate_email") masterUserReset, _ = url.Parse("/session/reset") } @@ -62,6 +64,15 @@ func MasterUserActivateUrl(base *url.URL) *url.URL { 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 { return base.ResolveReference(masterUserReset) } diff --git a/routers/controllers/user.go b/routers/controllers/user.go index e4363e73..d3b271a5 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -170,6 +170,30 @@ func UserActivate(c *gin.Context) { 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 用户退出登录 func UserSignOut(c *gin.Context) { service := ParametersFromContext[*user.RefreshTokenService](c, user.RefreshTokenParameterCtx{}) diff --git a/routers/router.go b/routers/router.go index cb919e57..f573268b 100644 --- a/routers/router.go +++ b/routers/router.go @@ -431,6 +431,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { middleware.HashID(hashid.UserID), controllers.UserActivate, ) + // 邮箱更换确认 Done + user.GET("activate_email/:id", + middleware.SignRequired(dep.GeneralAuth()), + middleware.HashID(hashid.UserID), + controllers.UserActivateEmailChange, + ) // 获取用户头像 user.GET("avatar/:id", middleware.HashID(hashid.UserID), @@ -1346,6 +1352,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { ) // 获得二步验证初始化信息 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, + ) } } diff --git a/service/user/email_change.go b/service/user/email_change.go new file mode 100644 index 00000000..e0c3817c --- /dev/null +++ b/service/user/email_change.go @@ -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 +} diff --git a/service/user/email_change_test.go b/service/user/email_change_test.go new file mode 100644 index 00000000..e1829a6e --- /dev/null +++ b/service/user/email_change_test.go @@ -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) +}