|
|
package user
|
|
|
|
|
|
import (
|
|
|
"context"
|
|
|
"crypto/md5"
|
|
|
"fmt"
|
|
|
"io"
|
|
|
"net/http"
|
|
|
"net/url"
|
|
|
"os"
|
|
|
"path/filepath"
|
|
|
"regexp"
|
|
|
"strings"
|
|
|
|
|
|
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
|
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
|
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
|
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/activity"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/request"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/thumb"
|
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
|
|
"github.com/gin-gonic/gin"
|
|
|
"github.com/pquerna/otp/totp"
|
|
|
"github.com/samber/lo"
|
|
|
)
|
|
|
|
|
|
const (
|
|
|
twoFaEnableSessionKey = "2fa_init_"
|
|
|
)
|
|
|
|
|
|
// Init2FA 初始化二步验证
|
|
|
func Init2FA(c *gin.Context) (string, error) {
|
|
|
dep := dependency.FromContext(c)
|
|
|
user := inventory.UserFromContext(c)
|
|
|
|
|
|
key, err := totp.Generate(totp.GenerateOpts{
|
|
|
Issuer: "Cloudreve",
|
|
|
AccountName: user.Email,
|
|
|
})
|
|
|
if err != nil {
|
|
|
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to generate TOTP secret", err)
|
|
|
}
|
|
|
|
|
|
if err := dep.KV().Set(fmt.Sprintf("%s%d", twoFaEnableSessionKey, user.ID), key.Secret(), 600); err != nil {
|
|
|
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to store TOTP session", err)
|
|
|
}
|
|
|
|
|
|
return key.Secret(), nil
|
|
|
}
|
|
|
|
|
|
type (
|
|
|
// AvatarService Service to get avatar
|
|
|
GetAvatarService struct {
|
|
|
NoCache bool `form:"nocache"`
|
|
|
}
|
|
|
GetAvatarServiceParamsCtx struct{}
|
|
|
)
|
|
|
|
|
|
const (
|
|
|
GravatarAvatar = "gravatar"
|
|
|
FileAvatar = "file"
|
|
|
)
|
|
|
|
|
|
// Get 获取用户头像
|
|
|
func (service *GetAvatarService) Get(c *gin.Context) error {
|
|
|
dep := dependency.FromContext(c)
|
|
|
settings := dep.SettingProvider()
|
|
|
// 查找目标用户
|
|
|
uid := hashid.FromContext(c)
|
|
|
userClient := dep.UserClient()
|
|
|
user, err := userClient.GetByID(c, uid)
|
|
|
|
|
|
if err != nil {
|
|
|
return serializer.NewError(serializer.CodeUserNotFound, "", err)
|
|
|
}
|
|
|
|
|
|
if !service.NoCache {
|
|
|
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", settings.PublicResourceMaxAge(c)))
|
|
|
}
|
|
|
|
|
|
// 未设定头像时,返回404错误
|
|
|
if user.Avatar == "" {
|
|
|
c.Status(404)
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
avatarSettings := settings.Avatar(c)
|
|
|
|
|
|
// Gravatar 头像重定向
|
|
|
if user.Avatar == GravatarAvatar {
|
|
|
gravatarRoot, err := url.Parse(avatarSettings.Gravatar)
|
|
|
if err != nil {
|
|
|
return serializer.NewError(serializer.CodeInternalSetting, "Failed to parse Gravatar server", err)
|
|
|
}
|
|
|
email_lowered := strings.ToLower(user.Email)
|
|
|
has := md5.Sum([]byte(email_lowered))
|
|
|
avatar, _ := url.Parse(fmt.Sprintf("/avatar/%x?d=mm&s=200", has))
|
|
|
|
|
|
c.Redirect(http.StatusFound, gravatarRoot.ResolveReference(avatar).String())
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
// 本地文件头像
|
|
|
if user.Avatar == FileAvatar {
|
|
|
avatarRoot := util.DataPath(avatarSettings.Path)
|
|
|
|
|
|
avatar, err := os.Open(filepath.Join(avatarRoot, fmt.Sprintf("avatar_%d.png", user.ID)))
|
|
|
if err != nil {
|
|
|
dep.Logger().Warning("Failed to open avatar file", err)
|
|
|
c.Status(404)
|
|
|
}
|
|
|
defer avatar.Close()
|
|
|
|
|
|
http.ServeContent(c.Writer, c.Request, "avatar.png", user.UpdatedAt, avatar)
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
c.Status(404)
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
// Settings 获取用户设定
|
|
|
func GetUserSettings(c *gin.Context) (*UserSettings, error) {
|
|
|
dep := dependency.FromContext(c)
|
|
|
u := inventory.UserFromContext(c)
|
|
|
userClient := dep.UserClient()
|
|
|
passkeys, err := userClient.ListPasskeys(c, u.ID)
|
|
|
if err != nil {
|
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user passkey", err)
|
|
|
}
|
|
|
|
|
|
ctx := context.WithValue(c, inventory.LoadOAuthGrantClient{}, true)
|
|
|
grants, err := dep.OAuthClientClient().GetGrantsByUserID(ctx, u.ID)
|
|
|
if err != nil {
|
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user OAuth grants", err)
|
|
|
}
|
|
|
|
|
|
res := BuildUserSettings(u, passkeys, dep.UAParser(), grants)
|
|
|
if u.Settings.PreferredPolicy > 0 {
|
|
|
res.PreferredPolicy = hashid.EncodePolicyID(dep.HashIDEncoder(), u.Settings.PreferredPolicy)
|
|
|
}
|
|
|
return res, nil
|
|
|
|
|
|
// 用户组有效期
|
|
|
|
|
|
//return serializer.Response{
|
|
|
// Data: map[string]interface{}{
|
|
|
// "uid": user.ID,
|
|
|
// "qq": user.OpenID != "",
|
|
|
// "homepage": !user.OptionsSerialized.ProfileOff,
|
|
|
// "two_factor": user.TwoFactor != "",
|
|
|
// "prefer_theme": user.OptionsSerialized.PreferredTheme,
|
|
|
// "themes": model.GetSettingByName("themes"),
|
|
|
// "group_expires": groupExpires,
|
|
|
// "authn": serializer.BuildWebAuthnList(user.WebAuthnCredentials()),
|
|
|
// },
|
|
|
//}
|
|
|
}
|
|
|
|
|
|
func UpdateUserAvatar(c *gin.Context) error {
|
|
|
dep := dependency.FromContext(c)
|
|
|
u := inventory.UserFromContext(c)
|
|
|
settings := dep.SettingProvider()
|
|
|
|
|
|
avatarSettings := settings.AvatarProcess(c)
|
|
|
if c.Request.ContentLength == -1 || c.Request.ContentLength > avatarSettings.MaxFileSize {
|
|
|
request.BlackHole(c.Request.Body)
|
|
|
return serializer.NewError(serializer.CodeFileTooLarge, "", nil)
|
|
|
}
|
|
|
|
|
|
if c.Request.ContentLength == 0 {
|
|
|
// Use Gravatar for empty body
|
|
|
if _, err := dep.UserClient().UpdateAvatar(c, u, GravatarAvatar); err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to update user avatar", err)
|
|
|
}
|
|
|
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventChangeAvatar)
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
return updateAvatarFile(c, u, c.GetHeader("Content-Type"), c.Request.Body, avatarSettings)
|
|
|
}
|
|
|
|
|
|
func updateAvatarFile(ctx context.Context, u *ent.User, contentType string, file io.Reader, avatarSettings *setting.AvatarProcess) error {
|
|
|
dep := dependency.FromContext(ctx)
|
|
|
// Detect ext from content type
|
|
|
ext := "png"
|
|
|
switch contentType {
|
|
|
case "image/jpeg", "image/jpg":
|
|
|
ext = "jpg"
|
|
|
case "image/gif":
|
|
|
ext = "gif"
|
|
|
}
|
|
|
avatar, err := thumb.NewThumbFromFile(file, ext)
|
|
|
if err != nil {
|
|
|
return serializer.NewError(serializer.CodeParamErr, "Invalid image", err)
|
|
|
}
|
|
|
|
|
|
// Resize and save avatar
|
|
|
avatar.CreateAvatar(avatarSettings.MaxWidth)
|
|
|
avatarRoot := util.DataPath(avatarSettings.Path)
|
|
|
f, err := util.CreatNestedFile(filepath.Join(avatarRoot, fmt.Sprintf("avatar_%d.png", u.ID)))
|
|
|
if err != nil {
|
|
|
return serializer.NewError(serializer.CodeIOFailed, "Failed to create avatar file", err)
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
if err := avatar.Save(f, &setting.ThumbEncode{
|
|
|
Quality: 100,
|
|
|
Format: "png",
|
|
|
}); err != nil {
|
|
|
return serializer.NewError(serializer.CodeIOFailed, "Failed to save avatar file", err)
|
|
|
}
|
|
|
|
|
|
if _, err := dep.UserClient().UpdateAvatar(ctx, u, FileAvatar); err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to update user avatar", err)
|
|
|
}
|
|
|
activity.Record(ctx, dep.SettingProvider(), dep.ActivityClient(), types.EventChangeAvatar)
|
|
|
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
type (
|
|
|
PatchUserSetting struct {
|
|
|
Nick *string `json:"nick" binding:"omitempty,min=1,max=255"`
|
|
|
Language *string `json:"language" binding:"omitempty,min=1,max=255"`
|
|
|
PreferredTheme *string `json:"preferred_theme" binding:"omitempty,hexcolor|rgb|rgba|hsl"`
|
|
|
VersionRetentionEnabled *bool `json:"version_retention_enabled" binding:"omitempty"`
|
|
|
VersionRetentionExt *[]string `json:"version_retention_ext" binding:"omitempty"`
|
|
|
VersionRetentionMax *int `json:"version_retention_max" binding:"omitempty,min=0"`
|
|
|
CurrentPassword *string `json:"current_password" binding:"omitempty,min=4,max=128"`
|
|
|
NewPassword *string `json:"new_password" binding:"omitempty,min=6,max=128"`
|
|
|
TwoFAEnabled *bool `json:"two_fa_enabled" binding:"omitempty"`
|
|
|
TwoFACode *string `json:"two_fa_code" binding:"omitempty"`
|
|
|
DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"`
|
|
|
ShareLinksInProfile *string `json:"share_links_in_profile" binding:"omitempty"`
|
|
|
// 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"`
|
|
|
// TrashRetention overrides group trash retention, in seconds.
|
|
|
// 0 clears the override. Capped at ~10 years.
|
|
|
TrashRetention *int `json:"trash_retention" binding:"omitempty,min=0,max=315360000"`
|
|
|
// PreferredPolicy selects the user's default storage policy from the
|
|
|
// group's allowed set, hashid-encoded. "" clears the preference.
|
|
|
PreferredPolicy *string `json:"preferred_policy" 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)
|
|
|
userClient := dep.UserClient()
|
|
|
saveSetting := false
|
|
|
|
|
|
if s.Nick != nil {
|
|
|
if _, err := userClient.UpdateNickname(c, u, *s.Nick); err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to update user nick", err)
|
|
|
}
|
|
|
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventChangeNick)
|
|
|
}
|
|
|
|
|
|
if s.Language != nil {
|
|
|
u.Settings.Language = *s.Language
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.PreferredTheme != nil {
|
|
|
u.Settings.PreferredTheme = *s.PreferredTheme
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.VersionRetentionEnabled != nil {
|
|
|
u.Settings.VersionRetention = *s.VersionRetentionEnabled
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.VersionRetentionExt != nil {
|
|
|
u.Settings.VersionRetentionExt = *s.VersionRetentionExt
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.VersionRetentionMax != nil {
|
|
|
u.Settings.VersionRetentionMax = *s.VersionRetentionMax
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.DisableViewSync != nil {
|
|
|
u.Settings.DisableViewSync = *s.DisableViewSync
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.ShareLinksInProfile != nil {
|
|
|
level := types.ShareLinksInProfileLevel(*s.ShareLinksInProfile)
|
|
|
switch level {
|
|
|
case types.ProfilePublicShareOnly, types.ProfileAllShare, types.ProfileHideShare, types.ProfileSharePublic:
|
|
|
default:
|
|
|
return serializer.NewError(serializer.CodeParamErr, "Invalid share links visibility", nil)
|
|
|
}
|
|
|
u.Settings.ShareLinksInProfile = level
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.ShareDefaultPrivate != nil {
|
|
|
if *s.ShareDefaultPrivate != "" && *s.ShareDefaultPrivate != "true" && *s.ShareDefaultPrivate != "false" {
|
|
|
return serializer.NewError(serializer.CodeParamErr, "Invalid share privacy default", nil)
|
|
|
}
|
|
|
u.Settings.ShareDefaultPrivate = nil
|
|
|
if *s.ShareDefaultPrivate != "" {
|
|
|
v := *s.ShareDefaultPrivate == "true"
|
|
|
u.Settings.ShareDefaultPrivate = &v
|
|
|
}
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.PreferredViewers != nil {
|
|
|
if err := validatePreferredViewers(*s.PreferredViewers); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
u.Settings.PreferredViewers = *s.PreferredViewers
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.TrashRetention != nil {
|
|
|
u.Settings.TrashRetention = *s.TrashRetention
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.PreferredPolicy != nil {
|
|
|
if *s.PreferredPolicy == "" {
|
|
|
u.Settings.PreferredPolicy = 0
|
|
|
} else {
|
|
|
pid, err := dep.HashIDEncoder().Decode(*s.PreferredPolicy, hashid.PolicyID)
|
|
|
if err != nil {
|
|
|
return serializer.NewError(serializer.CodeParamErr, "Invalid storage policy", err)
|
|
|
}
|
|
|
allowed, err := dep.StoragePolicyClient().ListByGroup(c, u.Edges.Group)
|
|
|
if err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to list storage policies", err)
|
|
|
}
|
|
|
if !lo.ContainsBy(allowed, func(p *ent.StoragePolicy) bool { return p.ID == pid }) {
|
|
|
return serializer.NewError(serializer.CodeNoPermissionErr, "Storage policy is not available for your group", nil)
|
|
|
}
|
|
|
u.Settings.PreferredPolicy = pid
|
|
|
}
|
|
|
saveSetting = true
|
|
|
}
|
|
|
|
|
|
if s.CurrentPassword != nil && s.NewPassword != nil {
|
|
|
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
if err := inventory.CheckPassword(u, *s.CurrentPassword); err != nil {
|
|
|
return serializer.NewError(serializer.CodeIncorrectPassword, "Incorrect password", err)
|
|
|
}
|
|
|
|
|
|
if _, err := userClient.UpdatePassword(c, u, *s.NewPassword); err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to update user password", err)
|
|
|
}
|
|
|
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventChangePassword)
|
|
|
}
|
|
|
|
|
|
if s.TwoFAEnabled != nil {
|
|
|
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
|
|
|
return err
|
|
|
}
|
|
|
|
|
|
if *s.TwoFAEnabled {
|
|
|
kv := dep.KV()
|
|
|
secret, ok := kv.Get(fmt.Sprintf("%s%d", twoFaEnableSessionKey, u.ID))
|
|
|
if !ok {
|
|
|
return serializer.NewError(serializer.CodeInternalSetting, "You have not initiated 2FA session", nil)
|
|
|
}
|
|
|
|
|
|
if !totp.Validate(*s.TwoFACode, secret.(string)) {
|
|
|
return serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
|
|
|
}
|
|
|
|
|
|
if _, err := userClient.UpdateTwoFASecret(c, u, secret.(string)); err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to update user 2FA", err)
|
|
|
}
|
|
|
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventEnable2FA)
|
|
|
|
|
|
} else {
|
|
|
if !totp.Validate(*s.TwoFACode, u.TwoFactorSecret) {
|
|
|
return serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
|
|
|
}
|
|
|
|
|
|
if _, err := userClient.UpdateTwoFASecret(c, u, ""); err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to update user 2FA", err)
|
|
|
}
|
|
|
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventDisable2FA)
|
|
|
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if saveSetting {
|
|
|
if err := userClient.SaveSettings(c, u); err != nil {
|
|
|
return serializer.NewError(serializer.CodeDBError, "Failed to update user settings", err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
}
|