first commit

This commit is contained in:
Blake Ridgway
2026-03-07 21:16:51 -06:00
parent 21bd542469
commit 03fcf37beb
33 changed files with 3532 additions and 0 deletions

352
internal/handler/admin.go Normal file
View File

@@ -0,0 +1,352 @@
package handler
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/status"
)
// AdminRouter dispatches /admin/* paths.
func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/admin/login":
if r.Method == http.MethodPost {
h.adminLoginPost(w, r)
} else {
h.adminLoginGet(w, r)
}
case path == "/admin/logout":
h.requireAuth(h.adminLogout)(w, r)
case path == "/admin/new":
if r.Method == http.MethodPost {
h.requireAuth(h.adminNewPost)(w, r)
} else {
h.requireAuth(h.adminNewGet)(w, r)
}
case strings.HasPrefix(path, "/admin/edit/"):
if r.Method == http.MethodPost {
h.requireAuth(h.adminEditPost)(w, r)
} else {
h.requireAuth(h.adminEditGet)(w, r)
}
case strings.HasPrefix(path, "/admin/delete/"):
h.requireAuth(h.adminDeletePost)(w, r)
case path == "/admin/status":
if r.Method == http.MethodPost {
h.requireAuth(h.adminStatusPost)(w, r)
} else {
h.requireAuth(h.adminStatusGet)(w, r)
}
case path == "/admin/preview":
h.requireAuth(h.adminPreview)(w, r)
case path == "/admin/upload":
h.requireAuth(h.adminUpload)(w, r)
default:
h.renderErr(w, http.StatusNotFound, "Admin page not found.")
}
}
// AdminDashboard handles GET /admin.
func (h *Handler) AdminDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/admin" && r.URL.Path != "/admin/" {
// Fall through to router
h.AdminRouter(w, r)
return
}
h.requireAuth(h.adminDashboard)(w, r)
}
type dashboardData struct {
Posts []*blog.Post
Flash string
}
func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) {
flash := r.URL.Query().Get("flash")
posts, err := h.store.All(true)
if err != nil {
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
return
}
h.render(w, "admin-dashboard", dashboardData{Posts: posts, Flash: flash})
}
// --- Login ---
func (h *Handler) adminLoginGet(w http.ResponseWriter, r *http.Request) {
if isAuthenticated(r) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
h.render(w, "admin-login", map[string]string{"Error": ""})
}
func (h *Handler) adminLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
password := r.FormValue("password")
if !checkPassword(password) {
h.render(w, "admin-login", map[string]string{"Error": "Invalid password."})
return
}
setSession(w)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (h *Handler) adminLogout(w http.ResponseWriter, r *http.Request) {
clearSession(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// --- New post ---
type editorData struct {
Post *blog.Post
Raw string
IsNew bool
Error string
}
func (h *Handler) adminNewGet(w http.ResponseWriter, r *http.Request) {
now := time.Now().Format("2006-01-02")
raw := fmt.Sprintf("---\ntitle: New Post\ndate: %s\ntags: []\ndraft: true\ndescription: \"\"\n---\n\n", now)
h.render(w, "admin-editor", editorData{Raw: raw, IsNew: true})
}
func (h *Handler) adminNewPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
slug := sanitizeSlug(r.FormValue("slug"))
content := r.FormValue("content")
if slug == "" {
h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Slug is required."})
return
}
if err := h.store.Save(slug, content); err != nil {
h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Failed to save: " + err.Error()})
return
}
http.Redirect(w, r, "/admin?flash=Post+created", http.StatusSeeOther)
}
// --- Edit post ---
func (h *Handler) adminEditGet(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/")
raw, err := h.store.RawContent(slug)
if err != nil {
h.renderErr(w, http.StatusNotFound, "Post not found.")
return
}
post, _ := h.store.Get(slug)
h.render(w, "admin-editor", editorData{Post: post, Raw: raw, IsNew: false})
}
func (h *Handler) adminEditPost(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/")
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
content := r.FormValue("content")
if err := h.store.Save(slug, content); err != nil {
h.render(w, "admin-editor", editorData{Raw: content, Error: "Failed to save: " + err.Error()})
return
}
http.Redirect(w, r, "/admin?flash=Post+saved", http.StatusSeeOther)
}
// --- Delete post ---
func (h *Handler) adminDeletePost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
return
}
slug := strings.TrimPrefix(r.URL.Path, "/admin/delete/")
if err := h.store.Delete(slug); err != nil {
h.renderErr(w, http.StatusInternalServerError, "Delete failed: "+err.Error())
return
}
http.Redirect(w, r, "/admin?flash=Post+deleted", http.StatusSeeOther)
}
// --- Status editor ---
type adminStatusData struct {
JSON string
Error string
Flash string
}
func (h *Handler) adminStatusGet(w http.ResponseWriter, r *http.Request) {
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
if err != nil {
h.render(w, "admin-status", adminStatusData{Error: "Could not load status.json: " + err.Error()})
return
}
raw, _ := json.MarshalIndent(p, "", " ")
flash := r.URL.Query().Get("flash")
h.render(w, "admin-status", adminStatusData{JSON: string(raw), Flash: flash})
}
func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
raw := r.FormValue("json")
var p status.Page
if err := json.Unmarshal([]byte(raw), &p); err != nil {
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Invalid JSON: " + err.Error()})
return
}
p.LastChecked = time.Now().UTC()
if err := status.Save(filepath.Join(h.dataDir, "status.json"), &p); err != nil {
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Save failed: " + err.Error()})
return
}
http.Redirect(w, r, "/admin/status?flash=Saved", http.StatusSeeOther)
}
// --- Preview ---
func (h *Handler) adminPreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
content := r.FormValue("content")
html, err := blog.RenderMarkdown(content)
if err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<div class='preview-body'>%s</div>", template.HTML(html))
}
// --- Image upload ---
var allowedImageTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
const maxUploadSize = 8 << 20 // 8 MB
func (h *Handler) adminUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"POST required"}`, http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"file too large (max 8 MB)"}`)
return
}
file, header, err := r.FormFile("image")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"no image field in form"}`)
return
}
defer file.Close()
// Read first 512 bytes to detect MIME type
buf := make([]byte, 512)
n, _ := file.Read(buf)
mimeType := http.DetectContentType(buf[:n])
ext, ok := allowedImageTypes[mimeType]
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"unsupported image type: %s"}`, mimeType)
return
}
// Build a safe filename: sanitize original name + timestamp
base := sanitizeSlug(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
if base == "" {
base = "image"
}
filename := fmt.Sprintf("%s-%d%s", base, time.Now().UnixMilli(), ext)
dest := filepath.Join("static", "uploads", filename)
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":"could not create upload directory"}`)
return
}
out, err := os.Create(dest)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":"could not save file"}`)
return
}
defer out.Close()
// Write the already-read bytes, then the rest
out.Write(buf[:n])
if _, err := io.Copy(out, file); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":"write failed"}`)
return
}
url := "/static/uploads/" + filename
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"url":"%s","markdown":"![image](%s)"}`, url, url)
}
// sanitizeSlug ensures a slug is filesystem-safe.
func sanitizeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else if r == ' ' {
b.WriteRune('-')
}
}
return b.String()
}

124
internal/handler/auth.go Normal file
View File

@@ -0,0 +1,124 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const sessionCookie = "rs_session"
const sessionDuration = 24 * time.Hour
// sessionSecret returns the HMAC signing key from env, with a fallback warning.
func sessionSecret() []byte {
s := os.Getenv("SESSION_SECRET")
if s == "" {
// Insecure fallback for local dev only — set SESSION_SECRET in production
return []byte("change-me-in-production")
}
return []byte(s)
}
// adminPasswordHash returns the bcrypt hash of the admin password from env.
func adminPasswordHash() string {
return os.Getenv("ADMIN_PASSWORD_HASH")
}
// checkPassword verifies a plaintext password against the stored bcrypt hash.
func checkPassword(password string) bool {
hash := adminPasswordHash()
if hash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// signValue creates an HMAC-signed value: base64(payload)|base64(sig).
func signValue(payload string) string {
mac := hmac.New(sha256.New, sessionSecret())
mac.Write([]byte(payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "|" + sig
}
// verifyValue checks the signature and returns the original payload.
func verifyValue(signed string) (string, bool) {
parts := strings.SplitN(signed, "|", 2)
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
expected := signValue(payload)
if !hmac.Equal([]byte(signed), []byte(expected)) {
return "", false
}
return payload, true
}
// setSession writes a signed session cookie.
func setSession(w http.ResponseWriter) {
expiry := time.Now().Add(sessionDuration).Format(time.RFC3339)
value := signValue("admin|" + expiry)
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: value,
Path: "/admin",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionDuration.Seconds()),
})
}
// clearSession deletes the session cookie.
func clearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/admin",
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
// isAuthenticated returns true if the request has a valid session cookie.
func isAuthenticated(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
payload, ok := verifyValue(c.Value)
if !ok {
return false
}
// payload = "admin|<RFC3339 expiry>"
parts := strings.SplitN(payload, "|", 2)
if len(parts) != 2 || parts[0] != "admin" {
return false
}
expiry, err := time.Parse(time.RFC3339, parts[1])
if err != nil {
return false
}
return time.Now().Before(expiry)
}
// requireAuth is middleware that redirects to /admin/login if not authenticated.
func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next(w, r)
}
}

105
internal/handler/handler.go Normal file
View File

@@ -0,0 +1,105 @@
// Package handler contains all HTTP request handlers.
package handler
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"ridgwaysystems.org/website/internal/blog"
)
// Handler holds shared dependencies for all HTTP handlers.
type Handler struct {
store *blog.Store
dataDir string
templates map[string]*template.Template
siteURL string
devMode bool
}
// New creates a Handler. dataDir is the path to the data/ directory.
func New(store *blog.Store, dataDir string) *Handler {
h := &Handler{
store: store,
dataDir: dataDir,
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
devMode: os.Getenv("DEV") == "1",
}
if !h.devMode {
h.templates = mustLoadTemplates()
}
return h
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// tmpl returns the template set for a given name, reloading in dev mode.
func (h *Handler) tmpl(name string) *template.Template {
if h.devMode {
return mustLoadTemplates()[name]
}
return h.templates[name]
}
func mustLoadTemplates() map[string]*template.Template {
m := make(map[string]*template.Template)
funcMap := template.FuncMap{
"formatDate": formatDate,
"joinTags": joinTags,
}
base := "templates/base.html"
pages := []struct {
name string
file string
}{
{"index", "templates/index.html"},
{"blog", "templates/blog.html"},
{"post", "templates/post.html"},
{"infrastructure", "templates/infrastructure.html"},
{"status", "templates/status.html"},
{"about", "templates/about.html"},
{"admin-login", "templates/admin/login.html"},
{"admin-dashboard", "templates/admin/dashboard.html"},
{"admin-editor", "templates/admin/editor.html"},
{"admin-status", "templates/admin/status.html"},
}
for _, p := range pages {
t, err := template.New(filepath.Base(p.file)).Funcs(funcMap).ParseFiles(base, p.file)
if err != nil {
log.Fatalf("template %s: %v", p.name, err)
}
m[p.name] = t
}
return m
}
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
t := h.tmpl(name)
if t == nil {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
log.Printf("render %s: %v", name, err)
}
}
func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
w.Write([]byte("<html><body><h1>" + http.StatusText(code) + "</h1><p>" + msg + "</p><a href='/'>Home</a></body></html>"))
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"strings"
"time"
)
func formatDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2 January 2006")
}
func joinTags(tags []string) string {
return strings.Join(tags, ", ")
}

219
internal/handler/public.go Normal file
View File

@@ -0,0 +1,219 @@
package handler
import (
"encoding/xml"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/feed"
"ridgwaysystems.org/website/internal/status"
)
const postsPerPage = 10
// indexData is passed to the index template.
type indexData struct {
RecentPosts []*blog.Post
}
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
h.renderErr(w, http.StatusNotFound, "Page not found.")
return
}
posts, err := h.store.All(false)
if err != nil {
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
return
}
limit := 5
if len(posts) < limit {
limit = len(posts)
}
h.render(w, "index", indexData{RecentPosts: posts[:limit]})
}
// blogData is passed to the blog list template.
type blogData struct {
Posts []*blog.Post
Tags []string
ActiveTag string
SearchQuery string
Page int
TotalPages int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
func (h *Handler) BlogList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
tag := q.Get("tag")
search := strings.TrimSpace(q.Get("q"))
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
var posts []*blog.Post
var err error
switch {
case search != "":
posts, err = h.store.Search(search)
case tag != "":
posts, err = h.store.ByTag(tag)
default:
posts, err = h.store.All(false)
}
if err != nil {
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
return
}
tags, _ := h.store.AllTags()
// Paginate
total := len(posts)
totalPages := (total + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > total {
end = total
}
h.render(w, "blog", blogData{
Posts: posts[start:end],
Tags: tags,
ActiveTag: tag,
SearchQuery: search,
Page: page,
TotalPages: totalPages,
HasPrev: page > 1,
HasNext: page < totalPages,
PrevPage: page - 1,
NextPage: page + 1,
})
}
func (h *Handler) BlogPost(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.Redirect(w, r, "/blog", http.StatusSeeOther)
return
}
post, err := h.store.Get(slug)
if err != nil {
h.renderErr(w, http.StatusNotFound, "Post not found.")
return
}
// Drafts are visible to authenticated admins only
if post.Draft && !isAuthenticated(r) {
h.renderErr(w, http.StatusNotFound, "Post not found.")
return
}
h.render(w, "post", post)
}
func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
posts, err := h.store.All(false)
if err != nil {
http.Error(w, "feed unavailable", http.StatusInternalServerError)
return
}
rss, err := feed.RSS(h.siteURL, "Ridgway Systems", "A homelab built on OpenBSD.", posts)
if err != nil {
http.Error(w, "feed error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.Write(rss)
}
func (h *Handler) Infrastructure(w http.ResponseWriter, r *http.Request) {
h.render(w, "infrastructure", nil)
}
// statusData is passed to the status template.
type statusData struct {
Page *status.Page
LastChecked string
}
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
if err != nil {
p = &status.Page{LastChecked: time.Now(), Services: []status.Service{}}
}
var lastChecked string
if !p.LastChecked.IsZero() {
lastChecked = p.LastChecked.UTC().Format("2006-01-02 15:04 UTC")
}
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked})
}
func (h *Handler) About(w http.ResponseWriter, r *http.Request) {
h.render(w, "about", nil)
}
// --- Sitemap ---
type urlset struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
URLs []sitemapURL `xml:"url"`
}
type sitemapURL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
Freq string `xml:"changefreq,omitempty"`
Prio string `xml:"priority,omitempty"`
}
func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) {
posts, _ := h.store.All(false)
urls := []sitemapURL{
{Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"},
{Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"},
{Loc: h.siteURL + "/infrastructure", Freq: "monthly", Prio: "0.7"},
{Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"},
{Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"},
}
for _, p := range posts {
u := sitemapURL{
Loc: h.siteURL + "/blog/" + p.Slug,
Freq: "never",
Prio: "0.8",
}
if !p.ParsedDate.IsZero() {
u.LastMod = p.ParsedDate.Format("2006-01-02")
}
urls = append(urls, u)
}
out, err := xml.MarshalIndent(urlset{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}, "", " ")
if err != nil {
http.Error(w, "sitemap error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Write([]byte(xml.Header))
w.Write(out)
}