From 85920b8c7a2696115d1f77c046f48f6f00d639f1 Mon Sep 17 00:00:00 2001
From: ivar
Date: Tue, 7 Apr 2026 00:23:24 +0200
Subject: Init
---
internal/auth.go | 198 +++++++++++
internal/builder/editorjs.go | 255 ++++++++++++++
internal/db/db.go | 66 ++++
internal/db/pages.go | 84 +++++
internal/db/posts.go | 248 ++++++++++++++
internal/db/posts_test.go | 186 +++++++++++
internal/db/search.go | 62 ++++
internal/db/settings.go | 21 ++
internal/media/handler.go | 221 ++++++++++++
internal/media/handler_test.go | 198 +++++++++++
internal/media/process.go | 82 +++++
internal/media/process_test.go | 78 +++++
internal/server/adminserver.go | 664 +++++++++++++++++++++++++++++++++++++
internal/server/fileserver.go | 111 +++++++
internal/server/fileserver_test.go | 44 +++
internal/server/frontpage.go | 87 +++++
internal/server/posthandler.go | 190 +++++++++++
17 files changed, 2795 insertions(+)
create mode 100644 internal/auth.go
create mode 100644 internal/builder/editorjs.go
create mode 100644 internal/db/db.go
create mode 100644 internal/db/pages.go
create mode 100644 internal/db/posts.go
create mode 100644 internal/db/posts_test.go
create mode 100644 internal/db/search.go
create mode 100644 internal/db/settings.go
create mode 100644 internal/media/handler.go
create mode 100644 internal/media/handler_test.go
create mode 100644 internal/media/process.go
create mode 100644 internal/media/process_test.go
create mode 100644 internal/server/adminserver.go
create mode 100644 internal/server/fileserver.go
create mode 100644 internal/server/fileserver_test.go
create mode 100644 internal/server/frontpage.go
create mode 100644 internal/server/posthandler.go
(limited to 'internal')
diff --git a/internal/auth.go b/internal/auth.go
new file mode 100644
index 0000000..5c5991e
--- /dev/null
+++ b/internal/auth.go
@@ -0,0 +1,198 @@
+// Package auth manages a htpasswd-compatible password file (bcrypt entries).
+// The file format is one "username:$2a$..." entry per line.
+// nginx auth_basic accepts this file directly via auth_basic_user_file.
+package auth
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+ "syscall"
+
+ "golang.org/x/crypto/bcrypt"
+ "golang.org/x/term"
+)
+
+type Auth struct {
+ path string
+}
+
+func NewAuth(path string) *Auth { return &Auth{path: path} }
+
+// IsConfigured reports whether the password file exists and contains at least one user.
+// It does not create the file as a side effect.
+func (a *Auth) IsConfigured() bool {
+ info, err := os.Stat(a.path)
+ return err == nil && info.Size() > 0
+}
+
+// AddUserWithPassword adds a user with an already-known password (for HTTP handlers).
+func (a *Auth) AddUserWithPassword(username, password string) error {
+ users, err := a.read()
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ if users == nil {
+ users = make(map[string]string)
+ }
+ if _, exists := users[username]; exists {
+ return fmt.Errorf("user %q already exists", username)
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ users[username] = string(hash)
+ return a.write(users)
+}
+
+func (a *Auth) AddUser(username string) error {
+ users, err := a.read()
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ if users == nil {
+ users = make(map[string]string)
+ }
+ if _, exists := users[username]; exists {
+ return fmt.Errorf("user %q already exists", username)
+ }
+ pw, err := readPassword("Password: ")
+ if err != nil {
+ return err
+ }
+ confirm, err := readPassword("Confirm: ")
+ if err != nil {
+ return err
+ }
+ if pw != confirm {
+ return fmt.Errorf("passwords do not match")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ users[username] = string(hash)
+ return a.write(users)
+}
+
+func (a *Auth) ChangePassword(username string) error {
+ users, err := a.read()
+ if err != nil {
+ return err
+ }
+ if _, exists := users[username]; !exists {
+ return fmt.Errorf("user %q not found", username)
+ }
+ pw, err := readPassword("New password: ")
+ if err != nil {
+ return err
+ }
+ confirm, err := readPassword("Confirm: ")
+ if err != nil {
+ return err
+ }
+ if pw != confirm {
+ return fmt.Errorf("passwords do not match")
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ users[username] = string(hash)
+ return a.write(users)
+}
+
+func (a *Auth) DeleteUser(username string) error {
+ users, err := a.read()
+ if err != nil {
+ return err
+ }
+ if _, exists := users[username]; !exists {
+ return fmt.Errorf("user %q not found", username)
+ }
+ delete(users, username)
+ return a.write(users)
+}
+
+func (a *Auth) ListUsers() ([]string, error) {
+ users, err := a.read()
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ names := make([]string, 0, len(users))
+ for k := range users {
+ names = append(names, k)
+ }
+ return names, nil
+}
+
+func (a *Auth) Verify(username, password string) (bool, error) {
+ users, err := a.read()
+ if err != nil {
+ return false, err
+ }
+ hash, ok := users[username]
+ if !ok {
+ return false, nil
+ }
+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+ if err == bcrypt.ErrMismatchedHashAndPassword {
+ return false, nil
+ }
+ return err == nil, err
+}
+
+func (a *Auth) read() (map[string]string, error) {
+ var _, staterr = os.Stat(a.path)
+ if os.IsNotExist(staterr) {
+ var cf, cferr = os.Create(a.path)
+ if cferr != nil {
+ return nil, cferr
+ }
+ defer cf.Close()
+ }
+
+ f, err := os.Open(a.path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ users := make(map[string]string)
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ user, hash, ok := strings.Cut(line, ":")
+ if ok {
+ users[user] = hash
+ }
+ }
+ return users, scanner.Err()
+}
+
+func (a *Auth) write(users map[string]string) error {
+ f, err := os.OpenFile(a.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ w := bufio.NewWriter(f)
+ for user, hash := range users {
+ fmt.Fprintf(w, "%s:%s\n", user, hash)
+ }
+ return w.Flush()
+}
+
+func readPassword(prompt string) (string, error) {
+ fmt.Print(prompt)
+ b, err := term.ReadPassword(int(syscall.Stdin))
+ fmt.Println()
+ return string(b), err
+}
diff --git a/internal/builder/editorjs.go b/internal/builder/editorjs.go
new file mode 100644
index 0000000..68237d0
--- /dev/null
+++ b/internal/builder/editorjs.go
@@ -0,0 +1,255 @@
+package builder
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "strings"
+)
+
+// PageData is passed to HTML templates when rendering posts.
+type PageData struct {
+ Title string
+ Content template.HTML
+ ComponentScripts []string
+ Date string
+ Tags []string
+ Path string
+}
+
+type EditorDocument struct {
+ Version string `json:"version"`
+ Time int64 `json:"time"`
+ Blocks []EditorBlock `json:"blocks"`
+}
+
+type EditorBlock struct {
+ Type string `json:"type"`
+ Data json.RawMessage `json:"data"`
+}
+
+// RenderEditorJS converts EditorJS block JSON to HTML and extracts script URLs.
+// Returns the rendered HTML body, script URLs, and any error.
+func RenderEditorJS(blocksJSON string) (html string, scripts []string, err error) {
+ if blocksJSON == "" || blocksJSON == "[]" {
+ return "", nil, nil
+ }
+
+ var doc EditorDocument
+ if err := json.Unmarshal([]byte(blocksJSON), &doc); err != nil {
+ return "", nil, err
+ }
+
+ var buf strings.Builder
+ var scriptURLs []string
+
+ for _, block := range doc.Blocks {
+ blockHTML, blockScripts, err := renderBlock(block)
+ if err != nil {
+ return "", nil, err
+ }
+ if blockHTML != "" {
+ buf.WriteString(blockHTML)
+ }
+ scriptURLs = append(scriptURLs, blockScripts...)
+ }
+
+ return buf.String(), scriptURLs, nil
+}
+
+func renderBlock(block EditorBlock) (html string, scripts []string, err error) {
+ switch block.Type {
+ case "paragraph":
+ return renderParagraph(block.Data)
+ case "header":
+ return renderHeader(block.Data)
+ case "image":
+ return renderImage(block.Data)
+ case "list":
+ return renderList(block.Data)
+ case "code":
+ return renderCode(block.Data)
+ case "quote":
+ return renderQuote(block.Data)
+ case "script":
+ return renderScript(block.Data)
+ case "component":
+ return renderComponent(block.Data)
+ default:
+ return "", nil, nil
+ }
+}
+
+func renderParagraph(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if strings.TrimSpace(d.Text) == "" {
+ return "", nil, nil
+ }
+ return fmt.Sprintf("
%s
\n", template.HTML(d.Text)), nil, nil
+}
+
+func renderHeader(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ Text string `json:"text"`
+ Level int `json:"level"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if d.Level < 1 || d.Level > 6 {
+ d.Level = 2
+ }
+ if strings.TrimSpace(d.Text) == "" {
+ return "", nil, nil
+ }
+ return fmt.Sprintf("%s\n", d.Level, template.HTML(d.Text), d.Level), nil, nil
+}
+
+// renderImage renders an EditorJS image block.
+// The thumbhash (if present) is placed in data-thumbhash on the so
+// the client can decode it and use it as a blurry placeholder via JS.
+func renderImage(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ File struct {
+ URL string `json:"url"`
+ Thumbhash string `json:"thumbhash"`
+ } `json:"file"`
+ Caption string `json:"caption"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if d.File.URL == "" {
+ return "", nil, nil
+ }
+
+ alt := d.Caption
+ if alt == "" {
+ alt = "image"
+ }
+
+ var figAttrs string
+ if d.File.Thumbhash != "" {
+ figAttrs = fmt.Sprintf(` data-thumbhash="%s"`, template.HTMLEscapeString(d.File.Thumbhash))
+ }
+
+ var buf strings.Builder
+ fmt.Fprintf(&buf, "", figAttrs)
+ fmt.Fprintf(&buf, `
`,
+ template.HTMLEscapeString(d.File.URL),
+ template.HTMLEscapeString(alt),
+ )
+ if d.Caption != "" {
+ fmt.Fprintf(&buf, "%s", template.HTML(d.Caption))
+ }
+ buf.WriteString("\n")
+ return buf.String(), nil, nil
+}
+
+type listItem struct {
+ Content string `json:"content"`
+ Items []listItem `json:"items"`
+}
+
+func renderList(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ Style string `json:"style"`
+ Items []listItem `json:"items"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if len(d.Items) == 0 {
+ return "", nil, nil
+ }
+ tag := "ul"
+ if d.Style == "ordered" {
+ tag = "ol"
+ }
+ var buf strings.Builder
+ renderListItems(&buf, d.Items, tag)
+ return buf.String(), nil, nil
+}
+
+func renderListItems(buf *strings.Builder, items []listItem, tag string) {
+ fmt.Fprintf(buf, "<%s>\n", tag)
+ for _, item := range items {
+ fmt.Fprintf(buf, "%s", template.HTML(item.Content))
+ if len(item.Items) > 0 {
+ renderListItems(buf, item.Items, tag)
+ }
+ buf.WriteString("\n")
+ }
+ fmt.Fprintf(buf, "%s>\n", tag)
+}
+
+func renderCode(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ Code string `json:"code"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if strings.TrimSpace(d.Code) == "" {
+ return "", nil, nil
+ }
+ return fmt.Sprintf("%s
\n", template.HTMLEscapeString(d.Code)), nil, nil
+}
+
+func renderQuote(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ Text string `json:"text"`
+ Caption string `json:"caption"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if strings.TrimSpace(d.Text) == "" {
+ return "", nil, nil
+ }
+ var buf strings.Builder
+ fmt.Fprintf(&buf, "\n%s
\n", template.HTML(d.Text))
+ if d.Caption != "" {
+ fmt.Fprintf(&buf, "%s\n", template.HTML(d.Caption))
+ }
+ buf.WriteString("
\n")
+ return buf.String(), nil, nil
+}
+
+func renderScript(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ Src string `json:"src"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if d.Src == "" {
+ return "", nil, nil
+ }
+ return "", []string{d.Src}, nil
+}
+
+func renderComponent(data json.RawMessage) (string, []string, error) {
+ var d struct {
+ Name string `json:"name"`
+ Props map[string]string `json:"props"`
+ }
+ if err := json.Unmarshal(data, &d); err != nil {
+ return "", nil, err
+ }
+ if d.Name == "" {
+ return "", nil, nil
+ }
+ var buf strings.Builder
+ fmt.Fprintf(&buf, "<%s", template.HTMLEscapeString(d.Name))
+ for k, v := range d.Props {
+ fmt.Fprintf(&buf, " %s=\"%s\"", template.HTMLEscapeString(k), template.HTMLEscapeString(v))
+ }
+ fmt.Fprintf(&buf, ">%s>\n", template.HTMLEscapeString(d.Name))
+ return buf.String(), []string{"/assets/components/" + d.Name + ".js"}, nil
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..d348b96
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,66 @@
+package db
+
+import (
+ "database/sql"
+
+ _ "modernc.org/sqlite"
+)
+
+type DB struct {
+ db *sql.DB
+}
+
+func Open(path string) (*DB, error) {
+ sqldb, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ _, err = sqldb.Exec(`
+ CREATE TABLE IF NOT EXISTS pages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ path TEXT NOT NULL UNIQUE,
+ html_path TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '',
+ date TEXT DEFAULT '',
+ tags TEXT DEFAULT '[]',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE INDEX IF NOT EXISTS idx_pages_path ON pages(path);
+ CREATE INDEX IF NOT EXISTS idx_pages_date ON pages(date);
+ CREATE TABLE IF NOT EXISTS posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ slug TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL DEFAULT '',
+ date TEXT DEFAULT '',
+ tags TEXT DEFAULT '[]',
+ draft INTEGER NOT NULL DEFAULT 0,
+ blocks TEXT NOT NULL DEFAULT '[]',
+ updated_at INTEGER NOT NULL DEFAULT (cast(strftime('%s','now') * 1000000 as integer))
+ );
+ CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
+ CREATE INDEX IF NOT EXISTS idx_posts_date ON posts(date);
+ CREATE TABLE IF NOT EXISTS redirects (
+ from_slug TEXT PRIMARY KEY,
+ to_slug TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL DEFAULT ''
+ );
+ CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
+ path UNINDEXED,
+ title,
+ content,
+ tokenize = 'porter unicode61'
+ );
+ `)
+ if err != nil {
+ return nil, err
+ }
+ return &DB{db: sqldb}, nil
+}
+
+func (d *DB) Close() error { return d.db.Close() }
+
+// RawDB returns the underlying *sql.DB for advanced queries.
+func (d *DB) RawDB() *sql.DB { return d.db }
diff --git a/internal/db/pages.go b/internal/db/pages.go
new file mode 100644
index 0000000..fc048c0
--- /dev/null
+++ b/internal/db/pages.go
@@ -0,0 +1,84 @@
+package db
+
+import (
+ "database/sql"
+ "encoding/json"
+ "strings"
+ "time"
+)
+
+type PageMeta struct {
+ Path string
+ HTMLPath string
+ Title string
+ Date string
+ Tags []string
+ UpdatedAt time.Time
+}
+
+func (d *DB) UpsertPage(p PageMeta) error {
+ tags, _ := json.Marshal(p.Tags)
+ _, err := d.db.Exec(`
+ INSERT INTO pages (path, html_path, title, date, tags, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ html_path = excluded.html_path,
+ title = excluded.title,
+ date = excluded.date,
+ tags = excluded.tags,
+ updated_at = excluded.updated_at
+ `, p.Path, p.HTMLPath, p.Title, p.Date, string(tags), p.UpdatedAt.UTC())
+ return err
+}
+
+func (d *DB) DeletePage(path string) error {
+ _, err := d.db.Exec(`DELETE FROM pages WHERE path = ?`, path)
+ return err
+}
+
+func (d *DB) GetPage(path string) (*PageMeta, error) {
+ row := d.db.QueryRow(
+ `SELECT path, html_path, title, date, tags FROM pages WHERE path = ?`, path)
+ var p PageMeta
+ var tagsJSON string
+ if err := row.Scan(&p.Path, &p.HTMLPath, &p.Title, &p.Date, &tagsJSON); err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+ return &p, nil
+}
+
+func (d *DB) ListPages() ([]PageMeta, error) {
+ rows, err := d.db.Query(
+ `SELECT path, html_path, title, date, tags FROM pages ORDER BY date DESC, path`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanPages(rows)
+}
+
+func (d *DB) ListByTag(tag string) ([]PageMeta, error) {
+ needle := `%"` + strings.ReplaceAll(tag, `"`, `\"`) + `"%`
+ rows, err := d.db.Query(
+ `SELECT path, html_path, title, date, tags FROM pages WHERE tags LIKE ? ORDER BY date DESC`, needle)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanPages(rows)
+}
+
+func scanPages(rows *sql.Rows) ([]PageMeta, error) {
+ var pages []PageMeta
+ for rows.Next() {
+ var p PageMeta
+ var tagsJSON string
+ if err := rows.Scan(&p.Path, &p.HTMLPath, &p.Title, &p.Date, &tagsJSON); err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+ pages = append(pages, p)
+ }
+ return pages, rows.Err()
+}
diff --git a/internal/db/posts.go b/internal/db/posts.go
new file mode 100644
index 0000000..3c90b5f
--- /dev/null
+++ b/internal/db/posts.go
@@ -0,0 +1,248 @@
+package db
+
+import (
+ "database/sql"
+ "encoding/json"
+ "time"
+)
+
+type PostRecord struct {
+ Id string
+ Slug string
+ Title string
+ Date string
+ Tags []string
+ Draft bool
+ Blocks string // raw EditorJS JSON
+ UpdatedAt int64 // Unix microseconds
+}
+
+func (d *DB) UpsertPost(p PostRecord) error {
+ tags, _ := json.Marshal(p.Tags)
+ draft := 0
+ if p.Draft {
+ draft = 1
+ }
+ _, err := d.db.Exec(`
+ INSERT INTO posts (slug, title, date, tags, draft, blocks, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(slug) DO UPDATE SET
+ title = excluded.title,
+ date = excluded.date,
+ tags = excluded.tags,
+ draft = excluded.draft,
+ blocks = excluded.blocks,
+ updated_at = excluded.updated_at
+ `, p.Slug, p.Title, p.Date, string(tags), draft, p.Blocks, p.UpdatedAt)
+ return err
+}
+
+func (d *DB) GetPostBySlug(slug string) (*PostRecord, error) {
+ row := d.db.QueryRow(
+ `SELECT id, slug, title, date, tags, draft, blocks, updated_at FROM posts WHERE slug = ?`, slug)
+ var p PostRecord
+ var tagsJSON string
+ var draft int
+ if err := row.Scan(&p.Id, &p.Slug, &p.Title, &p.Date, &tagsJSON, &draft, &p.Blocks, &p.UpdatedAt); err != nil {
+ return nil, err
+ }
+ p.Draft = draft != 0
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+ if p.Tags == nil {
+ p.Tags = []string{}
+ }
+ return &p, nil
+}
+
+func (d *DB) GetPostById(id string) (*PostRecord, error) {
+ row := d.db.QueryRow(
+ `SELECT id, slug, title, date, tags, draft, blocks, updated_at FROM posts WHERE id = ?`, id)
+ var p PostRecord
+ var tagsJSON string
+ var draft int
+ if err := row.Scan(&p.Id, &p.Slug, &p.Title, &p.Date, &tagsJSON, &draft, &p.Blocks, &p.UpdatedAt); err != nil {
+ return nil, err
+ }
+ p.Draft = draft != 0
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+ if p.Tags == nil {
+ p.Tags = []string{}
+ }
+ return &p, nil
+}
+
+func (d *DB) ListPosts(includeDrafts bool) ([]PostRecord, error) {
+ query := `SELECT id, slug, title, date, tags, draft, blocks, updated_at FROM posts`
+ if !includeDrafts {
+ query += ` WHERE draft = 0`
+ }
+ query += ` ORDER BY date DESC, slug`
+ rows, err := d.db.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanPosts(rows)
+}
+
+func (d *DB) DeletePostBySlug(slug string) error {
+ _, err := d.db.Exec(`DELETE FROM posts WHERE slug = ?`, slug)
+ return err
+}
+
+func (d *DB) DeletePostById(id string) error {
+ _, err := d.db.Exec(`DELETE FROM posts WHERE id = ?`, id)
+ return err
+}
+
+func scanPosts(rows *sql.Rows) ([]PostRecord, error) {
+ var posts []PostRecord
+ for rows.Next() {
+ var p PostRecord
+ var tagsJSON string
+ var draft int
+ if err := rows.Scan(&p.Id, &p.Slug, &p.Title, &p.Date, &tagsJSON, &draft, &p.Blocks, &p.UpdatedAt); err != nil {
+ return nil, err
+ }
+ p.Draft = draft != 0
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+ if p.Tags == nil {
+ p.Tags = []string{}
+ }
+ posts = append(posts, p)
+ }
+ return posts, rows.Err()
+}
+
+func (p *PostRecord) GetPostRawPath() string {
+ return "/" + p.Slug
+}
+
+func (p *PostRecord) GetUpdatedTime() time.Time {
+ return time.UnixMicro(p.UpdatedAt).UTC()
+}
+
+func (d *DB) AddRedirect(fromSlug, toSlug string) error {
+ _, err := d.db.Exec(
+ `INSERT INTO redirects (from_slug, to_slug) VALUES (?, ?)
+ ON CONFLICT(from_slug) DO UPDATE SET to_slug = excluded.to_slug`,
+ fromSlug, toSlug,
+ )
+ return err
+}
+
+func (d *DB) GetRedirect(fromSlug string) (string, error) {
+ var toSlug string
+ err := d.db.QueryRow(
+ `SELECT to_slug FROM redirects WHERE from_slug = ?`, fromSlug,
+ ).Scan(&toSlug)
+ return toSlug, err
+}
+
+func (d *DB) CollapseRedirects(oldSlug, newSlug string) error {
+ _, err := d.db.Exec(
+ `UPDATE redirects SET to_slug = ? WHERE to_slug = ?`, newSlug, oldSlug,
+ )
+ return err
+}
+
+func (d *DB) RenamePost(oldSlug, newSlug string) error {
+ if oldSlug == newSlug {
+ return nil
+ }
+ tx, err := d.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ var p PostRecord
+ var tagsJSON string
+ var draft int
+ row := tx.QueryRow(
+ `SELECT slug, title, date, tags, draft, blocks, updated_at FROM posts WHERE slug = ?`, oldSlug)
+ if err := row.Scan(&p.Slug, &p.Title, &p.Date, &tagsJSON, &draft, &p.Blocks, &p.UpdatedAt); err != nil {
+ return err
+ }
+ p.Draft = draft != 0
+ _ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
+
+ tags, _ := json.Marshal(p.Tags)
+ draftInt := 0
+ if p.Draft {
+ draftInt = 1
+ }
+ _, err = tx.Exec(`
+ INSERT INTO posts (slug, title, date, tags, draft, blocks, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ newSlug, p.Title, p.Date, string(tags), draftInt, p.Blocks, p.UpdatedAt,
+ )
+ if err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(`DELETE FROM posts WHERE slug = ?`, oldSlug); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(
+ `UPDATE redirects SET to_slug = ? WHERE to_slug = ?`, newSlug, oldSlug,
+ ); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(`
+ INSERT INTO redirects (from_slug, to_slug) VALUES (?, ?)
+ ON CONFLICT(from_slug) DO UPDATE SET to_slug = excluded.to_slug`,
+ oldSlug, newSlug,
+ ); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+func (d *DB) RenameAndUpsertPost(oldSlug string, p PostRecord) error {
+ if oldSlug == p.Slug {
+ return d.UpsertPost(p)
+ }
+ tx, err := d.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ tags, _ := json.Marshal(p.Tags)
+ draftInt := 0
+ if p.Draft {
+ draftInt = 1
+ }
+
+ if _, err = tx.Exec(`
+ INSERT INTO posts (slug, title, date, tags, draft, blocks, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ p.Slug, p.Title, p.Date, string(tags), draftInt, p.Blocks, p.UpdatedAt,
+ ); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(`DELETE FROM posts WHERE slug = ?`, oldSlug); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(
+ `UPDATE redirects SET to_slug = ? WHERE to_slug = ?`, p.Slug, oldSlug,
+ ); err != nil {
+ return err
+ }
+
+ if _, err = tx.Exec(`
+ INSERT INTO redirects (from_slug, to_slug) VALUES (?, ?)
+ ON CONFLICT(from_slug) DO UPDATE SET to_slug = excluded.to_slug`,
+ oldSlug, p.Slug,
+ ); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
diff --git a/internal/db/posts_test.go b/internal/db/posts_test.go
new file mode 100644
index 0000000..804e47d
--- /dev/null
+++ b/internal/db/posts_test.go
@@ -0,0 +1,186 @@
+package db
+
+import (
+ "database/sql"
+ "testing"
+)
+
+func openTestDB(t *testing.T) *DB {
+ t.Helper()
+ d, err := Open(":memory:")
+ if err != nil {
+ t.Fatalf("open test db: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ return d
+}
+
+func TestAddAndGetRedirect(t *testing.T) {
+ d := openTestDB(t)
+
+ if err := d.AddRedirect("old-slug", "new-slug"); err != nil {
+ t.Fatalf("AddRedirect: %v", err)
+ }
+
+ got, err := d.GetRedirect("old-slug")
+ if err != nil {
+ t.Fatalf("GetRedirect: %v", err)
+ }
+ if got != "new-slug" {
+ t.Errorf("got %q, want %q", got, "new-slug")
+ }
+}
+
+func TestGetRedirect_NotFound(t *testing.T) {
+ d := openTestDB(t)
+
+ _, err := d.GetRedirect("missing")
+ if err != sql.ErrNoRows {
+ t.Errorf("expected sql.ErrNoRows, got %v", err)
+ }
+}
+
+func TestCollapseRedirects(t *testing.T) {
+ d := openTestDB(t)
+
+ if err := d.AddRedirect("a", "b"); err != nil {
+ t.Fatalf("AddRedirect: %v", err)
+ }
+ if err := d.CollapseRedirects("b", "c"); err != nil {
+ t.Fatalf("CollapseRedirects: %v", err)
+ }
+
+ got, err := d.GetRedirect("a")
+ if err != nil {
+ t.Fatalf("GetRedirect: %v", err)
+ }
+ if got != "c" {
+ t.Errorf("got %q, want %q", got, "c")
+ }
+}
+
+func TestAddRedirect_Upsert(t *testing.T) {
+ d := openTestDB(t)
+
+ if err := d.AddRedirect("old", "first"); err != nil {
+ t.Fatalf("AddRedirect first: %v", err)
+ }
+ if err := d.AddRedirect("old", "second"); err != nil {
+ t.Fatalf("AddRedirect second: %v", err)
+ }
+
+ got, err := d.GetRedirect("old")
+ if err != nil {
+ t.Fatalf("GetRedirect: %v", err)
+ }
+ if got != "second" {
+ t.Errorf("got %q, want %q", got, "second")
+ }
+}
+
+func TestRenamePost(t *testing.T) {
+ d := openTestDB(t)
+
+ original := PostRecord{
+ Slug: "original-slug",
+ Title: "My Post",
+ Date: "2026-04-04",
+ Tags: []string{"go"},
+ Draft: false,
+ Blocks: `[{"type":"paragraph","data":{"text":"hello"}}]`,
+ UpdatedAt: 1000000,
+ }
+ if err := d.UpsertPost(original); err != nil {
+ t.Fatalf("UpsertPost: %v", err)
+ }
+
+ if err := d.RenamePost("original-slug", "new-slug"); err != nil {
+ t.Fatalf("RenamePost: %v", err)
+ }
+
+ got, err := d.GetPostBySlug("new-slug")
+ if err != nil {
+ t.Fatalf("GetPost new-slug: %v", err)
+ }
+ if got.Title != "My Post" {
+ t.Errorf("title: got %q, want %q", got.Title, "My Post")
+ }
+
+ _, err = d.GetPostBySlug("original-slug")
+ if err == nil {
+ t.Error("expected old slug to be deleted, but GetPost returned no error")
+ }
+
+ toSlug, err := d.GetRedirect("original-slug")
+ if err != nil {
+ t.Fatalf("GetRedirect: %v", err)
+ }
+ if toSlug != "new-slug" {
+ t.Errorf("redirect: got %q, want %q", toSlug, "new-slug")
+ }
+}
+
+func TestRenameAndUpsertPost(t *testing.T) {
+ d := openTestDB(t)
+
+ if err := d.UpsertPost(PostRecord{Slug: "old", Title: "Old Title", Date: "2026-01-01", Blocks: "[]", UpdatedAt: 1}); err != nil {
+ t.Fatalf("UpsertPost: %v", err)
+ }
+
+ updated := PostRecord{
+ Slug: "new",
+ Title: "New Title",
+ Date: "2026-04-04",
+ Tags: []string{"go"},
+ Blocks: "[]",
+ UpdatedAt: 2,
+ }
+ if err := d.RenameAndUpsertPost("old", updated); err != nil {
+ t.Fatalf("RenameAndUpsertPost: %v", err)
+ }
+
+ got, err := d.GetPostBySlug("new")
+ if err != nil {
+ t.Fatalf("GetPost new: %v", err)
+ }
+ if got.Title != "New Title" {
+ t.Errorf("title: got %q, want %q", got.Title, "New Title")
+ }
+ if got.Date != "2026-04-04" {
+ t.Errorf("date: got %q, want %q", got.Date, "2026-04-04")
+ }
+
+ if _, err := d.GetPostBySlug("old"); err == nil {
+ t.Error("expected old slug to be deleted")
+ }
+
+ toSlug, err := d.GetRedirect("old")
+ if err != nil {
+ t.Fatalf("GetRedirect: %v", err)
+ }
+ if toSlug != "new" {
+ t.Errorf("redirect: got %q, want %q", toSlug, "new")
+ }
+}
+
+func TestRenamePost_CollapsesChain(t *testing.T) {
+ d := openTestDB(t)
+
+ if err := d.UpsertPost(PostRecord{Slug: "b", Title: "B", Blocks: "[]", UpdatedAt: 1}); err != nil {
+ t.Fatalf("UpsertPost: %v", err)
+ }
+ if err := d.AddRedirect("a", "b"); err != nil {
+ t.Fatalf("AddRedirect: %v", err)
+ }
+ if err := d.RenamePost("b", "c"); err != nil {
+ t.Fatalf("RenamePost: %v", err)
+ }
+
+ got, err := d.GetRedirect("a")
+ if err != nil {
+ t.Fatalf("GetRedirect a: %v", err)
+ }
+ if got != "c" {
+ t.Errorf("chain collapse: got %q, want %q", got, "c")
+ }
+}
diff --git a/internal/db/search.go b/internal/db/search.go
new file mode 100644
index 0000000..1214e2d
--- /dev/null
+++ b/internal/db/search.go
@@ -0,0 +1,62 @@
+package db
+
+type SearchPage struct {
+ Path string
+ Title string
+ Content string
+}
+
+type SearchResult struct {
+ Path string
+ Title string
+ Snippet string
+}
+
+func (d *DB) IndexPage(p SearchPage) error {
+ tx, err := d.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ if _, err = tx.Exec(`DELETE FROM pages_fts WHERE path = ?`, p.Path); err != nil {
+ return err
+ }
+ if _, err = tx.Exec(
+ `INSERT INTO pages_fts (path, title, content) VALUES (?, ?, ?)`,
+ p.Path, p.Title, p.Content,
+ ); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func (d *DB) UnindexPage(path string) error {
+ _, err := d.db.Exec(`DELETE FROM pages_fts WHERE path = ?`, path)
+ return err
+}
+
+// Search runs a full-text query and returns up to 20 results with snippets.
+func (d *DB) Search(query string) ([]SearchResult, error) {
+ rows, err := d.db.Query(`
+ SELECT path, title,
+ snippet(pages_fts, 2, '', '', '...', 20)
+ FROM pages_fts
+ WHERE pages_fts MATCH ?
+ ORDER BY rank
+ LIMIT 20
+ `, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var results []SearchResult
+ for rows.Next() {
+ var r SearchResult
+ if err := rows.Scan(&r.Path, &r.Title, &r.Snippet); err != nil {
+ return nil, err
+ }
+ results = append(results, r)
+ }
+ return results, rows.Err()
+}
diff --git a/internal/db/settings.go b/internal/db/settings.go
new file mode 100644
index 0000000..6b2a007
--- /dev/null
+++ b/internal/db/settings.go
@@ -0,0 +1,21 @@
+package db
+
+import "database/sql"
+
+func (d *DB) GetSetting(key string) (string, error) {
+ var val string
+ err := d.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&val)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return val, err
+}
+
+func (d *DB) SetSetting(key, value string) error {
+ _, err := d.db.Exec(
+ `INSERT INTO settings (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
+ key, value,
+ )
+ return err
+}
diff --git a/internal/media/handler.go b/internal/media/handler.go
new file mode 100644
index 0000000..6b4d114
--- /dev/null
+++ b/internal/media/handler.go
@@ -0,0 +1,221 @@
+package media
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+const (
+ maxUploadSize = 20 << 20 // 20 MB
+ maxWidth = 3000
+)
+
+// allowedMIMEs maps detected content types to canonical file extensions.
+var allowedMIMEs = map[string]string{
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/webp": ".webp",
+}
+
+// MediaHandler handles image uploads and on-the-fly image serving.
+type MediaHandler struct {
+ storageDir string
+}
+
+// NewMediaHandler returns a MediaHandler that stores files in storageDir.
+// The directory is created if it does not exist.
+func NewMediaHandler(storageDir string) *MediaHandler {
+ _ = os.MkdirAll(storageDir, 0755)
+ return &MediaHandler{storageDir: storageDir}
+}
+
+// HandleUpload handles POST /admin/upload/image.
+// Expects multipart/form-data with an "image" field.
+// Returns EditorJS-compatible JSON: {"success":1,"file":{"url":"/media/.webp"}}
+func (h *MediaHandler) HandleUpload(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
+
+ file, _, err := c.Request.FormFile("image")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": 0, "error": err.Error()})
+ return
+ }
+ defer file.Close()
+
+ // Sniff MIME type from first 512 bytes
+ sniff := make([]byte, 512)
+ n, _ := file.Read(sniff)
+ mimeType := http.DetectContentType(sniff[:n])
+ ext, ok := allowedMIMEs[mimeType]
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"success": 0, "error": "unsupported image type: " + mimeType})
+ return
+ }
+
+ // Rewind so the full file is copied to disk
+ seeker, ok := file.(io.Seeker)
+ if !ok {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "file not seekable"})
+ return
+ }
+ if _, err := seeker.Seek(0, io.SeekStart); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "seek error"})
+ return
+ }
+
+ if err := os.MkdirAll(h.storageDir, 0755); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "storage error"})
+ return
+ }
+
+ id := uuid.New().String()
+ destPath := filepath.Join(h.storageDir, id+ext)
+
+ out, err := os.Create(destPath)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "storage error"})
+ return
+ }
+
+ if _, err := io.Copy(out, file); err != nil {
+ out.Close()
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "write error"})
+ return
+ }
+ if err := out.Close(); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "finalize error"})
+ return
+ }
+
+ fileInfo := gin.H{"url": "/media/" + id + ".webp"}
+ if w, h, th, err := ImageInfo(destPath); err == nil {
+ fileInfo["width"] = w
+ fileInfo["height"] = h
+ fileInfo["thumbhash"] = th
+ }
+
+ c.JSON(http.StatusOK, gin.H{"success": 1, "file": fileInfo})
+}
+
+// HandleServe handles GET /media/*filepath.
+// Format is determined by the file extension (.webp or .jpg/.jpeg).
+// The optional ?w= query param resizes the image (clamped to 3000; error if <= 0).
+func (h *MediaHandler) HandleServe(c *gin.Context) {
+ filename := filepath.Base(strings.TrimPrefix(c.Param("filepath"), "/"))
+ ext := strings.ToLower(filepath.Ext(filename))
+ base := strings.TrimSuffix(filename, filepath.Ext(filename))
+
+ var format, contentType string
+ switch ext {
+ case ".webp":
+ format, contentType = "webp", "image/webp"
+ case ".jpg", ".jpeg":
+ format, contentType = "jpeg", "image/jpeg"
+ default:
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ width := 0
+ if wStr := c.Query("w"); wStr != "" {
+ w, err := strconv.Atoi(wStr)
+ if err != nil || w <= 0 {
+ c.AbortWithStatus(http.StatusBadRequest)
+ return
+ }
+ if w > maxWidth {
+ w = maxWidth
+ }
+ width = w
+ }
+
+ cacheName := cacheKey(base, width, format)
+ cachePath := filepath.Join(h.storageDir, cacheName)
+
+ // Cache hit: serve the existing variant directly
+ if f, err := os.Open(cachePath); err == nil {
+ defer f.Close()
+ info, _ := f.Stat()
+ c.Header("Content-Type", contentType)
+ http.ServeContent(c.Writer, c.Request, cacheName, info.ModTime(), f)
+ return
+ }
+
+ origPath, err := findOriginal(h.storageDir, base)
+ if err != nil {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Skip processing if format matches original and no resize is requested
+ origExt := strings.ToLower(filepath.Ext(origPath))
+ sameFormat := origExt == ext ||
+ (origExt == ".jpg" && ext == ".jpeg") ||
+ (origExt == ".jpeg" && ext == ".jpg")
+ if width == 0 && sameFormat {
+ f, err := os.Open(origPath)
+ if err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+ defer f.Close()
+ info, _ := f.Stat()
+ c.Header("Content-Type", contentType)
+ http.ServeContent(c.Writer, c.Request, filename, info.ModTime(), f)
+ return
+ }
+
+ result, err := ConvertAndResize(origPath, width, format)
+ if err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ // Atomic cache write — concurrent writers are harmless (last rename wins)
+ tmp := cachePath + ".tmp"
+ if err := os.WriteFile(tmp, result, 0644); err == nil {
+ _ = os.Rename(tmp, cachePath)
+ }
+
+ c.Header("Content-Type", contentType)
+ c.Data(http.StatusOK, contentType, result)
+}
+
+// cacheKey returns the filename for a processed image variant.
+// - No width: ".webp" / ".jpg"
+// - With width: "_w.webp" / "_w.jpg"
+func cacheKey(base string, width int, format string) string {
+ ext := ".webp"
+ if format == "jpeg" {
+ ext = ".jpg"
+ }
+ if width > 0 {
+ return fmt.Sprintf("%s_%dw%s", base, width, ext)
+ }
+ return base + ext
+}
+
+// findOriginal finds the original upload file for a given UUID base name.
+// Cache variants (which contain '_' before the extension) are excluded.
+func findOriginal(dir, base string) (string, error) {
+ matches, err := filepath.Glob(filepath.Join(dir, base+".*"))
+ if err != nil {
+ return "", err
+ }
+ for _, m := range matches {
+ name := filepath.Base(m)
+ nameBase := strings.TrimSuffix(name, filepath.Ext(name))
+ if !strings.Contains(nameBase, "_") {
+ return m, nil
+ }
+ }
+ return "", fmt.Errorf("original not found for %q in %s", base, dir)
+}
diff --git a/internal/media/handler_test.go b/internal/media/handler_test.go
new file mode 100644
index 0000000..d65544c
--- /dev/null
+++ b/internal/media/handler_test.go
@@ -0,0 +1,198 @@
+package media
+
+import (
+ "bytes"
+ "encoding/json"
+ "image"
+ "image/jpeg"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+func newTestHandler(t *testing.T) (*MediaHandler, string) {
+ t.Helper()
+ dir := t.TempDir()
+ return NewMediaHandler(dir), dir
+}
+
+func testJPEGBytes(t *testing.T) []byte {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 100, 60))
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, img, nil); err != nil {
+ t.Fatalf("encode jpeg: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func multipartUpload(t *testing.T, field, filename string, data []byte) *http.Request {
+ t.Helper()
+ var body bytes.Buffer
+ w := multipart.NewWriter(&body)
+ fw, err := w.CreateFormFile(field, filename)
+ if err != nil {
+ t.Fatalf("create form file: %v", err)
+ }
+ fw.Write(data)
+ w.Close()
+ req, _ := http.NewRequest(http.MethodPost, "/admin/upload/image", &body)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ return req
+}
+
+// TestUploadValid verifies a valid JPEG upload returns success with a /media/ URL.
+func TestUploadValid(t *testing.T) {
+ h, dir := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "image", "photo.jpg", testJPEGBytes(t))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("parse response: %v", err)
+ }
+ if resp["success"] != float64(1) {
+ t.Fatalf("expected success=1, got %v", resp)
+ }
+ file, ok := resp["file"].(map[string]interface{})
+ if !ok {
+ t.Fatal("expected file object in response")
+ }
+ url, _ := file["url"].(string)
+ if len(url) < 7 || url[:7] != "/media/" {
+ t.Fatalf("expected /media/ URL, got %q", url)
+ }
+ entries, _ := os.ReadDir(dir)
+ if len(entries) == 0 {
+ t.Fatal("expected original to be written to storage dir")
+ }
+}
+
+// TestUploadInvalidMIME rejects non-image content.
+func TestUploadInvalidMIME(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "image", "evil.txt", []byte("not an image at all"))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestUploadWrongField rejects request missing the "image" field.
+func TestUploadWrongField(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "file", "photo.jpg", testJPEGBytes(t))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestServeCacheHit serves a pre-existing cache file directly.
+func TestServeCacheHit(t *testing.T) {
+ h, dir := newTestHandler(t)
+ if err := os.WriteFile(filepath.Join(dir, "abc123.webp"), []byte("FAKE_WEBP"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc123.webp", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc123.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "image/webp" {
+ t.Fatalf("expected image/webp, got %q", ct)
+ }
+}
+
+// TestServeNotFound returns 404 when no original exists.
+func TestServeNotFound(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/missing.webp", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/missing.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rr.Code)
+ }
+}
+
+// TestServeInvalidWidth returns 400 for non-positive width.
+func TestServeInvalidWidth(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc.webp?w=0", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestServeUnknownFormat returns 404 for unrecognised extensions.
+func TestServeUnknownFormat(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc.png", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc.png"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rr.Code)
+ }
+}
+
+// TestCacheKey verifies the cache filename scheme.
+func TestCacheKey(t *testing.T) {
+ cases := []struct {
+ base, format string
+ width int
+ want string
+ }{
+ {"uuid1", "webp", 0, "uuid1.webp"},
+ {"uuid1", "webp", 800, "uuid1_800w.webp"},
+ {"uuid1", "jpeg", 0, "uuid1.jpg"},
+ {"uuid1", "jpeg", 400, "uuid1_400w.jpg"},
+ }
+ for _, tc := range cases {
+ got := cacheKey(tc.base, tc.width, tc.format)
+ if got != tc.want {
+ t.Errorf("cacheKey(%q, %d, %q) = %q, want %q", tc.base, tc.width, tc.format, got, tc.want)
+ }
+ }
+}
diff --git a/internal/media/process.go b/internal/media/process.go
new file mode 100644
index 0000000..b295380
--- /dev/null
+++ b/internal/media/process.go
@@ -0,0 +1,82 @@
+package media
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image/png"
+
+ "github.com/davidbyttow/govips/v2/vips"
+ thumbhash "go.n16f.net/thumbhash"
+)
+
+// ImageInfo returns the pixel dimensions and a base64-encoded thumbhash for
+// the image at path. The thumbhash is generated from a ≤100px thumbnail so
+// it is fast regardless of the original size.
+func ImageInfo(path string) (width, height int, thumbhashB64 string, err error) {
+ img, err := vips.NewImageFromFile(path)
+ if err != nil {
+ return 0, 0, "", fmt.Errorf("load %s: %w", path, err)
+ }
+ defer img.Close()
+
+ width = img.Width()
+ height = img.Height()
+
+ const maxThumbDim = 100
+ if width > maxThumbDim || height > maxThumbDim {
+ scale := float64(maxThumbDim) / float64(max(width, height))
+ if err := img.Resize(scale, vips.KernelLanczos3); err != nil {
+ return width, height, "", fmt.Errorf("resize for thumbhash: %w", err)
+ }
+ }
+
+ pngBytes, _, err := img.ExportPng(vips.NewPngExportParams())
+ if err != nil {
+ return width, height, "", fmt.Errorf("export png for thumbhash: %w", err)
+ }
+
+ goImg, err := png.Decode(bytes.NewReader(pngBytes))
+ if err != nil {
+ return width, height, "", fmt.Errorf("decode png for thumbhash: %w", err)
+ }
+
+ hash := thumbhash.EncodeImage(goImg)
+ return width, height, base64.StdEncoding.EncodeToString(hash), nil
+}
+
+// ConvertAndResize loads the image at src, optionally resizes it to width pixels
+// wide (maintaining aspect ratio), and exports it in the requested format.
+//
+// width <= 0 means no resize. format must be "webp" or "jpeg".
+func ConvertAndResize(src string, width int, format string) ([]byte, error) {
+ img, err := vips.NewImageFromFile(src)
+ if err != nil {
+ return nil, fmt.Errorf("load image %s: %w", src, err)
+ }
+ defer img.Close()
+
+ if width > 0 && width < img.Width() {
+ scale := float64(width) / float64(img.Width())
+ if err := img.Resize(scale, vips.KernelLanczos3); err != nil {
+ return nil, fmt.Errorf("resize: %w", err)
+ }
+ }
+
+ switch format {
+ case "webp":
+ buf, _, err := img.ExportWebp(vips.NewWebpExportParams())
+ if err != nil {
+ return nil, fmt.Errorf("export webp: %w", err)
+ }
+ return buf, nil
+ case "jpeg":
+ buf, _, err := img.ExportJpeg(vips.NewJpegExportParams())
+ if err != nil {
+ return nil, fmt.Errorf("export jpeg: %w", err)
+ }
+ return buf, nil
+ default:
+ return nil, fmt.Errorf("unsupported format %q: must be webp or jpeg", format)
+ }
+}
diff --git a/internal/media/process_test.go b/internal/media/process_test.go
new file mode 100644
index 0000000..da9d4a8
--- /dev/null
+++ b/internal/media/process_test.go
@@ -0,0 +1,78 @@
+package media
+
+import (
+ "image"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/davidbyttow/govips/v2/vips"
+)
+
+func TestMain(m *testing.M) {
+ vips.Startup(nil)
+ code := m.Run()
+ vips.Shutdown()
+ os.Exit(code)
+}
+
+// testPNG writes a 100×60 PNG to a temp file and returns its path.
+func testPNG(t *testing.T) string {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 100, 60))
+ path := filepath.Join(t.TempDir(), "src.png")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("create test png: %v", err)
+ }
+ if err := png.Encode(f, img); err != nil {
+ t.Fatalf("encode test png: %v", err)
+ }
+ f.Close()
+ return path
+}
+
+func TestConvertToWebP(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 0, "webp")
+ if err != nil {
+ t.Fatalf("ConvertAndResize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestConvertToJPEG(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 0, "jpeg")
+ if err != nil {
+ t.Fatalf("ConvertAndResize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestResizeAndConvert(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 50, "webp")
+ if err != nil {
+ t.Fatalf("ConvertAndResize resize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestConvertUnsupportedFormat(t *testing.T) {
+ _, err := ConvertAndResize(testPNG(t), 0, "avif")
+ if err == nil {
+ t.Fatal("expected error for unsupported format")
+ }
+}
+
+func TestConvertMissingFile(t *testing.T) {
+ _, err := ConvertAndResize("/nonexistent/file.png", 0, "webp")
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
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, " ")
+}
diff --git a/internal/server/fileserver.go b/internal/server/fileserver.go
new file mode 100644
index 0000000..d231519
--- /dev/null
+++ b/internal/server/fileserver.go
@@ -0,0 +1,111 @@
+package server
+
+import (
+ "io/fs"
+ "mime"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// AllowedMIMEs maps file extensions (including dot) to Content-Type values.
+// A nil map means any extension is allowed; MIME type is detected automatically.
+type AllowedMIMEs map[string]string
+
+// FileHandler returns a Gin HandlerFunc that serves files from the given root directory.
+// The wildcard param name is *filepath.
+func FileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ filepath := c.Param("filepath")
+ serveFile(c, root, filepath, allowed)
+ }
+}
+
+// PublicFileHandler returns a Gin HandlerFunc that serves files using the request URL path.
+// Used for NoRoute fallback to serve public static files.
+func PublicFileHandler(root string, allowed AllowedMIMEs) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ filepath := c.Request.URL.Path
+ serveFile(c, root, filepath, allowed)
+ }
+}
+
+func serveFile(c *gin.Context, root, requestPath string, allowed AllowedMIMEs) {
+ // Clean the path to prevent directory traversal
+ clean := path.Clean(requestPath)
+ if strings.Contains(clean, "..") {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Remove leading slash for filepath.Join
+ clean = strings.TrimPrefix(clean, "/")
+
+ // Map empty path or directory to index.html (clean URLs)
+ if clean == "" || clean == "." {
+ clean = "index.html"
+ }
+
+ fullPath := filepath.Join(root, clean)
+
+ // Check file existence; if directory, try index.html inside it
+ info, err := os.Stat(fullPath)
+ if err == nil && info.IsDir() {
+ fullPath = filepath.Join(fullPath, "index.html")
+ info, err = os.Stat(fullPath)
+ }
+ if err != nil {
+ // Try appending .html for clean URLs (e.g. /about -> /about.html)
+ htmlPath := fullPath + ".html"
+ if info2, err2 := os.Stat(htmlPath); err2 == nil && !info2.IsDir() {
+ fullPath = htmlPath
+ info = info2
+ err = nil
+ }
+ }
+ if err != nil || info.IsDir() {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ // Determine MIME type
+ ext := filepath.Ext(fullPath)
+ var mimeType string
+ if allowed != nil {
+ var ok bool
+ mimeType, ok = allowed[ext]
+ if !ok {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ } else {
+ mimeType = mime.TypeByExtension(ext)
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+ }
+
+ // Serve the file with proper MIME type
+ c.Header("Content-Type", mimeType)
+ c.File(fullPath)
+}
+
+// EmbedHandler serves files from an embedded (or any) fs.FS.
+// root is the subdirectory within fsys to serve from.
+// The gin wildcard param must be named *filepath.
+func EmbedHandler(fsys fs.FS, root string) gin.HandlerFunc {
+ sub, err := fs.Sub(fsys, root)
+ if err != nil {
+ panic("EmbedHandler: " + err.Error())
+ }
+ fileServer := http.FileServer(http.FS(sub))
+ return func(c *gin.Context) {
+ req := c.Request.Clone(c.Request.Context())
+ req.URL.Path = c.Param("filepath")
+ fileServer.ServeHTTP(c.Writer, req)
+ }
+}
diff --git a/internal/server/fileserver_test.go b/internal/server/fileserver_test.go
new file mode 100644
index 0000000..a6ef926
--- /dev/null
+++ b/internal/server/fileserver_test.go
@@ -0,0 +1,44 @@
+package server_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "testing/fstest"
+
+ "github.com/gin-gonic/gin"
+ "iblog/internal/server"
+)
+
+func init() { gin.SetMode(gin.TestMode) }
+
+func TestEmbedHandler_ServesFile(t *testing.T) {
+ fsys := fstest.MapFS{
+ "root/hello.js": {Data: []byte(`console.log("hi")`)},
+ }
+ r := gin.New()
+ r.GET("/assets/*filepath", server.EmbedHandler(fsys, "root"))
+
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/assets/hello.js", nil))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if got := w.Body.String(); got != `console.log("hi")` {
+ t.Fatalf("unexpected body: %q", got)
+ }
+}
+
+func TestEmbedHandler_NotFound(t *testing.T) {
+ fsys := fstest.MapFS{}
+ r := gin.New()
+ r.GET("/assets/*filepath", server.EmbedHandler(fsys, "root"))
+
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest("GET", "/assets/missing.js", nil))
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", w.Code)
+ }
+}
diff --git a/internal/server/frontpage.go b/internal/server/frontpage.go
new file mode 100644
index 0000000..faca83c
--- /dev/null
+++ b/internal/server/frontpage.go
@@ -0,0 +1,87 @@
+package server
+
+import (
+ "net/http"
+ "strings"
+
+ "iblog/internal/db"
+
+ "github.com/gin-gonic/gin"
+)
+
+type FrontpageHandler struct {
+ DB *db.DB
+}
+
+type frontpageData struct {
+ Posts []db.PostRecord
+ Query string
+ ActiveTag string
+ SiteTitle string
+ SiteDescription string
+}
+
+func NewFrontpageHandler(database *db.DB) *FrontpageHandler {
+ return &FrontpageHandler{DB: database}
+}
+
+func (h *FrontpageHandler) Serve(c *gin.Context) {
+ query := strings.TrimSpace(c.Query("q"))
+ tag := strings.TrimSpace(c.Query("tag"))
+
+ title, _ := h.DB.GetSetting("site_title")
+ desc, _ := h.DB.GetSetting("site_description")
+
+ data := frontpageData{
+ Query: query,
+ ActiveTag: tag,
+ SiteTitle: title,
+ SiteDescription: desc,
+ }
+
+ if query != "" {
+ results, err := h.DB.Search(query)
+ if err == nil {
+ data.Posts = searchResultsToPosts(results, h.DB)
+ }
+ } else {
+ posts, err := h.DB.ListPosts(false) // exclude drafts
+ if err == nil {
+ if tag != "" {
+ posts = filterByTag(posts, tag)
+ }
+ data.Posts = posts
+ }
+ }
+
+ c.HTML(http.StatusOK, "index.html", data)
+}
+
+func filterByTag(posts []db.PostRecord, tag string) []db.PostRecord {
+ var filtered []db.PostRecord
+ for _, p := range posts {
+ for _, t := range p.Tags {
+ if strings.EqualFold(t, tag) {
+ filtered = append(filtered, p)
+ break
+ }
+ }
+ }
+ return filtered
+}
+
+func searchResultsToPosts(results []db.SearchResult, database *db.DB) []db.PostRecord {
+ var posts []db.PostRecord
+ for _, r := range results {
+ slug := strings.TrimPrefix(r.Path, "/")
+ if slug == r.Path {
+ continue // not a post
+ }
+ post, err := database.GetPostBySlug(slug)
+ if err != nil || post.Draft {
+ continue
+ }
+ posts = append(posts, *post)
+ }
+ return posts
+}
diff --git a/internal/server/posthandler.go b/internal/server/posthandler.go
new file mode 100644
index 0000000..f3a885c
--- /dev/null
+++ b/internal/server/posthandler.go
@@ -0,0 +1,190 @@
+package server
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "iblog/internal/builder"
+ "iblog/internal/db"
+
+ "github.com/gin-gonic/gin"
+)
+
+type PostHandler struct {
+ DB *db.DB
+ Templates *template.Template
+ PublicDir string
+}
+
+func NewPostHandler(database *db.DB, tmpl *template.Template, publicDir string) *PostHandler {
+ return &PostHandler{
+ DB: database,
+ Templates: tmpl,
+ PublicDir: publicDir,
+ }
+}
+
+// Serve dispatches to ServeHTML or ServeJSON based on the slug extension.
+func (h *PostHandler) Serve(c *gin.Context) {
+ slug := c.Param("slug")
+ if strings.HasSuffix(slug, ".json") {
+ h.ServeJSON(c)
+ return
+ }
+ h.ServeHTML(c)
+}
+
+// ServeHTML serves the HTML version of a post.
+func (h *PostHandler) ServeHTML(c *gin.Context) {
+ slug := c.Param("slug")
+ slug = strings.TrimSuffix(slug, ".html")
+
+ post, err := h.DB.GetPostBySlug(slug)
+ if err != nil {
+ if toSlug, rerr := h.DB.GetRedirect(slug); rerr == nil {
+ c.Redirect(http.StatusMovedPermanently, "/"+toSlug)
+ return
+ }
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ cacheFile := filepath.Join(h.PublicDir, slug+".html")
+
+ // Check cache freshness
+ if isCacheFresh(cacheFile, post.UpdatedAt) {
+ if f, err := os.Open(cacheFile); err == nil {
+ defer f.Close()
+ info, _ := os.Stat(cacheFile)
+ c.Header("Content-Type", "text/html; charset=utf-8")
+ http.ServeContent(c.Writer, c.Request, slug+".html", info.ModTime(), f)
+ return
+ }
+ }
+
+ // Render post
+ html, err := h.renderPostHTML(post)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "render post %s: %v\n", slug, err)
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ // Atomic write: write to temp file, then rename
+ if err := os.MkdirAll(h.PublicDir, 0755); err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ tmpFile := cacheFile + ".tmp"
+ if err := os.WriteFile(tmpFile, []byte(html), 0644); err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+ if err := os.Rename(tmpFile, cacheFile); err != nil {
+ os.Remove(tmpFile)
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ // Serve rendered HTML
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
+}
+
+// ServeJSON handles GET /posts/:slug.json and serves the JSON version of a post.
+func (h *PostHandler) ServeJSON(c *gin.Context) {
+ slug := c.Param("slug")
+ slug = strings.TrimSuffix(slug, ".json")
+
+ post, err := h.DB.GetPostBySlug(slug)
+ if err != nil {
+ if toSlug, rerr := h.DB.GetRedirect(slug); rerr == nil {
+ c.Redirect(http.StatusMovedPermanently, "/"+toSlug+".json")
+ return
+ }
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+
+ cacheFile := filepath.Join(h.PublicDir, slug+".json")
+
+ // Check cache freshness
+ if isCacheFresh(cacheFile, post.UpdatedAt) {
+ if f, err := os.Open(cacheFile); err == nil {
+ defer f.Close()
+ info, _ := os.Stat(cacheFile)
+ c.Header("Content-Type", "application/json")
+ http.ServeContent(c.Writer, c.Request, slug+".json", info.ModTime(), f)
+ return
+ }
+ }
+
+ // Generate JSON representation
+ jsonData := map[string]interface{}{
+ "slug": post.Slug,
+ "title": post.Title,
+ "date": post.Date,
+ "tags": post.Tags,
+ "blocks": json.RawMessage(post.Blocks),
+ }
+ jsonBytes, _ := json.MarshalIndent(jsonData, "", " ")
+
+ // Atomic write
+ if err := os.MkdirAll(h.PublicDir, 0755); err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ tmpFile := cacheFile + ".tmp"
+ if err := os.WriteFile(tmpFile, jsonBytes, 0644); err != nil {
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+ if err := os.Rename(tmpFile, cacheFile); err != nil {
+ os.Remove(tmpFile)
+ c.AbortWithStatus(http.StatusInternalServerError)
+ return
+ }
+
+ // Serve JSON
+ c.Header("Content-Type", "application/json")
+ c.Data(http.StatusOK, "application/json", jsonBytes)
+}
+
+// renderPostHTML renders a post to HTML using the base template.
+func (h *PostHandler) renderPostHTML(post *db.PostRecord) (string, error) {
+ htmlBody, scripts, err := builder.RenderEditorJS(post.Blocks)
+ if err != nil {
+ return "", fmt.Errorf("render editorjs: %w", err)
+ }
+
+ pageData := builder.PageData{
+ Title: post.Title,
+ Content: template.HTML(htmlBody),
+ ComponentScripts: scripts,
+ Date: post.Date,
+ Tags: post.Tags,
+ Path: "/" + post.Slug,
+ }
+
+ var buf strings.Builder
+ if err := h.Templates.ExecuteTemplate(&buf, "post.html", pageData); err != nil {
+ return "", fmt.Errorf("template: %w", err)
+ }
+ return buf.String(), nil
+}
+
+// isCacheFresh checks if the cache file is newer than the post's updated_at timestamp.
+func isCacheFresh(cacheFile string, updatedAtMicros int64) bool {
+ info, err := os.Stat(cacheFile)
+ if err != nil {
+ return false
+ }
+ cacheModTime := info.ModTime().UnixMicro()
+ return cacheModTime >= updatedAtMicros
+}
--
cgit v1.3