diff options
Diffstat (limited to 'internal/server/adminserver.go')
| -rw-r--r-- | internal/server/adminserver.go | 664 |
1 files changed, 664 insertions, 0 deletions
diff --git a/internal/server/adminserver.go b/internal/server/adminserver.go new file mode 100644 index 0000000..6b46bf3 --- /dev/null +++ b/internal/server/adminserver.go @@ -0,0 +1,664 @@ +// 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, " ") +} |
