diff --git a/backend/internal/handler/page_handler.go b/backend/internal/handler/page_handler.go index 8c29e971..a3e4f5d2 100644 --- a/backend/internal/handler/page_handler.go +++ b/backend/internal/handler/page_handler.go @@ -1,6 +1,7 @@ package handler import ( + "encoding/json" "net/http" "os" "path/filepath" @@ -8,6 +9,8 @@ import ( "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) @@ -16,13 +19,14 @@ var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) const maxPageFileSize = 1 << 20 // 1MB type PageHandler struct { - pagesDir string + pagesDir string + settingService *service.SettingService } -func NewPageHandler(dataDir string) *PageHandler { +func NewPageHandler(dataDir string, settingService *service.SettingService) *PageHandler { pagesDir := filepath.Join(dataDir, "pages") _ = os.MkdirAll(pagesDir, 0755) - return &PageHandler{pagesDir: pagesDir} + return &PageHandler{pagesDir: pagesDir, settingService: settingService} } // GetPageContent serves raw markdown content for a given slug. @@ -34,6 +38,13 @@ func (h *PageHandler) GetPageContent(c *gin.Context) { return } + // Visibility check: slug must be configured in custom_menu_items + // and the user must have permission based on visibility setting + if !h.checkSlugVisibility(c, slug) { + c.JSON(http.StatusNotFound, gin.H{"error": "page not found"}) + return + } + filePath := filepath.Join(h.pagesDir, slug+".md") cleaned := filepath.Clean(filePath) if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) { @@ -84,6 +95,7 @@ func (h *PageHandler) ListPages(c *gin.Context) { // ServePageImage serves images from data/pages/{slug}/ directory. // GET /api/v1/pages/:slug/images/*filename +// No JWT required (browser img tags can't carry tokens), but visibility is checked. func (h *PageHandler) ServePageImage(c *gin.Context) { slug := c.Param("slug") filename := c.Param("filename") @@ -93,6 +105,12 @@ func (h *PageHandler) ServePageImage(c *gin.Context) { c.Status(http.StatusNotFound) return } + + if !h.checkImageSlugVisibility(c, slug) { + c.Status(http.StatusNotFound) + return + } + if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { c.Status(http.StatusNotFound) return @@ -115,13 +133,83 @@ func (h *PageHandler) ServePageImage(c *gin.Context) { c.File(cleaned) } +// findSlugVisibility looks up the slug in custom_menu_items and returns (visibility, found). +func (h *PageHandler) findSlugVisibility(c *gin.Context, slug string) (string, bool) { + if h.settingService == nil { + return "", false + } + + raw := h.settingService.GetCustomMenuItemsRaw(c.Request.Context()) + if raw == "" || raw == "[]" { + return "", false + } + + var items []struct { + URL string `json:"url"` + PageSlug string `json:"page_slug"` + Visibility string `json:"visibility"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return "", false + } + + for _, item := range items { + itemSlug := item.PageSlug + if itemSlug == "" && strings.HasPrefix(item.URL, "md:") { + itemSlug = strings.TrimPrefix(item.URL, "md:") + } + if itemSlug == slug { + return item.Visibility, true + } + } + return "", false +} + +// checkSlugVisibility verifies the slug is configured in custom_menu_items +// and the authenticated user has permission to view it. +func (h *PageHandler) checkSlugVisibility(c *gin.Context, slug string) bool { + visibility, found := h.findSlugVisibility(c, slug) + if !found { + return false + } + if visibility == "admin" { + role, _ := middleware2.GetUserRoleFromContext(c) + return role == "admin" + } + return true +} + +// checkImageSlugVisibility checks visibility for image requests (no JWT available). +// Only allows user-visible pages; admin-only pages are blocked. +func (h *PageHandler) checkImageSlugVisibility(c *gin.Context, slug string) bool { + visibility, found := h.findSlugVisibility(c, slug) + if !found { + return false + } + return visibility != "admin" +} + // RegisterPageRoutes registers page routes on a router group. -func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string) { - h := NewPageHandler(dataDir) +func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string, jwtAuth gin.HandlerFunc, adminAuth gin.HandlerFunc, settingService *service.SettingService) { + h := NewPageHandler(dataDir, settingService) + + // Authenticated page content (JWT required + visibility check) pages := v1.Group("/pages") + pages.Use(jwtAuth) { - pages.GET("", h.ListPages) pages.GET("/:slug", h.GetPageContent) - pages.GET("/:slug/images/*filename", h.ServePageImage) + } + + // Images: no JWT (browser img tags can't carry tokens), visibility check in handler + pageImages := v1.Group("/pages") + { + pageImages.GET("/:slug/images/*filename", h.ServePageImage) + } + + // Admin-only: list all available pages + adminPages := v1.Group("/pages") + adminPages.Use(adminAuth) + { + adminPages.GET("", h.ListPages) } } diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 9d424369..f477f3a7 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -113,5 +113,5 @@ func registerRoutes( routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService) - handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir) + handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService) } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index a5d65ad7..a158aef9 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1534,6 +1534,15 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool { return value == "true" } +// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting. +func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems) + if err != nil { + return "[]" + } + return value +} + // IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index a49a2a3a..0752d5e3 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -202,7 +202,9 @@ async function fetchAndRenderMarkdown(slug: string) { tocItems.value = [] activeHeadingId.value = '' try { - const resp = await fetch(`/api/v1/pages/${encodeURIComponent(slug)}`) + const resp = await fetch(`/api/v1/pages/${encodeURIComponent(slug)}`, { + headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}, + }) if (!resp.ok) { renderedHtml.value = '

Page not found

' return