refactor: decouple services from global config and clean up structure

- Remove duplicate UploadBaseURL constant (identical to APIBaseURL)
- Pass parentFileID and customDomain into service constructors instead of
  reading from config.GlobalConfig at call time, eliminating hidden global
  state dependencies in the service layer
- Replace []interface{} response building in HandleList with a typed
  imageResponse struct for compile-time safety
- Extract inline CORS closure from main.go into handler.CORSMiddleware(),
  consistent with how AuthMiddleware is organized
- Remove narrating comments throughout; keep only the non-obvious one
  explaining why DoRawPUT omits auth headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 14:48:11 +08:00
parent a2a5acdb06
commit 7229dfa1b7
9 changed files with 120 additions and 218 deletions

View File

@@ -17,49 +17,28 @@ func main() {
flag.StringVar(&cfgFlag, "c", "conf/config.yaml", "指定配置文件路径") flag.StringVar(&cfgFlag, "c", "conf/config.yaml", "指定配置文件路径")
flag.Parse() flag.Parse()
// 1. 初始化读取配置
config.InitConfig(cfgFlag) config.InitConfig(cfgFlag)
cfg := config.GlobalConfig
// 2. 注入 123pan 客户端基础 SDK client := pan123.NewClient(cfg.ClientID, cfg.ClientSecret)
client := pan123.NewClient(config.GlobalConfig.ClientID, config.GlobalConfig.ClientSecret)
// 3. 构造上层 Service 业务操作对象 uploadSvc := service.NewUploadService(client, cfg.ParentFileID, cfg.CustomDomain)
uploadSvc := service.NewUploadService(client) imageSvc := service.NewImageService(client, cfg.ParentFileID, cfg.CustomDomain)
imageSvc := service.NewImageService(client)
// 4. 组装 Web Handler 层
uploadHandler := handler.NewUploadHandler(uploadSvc) uploadHandler := handler.NewUploadHandler(uploadSvc)
imageHandler := handler.NewImageHandler(imageSvc) imageHandler := handler.NewImageHandler(imageSvc)
// 5. 启动 Gin 骨架
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.Default() r := gin.Default()
r.MaxMultipartMemory = 10 << 20
// 【安全防护】:将单次 Multipart 表单承受的最高物理占用内存限制为 10MB r.Use(handler.CORSMiddleware())
// 若超过此量则会被拒绝或直接下刷到临时文件盘,彻底免疫大发包引起的 OOM 宕机
r.MaxMultipartMemory = 10 << 20
// 提供一个基础版的跨域中间件(如果后续要在其他域名环境挂载 API 小组件的话会用到)
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
handler.ConfigureRoutes(r, imageHandler, uploadHandler) handler.ConfigureRoutes(r, imageHandler, uploadHandler)
addr := fmt.Sprintf(":%d", config.GlobalConfig.Port) addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("==============================") log.Printf("服务已启动,访问 http://localhost%s", addr)
log.Printf("123pan 私人图床服务已启动,并挂载高危防御盾牌!")
log.Printf("请访问 http://localhost%s 浏览!", addr)
log.Printf("==============================")
if err := r.Run(addr); err != nil { if err := r.Run(addr); err != nil {
log.Fatalf("Fatal: Web 服务启动暴毙了: %v", err) log.Fatalf("服务启动失败: %v", err)
} }
} }

View File

