用户文档上传权限控制

dev
truthhun 2 years ago
parent 6b2c4c47cf
commit 69a242bab5

@ -0,0 +1,6 @@
# ROADMAP
- [ ] 用户允许上传的文件大小限制,避免文件太大导致服务器崩溃
- [ ] 每次上传的文件数量限制,避免一次上传太多,处理不过来
- [ ] 每天上传的文件数量限制,用于避免用户恶意上传文档
- [ ] 根据用户组来进行额度授权

@ -21,6 +21,7 @@ message Group {
string description = 6;
int32 user_count = 7;
int32 sort = 8;
bool enable_upload = 11;
google.protobuf.Timestamp created_at = 9 [ (gogoproto.stdtime) = true ];
google.protobuf.Timestamp updated_at = 10 [ (gogoproto.stdtime) = true ];
}

@ -192,6 +192,14 @@ service UserAPI {
};
}
//
rpc CanIUploadDocument(google.protobuf.Empty)
returns (google.protobuf.Empty) {
option (google.api.http) = {
get : '/api/v1/user/caniuploaddocument',
};
}
//
// rpc ListUserFans(ListUserFansRequest) returns (ListUserReply) {
// option (google.api.http) = {

@ -52,6 +52,10 @@ func (s *AttachmentAPIService) checkGinPermission(ctx *gin.Context) (userClaims
return checkGinPermission(s.dbModel, ctx)
}
func (s *AttachmentAPIService) checkLogin(ctx *gin.Context) (userClaims *auth.UserClaims, statusCode int, err error) {
return checkGinLogin(s.dbModel, ctx)
}
// UpdateAttachment 更新附件。只允许更新附件名称、是否合法以及描述字段
func (s *AttachmentAPIService) UpdateAttachment(ctx context.Context, req *pb.Attachment) (*emptypb.Empty, error) {
_, err := s.checkPermission(ctx)
@ -172,12 +176,20 @@ func (s *AttachmentAPIService) ListAttachment(ctx context.Context, req *pb.ListA
// UploadDocument 上传文档
func (s *AttachmentAPIService) UploadDocument(ctx *gin.Context) {
userCliams, statusCodes, err := s.checkGinPermission(ctx)
// 检查用户是否已登录
userClaims, statusCodes, err := s.checkLogin(ctx)
if err != nil {
s.logger.Debug("checkGinPermission", zap.Error(err), zap.Any("userClaims", userClaims), zap.Any("statusCodes", statusCodes))
ctx.JSON(statusCodes, ginResponse{Code: statusCodes, Message: err.Error(), Error: err.Error()})
return
}
// 检查用户是否有权限上传文档
if !s.dbModel.CanIUploadDocument(userClaims.UserId) {
ctx.JSON(http.StatusForbidden, ginResponse{Code: http.StatusForbidden, Message: "没有权限上传文档", Error: "没有权限上传文档"})
return
}
name := "file"
fileheader, err := ctx.FormFile(name)
if err != nil {
@ -197,7 +209,7 @@ func (s *AttachmentAPIService) UploadDocument(ctx *gin.Context) {
ctx.JSON(http.StatusInternalServerError, ginResponse{Code: http.StatusInternalServerError, Message: err.Error(), Error: err.Error()})
return
}
attachment.UserId = userCliams.UserId
attachment.UserId = userClaims.UserId
attachment.Type = model.AttachmentTypeDocument
err = s.dbModel.CreateAttachment(attachment)

@ -29,17 +29,25 @@ func (s *DocumentAPIService) checkPermission(ctx context.Context) (userClaims *a
return checkGRPCPermission(s.dbModel, ctx)
}
func (s *DocumentAPIService) checkLogin(ctx context.Context) (userClaims *auth.UserClaims, err error) {
return checkGRPCLogin(s.dbModel, ctx)
}
// CreateDocument 创建文档
// 0. 判断是否有权限
// 1. 同名覆盖找到该作者上传的相同title和ext的文档然后用新文件覆盖同时文档状态改为待转换
// 2. 相同hash的文档如果已经被转换了则该文档的状态直接改为已转换
// 3. 判断附件ID是否与用户ID匹配不匹配则跳过该文档
func (s *DocumentAPIService) CreateDocument(ctx context.Context, req *pb.CreateDocumentRequest) (*emptypb.Empty, error) {
userCliams, err := s.checkPermission(ctx)
userCliams, err := s.checkLogin(ctx)
if err != nil {
return nil, err
}
if !s.dbModel.CanIUploadDocument(userCliams.UserId) {
return nil, status.Error(codes.PermissionDenied, "没有权限上传文档")
}
var (
attachmentIds []interface{}
attachmentMap = make(map[int64]model.Attachment)

@ -410,8 +410,6 @@ func (s *UserAPIService) GetUserCaptcha(ctx context.Context, req *pb.GetUserCapt
res.Enable = cfgSecurity.EnableCaptchaRegister
case "login":
res.Enable = cfgSecurity.EnableCaptchaLogin
case "upload":
res.Enable = cfgSecurity.EnableCaptchaUpload
case "find_password":
res.Enable = cfgSecurity.EnableCaptchaFindPassword
case "comment":
@ -480,3 +478,16 @@ func (s *UserAPIService) AddUser(ctx context.Context, req *pb.SetUserRequest) (*
return &emptypb.Empty{}, nil
}
func (s *UserAPIService) CanIUploadDocument(ctx context.Context, req *emptypb.Empty) (*emptypb.Empty, error) {
userClaims, err := checkGRPCLogin(s.dbModel, ctx)
if err != nil {
return nil, err
}
if !s.dbModel.CanIUploadDocument(userClaims.UserId) {
return nil, status.Errorf(codes.PermissionDenied, "您没有上传文档的权限")
}
return &emptypb.Empty{}, nil
}

@ -16,11 +16,9 @@ import (
var errorMessagePermissionDeniedFormat = "您没有权限访问【%s】"
func checkGinPermission(dbModel *model.DBModel, ctx *gin.Context) (userClaims *auth.UserClaims, statusCode int, err error) {
var ok bool
userClaims, ok = ctx.Value(auth.CtxKeyUserClaims.String()).(*auth.UserClaims)
if !ok || dbModel.IsInvalidToken(userClaims.UUID) {
statusCode = http.StatusUnauthorized
return nil, statusCode, errors.New(ErrorMessageInvalidToken)
userClaims, statusCode, err = checkGinLogin(dbModel, ctx)
if err != nil {
return
}
if permission, yes := dbModel.CheckPermissionByUserId(userClaims.UserId, ctx.Request.URL.Path, ctx.Request.Method); !yes {
@ -34,11 +32,20 @@ func checkGinPermission(dbModel *model.DBModel, ctx *gin.Context) (userClaims *a
return
}
func checkGRPCPermission(dbModel *model.DBModel, ctx context.Context) (userClaims *auth.UserClaims, err error) {
func checkGinLogin(dbModel *model.DBModel, ctx *gin.Context) (userClaims *auth.UserClaims, statusCode int, err error) {
var ok bool
userClaims, ok = ctx.Value(auth.CtxKeyUserClaims).(*auth.UserClaims)
userClaims, ok = ctx.Value(auth.CtxKeyUserClaims.String()).(*auth.UserClaims)
if !ok || dbModel.IsInvalidToken(userClaims.UUID) {
return nil, status.Errorf(codes.Unauthenticated, ErrorMessageInvalidToken)
statusCode = http.StatusUnauthorized
return nil, statusCode, errors.New(ErrorMessageInvalidToken)
}
return
}
func checkGRPCPermission(dbModel *model.DBModel, ctx context.Context) (userClaims *auth.UserClaims, err error) {
userClaims, err = checkGRPCLogin(dbModel, ctx)
if err != nil {
return
}
fullMethod, _ := ctx.Value(auth.CtxKeyFullMethod).(string)
@ -51,3 +58,12 @@ func checkGRPCPermission(dbModel *model.DBModel, ctx context.Context) (userClaim
}
return
}
func checkGRPCLogin(dbModel *model.DBModel, ctx context.Context) (userClaims *auth.UserClaims, err error) {
var ok bool
userClaims, ok = ctx.Value(auth.CtxKeyUserClaims).(*auth.UserClaims)
if !ok || dbModel.IsInvalidToken(userClaims.UUID) {
return nil, status.Errorf(codes.Unauthenticated, ErrorMessageInvalidToken)
}
return
}

@ -3,9 +3,10 @@ module moredoc
go 1.18
require (
github.com/PuerkitoBio/goquery v1.8.0
github.com/gin-contrib/cors v1.3.1
github.com/gin-contrib/gzip v0.0.5
github.com/gin-gonic/gin v1.7.7
github.com/gin-gonic/gin v1.8.1
github.com/gogo/protobuf v1.3.2
github.com/golang/protobuf v1.5.2
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0
@ -16,19 +17,19 @@ require (
github.com/spf13/viper v1.10.1
go.uber.org/zap v1.21.0
golang.org/x/net v0.1.0
google.golang.org/genproto v0.0.0-20220228195345-15d65a4533f7
google.golang.org/grpc v1.44.0
google.golang.org/protobuf v1.27.1
google.golang.org/protobuf v1.28.0
gorm.io/driver/mysql v1.3.2
gorm.io/gorm v1.23.2
)
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/goccy/go-json v0.9.7 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/pelletier/go-toml/v2 v2.0.1 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
google.golang.org/genproto v0.0.0-20220228195345-15d65a4533f7 // indirect
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
)
require (
@ -37,10 +38,9 @@ require (
github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-contrib/static v0.0.1
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator v9.31.0+incompatible
github.com/go-playground/validator/v10 v10.10.1 // indirect
github.com/go-playground/locales v0.14.0
github.com/go-playground/universal-translator v0.18.0
github.com/go-playground/validator/v10 v10.10.1
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/gofrs/uuid v4.3.0+incompatible
github.com/golang-jwt/jwt v3.2.2+incompatible
@ -62,7 +62,7 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/ugorji/go/codec v1.1.7 // indirect
github.com/ugorji/go/codec v1.2.7 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect

@ -260,7 +260,6 @@ const (
ConfigSecurityEnableCaptchaRegister = "enable_captcha_register" // 是否开启注册验证码
ConfigSecurityEnableCaptchaComment = "enable_captcha_comment" // 是否开启注册验证码
ConfigSecurityEnableCaptchaFindPassword = "enable_captcha_find_password" // 是否开启注册验证码
ConfigSecurityEnableCaptchaUpload = "enable_captcha_upload" // 是否开启注册验证码
)
type ConfigSecurity struct {
@ -271,7 +270,6 @@ type ConfigSecurity struct {
EnableCaptchaRegister bool `json:"enable_captcha_register"` // 是否启用注册验证码
EnableCaptchaComment bool `json:"enable_captcha_comment"` // 是否启用评论验证码
EnableCaptchaFindPassword bool `json:"enable_captcha_find_password"` // 找回密码是否需要验证码
EnableCaptchaUpload bool `json:"enable_captcha_upload"` // 上传文档是否需要验证码
}
// GetConfigOfSecurity 获取安全配置
@ -368,7 +366,6 @@ func (m *DBModel) initConfig() (err error) {
{Category: ConfigCategorySecurity, Name: ConfigSecurityEnableCaptchaRegister, Label: "是否开启注册验证码", Value: "true", Placeholder: "请选择是否开启注册验证码", InputType: "switch", Sort: 20, Options: ""},
{Category: ConfigCategorySecurity, Name: ConfigSecurityEnableCaptchaComment, Label: "是否开启评论验证码", Value: "true", Placeholder: "请选择是否开启评论验证码", InputType: "switch", Sort: 21, Options: ""},
{Category: ConfigCategorySecurity, Name: ConfigSecurityEnableCaptchaFindPassword, Label: "是否开启找回密码验证码", Value: "true", Placeholder: "请选择是否开启找回密码验证码", InputType: "switch", Sort: 22, Options: ""},
{Category: ConfigCategorySecurity, Name: ConfigSecurityEnableCaptchaUpload, Label: "是否开启文档上传验证码", Value: "true", Placeholder: "请选择是否开启文档上传验证码", InputType: "switch", Sort: 23, Options: ""},
// 底部链接
{Category: ConfigCategoryFooter, Name: ConfigFooterAbout, Label: "关于我们", Value: "", Placeholder: "请输入关于我们的链接地址,留空表示不显示", InputType: "text", Sort: 24, Options: ""},

@ -9,16 +9,17 @@ import (
)
type Group struct {
Id int64 `form:"id" json:"id,omitempty" gorm:"primaryKey;autoIncrement;column:id;comment:用户组 id;"`
Title string `form:"title" json:"title,omitempty" gorm:"column:title;type:varchar(64);size:64;index:title,unique;comment:用户组名称;"`
Color string `form:"color" json:"color,omitempty" gorm:"column:color;type:varchar(20);size:20;comment:颜色;"`
IsDefault bool `form:"is_default" json:"is_default,omitempty" gorm:"column:is_default;type:tinyint(3);default:0;index:is_default;comment:是否默认;"`
IsDisplay bool `form:"is_display" json:"is_display,omitempty" gorm:"column:is_display;type:tinyint(3);default:0;comment:是否显示在用户名后;"`
Description string `form:"description" json:"description,omitempty" gorm:"column:description;type:varchar(255);size:255;comment:用户组描述;"`
UserCount int `form:"user_count" json:"user_count,omitempty" gorm:"column:user_count;type:int(11);size:11;default:0;comment:用户数量;"`
Sort int `form:"sort" json:"sort,omitempty" gorm:"column:sort;type:int(11);size:11;default:0;comment:排序,值越大越靠前;"`
CreatedAt *time.Time `form:"created_at" json:"created_at,omitempty" gorm:"column:created_at;type:datetime;comment:创建时间;"`
UpdatedAt *time.Time `form:"updated_at" json:"updated_at,omitempty" gorm:"column:updated_at;type:datetime;comment:更新时间;"`
Id int64 `form:"id" json:"id,omitempty" gorm:"primaryKey;autoIncrement;column:id;comment:用户组 id;"`
Title string `form:"title" json:"title,omitempty" gorm:"column:title;type:varchar(64);size:64;index:title,unique;comment:用户组名称;"`
Color string `form:"color" json:"color,omitempty" gorm:"column:color;type:varchar(20);size:20;comment:颜色;"`
IsDefault bool `form:"is_default" json:"is_default,omitempty" gorm:"column:is_default;type:tinyint(3);default:0;index:is_default;comment:是否默认;"`
IsDisplay bool `form:"is_display" json:"is_display,omitempty" gorm:"column:is_display;type:tinyint(3);default:0;comment:是否显示在用户名后;"`
Description string `form:"description" json:"description,omitempty" gorm:"column:description;type:varchar(255);size:255;comment:用户组描述;"`
UserCount int `form:"user_count" json:"user_count,omitempty" gorm:"column:user_count;type:int(11);size:11;default:0;comment:用户数量;"`
Sort int `form:"sort" json:"sort,omitempty" gorm:"column:sort;type:int(11);size:11;default:0;comment:排序,值越大越靠前;"`
EnableUpload bool `form:"enable_upload" json:"enable_upload,omitempty" gorm:"column:enable_upload;type:tinyint(3);default:0;comment:是否允许上传文档;"`
CreatedAt *time.Time `form:"created_at" json:"created_at,omitempty" gorm:"column:created_at;type:datetime;comment:创建时间;"`
UpdatedAt *time.Time `form:"updated_at" json:"updated_at,omitempty" gorm:"column:updated_at;type:datetime;comment:更新时间;"`
}
func (Group) TableName() string {
@ -81,9 +82,8 @@ func (m *DBModel) UpdateGroup(group *Group, updateFields ...string) (err error)
updateFields = m.FilterValidFields(Group{}.TableName(), updateFields...)
if len(updateFields) > 0 { // 更新指定字段
sess = sess.Select(updateFields)
} else {
// 不更新用户统计数据
sess = sess.Omit("id", "user_count")
} else { // 不更新用户统计数据
sess = sess.Select(m.GetTableFields(Group{}.TableName())).Omit("id", "user_count")
}
err = sess.Where("id = ?", group.Id).Updates(group).Error

@ -136,7 +136,7 @@ func (m *DBModel) CheckPermissionByUserId(userId int64, path string, httpMethod
}
permission, yes = m.CheckPermissionByGroupId(groupId, method, path)
return permission, yes || userId == 1
return permission, yes
}
// CheckPermissionByGroupId 根据用户所属用户组ID检查用户是否有权限

@ -404,3 +404,20 @@ func (m *DBModel) SetUserGroupAndPassword(userId int64, groupId []int64, passwor
return
}
// CanIUploadDocument 判断用户是否有上传文档的权限
func (m *DBModel) CanIUploadDocument(userId int64) (yes bool) {
var (
tableGroup = Group{}.TableName()
tableUserGroup = UserGroup{}.TableName()
group Group
)
err := m.db.Select("g.id").Table(tableGroup+" g").Joins(
"left join "+tableUserGroup+" ug on g.id=ug.group_id",
).Where("ug.user_id = ? and g.enable_upload = ?", userId, true).Find(&group).Error
if err != nil {
m.logger.Error("CanIUploadDocument", zap.Error(err))
return
}
return group.Id > 0
}

@ -32,6 +32,13 @@ export const getUser = (params) => {
})
}
export const canIUploadDocument = () => {
return service({
url: '/api/v1/user/caniuploaddocument',
method: 'get',
})
}
export const updateUserPassword = (data) => {
return service({
url: '/api/v1/user/password',

@ -39,6 +39,17 @@
>
</el-switch>
</el-form-item>
<el-form-item label="允许上传文档">
<el-switch
v-model="group.enable_upload"
style="display: block"
active-color="#13ce66"
inactive-color="#ff4949"
active-text="是"
inactive-text="否"
>
</el-switch>
</el-form-item>
<el-form-item label="是否在用户名后展示">
<el-switch
v-model="group.is_display"

@ -192,11 +192,22 @@ export default {
},
head() {
return {
title: 'MOREDOC · 魔刀文库',
title:
this.settings.system.title ||
this.settings.system.sitename ||
'魔刀文库',
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: this.settings.system.favicon,
},
],
}
},
computed: {
...mapGetters('user', ['user', 'token', 'permissions', 'allowPages']),
...mapGetters('setting', ['settings']),
},
created() {
this.getUserPermissions()

@ -226,6 +226,7 @@ export default {
color: '#000000',
title: '',
is_display: true,
enable_upload: false,
is_default: false,
}
},
@ -247,6 +248,12 @@ export default {
{ prop: 'user_count', label: '用户数', width: 80, type: 'number' },
{ prop: 'color', label: '颜色', width: 120, type: 'color' },
{ prop: 'is_default', label: '是否默认', width: 80, type: 'bool' },
{
prop: 'enable_upload',
label: '允许上传文档',
width: 120,
type: 'bool',
},
{ prop: 'is_display', label: '是否展示', width: 80, type: 'bool' },
{ prop: 'description', label: '描述', width: 250 },
{ prop: 'created_at', label: '创建时间', width: 160, type: 'datetime' },

@ -58,7 +58,7 @@
:on-success="onSuccess"
:on-error="onError"
multiple
:disabled="loading"
:disabled="loading || !canIUploadDocument"
:auto-upload="false"
:on-change="handleChange"
:file-list="fileList"
@ -105,7 +105,10 @@
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template slot="header">
操作 (<el-button type="text" @click="clearAllFiles"
操作 (<el-button
type="text"
:disabled="loading"
@click="clearAllFiles"
>清空</el-button
>)
</template>
@ -133,6 +136,7 @@
></el-progress>
<div v-if="loading" class="mgt-20px"></div>
<el-button
v-if="canIUploadDocument"
type="primary"
class="btn-block"
:loading="loading"
@ -141,6 +145,14 @@
<span v-if="loading">...</span>
<span v-else></span>
</el-button>
<el-button
v-else
type="primary"
icon="el-icon-hot-water"
class="btn-block"
disabled
>您未登录或您暂无文档上传权限</el-button
>
</el-form-item>
</el-form>
</el-col>
@ -227,10 +239,12 @@
import { mapGetters } from 'vuex'
import { formatBytes } from '~/utils/utils'
import { createDocument } from '~/api/document'
import { canIUploadDocument } from '~/api/user'
export default {
name: 'PageUpload',
data() {
return {
canIUploadDocument: false,
document: {
category_id: [],
price: 0,
@ -273,9 +287,13 @@ export default {
},
computed: {
...mapGetters('category', ['categoryTrees']),
...mapGetters('user', ['token']),
},
async created() {},
async created() {
const res = await canIUploadDocument()
if (res.status === 200) {
this.canIUploadDocument = true
}
},
methods: {
formatBytes,
handleChange(file) {
@ -305,56 +323,73 @@ export default {
return
}
this.loading = true
this.$refs.upload.submit()
console.log('onSubmit')
if (this.percentAge === 100) {
//
this.createDocuments()
} else {
this.$refs.upload.submit()
}
}
})
},
clearAllFiles() {
if (this.loading) {
return
}
this.fileList = []
this.filesMap = {}
this.$refs.upload.clearFiles()
},
onError(err) {
this.$message.error(err.message)
this.loading = false
try {
const message = JSON.parse(err.message)
this.$message.error(message.error)
} catch (error) {
this.$message.error(err.message)
}
},
// TODO:
async onSuccess(res, file, fileList) {
onSuccess(res, file, fileList) {
const length = fileList.length
const successItems = fileList.filter(
(item) => item.response && item.response.code === 200
)
const percentAge = parseInt((successItems.length / length) * 100)
if (percentAge === 100) {
const createDocumentRequest = {
overwrite: this.document.overwrite,
category_id: this.document.category_id,
document: successItems.map((item) => {
return {
title: item.title,
price: item.price,
attachment_id: item.response.data.id,
}
}),
}
const res = await createDocument(createDocumentRequest)
if (res.status === 200) {
this.$message.success('上传成功')
this.loading = false
this.percentAge = 0
this.fileList = []
this.filesMap = {}
this.document = {
category_id: [],
price: 0,
overwrite: false,
this.percentAge = (successItems.length / length) * 100
if (this.percentAge === 100) {
this.createDocuments()
}
},
async createDocuments() {
const createDocumentRequest = {
overwrite: this.document.overwrite,
category_id: this.document.category_id,
document: this.fileList.map((item) => {
return {
title: item.title,
price: item.price,
attachment_id: item.response.data.id,
}
this.$refs.upload.clearFiles()
} else {
this.$message.err(res.data.message || '上传失败')
}),
}
const res = await createDocument(createDocumentRequest)
if (res.status === 200) {
this.$message.success('上传成功')
this.loading = false
this.percentAge = 0
this.fileList = []
this.filesMap = {}
this.document = {
category_id: [],
price: 0,
overwrite: false,
}
this.$refs.upload.clearFiles()
} else {
this.percentAge = percentAge
console.log(res)
this.$message.error(res.data.message || '上传失败')
this.loading = false
}
},
},

Loading…
Cancel
Save