diff options
| author | ivar <i@oiee.no> | 2026-04-07 00:23:24 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-07 00:23:24 +0200 |
| commit | 85920b8c7a2696115d1f77c046f48f6f00d639f1 (patch) | |
| tree | 14ed2043796eadd6ed5b0a95c55e38e48713d638 /internal | |
| download | iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.tar.xz iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.zip | |
Init
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/auth.go | 198 | ||||
| -rw-r--r-- | internal/builder/editorjs.go | 255 | ||||
| -rw-r--r-- | internal/db/db.go | 66 | ||||
| -rw-r--r-- | internal/db/pages.go | 84 | ||||
| -rw-r--r-- | internal/db/posts.go | 248 | ||||
| -rw-r--r-- | internal/db/posts_test.go | 186 | ||||
| -rw-r--r-- | internal/db/search.go | 62 | ||||
| -rw-r--r-- | internal/db/settings.go | 21 | ||||
| -rw-r--r-- | internal/media/handler.go | 221 | ||||
| -rw-r--r-- | internal/media/handler_test.go | 198 | ||||
| -rw-r--r-- | internal/media/process.go | 82 | ||||
| -rw-r--r-- | internal/media/process_test.go | 78 | ||||
| -rw-r--r-- | internal/server/adminserver.go | 664 | ||||
| -rw-r--r-- | internal/server/fileserver.go | 111 | ||||
| -rw-r--r-- | internal/server/fileserver_test.go | 44 | ||||
| -rw-r--r-- | internal/server/frontpage.go | 87 | ||||
| -rw-r--r-- | internal/server/posthandler.go | 190 |
17 files changed, 2795 insertions, 0 deletions
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("<p>%s</p>\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("<h%d>%s</h%d>\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 <figure> 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, "<figure%s>", figAttrs) + fmt.Fprintf(&buf, `<img src="%s" alt="%s" loading="lazy" decoding="async">`, + template.HTMLEscapeString(d.File.URL), + template.HTMLEscapeString(alt), + ) + if d.Caption != "" { + fmt.Fprintf(&buf, "<figcaption>%s</figcaption>", template.HTML(d.Caption)) + } + buf.WriteString("</figure>\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, "<li>%s", template.HTML(item.Content)) + if len(item.Items) > 0 { + renderListItems(buf, item.Items, tag) + } + buf.WriteString("</li>\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("<pre><code>%s</code></pre>\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, "<blockquote>\n<p>%s</p>\n", template.HTML(d.Text)) + if d.Caption != "" { + fmt.Fprintf(&buf, "<cite>%s</cite>\n", template.HTML(d.Caption)) + } + buf.WriteString("</blockquote>\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, '<mark>', '</mark>', '...', 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/<uuid>.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=<pixels> 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: "<base>.webp" / "<base>.jpg" +// - With width: "<base>_<width>w.webp" / "<base>_<width>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 +} |
