115 lines
4.0 KiB
Go
115 lines
4.0 KiB
Go
|
|
// 功能描述:独立的报修照片受控存储,内容摘要使同一账户重复上传幂等。
|
|||
|
|
// 版本:1.0.0。
|
|||
|
|
package upload
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"crypto/sha256"
|
|||
|
|
"fmt"
|
|||
|
|
"git.apinb.com/bsm-sdk/core/errcode"
|
|||
|
|
"git.apinb.com/bsm-sdk/core/infra"
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
"io"
|
|||
|
|
"log"
|
|||
|
|
"net/http"
|
|||
|
|
"os"
|
|||
|
|
"path/filepath"
|
|||
|
|
"regexp"
|
|||
|
|
"strings"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
var ticketPhotoName = regexp.MustCompile(`^[a-f0-9]{64}\.(jpg|png)$`)
|
|||
|
|
|
|||
|
|
// ServeOwnedTicketPhoto 恢复尚未关联工单的照片,只解析当前账户目录内的摘要文件名。
|
|||
|
|
func ServeOwnedTicketPhoto(ctx *gin.Context, identity, name string) {
|
|||
|
|
if !ticketPhotoName.MatchString(name) {
|
|||
|
|
ctx.Status(http.StatusNotFound)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
ServeTicketPhoto(ctx, identity, "/uploads/ticket-photos/"+avatarOwnerDirectory("user_app", identity)+"/"+name)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ticketPhotoPath 只接受当前账户目录中的摘要文件名,不允许用户指定路径。
|
|||
|
|
func ticketPhotoPath(identity, uri string) (string, bool) {
|
|||
|
|
prefix := "/uploads/ticket-photos/" + avatarOwnerDirectory("user_app", identity) + "/"
|
|||
|
|
name := strings.TrimPrefix(uri, prefix)
|
|||
|
|
if !strings.HasPrefix(uri, prefix) || !ticketPhotoName.MatchString(name) {
|
|||
|
|
return "", false
|
|||
|
|
}
|
|||
|
|
return filepath.Join(uploadRoot(), "ticket-photos", filepath.FromSlash(avatarOwnerDirectory("user_app", identity)), name), true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// OwnsTicketPhoto 校验工单创建引用的资源归属和完整普通文件。
|
|||
|
|
func OwnsTicketPhoto(identity, uri string) bool {
|
|||
|
|
path, ok := ticketPhotoPath(identity, uri)
|
|||
|
|
if !ok {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
info, err := os.Lstat(path)
|
|||
|
|
return err == nil && info.Mode().IsRegular() && info.Size() > 0 && info.Size() <= maxAvatarSize
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// UploadTicketPhoto 由用户Client鉴权入口调用,仅复用图片字节校验,不复用头像存储或权限。
|
|||
|
|
func UploadTicketPhoto(ctx *gin.Context, identity string) {
|
|||
|
|
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10))
|
|||
|
|
header, err := ctx.FormFile("file")
|
|||
|
|
if err != nil || header.Size <= 0 || header.Size > maxAvatarSize {
|
|||
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
file, err := header.Open()
|
|||
|
|
if err != nil {
|
|||
|
|
infra.Response.Error(ctx, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
defer file.Close()
|
|||
|
|
content, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
|
|||
|
|
if err != nil || int64(len(content)) > maxAvatarSize {
|
|||
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
extension, contentType, err := validateAvatar(header.Filename, content)
|
|||
|
|
if err != nil {
|
|||
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
name := fmt.Sprintf("%x%s", sha256.Sum256(content), extension)
|
|||
|
|
uri := "/uploads/ticket-photos/" + avatarOwnerDirectory("user_app", identity) + "/" + name
|
|||
|
|
path, _ := ticketPhotoPath(identity, uri)
|
|||
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
|||
|
|
infra.Response.Error(ctx, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// 先完整写临时文件再原子移动,避免并发重试读到半张图片。
|
|||
|
|
temp, err := os.CreateTemp(filepath.Dir(path), ".pending-")
|
|||
|
|
if err != nil {
|
|||
|
|
infra.Response.Error(ctx, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
defer os.Remove(temp.Name())
|
|||
|
|
_, writeErr := temp.Write(content)
|
|||
|
|
closeErr := temp.Close()
|
|||
|
|
if writeErr != nil || closeErr != nil {
|
|||
|
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := os.Rename(temp.Name(), path); err != nil && !OwnsTicketPhoto(identity, uri) {
|
|||
|
|
infra.Response.Error(ctx, err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
infra.Response.Success(ctx, UploadFileReply{URI: uri, ContentType: contentType, Size: int64(len(content))})
|
|||
|
|
log.Printf("ticket photo upload account=%s digest=%s bytes=%d", identity, name, len(content))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ServeTicketPhoto 在工单归属和证据关联已经校验后读取,禁止公开缓存和MIME嗅探。
|
|||
|
|
func ServeTicketPhoto(ctx *gin.Context, identity, uri string) {
|
|||
|
|
if !OwnsTicketPhoto(identity, uri) {
|
|||
|
|
ctx.Status(http.StatusNotFound)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
path, _ := ticketPhotoPath(identity, uri)
|
|||
|
|
ctx.Header("Cache-Control", "private, no-store")
|
|||
|
|
ctx.Header("X-Content-Type-Options", "nosniff")
|
|||
|
|
ctx.File(path)
|
|||
|
|
log.Printf("ticket photo read account=%s digest=%s", identity, filepath.Base(path))
|
|||
|
|
}
|