package console // 文件直传(固件升级包 / 产品图片等)—— 后端签发「阿里云 OSS 预签名 PUT URL」,浏览器直传。 // // 设计要点: // 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss), // 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。 // ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。 // 2. 固件 / 产品图的 object key 以「产品绑定的应用名」为根,形如 // <应用名>/product/<产品id>/<文件名>。应用名取自产品的 appnames, // 不写死、也不跟登录态里的 X-App-Id。并做防路径穿越清洗。 // 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。 import ( "errors" "fmt" "path" "strconv" "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 = comm.AliyunOSSProvider // 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:<应用名>/<目录>/<文件名>。 // // app 产品绑定的应用名(如 deepGlass),先经 distAppSeg 清洗。 // dir 调用方给的逻辑目录(如 "product/47632/")。 // name 文件名(取 base 去掉任何目录部分)。 // // 例:buildObjectKey("deepGlass", "product/47632/", "fw.bin") // // => "deepGlass/product/47632/fw.bin" func buildObjectKey(app, dir, name string) (string, error) { seg := distAppSeg(app) if seg == "" { return "", fmt.Errorf("上传必须指定应用") } dir = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/") if dir == "" || dir == "." || strings.HasPrefix(dir, "..") { return "", fmt.Errorf("上传目录无效") } name = path.Base(strings.TrimSpace(name)) if name == "" || name == "." || name == "/" { return "", fmt.Errorf("文件名无效") } key := path.Join(seg, dir, name) if !strings.HasPrefix(key, seg+"/") { return "", fmt.Errorf("上传目录无效") } return key, nil } // parseMainUploadDir 清洗主桶目录,并认出 product/<产品id>/… 里的产品 id。 // // 没有产品 id 的只有「新建产品、还没保存」时的产品图:product/images/。 // 其它目录一律拒绝——主桶这条入口现在只给固件和产品图。 func parseMainUploadDir(dir string) (clean string, productID uint32, err error) { clean = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/") if clean == "" || clean == "." || strings.HasPrefix(clean, "..") { return "", 0, fmt.Errorf("上传目录无效") } parts := strings.Split(clean, "/") if parts[0] != "product" { return "", 0, fmt.Errorf("不支持的上传目录: %s", parts[0]) } if len(parts) < 2 { return "", 0, fmt.Errorf("上传目录无效") } if parts[1] == "images" { return clean, 0, nil } id, nerr := strconv.ParseUint(parts[1], 10, 32) if nerr != nil || id == 0 { return "", 0, fmt.Errorf("产品目录无效") } return clean, uint32(id), nil } func splitAppNames(csv string) []string { parts := strings.Split(csv, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } // pickBoundApp 从产品已绑定的应用里挑上传目录用的那一个。 // // requested 非空时必须真在绑定列表里,避免前端把 deepGlass 的固件写进别的应用目录。 // 没指定时:只绑了一个就用它;一个都没有、或绑了多个却不说用哪个,都拒绝。 func pickBoundApp(bound []string, requested string) (string, error) { req := strings.TrimSpace(requested) if req != "" { for _, a := range bound { if a == req { return a, nil } } return "", fmt.Errorf("该产品未绑定应用 %s", req) } switch len(bound) { case 0: return "", fmt.Errorf("产品未绑定应用,请先绑定后再上传") case 1: return bound[0], nil default: return "", fmt.Errorf("该产品绑定了多个应用,上传时必须指定应用") } } // resolveMainUploadApp 决定固件/产品图放在哪个应用目录下。 // // 已有产品:读产品行上的 appnames(现查库,不走缓存,刚改的绑定马上生效)。 // 还没保存的新产品:目录是 product/images/,应用名必须是应用中心里真实存在的名字。 func (this *serverComp) resolveMainUploadApp(dir, requested string) (app, cleanDir string, err error) { cleanDir, pid, err := parseMainUploadDir(dir) if err != nil { return "", "", err } if pid > 0 { p, e := dvProduct(consoleDeviceConn(), pid) if e != nil { if errors.Is(e, postgres.ErrNoDocuments) { return "", "", fmt.Errorf("产品不存在") } return "", "", e } if p == nil || p.Id == 0 { return "", "", fmt.Errorf("产品不存在") } app, err = pickBoundApp(splitAppNames(p.Appnames), requested) return app, cleanDir, err } req := strings.TrimSpace(requested) if req == "" { return "", "", fmt.Errorf("请先绑定应用,再上传产品图") } exist, e := this.module.model.getProductByName(req) if e != nil { if errors.Is(e, postgres.ErrNoDocuments) { return "", "", fmt.Errorf("应用不存在: %s", req) } return "", "", e } if exist == nil || exist.Id == 0 || exist.Name == "" { return "", "", fmt.Errorf("应用不存在: %s", req) } return exist.Name, cleanDir, nil } // 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 两种用法: // - target=dist:页面上选中的应用(安装包目录)。没有 X-App-Id 时用它补作用域。 // - 主桶(固件/产品图):产品绑了多个应用时,点名文件放进哪一个应用的目录。 // 必须是该产品 appnames 里的名字;登录态的 X-App-Id 不参与这段路径。 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 } } } var conf *aliyunOSSConf var key string var err error if strings.EqualFold(strings.TrimSpace(req.Target), "dist") { conf, err = this.loadAliyunOSSConf(appName) if err != nil { writeErr(c, pb.ErrorCode_SystemError, err.Error()) return } conf = conf.ForDist() if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } } else { // 目录根取产品绑定的应用名,不看登录态里的 X-App-Id。 // 请求体 app_name 只在产品绑了多个应用时用来点名用哪一个, // 而且这个名字必须真在绑定列表里。 uploadApp, dir, rerr := this.resolveMainUploadApp(req.Path, req.AppName) if rerr != nil { writeErr(c, pb.ErrorCode_ReqParameterError, rerr.Error()) return } conf, err = this.loadAliyunOSSConf(uploadApp) if err != nil { writeErr(c, pb.ErrorCode_SystemError, err.Error()) return } if key, err = buildObjectKey(uploadApp, dir, req.Filename); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, 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 } 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}) }