|
|
@ -6,16 +6,20 @@ package console |
|
|
// 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss),
|
|
|
// 1. OSS 凭证来自【第三方服务配置(svc_config) → 存储 → 阿里云 OSS】(provider=aliyun_oss),
|
|
|
// 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。
|
|
|
// 即 serviceconfig.vue 里落库、并按应用分离(app_name='' 为全局默认)的那份配置。
|
|
|
// ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。
|
|
|
// ——不再走腾讯云 COS / 也不读那份纯演示未落库的「系统配置」页。
|
|
|
// 2. object key 由服务端强制置于 uploadRoot("EAIMAR") 根前缀下,形如
|
|
|
// 2. 固件 / 产品图的 object key 以「产品绑定的应用名」为根,形如
|
|
|
// EAIMAR/product/<产品id>/<文件名>,并做防路径穿越清洗。
|
|
|
// <应用名>/product/<产品id>/<文件名>。应用名取自产品的 appnames,
|
|
|
|
|
|
// 不写死、也不跟登录态里的 X-App-Id。并做防路径穿越清洗。
|
|
|
// 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。
|
|
|
// 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。
|
|
|
|
|
|
|
|
|
import ( |
|
|
import ( |
|
|
|
|
|
"errors" |
|
|
"fmt" |
|
|
"fmt" |
|
|
"path" |
|
|
"path" |
|
|
|
|
|
"strconv" |
|
|
"strings" |
|
|
"strings" |
|
|
|
|
|
|
|
|
"yunyan/comm" |
|
|
"yunyan/comm" |
|
|
|
|
|
"yunyan/lego/sys/postgres" |
|
|
"yunyan/pb" |
|
|
"yunyan/pb" |
|
|
"yunyan/sys/aliyun/oss" |
|
|
"yunyan/sys/aliyun/oss" |
|
|
|
|
|
|
|
|
@ -26,9 +30,6 @@ import ( |
|
|
// storage_aliyun_oss 模板 provider 一致)。
|
|
|
// storage_aliyun_oss 模板 provider 一致)。
|
|
|
const ossStorageProvider = comm.AliyunOSSProvider |
|
|
const ossStorageProvider = comm.AliyunOSSProvider |
|
|
|
|
|
|
|
|
// uploadRoot 定义已抽到 comm.OSSUploadRoot,与 App 上传(user_getuploadurl)共用。
|
|
|
|
|
|
const uploadRoot = comm.OSSUploadRoot |
|
|
|
|
|
|
|
|
|
|
|
// aliyunOSSConf 定义已抽到 comm,供 console 后台直传与 App 上传(user_getuploadurl)
|
|
|
// aliyunOSSConf 定义已抽到 comm,供 console 后台直传与 App 上传(user_getuploadurl)
|
|
|
// 共用同一份读取逻辑——两边必须落在同一个桶里,否则后台传的固件和 App 传的录音会分家。
|
|
|
// 共用同一份读取逻辑——两边必须落在同一个桶里,否则后台传的固件和 App 传的录音会分家。
|
|
|
type aliyunOSSConf = comm.AliyunOSSConf |
|
|
type aliyunOSSConf = comm.AliyunOSSConf |
|
|
@ -38,23 +39,135 @@ func (this *serverComp) loadAliyunOSSConf(appName string) (*aliyunOSSConf, error |
|
|
return comm.LoadAliyunOSSConf(appName, this.options.EncryptKey) |
|
|
return comm.LoadAliyunOSSConf(appName, this.options.EncryptKey) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// buildObjectKey 生成对象存储 key,强制置于 uploadRoot 根下并做防路径穿越清洗。
|
|
|
// buildObjectKey 生成主桶对象 key:<应用名>/<目录>/<文件名>。
|
|
|
//
|
|
|
//
|
|
|
// dir 调用方给的逻辑目录(如 "product/12/"),可空。
|
|
|
// app 产品绑定的应用名(如 deepGlass),先经 distAppSeg 清洗。
|
|
|
|
|
|
// dir 调用方给的逻辑目录(如 "product/47632/")。
|
|
|
// name 文件名(取 base 去掉任何目录部分)。
|
|
|
// name 文件名(取 base 去掉任何目录部分)。
|
|
|
//
|
|
|
//
|
|
|
// 例:buildObjectKey("product/12/", "fw_v1.bin") => "EAIMAR/product/12/fw_v1.bin"
|
|
|
// 例:buildObjectKey("deepGlass", "product/47632/", "fw.bin")
|
|
|
func buildObjectKey(dir, name string) string { |
|
|
//
|
|
|
name = path.Base(strings.TrimSpace(name)) // 去掉任何目录部分,防止 name 里夹带 ../
|
|
|
// => "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 == "/" { |
|
|
if name == "" || name == "." || name == "/" { |
|
|
name = "file" |
|
|
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("上传目录无效") |
|
|
} |
|
|
} |
|
|
key := path.Clean(path.Join(uploadRoot, dir, name)) // Join+Clean 合并 //、解析 ..
|
|
|
parts := strings.Split(clean, "/") |
|
|
// 兜底:若 Clean 后逃逸出根前缀(dir 里塞了过量 ../),强制退回根下只保留文件名。
|
|
|
if parts[0] != "product" { |
|
|
if key != uploadRoot && !strings.HasPrefix(key, uploadRoot+"/") { |
|
|
return "", 0, fmt.Errorf("不支持的上传目录: %s", parts[0]) |
|
|
key = uploadRoot + "/" + name |
|
|
|
|
|
} |
|
|
} |
|
|
return key |
|
|
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。
|
|
|
// distContentTypes 分发桶里按扩展名强制的 Content-Type。
|
|
|
@ -135,13 +248,10 @@ func (this *serverComp) getUploadURL(c *gin.Context) { |
|
|
Filename string `json:"filename"` |
|
|
Filename string `json:"filename"` |
|
|
ContentType string `json:"content_type"` |
|
|
ContentType string `json:"content_type"` |
|
|
Target string `json:"target"` |
|
|
Target string `json:"target"` |
|
|
// AppName 页面上选中的应用名(app_registry 的 name / app_name)。
|
|
|
// AppName 两种用法:
|
|
|
//
|
|
|
// - target=dist:页面上选中的应用(安装包目录)。没有 X-App-Id 时用它补作用域。
|
|
|
// ⚠️ 只在没有 X-App-Id 时才用它。X-App-Id 来自登录态里的 currentAppId,
|
|
|
// - 主桶(固件/产品图):产品绑了多个应用时,点名文件放进哪一个应用的目录。
|
|
|
// 只有运营/代理这种「绑定了应用」的账号才有;超管在「版本与分发」页顶部选了应用,
|
|
|
// 必须是该产品 appnames 里的名字;登录态的 X-App-Id 不参与这段路径。
|
|
|
// 那个选择只是页面里的一个 app_name 字符串,根本不会进请求头——于是超管传安装包
|
|
|
|
|
|
// 必然被拒成「必须先选择应用」,且看起来像是没选。前端现在把页面上选的应用名
|
|
|
|
|
|
// 一并放进请求体,这里按名字反查注册表补上作用域。
|
|
|
|
|
|
AppName string `json:"app_name"` |
|
|
AppName string `json:"app_name"` |
|
|
} |
|
|
} |
|
|
_ = c.ShouldBindJSON(&req) |
|
|
_ = c.ShouldBindJSON(&req) |
|
|
@ -163,20 +273,38 @@ func (this *serverComp) getUploadURL(c *gin.Context) { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
conf, err := this.loadAliyunOSSConf(appName) |
|
|
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 { |
|
|
if err != nil { |
|
|
writeErr(c, pb.ErrorCode_SystemError, err.Error()) |
|
|
writeErr(c, pb.ErrorCode_SystemError, err.Error()) |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
var key string |
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(req.Target), "dist") { |
|
|
|
|
|
conf = conf.ForDist() |
|
|
conf = conf.ForDist() |
|
|
if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil { |
|
|
if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil { |
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
} else { |
|
|
} else { |
|
|
key = buildObjectKey(req.Path, req.Filename) |
|
|
// 目录根取产品绑定的应用名,不看登录态里的 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) |
|
|
presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket) |
|
|
if err != nil { |
|
|
if err != nil { |
|
|
|