Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestCategoryDetailTemplateParses(t *testing.T) {
|
||||
workingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(".."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(workingDir) })
|
||||
|
||||
engine := gin.New()
|
||||
setupEngine(engine)
|
||||
if engine.HTMLRender == nil {
|
||||
t.Fatal("HTML renderer was not initialized")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"videoweb/pub/common/useragent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// applyClientForwardHeaders 向上游透传客户端 IP / UA(与 API 上游一致):
|
||||
// X-Real-IP、User-Agent、X-User-Agent。
|
||||
func applyClientForwardHeaders(header map[string]string, c *gin.Context) {
|
||||
if header == nil {
|
||||
return
|
||||
}
|
||||
useragent.ApplyToHeader(header, c, useragent.DefaultVer)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"videoweb/app/api/web"
|
||||
"videoweb/app/appg"
|
||||
"videoweb/app/service/configser"
|
||||
"videoweb/app/service/playletser"
|
||||
"videoweb/app/service/seoser"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Init() *gin.Engine {
|
||||
setGinMode()
|
||||
engine := gin.Default()
|
||||
engine.HandleMethodNotAllowed = true
|
||||
setupEngine(engine)
|
||||
engine.NoRoute(renderNotFound)
|
||||
engine.NoMethod(renderNotFound)
|
||||
|
||||
registerM3U8Routes(engine)
|
||||
registerRoutes(engine)
|
||||
return engine
|
||||
}
|
||||
|
||||
// DHInit 导航站独立端口:仅暴露官方导航页及静态资源。
|
||||
func DHInit() *gin.Engine {
|
||||
setGinMode()
|
||||
engine := gin.Default()
|
||||
engine.HandleMethodNotAllowed = true
|
||||
setupEngine(engine)
|
||||
engine.StaticFile("/favicon.ico", "./static/favicon.ico")
|
||||
engine.StaticFile("/favicon.svg", "./static/favicon.svg")
|
||||
engine.StaticFile("/favicon.png", "./static/favicon.png")
|
||||
engine.GET("/ui/dialog/:name", web.DialogFragment)
|
||||
engine.GET("/", web.Nav)
|
||||
engine.GET("/nav/", web.Nav)
|
||||
engine.GET("/dh/", web.Nav)
|
||||
engine.NoRoute(renderNotFound)
|
||||
engine.NoMethod(renderNotFound)
|
||||
return engine
|
||||
}
|
||||
|
||||
func setGinMode() {
|
||||
switch strings.ToLower(strings.TrimSpace(appg.Conf.RunMod)) {
|
||||
case "test":
|
||||
gin.SetMode(gin.TestMode)
|
||||
case "prod":
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
default:
|
||||
gin.SetMode(gin.DebugMode)
|
||||
}
|
||||
}
|
||||
|
||||
func setupEngine(engine *gin.Engine) {
|
||||
tplFuncs := playletser.TemplateFuncs()
|
||||
tplFuncs["InJSONScript"] = inJSONScript
|
||||
tplFuncs["jsonStr"] = jsonStr
|
||||
tplFuncs["InTrackerAppId"] = inTrackerAppId
|
||||
engine.SetFuncMap(tplFuncs)
|
||||
engine.LoadHTMLFiles(listHTMLTemplates("templates")...)
|
||||
engine.Use(staticCacheHeaders(), crawlerHeaders())
|
||||
registerBrowserDiscoveryRoutes(engine)
|
||||
engine.Static("/static", "./static")
|
||||
}
|
||||
|
||||
// registerBrowserDiscoveryRoutes 处理 Chrome DevTools 的本地工作区探测请求。
|
||||
// 该请求并非站点页面,返回 204 可避免进入业务 404 页面和产生误导日志。
|
||||
func registerBrowserDiscoveryRoutes(engine *gin.Engine) {
|
||||
engine.GET("/.well-known/appspecific/com.chrome.devtools.json", func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow")
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
}
|
||||
|
||||
// staticCacheHeaders 为静态资源设置浏览器缓存,减少重复下载。
|
||||
func staticCacheHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/static/") {
|
||||
c.Header("Cache-Control", "public, max-age=604800, stale-while-revalidate=86400")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// crawlerHeaders 防止接口、片段和健康检查等非 HTML 内容进入搜索索引。
|
||||
func crawlerHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/ui/") ||
|
||||
strings.HasPrefix(path, "/h5/") ||
|
||||
path == "/healthz" || path == "/enkey" || path == "/newenkey" {
|
||||
c.Header("X-Robots-Tag", "noindex, nofollow")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// listHTMLTemplates 收集 templates 下页面模板,跳过遗留广告文件。
|
||||
func listHTMLTemplates(root string) []string {
|
||||
var files []string
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
name := strings.ToLower(d.Name())
|
||||
if !strings.HasSuffix(name, ".html") {
|
||||
return nil
|
||||
}
|
||||
if name == "banners.html" {
|
||||
return nil
|
||||
}
|
||||
files = append(files, path)
|
||||
return nil
|
||||
})
|
||||
if len(files) == 0 {
|
||||
panic("no html templates found under " + root)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// inJSONScript 将任意数据序列化为可嵌入 <script> 的 JSON。
|
||||
func inJSONScript(v any) template.JS {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
// jsonStr 将字符串序列化为可嵌入 application/ld+json 的 JSON 字符串字面量。
|
||||
func jsonStr(s string) template.JS {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return template.JS(`""`)
|
||||
}
|
||||
return template.JS(b)
|
||||
}
|
||||
|
||||
func inTrackerAppId() string {
|
||||
return configser.ResolveTrackerAppID()
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.Engine) {
|
||||
r.GET("/", web.Index)
|
||||
r.GET("/ui/dialog/:name", web.DialogFragment)
|
||||
|
||||
r.POST("/api/auth/register", web.Register)
|
||||
r.POST("/api/auth/login", web.Login)
|
||||
r.POST("/api/auth/resetPassword", web.ResetPassword)
|
||||
r.POST("/api/auth/guest", web.Guest)
|
||||
r.POST("/api/auth/logout", web.Logout)
|
||||
r.POST("/api/auth/profile", web.UpdateProfile)
|
||||
r.GET("/api/auth/getInfo", web.GetUserInfo)
|
||||
r.POST("/api/upload/image", web.UploadImage)
|
||||
r.POST("/api/feedback/add", web.AddFeedback)
|
||||
r.POST("/api/contactus/add", web.AddContactUs)
|
||||
r.GET("/api/comment/list", web.CommentList)
|
||||
r.POST("/api/comment/send", web.CommentSend)
|
||||
r.POST("/api/comment/like/set", web.CommentLikeSet)
|
||||
r.POST("/api/playlet/like/set", web.PlayletLikeSet)
|
||||
r.POST("/api/playlet/collect/set", web.PlayletCollectSet)
|
||||
r.POST("/api/playlet/forward/heat", web.PlayletForwardHeat)
|
||||
r.GET("/api/playlet/chapter/detail", web.ChapterDetail)
|
||||
r.GET("/api/playlet/chapter/list", web.ChapterList)
|
||||
r.GET("/api/playlet/operate/list", web.OperateList)
|
||||
r.GET("/api/playlet/hot-like", web.HotLike)
|
||||
r.GET("/api/app/playlet/hot-like", web.HotLike)
|
||||
r.POST("/api/playlet/chapter/like/set", web.ChapterLikeSet)
|
||||
r.POST("/api/playlet/chapter/collect/set", web.ChapterCollectSet)
|
||||
r.POST("/api/playlet/chapter/watch/report", web.ChapterWatchReport)
|
||||
r.POST("/api/playlet/history/clear", web.HistoryClear)
|
||||
r.POST("/api/playlet/chapter/unlock", web.ChapterUnlock)
|
||||
r.POST("/api/coin/buy", web.CoinBuy)
|
||||
r.POST("/api/vip/pay", web.VipPay)
|
||||
|
||||
r.GET("/list/", web.List)
|
||||
r.GET("/list/:slug/", web.List)
|
||||
r.GET("/list/:slug/:sortOrder/", web.List)
|
||||
r.GET("/list/:slug/:sortOrder/page/:page/", web.List)
|
||||
|
||||
r.GET("/series/", web.Series)
|
||||
r.GET("/series/page/:page/", web.Series)
|
||||
r.GET("/series/:filter/", web.Series)
|
||||
r.GET("/series/:filter/page/:page/", web.Series)
|
||||
|
||||
// 标签页面使用 tag 路由;tag-list 仅作为历史地址保留并由 handler 301 到 canonical。
|
||||
r.GET("/tag/", web.TagList)
|
||||
r.GET("/tag/page/:page/", web.TagList)
|
||||
r.GET("/tag/:slug/", web.TagList)
|
||||
r.GET("/tag/:slug/page/:page/", web.TagList)
|
||||
r.GET("/tag-list/", web.TagList)
|
||||
r.GET("/tag-list/page/:page/", web.TagList)
|
||||
r.GET("/tag-list/:slug/", web.TagList)
|
||||
r.GET("/tag-list/:slug/page/:page/", web.TagList)
|
||||
|
||||
r.GET("/rank/", web.Rank)
|
||||
r.GET("/rank/page/:page/", web.Rank)
|
||||
r.GET("/rank/:sortOrder/", web.Rank)
|
||||
r.GET("/rank/:sortOrder/page/:page/", web.Rank)
|
||||
|
||||
r.GET("/today-update/", web.TodayUpdate)
|
||||
r.GET("/today-update/page/:page/", web.TodayUpdate)
|
||||
|
||||
// 分类详情使用语义化 category 路由,分类标识优先使用 slug、无 slug 时使用 ID。
|
||||
r.GET("/category/:slug/", web.CategoryDetail)
|
||||
r.GET("/category/:slug/page/:page/", web.CategoryDetail)
|
||||
|
||||
// 专题列表和详情统一使用 topic 路由;more 仅作为历史地址保留并由 handler 301 到 canonical。
|
||||
r.GET("/topic/", web.ThemeIndex)
|
||||
r.GET("/topic/page/:page/", web.ThemeIndex)
|
||||
r.GET("/topic/:slug/", web.More)
|
||||
r.GET("/topic/:slug/page/:page/", web.More)
|
||||
r.GET("/more/", web.More)
|
||||
r.GET("/more/page/:page/", web.More)
|
||||
r.GET("/more/:slug/", web.More)
|
||||
r.GET("/more/:slug/page/:page/", web.More)
|
||||
|
||||
r.GET("/download-app/", func(c *gin.Context) {
|
||||
web.HTMLOK(c, "downloadApp.html", gin.H{
|
||||
"title": "下载APP",
|
||||
"navActive": "downloadApp",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/application/", web.Application)
|
||||
r.GET("/home-road/", web.HomeRoad)
|
||||
|
||||
r.GET("/detail/:slug/", web.Detail)
|
||||
|
||||
r.GET("/play/:slug/", web.Play)
|
||||
r.GET("/play/:slug/:chapterId/", web.Play)
|
||||
|
||||
r.GET("/login/", web.AuthIndex("login"))
|
||||
r.GET("/login/return/*redirect", web.AuthIndex("login"))
|
||||
r.GET("/register/", web.AuthIndex("register"))
|
||||
|
||||
r.GET("/user-center/", web.UserCenter)
|
||||
|
||||
r.GET("/deposit/", web.Deposit)
|
||||
r.GET("/deposit/:tab/", web.Deposit)
|
||||
|
||||
r.GET("/collect/", web.Collect)
|
||||
r.GET("/collect/page/:page/", web.Collect)
|
||||
r.GET("/collect/:type/", web.Collect)
|
||||
r.GET("/collect/:type/page/:page/", web.Collect)
|
||||
|
||||
r.GET("/history/", web.History)
|
||||
r.GET("/history/page/:page/", web.History)
|
||||
|
||||
r.GET("/setting/", web.Setting)
|
||||
r.GET("/edit-profile/", web.EditProfile)
|
||||
r.GET("/edit-password/", web.EditPassword)
|
||||
r.GET("/search/", web.Search)
|
||||
r.GET("/search/:keyword/", web.Search)
|
||||
r.GET("/search/:keyword/page/:page/", web.Search)
|
||||
|
||||
r.GET("/about-us/", web.RichtextPage("关于我们"))
|
||||
r.GET("/user-agreement/", web.RichtextPage("用户协议"))
|
||||
r.GET("/privacy-policy/", web.RichtextPage("隐私政策"))
|
||||
r.GET("/help-center/", web.RichtextPage("帮助中心"))
|
||||
|
||||
r.GET("/richtext/", web.Richtext)
|
||||
r.GET("/richtext/:title/", web.Richtext)
|
||||
|
||||
r.GET("/feedback/", func(c *gin.Context) {
|
||||
web.HTMLOK(c, "feedback.html", gin.H{
|
||||
"title": "问题反馈",
|
||||
"noindex": true,
|
||||
"robots": "noindex,nofollow",
|
||||
})
|
||||
})
|
||||
|
||||
r.GET("/nav/", web.Nav)
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
})
|
||||
|
||||
registerSitemapRoutes(r)
|
||||
r.GET("/robots.txt", web.RobotsTXT)
|
||||
registerSEOVerifyRoutes(r)
|
||||
|
||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||
c.File("./static/favicon.ico")
|
||||
})
|
||||
r.GET("/favicon.svg", func(c *gin.Context) {
|
||||
c.File("./static/favicon.svg")
|
||||
})
|
||||
r.GET("/favicon.png", func(c *gin.Context) {
|
||||
c.File("./static/favicon.png")
|
||||
})
|
||||
}
|
||||
|
||||
func renderNotFound(c *gin.Context) {
|
||||
web.HTML(c, http.StatusNotFound, "404.html", gin.H{
|
||||
"path": c.Request.URL.Path,
|
||||
"noindex": true,
|
||||
"robots": "noindex,follow",
|
||||
})
|
||||
}
|
||||
|
||||
func registerSEOVerifyRoutes(r *gin.Engine) {
|
||||
codes := seoser.VerifyFromConfig()
|
||||
if codes.BaiduHTML != "" {
|
||||
r.GET("/"+codes.BaiduHTML, web.SEOVerifyFile)
|
||||
}
|
||||
if codes.IndexNowKey != "" {
|
||||
r.GET("/"+codes.IndexNowKey+".txt", web.SEOVerifyFile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestContentPagesOnlyRegisterPathParameterRoutes(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerRoutes(engine)
|
||||
routes := engine.Routes()
|
||||
registered := make(map[string]bool, len(routes))
|
||||
handlers := make(map[string]string, len(routes))
|
||||
for _, route := range routes {
|
||||
key := route.Method + " " + route.Path
|
||||
registered[key] = true
|
||||
handlers[key] = route.Handler
|
||||
}
|
||||
|
||||
for _, route := range []string{
|
||||
"GET /detail/:slug",
|
||||
"GET /play/:slug",
|
||||
"GET /play/:slug/:chapterId",
|
||||
"GET /topic",
|
||||
"GET /topic/page/:page",
|
||||
"GET /topic/:slug",
|
||||
"GET /topic/:slug/page/:page",
|
||||
"GET /tag",
|
||||
"GET /tag/page/:page",
|
||||
"GET /tag/:slug",
|
||||
"GET /tag/:slug/page/:page",
|
||||
"GET /series",
|
||||
"GET /series/page/:page",
|
||||
"GET /series/:filter",
|
||||
"GET /series/:filter/page/:page",
|
||||
"GET /category/:slug",
|
||||
"GET /category/:slug/page/:page",
|
||||
"GET /home-road",
|
||||
} {
|
||||
if !registered[route] {
|
||||
t.Fatalf("missing path parameter route %s", route)
|
||||
}
|
||||
}
|
||||
for _, route := range []string{
|
||||
"POST /api/auth/resetPassword",
|
||||
"POST /api/contactus/add",
|
||||
"GET /api/playlet/chapter/list",
|
||||
"GET /api/playlet/operate/list",
|
||||
"GET /api/playlet/hot-like",
|
||||
"GET /api/app/playlet/hot-like",
|
||||
} {
|
||||
if !registered[route] {
|
||||
t.Fatalf("missing documented API route %s", route)
|
||||
}
|
||||
}
|
||||
for _, route := range []string{"GET /detail", "GET /play"} {
|
||||
if registered[route] {
|
||||
t.Fatalf("legacy query route should not be registered: %s", route)
|
||||
}
|
||||
}
|
||||
|
||||
for route, handlerSuffix := range map[string]string{
|
||||
"GET /topic": ".ThemeIndex",
|
||||
"GET /topic/page/:page": ".ThemeIndex",
|
||||
"GET /topic/:slug": ".More",
|
||||
"GET /tag": ".TagList",
|
||||
"GET /tag/:slug": ".TagList",
|
||||
"GET /category/:slug": ".CategoryDetail",
|
||||
} {
|
||||
if !strings.HasSuffix(handlers[route], handlerSuffix) {
|
||||
t.Fatalf("handler for %s = %q, want suffix %q", route, handlers[route], handlerSuffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyTagListRouteRedirectsToSemanticTagRoute(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerRoutes(engine)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/tag-list/fantasy/page/2", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if location := recorder.Header().Get("Location"); location != "/tag/fantasy/page/2" {
|
||||
t.Fatalf("Location = %q, want %q", location, "/tag/fantasy/page/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyAISeriesListRedirectsToSeriesPage(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerRoutes(engine)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/list/ai-series/1/page/3", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if location := recorder.Header().Get("Location"); location != "/series/popular/page/3" {
|
||||
t.Fatalf("Location = %q, want %q", location, "/series/popular/page/3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrawlerHeadersNoIndexNonHTMLRoutes(t *testing.T) {
|
||||
engine := gin.New()
|
||||
engine.Use(crawlerHeaders())
|
||||
engine.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
|
||||
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromeDevToolsDiscoveryReturnsNoContent(t *testing.T) {
|
||||
engine := gin.New()
|
||||
registerBrowserDiscoveryRoutes(engine)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/.well-known/appspecific/com.chrome.devtools.json", nil)
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNoContent)
|
||||
}
|
||||
if got := recorder.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" {
|
||||
t.Fatalf("X-Robots-Tag = %q, want noindex, nofollow", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"videoweb/app/appg"
|
||||
commonhttp "videoweb/pub/common/http"
|
||||
"videoweb/pub/common/log"
|
||||
"videoweb/pub/common/m3u8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerM3U8Routes(r *gin.Engine) {
|
||||
r.GET("/h5/m3u8/*path", proxyM3U8)
|
||||
r.GET("/enkey", serveM3U8Key)
|
||||
r.GET("/newenkey", serveNewM3U8Key)
|
||||
}
|
||||
|
||||
func proxyM3U8(c *gin.Context) {
|
||||
rawPath := strings.TrimPrefix(c.Param("path"), "/")
|
||||
if rawPath == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
rawPath += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
|
||||
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
|
||||
if serverAddr == "" {
|
||||
log.Warn("proxyM3U8 empty serverAddr")
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
targetURL := serverAddr + "/h5/m3u8/" + rawPath
|
||||
|
||||
// 播放地址本身带签名 token;勿强制带登录 Authorization。
|
||||
body, status, err := fetchM3U8Upstream(c, targetURL, "")
|
||||
if err != nil {
|
||||
log.Warn("proxyM3U8 request failed", log.Any("target", targetURL), log.E(err))
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
c.Status(status)
|
||||
return
|
||||
}
|
||||
if code, ok := jsonErrorCode(body); ok {
|
||||
log.Warn("proxyM3U8 upstream error",
|
||||
log.Any("target", targetURL),
|
||||
log.Any("code", code),
|
||||
log.Any("body", string(body)))
|
||||
c.Data(http.StatusBadGateway, "application/json; charset=utf-8", body)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := detectMediaContentType(rawPath)
|
||||
if isM3U8Content(rawPath, body) {
|
||||
contentType = "application/vnd.apple.mpegurl"
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, contentType, body)
|
||||
}
|
||||
|
||||
func fetchM3U8Upstream(c *gin.Context, targetURL, auth string) ([]byte, int, error) {
|
||||
header := map[string]string{
|
||||
"Accept": "*/*",
|
||||
}
|
||||
if auth != "" {
|
||||
header["Authorization"] = auth
|
||||
}
|
||||
applyClientForwardHeaders(header, c)
|
||||
resp, err := commonhttp.GetLargeFile(targetURL, header)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return resp.Data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func jsonErrorCode(body []byte) (int, bool) {
|
||||
if len(body) == 0 || body[0] != '{' {
|
||||
return 0, false
|
||||
}
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if payload.Code == 200 || payload.Code == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return payload.Code, true
|
||||
}
|
||||
|
||||
func isM3U8Content(rawPath string, body []byte) bool {
|
||||
if strings.Contains(strings.Split(rawPath, "?")[0], ".m3u8") {
|
||||
return true
|
||||
}
|
||||
return len(body) > 0 && body[0] == '#'
|
||||
}
|
||||
|
||||
func detectMediaContentType(rawPath string) string {
|
||||
pathOnly := strings.Split(rawPath, "?")[0]
|
||||
switch {
|
||||
case strings.HasSuffix(pathOnly, ".m3u8"):
|
||||
return "application/vnd.apple.mpegurl"
|
||||
case strings.HasSuffix(pathOnly, ".ts"):
|
||||
return "video/mp2t"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func serveM3U8Key(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
if key, ok := fetchUpstreamEnkey(c, c.Request.URL.RawQuery); ok {
|
||||
c.Data(http.StatusOK, "application/octet-stream", key)
|
||||
return
|
||||
}
|
||||
|
||||
buffer, err := m3u8.Enkey(c.Request.Context())
|
||||
if err != nil || buffer == nil || buffer.Len() == 0 {
|
||||
log.Warn("serveM3U8Key local key unavailable", log.E(err))
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/octet-stream", buffer.Bytes())
|
||||
}
|
||||
|
||||
func serveNewM3U8Key(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
key, ok := fetchUpstreamKey(c, "/newenkey", c.Request.URL.RawQuery)
|
||||
if !ok {
|
||||
c.Status(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/octet-stream", key)
|
||||
}
|
||||
|
||||
func fetchUpstreamEnkey(c *gin.Context, rawQuery string) ([]byte, bool) {
|
||||
return fetchUpstreamKey(c, "/enkey", rawQuery)
|
||||
}
|
||||
|
||||
func fetchUpstreamKey(c *gin.Context, keyPath, rawQuery string) ([]byte, bool) {
|
||||
serverAddr := strings.TrimRight(strings.TrimSpace(appg.Conf.ServerAddr), "/")
|
||||
if serverAddr == "" {
|
||||
return nil, false
|
||||
}
|
||||
targetURL := serverAddr + keyPath
|
||||
if rawQuery = strings.TrimSpace(rawQuery); rawQuery != "" {
|
||||
targetURL += "?" + rawQuery
|
||||
}
|
||||
header := map[string]string{
|
||||
"Accept": "*/*",
|
||||
}
|
||||
applyClientForwardHeaders(header, c)
|
||||
resp, err := commonhttp.GetLargeFile(targetURL, header)
|
||||
if err != nil {
|
||||
log.Warn("fetchUpstreamKey request failed", log.Any("target", targetURL), log.E(err))
|
||||
return nil, false
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || len(resp.Data) == 0 || jsonErrorCodeBody(resp.Data) {
|
||||
log.Warn("fetchUpstreamKey invalid response",
|
||||
log.Any("target", targetURL),
|
||||
log.Any("status", resp.StatusCode),
|
||||
log.Any("len", len(resp.Data)))
|
||||
return nil, false
|
||||
}
|
||||
return resp.Data, true
|
||||
}
|
||||
|
||||
func jsonErrorCodeBody(body []byte) bool {
|
||||
_, ok := jsonErrorCode(body)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNavigationPageUsesDedicatedVerificationAndAnalytics(t *testing.T) {
|
||||
navHTML := renderSEOCommonForTrackingTest(t, true)
|
||||
for _, want := range []string{
|
||||
"UEtS2h9mIjbFSM_VHEciNQ5iWuFPq9d-Q2WO0SFyPAw",
|
||||
"eafd02fb6517fc76",
|
||||
"112390883",
|
||||
"G-SY26Y2MSHS",
|
||||
} {
|
||||
if !strings.Contains(navHTML, want) {
|
||||
t.Fatalf("navigation SEO output does not contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"112387687", "G-TZQS2NZ3TN", "6bb06ed5659790d0"} {
|
||||
if strings.Contains(navHTML, unwanted) {
|
||||
t.Fatalf("navigation SEO output unexpectedly contains main-site id %q", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainSiteKeepsMainVerificationAndAnalytics(t *testing.T) {
|
||||
mainHTML := renderSEOCommonForTrackingTest(t, false)
|
||||
for _, want := range []string{
|
||||
"r7geXn5kBugzOSLZzyw4v3NBDMQwAmzFCVkqp92odh0",
|
||||
"6bb06ed5659790d0",
|
||||
"112387687",
|
||||
"G-TZQS2NZ3TN",
|
||||
} {
|
||||
if !strings.Contains(mainHTML, want) {
|
||||
t.Fatalf("main-site SEO output does not contain %q", want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"112390883", "G-SY26Y2MSHS", "eafd02fb6517fc76"} {
|
||||
if strings.Contains(mainHTML, unwanted) {
|
||||
t.Fatalf("main-site SEO output unexpectedly contains navigation id %q", unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renderSEOCommonForTrackingTest(t *testing.T, isNavPage bool) string {
|
||||
t.Helper()
|
||||
tpl, err := template.New("tracking-test").Funcs(template.FuncMap{
|
||||
"InTrackerAppId": func() string { return "test-app" },
|
||||
"jsonStr": func(any) template.JS { return template.JS(`""`) },
|
||||
}).ParseFiles("../templates/base/seoCommon.html", "../templates/base/trackerInit.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := map[string]any{
|
||||
"isNavPage": isNavPage,
|
||||
"seoVerify": map[string]any{"Baidu": ""},
|
||||
"appConfig": map[string]any{
|
||||
"ReportSDKConfig": "",
|
||||
"SeoInfo": map[string]any{"WebSiteName": ""},
|
||||
},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := tpl.ExecuteTemplate(&out, "seoCommon", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const sitemapDir = "./templates/page/sitemaps"
|
||||
|
||||
var sitemapLocPattern = regexp.MustCompile(`<loc>([^<]+)</loc>`)
|
||||
|
||||
// registerSitemapRoutes 暴露 hgdj_server app 生成的 sitemap 索引及分片文件。
|
||||
func registerSitemapRoutes(r *gin.Engine) {
|
||||
r.StaticFile("/sitemap.xml", filepath.Join(sitemapDir, "sitemap.xml"))
|
||||
r.GET("/sitemaps/:name", serveSitemapShard)
|
||||
}
|
||||
|
||||
func serveSitemapShard(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !isValidSitemapShardName(name) {
|
||||
renderNotFound(c)
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(sitemapDir, name))
|
||||
if err != nil {
|
||||
renderNotFound(c)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", normalizeSitemapPageURLs(raw))
|
||||
}
|
||||
|
||||
// normalizeSitemapPageURLs 让运行时生成的旧 sitemap 与当前 canonical 路由保持一致。
|
||||
// sitemap 索引和静态文件地址不经过这里,仍保留文件型 URL(无尾斜杠)。
|
||||
func normalizeSitemapPageURLs(raw []byte) []byte {
|
||||
return sitemapLocPattern.ReplaceAllFunc(raw, func(match []byte) []byte {
|
||||
parts := sitemapLocPattern.FindSubmatch(match)
|
||||
if len(parts) != 2 {
|
||||
return match
|
||||
}
|
||||
loc := html.UnescapeString(strings.TrimSpace(string(parts[1])))
|
||||
u, err := url.Parse(loc)
|
||||
if err != nil || u.Path == "" {
|
||||
return match
|
||||
}
|
||||
u.Path = canonicalSitemapPagePath(u.Path)
|
||||
return []byte("<loc>" + html.EscapeString(u.String()) + "</loc>")
|
||||
})
|
||||
}
|
||||
|
||||
func canonicalSitemapPagePath(path string) string {
|
||||
switch {
|
||||
case path == "/more":
|
||||
path = "/topic"
|
||||
case strings.HasPrefix(path, "/more/"):
|
||||
path = "/topic/" + strings.TrimPrefix(path, "/more/")
|
||||
case path == "/tag-list":
|
||||
path = "/tag"
|
||||
case strings.HasPrefix(path, "/tag-list/"):
|
||||
path = "/tag/" + strings.TrimPrefix(path, "/tag-list/")
|
||||
}
|
||||
if path != "/" && !strings.HasSuffix(path, "/") {
|
||||
path += "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isValidSitemapShardName 校验分片文件名,防止路径遍历。
|
||||
// 合法示例:sitemap-pages.xml、sitemap-playletdetail-0.xml
|
||||
func isValidSitemapShardName(name string) bool {
|
||||
if !strings.HasPrefix(name, "sitemap-") || !strings.HasSuffix(name, ".xml") {
|
||||
return false
|
||||
}
|
||||
base := strings.TrimSuffix(name, ".xml")
|
||||
if len(base) <= len("sitemap-") {
|
||||
return false
|
||||
}
|
||||
for _, ch := range base[len("sitemap-"):] {
|
||||
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user