feat(user): server-synced open-with defaults + visible TOTP setup key

#5 — preferred_viewers map on user settings (ext → viewer id), patched via
/user/setting, exposed on the login User object and settings response.
openViewers consults the synced map before localStorage, so "always open
with" follows the user across devices; stale viewer ids fall back to the
local copy. Preference page lists saved ext → app pairs with per-entry
removal. Map is shape-validated and capped at 200 entries.

#3 — the 2FA setup dialog now shows the raw TOTP secret under the QR code
with click-to-copy, for authenticators that can't scan.

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 bb53ee5c4c
commit 606ee032d3

@ -836,6 +836,9 @@
"syncViewOff": "Do not sync", "syncViewOff": "Do not sync",
"folderClickAction": "Folder click action", "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).", "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", "folderClickOpen": "Open",
"folderClickSelect": "Select", "folderClickSelect": "Select",
"noAuthenticator": "Add a passkey to sign in using fingerprint, face or USB key.", "noAuthenticator": "Add a passkey to sign in using fingerprint, face or USB key.",
@ -961,6 +964,8 @@
"disable2FA": "Disable 2FA authentication", "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.", "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.", "inputCurrent2FACode": "Enter current 2FA verification code.",
"copySecret": "Can't scan? Click to copy the setup key",
"secretCopied": "Setup key copied",
"timeZoneCode": "IANA timezone code", "timeZoneCode": "IANA timezone code",
"authenticatorRemoved": "Authenticator removed.", "authenticatorRemoved": "Authenticator removed.",
"authenticatorAdded": "Authenticator added.", "authenticatorAdded": "Authenticator added.",

@ -836,6 +836,9 @@
"syncViewOff": "不同步", "syncViewOff": "不同步",
"folderClickAction": "文件夹点击操作", "folderClickAction": "文件夹点击操作",
"folderClickActionDes": "选择点击文件夹时的行为。「打开」将进入文件夹,「选中」将选中文件夹(双击打开)。", "folderClickActionDes": "选择点击文件夹时的行为。「打开」将进入文件夹,「选中」将选中文件夹(双击打开)。",
"preferredViewers": "默认打开方式",
"preferredViewersDes": "在「打开方式」对话框中选择「始终」后保存的应用,会在所有设备上生效。删除后下次打开时将重新询问。",
"preferredViewersEmpty": "尚未保存默认应用。在文件的「打开方式」对话框中选择「始终」即可添加。",
"folderClickOpen": "打开", "folderClickOpen": "打开",
"folderClickSelect": "选中", "folderClickSelect": "选中",
"noAuthenticator": "添加通行密钥以使用人脸、指纹或 USB 密钥登录账号", "noAuthenticator": "添加通行密钥以使用人脸、指纹或 USB 密钥登录账号",
@ -961,6 +964,8 @@
"disable2FA": "关闭二步验证", "disable2FA": "关闭二步验证",
"2faDescription": "请使用任意二步验证 APP 或者支持二步验证的密码管理软件扫描二维码添加本站。扫描完成后请填写二步验证 APP 给出的 6 位验证码以开启二步验证。", "2faDescription": "请使用任意二步验证 APP 或者支持二步验证的密码管理软件扫描二维码添加本站。扫描完成后请填写二步验证 APP 给出的 6 位验证码以开启二步验证。",
"inputCurrent2FACode": "输入当前二步验证 APP 给出的 6 位验证码:", "inputCurrent2FACode": "输入当前二步验证 APP 给出的 6 位验证码:",
"copySecret": "无法扫码?点击复制密钥",
"secretCopied": "已复制密钥",
"timeZoneCode": "IANA 时区名称标识", "timeZoneCode": "IANA 时区名称标识",
"authenticatorRemoved": "凭证已删除", "authenticatorRemoved": "凭证已删除",
"authenticatorAdded": "验证器已添加", "authenticatorAdded": "验证器已添加",

