package api
import (
"encoding/json"
"fmt"
"html"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/modules/seo"
"git.iioio.com/freefire/jiang13-forum/models"
"git.iioio.com/freefire/jiang13-forum/services"
)
var (
seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
)
const (
seoDescMax = 160
seoPrerenderMax = 4000
seoSitemapLimit = 5000
)
// RobotsTxt 搜索引擎抓取规则
func (h *Handlers) RobotsTxt(c *gin.Context) {
base := h.publicBaseURL(c)
var b strings.Builder
b.WriteString("User-agent: *\n")
b.WriteString("Allow: /\n")
b.WriteString("Disallow: /api/\n")
b.WriteString("Disallow: /admin\n")
b.WriteString("Disallow: /compose\n")
b.WriteString("Disallow: /login\n")
b.WriteString("Disallow: /register\n")
b.WriteString("Disallow: /profile\n")
b.WriteString("Disallow: /favorites\n")
b.WriteString("Disallow: /oauth/\n")
b.WriteString("Disallow: /media/\n")
b.WriteString("Disallow: /*/edit\n")
if base != "" {
b.WriteString("\nSitemap: ")
b.WriteString(base)
b.WriteString("/sitemap.xml\n")
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
}
// SitemapXML 公开页面站点地图
func (h *Handlers) SitemapXML(c *gin.Context) {
base := h.publicBaseURL(c)
if base == "" {
c.String(http.StatusServiceUnavailable, "未配置站点 ROOT_URL,无法生成 sitemap")
return
}
now := time.Now().UTC()
permalink := h.Settings.Permalink()
urls := []services.SitemapURL{
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
{Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"},
{Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"},
}
if boards, err := h.Board.List(); err == nil {
for _, board := range boards {
urls = append(urls, services.SitemapURL{
Loc: base + services.QueryBoardHome(board.ID, permalink),
LastMod: board.UpdatedAt.UTC(),
ChangeFreq: "daily",
Priority: "0.7",
})
}
}
if posts, e1 := h.Post.ListSitemap(seoSitemapLimit); e1 == nil {
for _, p := range posts {
lm := p.UpdatedAt
if lm.IsZero() {
lm = p.CreatedAt
}
urls = append(urls, services.SitemapURL{
Loc: base + permalink.PostPath(p.ID),
LastMod: lm.UTC(),
ChangeFreq: "weekly",
Priority: "0.8",
})
}
}
if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil {
for _, u := range users {
urls = append(urls, services.SitemapURL{
Loc: base + permalink.UserPath(u.ID),
LastMod: u.UpdatedAt.UTC(),
ChangeFreq: "weekly",
Priority: "0.5",
})
}
}
if pages, e3 := h.SitePage.ListSitemap(seoSitemapLimit); e3 == nil {
for _, p := range pages {
lm := p.UpdatedAt
if lm.IsZero() {
lm = p.CreatedAt
}
urls = append(urls, services.SitemapURL{
Loc: base + permalink.PagePath(p.Slug),
LastMod: lm.UTC(),
ChangeFreq: "monthly",
Priority: "0.5",
})
}
}
var b strings.Builder
b.WriteString(``)
b.WriteString(`
板块页 SSR 迁移中,请先从 首页 浏览。
`) return } // 帖子详情(含可选伪静态后缀) if pm := permalink.MatchPostPath(path); pm.OK { if pm.NeedsCanonicalRedirect(path) { c.Redirect(http.StatusMovedPermanently, pm.Canonical) return } post, err := h.Post.FindByID(pm.ID) if err != nil || !services.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) { h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) return } postKeywords := services.JoinSEOKeywords(post.Board.Name, siteKeywords) if isBot { c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post))) return } servePendingSSR(c, pageTitle(post.Title, siteName), `帖子详情 SSR 迁移中,请先从 首页 返回。
`) return } // 用户主页 if um := permalink.MatchUserPath(path); um.OK { if um.NeedsCanonicalRedirect(path) { c.Redirect(http.StatusMovedPermanently, um.Canonical) return } user, err := h.User.GetByID(um.ID) if err != nil || user.Banned { h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) return } if isBot { c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user))) return } servePendingSSR(c, pageTitle(user.Nickname, siteName), `用户主页 SSR 迁移中,请先从 首页 返回。
`) return } // 自定义单页 if pg := permalink.MatchPagePath(path); pg.OK { if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") { c.Redirect(http.StatusMovedPermanently, pg.Canonical) return } page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c)) if err != nil { h.serveNotFound(c, base, siteName, siteKeywords, path, isBot) return } desc := services.ExcerptFromHTML(page.Content, seoDescMax) meta := attachSiteSEO(&seo.PageMeta{ Title: pageTitle(page.Title, siteName), Description: desc, Keywords: services.JoinSEOKeywords(page.Title, siteKeywords), Canonical: services.AbsoluteURL(base, pg.Canonical), OGType: "article", OGImage: defaultImage, }, siteName, siteKeywords) if isBot { body := fmt.Sprintf(`该页面 SSR 迁移中。返回首页
`) } func servePendingSSR(c *gin.Context, title, bodyHTML string) { if strings.TrimSpace(title) == "" { title = "姜十三论坛" } page := fmt.Sprintf(`页面不存在。
`, html.EscapeString(pageTitle("页面不存在", siteName))) c.Header("Cache-Control", "no-cache") c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(page)) } func notFoundPageMeta(base, siteName, keywords, path string) *seo.PageMeta { return attachSiteSEO(&seo.PageMeta{ Title: pageTitle("页面不存在", siteName), Description: "您访问的页面不存在或已删除", Canonical: services.AbsoluteURL(base, path), OGType: "website", Robots: "noindex,follow", Status: http.StatusNotFound, }, siteName, keywords) } // attachSiteSEO 填充站点级 keywords / og:site_name / og:locale func attachSiteSEO(meta *seo.PageMeta, siteName, keywords string) *seo.PageMeta { if meta == nil { return nil } meta.SiteName = strings.TrimSpace(siteName) if strings.TrimSpace(meta.Keywords) == "" { meta.Keywords = strings.TrimSpace(keywords) } meta.Locale = "zh_CN" return meta } func isKnownPublicPath(path string) bool { switch path { case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards": return true } if seoPostEditRe.MatchString(path) { return true } permalink := services.PermalinkConfig{} if permalink.MatchBoardPath(path).OK { return true } if permalink.MatchPagePath(path).OK { return true } return false } func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand services.SiteBranding, base, siteName, defaultImage string) *seo.PageMeta { siteTitle := brand.DocumentTitle() homeDesc := services.TruncateRunes(brand.MetaDescription(), seoDescMax) siteKeywords := brand.MetaKeywords() meta := attachSiteSEO(&seo.PageMeta{ Title: siteTitle, Description: homeDesc, Keywords: siteKeywords, Canonical: services.AbsoluteURL(base, pathWithQuery(c)), OGType: "website", OGImage: defaultImage, }, siteName, siteKeywords) if isNoIndexPath(path) { meta.Robots = "noindex,nofollow" meta.Title = pageTitle(pathLabel(path), siteName) return meta } if path == "/" || path == "" { boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64) if boardID > 0 { if board, err := h.Board.GetByID(uint(boardID)); err == nil { desc := strings.TrimSpace(board.Description) if desc == "" { desc = brand.MetaDescription() } meta.Title = pageTitle(board.Name, siteName) meta.Description = services.TruncateRunes(desc, seoDescMax) meta.Canonical = services.AbsoluteURL(base, services.QueryBoardHome(board.ID, h.Settings.Permalink())) meta.Keywords = services.JoinSEOKeywords(board.Name, siteKeywords) return meta } // 无效板块 id:仍显示首页,但可标记 noindex meta.Robots = "noindex,follow" return meta } meta.JSONLD = mustJSON(map[string]any{ "@context": "https://schema.org", "@type": "WebSite", "name": siteName, "description": meta.Description, "url": services.AbsoluteURL(base, "/"), }) } if path == "/projects" { meta.Title = pageTitle("项目", siteName) meta.Description = services.TruncateRunes(siteName+" 的公开项目列表", seoDescMax) meta.Keywords = services.JoinSEOKeywords("项目", siteKeywords) } if path == "/links" { meta.Title = pageTitle("友情链接", siteName) meta.Description = services.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax) meta.Keywords = services.JoinSEOKeywords("友情链接", siteKeywords) } return meta } func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *models.Post) *seo.PageMeta { permalink := h.Settings.Permalink() content := services.RedactGatedPostHTML(post.Content) plain := post.ContentPlain if plain == "" { plain = services.StripHTMLForSearch(content) } desc := services.TruncateRunes(plain, seoDescMax) author := services.DisplayName(&post.User) canonical := services.AbsoluteURL(base, permalink.PostPath(post.ID)) ogImage := services.AbsoluteURL(base, services.FirstImageURL(content)) if ogImage == "" { ogImage = services.AbsoluteURL(base, post.User.Avatar) } if ogImage == "" { ogImage = defaultImage } jsonld := map[string]any{ "@context": "https://schema.org", "@type": "DiscussionForumPosting", "headline": post.Title, "description": desc, "datePublished": post.CreatedAt.UTC().Format(time.RFC3339), "dateModified": post.UpdatedAt.UTC().Format(time.RFC3339), "url": canonical, "mainEntityOfPage": canonical, "author": map[string]any{ "@type": "Person", "name": author, "url": services.AbsoluteURL(base, permalink.UserPath(post.UserID)), }, "interactionStatistic": map[string]any{ "@type": "InteractionCounter", "interactionType": "https://schema.org/ViewAction", "userInteractionCount": post.ViewCount, }, } if post.Board.Name != "" { jsonld["articleSection"] = post.Board.Name } if ogImage != "" { jsonld["image"] = []string{ogImage} } body := services.TruncateRunes(plain, seoPrerenderMax) if body != "" { jsonld["articleBody"] = body } return &seo.PageMeta{ Title: pageTitle(post.Title, siteName), Description: desc, Canonical: canonical, OGType: "article", OGImage: ogImage, JSONLD: mustJSON(jsonld), } } func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *models.User) *seo.PageMeta { permalink := h.Settings.Permalink() name := services.DisplayName(user) desc := strings.TrimSpace(user.Signature) if desc == "" { desc = name + " 的主页" } desc = services.TruncateRunes(desc, seoDescMax) canonical := services.AbsoluteURL(base, permalink.UserPath(user.ID)) ogImage := services.AbsoluteURL(base, user.Avatar) if ogImage == "" { ogImage = defaultImage } jsonld := map[string]any{ "@context": "https://schema.org", "@type": "ProfilePage", "url": canonical, "mainEntity": map[string]any{ "@type": "Person", "name": name, "description": desc, "url": canonical, }, } if ogImage != "" { jsonld["mainEntity"].(map[string]any)["image"] = ogImage } return &seo.PageMeta{ Title: pageTitle(name+" 的主页", siteName), Description: desc, Canonical: canonical, OGType: "profile", OGImage: ogImage, JSONLD: mustJSON(jsonld), } } func (h *Handlers) publicBaseURL(c *gin.Context) string { return h.Settings.SitePublicBaseURL(requestOrigin(c)) } func requestOrigin(c *gin.Context) string { proto := c.GetHeader("X-Forwarded-Proto") if proto == "" { if c.Request.TLS != nil { proto = "https" } else { proto = "http" } } host := c.GetHeader("X-Forwarded-Host") if host == "" { host = c.Request.Host } if host == "" { return "" } return proto + "://" + host } func pathWithQuery(c *gin.Context) string { path := c.Request.URL.Path if path == "" { path = "/" } permalink := services.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board if q := c.Request.URL.RawQuery; q != "" { if path == "/" { board := c.Query("board") if board != "" { _ = permalink return services.LegacyQueryBoardHome(uint(parseUintOrZero(board))) } return "/" } return path + "?" + q } return path } func preserveQueryExceptBoard(c *gin.Context) string { vals := c.Request.URL.Query() vals.Del("board") if rest := vals.Encode(); rest != "" { return "?" + rest } return "" } func parseUintOrZero(s string) uint64 { n, _ := strconv.ParseUint(s, 10, 64) return n } func isNoIndexPath(path string) bool { switch { case path == "/login", path == "/register", path == "/compose", path == "/profile", path == "/favorites": return true case strings.HasPrefix(path, "/admin"): return true case strings.HasSuffix(path, "/edit"): return true default: return false } } func pathLabel(path string) string { switch { case path == "/login": return "登录" case path == "/register": return "注册" case path == "/compose": return "发帖" case path == "/profile": return "个人中心" case path == "/favorites": return "我的收藏" case strings.HasSuffix(path, "/edit"): return "编辑帖子" case strings.HasPrefix(path, "/admin"): return "管理后台" default: return "" } } func pageTitle(page, siteName string) string { page = strings.TrimSpace(page) siteName = strings.TrimSpace(siteName) switch { case page == "" && siteName == "": return "姜十三论坛" case page == "": return siteName case siteName == "": return page default: return page + " - " + siteName } } func mustJSON(v any) string { b, err := json.Marshal(v) if err != nil { return "" } return string(b) } func xmlEscape(s string) string { r := strings.NewReplacer( `&`, "&", `<`, "<", `>`, ">", `"`, """, `'`, "'", ) return r.Replace(s) }