You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
9.0 KiB
206 lines
9.0 KiB
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://<domain>/<key>(走 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})
|
|
}
|
|
|