@ -27,6 +27,7 @@ export interface User {
disable_view_sync?: boolean; disable_view_sync?: boolean;
share_links_in_profile?: ShareLinksInProfileLevel; share_links_in_profile?: ShareLinksInProfileLevel;
share_default_private?: boolean; share_default_private?: boolean;
preferred_viewers?: Record<string, string>;
} }
export interface Group { export interface Group {
id: string; id: string;
@ -142,6 +143,7 @@ export interface UserSettings {
disable_view_sync: boolean; disable_view_sync: boolean;
share_links_in_profile: ShareLinksInProfileLevel; share_links_in_profile: ShareLinksInProfileLevel;
share_default_private?: boolean; share_default_private?: boolean;
preferred_viewers?: Record<string, string>;
oauth_grants?: OAuthGrant[]; oauth_grants?: OAuthGrant[];
} }
@ -168,6 +170,7 @@ export interface PatchUserSetting {
share_links_in_profile?: ShareLinksInProfileLevel; share_links_in_profile?: ShareLinksInProfileLevel;
// Tri-state: "true" / "false" / "" (inherit the site default). // Tri-state: "true" / "false" / "" (inherit the site default).
share_default_private?: string; share_default_private?: string;
preferred_viewers?: Record<string, string>;
} }
export interface PasskeyCredentialOption { export interface PasskeyCredentialOption {

@ -13,6 +13,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { sendUpdateUserSetting } from "../../../api/api.ts";
import { Viewer, ViewerType } from "../../../api/explorer.ts"; import { Viewer, ViewerType } from "../../../api/explorer.ts";
import { closeViewerSelector } from "../../../redux/globalStateSlice.ts"; import { closeViewerSelector } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
@ -129,7 +130,15 @@ const OpenWith = () => {
} }
if (always) { 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( dispatch(

@ -23,6 +23,7 @@ import { UserSettings as UserSettingsType } from "../../../api/user.ts";
import { languages } from "../../../i18n.ts"; import { languages } from "../../../i18n.ts";
import { setPreferredTheme } from "../../../redux/globalStateSlice.ts"; import { setPreferredTheme } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { ViewersByID } from "../../../redux/siteConfigSlice.ts";
import { clearLocalCustomView } from "../../../redux/thunks/filemanager.ts"; import { clearLocalCustomView } from "../../../redux/thunks/filemanager.ts";
import { selectLanguage } from "../../../redux/thunks/settings.ts"; import { selectLanguage } from "../../../redux/thunks/settings.ts";
import SessionManager, { UserSettings } from "../../../session"; 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 ( return (
<Stack spacing={3}> <Stack spacing={3}>
<SettingForm title={t("setting.language")} lgWidth={3}> <SettingForm title={t("setting.language")} lgWidth={3}>
@ -375,6 +397,25 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
</ToggleButtonGroup> </ToggleButtonGroup>
<FormHelperText>{t("setting.folderClickActionDes")}</FormHelperText> <FormHelperText>{t("setting.folderClickActionDes")}</FormHelperText>
</SettingForm> </SettingForm>
<SettingForm title={t("setting.preferredViewers")} lgWidth={12}>
<Box>
{preferredViewerEntries.length == 0 && (
<FormHelperText>{t("setting.preferredViewersEmpty")}</FormHelperText>
)}
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{preferredViewerEntries.map(([ext, viewerId]) => (
<Chip
key={ext}
size="small"
label={`.${ext} → ${ViewersByID[viewerId] ? t(ViewersByID[viewerId].display_name) : viewerId}`}
onDelete={() => removePreferredViewer(ext)}
disabled={loading}
/>
))}
</Stack>
<FormHelperText>{t("setting.preferredViewersDes")}</FormHelperText>
</Box>
</SettingForm>
</Stack> </Stack>
); );
}; };

@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next";
import { useSnackbar } from "notistack"; import { useSnackbar } from "notistack";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts"; import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import React, { useEffect, useState } from "react"; 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 { CSSTransition, SwitchTransition } from "react-transition-group";
import AutoHeight from "../../../Common/AutoHeight.tsx"; import AutoHeight from "../../../Common/AutoHeight.tsx";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx"; import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import { get2FAInitSecret, sendUpdateUserSetting } from "../../../../api/api.ts"; import { get2FAInitSecret, sendUpdateUserSetting } from "../../../../api/api.ts";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import SessionManager from "../../../../session"; import SessionManager from "../../../../session";
import { copyToClipboard } from "../../../../util";
import { MuiOtpInput } from "mui-one-time-password-input"; import { MuiOtpInput } from "mui-one-time-password-input";
export interface Enable2FADialogProps { export interface Enable2FADialogProps {
@ -129,6 +130,25 @@ const Enable2FADialog = ({ open, onClose, on2FAEnabled }: Enable2FADialogProps)
<Typography variant={"body2"} sx={{ mt: 1 }}> <Typography variant={"body2"} sx={{ mt: 1 }}>
{t("setting.2faDescription")} {t("setting.2faDescription")}
</Typography> </Typography>
<Tooltip title={t("setting.copySecret")}>
<Typography
variant={"body2"}
onClick={() => {
copyToClipboard(secret);
enqueueSnackbar({ message: t("setting.secretCopied"), variant: "success" });
}}
sx={{
mt: 1,
fontFamily: "monospace",
letterSpacing: 1,
cursor: "pointer",
wordBreak: "break-all",
userSelect: "all",
}}
>
{secret}
</Typography>
</Tooltip>
<Typography variant={"body2"} sx={{ mt: 1 }}> <Typography variant={"body2"} sx={{ mt: 1 }}>
{t("setting.inputCurrent2FACode")} {t("setting.inputCurrent2FACode")}
</Typography> </Typography>

@ -74,12 +74,18 @@ export function openViewers(
const ext = fileExtension(file.name) ?? ""; const ext = fileExtension(file.name) ?? "";
const entitySize = size ?? file.size; const entitySize = size ?? file.size;
// Try user preference // Try user preference — the server-synced map wins over the per-device
const userPreference = SessionManager.get(UserSettings.OpenWithPrefix + ext); // localStorage copy so the choice follows the user across devices. A
if (!ignorePreference && userPreference && ViewersByID[userPreference]) { // 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)); dispatch(openViewer(file, ViewersByID[userPreference], entitySize, preferredVersion));
return; return;
} }
}
const viewerOptions = Viewers[ext]; const viewerOptions = Viewers[ext];

@ -20,6 +20,10 @@ type (
// ShareDefaultPrivate overrides the site-wide private-share default // ShareDefaultPrivate overrides the site-wide private-share default
// for this user. nil means inherit the site default. // for this user. nil means inherit the site default.
ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` 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 ShareLinksInProfileLevel string

@ -33,6 +33,7 @@ type UserSettings struct {
DisableViewSync bool `json:"disable_view_sync"` DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"` ShareLinksInProfile string `json:"share_links_in_profile"`
ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` ShareDefaultPrivate *bool `json:"share_default_private,omitempty"`
PreferredViewers map[string]string `json:"preferred_viewers,omitempty"`
OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"` OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
} }
@ -49,6 +50,7 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
DisableViewSync: u.Settings.DisableViewSync, DisableViewSync: u.Settings.DisableViewSync,
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile), ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
ShareDefaultPrivate: u.Settings.ShareDefaultPrivate, ShareDefaultPrivate: u.Settings.ShareDefaultPrivate,
PreferredViewers: u.Settings.PreferredViewers,
OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant { OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant {
return BuildOauthGrant(item) return BuildOauthGrant(item)
}), }),
@ -124,6 +126,8 @@ type User struct {
// the site default applies. Never resolved here — the dialog computes the // the site default applies. Never resolved here — the dialog computes the
// effective default from user ?? site. // effective default from user ?? site.
ShareDefaultPrivate *bool `json:"share_default_private,omitempty"` 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 { type Group struct {
@ -182,6 +186,7 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User {
DisableViewSync: user.Settings.DisableViewSync, DisableViewSync: user.Settings.DisableViewSync,
ShareLinksInProfile: user.Settings.ShareLinksInProfile, ShareLinksInProfile: user.Settings.ShareLinksInProfile,
ShareDefaultPrivate: user.Settings.ShareDefaultPrivate, ShareDefaultPrivate: user.Settings.ShareDefaultPrivate,
PreferredViewers: user.Settings.PreferredViewers,
} }
} }

