| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| package uploader |
| |
| import ( |
| "bytes" |
| "fmt" |
| "io" |
| "mime/multipart" |
| "net/http" |
| "net/url" |
| "os" |
| "path" |
| "path/filepath" |
| "strings" |
| |
| "github.com/apache/answer/internal/service/file_record" |
| |
| "github.com/apache/answer/internal/base/constant" |
| "github.com/apache/answer/internal/base/reason" |
| "github.com/apache/answer/internal/service/service_config" |
| "github.com/apache/answer/internal/service/siteinfo_common" |
| "github.com/apache/answer/pkg/checker" |
| "github.com/apache/answer/pkg/dir" |
| "github.com/apache/answer/pkg/uid" |
| "github.com/apache/answer/plugin" |
| "github.com/disintegration/imaging" |
| "github.com/gin-gonic/gin" |
| exifremove "github.com/scottleedavis/go-exif-remove" |
| "github.com/segmentfault/pacman/errors" |
| "github.com/segmentfault/pacman/log" |
| ) |
| |
| var ( |
| subPathList = []string{ |
| constant.AvatarSubPath, |
| constant.AvatarThumbSubPath, |
| constant.PostSubPath, |
| constant.BrandingSubPath, |
| constant.FilesPostSubPath, |
| constant.DeletedSubPath, |
| } |
| supportedThumbFileExtMapping = map[string]imaging.Format{ |
| ".jpg": imaging.JPEG, |
| ".jpeg": imaging.JPEG, |
| ".png": imaging.PNG, |
| ".gif": imaging.GIF, |
| } |
| ) |
| |
| type UploaderService interface { |
| UploadAvatarFile(ctx *gin.Context, userID string) (url string, err error) |
| UploadPostFile(ctx *gin.Context, userID string) (url string, err error) |
| UploadPostAttachment(ctx *gin.Context, userID string) (url string, err error) |
| UploadBrandingFile(ctx *gin.Context, userID string) (url string, err error) |
| AvatarThumbFile(ctx *gin.Context, fileName string, size int) (url string, err error) |
| } |
| |
| // uploaderService uploader service |
| type uploaderService struct { |
| serviceConfig *service_config.ServiceConfig |
| siteInfoService siteinfo_common.SiteInfoCommonService |
| fileRecordService *file_record.FileRecordService |
| } |
| |
| // NewUploaderService new upload service |
| func NewUploaderService( |
| serviceConfig *service_config.ServiceConfig, |
| siteInfoService siteinfo_common.SiteInfoCommonService, |
| fileRecordService *file_record.FileRecordService, |
| ) UploaderService { |
| for _, subPath := range subPathList { |
| err := dir.CreateDirIfNotExist(filepath.Join(serviceConfig.UploadPath, subPath)) |
| if err != nil { |
| panic(err) |
| } |
| } |
| return &uploaderService{ |
| serviceConfig: serviceConfig, |
| siteInfoService: siteInfoService, |
| fileRecordService: fileRecordService, |
| } |
| } |
| |
| // UploadAvatarFile upload avatar file |
| func (us *uploaderService) UploadAvatarFile(ctx *gin.Context, userID string) (url string, err error) { |
| url, err = us.tryToUploadByPlugin(ctx, plugin.UserAvatar) |
| if err != nil { |
| return "", err |
| } |
| if len(url) > 0 { |
| return url, nil |
| } |
| |
| siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) |
| if err != nil { |
| return "", err |
| } |
| |
| ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteAdvanced.GetMaxImageSize()) |
| file, fileHeader, err := ctx.Request.FormFile("file") |
| if err != nil { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| defer func() { |
| _ = file.Close() |
| }() |
| fileExt := strings.ToLower(path.Ext(fileHeader.Filename)) |
| if _, ok := plugin.DefaultFileTypeCheckMapping[plugin.UserAvatar][fileExt]; !ok { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| |
| newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt) |
| avatarFilePath := path.Join(constant.AvatarSubPath, newFilename) |
| url, err = us.uploadImageFile(ctx, fileHeader, avatarFilePath) |
| if err != nil { |
| return "", err |
| } |
| us.fileRecordService.AddFileRecord(ctx, userID, avatarFilePath, url, string(plugin.UserAvatar)) |
| return url, nil |
| } |
| |
| func (us *uploaderService) AvatarThumbFile(ctx *gin.Context, fileName string, size int) (url string, err error) { |
| fileSuffix := path.Ext(fileName) |
| if _, ok := supportedThumbFileExtMapping[fileSuffix]; !ok { |
| // if file type is not supported, return original file |
| return path.Join(us.serviceConfig.UploadPath, constant.AvatarSubPath, fileName), nil |
| } |
| if size > 1024 { |
| size = 1024 |
| } |
| |
| thumbFileName := fmt.Sprintf("%d_%d@%s", size, size, fileName) |
| thumbFilePath := fmt.Sprintf("%s/%s/%s", us.serviceConfig.UploadPath, constant.AvatarThumbSubPath, thumbFileName) |
| _, err = os.ReadFile(thumbFilePath) |
| if err == nil { |
| return thumbFilePath, nil |
| } |
| filePath := fmt.Sprintf("%s/%s/%s", us.serviceConfig.UploadPath, constant.AvatarSubPath, fileName) |
| avatarFile, err := os.ReadFile(filePath) |
| if err != nil { |
| return "", errors.NotFound(reason.UnknownError).WithError(err) |
| } |
| reader := bytes.NewReader(avatarFile) |
| img, err := imaging.Decode(reader) |
| if err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| |
| var buf bytes.Buffer |
| newImage := imaging.Fill(img, size, size, imaging.Center, imaging.Linear) |
| if err = imaging.Encode(&buf, newImage, supportedThumbFileExtMapping[fileSuffix]); err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| |
| if err = dir.CreateDirIfNotExist(path.Join(us.serviceConfig.UploadPath, constant.AvatarThumbSubPath)); err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| |
| avatarFilePath := path.Join(constant.AvatarThumbSubPath, thumbFileName) |
| saveFilePath := path.Join(us.serviceConfig.UploadPath, avatarFilePath) |
| out, err := os.Create(saveFilePath) |
| if err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| defer func() { |
| _ = out.Close() |
| }() |
| |
| thumbReader := bytes.NewReader(buf.Bytes()) |
| if _, err = io.Copy(out, thumbReader); err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| return saveFilePath, nil |
| } |
| |
| func (us *uploaderService) UploadPostFile(ctx *gin.Context, userID string) ( |
| url string, err error) { |
| url, err = us.tryToUploadByPlugin(ctx, plugin.UserPost) |
| if err != nil { |
| return "", err |
| } |
| if len(url) > 0 { |
| return url, nil |
| } |
| |
| siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) |
| if err != nil { |
| return "", err |
| } |
| |
| ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteAdvanced.GetMaxImageSize()) |
| file, fileHeader, err := ctx.Request.FormFile("file") |
| if err != nil { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| defer func() { |
| _ = file.Close() |
| }() |
| if checker.IsUnAuthorizedExtension(fileHeader.Filename, siteAdvanced.AuthorizedImageExtensions) { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| |
| fileExt := strings.ToLower(path.Ext(fileHeader.Filename)) |
| newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt) |
| avatarFilePath := path.Join(constant.PostSubPath, newFilename) |
| url, err = us.uploadImageFile(ctx, fileHeader, avatarFilePath) |
| if err != nil { |
| return "", err |
| } |
| us.fileRecordService.AddFileRecord(ctx, userID, avatarFilePath, url, string(plugin.UserPost)) |
| return url, nil |
| } |
| |
| func (us *uploaderService) UploadPostAttachment(ctx *gin.Context, userID string) ( |
| url string, err error) { |
| url, err = us.tryToUploadByPlugin(ctx, plugin.UserPostAttachment) |
| if err != nil { |
| return "", err |
| } |
| if len(url) > 0 { |
| return url, nil |
| } |
| |
| resp, err := us.siteInfoService.GetSiteAdvanced(ctx) |
| if err != nil { |
| return "", err |
| } |
| |
| ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, resp.GetMaxAttachmentSize()) |
| file, fileHeader, err := ctx.Request.FormFile("file") |
| if err != nil { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| defer func() { |
| _ = file.Close() |
| }() |
| if checker.IsUnAuthorizedExtension(fileHeader.Filename, resp.AuthorizedAttachmentExtensions) { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| |
| fileExt := strings.ToLower(path.Ext(fileHeader.Filename)) |
| newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt) |
| attachmentFilePath := path.Join(constant.FilesPostSubPath, newFilename) |
| url, err = us.uploadAttachmentFile(ctx, fileHeader, fileHeader.Filename, attachmentFilePath) |
| if err != nil { |
| return "", err |
| } |
| us.fileRecordService.AddFileRecord(ctx, userID, attachmentFilePath, url, string(plugin.UserPostAttachment)) |
| return url, nil |
| } |
| |
| func (us *uploaderService) UploadBrandingFile(ctx *gin.Context, userID string) ( |
| url string, err error) { |
| url, err = us.tryToUploadByPlugin(ctx, plugin.AdminBranding) |
| if err != nil { |
| return "", err |
| } |
| if len(url) > 0 { |
| return url, nil |
| } |
| |
| siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) |
| if err != nil { |
| return "", err |
| } |
| |
| ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, siteAdvanced.GetMaxImageSize()) |
| file, fileHeader, err := ctx.Request.FormFile("file") |
| if err != nil { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| defer func() { |
| _ = file.Close() |
| }() |
| fileExt := strings.ToLower(path.Ext(fileHeader.Filename)) |
| if _, ok := plugin.DefaultFileTypeCheckMapping[plugin.AdminBranding][fileExt]; !ok { |
| return "", errors.BadRequest(reason.RequestFormatError).WithError(err) |
| } |
| |
| newFilename := fmt.Sprintf("%s%s", uid.IDStr12(), fileExt) |
| avatarFilePath := path.Join(constant.BrandingSubPath, newFilename) |
| url, err = us.uploadImageFile(ctx, fileHeader, avatarFilePath) |
| if err != nil { |
| return "", err |
| } |
| us.fileRecordService.AddFileRecord(ctx, userID, avatarFilePath, url, string(plugin.AdminBranding)) |
| return url, nil |
| } |
| |
| func (us *uploaderService) uploadImageFile(ctx *gin.Context, file *multipart.FileHeader, fileSubPath string) ( |
| url string, err error) { |
| siteGeneral, err := us.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| return "", err |
| } |
| siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) |
| if err != nil { |
| return "", err |
| } |
| filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath) |
| if err := ctx.SaveUploadedFile(file, filePath); err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| |
| src, err := file.Open() |
| if err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| defer func() { |
| _ = src.Close() |
| }() |
| |
| if !checker.DecodeAndCheckImageFile(filePath, siteAdvanced.GetMaxImageMegapixel()) { |
| return "", errors.BadRequest(reason.UploadFileUnsupportedFileFormat) |
| } |
| |
| if err := removeExif(filePath); err != nil { |
| log.Error(err) |
| } |
| |
| url = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, fileSubPath) |
| return url, nil |
| } |
| |
| func (us *uploaderService) uploadAttachmentFile(ctx *gin.Context, file *multipart.FileHeader, originalFilename, fileSubPath string) ( |
| downloadUrl string, err error) { |
| siteGeneral, err := us.siteInfoService.GetSiteGeneral(ctx) |
| if err != nil { |
| return "", err |
| } |
| filePath := path.Join(us.serviceConfig.UploadPath, fileSubPath) |
| if err := ctx.SaveUploadedFile(file, filePath); err != nil { |
| return "", errors.InternalServer(reason.UnknownError).WithError(err).WithStack() |
| } |
| |
| // Need url encode the original filename. Because the filename may contain special characters that conflict with the markdown syntax. |
| originalFilename = url.QueryEscape(originalFilename) |
| |
| // The original filename is 123.pdf |
| // The local saved path is /UploadPath/hash.pdf |
| // When downloading, the download link will be redirect to the local saved path. And the download filename will be 123.png. |
| downloadPath := strings.TrimSuffix(fileSubPath, filepath.Ext(fileSubPath)) + "/" + originalFilename |
| downloadUrl = fmt.Sprintf("%s/uploads/%s", siteGeneral.SiteUrl, downloadPath) |
| return downloadUrl, nil |
| } |
| |
| func (us *uploaderService) tryToUploadByPlugin(ctx *gin.Context, source plugin.UploadSource) ( |
| url string, err error) { |
| siteAdvanced, err := us.siteInfoService.GetSiteAdvanced(ctx) |
| if err != nil { |
| return "", err |
| } |
| cond := plugin.UploadFileCondition{ |
| Source: source, |
| MaxImageSize: siteAdvanced.MaxImageSize, |
| MaxAttachmentSize: siteAdvanced.MaxAttachmentSize, |
| MaxImageMegapixel: siteAdvanced.MaxImageMegapixel, |
| AuthorizedImageExtensions: siteAdvanced.AuthorizedImageExtensions, |
| AuthorizedAttachmentExtensions: siteAdvanced.AuthorizedAttachmentExtensions, |
| } |
| _ = plugin.CallStorage(func(fn plugin.Storage) error { |
| resp := fn.UploadFile(ctx, cond) |
| if resp.OriginalError != nil { |
| log.Errorf("upload file by plugin failed, err: %v", resp.OriginalError) |
| err = errors.BadRequest("").WithMsg(resp.DisplayErrorMsg.Translate(ctx)).WithError(err) |
| } else { |
| url = resp.FullURL |
| } |
| return nil |
| }) |
| return url, err |
| } |
| |
| // removeExif remove exif |
| // only support jpg/jpeg/png |
| func removeExif(path string) error { |
| ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) |
| if ext != "jpeg" && ext != "jpg" && ext != "png" { |
| return nil |
| } |
| img, err := os.ReadFile(path) |
| if err != nil { |
| return err |
| } |
| noExifBytes, err := exifremove.Remove(img) |
| if err != nil { |
| return err |
| } |
| return os.WriteFile(path, noExifBytes, 0644) |
| } |