已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
@@ -4,7 +4,9 @@ package upload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
@@ -30,6 +32,11 @@ const (
|
||||
|
||||
// UploadAvatar 接收 JPG/PNG 头像,验证真实图片内容后写入受控目录。
|
||||
func UploadAvatar(ctx *gin.Context) {
|
||||
claims, parseErr := sdkmiddleware.ParseAuth(ctx)
|
||||
if parseErr != nil || claims.Identity == "" || claims.Client == "" {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10))
|
||||
fileHeader, err := ctx.FormFile("file")
|
||||
if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > maxAvatarSize {
|
||||
@@ -55,7 +62,8 @@ func UploadAvatar(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
datePath := time.Now().Format("2006/01/02")
|
||||
// 将上传文件绑定到登录主体,后续资料更新不能引用其他账户的头像。
|
||||
datePath := avatarOwnerDirectory(claims.Client, claims.Identity) + "/" + time.Now().Format("2006/01/02")
|
||||
filename := models.NewIdentity() + extension
|
||||
directory := filepath.Join(uploadRoot(), "avatars", filepath.FromSlash(datePath))
|
||||
if err := os.MkdirAll(directory, 0o750); err != nil {
|
||||
@@ -92,6 +100,25 @@ func UploadAvatar(ctx *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// avatarOwnerDirectory 对客户端和账户组合取摘要,避免身份内容成为文件系统路径。
|
||||
func avatarOwnerDirectory(client, identity string) string {
|
||||
return fmt.Sprintf("owners/%x", sha256.Sum256([]byte(client+"\x00"+identity)))
|
||||
}
|
||||
|
||||
// OwnsAvatar 验证规范 URI 属于当前账户且指向已上传的普通文件。
|
||||
func OwnsAvatar(client, identity, uri string) bool {
|
||||
prefix := "/uploads/avatars/" + avatarOwnerDirectory(client, identity) + "/"
|
||||
if !strings.HasPrefix(uri, prefix) || strings.Contains(uri, "\\") || strings.Contains(uri, "..") {
|
||||
return false
|
||||
}
|
||||
path, err := avatarPathFromURI(uri)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
return err == nil && info.Mode().IsRegular()
|
||||
}
|
||||
|
||||
// ServeAvatar 仅从头像受控目录读取文件,拒绝外部 URL 与目录穿越路径。
|
||||
func ServeAvatar(ctx *gin.Context, uri string) {
|
||||
path, err := avatarPathFromURI(uri)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -24,6 +25,33 @@ func pngBytes(t *testing.T, width, height int) []byte {
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
// TestAvatarOwnership 拒绝跨用户、跨客户端及穿越伪造路径,兼容读取旧头像路径。
|
||||
func TestAvatarOwnership(t *testing.T) {
|
||||
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
|
||||
uri := "/uploads/avatars/" + avatarOwnerDirectory("user_app", "alice") + "/2026/09/07/avatar.png"
|
||||
path, err := avatarPathFromURI(uri)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, pngBytes(t, 2, 2), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !OwnsAvatar("user_app", "alice", uri) {
|
||||
t.Fatal("当前用户上传资源未识别")
|
||||
}
|
||||
if OwnsAvatar("user_app", "bob", uri) || OwnsAvatar("platform_admin", "alice", uri) {
|
||||
t.Fatal("允许跨账户引用")
|
||||
}
|
||||
for _, unsafe := range []string{uri + "/../avatar.png", strings.ReplaceAll(uri, "/", "\\"), "/uploads/avatars/2026/09/07/avatar.png"} {
|
||||
if OwnsAvatar("user_app", "alice", unsafe) {
|
||||
t.Fatalf("接受未绑定 URI:%s", unsafe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAvatarAcceptsRealPNG 验证真实 PNG 可通过并规范化类型。
|
||||
func TestValidateAvatarAcceptsRealPNG(t *testing.T) {
|
||||
extension, contentType, err := validateAvatar("头像.PNG", pngBytes(t, 2, 2))
|
||||
|
||||
88
backend/api/internal/logic/upload/product_image.go
Normal file
88
backend/api/internal/logic/upload/product_image.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// 功能描述:后台商品图片上传及公开读取,独立于私有头像和工单照片;版本:1.0.0。
|
||||
package upload
|
||||
|
||||
import (
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var productImageName = regexp.MustCompile(`^[a-fA-F0-9-]{36}\.(jpg|png)$`)
|
||||
|
||||
// UploadProductImage 仅平台后台上传公开商品素材,限制2MB和4096像素并完整解码。
|
||||
func UploadProductImage(ctx *gin.Context) {
|
||||
claims, err := sdkmiddleware.ParseAuth(ctx)
|
||||
if err != nil || claims.Client != "platform_admin" || claims.Identity == "" {
|
||||
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
|
||||
return
|
||||
}
|
||||
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()
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
|
||||
if err != nil || int64(len(data)) > maxAvatarSize {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
ext, mime, err := validateAvatar(header.Filename, data)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
name := models.NewIdentity() + ext
|
||||
directory := filepath.Join(uploadRoot(), "product-images")
|
||||
if err := os.MkdirAll(directory, 0750); err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(directory, name)
|
||||
target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
_, writeErr := target.Write(data)
|
||||
closeErr := target.Close()
|
||||
if writeErr != nil || closeErr != nil {
|
||||
_ = os.Remove(path)
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, UploadFileReply{URI: "/uploads/product-images/" + name, OriginalName: header.Filename, ContentType: mime, Size: int64(len(data))})
|
||||
}
|
||||
|
||||
// ServeProductImage 仅公开独立商品图片目录内的普通文件,不暴露通用上传根目录。
|
||||
func ServeProductImage(ctx *gin.Context) {
|
||||
name := ctx.Param("name")
|
||||
if !productImageName.MatchString(name) {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(uploadRoot(), "product-images", name)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
ctx.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ctx.Header("X-Content-Type-Options", "nosniff")
|
||||
// 普通img请求也必须按Origin区分缓存,避免无跨域头的缓存被Flutter跨域下载复用。
|
||||
ctx.Header("Vary", "Origin")
|
||||
ctx.Header("Cache-Control", "public, max-age=86400")
|
||||
ctx.File(path)
|
||||
}
|
||||
82
backend/api/internal/logic/upload/product_image_test.go
Normal file
82
backend/api/internal/logic/upload/product_image_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// 功能描述:商品图片上传读取闭环及权限边界;版本:1.0.0。
|
||||
package upload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"git.apinb.com/bsm-sdk/core/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProductImageRoundTrip 验证后台上传后匿名图片读取字节一致,普通用户不能上传。
|
||||
func TestProductImageRoundTrip(t *testing.T) {
|
||||
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
|
||||
picture := pngBytes(t, 8, 8)
|
||||
for _, client := range []string{"user_app", "platform_admin"} {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, _ := writer.CreateFormFile("file", "product.png")
|
||||
_, _ = part.Write(picture)
|
||||
_ = writer.Close()
|
||||
response := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(response)
|
||||
ctx.Set("Auth", &types.JwtClaims{Client: client, Identity: "test-admin"})
|
||||
ctx.Request = httptest.NewRequest("POST", "/upload/product-image", &body)
|
||||
ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
UploadProductImage(ctx)
|
||||
var reply struct {
|
||||
Code int `json:"code"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &reply); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client == "user_app" {
|
||||
if reply.Code == 0 {
|
||||
t.Fatal("普通用户上传成功")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reply.Code != 0 {
|
||||
t.Fatal(response.Body.String())
|
||||
}
|
||||
var uploaded UploadFileReply
|
||||
if err := json.Unmarshal(reply.Details, &uploaded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
readResponse := httptest.NewRecorder()
|
||||
readContext, _ := gin.CreateTestContext(readResponse)
|
||||
readContext.Request = httptest.NewRequest("GET", uploaded.URI, nil)
|
||||
readContext.Params = gin.Params{{Key: "name", Value: filepath.Base(uploaded.URI)}}
|
||||
ServeProductImage(readContext)
|
||||
if readResponse.Header().Get("Vary") != "Origin" {
|
||||
t.Fatal("无Origin的图片请求也必须区分跨域缓存")
|
||||
}
|
||||
if readResponse.Code != 200 || !bytes.Equal(readResponse.Body.Bytes(), picture) {
|
||||
t.Fatal("商品图片读取不一致")
|
||||
}
|
||||
if readResponse.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("缺少类型保护")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProductImageRejectsForeignPath 商品公开入口不能读取头像、工单或任意磁盘路径。
|
||||
func TestProductImageRejectsForeignPath(t *testing.T) {
|
||||
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
|
||||
for _, name := range []string{"../avatars/private.png", `..\ticket-photos\private.png`, "C:\\private.png", "photo.svg", ""} {
|
||||
response := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(response)
|
||||
ctx.Request = httptest.NewRequest("GET", "/uploads/product-images/invalid", nil)
|
||||
ctx.Params = gin.Params{{Key: "name", Value: name}}
|
||||
ServeProductImage(ctx)
|
||||
ctx.Writer.WriteHeaderNow()
|
||||
if response.Code != 404 || response.Body.Len() != 0 {
|
||||
t.Fatalf("错误读取非商品资源: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
backend/api/internal/logic/upload/ticket_photo.go
Normal file
114
backend/api/internal/logic/upload/ticket_photo.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// 功能描述:独立的报修照片受控存储,内容摘要使同一账户重复上传幂等。
|
||||
// 版本: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))
|
||||
}
|
||||
119
backend/api/internal/logic/upload/ticket_photo_test.go
Normal file
119
backend/api/internal/logic/upload/ticket_photo_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// 功能描述:报修上传的内容幂等、格式、跨账户和路径边界回归。
|
||||
// 版本:1.0.0。
|
||||
package upload
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/gin-gonic/gin"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func uploadPhotoForTest(t *testing.T, owner, filename string, content []byte) (string, int) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part.Write(content)
|
||||
writer.Close()
|
||||
response := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(response)
|
||||
ctx.Request = httptest.NewRequest("POST", "/ticket-photos", &body)
|
||||
ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
UploadTicketPhoto(ctx, owner)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var details struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
if result.Code == 0 {
|
||||
if err := json.Unmarshal(result.Details, &details); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return details.URI, result.Code
|
||||
}
|
||||
|
||||
// TestDraftPhotoRead 验证草稿读取只能访问当前账户已上传的文件名。
|
||||
func TestDraftPhotoRead(t *testing.T) {
|
||||
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
|
||||
picture := pngBytes(t, 2, 2)
|
||||
uri, code := uploadPhotoForTest(t, "alice", "image.png", picture)
|
||||
if code != 0 {
|
||||
t.Fatal("上传失败", code)
|
||||
}
|
||||
for _, scenario := range []struct {
|
||||
owner, name string
|
||||
status int
|
||||
}{
|
||||
{"alice", path.Base(uri), 200},
|
||||
{"bob", path.Base(uri), 404},
|
||||
{"alice", "../" + path.Base(uri), 404},
|
||||
{"alice", "unknown.png", 404},
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(response)
|
||||
ctx.Request = httptest.NewRequest("GET", "/ticket-photos/file", nil)
|
||||
ServeOwnedTicketPhoto(ctx, scenario.owner, scenario.name)
|
||||
ctx.Writer.WriteHeaderNow()
|
||||
if response.Code != scenario.status {
|
||||
t.Fatalf("%s: 状态 %d", scenario.owner, response.Code)
|
||||
}
|
||||
if scenario.status == 200 && !bytes.Equal(response.Body.Bytes(), picture) {
|
||||
t.Fatal("图片内容不一致")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicketPhotoStorage(t *testing.T) {
|
||||
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
|
||||
picture := pngBytes(t, 2, 2)
|
||||
first, code := uploadPhotoForTest(t, "alice", "image.png", picture)
|
||||
if code != 0 || !OwnsTicketPhoto("alice", first) {
|
||||
t.Fatal("上传失败", code)
|
||||
}
|
||||
second, code := uploadPhotoForTest(t, "alice", "other.png", picture)
|
||||
if code != 0 || first != second {
|
||||
t.Fatal("重复上传未复用")
|
||||
}
|
||||
if OwnsTicketPhoto("bob", first) {
|
||||
t.Fatal("跨账户引用")
|
||||
}
|
||||
for _, uri := range []string{first + "/../secret.png", strings.ReplaceAll(first, "/", "\\"), "https://example.com/a.png", "/uploads/avatars/a.png"} {
|
||||
if OwnsTicketPhoto("alice", uri) {
|
||||
t.Fatal("非法路径", uri)
|
||||
}
|
||||
}
|
||||
for _, data := range [][]byte{[]byte("fake"), pngBytes(t, 4097, 1), make([]byte, (2<<20)+1)} {
|
||||
if _, code := uploadPhotoForTest(t, "alice", "x.png", data); code == 0 {
|
||||
t.Fatal("错误图片被接受")
|
||||
}
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(response)
|
||||
ctx.Request = httptest.NewRequest("GET", "/photo", nil)
|
||||
ServeTicketPhoto(ctx, "alice", first)
|
||||
if response.Code != 200 || response.Header().Get("Cache-Control") != "private, no-store" || !bytes.Equal(response.Body.Bytes(), picture) {
|
||||
t.Fatal("读取或缓存保护失败")
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
ctx, _ = gin.CreateTestContext(response)
|
||||
ctx.Request = httptest.NewRequest("GET", "/photo", nil)
|
||||
ServeTicketPhoto(ctx, "bob", first)
|
||||
ctx.Writer.WriteHeaderNow()
|
||||
if response.Code != 404 {
|
||||
t.Fatal("越权读取")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user