add rate limiting, CSRF, newsletter, auto-checker, /uses and /projects pages

This commit is contained in:
Blake Ridgway
2026-03-11 14:12:52 -05:00
parent 261745a5b7
commit 58831e2429
17 changed files with 913 additions and 19 deletions

View File

@@ -12,6 +12,7 @@ import (
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/newsletter"
"ridgwaysystems.org/website/internal/status"
)
@@ -63,6 +64,13 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
case path == "/admin/uploads":
h.requireAuth(h.adminUploads)(w, r)
case path == "/admin/newsletter":
if r.Method == http.MethodPost {
h.requireAuth(h.adminNewsletterDelete)(w, r)
} else {
h.requireAuth(h.adminNewsletter)(w, r)
}
default:
h.renderErr(w, http.StatusNotFound, "Admin page not found.")
}
@@ -374,6 +382,40 @@ func (h *Handler) adminUploads(w http.ResponseWriter, r *http.Request) {
h.render(w, "admin-uploads", uploadsData{Files: files, Flash: r.URL.Query().Get("flash")})
}
// --- Newsletter admin ---
type adminNewsletterData struct {
Subscribers []newsletter.Subscriber
Flash string
Count int
}
func (h *Handler) adminNewsletter(w http.ResponseWriter, r *http.Request) {
subs, err := h.news.All()
if err != nil {
h.renderErr(w, http.StatusInternalServerError, "Could not load subscribers.")
return
}
h.render(w, "admin-newsletter", adminNewsletterData{
Subscribers: subs,
Count: len(subs),
Flash: r.URL.Query().Get("flash"),
})
}
func (h *Handler) adminNewsletterDelete(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
email := r.FormValue("email")
if err := h.news.Remove(email); err != nil {
h.renderErr(w, http.StatusInternalServerError, "Remove failed: "+err.Error())
return
}
http.Redirect(w, r, "/admin/newsletter?flash=Removed", http.StatusSeeOther)
}
// sanitizeSlug ensures a slug is filesystem-safe.
func sanitizeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))

37
internal/handler/csrf.go Normal file
View File

@@ -0,0 +1,37 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
// csrfToken returns an HMAC token valid for the current and previous hour.
// Reuses the session secret so no additional secret is required.
func csrfToken() string {
return csrfTokenForTime(time.Now().UTC())
}
func csrfTokenForTime(t time.Time) string {
bucket := t.Truncate(time.Hour).Unix()
mac := hmac.New(sha256.New, sessionSecret())
mac.Write([]byte(fmt.Sprintf("csrf:%d", bucket)))
return hex.EncodeToString(mac.Sum(nil))
}
// csrfValid returns true if token matches the current or previous hour's token.
func csrfValid(token string) bool {
if token == "" {
return false
}
now := time.Now().UTC()
for _, t := range []time.Time{now, now.Add(-time.Hour)} {
expected := csrfTokenForTime(t)
if hmac.Equal([]byte(token), []byte(expected)) {
return true
}
}
return false
}

View File

@@ -8,28 +8,35 @@ import (
"net/http"
"os"
"path/filepath"
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/newsletter"
"ridgwaysystems.org/website/internal/ratelimit"
)
// Handler holds shared dependencies for all HTTP handlers.
type Handler struct {
store *blog.Store
news *newsletter.Store
dataDir string
templates map[string]*template.Template
siteURL string
contactEmail string
devMode bool
postLimit *ratelimit.Limiter // rate-limits contact + newsletter POSTs
}
// New creates a Handler. dataDir is the path to the data/ directory.
func New(store *blog.Store, dataDir string) *Handler {
func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler {
h := &Handler{
store: store,
news: news,
dataDir: dataDir,
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"),
devMode: os.Getenv("DEV") == "1",
postLimit: ratelimit.New(10*time.Minute, 5),
}
if !h.devMode {
h.templates = mustLoadTemplates()
@@ -74,12 +81,15 @@ func mustLoadTemplates() map[string]*template.Template {
{"about", "templates/about.html"},
{"hire", "templates/hire.html"},
{"resume", "templates/resume.html"},
{"uses", "templates/uses.html"},
{"projects", "templates/projects.html"},
{"error", "templates/error.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"},
{"admin-uploads", "templates/admin/uploads.html"},
{"admin-newsletter", "templates/admin/newsletter.html"},
}
for _, p := range pages {

View File

@@ -182,31 +182,57 @@ func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) {
h.render(w, "resume", nil)
}
func (h *Handler) Uses(w http.ResponseWriter, r *http.Request) {
h.render(w, "uses", nil)
}
func (h *Handler) Projects(w http.ResponseWriter, r *http.Request) {
h.render(w, "projects", nil)
}
// --- Hire / Contact ---
type hireData struct {
Name string
Email string
Company string
Message string
Error string
Success bool
Name string
Email string
Company string
Message string
Error string
Success bool
CSRFToken string
}
func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) {
h.render(w, "hire", hireData{})
h.render(w, "hire", hireData{CSRFToken: csrfToken()})
}
func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) {
// Rate limit
if !h.postLimit.Allow(r.RemoteAddr) {
h.renderErr(w, http.StatusTooManyRequests, "Too many requests. Please try again later.")
return
}
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
// Honeypot: bots fill hidden fields, humans don't
if r.FormValue("website") != "" {
// Silently succeed to not reveal the check
h.render(w, "hire", hireData{Success: true})
return
}
// CSRF
if !csrfValid(r.FormValue("csrf_token")) {
h.renderErr(w, http.StatusForbidden, "Invalid or expired form token. Please reload the page and try again.")
return
}
d := hireData{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Company: strings.TrimSpace(r.FormValue("company")),
Message: strings.TrimSpace(r.FormValue("message")),
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Company: strings.TrimSpace(r.FormValue("company")),
Message: strings.TrimSpace(r.FormValue("message")),
CSRFToken: csrfToken(),
}
if d.Name == "" || d.Email == "" || d.Message == "" {
d.Error = "Name, email, and message are required."
@@ -234,6 +260,44 @@ func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) {
h.render(w, "hire", d)
}
// --- Newsletter ---
type newsletterData struct {
Error string
Success bool
}
func (h *Handler) NewsletterPost(w http.ResponseWriter, r *http.Request) {
// Rate limit
if !h.postLimit.Allow(r.RemoteAddr) {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
// Honeypot
if r.FormValue("url") != "" {
http.Redirect(w, r, r.Referer(), http.StatusSeeOther)
return
}
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
if email == "" || !strings.Contains(email, "@") {
http.Redirect(w, r, r.Referer()+"?subscribe=invalid", http.StatusSeeOther)
return
}
if _, err := h.news.Add(email); err != nil {
log.Printf("newsletter add %s: %v", email, err)
}
// Redirect back with success param regardless (don't confirm existence)
ref := r.Referer()
if ref == "" {
ref = "/"
}
http.Redirect(w, r, ref+"?subscribe=ok", http.StatusSeeOther)
}
// --- Sitemap ---
type urlset struct {
@@ -256,7 +320,9 @@ func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) {
{Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"},
{Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"},
{Loc: h.siteURL + "/hire", Freq: "monthly", Prio: "0.9"},
{Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.7"},
{Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.8"},
{Loc: h.siteURL + "/projects", Freq: "monthly", Prio: "0.7"},
{Loc: h.siteURL + "/uses", Freq: "monthly", Prio: "0.6"},
{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"},