Files

115 lines
4.0 KiB
Go
Raw Permalink Normal View History

// 功能描述:独立的报修照片受控存储,内容摘要使同一账户重复上传幂等。
// 版本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))
}