diff --git a/go.mod b/go.mod index d52069d..cd6e200 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/gin-gonic/gin v1.4.0 github.com/go-ini/ini v1.50.0 github.com/go-mail/mail v2.3.1+incompatible + github.com/gofrs/uuid v3.2.0+incompatible github.com/gomodule/redigo v2.0.0+incompatible github.com/google/go-querystring v1.0.0 github.com/gorilla/websocket v1.4.1 diff --git a/go.sum b/go.sum index 8d56499..e9f02ff 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/go-mail/mail v2.3.1+incompatible/go.mod h1:VPWjmmNyRsWXQZHVHT3g0YbIIN github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= diff --git a/models/migration.go b/models/migration.go index 55b358b..daca13a 100644 --- a/models/migration.go +++ b/models/migration.go @@ -148,7 +148,7 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti {Name: "hot_share_num", Value: `10`, Type: "share"}, {Name: "allow_buy_group", Value: `1`, Type: "group_sell"}, {Name: "group_sell_data", Value: `[]`, Type: "group_sell"}, - {Name: "gravatar_server", Value: `https://v2ex.assets.uxengine.net/gravatar/`, Type: "avatar"}, + {Name: "gravatar_server", Value: `https://gravatar.loli.net/`, Type: "avatar"}, {Name: "defaultTheme", Value: `#3f51b5`, Type: "basic"}, {Name: "themes", Value: `{"#3f51b5":{"palette":{"primary":{"light":"#7986cb","main":"#3f51b5","dark":"#303f9f","contrastText":"#fff"},"secondary":{"light":"#ff4081","main":"#f50057","dark":"#c51162","contrastText":"#fff"},"error":{"light":"#e57373","main":"#f44336","dark":"#d32f2f","contrastText":"#fff"},"explorer":{"filename":"#474849","icon":"#8f8f8f","bgSelected":"#D5DAF0","emptyIcon":"#e8e8e8"}}}}`, Type: "basic"}, {Name: "aria2_token", Value: `your token`, Type: "aria2"}, @@ -160,6 +160,11 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti {Name: "max_parallel_transfer", Value: `4`, Type: "task"}, {Name: "secret_key", Value: util.RandStringRunes(256), Type: "auth"}, {Name: "temp_path", Value: "temp", Type: "path"}, + {Name: "avatar_path", Value: "avatar", Type: "path"}, + {Name: "avatar_size", Value: "2097152", Type: "avatar"}, + {Name: "avatar_size_l", Value: "200", Type: "avatar"}, + {Name: "avatar_size_m", Value: "130", Type: "avatar"}, + {Name: "avatar_size_s", Value: "50", Type: "avatar"}, {Name: "score_enabled", Value: "1", Type: "score"}, {Name: "share_score_rate", Value: "80", Type: "score"}, {Name: "score_price", Value: "1", Type: "score"}, diff --git a/models/user.go b/models/user.go index 80a25c0..7c81046 100644 --- a/models/user.go +++ b/models/user.go @@ -45,7 +45,7 @@ type User struct { NotifyDate *time.Time // 通知超出配额时的日期 // 关联模型 - Group Group `gorm:"association_autoupdate:false"` + Group Group `gorm:"save_associations:false:false"` Policy Policy `gorm:"PRELOAD:false,association_autoupdate:false"` // 数据库忽略字段 @@ -73,12 +73,12 @@ func (user *User) DeductionStorage(size uint64) bool { } if size <= user.Storage { user.Storage -= size - DB.Model(user).UpdateColumn("storage", gorm.Expr("storage - ?", size)) + DB.Model(user).Update("storage", gorm.Expr("storage - ?", size)) return true } // 如果要减少的容量超出已用容量,则设为零 user.Storage = 0 - DB.Model(user).UpdateColumn("storage", 0) + DB.Model(user).Update("storage", 0) return false } @@ -90,7 +90,7 @@ func (user *User) IncreaseStorage(size uint64) bool { } if size <= user.GetRemainingCapacity() { user.Storage += size - DB.Model(user).UpdateColumn("storage", gorm.Expr("storage + ?", size)) + DB.Model(user).Update("storage", gorm.Expr("storage + ?", size)) return true } return false @@ -103,7 +103,7 @@ func (user *User) PayScore(score int) bool { } if score <= user.Score { user.Score -= score - DB.Model(user).UpdateColumn("score", gorm.Expr("score - ?", score)) + DB.Model(user).Update("score", gorm.Expr("score - ?", score)) return true } return false @@ -112,7 +112,7 @@ func (user *User) PayScore(score int) bool { // AddScore 增加积分 func (user *User) AddScore(score int) { user.Score += score - DB.Model(user).UpdateColumn("score", gorm.Expr("score + ?", score)) + DB.Model(user).Update("score", gorm.Expr("score + ?", score)) } // IncreaseStorageWithoutCheck 忽略可用容量,增加用户已用容量 @@ -121,7 +121,7 @@ func (user *User) IncreaseStorageWithoutCheck(size uint64) { return } user.Storage += size - DB.Model(user).UpdateColumn("storage", gorm.Expr("storage + ?", size)) + DB.Model(user).Update("storage", gorm.Expr("storage + ?", size)) } @@ -175,7 +175,7 @@ func GetActiveUserByID(ID interface{}) (User, error) { // GetUserByEmail 用Email获取用户 func GetUserByEmail(email string) (User, error) { var user User - result := DB.Set("gorm:auto_preload", true).Where("email = ?", email).First(&user) + result := DB.Set("gorm:auto_preload", true).Where("status = ? and email = ?", Active, email).First(&user) return user, result.Error } @@ -296,6 +296,11 @@ func (user *User) SetStatus(status int) { DB.Model(&user).Update("status", status) } +// Update 更新用户 +func (user *User) Update(val map[string]interface{}) error { + return DB.Model(user).Updates(val).Error +} + // GetGroupExpiredUsers 获取用户组过期的用户 func GetGroupExpiredUsers() []User { var users []User diff --git a/models/user_authn.go b/models/user_authn.go index 6408c70..99c09a1 100644 --- a/models/user_authn.go +++ b/models/user_authn.go @@ -49,5 +49,5 @@ func (user *User) RegisterAuthn(credential *webauthn.Credential) { if err != nil { fmt.Println(err) } - DB.Model(user).UpdateColumn("authn", string(res)) + DB.Model(user).Update("authn", string(res)) } diff --git a/models/user_test.go b/models/user_test.go index 7029229..981eab3 100644 --- a/models/user_test.go +++ b/models/user_test.go @@ -151,7 +151,6 @@ func TestNewUser(t *testing.T) { newUser := NewUser() asserts.IsType(User{}, newUser) asserts.NotEmpty(newUser.Avatar) - asserts.NotEmpty(newUser.OptionsSerialized) } func TestUser_AfterFind(t *testing.T) { @@ -304,7 +303,7 @@ func TestUser_DeductionStorage(t *testing.T) { Storage: 10, } mock.ExpectBegin() - mock.ExpectExec("UPDATE(.+)").WithArgs(5, 1).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("UPDATE(.+)").WithArgs(5, sqlmock.AnyArg(), 1).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() asserts.True(user.DeductionStorage(5)) @@ -319,7 +318,7 @@ func TestUser_DeductionStorage(t *testing.T) { Storage: 10, } mock.ExpectBegin() - mock.ExpectExec("UPDATE(.+)").WithArgs(0, 1).WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("UPDATE(.+)").WithArgs(0, sqlmock.AnyArg(), 1).WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectCommit() asserts.False(user.DeductionStorage(20)) @@ -355,11 +354,11 @@ func TestUser_IncreaseStorageWithoutCheck(t *testing.T) { func TestGetUserByEmail(t *testing.T) { asserts := assert.New(t) - mock.ExpectQuery("SELECT(.+)").WithArgs("abslant@foxmail.com").WillReturnRows(sqlmock.NewRows([]string{"id", "email"})) + mock.ExpectQuery("SELECT(.+)").WithArgs(Active, "abslant@foxmail.com").WillReturnRows(sqlmock.NewRows([]string{"id", "email"})) _, err := GetUserByEmail("abslant@foxmail.com") - asserts.NoError(mock.ExpectationsWereMet()) asserts.Error(err) + asserts.NoError(mock.ExpectationsWereMet()) } func TestUser_AfterCreate(t *testing.T) { diff --git a/pkg/qq/connect.go b/pkg/qq/connect.go new file mode 100644 index 0000000..c534205 --- /dev/null +++ b/pkg/qq/connect.go @@ -0,0 +1,159 @@ +package qq + +import ( + "crypto/md5" + "encoding/json" + "errors" + "fmt" + model "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/request" + "github.com/HFO4/cloudreve/pkg/serializer" + "github.com/gofrs/uuid" + "net/url" + "strings" +) + +// LoginPage 登陆页面描述 +type LoginPage struct { + URL string + SecretKey string +} + +// UserCredentials 登陆成功后的凭证 +type UserCredentials struct { + OpenID string + AccessToken string +} + +var ( + // ErrNotEnabled 未开启登录功能 + ErrNotEnabled = serializer.NewError(serializer.CodeNoPermissionErr, "QQ登录功能未开启", nil) + // ErrObtainAccessToken 无法获取AccessToken + ErrObtainAccessToken = serializer.NewError(serializer.CodeParamErr, "无法获取AccessToken", nil) + // ErrObtainOpenID 无法获取OpenID + ErrObtainOpenID = serializer.NewError(serializer.CodeParamErr, "无法获取OpenID", nil) + //ErrDecodeResponse 无法解析服务端响应 + ErrDecodeResponse = serializer.NewError(serializer.CodeInternalSetting, "无法解析服务端响应", nil) +) + +// NewLoginRequest 新建登录会话 +func NewLoginRequest() (*LoginPage, error) { + // 获取相关设定 + options := model.GetSettingByNames("qq_login", "qq_login_id") + if options["qq_login"] == "0" { + return nil, ErrNotEnabled + } + + // 生成唯一ID + u2, err := uuid.NewV4() + if err != nil { + return nil, err + } + secret := fmt.Sprintf("%x", md5.Sum(u2.Bytes())) + + // 生成登录地址 + loginURL, _ := url.Parse("https://graph.qq.com/oauth2.0/authorize?response_type=code") + queries := loginURL.Query() + queries.Add("client_id", options["qq_login_id"]) + queries.Add("redirect_uri", getCallbackURL()) + queries.Add("state", secret) + loginURL.RawQuery = queries.Encode() + + return &LoginPage{ + URL: loginURL.String(), + SecretKey: secret, + }, nil +} + +func getCallbackURL() string { + return "https://drive.aoaoao.me/Callback/QQ" + // 生成回调地址 + gateway, _ := url.Parse("/#/login/qq") + callback := model.GetSiteURL().ResolveReference(gateway).String() + + return callback +} + +func getAccessTokenURL(code string) string { + // 获取相关设定 + options := model.GetSettingByNames("qq_login_id", "qq_login_key") + + api, _ := url.Parse("https://graph.qq.com/oauth2.0/token?grant_type=authorization_code") + queries := api.Query() + queries.Add("client_id", options["qq_login_id"]) + queries.Add("redirect_uri", getCallbackURL()) + queries.Add("client_secret", options["qq_login_key"]) + queries.Add("code", code) + api.RawQuery = queries.Encode() + + return api.String() +} + +func getResponse(body string) (map[string]interface{}, error) { + var res map[string]interface{} + + if !strings.Contains(body, "callback") { + return res, nil + } + + body = strings.TrimPrefix(body, "callback(") + body = strings.TrimSuffix(body, ");\n") + + err := json.Unmarshal([]byte(body), &res) + + return res, err +} + +// Callback 处理回调,返回openid和access key +func Callback(code string) (*UserCredentials, error) { + // 获取相关设定 + options := model.GetSettingByNames("qq_login") + if options["qq_login"] == "0" { + return nil, ErrNotEnabled + } + + api := getAccessTokenURL(code) + + // 获取AccessToken + client := request.HTTPClient{} + res := client.Request("GET", api, nil) + resp, err := res.GetResponse() + if err != nil { + return nil, ErrObtainAccessToken.WithError(err) + } + + // 如果服务端返回错误 + errResp, err := getResponse(resp) + if msg, ok := errResp["error_description"]; err == nil && ok { + return nil, ErrObtainAccessToken.WithError(errors.New(msg.(string))) + } + + // 获取AccessToken + vals, err := url.ParseQuery(resp) + if err != nil { + return nil, ErrDecodeResponse.WithError(err) + } + accessToken := vals.Get("access_token") + + // 用 AccessToken 换取OpenID + res = client.Request("GET", "https://graph.qq.com/oauth2.0/me?access_token="+accessToken, nil) + resp, err = res.GetResponse() + if err != nil { + return nil, ErrObtainOpenID.WithError(err) + } + + // 解析服务端响应 + errResp, err = getResponse(resp) + if msg, ok := errResp["error_description"]; err == nil && ok { + return nil, ErrObtainOpenID.WithError(errors.New(msg.(string))) + } + + if openid, ok := errResp["openid"]; ok { + return &UserCredentials{ + OpenID: openid.(string), + AccessToken: accessToken, + }, nil + } + + return nil, ErrDecodeResponse +} diff --git a/pkg/serializer/user.go b/pkg/serializer/user.go index c9d035d..3852d55 100644 --- a/pkg/serializer/user.go +++ b/pkg/serializer/user.go @@ -46,6 +46,7 @@ type group struct { ShareFreeEnabled bool `json:"shareFree"` ShareDownload bool `json:"shareDownload"` CompressEnabled bool `json:"compress"` + WebDAVEnabled bool `json:"webdav"` } type tag struct { @@ -91,6 +92,7 @@ func BuildUser(user model.User) User { ShareFreeEnabled: user.Group.OptionsSerialized.ShareFree, ShareDownload: user.Group.OptionsSerialized.ShareDownload, CompressEnabled: user.Group.OptionsSerialized.ArchiveTask, + WebDAVEnabled: user.Group.WebDAVEnabled, }, Tags: buildTagRes(tags), } diff --git a/pkg/thumb/image.go b/pkg/thumb/image.go index 5078248..dd85fd1 100644 --- a/pkg/thumb/image.go +++ b/pkg/thumb/image.go @@ -2,12 +2,14 @@ package thumb import ( "errors" + "fmt" + model "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/util" "image" "image/gif" "image/jpeg" "image/png" "io" - "os" "path/filepath" "strings" @@ -66,14 +68,36 @@ func (image *Thumb) GetSize() (int, int) { // Save 保存图像到给定路径 func (image *Thumb) Save(path string) (err error) { - out, err := os.Create(path) - defer out.Close() + out, err := util.CreatNestedFile(path) if err != nil { return err } + defer out.Close() err = png.Encode(out, image.src) return err } + +// CreateAvatar 创建头像 +func (image *Thumb) CreateAvatar(uid uint) error { + // 读取头像相关设定 + savePath := model.GetSettingByName("avatar_path") + s := model.GetIntSetting("avatar_size_s", 50) + m := model.GetIntSetting("avatar_size_m", 130) + l := model.GetIntSetting("avatar_size_l", 200) + + // 生成头像缩略图 + src := image.src + for k, size := range []int{s, m, l} { + image.src = resize.Resize(uint(size), uint(size), src, resize.Lanczos3) + err := image.Save(filepath.Join(savePath, fmt.Sprintf("avatar_%d_%d.png", uid, k))) + if err != nil { + return err + } + } + + return nil + +} diff --git a/routers/controllers/file.go b/routers/controllers/file.go index 7bd2dc6..0a64087 100644 --- a/routers/controllers/file.go +++ b/routers/controllers/file.go @@ -9,7 +9,6 @@ import ( "github.com/HFO4/cloudreve/pkg/filesystem/driver/local" "github.com/HFO4/cloudreve/pkg/filesystem/fsctx" "github.com/HFO4/cloudreve/pkg/serializer" - "github.com/HFO4/cloudreve/pkg/util" "github.com/HFO4/cloudreve/service/explorer" "github.com/gin-gonic/gin" "net/http" @@ -294,7 +293,7 @@ func FileUploadStream(c *gin.Context) { File: c.Request.Body, Size: fileSize, Name: fileName, - VirtualPath: util.DotPathToStandardPath(filePath), + VirtualPath: filePath, } // 创建文件系统 diff --git a/routers/controllers/main.go b/routers/controllers/main.go index b43e21a..d06cc96 100644 --- a/routers/controllers/main.go +++ b/routers/controllers/main.go @@ -17,6 +17,7 @@ func ParamErrorMsg(filed string, tag string) string { "Path": "路径", "SourceID": "原始资源", "URL": "链接", + "Nick": "昵称", } // 未通过的规则与中文对应 tagMap := map[string]string{ diff --git a/routers/controllers/user.go b/routers/controllers/user.go index fb966a1..5a8b16d 100644 --- a/routers/controllers/user.go +++ b/routers/controllers/user.go @@ -5,6 +5,7 @@ import ( model "github.com/HFO4/cloudreve/models" "github.com/HFO4/cloudreve/pkg/authn" "github.com/HFO4/cloudreve/pkg/serializer" + "github.com/HFO4/cloudreve/pkg/thumb" "github.com/HFO4/cloudreve/pkg/util" "github.com/HFO4/cloudreve/service/user" "github.com/duo-labs/webauthn/webauthn" @@ -157,3 +158,117 @@ func UserTasks(c *gin.Context) { c.JSON(200, ErrorResponse(err)) } } + +// UserSetting 获取用户设定 +func UserSetting(c *gin.Context) { + var service user.SettingService + if err := c.ShouldBindUri(&service); err == nil { + res := service.Settings(c, CurrentUser(c)) + c.JSON(200, res) + } else { + c.JSON(200, ErrorResponse(err)) + } +} + +// UseGravatar 设定头像使用全球通用 +func UseGravatar(c *gin.Context) { + u := CurrentUser(c) + if err := u.Update(map[string]interface{}{"avatar": "gravatar"}); err != nil { + c.JSON(200, serializer.Err(serializer.CodeDBError, "无法更新头像", err)) + return + } + c.JSON(200, serializer.Response{}) +} + +// UploadAvatar 从文件上传头像 +func UploadAvatar(c *gin.Context) { + // 取得头像上传大小限制 + maxSize := model.GetIntSetting("avatar_size", 2097152) + if c.Request.ContentLength == -1 || c.Request.ContentLength > int64(maxSize) { + c.JSON(200, serializer.Err(serializer.CodeUploadFailed, "头像尺寸太大", nil)) + return + } + + // 取得上传的文件 + file, err := c.FormFile("avatar") + if err != nil { + c.JSON(200, serializer.Err(serializer.CodeIOFailed, "无法读取头像数据", err)) + return + } + + // 初始化头像 + r, err := file.Open() + if err != nil { + c.JSON(200, serializer.Err(serializer.CodeIOFailed, "无法读取头像数据", err)) + return + } + avatar, err := thumb.NewThumbFromFile(r, file.Filename) + if err != nil { + c.JSON(200, serializer.Err(serializer.CodeIOFailed, "无法解析图像数据", err)) + return + } + + // 创建头像 + u := CurrentUser(c) + err = avatar.CreateAvatar(u.ID) + if err != nil { + c.JSON(200, serializer.Err(serializer.CodeIOFailed, "无法创建头像", err)) + return + } + + // 保存头像标记 + if err := u.Update(map[string]interface{}{ + "avatar": "file", + }); err != nil { + c.JSON(200, serializer.Err(serializer.CodeDBError, "无法更新头像", err)) + return + } + + c.JSON(200, serializer.Response{}) +} + +// GetUserAvatar 获取用户头像 +func GetUserAvatar(c *gin.Context) { + var service user.AvatarService + if err := c.ShouldBindUri(&service); err == nil { + res := service.Get(c) + if res.Code == -301 { + // 重定向到gravatar + c.Redirect(301, res.Data.(string)) + } + } else { + c.JSON(200, ErrorResponse(err)) + } +} + +// UpdateOption 更改用户设定 +func UpdateOption(c *gin.Context) { + var service user.SettingUpdateService + if err := c.ShouldBindUri(&service); err == nil { + var ( + subService user.OptionsChangeHandler + subErr error + ) + + switch service.Option { + case "nick": + subService = &user.ChangerNick{} + case "vip": + subService = &user.VIPUnsubscribe{} + case "qq": + subService = &user.QQBind{} + } + + subErr = c.ShouldBindJSON(subService) + if subErr != nil { + c.JSON(200, ErrorResponse(subErr)) + return + } + + res := subService.Update(c, CurrentUser(c)) + c.JSON(200, res) + + } else { + c.JSON(200, ErrorResponse(err)) + } +} diff --git a/routers/controllers/vas.go b/routers/controllers/vas.go index 7f85374..6a77f80 100644 --- a/routers/controllers/vas.go +++ b/routers/controllers/vas.go @@ -131,3 +131,14 @@ func PayJSCallback(c *gin.Context) { payNotify.SendResponseMsg() } + +// QQCallback QQ互联回调 +func QQCallback(c *gin.Context) { + var service vas.QQCallbackService + if err := c.ShouldBindJSON(&service); err == nil { + res := service.Callback(c, CurrentUser(c)) + c.JSON(200, res) + } else { + c.JSON(200, ErrorResponse(err)) + } +} diff --git a/routers/router.go b/routers/router.go index 59a4830..fb28ed7 100644 --- a/routers/router.go +++ b/routers/router.go @@ -112,6 +112,11 @@ func InitMasterRouter() *gin.Engine { middleware.HashID(hashid.UserID), controllers.GetUserShare, ) + // 获取用户头像 + user.GET("avatar/:id/:size", + middleware.HashID(hashid.UserID), + controllers.GetUserAvatar, + ) } // 需要携带签名验证的 @@ -132,6 +137,11 @@ func InitMasterRouter() *gin.Engine { // 回调接口 callback := v3.Group("callback") { + // QQ互联回调 + callback.POST( + "qq", + controllers.QQCallback, + ) // PAYJS回调 callback.POST( "payjs", @@ -266,6 +276,14 @@ func InitMasterRouter() *gin.Engine { setting.GET("policies", controllers.UserAvailablePolicies) // 任务队列 setting.GET("tasks", controllers.UserTasks) + // 获取当前用户设定 + setting.GET("", controllers.UserSetting) + // 从文件上传头像 + setting.POST("avatar", controllers.UploadAvatar) + // 设定为Gravatar头像 + setting.PUT("avatar", controllers.UseGravatar) + // 更改用户设定 + setting.PATCH(":option", controllers.UpdateOption) } } diff --git a/service/user/setting.go b/service/user/setting.go index ade8220..db48f9d 100644 --- a/service/user/setting.go +++ b/service/user/setting.go @@ -1,9 +1,18 @@ package user import ( + "crypto/md5" + "fmt" model "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/qq" "github.com/HFO4/cloudreve/pkg/serializer" + "github.com/HFO4/cloudreve/pkg/util" "github.com/gin-gonic/gin" + "net/http" + "net/url" + "os" + "path/filepath" + "time" ) // SettingService 通用设置服务 @@ -15,6 +24,143 @@ type SettingListService struct { Page int `form:"page" binding:"required,min=1"` } +// AvatarService 头像服务 +type AvatarService struct { + Size string `uri:"size" binding:"required,eq=l|eq=m|eq=s"` +} + +// SettingUpdateService 设定更改服务 +type SettingUpdateService struct { + Option string `uri:"option" binding:"required,eq=nick|eq=theme|eq=homepage|eq=vip|eq=qq"` +} + +// OptionsChangeHandler 属性更改接口 +type OptionsChangeHandler interface { + Update(*gin.Context, *model.User) serializer.Response +} + +// ChangerNick 昵称更改服务 +type ChangerNick struct { + Nick string `json:"nick" binding:"required,min=1,max=255"` +} + +// VIPUnsubscribe 用户组解约服务 +type VIPUnsubscribe struct { +} + +// QQBind QQ互联服务 +type QQBind struct { +} + +// Update 绑定或解绑QQ +func (service *QQBind) Update(c *gin.Context, user *model.User) serializer.Response { + // 解除绑定 + if user.OpenID != "" { + if err := user.Update(map[string]interface{}{"open_id": ""}); err != nil { + return serializer.DBErr("接触绑定失败", err) + } + return serializer.Response{} + } + + // 新建绑定 + res, err := qq.NewLoginRequest() + if err != nil { + return serializer.Err(serializer.CodeNotSet, "无法使用QQ登录", err) + } + + // 设定QQ登录会话Secret + util.SetSession(c, map[string]interface{}{"qq_login_secret": res.SecretKey}) + + return serializer.Response{ + Data: res.URL, + } +} + +// Update 用户组解约 +func (service *VIPUnsubscribe) Update(c *gin.Context, user *model.User) serializer.Response { + if user.GroupExpires != nil { + timeNow := time.Now() + if time.Now().Before(*user.GroupExpires) { + if err := user.Update(map[string]interface{}{"group_expires": &timeNow}); err != nil { + return serializer.DBErr("解约失败", err) + } + } + } + return serializer.Response{} +} + +// Update 更改昵称 +func (service *ChangerNick) Update(c *gin.Context, user *model.User) serializer.Response { + if err := user.Update(map[string]interface{}{"nick": service.Nick}); err != nil { + return serializer.DBErr("无法更新昵称", err) + } + + return serializer.Response{} +} + +// Get 获取用户头像 +func (service *AvatarService) Get(c *gin.Context) serializer.Response { + // 查找目标用户 + uid, _ := c.Get("object_id") + user, err := model.GetActiveUserByID(uid.(uint)) + if err != nil { + return serializer.Err(serializer.CodeNotFound, "用户不存在", err) + } + + // 未设定头像时,返回404错误 + if user.Avatar == "" { + c.Status(404) + return serializer.Response{} + } + + // 获取头像设置 + sizes := map[string]string{ + "s": model.GetSettingByName("avatar_size_s"), + "m": model.GetSettingByName("avatar_size_m"), + "l": model.GetSettingByName("avatar_size_l"), + } + + // Gravatar 头像重定向 + if user.Avatar == "gravatar" { + server := model.GetSettingByName("gravatar_server") + gravatarRoot, err := url.Parse(server) + if err != nil { + return serializer.Err(serializer.CodeInternalSetting, "无法解析 Gravatar 服务器地址", err) + } + + has := md5.Sum([]byte(user.Email)) + avatar, _ := url.Parse(fmt.Sprintf("/avatar/%x?d=mm&s=%s", has, sizes[service.Size])) + + return serializer.Response{ + Code: -301, + Data: gravatarRoot.ResolveReference(avatar).String(), + } + } + + // 本地文件头像 + if user.Avatar == "file" { + avatarRoot := model.GetSettingByName("avatar_path") + sizeToInt := map[string]string{ + "s": "0", + "m": "1", + "l": "2", + } + + avatar, err := os.Open(filepath.Join(avatarRoot, fmt.Sprintf("avatar_%d_%s.png", user.ID, sizeToInt[service.Size]))) + if err != nil { + c.Status(404) + return serializer.Response{} + } + defer avatar.Close() + + http.ServeContent(c.Writer, c.Request, "avatar.png", user.UpdatedAt, avatar) + return serializer.Response{} + } + + c.Status(404) + return serializer.Response{} +} + // ListTasks 列出任务 func (service *SettingListService) ListTasks(c *gin.Context, user *model.User) serializer.Response { tasks, total := model.ListTasks(user.ID, service.Page, 10, "updated_at desc") @@ -36,3 +182,30 @@ func (service *SettingService) Policy(c *gin.Context, user *model.User) serializ return serializer.BuildPolicySettingRes(available, ¤t) } + +// Settings 获取用户设定 +func (service *SettingService) Settings(c *gin.Context, user *model.User) serializer.Response { + // 取得存储策略设定 + policy := service.Policy(c, user) + + // 用户组有效期 + var groupExpires int64 + if user.GroupExpires != nil { + if expires := user.GroupExpires.Unix() - time.Now().Unix(); expires > 0 { + groupExpires = user.GroupExpires.Unix() + } + } + + return serializer.Response{ + Data: map[string]interface{}{ + "policy": policy.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.GetSettingByNames("themes"), + "group_expires": groupExpires, + }, + } +} diff --git a/service/vas/qq.go b/service/vas/qq.go new file mode 100644 index 0000000..2bbe3d2 --- /dev/null +++ b/service/vas/qq.go @@ -0,0 +1,43 @@ +package vas + +import ( + model "github.com/HFO4/cloudreve/models" + "github.com/HFO4/cloudreve/pkg/qq" + "github.com/HFO4/cloudreve/pkg/serializer" + "github.com/HFO4/cloudreve/pkg/util" + "github.com/gin-gonic/gin" +) + +// QQCallbackService QQ互联回调处理服务 +type QQCallbackService struct { + Code string `json:"code" binding:"required"` + State string `json:"state" binding:"required"` +} + +// Callback 处理QQ互联回调 +func (service *QQCallbackService) Callback(c *gin.Context, user *model.User) serializer.Response { + state := util.GetSession(c, "qq_login_secret") + if stateStr, ok := state.(string); !ok || stateStr != service.State { + return serializer.Err(serializer.CodeSignExpired, "请求过期,请重试", nil) + } + + credential, err := qq.Callback(service.Code) + if err != nil { + return serializer.Err(serializer.CodeNotSet, "无法获取登录状态", err) + } + + // 如果已登录,则绑定已有用户 + if user != nil { + if user.OpenID != "" { + return serializer.Err(serializer.CodeCallbackError, "您已绑定了QQ账号,请先解除绑定", nil) + } + if err := user.Update(map[string]interface{}{"open_id": credential.OpenID}); err != nil { + return serializer.DBErr("绑定失败", err) + } + return serializer.Response{ + Data: "/setting", + } + } + + return serializer.Response{} +}