package controllers import ( "fmt" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "server/models" "server/pkg/tagengine" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" ) // TenantSiteController 租户官网前台渲染(Go 直出 HTML,无需登录) // 路由:GET / | /news | /news/:id | /page/:path type TenantSiteController struct { beego.Controller } // siteNotFoundPage 未匹配域名的提示页 const siteNotFoundPage = ` 站点不存在

站点不存在

当前域名未绑定任何租户官网,请检查域名配置。

` // renderErrorPage 渲染失败的兜底页 const renderErrorPage = ` 页面异常

页面暂时无法访问

%s

` // resolveTid 按访问 Host 识别租户 ID。 // 规则:dev 环境支持 X-Tenant-ID 头切换 → localhost/127.0.0.1 默认租户 1 // → 平台主域名池命中返回租户 1(平台站点)→ 租户域名表命中返回对应 tid // → 全部未命中返回 0,false(不再默认回落租户 1) func (c *TenantSiteController) resolveTid() (uint64, bool) { runmode, _ := beego.AppConfig.String("runmode") if runmode == "dev" { if h := strings.TrimSpace(c.Ctx.Request.Header.Get("X-Tenant-ID")); h != "" { if decoded, err := url.QueryUnescape(h); err == nil { h = decoded } if n, err := strconv.ParseUint(h, 10, 64); err == nil && n > 0 { return n, true } var tenant models.SystemTenant cond := orm.NewCondition().Or("tenant_name", h).Or("tenant_short_name", h) if err := models.Orm.QueryTable(new(models.SystemTenant)).SetCond(cond).One(&tenant); err == nil { return tenant.ID, true } } } host := c.Ctx.Request.Host if idx := strings.Index(host, ":"); idx > 0 { host = host[:idx] } if host == "localhost" || host == "127.0.0.1" { return 1, true } // 平台主域名池命中 → 平台站点(tid 1 + 默认模板) poolCnt, _ := models.Orm.QueryTable(new(models.SystemDomainPool)). Filter("main_domain", host). Filter("status", 1). Filter("delete_time__isnull", true). Count() if poolCnt > 0 { return 1, true } var td models.SystemTenantDomain err := models.Orm.QueryTable(new(models.SystemTenantDomain)). Filter("full_domain", host). Filter("status", 1). Filter("delete_time__isnull", true). One(&td) if err == nil && td.Tid != nil && *td.Tid > 0 { return *td.Tid, true } return 0, false } // resolveTemplateCode 解析租户选用模板编码,缺失/停用/目录不存在均回退 default func (c *TenantSiteController) resolveTemplateCode(tid uint64) string { code := strings.TrimSpace(cmsTenantTemplateCode(tid)) if code != "" && code != "default" { cnt, _ := models.Orm.QueryTable(new(models.CmsFrontendTemplate)). Filter("code", code). Filter("status", 1). Filter("delete_time__isnull", true). Count() if cnt == 0 { code = "" } } if code == "" { code = "default" } if !cmsTemplateDirExists(code) { code = "default" } return code } // writeHTML 直出 HTML func (c *TenantSiteController) writeHTML(status int, html string) { c.Ctx.Output.SetStatus(status) c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8") c.Ctx.WriteString(c.forceHTTPSStorageURLs(html)) } // forceHTTPSStorageURLs 访客以 HTTPS 访问站点时,把页面中存储(CDN)域名的 // http:// 资源升级为 https://,避免混合内容(Mixed Content)被浏览器拦截。 // 仅替换存储配置里那个域名,不动正文里的其他外部链接。 func (c *TenantSiteController) forceHTTPSStorageURLs(html string) string { proto := c.Ctx.Input.Header("X-Forwarded-Proto") if proto == "" && c.Ctx.Input.IsSecure() { proto = "https" } if proto != "https" { return html } cfg, err := models.GetStorageConfig() if err != nil || strings.TrimSpace(cfg.QiniuDomain) == "" { return html } host := strings.TrimSpace(cfg.QiniuDomain) host = strings.TrimPrefix(host, "http://") host = strings.TrimPrefix(host, "https://") host = strings.TrimRight(host, "/") if host == "" { return html } return strings.ReplaceAll(html, "http://"+host, "https://"+host) } // rewriteThemeAssets 将模板内书写的相对资源路径(href/src/url() 等) // 统一改写为 /themes/{code}/ 下的绝对路径,保证任意页面深度(/、/news、 // /news/:id、/page/:path)下静态资源都能命中 beego 的 /themes 静态映射。 // 第三方模板通常直接写相对路径(如 assets/css/xxx.css),不改写会 404。 // 注:Go regexp(RE2)不支持 \1/\2 反向引用,闭合引号用 ["'] 宽松匹配; // 值部分 [^"']* 不含引号,正常 HTML 下首尾引号必然一致,行为等价。 var ( reThemeAssetAttr = regexp.MustCompile(`(?i)\b(href|src|poster|data-src|srcset)\s*=\s*(["'])([^"']*)["']`) reThemeAssetURL = regexp.MustCompile(`(?i)url\(\s*(["']?)([^"')]+)["']?\s*\)`) ) func rewriteThemeAssets(html, code string) string { prefix := "/themes/" + code + "/" fix := func(u string) string { u = strings.TrimSpace(u) if u == "" { return u } lower := strings.ToLower(u) // 绝对路径/协议链接/锚点等一律不动(含引擎输出的 /uploads、存储 CDN 地址) if strings.HasPrefix(u, "/") || strings.HasPrefix(u, "#") || strings.HasPrefix(u, "?") || strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") || strings.HasPrefix(lower, "//") || strings.HasPrefix(lower, "data:") || strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") { return u } u = strings.TrimPrefix(u, "./") for strings.HasPrefix(u, "../") { u = strings.TrimPrefix(u, "../") } return prefix + u } html = reThemeAssetAttr.ReplaceAllStringFunc(html, func(m string) string { sub := reThemeAssetAttr.FindStringSubmatch(m) attr, quote, val := sub[1], sub[2], sub[3] if strings.EqualFold(attr, "srcset") { parts := strings.Split(val, ",") for i, p := range parts { seg := strings.Fields(strings.TrimSpace(p)) if len(seg) > 0 { seg[0] = fix(seg[0]) parts[i] = strings.Join(seg, " ") } } val = strings.Join(parts, ", ") } else { val = fix(val) } return attr + "=" + quote + val + quote }) // 行内 style /