summaryrefslogtreecommitdiffstats
path: root/internal/server/adminserver.go
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-07 00:23:24 +0200
committerivar <i@oiee.no>2026-04-07 00:23:24 +0200
commit85920b8c7a2696115d1f77c046f48f6f00d639f1 (patch)
tree14ed2043796eadd6ed5b0a95c55e38e48713d638 /internal/server/adminserver.go
downloadiblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.tar.xz
iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.zip
Init
Diffstat (limited to 'internal/server/adminserver.go')
-rw-r--r--internal/server/adminserver.go664
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, " ")
+}