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

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})
}