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.
229 lines
8.5 KiB
229 lines
8.5 KiB
package console
|
|
|
|
// 下载落地页管理(2026-09-23 加):后台「版本与分发」页的「下载页」一行。
|
|
//
|
|
// 分发桶(dl.ymaikj.com)上每个应用放一份静态下载页,外包装二维码指向它,
|
|
// 如 https://dl.ymaikj.com/EAIMAR/download.html。以前只能改仓库里的 deploy/dl 再手传 OSS,
|
|
// 现在由后台读回源码、编辑、发布:
|
|
//
|
|
// api_getdistpage 读线上那份 HTML(服务端 GetObject,不经 CDN/浏览器缓存)
|
|
// api_savedistpage 发布:PutObject 覆盖,并把地址写进 app_release.download_page
|
|
//
|
|
// 读写都走服务端而不是浏览器直传:能强制 Content-Type 与 Cache-Control(见 saveDistPage),
|
|
// 读到的也一定是桶里的最新版本,而不是某一层缓存。草稿只存在浏览器本地,不经过这里。
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"yunyan/comm"
|
|
"yunyan/pb"
|
|
"yunyan/sys/aliyun/oss"
|
|
|
|
aoss "github.com/aliyun/aliyun-oss-go-sdk/oss"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// distPageDefaultName 下载页默认文件名:<分发域名>/<应用名>/download.html。
|
|
const distPageDefaultName = "download.html"
|
|
|
|
// distPageMaxBytes 下载页源码上限。现网那份内联了 App 图标也才 70KB,
|
|
// 给到 2MB 足够,同时挡住误把安装包之类的大文件当页面发布。
|
|
const distPageMaxBytes = 2 << 20
|
|
|
|
// distPageNameRe 文件名只允许一层、字母数字开头、.html 结尾。
|
|
// 分发桶是公共读的,这里放宽一点就等于让人能在 dl.ymaikj.com 上随意挂页面。
|
|
var distPageNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\.html$`)
|
|
|
|
// distHost 从分发域名配置里取出 host(运营可能连 scheme、路径一起填)。
|
|
func distHost(domain string) string {
|
|
d := strings.TrimSpace(domain)
|
|
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")
|
|
d = strings.Trim(d, "/")
|
|
if i := strings.Index(d, "/"); i >= 0 {
|
|
d = d[:i]
|
|
}
|
|
return d
|
|
}
|
|
|
|
// defaultDistPageURL 某应用下载页的默认地址。
|
|
func defaultDistPageURL(domain, appSeg string) string {
|
|
return (&url.URL{Scheme: "https", Host: distHost(domain), Path: "/" + appSeg + "/" + distPageDefaultName}).String()
|
|
}
|
|
|
|
// parseDistPageURL 校验后台输入框里的地址,返回 object key 与规范化后的地址。
|
|
//
|
|
// 规则:域名必须是分发域名;路径必须恰好是 /<应用名>/<文件名>.html。
|
|
// 前缀由服务端按选中的应用定死,页面上只能改文件名——不然一个应用的运营
|
|
// 就能覆盖掉别的应用的下载页,或者覆盖 android/ 下的安装包。
|
|
func parseDistPageURL(raw, domain, appSeg string) (key, canonical string, err error) {
|
|
host := distHost(domain)
|
|
if host == "" {
|
|
return "", "", fmt.Errorf("未配置分发域名")
|
|
}
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", "", fmt.Errorf("下载页地址不能为空")
|
|
}
|
|
u, perr := url.Parse(raw)
|
|
if perr != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return "", "", fmt.Errorf("下载页地址格式不对,应形如 https://%s/%s/%s", host, appSeg, distPageDefaultName)
|
|
}
|
|
if !strings.EqualFold(u.Host, host) {
|
|
return "", "", fmt.Errorf("下载页只能放在分发域名 %s 下", host)
|
|
}
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("下载页地址必须是 https://%s/%s/<文件名>.html,不能多也不能少一层目录", host, appSeg)
|
|
}
|
|
if parts[0] != appSeg {
|
|
return "", "", fmt.Errorf("下载页只能放在本应用的目录 /%s/ 下", appSeg)
|
|
}
|
|
name := parts[1]
|
|
if !distPageNameRe.MatchString(name) {
|
|
return "", "", fmt.Errorf("文件名只能用字母、数字、. _ -,且以小写 .html 结尾")
|
|
}
|
|
key = path.Join(appSeg, name)
|
|
canonical = (&url.URL{Scheme: "https", Host: host, Path: "/" + key}).String()
|
|
return key, canonical, nil
|
|
}
|
|
|
|
// distPageScope 解析一次下载页请求的作用域:应用分段 + 分发桶句柄 + 分发域名。
|
|
func (this *serverComp) distPageScope(appName string) (appSeg string, bucket *oss.OSS, domain string, err error) {
|
|
appName = strings.TrimSpace(appName)
|
|
if appName == "" {
|
|
return "", nil, "", fmt.Errorf("请先在页面顶部选择应用:下载页按应用分目录存放")
|
|
}
|
|
// 必须是注册过的应用,不能让任意字符串在公共桶里造目录。
|
|
app, aerr := this.module.model.getAppByNameOrAppName(appName)
|
|
if aerr != nil || app == nil {
|
|
return "", nil, "", fmt.Errorf("应用 %s 未在应用注册表中登记", appName)
|
|
}
|
|
appSeg = distAppSeg(appName)
|
|
if appSeg == "" || comm.DistUploadDirs[appSeg] {
|
|
return "", nil, "", fmt.Errorf("应用名 %s 不能用作下载页目录", appName)
|
|
}
|
|
conf, cerr := this.loadAliyunOSSConf(app.Name)
|
|
if cerr != nil {
|
|
return "", nil, "", cerr
|
|
}
|
|
conf = conf.ForDist()
|
|
bucket, err = oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
|
|
if err != nil {
|
|
return "", nil, "", fmt.Errorf("初始化阿里云 OSS 失败: %v", err)
|
|
}
|
|
return appSeg, bucket, conf.Domain, nil
|
|
}
|
|
|
|
// getDistPage 读某应用的下载页源码。
|
|
//
|
|
// 请求 { app_name, url? }:url 为空时取已保存的地址,再空取默认地址。
|
|
// 返回 { url, saved_url, default_url, content, exists };对象不存在时 exists=false、content 为空,
|
|
// 不当成错误——新应用第一次用本功能时本来就没有页面。
|
|
func (this *serverComp) getDistPage(c *gin.Context) {
|
|
var req struct {
|
|
AppName string `json:"app_name"`
|
|
URL string `json:"url"`
|
|
}
|
|
_ = c.ShouldBindJSON(&req)
|
|
appSeg, bucket, domain, err := this.distPageScope(req.AppName)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
row, err := this.loadAppRelease(req.AppName)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
defURL := defaultDistPageURL(domain, appSeg)
|
|
target := strings.TrimSpace(req.URL)
|
|
if target == "" {
|
|
target = strings.TrimSpace(row.DownloadPage)
|
|
}
|
|
if target == "" {
|
|
target = defURL
|
|
}
|
|
key, canonical, err := parseDistPageURL(target, domain, appSeg)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
content, exists := "", true
|
|
data, gerr := bucket.GetObject(key)
|
|
if gerr != nil {
|
|
if se, ok := gerr.(aoss.ServiceError); ok && se.StatusCode == http.StatusNotFound {
|
|
exists = false
|
|
} else {
|
|
writeErr(c, pb.ErrorCode_SystemError, "读取下载页失败: "+gerr.Error())
|
|
return
|
|
}
|
|
} else {
|
|
content = string(data)
|
|
}
|
|
writeOK(c, gin.H{
|
|
"url": canonical,
|
|
"saved_url": row.DownloadPage,
|
|
"default_url": defURL,
|
|
"content": content,
|
|
"exists": exists,
|
|
})
|
|
}
|
|
|
|
// saveDistPage 发布下载页:覆盖写到分发桶,并把地址记进 app_release.download_page。
|
|
//
|
|
// 请求 { app_name, url, content }。
|
|
func (this *serverComp) saveDistPage(c *gin.Context) {
|
|
var req struct {
|
|
AppName string `json:"app_name"`
|
|
URL string `json:"url"`
|
|
Content string `json:"content"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Content) == "" {
|
|
// 发一个空页面上去,扫码的人就是一片白——比保留旧页面糟得多,直接拒。
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "页面内容为空,未发布")
|
|
return
|
|
}
|
|
if len(req.Content) > distPageMaxBytes {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, fmt.Sprintf("页面超过 %dMB 上限", distPageMaxBytes>>20))
|
|
return
|
|
}
|
|
appSeg, bucket, domain, err := this.distPageScope(req.AppName)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
key, canonical, err := parseDistPageURL(req.URL, domain, appSeg)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
// ⚠️ 两个头都不能省:
|
|
// Content-Type 不带 charset 时,部分安卓浏览器按 GBK 猜,中文整页乱码;
|
|
// Cache-Control 不设时浏览器/微信会按启发式缓存,点了保存扫码的人还看到旧页面。
|
|
if err := bucket.UploadObject(key, bytes.NewReader([]byte(req.Content)),
|
|
aoss.ContentType("text/html; charset=utf-8"),
|
|
aoss.CacheControl("no-cache"),
|
|
); err != nil {
|
|
writeErr(c, pb.ErrorCode_SystemError, "发布到 OSS 失败: "+err.Error())
|
|
return
|
|
}
|
|
// 页面已经发出去了,地址落库失败只影响下次打开时回填哪个地址,不回滚发布。
|
|
m, err := this.loadAppRelease(req.AppName)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, "页面已发布,但保存地址失败: "+err.Error())
|
|
return
|
|
}
|
|
m.AppName = req.AppName
|
|
m.DownloadPage = canonical
|
|
this.saveAppRelease(c, m)
|
|
}
|
|
|