@@ -17,60 +17,51 @@ func NewImageHandler(svc *service.ImageService) *ImageHandler {
return &ImageHandler{imageSvc: svc} return &ImageHandler{imageSvc: svc}
} }
// HandleList 获取图床目录下所有图片 type imageResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"url"`
OriginURL string `json:"origin_url"`
CreatedAt string `json:"created_at"`
}
func (h *ImageHandler) HandleList(c *gin.Context) { func (h *ImageHandler) HandleList(c *gin.Context) {
items, err := h.imageSvc.GetImageItems() items, err := h.imageSvc.GetImageItems()
if err != nil { if err != nil {
log.Printf("查询图床列表失败: %v", err) log.Printf("查询图床列表失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
"code": 500,
"message": err.Error(),
})
return return
} }
// 仅返回是文件的信息 (过滤可能存在的子文件夹) images := make([]imageResponse, 0, len(items))
var images []interface{}
for _, it := range items { for _, it := range items {
// 123pan 中 type 通常 0是文件1是文件夹不过创建文件时type又传的1...这里我们只输出有 size 或文件形态的 images = append(images, imageResponse{
images = append(images, gin.H{ ID: it.FileID,
"id": it.FileID, Name: it.Filename,
"name": it.Filename, Size: it.Size,
"size": it.Size, URL: it.UserSelfURL,
"url": it.UserSelfURL, OriginURL: it.DownloadURL,
"origin_url": it.DownloadURL, CreatedAt: it.CreateAt,
"created_at": it.CreateAt,
}) })
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": images})
"code": 0,
"message": "success",
"data": images,
})
} }
// HandleDelete 删除单个图床图片
func (h *ImageHandler) HandleDelete(c *gin.Context) { func (h *ImageHandler) HandleDelete(c *gin.Context) {
id := c.Param("id") // 删除支持 逗号分割多选或者单个记录 id := c.Param("id")
if id == "" { if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "图片 ID 为空"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "图片 ID 为空"})
return return
} }
ids := strings.Split(id, ",") ids := strings.Split(id, ",")
err := h.imageSvc.DeleteImages(ids) if err := h.imageSvc.DeleteImages(ids); err != nil {
if err != nil {
log.Printf("删除图片 %v 失败: %v", ids, err) log.Printf("删除图片 %v 失败: %v", ids, err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
"code": 500,
"message": err.Error(),
})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{"code": 0, "message": "deleted"})
"code": 0,
"message": "deleted",
})
} }

View File

@@ -10,17 +10,27 @@ import (
"imagehost/static" "imagehost/static"
) )
// AuthMiddleware 核心 API 保护护卫 func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func AuthMiddleware() gin.HandlerFunc { func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
tokenCfg := config.GlobalConfig.APIToken tokenCfg := config.GlobalConfig.APIToken
// 如果配置文件放空了代表使用者放弃防御
if tokenCfg == "" { if tokenCfg == "" {
c.Next() c.Next()
return return
} }
// 适配支持 Header 注入 (WEB端拦截器) 或者 查询字符串 (简化图床软件推送提取)
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
queryToken := c.Query("token") queryToken := c.Query("token")
@@ -33,8 +43,7 @@ func AuthMiddleware() gin.HandlerFunc {
clientToken = queryToken clientToken = queryToken
} }
// 【防护高级演练】拦截网络侧信道 Timing 定时猜测攻击 // constant-time compare prevents timing-based token enumeration
// (通过将比较强行推至相同的微小汇编运行时间从而防止密钥逐字枚举)
if subtle.ConstantTimeCompare([]byte(clientToken), []byte(tokenCfg)) != 1 { if subtle.ConstantTimeCompare([]byte(clientToken), []byte(tokenCfg)) != 1 {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
"code": 403, "code": 403,
@@ -43,23 +52,20 @@ func AuthMiddleware() gin.HandlerFunc {
c.Abort() c.Abort()
return return
} }
c.Next() c.Next()
} }
} }
// ConfigureRoutes 注册所有的 API 路由和静态资源
func ConfigureRoutes(r *gin.Engine, imgHandler *ImageHandler, upHandler *UploadHandler) { func ConfigureRoutes(r *gin.Engine, imgHandler *ImageHandler, upHandler *UploadHandler) {
// API 分组进行统一下挂网门,使用中间件强权控制
api := r.Group("/api") api := r.Group("/api")
api.Use(AuthMiddleware()) // 全部保护! api.Use(AuthMiddleware())
{ {
api.POST("/upload", upHandler.HandleUpload) // 接收表单 file 字段传图 api.POST("/upload", upHandler.HandleUpload)
api.GET("/images", imgHandler.HandleList) // 瀑布流展示列表获取 api.GET("/images", imgHandler.HandleList)
api.DELETE("/images/:id", imgHandler.HandleDelete) // 删除图片 api.DELETE("/images/:id", imgHandler.HandleDelete)
} }
// 静态资源:图床首页前端,这部分无需密码即可被公网下载渲染 // 【全静态内嵌化架构升级】无须再依赖同目录下的 static 文件夹!
r.StaticFS("/static", http.FS(static.FS)) r.StaticFS("/static", http.FS(static.FS))
r.GET("/", func(c *gin.Context) { r.GET("/", func(c *gin.Context) {
htmlData, err := static.FS.ReadFile("index.html") htmlData, err := static.FS.ReadFile("index.html")

View File

@@ -18,22 +18,16 @@ func NewUploadHandler(svc *service.UploadService) *UploadHandler {
return &UploadHandler{uploadSvc: svc} return &UploadHandler{uploadSvc: svc}
} }
// HandleUpload 接受 /api/upload 的 POST 表单请求
func (h *UploadHandler) HandleUpload(c *gin.Context) { func (h *UploadHandler) HandleUpload(c *gin.Context) {
// 'file' 是约定好的 form 表单 file 键值,支持 ShareX / PicGo 等
fileHeader, err := c.FormFile("file") fileHeader, err := c.FormFile("file")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法收到图片文件: " + err.Error()})
"code": 400,
"message": "无法收到图片文件: " + err.Error(),
})
return return
} }
// 【安全防护1】强行判定后缀格式禁止脚本上传渗透
ext := strings.ToLower(filepath.Ext(fileHeader.Filename)) ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
validExts := map[string]bool{ validExts := map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".jpg": true, ".jpeg": true, ".png": true,
".gif": true, ".webp": true, ".svg": true, ".bmp": true, ".gif": true, ".webp": true, ".svg": true, ".bmp": true,
} }
if !validExts[ext] { if !validExts[ext] {
@@ -44,32 +38,24 @@ func (h *UploadHandler) HandleUpload(c *gin.Context) {
return return
} }
contentType := fileHeader.Header.Get("Content-Type") if !strings.HasPrefix(fileHeader.Header.Get("Content-Type"), "image/") {
if !strings.HasPrefix(contentType, "image/") { c.JSON(http.StatusUnsupportedMediaType, gin.H{"code": 415, "message": "不支持的 Content-Type 类型。"})
c.JSON(http.StatusUnsupportedMediaType, gin.H{
"code": 415,
"message": "不支持的 Content-Type 类型。",
})
return return
} }
log.Printf("准备上传文件: %s", fileHeader.Filename) log.Printf("[上传] 接收文件: %s", fileHeader.Filename)
fileInfo, err := h.uploadSvc.UploadFile(fileHeader) fileInfo, err := h.uploadSvc.UploadFile(fileHeader)
if err != nil { if err != nil {
log.Printf("上传业务失败: %v", err) log.Printf("[上传] 失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()})
"code": 500,
"message": err.Error(),
})
return return
} }
// 适配外部图床响应。ShareX/PicGo 中可以通过 json_path 指定取 url
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"code": 0, "code": 0,
"message": "success", "message": "success",
"data": fileInfo, "data": fileInfo,
"url": fileInfo.UserSelfURL, "url": fileInfo.UserSelfURL,
}) })
} }

