package handler import ( "encoding/json" "net/http" "net/url" "os" "path/filepath" "regexp" "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" ) var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) const maxPageFileSize = 1 << 20 // 1MB type PageHandler struct { pagesDir string settingService *service.SettingService } func NewPageHandler(dataDir string, settingService *service.SettingService) *PageHandler { pagesDir := filepath.Join(dataDir, "pages") _ = os.MkdirAll(pagesDir, 0755) return &PageHandler{pagesDir: pagesDir, settingService: settingService} } // GetPageContent serves raw markdown content for a given slug. // GET /api/v1/pages/:slug func (h *PageHandler) GetPageContent(c *gin.Context) { slug := c.Param("slug") if !validSlugPattern.MatchString(slug) || len(slug) > 64 { response.BadRequest(c, "Invalid page slug") 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)) { response.BadRequest(c, "Invalid page slug") return } info, err := os.Stat(cleaned) if err != nil || info.IsDir() { c.JSON(http.StatusNotFound, gin.H{"error": "page not found"}) return } if info.Size() > maxPageFileSize { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "page too large"}) return } content, err := os.ReadFile(cleaned) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read page"}) return } c.Data(http.StatusOK, "text/markdown; charset=utf-8", content) } // ListPages returns available page slugs. // GET /api/v1/pages func (h *PageHandler) ListPages(c *gin.Context) { entries, err := os.ReadDir(h.pagesDir) if err != nil { response.Success(c, []string{}) return } slugs := make([]string, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } name := e.Name() if strings.HasSuffix(name, ".md") { slugs = append(slugs, strings.TrimSuffix(name, ".md")) } } response.Success(c, slugs) } // 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") filename = strings.TrimPrefix(filename, "/") if !validSlugPattern.MatchString(slug) || len(slug) > 64 { c.Status(http.StatusNotFound) return } if !h.checkImageSlugVisibility(c, slug) { c.Status(http.StatusNotFound) return } imagesDir := filepath.Join(h.pagesDir, slug) cleaned, ok := resolvePageImagePath(h.pagesDir, imagesDir, filename) if !ok { c.Status(http.StatusNotFound) return } info, err := os.Stat(cleaned) if err != nil || info.IsDir() { c.Status(http.StatusNotFound) return } c.File(cleaned) } func resolvePageImagePath(pagesDir, imagesDir, filename string) (string, bool) { relPath, ok := cleanPageImageRelativePath(filename) if !ok { return "", false } cleanedPagesDir := filepath.Clean(pagesDir) cleanedImagesDir := filepath.Clean(imagesDir) cleanedTarget := filepath.Clean(filepath.Join(cleanedImagesDir, relPath)) if !isPathWithinBase(cleanedTarget, cleanedImagesDir) { return "", false } realPagesDir, err := filepath.EvalSymlinks(cleanedPagesDir) if err != nil { return "", false } realImagesDir, err := filepath.EvalSymlinks(cleanedImagesDir) if err != nil || !isPathWithinBase(realImagesDir, realPagesDir) { return "", false } realTarget, err := filepath.EvalSymlinks(cleanedTarget) if err != nil || !isPathWithinBase(realTarget, realImagesDir) { return "", false } return realTarget, true } func cleanPageImageRelativePath(filename string) (string, bool) { if filename == "" { return "", false } if strings.HasPrefix(filename, "/") { return "", false } decoded, err := url.PathUnescape(filename) if err != nil { return "", false } if decoded == "" || strings.HasPrefix(decoded, "/") || strings.Contains(decoded, "\\") || strings.ContainsRune(decoded, 0) { return "", false } parts := make([]string, 0) for _, part := range strings.Split(decoded, "/") { switch part { case "", ".": continue case "..": return "", false default: parts = append(parts, part) } } if len(parts) == 0 { return "", false } relPath := filepath.Join(parts...) if filepath.IsAbs(relPath) || filepath.VolumeName(relPath) != "" { return "", false } return relPath, true } func isPathWithinBase(path, base string) bool { rel, err := filepath.Rel(filepath.Clean(base), filepath.Clean(path)) if err != nil { return false } return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } // 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, 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("/:slug", h.GetPageContent) } // 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) } }