Skip to content

Commit 88a3d5c

Browse files
Daniel Auerbroccoli
Daniel Auer
authored and
broccoli
committed
feature: saving files as blob in database
+ files (e.g. branding pictues, attachments) can be saved as a blob in a database. The feature can be turned on and off (feature toggle) with the env var FILE_STORAGE_MODE + a new controller with the endpoints /file/branding etc. had to be created since the files are statically linked when fs is used. + the files plus metadata are stored in the table 'file'
1 parent 3f1ed50 commit 88a3d5c

File tree

9 files changed

+203
-12
lines changed

9 files changed

+203
-12
lines changed

cmd/wire_gen.go

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/controller/controller.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ var ProviderSetController = wire.NewSet(
5353
NewEmbedController,
5454
NewBadgeController,
5555
NewRenderController,
56+
NewFileController,
5657
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package controller
2+
3+
import (
4+
"strconv"
5+
6+
"github.com/apache/answer/internal/base/handler"
7+
"github.com/apache/answer/internal/repo/file"
8+
"github.com/gin-gonic/gin"
9+
)
10+
11+
type FileController struct {
12+
FileRepo file.FileRepo
13+
}
14+
15+
func NewFileController(repo file.FileRepo) *FileController {
16+
return &FileController{FileRepo: repo}
17+
}
18+
19+
func (bc *FileController) GetFile(ctx *gin.Context) {
20+
id := ctx.Param("id")
21+
download := ctx.DefaultQuery("download", "")
22+
23+
blob, err := bc.FileRepo.GetByID(ctx.Request.Context(), id)
24+
if err != nil || blob == nil {
25+
handler.HandleResponse(ctx, err, "file not found")
26+
return
27+
}
28+
29+
ctx.Header("Content-Type", blob.MimeType)
30+
ctx.Header("Content-Length", strconv.FormatInt(blob.Size, 10))
31+
if download != "" {
32+
ctx.Header("Content-Disposition", "attachment; filename=\""+download+"\"")
33+
}
34+
35+
ctx.Data(200, blob.MimeType, blob.Content)
36+
}

internal/entity/file_entity.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package entity
2+
3+
import (
4+
"time"
5+
)
6+
7+
type File struct {
8+
ID string `xorm:"pk varchar(36)"`
9+
FileName string `xorm:"varchar(255) not null"`
10+
MimeType string `xorm:"varchar(100)"`
11+
Size int64 `xorm:"bigint"`
12+
Content []byte `xorm:"blob"`
13+
CreatedAt time.Time `xorm:"created"`
14+
}
15+
16+
func (File) TableName() string {
17+
return "file"
18+
}

internal/migrations/init_data.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ var (
7474
&entity.Badge{},
7575
&entity.BadgeGroup{},
7676
&entity.BadgeAward{},
77+
&entity.File{},
7778
}
7879

7980
roles = []*entity.Role{

internal/repo/file/file_repo.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package file
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
7+
"github.com/apache/answer/internal/base/data"
8+
"github.com/apache/answer/internal/base/reason"
9+
"github.com/apache/answer/internal/entity"
10+
"github.com/segmentfault/pacman/errors"
11+
)
12+
13+
type FileRepo interface {
14+
Save(ctx context.Context, file *entity.File) error
15+
GetByID(ctx context.Context, id string) (*entity.File, error)
16+
}
17+
18+
type fileRepo struct {
19+
data *data.Data
20+
}
21+
22+
func NewFileRepo(data *data.Data) FileRepo {
23+
return &fileRepo{data: data}
24+
}
25+
26+
func (r *fileRepo) Save(ctx context.Context, file *entity.File) error {
27+
_, err := r.data.DB.Context(ctx).Insert(file)
28+
if err != nil {
29+
return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
30+
}
31+
return nil
32+
}
33+
34+
func (r *fileRepo) GetByID(ctx context.Context, id string) (*entity.File, error) {
35+
var blob entity.File
36+
ok, err := r.data.DB.Context(ctx).ID(id).Get(&blob)
37+
if err != nil {
38+
return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
39+
}
40+
if !ok {
41+
return nil, sql.ErrNoRows
42+
}
43+
return &blob, nil
44+
}

internal/router/answer_api_router.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ type AnswerAPIRouter struct {
5757
metaController *controller.MetaController
5858
badgeController *controller.BadgeController
5959
adminBadgeController *controller_admin.BadgeController
60+
fileController *controller.FileController
6061
}
6162

6263
func NewAnswerAPIRouter(
@@ -90,6 +91,7 @@ func NewAnswerAPIRouter(
9091
metaController *controller.MetaController,
9192
badgeController *controller.BadgeController,
9293
adminBadgeController *controller_admin.BadgeController,
94+
fileController *controller.FileController,
9395
) *AnswerAPIRouter {
9496
return &AnswerAPIRouter{
9597
langController: langController,
@@ -122,6 +124,7 @@ func NewAnswerAPIRouter(
122124
metaController: metaController,
123125
badgeController: badgeController,
124126
adminBadgeController: adminBadgeController,
127+
fileController: fileController,
125128
}
126129
}
127130

@@ -148,6 +151,9 @@ func (a *AnswerAPIRouter) RegisterMustUnAuthAnswerAPIRouter(authUserMiddleware *
148151

149152
// plugins
150153
r.GET("/plugin/status", a.pluginController.GetAllPluginStatus)
154+
155+
// file branding
156+
r.GET("/file/branding/:id", a.fileController.GetFile)
151157
}
152158

153159
func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
@@ -171,6 +177,10 @@ func (a *AnswerAPIRouter) RegisterUnAuthAnswerAPIRouter(r *gin.RouterGroup) {
171177
r.GET("/personal/question/page", a.questionController.PersonalQuestionPage)
172178
r.GET("/question/link", a.questionController.GetQuestionLink)
173179

180+
//file
181+
r.GET("/file/post/:id", a.fileController.GetFile)
182+
r.GET("/file/avatar/:id", a.fileController.GetFile)
183+
174184
// comment
175185
r.GET("/comment/page", a.commentController.GetCommentWithPage)
176186
r.GET("/personal/comment/page", a.commentController.GetCommentPersonalWithPage)
@@ -310,6 +320,7 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) {
310320

311321
// meta
312322
r.PUT("/meta/reaction", a.metaController.AddOrUpdateReaction)
323+
313324
}
314325

315326
func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) {

internal/service/provider.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
package service
2121

2222
import (
23+
"github.com/apache/answer/internal/repo/file"
2324
"github.com/apache/answer/internal/service/action"
2425
"github.com/apache/answer/internal/service/activity"
2526
"github.com/apache/answer/internal/service/activity_common"
@@ -40,7 +41,7 @@ import (
4041
"github.com/apache/answer/internal/service/follow"
4142
"github.com/apache/answer/internal/service/importer"
4243
"github.com/apache/answer/internal/service/meta"
43-
"github.com/apache/answer/internal/service/meta_common"
44+
metacommon "github.com/apache/answer/internal/service/meta_common"
4445
"github.com/apache/answer/internal/service/notice_queue"
4546
"github.com/apache/answer/internal/service/notification"
4647
notficationcommon "github.com/apache/answer/internal/service/notification_common"
@@ -128,4 +129,5 @@ var ProviderSetService = wire.NewSet(
128129
badge.NewBadgeGroupService,
129130
importer.NewImporterService,
130131
file_record.NewFileRecordService,
132+
file.NewFileRepo,
131133
)

internal/service/uploader/upload.go

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ import (
3030
"path"
3131
"path/filepath"
3232
"strings"
33+
"time"
3334

35+
"github.com/apache/answer/internal/entity"
36+
"github.com/apache/answer/internal/repo/file"
3437
"github.com/apache/answer/internal/service/file_record"
38+
"github.com/google/uuid"
3539

3640
"github.com/apache/answer/internal/base/constant"
3741
"github.com/apache/answer/internal/base/reason"
@@ -65,6 +69,10 @@ var (
6569
}
6670
)
6771

72+
var (
73+
FileStorageMode = os.Getenv("FILE_STORAGE_MODE") // eg "fs" or "db"
74+
)
75+
6876
type UploaderService interface {
6977
UploadAvatarFile(ctx *gin.Context, userID string) (url string, err error)
7078
UploadPostFile(ctx *gin.Context, userID string) (url string, err error)
@@ -78,13 +86,15 @@ type uploaderService struct {
7886
serviceConfig *service_config.ServiceConfig
7987
siteInfoService siteinfo_common.SiteInfoCommonService
8088
fileRecordService *file_record.FileRecordService
89+
fileRepo file.FileRepo
8190
}
8291

8392
// NewUploaderService new upload service
8493
func NewUploaderService(
8594
serviceConfig *service_config.ServiceConfig,
8695
siteInfoService siteinfo_common.SiteInfoCommonService,
8796
fileRecordService *file_record.FileRecordService,
97+
fileRepo file.FileRepo,
8898
) UploaderService {
8999
for _, subPath := range subPathList {
90100
err := dir.CreateDirIfNotExist(filepath.Join(serviceConfig.UploadPath, subPath))
@@ -96,9 +106,14 @@ func NewUploaderService(
96106
serviceConfig: serviceConfig,
97107
siteInfoService: siteInfoService,
98108
fileRecordService: fileRecordService,
109+
fileRepo: fileRepo,
99110
}
100111
}
101112

113+
func UseDbStorage() bool {
114+
return FileStorageMode != "fs"
115+
}
116+
102117
// UploadAvatarFile upload avatar file
103118
func (us *uploaderService) UploadAvatarFile(ctx *gin.Context, userID string) (url string, err error) {
104119
url, err = us.tryToUploadByPlugin(ctx, plugin.UserAvatar)
@@ -126,8 +141,8 @@ func (us *uploaderService) UploadAvatarFile(ctx *gin.Context, userID string) (ur
126141
}
127142

128143
newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt)
129-
avatarFilePath := path.Join(constant.AvatarSubPath, newFilename)
130-
return us.uploadImageFile(ctx, fileHeader, avatarFilePath)
144+
fileHeader.Filename = newFilename
145+
return us.uploadImageFile(ctx, fileHeader, constant.AvatarSubPath)
131146
}
132147

133148
func (us *uploaderService) AvatarThumbFile(ctx *gin.Context, fileName string, size int) (url string, err error) {
@@ -209,12 +224,14 @@ func (us *uploaderService) UploadPostFile(ctx *gin.Context, userID string) (
209224

210225
fileExt := strings.ToLower(path.Ext(fileHeader.Filename))
211226
newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt)
212-
avatarFilePath := path.Join(constant.PostSubPath, newFilename)
213-
url, err = us.uploadImageFile(ctx, fileHeader, avatarFilePath)
227+
fileHeader.Filename = newFilename
228+
url, err = us.uploadImageFile(ctx, fileHeader, constant.PostSubPath)
229+
postFilePath := path.Join(constant.PostSubPath, newFilename)
230+
214231
if err != nil {
215232
return "", err
216233
}
217-
us.fileRecordService.AddFileRecord(ctx, userID, avatarFilePath, url, string(plugin.UserPost))
234+
us.fileRecordService.AddFileRecord(ctx, userID, postFilePath, url, string(plugin.UserPost))
218235
return url, nil
219236
}
220237

@@ -279,10 +296,9 @@ func (us *uploaderService) UploadBrandingFile(ctx *gin.Context, userID string) (
279296
if _, ok := plugin.DefaultFileTypeCheckMapping[plugin.AdminBranding][fileExt]; !ok {
280297
return "", errors.BadRequest(reason.RequestFormatError).WithError(err)
281298
}
282-
283299
newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt)
284-
avatarFilePath := path.Join(constant.BrandingSubPath, newFilename)
285-
return us.uploadImageFile(ctx, fileHeader, avatarFilePath)
300+
fileHeader.Filename = newFilename
301+
return us.uploadImageFile(ctx, fileHeader, constant.BrandingSubPath)
286302
}
287303

288304
func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.FileHeader, fileSubPath string) (
@@ -295,7 +311,37 @@ func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.Fil
295311
if err != nil {
296312
return "", err
297313
}
298-
filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath)
314+
if UseDbStorage() {
315+
src, err := file.Open()
316+
if err != nil {
317+
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
318+
}
319+
defer src.Close()
320+
321+
buffer := new(bytes.Buffer)
322+
if _, err = io.Copy(buffer, src); err != nil {
323+
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
324+
}
325+
326+
file := &entity.File{
327+
ID: uuid.New().String(),
328+
FileName: file.Filename,
329+
MimeType: file.Header.Get("Content-Type"),
330+
Size: int64(len(buffer.Bytes())),
331+
Content: buffer.Bytes(),
332+
CreatedAt: time.Now(),
333+
}
334+
335+
err = us.fileRepo.Save(ctx, file)
336+
if err != nil {
337+
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
338+
}
339+
340+
return fmt.Sprintf("%s/answer/api/v1/file/%s/%s", siteGeneral.SiteUrl, fileSubPath, file.ID), nil
341+
//TODO checks: DecodeAndCheckImageFile removeExif
342+
}
343+
filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath, file.Filename)
344+
299345
if err := ctx.SaveUploadedFile(file, filePath); err != nil {
300346
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
301347
}
@@ -324,6 +370,35 @@ func (us *uploaderService) uploadAttachmentFile(ctx *gin.Context, file *multipar
324370
if err != nil {
325371
return "", err
326372
}
373+
if UseDbStorage() {
374+
src, err := file.Open()
375+
if err != nil {
376+
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
377+
}
378+
defer src.Close()
379+
380+
buf := new(bytes.Buffer)
381+
if _, err = io.Copy(buf, src); err != nil {
382+
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
383+
}
384+
385+
blob := &entity.File{
386+
ID: uuid.New().String(),
387+
FileName: originalFilename,
388+
MimeType: file.Header.Get("Content-Type"),
389+
Size: int64(len(buf.Bytes())),
390+
Content: buf.Bytes(),
391+
CreatedAt: time.Now(),
392+
}
393+
394+
err = us.fileRepo.Save(ctx, blob)
395+
if err != nil {
396+
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()
397+
}
398+
399+
downloadUrl = fmt.Sprintf("%s/answer/api/v1/file/%s?download=%s", siteGeneral.SiteUrl, blob.ID, url.QueryEscape(originalFilename))
400+
return downloadUrl, nil
401+
}
327402
filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath)
328403
if err := ctx.SaveUploadedFile(file, filePath); err != nil {
329404
return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack()

0 commit comments

Comments
 (0)