chore: 初始化 Cloudreve 源码仓库

This commit is contained in:
2026-09-13 11:28:59 +08:00
commit 2a7b03ab1d
489 changed files with 163867 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
package user
import (
"context"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func GetUser(c *gin.Context) (*ent.User, error) {
uid := hashid.FromContext(c)
dep := dependency.FromContext(c)
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
return userClient.GetByID(ctx, uid)
}
func GetUserCapacity(c *gin.Context) (*fs.Capacity, error) {
user := inventory.UserFromContext(c)
dep := dependency.FromContext(c)
m := manager.NewFileManager(dep, user)
defer m.Recycle()
return m.Capacity(c)
}
type (
SearchUserService struct {
Keyword string `form:"keyword" binding:"required,min=2"`
}
SearchUserParamCtx struct{}
)
const resultLimit = 10
func (s *SearchUserService) Search(c *gin.Context) ([]*ent.User, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
res, err := userClient.SearchActive(c, resultLimit, s.Keyword)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to search user", err)
}
return res, nil
}
// ListAllGroups lists all groups.
func ListAllGroups(c *gin.Context) ([]*ent.Group, error) {
dep := dependency.FromContext(c)
groupClient := dep.GroupClient()
res, err := groupClient.ListAll(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list all groups", err)
}
res = lo.Filter(res, func(g *ent.Group, index int) bool {
return g.ID != inventory.AnonymousGroupID
})
return res, nil
}
+265
View File
@@ -0,0 +1,265 @@
package user
import (
"context"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"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/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/pquerna/otp/totp"
)
// LoginParameterCtx define key fore UserLoginService
type LoginParameterCtx struct{}
// UserLoginService 管理用户登录的服务
type UserLoginService struct {
UserName string `form:"email" json:"email" binding:"required,email"`
Password string `form:"password" json:"password" binding:"required,min=4,max=128"`
}
type (
// UserResetService 密码重设服务
UserResetService struct {
Password string `form:"password" json:"password" binding:"required,min=6,max=128"`
Secret string `json:"secret" binding:"required"`
}
UserResetParameterCtx struct{}
)
// Reset 重设密码
func (service *UserResetService) Reset(c *gin.Context) (*User, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
kv := dep.KV()
uid := hashid.FromContext(c)
resetSession, ok := kv.Get(fmt.Sprintf("user_reset_%d", uid))
if !ok || resetSession.(string) != service.Secret {
return nil, serializer.NewError(serializer.CodeTempLinkExpired, "Link is expired", nil)
}
if err := kv.Delete(fmt.Sprintf("user_reset_%d", uid)); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to delete reset session", err)
}
u, err := userClient.GetActiveByID(c, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
u, err = userClient.UpdatePassword(c, u, service.Password)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to update password", err)
}
userRes := BuildUser(u, dep.HashIDEncoder())
return &userRes, nil
}
type (
// UserResetEmailService 发送密码重设邮件服务
UserResetEmailService struct {
UserName string `form:"email" json:"email" binding:"required,email"`
Language string `form:"language" json:"language"`
}
UserResetEmailParameterCtx struct{}
)
const userResetPrefix = "user_reset_"
// Reset 发送密码重设邮件
func (service *UserResetEmailService) Reset(c *gin.Context) error {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
u, err := userClient.GetByEmail(c, service.UserName)
if err != nil {
return serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned {
return serializer.NewError(serializer.CodeUserBaned, "This user is banned", nil)
}
if u.Status == user.StatusInactive {
return serializer.NewError(serializer.CodeUserNotActivated, "This user is not activated", nil)
}
secret := util.RandStringRunesCrypto(32)
if err := dep.KV().Set(fmt.Sprintf("%s%d", userResetPrefix, u.ID), secret, 3600); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create reset session", err)
}
base := dep.SettingProvider().SiteURL(c)
resetUrl := routes.MasterUserResetUrl(base)
queries := resetUrl.Query()
queries.Add("id", hashid.EncodeUserID(dep.HashIDEncoder(), u.ID))
queries.Add("secret", secret)
resetUrl.RawQuery = queries.Encode()
title, body, err := email.NewResetEmail(c, dep.SettingProvider(), u, resetUrl.String())
if err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
if err := dep.EmailClient(c).Send(c, u.Email, title, body); err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
return nil
}
// Login 用户登录函数
func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
expectedUser, err := userClient.GetByEmail(ctx, service.UserName)
// 一系列校验
if err != nil {
err = serializer.NewError(serializer.CodeInvalidPassword, "Incorrect password or email address", err)
} else if checkErr := inventory.CheckPassword(expectedUser, service.Password); checkErr != nil {
err = serializer.NewError(serializer.CodeInvalidPassword, "Incorrect password or email address", err)
} else if expectedUser.Status == user.StatusManualBanned || expectedUser.Status == user.StatusSysBanned {
err = serializer.NewError(serializer.CodeUserBaned, "This account has been blocked", nil)
} else if expectedUser.Status == user.StatusInactive {
err = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil)
}
if err != nil {
return nil, "", err
}
if expectedUser.TwoFactorSecret != "" {
twoFaSessionID := uuid.Must(uuid.NewV4())
dep.KV().Set(fmt.Sprintf("user_2fa_%s", twoFaSessionID), expectedUser.ID, 600)
return expectedUser, twoFaSessionID.String(), nil
}
return expectedUser, "", nil
}
type (
LoginLogCtx struct{}
)
func IssueToken(c *gin.Context) (*BuiltinLoginResponse, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
token, err := dep.TokenAuth().Issue(c, &auth.IssueTokenArgs{
User: u,
RootTokenID: nil,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue token pair", err)
}
return &BuiltinLoginResponse{
User: BuildUser(u, dep.HashIDEncoder()),
Token: *token,
}, nil
}
// RefreshTokenParameterCtx define key fore RefreshTokenService
type RefreshTokenParameterCtx struct{}
// RefreshTokenService refresh token service
type RefreshTokenService struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (s *RefreshTokenService) Refresh(c *gin.Context) (*auth.Token, error) {
dep := dependency.FromContext(c)
token, err := dep.TokenAuth().Refresh(c, s.RefreshToken)
if err != nil {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Failed to issue token pair", err)
}
return token, nil
}
func (s *RefreshTokenService) Delete(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
claims, err := dep.TokenAuth().Claims(c, s.RefreshToken)
if err != nil {
return "", serializer.NewError(serializer.CodeCredentialInvalid, "Failed to parse token", err)
}
// Block root token
if claims.RootTokenID != nil {
tokenSettings := dep.SettingProvider().TokenAuth(c)
dep.KV().Set(fmt.Sprintf("%s%s", auth.RevokeTokenPrefix, claims.RootTokenID.String()), true, int(tokenSettings.RefreshTokenTTL.Seconds()+10))
}
return "", nil
}
type (
OtpValidationParameterCtx struct{}
OtpValidationService struct {
OTP string `json:"otp" binding:"required"`
SessionID string `json:"session_id" binding:"required"`
}
)
// Login 用户登录函数
func (service *OtpValidationService) Verify2FA(c *gin.Context) (*ent.User, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
sessionRaw, ok := kv.Get(fmt.Sprintf("user_2fa_%s", service.SessionID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
uid := sessionRaw.(int)
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
expectedUser, err := dep.UserClient().GetByID(ctx, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "User not found", err)
}
if expectedUser.TwoFactorSecret != "" {
if !totp.Validate(service.OTP, expectedUser.TwoFactorSecret) {
err := serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
return nil, err
}
}
kv.Delete("user_2fa_", service.SessionID)
return expectedUser, nil
}
type (
PrepareLoginParameterCtx struct{}
PrepareLoginService struct {
Email string `form:"email" binding:"required,email"`
}
)
func (service *PrepareLoginService) Prepare(c *gin.Context) (*PrepareLoginResponse, error) {
dep := dependency.FromContext(c)
ctx := context.WithValue(c, inventory.LoadUserPasskey{}, true)
expectedUser, err := dep.UserClient().GetByEmail(ctx, service.Email)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "User not found", err)
}
return &PrepareLoginResponse{
WebAuthnEnabled: len(expectedUser.Edges.Passkey) > 0,
PasswordEnabled: expectedUser.Password != "",
}, nil
}
+288
View File
@@ -0,0 +1,288 @@
package user
import (
"context"
"encoding/base64"
"encoding/gob"
"errors"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gofrs/uuid"
"github.com/samber/lo"
"strconv"
"strings"
)
func init() {
gob.Register(webauthn.SessionData{})
}
type authnUser struct {
hasher hashid.Encoder
u *ent.User
credentials []*ent.Passkey
}
func (a *authnUser) WebAuthnID() []byte {
return []byte(hashid.EncodeUserID(a.hasher, a.u.ID))
}
func (a *authnUser) WebAuthnName() string {
return a.u.Email
}
func (a *authnUser) WebAuthnDisplayName() string {
return a.u.Nick
}
func (a *authnUser) WebAuthnCredentials() []webauthn.Credential {
if a.credentials == nil {
return nil
}
return lo.Map(a.credentials, func(item *ent.Passkey, index int) webauthn.Credential {
return *item.Credential
})
}
const (
authnSessionKey = "authn_session_"
)
func PreparePasskeyLogin(c *gin.Context) (*PreparePasskeyLoginResponse, error) {
dep := dependency.FromContext(c)
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
options, sessionData, err := webAuthn.BeginDiscoverableLogin()
if err != nil {
return nil, serializer.NewError(serializer.CodeInitializeAuthn, "Failed to begin registration", err)
}
sessionID := uuid.Must(uuid.NewV4()).String()
if err := dep.KV().Set(fmt.Sprintf("%s%s", authnSessionKey, sessionID), *sessionData, 300); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err)
}
return &PreparePasskeyLoginResponse{
Options: options,
SessionID: sessionID,
}, nil
}
type (
FinishPasskeyLoginParameterCtx struct{}
FinishPasskeyLoginService struct {
Response string `json:"response" binding:"required"`
SessionID string `json:"session_id" binding:"required"`
}
)
func (s *FinishPasskeyLoginService) FinishPasskeyLogin(c *gin.Context) (*ent.User, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
userClient := dep.UserClient()
sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%s", authnSessionKey, s.SessionID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
_ = kv.Delete(authnSessionKey, s.Response)
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
sessionData := sessionDataRaw.(webauthn.SessionData)
pcc, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(s.Response))
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to parse request", err)
}
var loginedUser *ent.User
discoverUserHandle := func(rawID, userHandle []byte) (user webauthn.User, err error) {
uid, err := dep.HashIDEncoder().Decode(string(userHandle), hashid.UserID)
if err != nil {
return nil, err
}
ctx := context.WithValue(c, inventory.LoadUserPasskey{}, true)
ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true)
u, err := userClient.GetLoginUserByID(ctx, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
if inventory.IsAnonymousUser(u) {
return nil, errors.New("anonymous user")
}
loginedUser = u
return &authnUser{u: u, hasher: dep.HashIDEncoder(), credentials: u.Edges.Passkey}, nil
}
credential, err := webAuthn.ValidateDiscoverableLogin(discoverUserHandle, sessionData, pcc)
if err != nil {
return nil, serializer.NewError(serializer.CodeWebAuthnCredentialError, "Failed to validate login", err)
}
// Find the credential just used
usedCredentialId := base64.StdEncoding.EncodeToString(credential.ID)
usedCredential, found := lo.Find(loginedUser.Edges.Passkey, func(item *ent.Passkey) bool {
return item.CredentialID == usedCredentialId
})
if !found {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Passkey login passed but credential used is unknown", nil)
}
// Update used at
if err := userClient.MarkPasskeyUsed(c, loginedUser.ID, usedCredential.CredentialID); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update passkey", err)
}
return loginedUser, nil
}
func PreparePasskeyRegister(c *gin.Context) (*protocol.CredentialCreation, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
u := inventory.UserFromContext(c)
existingKeys, err := userClient.ListPasskeys(c, u.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list passkeys", err)
}
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
authSelect := protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationPreferred,
}
options, sessionData, err := webAuthn.BeginRegistration(
&authnUser{u: u, hasher: dep.HashIDEncoder()},
webauthn.WithAuthenticatorSelection(authSelect),
webauthn.WithExclusions(lo.Map(existingKeys, func(item *ent.Passkey, index int) protocol.CredentialDescriptor {
return protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: item.Credential.ID,
Transport: item.Credential.Transport,
AttestationType: item.Credential.AttestationType,
}
})),
)
if err != nil {
return nil, serializer.NewError(serializer.CodeInitializeAuthn, "Failed to begin registration", err)
}
if err := dep.KV().Set(fmt.Sprintf("%s%d", authnSessionKey, u.ID), *sessionData, 300); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err)
}
return options, nil
}
type (
FinishPasskeyRegisterParameterCtx struct{}
FinishPasskeyRegisterService struct {
Response string `json:"response" binding:"required"`
Name string `json:"name" binding:"required"`
UA string `json:"ua" binding:"required"`
}
)
func (s *FinishPasskeyRegisterService) FinishPasskeyRegister(c *gin.Context) (*Passkey, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
u := inventory.UserFromContext(c)
sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%d", authnSessionKey, u.ID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
_ = kv.Delete(authnSessionKey, strconv.Itoa(u.ID))
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
sessionData := sessionDataRaw.(webauthn.SessionData)
pcc, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(s.Response))
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to parse request", err)
}
credential, err := webAuthn.CreateCredential(&authnUser{u: u, hasher: dep.HashIDEncoder()}, sessionData, pcc)
if err != nil {
return nil, serializer.NewError(serializer.CodeWebAuthnCredentialError, "Failed to finish registration", err)
}
client := dep.UAParser().Parse(s.UA)
name := util.Replace(map[string]string{
"{os}": client.Os.Family,
"{browser}": client.UserAgent.Family,
}, s.Name)
passkey, err := dep.UserClient().AddPasskey(c, u.ID, name, credential)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to add passkey", err)
}
res := BuildPasskey(passkey)
return &res, nil
}
type (
DeletePasskeyService struct {
ID string `form:"id" binding:"required"`
}
DeletePasskeyParameterCtx struct{}
)
func (s *DeletePasskeyService) DeletePasskey(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
userClient := dep.UserClient()
existingKeys, err := userClient.ListPasskeys(c, u.ID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to list passkeys", err)
}
var existing *ent.Passkey
for _, key := range existingKeys {
if key.CredentialID == s.ID {
existing = key
break
}
}
if existing == nil {
return serializer.NewError(serializer.CodeNotFound, "Passkey not found", nil)
}
if err := userClient.RemovePasskey(c, u.ID, s.ID); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete passkey", err)
}
return nil
}
+148
View File
@@ -0,0 +1,148 @@
package user
import (
"context"
"errors"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"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/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
)
// RegisterParameterCtx define key fore UserRegisterService
type RegisterParameterCtx struct{}
// UserRegisterService 管理用户注册的服务
type UserRegisterService struct {
UserName string `form:"email" json:"email" binding:"required,email"`
Password string `form:"password" json:"password" binding:"required,min=6,max=128"`
Language string `form:"language" json:"language"`
}
// Register 新用户注册
func (service *UserRegisterService) Register(c *gin.Context) serializer.Response {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
isEmailRequired := settings.EmailActivationEnabled(c)
args := &inventory.NewUserArgs{
Email: strings.ToLower(service.UserName),
PlainPassword: service.Password,
Status: user.StatusActive,
GroupID: settings.DefaultGroup(c),
Language: service.Language,
}
if isEmailRequired {
args.Status = user.StatusInactive
}
userClient := dep.UserClient()
uc, tx, _, err := inventory.WithTx(c, userClient)
if err != nil {
return serializer.DBErr(c, "Failed to start transaction", err)
}
expectedUser, err := uc.Create(c, args)
if expectedUser != nil {
util.WithValue(c, inventory.UserCtx{}, expectedUser)
}
if err != nil {
_ = inventory.Rollback(tx)
if errors.Is(err, inventory.ErrUserEmailExisted) {
return serializer.ErrWithDetails(c, serializer.CodeEmailExisted, "Email already in use", err)
}
if errors.Is(err, inventory.ErrInactiveUserExisted) {
if err := sendActivationEmail(c, dep, expectedUser); err != nil {
return serializer.ErrWithDetails(c, serializer.CodeNotSet, "", err)
}
return serializer.ErrWithDetails(c, serializer.CodeEmailSent, "User is not activated, activation email has been resent", nil)
}
return serializer.DBErr(c, "Failed to insert user row", err)
}
if err := inventory.Commit(tx); err != nil {
return serializer.DBErr(c, "Failed to commit user row", err)
}
if isEmailRequired {
if err := sendActivationEmail(c, dep, expectedUser); err != nil {
return serializer.ErrWithDetails(c, serializer.CodeNotSet, "", err)
}
return serializer.Response{Code: serializer.CodeNotFullySuccess}
}
return serializer.Response{Data: BuildUser(expectedUser, dep.HashIDEncoder())}
}
func sendActivationEmail(ctx context.Context, dep dependency.Dep, newUser *ent.User) error {
base := dep.SettingProvider().SiteURL(ctx)
userID := hashid.EncodeUserID(dep.HashIDEncoder(), newUser.ID)
ttl := time.Now().Add(time.Duration(24) * time.Hour)
activateURL, err := auth.SignURI(ctx, dep.GeneralAuth(), routes.MasterUserActivateAPIUrl(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.MasterUserActivateUrl(base)
queries := finalURL.Query()
queries.Add("id", userID)
queries.Add("sign", credential)
finalURL.RawQuery = queries.Encode()
// 返送激活邮件
title, body, err := email.NewActivationEmail(ctx, dep.SettingProvider(), newUser, finalURL.String())
if err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
if err := dep.EmailClient(ctx).Send(ctx, newUser.Email, title, body); err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
return nil
}
// ActivateUser 激活用户
func ActivateUser(c *gin.Context) serializer.Response {
uid := hashid.FromContext(c)
dep := dependency.FromContext(c)
userClient := dep.UserClient()
// 查找待激活用户
inactiveUser, err := userClient.GetByID(c, uid)
if err != nil {
return serializer.ErrWithDetails(c, serializer.CodeUserNotFound, "User not fount", err)
}
// 检查状态
if inactiveUser.Status != user.StatusInactive {
return serializer.ErrWithDetails(c, serializer.CodeUserCannotActivate, "This user cannot be activated", nil)
}
// 激活用户
activeUser, err := userClient.SetStatus(c, inactiveUser, user.StatusActive)
if err != nil {
return serializer.DBErr(c, "Failed to update user", err)
}
util.WithValue(c, inventory.UserCtx{}, activeUser)
return serializer.Response{Data: BuildUser(activeUser, dep.HashIDEncoder())}
}
+261
View File
@@ -0,0 +1,261 @@
package user
import (
"context"
"fmt"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/samber/lo"
"github.com/ua-parser/uap-go/uaparser"
)
type PreparePasskeyLoginResponse struct {
Options *protocol.CredentialAssertion `json:"options"`
SessionID string `json:"session_id"`
}
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"`
OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
}
func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser, grants []*ent.OAuthGrant) *UserSettings {
return &UserSettings{
VersionRetentionEnabled: u.Settings.VersionRetention,
VersionRetentionExt: u.Settings.VersionRetentionExt,
VersionRetentionMax: u.Settings.VersionRetentionMax,
TwoFAEnabled: u.TwoFactorSecret != "",
Paswordless: u.Password == "",
Passkeys: lo.Map(passkeys, func(item *ent.Passkey, index int) Passkey {
return BuildPasskey(item)
}),
DisableViewSync: u.Settings.DisableViewSync,
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant {
return BuildOauthGrant(item)
}),
}
}
type Passkey struct {
ID string `json:"id"`
Name string `json:"name"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func BuildPasskey(passkey *ent.Passkey) Passkey {
return Passkey{
ID: passkey.CredentialID,
Name: passkey.Name,
UsedAt: passkey.UsedAt,
CreatedAt: passkey.CreatedAt,
}
}
// Node option for handling workflows.
type Node struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Capabilities *boolset.BooleanSet `json:"capabilities"`
}
// BuildNodes serialize a list of nodes.
func BuildNodes(nodes []*ent.Node, idEncoder hashid.Encoder) []*Node {
res := make([]*Node, 0, len(nodes))
for _, v := range nodes {
res = append(res, BuildNode(v, idEncoder))
}
return res
}
// BuildNode serialize a node.
func BuildNode(node *ent.Node, idEncoder hashid.Encoder) *Node {
return &Node{
ID: hashid.EncodeNodeID(idEncoder, node.ID),
Name: node.Name,
Type: string(node.Type),
Capabilities: node.Capabilities,
}
}
// BuiltinLoginResponse response for a successful login for builtin auth provider.
type BuiltinLoginResponse struct {
User User `json:"user"`
Token auth.Token `json:"token"`
}
// User 用户序列化器
type User struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Nickname string `json:"nickname"`
Status user.Status `json:"status,omitempty"`
Avatar string `json:"avatar,omitempty"`
CreatedAt time.Time `json:"created_at"`
PreferredTheme string `json:"preferred_theme,omitempty"`
Anonymous bool `json:"anonymous,omitempty"`
Group *Group `json:"group,omitempty"`
Pined []types.PinedFile `json:"pined,omitempty"`
Language string `json:"language,omitempty"`
DisableViewSync bool `json:"disable_view_sync,omitempty"`
ShareLinksInProfile types.ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"`
}
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
Permission *boolset.BooleanSet `json:"permission,omitempty"`
DirectLinkBatchSize int `json:"direct_link_batch_size,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"`
}
type storage struct {
Used uint64 `json:"used"`
Free uint64 `json:"free"`
Total uint64 `json:"total"`
}
// WebAuthnCredentials 外部验证器凭证
type WebAuthnCredentials struct {
ID []byte `json:"id"`
FingerPrint string `json:"fingerprint"`
}
type PrepareLoginResponse struct {
WebAuthnEnabled bool `json:"webauthn_enabled"`
PasswordEnabled bool `json:"password_enabled"`
}
// BuildWebAuthnList 构建设置页面凭证列表
func BuildWebAuthnList(credentials []webauthn.Credential) []WebAuthnCredentials {
res := make([]WebAuthnCredentials, 0, len(credentials))
for _, v := range credentials {
credential := WebAuthnCredentials{
ID: v.ID,
FingerPrint: fmt.Sprintf("% X", v.Authenticator.AAGUID),
}
res = append(res, credential)
}
return res
}
// BuildUser 序列化用户
func BuildUser(user *ent.User, idEncoder hashid.Encoder) User {
return User{
ID: hashid.EncodeUserID(idEncoder, user.ID),
Email: user.Email,
Nickname: user.Nick,
Status: user.Status,
Avatar: user.Avatar,
CreatedAt: user.CreatedAt,
PreferredTheme: user.Settings.PreferredTheme,
Anonymous: user.ID == 0,
Group: BuildGroup(user.Edges.Group, idEncoder),
Pined: user.Settings.Pined,
Language: user.Settings.Language,
DisableViewSync: user.Settings.DisableViewSync,
ShareLinksInProfile: user.Settings.ShareLinksInProfile,
}
}
type OauthGrant struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
ClientLogo string `json:"client_logo"`
Scopes []string `json:"scopes"`
LastUsedAt *time.Time `json:"last_used_at"`
}
func BuildOauthGrant(grant *ent.OAuthGrant) OauthGrant {
res := OauthGrant{
Scopes: grant.Scopes,
LastUsedAt: grant.LastUsedAt,
}
if grant.Edges.Client != nil {
res.ClientID = grant.Edges.Client.GUID
res.ClientName = grant.Edges.Client.Name
if grant.Edges.Client.Props != nil {
res.ClientLogo = grant.Edges.Client.Props.Icon
}
}
return res
}
func BuildGroup(group *ent.Group, idEncoder hashid.Encoder) *Group {
if group == nil {
return nil
}
return &Group{
ID: hashid.EncodeGroupID(idEncoder, group.ID),
Name: group.Name,
Permission: group.Permissions,
DirectLinkBatchSize: group.Settings.SourceBatchSize,
TrashRetention: group.Settings.TrashRetention,
}
}
const sensitiveTag = "redacted"
const (
RedactLevelAnonymous = iota
RedactLevelUser
)
// BuildUserRedacted Serialize a user without sensitive information.
// The email is only exposed when the requester is a signed-in user (level >=
// RedactLevelUser) and the `expose_user_email` setting is enabled.
func BuildUserRedacted(ctx context.Context, u *ent.User, level int, idEncoder hashid.Encoder) User {
userRaw := BuildUser(u, idEncoder)
user := User{
ID: userRaw.ID,
Nickname: userRaw.Nickname,
Avatar: userRaw.Avatar,
CreatedAt: userRaw.CreatedAt,
ShareLinksInProfile: userRaw.ShareLinksInProfile,
}
if userRaw.Group != nil {
user.Group = RedactedGroup(userRaw.Group)
}
if level == RedactLevelUser && dependency.FromContext(ctx).SettingProvider().ExposeUserEmail(ctx) {
user.Email = userRaw.Email
}
return user
}
// BuildGroupRedacted Serialize a group without sensitive information.
func RedactedGroup(g *Group) *Group {
if g == nil {
return nil
}
return &Group{
ID: g.ID,
Name: g.Name,
}
}
+337
View File
@@ -0,0 +1,337 @@
package user
import (
"context"
"crypto/md5"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"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/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"
)
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)
}
return BuildUserSettings(u, passkeys, dep.UAParser(), grants), 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)
}
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)
}
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"`
}
PatchUserSettingParamsCtx struct{}
)
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)
}
}
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 {
u.Settings.ShareLinksInProfile = types.ShareLinksInProfileLevel(*s.ShareLinksInProfile)
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)
}
}
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)
}
} 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)
}
}
}
if saveSetting {
if err := userClient.SaveSettings(c, u); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user settings", err)
}
}
return nil
}