diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json index c9b49d14..c922e07e 100644 --- a/frontend/public/locales/en-US/application.json +++ b/frontend/public/locales/en-US/application.json @@ -836,6 +836,9 @@ "syncViewOff": "Do not sync", "folderClickAction": "Folder click action", "folderClickActionDes": "Choose what happens when you click a folder. \"Open\" navigates into the folder, \"Select\" selects it (double-click to open).", + "preferredViewers": "Default apps", + "preferredViewersDes": "Apps chosen with \"Always\" in the open-with dialog. They apply on all your devices; remove one to be asked again.", + "preferredViewersEmpty": "No default apps saved yet. Pick \"Always\" in the open-with dialog on a file.", "folderClickOpen": "Open", "folderClickSelect": "Select", "noAuthenticator": "Add a passkey to sign in using fingerprint, face or USB key.", @@ -961,6 +964,8 @@ "disable2FA": "Disable 2FA authentication", "2faDescription": "Please use any 2FA mobile app or password management software that supports 2FA to scan the QR code to add this site. After scanning, please fill in the 6-digit verification code given by the 2FA app to enable 2FA.", "inputCurrent2FACode": "Enter current 2FA verification code.", + "copySecret": "Can't scan? Click to copy the setup key", + "secretCopied": "Setup key copied", "timeZoneCode": "IANA timezone code", "authenticatorRemoved": "Authenticator removed.", "authenticatorAdded": "Authenticator added.", diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json index 811525ae..ca9d0fe7 100644 --- a/frontend/public/locales/zh-CN/application.json +++ b/frontend/public/locales/zh-CN/application.json @@ -836,6 +836,9 @@ "syncViewOff": "不同步", "folderClickAction": "文件夹点击操作", "folderClickActionDes": "选择点击文件夹时的行为。「打开」将进入文件夹,「选中」将选中文件夹(双击打开)。", + "preferredViewers": "默认打开方式", + "preferredViewersDes": "在「打开方式」对话框中选择「始终」后保存的应用,会在所有设备上生效。删除后下次打开时将重新询问。", + "preferredViewersEmpty": "尚未保存默认应用。在文件的「打开方式」对话框中选择「始终」即可添加。", "folderClickOpen": "打开", "folderClickSelect": "选中", "noAuthenticator": "添加通行密钥以使用人脸、指纹或 USB 密钥登录账号", @@ -961,6 +964,8 @@ "disable2FA": "关闭二步验证", "2faDescription": "请使用任意二步验证 APP 或者支持二步验证的密码管理软件扫描二维码添加本站。扫描完成后请填写二步验证 APP 给出的 6 位验证码以开启二步验证。", "inputCurrent2FACode": "输入当前二步验证 APP 给出的 6 位验证码:", + "copySecret": "无法扫码?点击复制密钥", + "secretCopied": "已复制密钥", "timeZoneCode": "IANA 时区名称标识", "authenticatorRemoved": "凭证已删除", "authenticatorAdded": "验证器已添加", diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index d22b5de0..c8600891 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -27,6 +27,7 @@ export interface User { disable_view_sync?: boolean; share_links_in_profile?: ShareLinksInProfileLevel; share_default_private?: boolean; + preferred_viewers?: Record; } export interface Group { id: string; @@ -142,6 +143,7 @@ export interface UserSettings { disable_view_sync: boolean; share_links_in_profile: ShareLinksInProfileLevel; share_default_private?: boolean; + preferred_viewers?: Record; oauth_grants?: OAuthGrant[]; } @@ -168,6 +170,7 @@ export interface PatchUserSetting { share_links_in_profile?: ShareLinksInProfileLevel; // Tri-state: "true" / "false" / "" (inherit the site default). share_default_private?: string; + preferred_viewers?: Record; } export interface PasskeyCredentialOption { diff --git a/frontend/src/component/FileManager/Dialogs/OpenWith.tsx b/frontend/src/component/FileManager/Dialogs/OpenWith.tsx index add4c67b..367b68e5 100644 --- a/frontend/src/component/FileManager/Dialogs/OpenWith.tsx +++ b/frontend/src/component/FileManager/Dialogs/OpenWith.tsx @@ -13,6 +13,7 @@ import { } from "@mui/material"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { sendUpdateUserSetting } from "../../../api/api.ts"; import { Viewer, ViewerType } from "../../../api/explorer.ts"; import { closeViewerSelector } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; @@ -129,7 +130,15 @@ const OpenWith = () => { } if (always) { - SessionManager.set(UserSettings.OpenWithPrefix + ext, viewer?.id ?? selectedViewer?.id); + const viewerId = viewer?.id ?? selectedViewer?.id; + SessionManager.set(UserSettings.OpenWithPrefix + ext, viewerId); + const user = SessionManager.currentUser(); + if (user && viewerId) { + const merged = { ...(user.preferred_viewers ?? {}), [ext]: viewerId }; + dispatch(sendUpdateUserSetting({ preferred_viewers: merged })) + .then(() => SessionManager.updateUserIfExist({ ...user, preferred_viewers: merged })) + .catch(() => {}); + } } dispatch( diff --git a/frontend/src/component/Pages/Setting/PreferenceSetting.tsx b/frontend/src/component/Pages/Setting/PreferenceSetting.tsx index 21b63585..ca22b2ff 100644 --- a/frontend/src/component/Pages/Setting/PreferenceSetting.tsx +++ b/frontend/src/component/Pages/Setting/PreferenceSetting.tsx @@ -23,6 +23,7 @@ import { UserSettings as UserSettingsType } from "../../../api/user.ts"; import { languages } from "../../../i18n.ts"; import { setPreferredTheme } from "../../../redux/globalStateSlice.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; +import { ViewersByID } from "../../../redux/siteConfigSlice.ts"; import { clearLocalCustomView } from "../../../redux/thunks/filemanager.ts"; import { selectLanguage } from "../../../redux/thunks/settings.ts"; import SessionManager, { UserSettings } from "../../../session"; @@ -190,6 +191,27 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => { } }; + const preferredViewerEntries = useMemo( + () => Object.entries(setting.preferred_viewers ?? {}), + [setting.preferred_viewers], + ); + + const removePreferredViewer = (ext: string) => { + const next = { ...(setting.preferred_viewers ?? {}) }; + delete next[ext]; + setSetting({ ...setting, preferred_viewers: next }); + setLoading(true); + dispatch(sendUpdateUserSetting({ preferred_viewers: next })) + .then(() => { + const session = SessionManager.currentLoginOrNull(); + if (session?.user) { + SessionManager.updateUserIfExist({ ...session.user, preferred_viewers: next }); + } + SessionManager.set(UserSettings.OpenWithPrefix + ext, undefined); + }) + .finally(() => setLoading(false)); + }; + return ( @@ -375,6 +397,25 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => { {t("setting.folderClickActionDes")} + + + {preferredViewerEntries.length == 0 && ( + {t("setting.preferredViewersEmpty")} + )} + + {preferredViewerEntries.map(([ext, viewerId]) => ( + removePreferredViewer(ext)} + disabled={loading} + /> + ))} + + {t("setting.preferredViewersDes")} + + ); }; diff --git a/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx b/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx index f42192a4..04486b20 100644 --- a/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx +++ b/frontend/src/component/Pages/Setting/Security/Enable2FADialog.tsx @@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next"; import { useSnackbar } from "notistack"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; import React, { useEffect, useState } from "react"; -import { Box, DialogContent, FormControl, Stack, styled, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { Box, DialogContent, FormControl, Stack, styled, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material"; import { CSSTransition, SwitchTransition } from "react-transition-group"; import AutoHeight from "../../../Common/AutoHeight.tsx"; import FacebookCircularProgress from "../../../Common/CircularProgress.tsx"; import { get2FAInitSecret, sendUpdateUserSetting } from "../../../../api/api.ts"; import { QRCodeSVG } from "qrcode.react"; import SessionManager from "../../../../session"; +import { copyToClipboard } from "../../../../util"; import { MuiOtpInput } from "mui-one-time-password-input"; export interface Enable2FADialogProps { @@ -129,6 +130,25 @@ const Enable2FADialog = ({ open, onClose, on2FAEnabled }: Enable2FADialogProps) {t("setting.2faDescription")} + + { + copyToClipboard(secret); + enqueueSnackbar({ message: t("setting.secretCopied"), variant: "success" }); + }} + sx={{ + mt: 1, + fontFamily: "monospace", + letterSpacing: 1, + cursor: "pointer", + wordBreak: "break-all", + userSelect: "all", + }} + > + {secret} + + {t("setting.inputCurrent2FACode")} diff --git a/frontend/src/redux/thunks/viewer.ts b/frontend/src/redux/thunks/viewer.ts index 3e048cf4..00336e96 100644 --- a/frontend/src/redux/thunks/viewer.ts +++ b/frontend/src/redux/thunks/viewer.ts @@ -74,11 +74,17 @@ export function openViewers( const ext = fileExtension(file.name) ?? ""; const entitySize = size ?? file.size; - // Try user preference - const userPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext); - if (!ignorePreference && userPreference && ViewersByID[userPreference]) { - dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion)); - return; + // Try user preference — the server-synced map wins over the per-device + // localStorage copy so the choice follows the user across devices. A + // stale server entry (viewer removed site-wide) falls back to local. + if (!ignorePreference) { + const serverPreference = SessionManager.currentUser()?.preferred_viewers?.[ext]; + const localPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext); + const userPreference = [serverPreference, localPreference].find((id) => id && ViewersByID[id]); + if (userPreference) { + dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion)); + return; + } } const viewerOptions = Viewers[ext]; diff --git a/inventory/types/types.go b/inventory/types/types.go index bdc06de4..0a3a5a24 100644 --- a/inventory/types/types.go +++ b/inventory/types/types.go @@ -20,6 +20,10 @@ type ( // ShareDefaultPrivate overrides the site-wide private-share default // for this user. nil means inherit the site default. ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` + // PreferredViewers maps file extensions (without dot, lowercase) to + // viewer IDs chosen via "always open with". Synced server-side so the + // preference follows the user across devices. + PreferredViewers map[string]string `json:"preferred_viewers,omitempty"` } ShareLinksInProfileLevel string diff --git a/service/user/response.go b/service/user/response.go index 5f215215..7ff8f8f3 100644 --- a/service/user/response.go +++ b/service/user/response.go @@ -24,16 +24,17 @@ type PreparePasskeyLoginResponse struct { } type UserSettings struct { - VersionRetentionEnabled bool `json:"version_retention_enabled"` - VersionRetentionExt []string `json:"version_retention_ext,omitempty"` - VersionRetentionMax int `json:"version_retention_max,omitempty"` - Paswordless bool `json:"passwordless"` - TwoFAEnabled bool `json:"two_fa_enabled"` - Passkeys []Passkey `json:"passkeys,omitempty"` - DisableViewSync bool `json:"disable_view_sync"` - ShareLinksInProfile string `json:"share_links_in_profile"` - ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` - OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"` + VersionRetentionEnabled bool `json:"version_retention_enabled"` + VersionRetentionExt []string `json:"version_retention_ext,omitempty"` + VersionRetentionMax int `json:"version_retention_max,omitempty"` + Paswordless bool `json:"passwordless"` + TwoFAEnabled bool `json:"two_fa_enabled"` + Passkeys []Passkey `json:"passkeys,omitempty"` + DisableViewSync bool `json:"disable_view_sync"` + ShareLinksInProfile string `json:"share_links_in_profile"` + ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` + PreferredViewers map[string]string `json:"preferred_viewers,omitempty"` + OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"` } func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser, grants []*ent.OAuthGrant) *UserSettings { @@ -49,6 +50,7 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa DisableViewSync: u.Settings.DisableViewSync, ShareLinksInProfile: string(u.Settings.ShareLinksInProfile), ShareDefaultPrivate: u.Settings.ShareDefaultPrivate, + PreferredViewers: u.Settings.PreferredViewers, OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant { return BuildOauthGrant(item) }), @@ -124,6 +126,8 @@ type User struct { // the site default applies. Never resolved here — the dialog computes the // effective default from user ?? site. ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` + // PreferredViewers maps file extensions to viewer IDs ("always open with"). + PreferredViewers map[string]string `json:"preferred_viewers,omitempty"` } type Group struct { @@ -182,6 +186,7 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User { DisableViewSync: user.Settings.DisableViewSync, ShareLinksInProfile: user.Settings.ShareLinksInProfile, ShareDefaultPrivate: user.Settings.ShareDefaultPrivate, + PreferredViewers: user.Settings.PreferredViewers, } } diff --git a/service/user/setting.go b/service/user/setting.go index b2bbf7ac..79527302 100644 --- a/service/user/setting.go +++ b/service/user/setting.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "github.com/cloudreve/Cloudreve/v4/application/dependency" @@ -233,10 +234,33 @@ type ( // ShareDefaultPrivate accepts "true", "false" or "" (clear the // override and inherit the site default). ShareDefaultPrivate *string `json:"share_default_private" binding:"omitempty"` + // PreferredViewers replaces the whole extension → viewer-id map. + PreferredViewers *map[string]string `json:"preferred_viewers" binding:"omitempty"` } PatchUserSettingParamsCtx struct{} ) +var ( + preferredViewerExtPattern = regexp.MustCompile(`^[a-z0-9]{1,20}$`) + preferredViewerIDPattern = regexp.MustCompile(`^[\w.\-:]{1,64}$`) +) + +const preferredViewersMaxEntries = 200 + +// validatePreferredViewers bounds the extension → viewer-id map to sane +// shapes so it can't be abused as arbitrary JSON storage. +func validatePreferredViewers(m map[string]string) error { + if len(m) > preferredViewersMaxEntries { + return serializer.NewError(serializer.CodeParamErr, "Too many preferred viewers", nil) + } + for ext, viewerID := range m { + if !preferredViewerExtPattern.MatchString(ext) || !preferredViewerIDPattern.MatchString(viewerID) { + return serializer.NewError(serializer.CodeParamErr, "Invalid preferred viewer entry", nil) + } + } + return nil +} + func (s *PatchUserSetting) Patch(c *gin.Context) error { dep := dependency.FromContext(c) u := inventory.UserFromContext(c) @@ -302,6 +326,14 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error { saveSetting = true } + if s.PreferredViewers != nil { + if err := validatePreferredViewers(*s.PreferredViewers); err != nil { + return err + } + u.Settings.PreferredViewers = *s.PreferredViewers + saveSetting = true + } + if s.CurrentPassword != nil && s.NewPassword != nil { if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil { return err diff --git a/service/user/setting_test.go b/service/user/setting_test.go index dd422af5..c9e724e4 100644 --- a/service/user/setting_test.go +++ b/service/user/setting_test.go @@ -3,6 +3,7 @@ package user import ( "context" "net/http/httptest" + "strconv" "testing" "github.com/cloudreve/Cloudreve/v4/application/dependency" @@ -81,3 +82,65 @@ func TestPatchUserSettingShareDefaults(t *testing.T) { // Invalid profile visibility rejected. require.Error(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr("friends_only")})) } + +// TestPatchUserSettingPreferredViewers verifies the extension → viewer-id map +// is persisted and validated (upstream: persist "always open with" in DB). +func TestPatchUserSettingPreferredViewers(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("viewers@example.com"). + SetNick("viewers"). + SetStatus("active"). + SetGroup(group). + SetSettings(&types.UserSetting{}). + SaveX(ctx) + + newCtx := func() *gin.Context { + engine := gin.New() + engine.ContextWithFallback = true + c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine) + c.Request = httptest.NewRequest("PATCH", "/", nil) + util.WithValue(c, dependency.DepCtx{}, dependency.NewDependency( + dependency.WithUserClient(inventory.NewUserClient(client)), + )) + util.WithValue(c, inventory.UserCtx{}, u) + return c + } + + patch := func(s *PatchUserSetting) error { + return s.Patch(newCtx()) + } + + // Valid map persists. + valid := map[string]string{"md": "codemirror", "epub": "epub"} + require.NoError(t, patch(&PatchUserSetting{PreferredViewers: &valid})) + require.Equal(t, "codemirror", u.Settings.PreferredViewers["md"]) + + persisted := client.User.GetX(ctx, u.ID) + require.Equal(t, "epub", persisted.Settings.PreferredViewers["epub"]) + + // Empty map clears all preferences. + empty := map[string]string{} + require.NoError(t, patch(&PatchUserSetting{PreferredViewers: &empty})) + require.Empty(t, u.Settings.PreferredViewers) + + // Invalid extension key rejected. + badExt := map[string]string{"../evil": "x"} + require.Error(t, patch(&PatchUserSetting{PreferredViewers: &badExt})) + + // Invalid viewer id rejected. + badID := map[string]string{"md": "../../etc"} + require.Error(t, patch(&PatchUserSetting{PreferredViewers: &badID})) + + // Oversized map rejected. + big := map[string]string{} + for i := 0; i < preferredViewersMaxEntries+1; i++ { + big[string(rune('a'+i%26))+strconv.Itoa(i)] = "v" + } + require.Error(t, patch(&PatchUserSetting{PreferredViewers: &big})) +}