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.
334 lines
13 KiB
334 lines
13 KiB
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://<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})
|
|
}
|
|
|