package console // 文件直传(固件升级包 / 产品图片等)—— 后端签发「阿里云 OSS 预签名 PUT URL」,浏览器直传。 // // 设计要点: // 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss), // 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。 // ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。 // 2. object key 由服务端强制置于 firmwareRoot("yunyan") 根前缀下,形如 // yunyan/product/<产品id>/<文件名>,并做防路径穿越清洗。 // 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。 import ( "fmt" "net/url" "path" "strings" "yunyan/comm" "yunyan/lego/sys/postgres" "yunyan/pb" "yunyan/sys/aliyun/oss" "github.com/gin-gonic/gin" ) // ossStorageProvider svc_config 中对象存储服务的 provider 值(与 api_svctemplate.go 的 // storage_aliyun_oss 模板 provider 一致)。 const ossStorageProvider = "aliyun_oss" // firmwareRoot 所有上传对象强制置于该根前缀下(用户要求 yunyan/... 结构,服务端兜底)。 const firmwareRoot = "yunyan" // aliyunOSSConf 从 svc_config 解出的一份可用的阿里云 OSS 凭证。 type aliyunOSSConf struct { Endpoint string AccessKeyId string AccessSecret string Bucket string Domain string // 可选:自定义 CDN 域名,配了则文件下载 URL 走该域名 } // loadAliyunOSSConf 读取阿里云 OSS 有效配置。 // 优先读【系统配置 → 对象存储】(平台自用表 sys_service_config,category=storage, provider=aliyun_oss); // 该表无有效配置时,回退旧的【第三方服务配置 → 存储】(svc_config) 以兼容存量部署。 // 加密字段(access_key_id/access_key_secret) 用 console 的 EncryptKey 解密。 func (this *serverComp) loadAliyunOSSConf(appName string) (*aliyunOSSConf, error) { if oc := this.loadSysStorageAliyun(); oc != nil { return oc, nil } find := func(app string) *ThirdSvcConfig { rows := make([]*ThirdSvcConfig, 0) if err := postgres.Find(comm.TableSvcConfig, &rows, "app_name=? AND provider=? AND enable=?", app, ossStorageProvider, true); err != nil || len(rows) == 0 { return nil } return rows[0] } cfg := find(appName) if cfg == nil && appName != "" { cfg = find("") // 应用无专属配置则回退全局默认 } if cfg == nil { return nil, fmt.Errorf("未配置阿里云 OSS:请在【第三方服务配置 → 存储】新增并启用「阿里云 OSS」服务") } get := func(key string) string { for _, f := range cfg.Fields { if f.Key != key { continue } v := f.DefValue if f.Encrypted && v != "" { if plain, err := svcDecrypt(this.options.EncryptKey, v); err == nil { v = plain } } return strings.TrimSpace(v) } return "" } oc := &aliyunOSSConf{ Endpoint: get("endpoint"), AccessKeyId: get("access_key_id"), AccessSecret: get("access_key_secret"), Bucket: get("bucket"), Domain: get("domain"), } if oc.Endpoint == "" || oc.AccessKeyId == "" || oc.AccessSecret == "" || oc.Bucket == "" { return nil, fmt.Errorf("阿里云 OSS 配置不完整:endpoint / access_key_id / access_key_secret / bucket 均为必填") } // 规范化 endpoint 的 scheme:SDK 对无 scheme 的 endpoint 默认拼 http://, // 会导致 https 后台页直传时被浏览器按「混合内容」拦截;这里统一升到 https。 if !strings.HasPrefix(oc.Endpoint, "http://") && !strings.HasPrefix(oc.Endpoint, "https://") { oc.Endpoint = "https://" + oc.Endpoint } return oc, nil } // buildObjectKey 生成对象存储 key,强制置于 firmwareRoot 根下并做防路径穿越清洗。 // // dir 调用方给的逻辑目录(如 "product/12/"),可空。 // name 文件名(取 base 去掉任何目录部分)。 // // 例:buildObjectKey("product/12/", "fw_v1.bin") => "yunyan/product/12/fw_v1.bin" func buildObjectKey(dir, name string) string { name = path.Base(strings.TrimSpace(name)) // 去掉任何目录部分,防止 name 里夹带 ../ if name == "" || name == "." || name == "/" { name = "file" } key := path.Clean(path.Join(firmwareRoot, dir, name)) // Join+Clean 合并 //、解析 .. // 兜底:若 Clean 后逃逸出根前缀(dir 里塞了过量 ../),强制退回根下只保留文件名。 if key != firmwareRoot && !strings.HasPrefix(key, firmwareRoot+"/") { key = firmwareRoot + "/" + name } return key } // getUploadURL 前端固件/图片直传:读取阿里云 OSS 配置,为目标对象签发一个预签名 PUT URL。 // // 路由沿用 api_getcostoken(前端 useApi.uploadFile 用该名,避免前端改路由)。 // 请求体 { path, filename, content_type };返回 { upload_url(预签名PUT), url(去签名的公网URL), key }。 func (this *serverComp) getUploadURL(c *gin.Context) { var req struct { Path string `json:"path"` Filename string `json:"filename"` ContentType string `json:"content_type"` } _ = c.ShouldBindJSON(&req) if strings.TrimSpace(req.Filename) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "filename 必填") return } // 作用域:X-App-Id → 应用名(缺失/解析失败即全局默认)。 appName := "" if appId := parseAppId(c.GetHeader("X-App-Id")); appId > 0 { if app, err := this.module.model.getApp(appId); err == nil && app != nil { appName = app.Name } } conf, err := this.loadAliyunOSSConf(appName) if err != nil { writeErr(c, pb.ErrorCode_SystemError, err.Error()) return } presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket) if err != nil { writeErr(c, pb.ErrorCode_SystemError, "初始化阿里云 OSS 失败: "+err.Error()) return } key := buildObjectKey(req.Path, req.Filename) ct := strings.TrimSpace(req.ContentType) if ct == "" { ct = "application/octet-stream" } uploadURL, err := presigner.SignPutURL(key, ct, 900) // 有效期 15 分钟 if err != nil { writeErr(c, pb.ErrorCode_SystemError, "生成上传地址失败: "+err.Error()) return } // 公网访问 URL(存进 updatepackageaddress 的裸地址): // 配了自定义域名 → https:///(走 CDN);否则去掉预签名 query 的裸 OSS 地址。 publicURL := uploadURL if d := strings.TrimSpace(conf.Domain); d != "" { d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") publicURL = "https://" + strings.TrimRight(d, "/") + "/" + key } else if u, e := url.Parse(uploadURL); e == nil { u.RawQuery = "" publicURL = u.String() } writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key}) }