View File

@@ -11,36 +11,31 @@ import (
) )
const ( const (
APIBaseURL = "https://open-api.123pan.com" APIBaseURL = "https://open-api.123pan.com"
UploadBaseURL = "https://open-api.123pan.com" PlatformName = "open_platform"
PlatformName = "open_platform"
) )
type Client struct { type Client struct {
httpClient *http.Client httpClient *http.Client
clientID string clientID string
clientSecret string clientSecret string
mu sync.RWMutex mu sync.RWMutex
token string token string
expiredAt time.Time expiredAt time.Time
} }
// NewClient 初始化 123pan API 的定制化客户端
func NewClient(clientID, clientSecret string) *Client { func NewClient(clientID, clientSecret string) *Client {
return &Client{ return &Client{
httpClient: &http.Client{ httpClient: &http.Client{Timeout: 30 * time.Second},
Timeout: 30 * time.Second, // 设置超时防阻塞
},
clientID: clientID, clientID: clientID,
clientSecret: clientSecret, clientSecret: clientSecret,
} }
} }
// getToken 安全获取当前有效的 token必要时自动刷新并缓存
func (c *Client) getToken() (string, error) { func (c *Client) getToken() (string, error) {
c.mu.RLock() c.mu.RLock()
// 提前 5 分钟刷新以防恰好在请求过程中过期 // refresh 5 minutes before expiry to avoid mid-request expiration
if c.token != "" && time.Now().Before(c.expiredAt.Add(-5*time.Minute)) { if c.token != "" && time.Now().Before(c.expiredAt.Add(-5*time.Minute)) {
t := c.token t := c.token
c.mu.RUnlock() c.mu.RUnlock()
@@ -50,7 +45,6 @@ func (c *Client) getToken() (string, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
// 双重检查锁定
if c.token != "" && time.Now().Before(c.expiredAt.Add(-5*time.Minute)) { if c.token != "" && time.Now().Before(c.expiredAt.Add(-5*time.Minute)) {
return c.token, nil return c.token, nil
} }
@@ -65,13 +59,12 @@ func (c *Client) getToken() (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("create token request error: %w", err) return "", fmt.Errorf("create token request error: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Platform", PlatformName) req.Header.Set("Platform", PlatformName)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("http execute token request failed: %w", err) return "", fmt.Errorf("token request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -79,7 +72,7 @@ func (c *Client) getToken() (string, error) {
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return "", fmt.Errorf("decode token response error: %w", err) return "", fmt.Errorf("decode token response error: %w", err)
} }
if apiResp.Code != 0 { if apiResp.Code != 0 {
return "", fmt.Errorf("123pan API error (get token), code: %d, msg: %s", apiResp.Code, apiResp.Message) return "", fmt.Errorf("123pan API error (get token), code: %d, msg: %s", apiResp.Code, apiResp.Message)
} }
@@ -91,7 +84,6 @@ func (c *Client) getToken() (string, error) {
exp, err := time.Parse(time.RFC3339, data.ExpiredAt) exp, err := time.Parse(time.RFC3339, data.ExpiredAt)
if err != nil { if err != nil {
// 解析失败时给个保守过期时间
exp = time.Now().Add(2 * time.Hour) exp = time.Now().Add(2 * time.Hour)
} }
@@ -100,7 +92,6 @@ func (c *Client) getToken() (string, error) {
return c.token, nil return c.token, nil
} }
// DoJSONRequest 发起基于 JSON 的标准请求,并自动装配所需的三大件 Header
func (c *Client) DoJSONRequest(method, url string, body interface{}, target interface{}) error { func (c *Client) DoJSONRequest(method, url string, body interface{}, target interface{}) error {
var bodyReader io.Reader var bodyReader io.Reader
if body != nil { if body != nil {
@@ -121,7 +112,6 @@ func (c *Client) DoJSONRequest(method, url string, body interface{}, target inte
return fmt.Errorf("获取 123pan AccessToken 失败: %w", err) return fmt.Errorf("获取 123pan AccessToken 失败: %w", err)
} }
// 123pan 开放平台标准 Header 规范
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Platform", PlatformName) req.Header.Set("Platform", PlatformName)
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
@@ -138,10 +128,9 @@ func (c *Client) DoJSONRequest(method, url string, body interface{}, target inte
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http status code error %d, body: %s", resp.StatusCode, string(respBytes)) return fmt.Errorf("http status %d, body: %s", resp.StatusCode, string(respBytes))
} }
// 反序列化 123pan 返回的整体数据 (BaseResp 格式需在调用该处的人自己指定或由外包装封入)
if target != nil { if target != nil {
if err := json.Unmarshal(respBytes, target); err != nil { if err := json.Unmarshal(respBytes, target); err != nil {
return fmt.Errorf("unmarshal response error: %w, payload: %s", err, string(respBytes)) return fmt.Errorf("unmarshal response error: %w, payload: %s", err, string(respBytes))
@@ -151,27 +140,25 @@ func (c *Client) DoJSONRequest(method, url string, body interface{}, target inte
return nil return nil
} }
// DoRawPUT 对于实际的分片数据上传,需要剥离 Auth 头部并发起干净的 PUT 请求 // DoRawPUT uploads binary data to a presigned URL without auth headers (required by 123pan).
func (c *Client) DoRawPUT(url string, data io.Reader, size int64) error { func (c *Client) DoRawPUT(url string, data io.Reader, size int64) error {
req, err := http.NewRequest("PUT", url, data) req, err := http.NewRequest("PUT", url, data)
if err != nil { if err != nil {
return fmt.Errorf("create put request error: %w", err) return fmt.Errorf("create put request error: %w", err)
} }
// 对于纯二进制上传必须准确标注 Content-Type 和 Length
req.Header.Set("Content-Type", "application/octet-stream") req.Header.Set("Content-Type", "application/octet-stream")
req.ContentLength = size req.ContentLength = size
// 文档强调: PUT请求的header中请不要携带Authorization、Platform参数
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("http put execute error: %w", err) return fmt.Errorf("PUT request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBytes, _ := io.ReadAll(resp.Body) respBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("http error code %d during PUT upload, body: %s", resp.StatusCode, string(respBytes)) return fmt.Errorf("PUT http status %d, body: %s", resp.StatusCode, string(respBytes))
} }
return nil return nil

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
) )
// GetFileList 获取某个目录下面的(图片)文件列表
func (c *Client) GetFileList(parentFileID string, limit int, lastFileID string) ([]FileItem, string, error) { func (c *Client) GetFileList(parentFileID string, limit int, lastFileID string) ([]FileItem, string, error) {
reqBody := FileListReq{ reqBody := FileListReq{
ParentFileID: parentFileID, ParentFileID: parentFileID,
@@ -15,37 +14,28 @@ func (c *Client) GetFileList(parentFileID string, limit int, lastFileID string)
} }
var resp BaseResp var resp BaseResp
// 注意这里文档使用的是 APIBaseURL + /api/v1/oss/file/list if err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/list", reqBody, &resp); err != nil {
err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/list", reqBody, &resp)
if err != nil {
return nil, "", err return nil, "", err
} }
if resp.Code != 0 { if resp.Code != 0 {
return nil, "", fmt.Errorf("123pan API error (list files), code: %d, msg: %s", resp.Code, resp.Message) return nil, "", fmt.Errorf("123pan API error (list files), code: %d, msg: %s", resp.Code, resp.Message)
} }
var data FileListRespData var data FileListRespData
if err := json.Unmarshal(resp.Data, &data); err != nil { if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, "", fmt.Errorf("fail to decode file list data: %w", err) return nil, "", fmt.Errorf("decode file list data error: %w", err)
} }
return data.FileList, data.LastFileID, nil return data.FileList, data.LastFileID, nil
} }
// DeleteFiles 根据 FileIDs 数组批量/单点删除远程文件
func (c *Client) DeleteFiles(fileIDs []string) error { func (c *Client) DeleteFiles(fileIDs []string) error {
reqBody := DeleteFileReq{ reqBody := DeleteFileReq{FileIDs: fileIDs}
FileIDs: fileIDs,
}
var resp BaseResp var resp BaseResp
// 删除接口使用的是 /api/v1/oss/file/delete if err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/delete", reqBody, &resp); err != nil {
err := c.DoJSONRequest("POST", APIBaseURL+"/api/v1/oss/file/delete", reqBody, &resp)
if err != nil {
return err return err
} }
if resp.Code != 0 { if resp.Code != 0 {
return fmt.Errorf("123pan API error (delete file), code: %d, msg: %s", resp.Code, resp.Message) return fmt.Errorf("123pan API error (delete file), code: %d, msg: %s", resp.Code, resp.Message)
} }

View File

@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
) )
// CreateFile 步骤1: 创建文件/发卷预申报。123pan 会下发预上传 IDpreuploadID或判定直接秒传。
func (c *Client) CreateFile(parentFileID, filename, etag string, size int64) (*CreateFileRespData, error) { func (c *Client) CreateFile(parentFileID, filename, etag string, size int64) (*CreateFileRespData, error) {
reqBody := CreateFileReq{ reqBody := CreateFileReq{
ParentFileID: parentFileID, ParentFileID: parentFileID,
@@ -16,12 +15,9 @@ func (c *Client) CreateFile(parentFileID, filename, etag string, size int64) (*C
} }
var resp BaseResp var resp BaseResp
// 上传接口大部分挂在 UploadBaseURL + /upload/v1/xxx if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/create", reqBody, &resp); err != nil {
err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/create", reqBody, &resp)
if err != nil {
return nil, err return nil, err
} }
if resp.Code != 0 { if resp.Code != 0 {
return nil, fmt.Errorf("123pan API error (create file), code: %d, msg: %s", resp.Code, resp.Message) return nil, fmt.Errorf("123pan API error (create file), code: %d, msg: %s", resp.Code, resp.Message)
} }
@@ -30,11 +26,9 @@ func (c *Client) CreateFile(parentFileID, filename, etag string, size int64) (*C
if err := json.Unmarshal(resp.Data, &data); err != nil { if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("decode create_file data error: %w", err) return nil, fmt.Errorf("decode create_file data error: %w", err)
} }
return &data, nil return &data, nil
} }
// GetUploadURL 步骤2: 凭借传入的 preuploadID 及 切片编号(对于整体直接设为1即可) 换取可以真正上传二进制的预签名 URL。
func (c *Client) GetUploadURL(preuploadID string, sliceNo int) (*GetUploadURLRespData, error) { func (c *Client) GetUploadURL(preuploadID string, sliceNo int) (*GetUploadURLRespData, error) {
reqBody := GetUploadURLReq{ reqBody := GetUploadURLReq{
PreuploadID: preuploadID, PreuploadID: preuploadID,
@@ -42,11 +36,9 @@ func (c *Client) GetUploadURL(preuploadID string, sliceNo int) (*GetUploadURLRes
} }
var resp BaseResp var resp BaseResp
err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/get_upload_url", reqBody, &resp) if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/get_upload_url", reqBody, &resp); err != nil {
if err != nil {
return nil, err return nil, err
} }
if resp.Code != 0 { if resp.Code != 0 {
return nil, fmt.Errorf("123pan API error (get upload url), code: %d, msg: %s", resp.Code, resp.Message) return nil, fmt.Errorf("123pan API error (get upload url), code: %d, msg: %s", resp.Code, resp.Message)
} }
@@ -55,22 +47,16 @@ func (c *Client) GetUploadURL(preuploadID string, sliceNo int) (*GetUploadURLRes
if err := json.Unmarshal(resp.Data, &data); err != nil { if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("decode get_upload_url data error: %w", err) return nil, fmt.Errorf("decode get_upload_url data error: %w", err)
} }
return &data, nil return &data, nil
} }
// UploadComplete 步骤4 (由于步骤3是调用泛化的 DoRawPUT 进行的纯数据上传): 当步骤3全部走完时调用此 API 宣告物理上传完毕
func (c *Client) UploadComplete(preuploadID string) (*UploadCompleteRespData, error) { func (c *Client) UploadComplete(preuploadID string) (*UploadCompleteRespData, error) {
reqBody := UploadCompleteReq{ reqBody := UploadCompleteReq{PreuploadID: preuploadID}
PreuploadID: preuploadID,
}
var resp BaseResp var resp BaseResp
err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/upload_complete", reqBody, &resp) if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/upload_complete", reqBody, &resp); err != nil {
if err != nil {
return nil, err return nil, err
} }
if resp.Code != 0 { if resp.Code != 0 {
return nil, fmt.Errorf("123pan API error (upload complete), code: %d, msg: %s", resp.Code, resp.Message) return nil, fmt.Errorf("123pan API error (upload complete), code: %d, msg: %s", resp.Code, resp.Message)
} }
@@ -79,22 +65,16 @@ func (c *Client) UploadComplete(preuploadID string) (*UploadCompleteRespData, er
if err := json.Unmarshal(resp.Data, &data); err != nil { if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("decode upload_complete data error: %w", err) return nil, fmt.Errorf("decode upload_complete data error: %w", err)
} }
return &data, nil return &data, nil
} }
// CheckAsyncResult 步骤5 (如需): 若上一步返回的数据指出 async == true 且 completed == false那么需轮询此 API
func (c *Client) CheckAsyncResult(preuploadID string) (*UploadAsyncResultRespData, error) { func (c *Client) CheckAsyncResult(preuploadID string) (*UploadAsyncResultRespData, error) {
reqBody := UploadAsyncResultReq{ reqBody := UploadAsyncResultReq{PreuploadID: preuploadID}
PreuploadID: preuploadID,
}
var resp BaseResp var resp BaseResp
err := c.DoJSONRequest("POST", UploadBaseURL+"/upload/v1/oss/file/upload_async_result", reqBody, &resp) if err := c.DoJSONRequest("POST", APIBaseURL+"/upload/v1/oss/file/upload_async_result", reqBody, &resp); err != nil {
if err != nil {
return nil, err return nil, err
} }
if resp.Code != 0 { if resp.Code != 0 {
return nil, fmt.Errorf("123pan API error (async result query), code: %d, msg: %s", resp.Code, resp.Message) return nil, fmt.Errorf("123pan API error (async result query), code: %d, msg: %s", resp.Code, resp.Message)
} }
@@ -103,6 +83,5 @@ func (c *Client) CheckAsyncResult(preuploadID string) (*UploadAsyncResultRespDat
if err := json.Unmarshal(resp.Data, &data); err != nil { if err := json.Unmarshal(resp.Data, &data); err != nil {
return nil, fmt.Errorf("decode async_result data error: %w", err) return nil, fmt.Errorf("decode async_result data error: %w", err)
} }
return &data, nil return &data, nil
} }

View File

@@ -4,41 +4,38 @@ import (
"fmt" "fmt"
"strings" "strings"
"imagehost/internal/config"
"imagehost/internal/pan123" "imagehost/internal/pan123"
) )
type ImageService struct { type ImageService struct {
client *pan123.Client client *pan123.Client
parentFileID string
customDomain string
} }
func NewImageService(client *pan123.Client) *ImageService { func NewImageService(client *pan123.Client, parentFileID, customDomain string) *ImageService {
return &ImageService{client: client} return &ImageService{
client: client,
parentFileID: parentFileID,
customDomain: strings.TrimRight(customDomain, "/"),
}
} }
// GetImageItems 获取图床列表格式化信息
func (s *ImageService) GetImageItems() ([]pan123.FileItem, error) { func (s *ImageService) GetImageItems() ([]pan123.FileItem, error) {
// 获取前 100 张即可 items, _, err := s.client.GetFileList(s.parentFileID, 100, "")
items, _, err := s.client.GetFileList(config.GlobalConfig.ParentFileID, 100, "")
if err != nil { if err != nil {
return nil, fmt.Errorf("获取列表异常: %w", err) return nil, fmt.Errorf("获取列表失败: %w", err)
} }
domain := strings.TrimRight(config.GlobalConfig.CustomDomain, "/")
// 格式化清理,如果有设置自定义解析,则将直链全部附魔为指定域名
for i := range items { for i := range items {
// 如果 123pan 源生返回了 userSelfURL可以优先。如果没有且我们配了 custom_domain,尝试通过拼接提供后备访问链 if items[i].UserSelfURL == "" && s.customDomain != "" {
if items[i].UserSelfURL == "" && domain != "" { items[i].UserSelfURL = fmt.Sprintf("%s/%s", s.customDomain, items[i].Filename)
// 一些简易替换,具体规则需按你在 123pan 的直链白名单空间里设置来调整
items[i].UserSelfURL = fmt.Sprintf("%s/%s", domain, items[i].Filename)
} }
} }
return items, nil return items, nil
} }
// DeleteImages 删除单张或多张
func (s *ImageService) DeleteImages(ids []string) error { func (s *ImageService) DeleteImages(ids []string) error {
if len(ids) == 0 { if len(ids) == 0 {
return nil return nil

View File

@@ -9,19 +9,23 @@ import (
"strings" "strings"
"time" "time"
"imagehost/internal/config"
"imagehost/internal/pan123" "imagehost/internal/pan123"
) )
type UploadService struct { type UploadService struct {
client *pan123.Client client *pan123.Client
parentFileID string
customDomain string
} }
func NewUploadService(client *pan123.Client) *UploadService { func NewUploadService(client *pan123.Client, parentFileID, customDomain string) *UploadService {
return &UploadService{client: client} return &UploadService{
client: client,
parentFileID: parentFileID,
customDomain: strings.TrimRight(customDomain, "/"),
}
} }
// UploadFile 处理单一文件的五步上传编排
func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.FileItem, error) { func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.FileItem, error) {
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
@@ -29,64 +33,53 @@ func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.Fil
} }
defer file.Close() defer file.Close()
// 【致命漏洞修复】使用流式处理替换无痛但高危的被动全量加载 (ReadAll)
h := md5.New() h := md5.New()
if _, err := io.Copy(h, file); err != nil { if _, err := io.Copy(h, file); err != nil {
return pan123.FileItem{}, fmt.Errorf("动态计算文件 MD5 失败: %w", err) return pan123.FileItem{}, fmt.Errorf("计算文件 MD5 失败: %w", err)
} }
etag := fmt.Sprintf("%x", h.Sum(nil)) etag := fmt.Sprintf("%x", h.Sum(nil))
size := fileHeader.Size size := fileHeader.Size
// 重置读取游标,准备让底层的 123pan.Client 直传该 file IO 节点
file.Seek(0, io.SeekStart) file.Seek(0, io.SeekStart)
fileName := fileHeader.Filename fileName := fileHeader.Filename
var fileID string var fileID string
// 步骤 1: 创建文件申报 createResp, err := s.client.CreateFile(s.parentFileID, fileName, etag, size)
createResp, err := s.client.CreateFile(config.GlobalConfig.ParentFileID, fileName, etag, size)
if err != nil { if err != nil {
return pan123.FileItem{}, fmt.Errorf("123pan 发送申报异常: %w", err) return pan123.FileItem{}, fmt.Errorf("123pan 创建文件失败: %w", err)
} }
if createResp.Reuse { if createResp.Reuse {
// 秒传命中
fileID = createResp.FileID fileID = createResp.FileID
log.Printf("[上传] 文件 %s 命中服务器秒传!", fileName) log.Printf("[上传] 文件 %s 命中秒传", fileName)
} else { } else {
// 没有秒传,需要物理 PUT 上传切片
preuploadID := createResp.PreuploadID preuploadID := createResp.PreuploadID
// 步骤 2: 获取签发 URL
urlResp, err := s.client.GetUploadURL(preuploadID, 1) urlResp, err := s.client.GetUploadURL(preuploadID, 1)
if err != nil { if err != nil {
return pan123.FileItem{}, fmt.Errorf("123pan 获取传地址失败: %w", err) return pan123.FileItem{}, fmt.Errorf("123pan 获取传地址失败: %w", err)
} }
log.Printf("[上传] 开始推送 %s 到 123pan (大小: %d bytes)...", fileName, size) log.Printf("[上传] 推送 %s 到 123pan (%d bytes)...", fileName, size)
// 步骤 3: 真正的二进制推送 (此处直接透传管道 file坚决不在 RAM 内过境缓存!) if err = s.client.DoRawPUT(urlResp.PresignedURL, file, size); err != nil {
err = s.client.DoRawPUT(urlResp.PresignedURL, file, size) return pan123.FileItem{}, fmt.Errorf("123pan 数据上传失败: %w", err)
if err != nil {
return pan123.FileItem{}, fmt.Errorf("123pan 数据切片推送失败: %w", err)
} }
// 步骤 4: 推送完毕申报
completeResp, err := s.client.UploadComplete(preuploadID) completeResp, err := s.client.UploadComplete(preuploadID)
if err != nil { if err != nil {
return pan123.FileItem{}, fmt.Errorf("123pan 汇报上传完结失败: %w", err) return pan123.FileItem{}, fmt.Errorf("123pan 上传完结通知失败: %w", err)
} }
// 判决处理策略
if completeResp.Completed { if completeResp.Completed {
fileID = completeResp.FileID fileID = completeResp.FileID
} else if completeResp.Async { } else if completeResp.Async {
// 步骤 5: 异步轮询 (最大容忍 20s) log.Printf("[上传] 123pan 异步处理 %s...", fileName)
log.Printf("[上传] 123pan 正在后端异步处理合并 %s...", fileName)
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
asyncResp, err := s.client.CheckAsyncResult(preuploadID) asyncResp, err := s.client.CheckAsyncResult(preuploadID)
if err != nil { if err != nil {
log.Printf("轮询错 (继续试): %v", err) log.Printf("[上传] 轮询错 (继续试): %v", err)
continue continue
} }
if asyncResp.Completed { if asyncResp.Completed {
@@ -95,24 +88,18 @@ func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.Fil
} }
} }
if fileID == "" { if fileID == "" {
return pan123.FileItem{}, fmt.Errorf("123pan 异步处理落盘超时") return pan123.FileItem{}, fmt.Errorf("123pan 异步处理超时")
} }
} else { } else {
return pan123.FileItem{}, fmt.Errorf("123pan 返回完结状态不能被理解: %v", completeResp) return pan123.FileItem{}, fmt.Errorf("123pan 返回未知完结状态: %v", completeResp)
} }
} }
log.Printf("[上传] 文件 %s 整体保存完毕, ID: %s", fileName, fileID) log.Printf("[上传] %s 完成, ID: %s", fileName, fileID)
// 根据图床需求,组装格式化的信息,此时可返回。
// 这里通过自定义协议拼接URL假若没有详情API能立即获取。或者使用 FileID 查询)
downloadURL := "" downloadURL := ""
if config.GlobalConfig.CustomDomain != "" { if s.customDomain != "" {
// 假设其静态资源访问域名形式为 domain/fileID/filename 或者是普通形式。 downloadURL = fmt.Sprintf("%s/%s", s.customDomain, fileName)
// 大部分直链空间只需: customDomain / fileName / file_id
// 作为通用兼容处理,如果直链域名未配特定的子径,先假设能直接根据路径拼接,具体的按实际调整。
domain := strings.TrimRight(config.GlobalConfig.CustomDomain, "/")
downloadURL = fmt.Sprintf("%s/%s", domain, fileName)
} }
return pan123.FileItem{ return pan123.FileItem{
@@ -121,6 +108,6 @@ func (s *UploadService) UploadFile(fileHeader *multipart.FileHeader) (pan123.Fil
Size: size, Size: size,
Etag: etag, Etag: etag,
DownloadURL: downloadURL, DownloadURL: downloadURL,
UserSelfURL: downloadURL, // 我们以此值为向外暴露的主要直链URL UserSelfURL: downloadURL,
}, nil }, nil
} }