package console // 文件直传(固件升级包 / 产品图片等)—— 后端签发「阿里云 OSS 预签名 PUT URL」,浏览器直传。 // // 设计要点: // 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss), // 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。 // ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。 // 2. object key 由服务端强制置于 uploadRoot("EAIMAR") 根前缀下,形如 // EAIMAR/product/<产品id>/<文件名>,并做防路径穿越清洗。 // 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。 import ( "fmt" "path" "strings" "yunyan/comm" "yunyan/pb" "yunyan/sys/aliyun/oss" "github.com/gin-gonic/gin" ) // ossStorageProvider svc_config 中对象存储服务的 provider 值(与 api_svctemplate.go 的 // storage_aliyun_oss 模板 provider 一致)。 const ossStorageProvider = comm.AliyunOSSProvider // uploadRoot 定义已抽到 comm.OSSUploadRoot,与 App 上传(user_getuploadurl)共用。 const uploadRoot = comm.OSSUploadRoot // aliyunOSSConf 定义已抽到 comm,供 console 后台直传与 App 上传(user_getuploadurl) // 共用同一份读取逻辑——两边必须落在同一个桶里,否则后台传的固件和 App 传的录音会分家。 type aliyunOSSConf = comm.AliyunOSSConf // loadAliyunOSSConf 读取阿里云 OSS 有效配置(取值顺序见 comm.LoadAliyunOSSConf)。 func (this *serverComp) loadAliyunOSSConf(appName string) (*aliyunOSSConf, error) { return comm.LoadAliyunOSSConf(appName, this.options.EncryptKey) } // buildObjectKey 生成对象存储 key,强制置于 uploadRoot 根下并做防路径穿越清洗。 // // dir 调用方给的逻辑目录(如 "product/12/"),可空。 // name 文件名(取 base 去掉任何目录部分)。 // // 例:buildObjectKey("product/12/", "fw_v1.bin") => "EAIMAR/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(uploadRoot, dir, name)) // Join+Clean 合并 //、解析 .. // 兜底:若 Clean 后逃逸出根前缀(dir 里塞了过量 ../),强制退回根下只保留文件名。 if key != uploadRoot && !strings.HasPrefix(key, uploadRoot+"/") { key = uploadRoot + "/" + name } return key } // distContentTypes 分发桶里按扩展名强制的 Content-Type。 // // ⚠️ 不能信前端传上来的 content_type:它计入预签名,一旦签成 text/plain 之类, // 安卓点下载会变成在浏览器里打开一堆乱码,而且要等真机装包时才发现。 // apk 用官方类型即可,浏览器不会尝试预览它,所以不需要再折腾 Content-Disposition。 var distContentTypes = map[string]string{ ".apk": "application/vnd.android.package-archive", } // distAppSeg 把应用名清洗成可安全放进 object key 的一段。 // // 值来自 X-App-Id 查库(不是前端传的),但仍然要洗:库里的名字是人填的, // 出现 "/" 或 ".." 就能把包传到别的目录去。只留字母数字和 -_,其余换成 _。 func distAppSeg(app string) string { app = strings.TrimSpace(app) var b strings.Builder for _, r := range app { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) default: b.WriteRune('_') } } // 洗完只剩下划线(应用名是 "//" 之类)时当作空:否则会造出 "__" 这种垃圾目录, // 而且它非空、能过下面的判断,等于白洗了。 seg := b.String() if strings.Trim(seg, "_") == "" { return "" } return seg } // buildDistObjectKey 生成「分发桶」里的对象 key:<目录>/<应用>/<文件名>。 // // 与主桶不同,这里**不加 EAIMAR 根前缀**:分发桶直接挂在 dl.ymaikj.com 上, // 传到 android/EAIMAR/x.apk 就是 https://dl.ymaikj.com/android/EAIMAR/x.apk, // 多一层固定前缀会让下载地址凭空多出一段。目录只允许 DistUploadDirs 里的那几个。 // // ⚠️ **应用名这一段是必须的**:一个桶要放多个应用(EAIMAR / deepGlass …)的安装包, // 不按应用分目录的话,两个应用只要打出同名文件就会互相覆盖——而且覆盖是静默的, // 要等用户下到别家的包才会发现。 func buildDistObjectKey(app, dir, name string) (string, error) { seg := distAppSeg(app) if seg == "" { return "", fmt.Errorf("上传安装包必须先选择应用(请求缺少 X-App-Id)") } dir = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/") if dir == "" || dir == "." { return "", fmt.Errorf("缺少上传目录") } // 只认第一段,且必须在白名单里:path.Clean 已经解掉了 ..,这里再卡一次目录名 top := dir if i := strings.Index(dir, "/"); i >= 0 { top = dir[:i] } if !comm.DistUploadDirs[top] { return "", fmt.Errorf("不支持的上传目录: %s", top) } name = path.Base(strings.TrimSpace(name)) if name == "" || name == "." || name == "/" { return "", fmt.Errorf("文件名无效") } return path.Join(dir, seg, name), nil } // getUploadURL 前端固件/图片/安装包直传:读取阿里云 OSS 配置,为目标对象签发一个预签名 PUT URL。 // // 路由沿用 api_getcostoken(前端 useApi.uploadFile 用该名,避免前端改路由)。 // 请求体 { path, filename, content_type, target };返回 { upload_url(预签名PUT), url(去签名的公网URL), key }。 // // target="dist" 时传到「分发桶」(dl.ymaikj.com),用于 App 安装包;缺省走主桶(固件/产品图)。 func (this *serverComp) getUploadURL(c *gin.Context) { var req struct { Path string `json:"path"` Filename string `json:"filename"` ContentType string `json:"content_type"` Target string `json:"target"` // AppName 页面上选中的应用名(app_registry 的 name / app_name)。 // // ⚠️ 只在没有 X-App-Id 时才用它。X-App-Id 来自登录态里的 currentAppId, // 只有运营/代理这种「绑定了应用」的账号才有;超管在「版本与分发」页顶部选了应用, // 那个选择只是页面里的一个 app_name 字符串,根本不会进请求头——于是超管传安装包 // 必然被拒成「必须先选择应用」,且看起来像是没选。前端现在把页面上选的应用名 // 一并放进请求体,这里按名字反查注册表补上作用域。 AppName string `json:"app_name"` } _ = 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 } } if appName == "" { if want := strings.TrimSpace(req.AppName); want != "" { if app, err := this.module.model.getAppByNameOrAppName(want); err == nil && app != nil { appName = app.Name } } } conf, err := this.loadAliyunOSSConf(appName) if err != nil { writeErr(c, pb.ErrorCode_SystemError, err.Error()) return } var key string if strings.EqualFold(strings.TrimSpace(req.Target), "dist") { conf = conf.ForDist() if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } } else { key = buildObjectKey(req.Path, req.Filename) } presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket) if err != nil { writeErr(c, pb.ErrorCode_SystemError, "初始化阿里云 OSS 失败: "+err.Error()) return } ct := strings.TrimSpace(req.ContentType) // 扩展名认识就以服务端为准,别信前端(理由见 distContentTypes) if forced := distContentTypes[strings.ToLower(path.Ext(key))]; forced != "" { ct = forced } 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 := conf.PublicURL(uploadURL, key) // ⚠️ 必须把实际签名用的 content_type 回带:服务端会按扩展名强制改写它(见 distContentTypes), // 而浏览器对 .apk 给出的 File.type 往往是**空字符串**。前端若仍按 file.type 发 PUT, // 与签名不符,OSS 直接回 403 SignatureDoesNotMatch——且报错里完全看不出是类型对不上。 writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key, "content_type": ct}) }