@ -9,6 +9,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/application/dependency"
@ -233,10 +234,33 @@ type (
// ShareDefaultPrivate accepts "true", "false" or "" (clear the // ShareDefaultPrivate accepts "true", "false" or "" (clear the
// override and inherit the site default). // override and inherit the site default).
ShareDefaultPrivate *string `json:"share_default_private" binding:"omitempty"` 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{} 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 { func (s *PatchUserSetting) Patch(c *gin.Context) error {
dep := dependency.FromContext(c) dep := dependency.FromContext(c)
u := inventory.UserFromContext(c) u := inventory.UserFromContext(c)
@ -302,6 +326,14 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error {
saveSetting = true 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 s.CurrentPassword != nil && s.NewPassword != nil {
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil { if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return err return err

@ -3,6 +3,7 @@ package user
import ( import (
"context" "context"
"net/http/httptest" "net/http/httptest"
"strconv"
"testing" "testing"
"github.com/cloudreve/Cloudreve/v4/application/dependency" "github.com/cloudreve/Cloudreve/v4/application/dependency"
@ -81,3 +82,65 @@ func TestPatchUserSettingShareDefaults(t *testing.T) {
// Invalid profile visibility rejected. // Invalid profile visibility rejected.
require.Error(t, patch(&PatchUserSetting{ShareLinksInProfile: strPtr("friends_only")})) 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}))
}

Loading…
Cancel
Save