// Package admin provides an HTTP server for managing posts via a web UI. // Admin pages are served dynamically and are never written to the static // output directory — they are intentionally excluded from the site generator. package server import ( "bytes" "encoding/json" "fmt" "html/template" "io" "io/fs" "net/http" "os" "path/filepath" "regexp" "strings" "sync/atomic" "time" "github.com/gin-gonic/gin" "github.com/gosimple/slug" auth "iblog/internal" "iblog/internal/db" ) // Server is an http.Handler that serves the admin post-management UI. type Server struct { // AuthFile is the path to the htpasswd-compatible passwords file. AuthFile string // DB provides access to all post records and search indexing DB *db.DB // PublicDir is the directory where cache files are stored PublicDir string // DataDir is the writable data directory (parent of AuthFile) DataDir string adminAssets fs.FS siteAssets fs.FS configured atomic.Bool engine *gin.Engine } // Post holds the metadata and content for form display and submission. type Post struct { Slug string Id string Title string Date string Tags []string Draft bool Blocks string // raw EditorJS JSON } // NewAdminServer creates a new admin server with Gin routing and auth middleware. // tmpl must be provided before any routes are registered so SetHTMLTemplate is // called at the right time (Gin requires this to be thread-safe). func NewAdminServer(authFile string, database *db.DB, publicDir string, adminAssets, siteAssets fs.FS, tmpl *template.Template) *Server { s := &Server{ AuthFile: authFile, DB: database, PublicDir: publicDir, DataDir: filepath.Dir(authFile), adminAssets: adminAssets, siteAssets: siteAssets, } s.configured.Store(auth.NewAuth(authFile).IsConfigured()) s.engine = gin.Default() s.engine.SetTrustedProxies(nil) s.engine.SetHTMLTemplate(tmpl) // Redirect to /setup when the site has not been configured yet. // Skip the setup route itself and all asset paths so the form loads properly. s.engine.Use(func(c *gin.Context) { if !s.configured.Load() { p := c.Request.URL.Path if p != "/setup" && !strings.HasPrefix(p, "/assets/") { c.Redirect(http.StatusFound, "/setup") c.Abort() return } } c.Next() }) // Setup routes — no auth, only accessible before the site is configured. s.engine.GET("/setup", s.handleSetupGet) s.engine.POST("/setup", s.handleSetupPost) // Silent auth probe: returns 200 if authenticated, 403 if not. // Must not send WWW-Authenticate so the browser never shows a login dialog. s.engine.GET("/admin/ping", func(c *gin.Context) { if s.AuthFile == "" { c.AbortWithStatus(http.StatusForbidden) return } username, password, ok := c.Request.BasicAuth() if !ok { c.AbortWithStatus(http.StatusForbidden) return } a := auth.NewAuth(s.AuthFile) valid, err := a.Verify(username, password) if err != nil || !valid { c.AbortWithStatus(http.StatusForbidden) return } c.Status(http.StatusOK) }) // Admin post routes (protected) admin := s.engine.Group("/admin") admin.Use(s.authMiddleware()) { admin.GET("/", s.handleList) admin.GET("/settings", s.handleSettingsGet) admin.POST("/settings", s.handleSettingsPost) admin.GET("/new", s.handleNew) admin.POST("/", s.handleNewPost) admin.GET("/:slug", s.handleEdit) admin.POST("/:slug", s.handleEdit) admin.DELETE("/:slug", s.handleDelete) } // Asset serving: /assets/admin/* requires auth and is served from embedded adminAssets. // All other /assets/* are public and served from embedded siteAssets. { adminMW := s.authMiddleware() adminHandler := EmbedHandler(s.adminAssets, "assets") siteHandler := EmbedHandler(s.siteAssets, "assets") s.engine.GET("/assets/*filepath", func(c *gin.Context) { fp := c.Param("filepath") if strings.HasPrefix(fp, "/admin") { adminMW(c) if c.IsAborted() { return } adminHandler(c) return } // Logo override: serve user-uploaded logo before falling back to embedded. if fp == "/static/image.png" { s.handleLogo(c) return } siteHandler(c) }) } return s } func (s *Server) Engine() *gin.Engine { return s.engine } // RegisterUploadRoute registers handler under POST /admin/upload/image // behind the admin Basic Auth middleware. func (s *Server) RegisterUploadRoute(handler gin.HandlerFunc) { admin := s.engine.Group("/admin") admin.Use(s.authMiddleware()) admin.POST("/upload/image", handler) } func (s *Server) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if s.AuthFile == "" { c.AbortWithStatus(http.StatusUnauthorized) return } if _, err := os.Stat(s.AuthFile); os.IsNotExist(err) { c.AbortWithStatus(http.StatusUnauthorized) return } username, password, ok := c.Request.BasicAuth() if !ok { c.Header("WWW-Authenticate", `Basic realm="Admin"`) c.AbortWithStatus(http.StatusUnauthorized) return } a := auth.NewAuth(s.AuthFile) valid, err := a.Verify(username, password) if err != nil || !valid { c.Header("WWW-Authenticate", `Basic realm="Admin"`) c.AbortWithStatus(http.StatusUnauthorized) return } c.Next() } } func (s *Server) handleList(c *gin.Context) { posts, err := s.DB.ListPosts(true) // includeDrafts=true for admin view if err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": "Failed to list posts: " + err.Error(), }) return } // Convert PostRecord to Post for template var formPosts []Post for _, p := range posts { formPosts = append(formPosts, Post{ Slug: p.Slug, Title: p.Title, Date: p.Date, Tags: p.Tags, Draft: p.Draft, Blocks: p.Blocks, }) } c.HTML(http.StatusOK, "base", gin.H{ "Title": "Posts", "ContentTemplate": "list-content", "Posts": formPosts, }) } func (s *Server) handleNew(c *gin.Context) { c.HTML(http.StatusOK, "base", gin.H{ "Title": "New Post", "ContentTemplate": "form-content", "Action": "/admin/", "Post": Post{ Date: time.Now().Format("2006-01-02"), Blocks: "[]", }, "IsNew": true, }) } func (s *Server) handleNewPost(c *gin.Context) { p := postFromForm(c) if p.Title == "" { s.renderError(c, "Title is required") return } if p.Slug == "" { p.Slug = slugify(p.Title) } if !validSlug.MatchString(p.Slug) { s.renderError(c, fmt.Sprintf("Invalid slug %q: use lowercase letters, numbers, and hyphens only", p.Slug)) return } // Check if post already exists existing, err := s.DB.GetPostBySlug(p.Slug) if err == nil && existing != nil { s.renderError(c, fmt.Sprintf("Post %q already exists", p.Slug)) return } // Prepare post record with current timestamp record := db.PostRecord{ Slug: p.Slug, Title: p.Title, Date: p.Date, Tags: p.Tags, Draft: p.Draft, Blocks: p.Blocks, UpdatedAt: time.Now().UnixMicro(), } // Upsert post if err := s.DB.UpsertPost(record); err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": err.Error(), }) return } // Index in search database plainText := extractPlainTextFromEditorJS(p.Blocks) _ = s.DB.IndexPage(db.SearchPage{ Path: "/" + p.Slug, Title: p.Title, Content: plainText, }) c.Redirect(http.StatusSeeOther, "/admin/") } func (s *Server) handleEdit(c *gin.Context) { slug := c.Param("slug") rec, err := s.DB.GetPostBySlug(slug) if err != nil { c.HTML(http.StatusNotFound, "base", gin.H{ "Title": "Not Found", "ContentTemplate": "error-content", "Message": "Post not found", }) return } p := Post{ Slug: rec.Slug, Title: rec.Title, Date: rec.Date, Tags: rec.Tags, Draft: rec.Draft, Blocks: rec.Blocks, } if c.Request.Method == http.MethodPost { updated := postFromForm(c) if updated.Title == "" { s.renderError(c, "Title is required") return } targetSlug := slug // default: slug unchanged record := db.PostRecord{ Slug: targetSlug, Title: updated.Title, Date: updated.Date, Tags: updated.Tags, Draft: updated.Draft, Blocks: updated.Blocks, UpdatedAt: time.Now().UnixMicro(), } if updated.Slug != "" && updated.Slug != slug { // Slug rename requested if !validSlug.MatchString(updated.Slug) { s.renderError(c, fmt.Sprintf("Invalid slug %q: use lowercase letters, numbers, and hyphens only", updated.Slug)) return } // Check target slug is not already taken if existing, err := s.DB.GetPostBySlug(updated.Slug); err == nil && existing != nil { s.renderError(c, fmt.Sprintf("Post %q already exists", updated.Slug)) return } // Atomically rename and update content in one transaction record.Slug = updated.Slug if err := s.DB.RenameAndUpsertPost(slug, record); err != nil { s.renderError(c, "Failed to rename post: "+err.Error()) return } // Invalidate old cache and remove old search entry s.invalidatePostCache(slug) _ = s.DB.UnindexPage("/" + slug) targetSlug = updated.Slug } else { if err := s.DB.UpsertPost(record); err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": err.Error(), }) return } } // Invalidate cache for the target slug s.invalidatePostCache(targetSlug) // Re-index with target slug plainText := extractPlainTextFromEditorJS(updated.Blocks) _ = s.DB.IndexPage(db.SearchPage{ Path: "/" + targetSlug, Title: updated.Title, Content: plainText, }) c.Redirect(http.StatusSeeOther, "/admin/") return } // GET: render form c.HTML(http.StatusOK, "base", gin.H{ "Title": "Edit Post", "ContentTemplate": "form-content", "Action": "/admin/" + slug, "Post": p, "IsNew": false, }) } func (s *Server) handleDelete(c *gin.Context) { slug := c.Param("slug") if err := s.DB.DeletePostBySlug(slug); err != nil { c.HTML(http.StatusInternalServerError, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": err.Error(), }) return } // Invalidate cache files s.invalidatePostCache(slug) // Remove from search index _ = s.DB.UnindexPage("/" + slug) c.Redirect(http.StatusSeeOther, "/admin/") } func (s *Server) handleSetupGet(c *gin.Context) { if s.configured.Load() { c.Redirect(http.StatusFound, "/admin/") return } c.HTML(http.StatusOK, "setup.html", gin.H{}) } func (s *Server) handleSetupPost(c *gin.Context) { if s.configured.Load() { c.Redirect(http.StatusFound, "/admin/") return } username := strings.TrimSpace(c.PostForm("username")) password := c.PostForm("password") confirm := c.PostForm("confirm") siteTitle := strings.TrimSpace(c.PostForm("site_title")) siteDesc := strings.TrimSpace(c.PostForm("site_description")) redisplay := func(msg string) { c.HTML(http.StatusBadRequest, "setup.html", gin.H{ "Error": msg, "Username": username, "SiteTitle": siteTitle, "SiteDescription": siteDesc, }) } if username == "" { redisplay("Username is required") return } if password == "" { redisplay("Password is required") return } if password != confirm { redisplay("Passwords do not match") return } // Create admin user a := auth.NewAuth(s.AuthFile) if err := a.AddUserWithPassword(username, password); err != nil { redisplay("Failed to create user: " + err.Error()) return } // Save optional site settings if siteTitle != "" { _ = s.DB.SetSetting("site_title", siteTitle) } if siteDesc != "" { _ = s.DB.SetSetting("site_description", siteDesc) } // Save optional logo (ignore errors — setup can proceed without it) _ = s.saveLogoUpload(c) s.configured.Store(true) c.Redirect(http.StatusSeeOther, "/admin/") } func (s *Server) handleSettingsGet(c *gin.Context) { title, _ := s.DB.GetSetting("site_title") desc, _ := s.DB.GetSetting("site_description") c.HTML(http.StatusOK, "base", gin.H{ "Title": "Settings", "ContentTemplate": "settings-content", "SiteTitle": title, "SiteDescription": desc, }) } func (s *Server) handleSettingsPost(c *gin.Context) { siteTitle := strings.TrimSpace(c.PostForm("site_title")) siteDesc := strings.TrimSpace(c.PostForm("site_description")) _ = s.DB.SetSetting("site_title", siteTitle) _ = s.DB.SetSetting("site_description", siteDesc) if err := s.saveLogoUpload(c); err != nil { title, _ := s.DB.GetSetting("site_title") desc, _ := s.DB.GetSetting("site_description") c.HTML(http.StatusBadRequest, "base", gin.H{ "Title": "Settings", "ContentTemplate": "settings-content", "Error": err.Error(), "SiteTitle": title, "SiteDescription": desc, }) return } title, _ := s.DB.GetSetting("site_title") desc, _ := s.DB.GetSetting("site_description") c.HTML(http.StatusOK, "base", gin.H{ "Title": "Settings", "ContentTemplate": "settings-content", "Success": true, "SiteTitle": title, "SiteDescription": desc, }) } // saveLogoUpload saves an uploaded logo file to DataDir if one was provided. // Returns nil if no file was uploaded (blank is not an error). func (s *Server) saveLogoUpload(c *gin.Context) error { file, _, err := c.Request.FormFile("logo") if err != nil { return nil // no file — not an error } defer file.Close() sniff := make([]byte, 512) n, _ := file.Read(sniff) mimeType := http.DetectContentType(sniff[:n]) extMap := map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", } ext, ok := extMap[mimeType] if !ok { return fmt.Errorf("unsupported image type: %s", mimeType) } logoPath := filepath.Join(s.DataDir, "logo"+ext) out, err := os.Create(logoPath) if err != nil { return fmt.Errorf("could not save logo: %w", err) } defer out.Close() _, err = io.Copy(out, io.MultiReader(bytes.NewReader(sniff[:n]), file)) return err } func (s *Server) handleLogo(c *gin.Context) { extTypes := map[string]string{ ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", } for ext, ct := range extTypes { p := filepath.Join(s.DataDir, "logo"+ext) if f, err := os.Open(p); err == nil { defer f.Close() info, _ := f.Stat() c.Header("Content-Type", ct) http.ServeContent(c.Writer, c.Request, "image"+ext, info.ModTime(), f) return } } // Fall back to embedded default data, err := fs.ReadFile(s.siteAssets, "assets/static/image.png") if err != nil { c.AbortWithStatus(http.StatusNotFound) return } c.Data(http.StatusOK, "image/png", data) } // invalidatePostCache removes cache files for a post to force regeneration. func (s *Server) invalidatePostCache(slug string) { htmlCache := filepath.Join(s.PublicDir, slug+".html") jsonCache := filepath.Join(s.PublicDir, slug+".json") _ = os.Remove(htmlCache) _ = os.Remove(jsonCache) } func (s *Server) renderError(c *gin.Context, msg string) { c.HTML(http.StatusBadRequest, "base", gin.H{ "Title": "Error", "ContentTemplate": "error-content", "Message": msg, }) } func postFromForm(c *gin.Context) Post { tagsStr := strings.TrimSpace(c.PostForm("tags")) var tags []string if tagsStr != "" { for _, t := range strings.Split(tagsStr, ",") { t = strings.TrimSpace(t) if t != "" { tags = append(tags, t) } } } draft := c.PostForm("draft") != "" return Post{ Slug: strings.TrimSpace(c.PostForm("slug")), Title: strings.TrimSpace(c.PostForm("title")), Date: strings.TrimSpace(c.PostForm("date")), Tags: tags, Blocks: c.PostForm("blocks"), Draft: draft, } } // slugify converts a title to a URL-safe slug. var validSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) func slugify(title string) string { s := slug.Make(title) if s == "" { s = fmt.Sprintf("post-%d", time.Now().Unix()) } return s } // extractPlainTextFromEditorJS extracts plain text from EditorJS blocks for indexing. // It's a simple extraction that pulls text from paragraph and header blocks. func extractPlainTextFromEditorJS(blocksJSON string) string { if blocksJSON == "" || blocksJSON == "[]" { return "" } type block struct { Type string `json:"type"` Data json.RawMessage `json:"data"` } type doc struct { Blocks []block `json:"blocks"` } var d doc if err := json.Unmarshal([]byte(blocksJSON), &d); err != nil { return "" } var texts []string for _, b := range d.Blocks { switch b.Type { case "paragraph", "header": var data struct { Text string `json:"text"` } if err := json.Unmarshal(b.Data, &data); err == nil && data.Text != "" { texts = append(texts, data.Text) } } } return strings.Join(texts, " ") }