add rate limiting, CSRF, newsletter, auto-checker, /uses and /projects pages
This commit is contained in:
@@ -43,3 +43,11 @@ SESSION_SECRET=change-me-use-openssl-rand-hex-32
|
|||||||
# Email address that receives hire form submissions.
|
# Email address that receives hire form submissions.
|
||||||
# Requires a working local MTA (OpenSMTPD) listening on localhost:25.
|
# Requires a working local MTA (OpenSMTPD) listening on localhost:25.
|
||||||
CONTACT_EMAIL=hire@ridgwaysystems.org
|
CONTACT_EMAIL=hire@ridgwaysystems.org
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Service checker
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# How often (in minutes) to HTTP-check services with a check_url in status.json.
|
||||||
|
# Default: 5
|
||||||
|
CHECK_INTERVAL=5
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||||
"github.com/alecthomas/chroma/v2/styles"
|
"github.com/alecthomas/chroma/v2/styles"
|
||||||
|
|
||||||
"ridgwaysystems.org/website/internal/blog"
|
"ridgwaysystems.org/website/internal/blog"
|
||||||
|
"ridgwaysystems.org/website/internal/checker"
|
||||||
"ridgwaysystems.org/website/internal/handler"
|
"ridgwaysystems.org/website/internal/handler"
|
||||||
|
"ridgwaysystems.org/website/internal/newsletter"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -28,11 +33,21 @@ func main() {
|
|||||||
log.Fatal("store:", err)
|
log.Fatal("store:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
h := handler.New(store, dataDir)
|
news, err := newsletter.NewStore(filepath.Join(dataDir, "subscribers.json"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("newsletter store:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := handler.New(store, news, dataDir)
|
||||||
|
|
||||||
|
// Start service auto-checker
|
||||||
|
checkInterval := checkerInterval()
|
||||||
|
checker.Start(dataDir, checkInterval)
|
||||||
|
log.Printf("service checker running every %s", checkInterval)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Static files (includes robots.txt via static/robots.txt)
|
// Static files
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
// robots.txt at root
|
// robots.txt at root
|
||||||
@@ -48,9 +63,12 @@ func main() {
|
|||||||
mux.HandleFunc("GET /infrastructure", h.Infrastructure)
|
mux.HandleFunc("GET /infrastructure", h.Infrastructure)
|
||||||
mux.HandleFunc("GET /status", h.Status)
|
mux.HandleFunc("GET /status", h.Status)
|
||||||
mux.HandleFunc("GET /about", h.About)
|
mux.HandleFunc("GET /about", h.About)
|
||||||
|
mux.HandleFunc("GET /uses", h.Uses)
|
||||||
|
mux.HandleFunc("GET /projects", h.Projects)
|
||||||
mux.HandleFunc("GET /hire", h.Hire)
|
mux.HandleFunc("GET /hire", h.Hire)
|
||||||
mux.HandleFunc("POST /hire", h.HirePost)
|
mux.HandleFunc("POST /hire", h.HirePost)
|
||||||
mux.HandleFunc("GET /resume", h.Resume)
|
mux.HandleFunc("GET /resume", h.Resume)
|
||||||
|
mux.HandleFunc("POST /newsletter", h.NewsletterPost)
|
||||||
mux.HandleFunc("GET /sitemap.xml", h.Sitemap)
|
mux.HandleFunc("GET /sitemap.xml", h.Sitemap)
|
||||||
|
|
||||||
// Admin routes (auth handled per-handler)
|
// Admin routes (auth handled per-handler)
|
||||||
@@ -66,19 +84,26 @@ func main() {
|
|||||||
log.Fatal(http.ListenAndServe(":"+port, srv))
|
log.Fatal(http.ListenAndServe(":"+port, srv))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkerInterval returns the status check interval from CHECK_INTERVAL env (minutes).
|
||||||
|
func checkerInterval() time.Duration {
|
||||||
|
if s := os.Getenv("CHECK_INTERVAL"); s != "" {
|
||||||
|
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||||
|
return time.Duration(n) * time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 5 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
// generateSyntaxCSS writes a chroma CSS file with light and dark themes.
|
// generateSyntaxCSS writes a chroma CSS file with light and dark themes.
|
||||||
func generateSyntaxCSS(path string) error {
|
func generateSyntaxCSS(path string) error {
|
||||||
formatter := chromahtml.New(chromahtml.WithClasses(true))
|
formatter := chromahtml.New(chromahtml.WithClasses(true))
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
// Light theme (github)
|
|
||||||
buf.WriteString("/* Auto-generated by chroma at server startup. Do not edit. */\n\n")
|
buf.WriteString("/* Auto-generated by chroma at server startup. Do not edit. */\n\n")
|
||||||
if err := formatter.WriteCSS(&buf, styles.Get("github")); err != nil {
|
if err := formatter.WriteCSS(&buf, styles.Get("github")); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dark theme wrapped in prefers-color-scheme media query
|
|
||||||
buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
|
buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
|
||||||
var dark bytes.Buffer
|
var dark bytes.Buffer
|
||||||
if err := formatter.WriteCSS(&dark, styles.Get("github-dark")); err != nil {
|
if err := formatter.WriteCSS(&dark, styles.Get("github-dark")); err != nil {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
{
|
{
|
||||||
"name": "Web (httpd)",
|
"name": "Web (httpd)",
|
||||||
"description": "ridgwaysystems.org",
|
"description": "ridgwaysystems.org",
|
||||||
|
"check_url": "https://ridgwaysystems.org",
|
||||||
"status": "up"
|
"status": "up"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Gitea",
|
"name": "Gitea",
|
||||||
"description": "git.ridgwaysystems.org",
|
"description": "git.ridgwaysystems.org",
|
||||||
"url": "https://git.ridgwaysystems.org",
|
"url": "https://git.ridgwaysystems.org",
|
||||||
|
"check_url": "https://git.ridgwaysystems.org",
|
||||||
"status": "up"
|
"status": "up"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
88
internal/checker/checker.go
Normal file
88
internal/checker/checker.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// Package checker periodically HTTP-checks services and updates status.json.
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"ridgwaysystems.org/website/internal/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start launches the background checker. It checks services every interval
|
||||||
|
// and updates status.json in dataDir. Services without a CheckURL are skipped.
|
||||||
|
func Start(dataDir string, interval time.Duration) {
|
||||||
|
go run(dataDir, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(dataDir string, interval time.Duration) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 3 {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := filepath.Join(dataDir, "status.json")
|
||||||
|
|
||||||
|
for {
|
||||||
|
check(client, path)
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func check(client *http.Client, path string) {
|
||||||
|
page, err := status.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("checker: load %s: %v", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for i, svc := range page.Services {
|
||||||
|
if svc.CheckURL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prev := svc.Status
|
||||||
|
newStatus := probe(client, svc.CheckURL)
|
||||||
|
if newStatus != prev {
|
||||||
|
log.Printf("checker: %s %s → %s", svc.Name, prev, newStatus)
|
||||||
|
page.Services[i].Status = newStatus
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
if err := status.Save(path, page); err != nil {
|
||||||
|
log.Printf("checker: save %s: %v", path, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Still update last_checked timestamp so the status page shows freshness
|
||||||
|
page.LastChecked = time.Now().UTC()
|
||||||
|
if err := status.Save(path, page); err != nil {
|
||||||
|
log.Printf("checker: save %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func probe(client *http.Client, url string) string {
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode < 400:
|
||||||
|
return "up"
|
||||||
|
case resp.StatusCode >= 500:
|
||||||
|
return "down"
|
||||||
|
default:
|
||||||
|
// 4xx could mean the service is up but the URL is wrong; treat as degraded
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ridgwaysystems.org/website/internal/blog"
|
"ridgwaysystems.org/website/internal/blog"
|
||||||
|
"ridgwaysystems.org/website/internal/newsletter"
|
||||||
"ridgwaysystems.org/website/internal/status"
|
"ridgwaysystems.org/website/internal/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,6 +64,13 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
|
|||||||
case path == "/admin/uploads":
|
case path == "/admin/uploads":
|
||||||
h.requireAuth(h.adminUploads)(w, r)
|
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:
|
default:
|
||||||
h.renderErr(w, http.StatusNotFound, "Admin page not found.")
|
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")})
|
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.
|
// sanitizeSlug ensures a slug is filesystem-safe.
|
||||||
func sanitizeSlug(s string) string {
|
func sanitizeSlug(s string) string {
|
||||||
s = strings.ToLower(strings.TrimSpace(s))
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
|||||||
37
internal/handler/csrf.go
Normal file
37
internal/handler/csrf.go
Normal 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
|
||||||
|
}
|
||||||
@@ -8,28 +8,35 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"ridgwaysystems.org/website/internal/blog"
|
"ridgwaysystems.org/website/internal/blog"
|
||||||
|
"ridgwaysystems.org/website/internal/newsletter"
|
||||||
|
"ridgwaysystems.org/website/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds shared dependencies for all HTTP handlers.
|
// Handler holds shared dependencies for all HTTP handlers.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *blog.Store
|
store *blog.Store
|
||||||
|
news *newsletter.Store
|
||||||
dataDir string
|
dataDir string
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
siteURL string
|
siteURL string
|
||||||
contactEmail string
|
contactEmail string
|
||||||
devMode bool
|
devMode bool
|
||||||
|
postLimit *ratelimit.Limiter // rate-limits contact + newsletter POSTs
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Handler. dataDir is the path to the data/ directory.
|
// 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{
|
h := &Handler{
|
||||||
store: store,
|
store: store,
|
||||||
|
news: news,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
|
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
|
||||||
contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"),
|
contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"),
|
||||||
devMode: os.Getenv("DEV") == "1",
|
devMode: os.Getenv("DEV") == "1",
|
||||||
|
postLimit: ratelimit.New(10*time.Minute, 5),
|
||||||
}
|
}
|
||||||
if !h.devMode {
|
if !h.devMode {
|
||||||
h.templates = mustLoadTemplates()
|
h.templates = mustLoadTemplates()
|
||||||
@@ -74,12 +81,15 @@ func mustLoadTemplates() map[string]*template.Template {
|
|||||||
{"about", "templates/about.html"},
|
{"about", "templates/about.html"},
|
||||||
{"hire", "templates/hire.html"},
|
{"hire", "templates/hire.html"},
|
||||||
{"resume", "templates/resume.html"},
|
{"resume", "templates/resume.html"},
|
||||||
|
{"uses", "templates/uses.html"},
|
||||||
|
{"projects", "templates/projects.html"},
|
||||||
{"error", "templates/error.html"},
|
{"error", "templates/error.html"},
|
||||||
{"admin-login", "templates/admin/login.html"},
|
{"admin-login", "templates/admin/login.html"},
|
||||||
{"admin-dashboard", "templates/admin/dashboard.html"},
|
{"admin-dashboard", "templates/admin/dashboard.html"},
|
||||||
{"admin-editor", "templates/admin/editor.html"},
|
{"admin-editor", "templates/admin/editor.html"},
|
||||||
{"admin-status", "templates/admin/status.html"},
|
{"admin-status", "templates/admin/status.html"},
|
||||||
{"admin-uploads", "templates/admin/uploads.html"},
|
{"admin-uploads", "templates/admin/uploads.html"},
|
||||||
|
{"admin-newsletter", "templates/admin/newsletter.html"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range pages {
|
for _, p := range pages {
|
||||||
|
|||||||
@@ -182,31 +182,57 @@ func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.render(w, "resume", nil)
|
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 ---
|
// --- Hire / Contact ---
|
||||||
|
|
||||||
type hireData struct {
|
type hireData struct {
|
||||||
Name string
|
Name string
|
||||||
Email string
|
Email string
|
||||||
Company string
|
Company string
|
||||||
Message string
|
Message string
|
||||||
Error string
|
Error string
|
||||||
Success bool
|
Success bool
|
||||||
|
CSRFToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) {
|
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) {
|
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 {
|
if err := r.ParseForm(); err != nil {
|
||||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||||
return
|
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{
|
d := hireData{
|
||||||
Name: strings.TrimSpace(r.FormValue("name")),
|
Name: strings.TrimSpace(r.FormValue("name")),
|
||||||
Email: strings.TrimSpace(r.FormValue("email")),
|
Email: strings.TrimSpace(r.FormValue("email")),
|
||||||
Company: strings.TrimSpace(r.FormValue("company")),
|
Company: strings.TrimSpace(r.FormValue("company")),
|
||||||
Message: strings.TrimSpace(r.FormValue("message")),
|
Message: strings.TrimSpace(r.FormValue("message")),
|
||||||
|
CSRFToken: csrfToken(),
|
||||||
}
|
}
|
||||||
if d.Name == "" || d.Email == "" || d.Message == "" {
|
if d.Name == "" || d.Email == "" || d.Message == "" {
|
||||||
d.Error = "Name, email, and message are required."
|
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)
|
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 ---
|
// --- Sitemap ---
|
||||||
|
|
||||||
type urlset struct {
|
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 + "/", Freq: "weekly", Prio: "1.0"},
|
||||||
{Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"},
|
{Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"},
|
||||||
{Loc: h.siteURL + "/hire", Freq: "monthly", 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 + "/infrastructure", Freq: "monthly", Prio: "0.7"},
|
||||||
{Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"},
|
{Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"},
|
||||||
{Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"},
|
{Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"},
|
||||||
|
|||||||
111
internal/newsletter/newsletter.go
Normal file
111
internal/newsletter/newsletter.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Package newsletter manages email subscriber storage as a flat JSON file.
|
||||||
|
package newsletter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscriber holds a single email subscription.
|
||||||
|
type Subscriber struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store manages subscribers persisted to a JSON file.
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore returns a Store backed by path, creating the file if needed.
|
||||||
|
func NewStore(path string) (*Store, error) {
|
||||||
|
s := &Store{path: path}
|
||||||
|
// Create empty file if it doesn't exist
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
if err := s.save(nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns all current subscribers.
|
||||||
|
func (s *Store) All() ([]Subscriber, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds an email if it isn't already subscribed. Returns false if duplicate.
|
||||||
|
func (s *Store) Add(email string) (bool, error) {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
if email == "" {
|
||||||
|
return false, errors.New("email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
subs, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.Email == email {
|
||||||
|
return false, nil // already subscribed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subs = append(subs, Subscriber{Email: email, CreatedAt: time.Now().UTC()})
|
||||||
|
return true, s.save(subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove unsubscribes an email address.
|
||||||
|
func (s *Store) Remove(email string) error {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
subs, err := s.load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
filtered := subs[:0]
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.Email != email {
|
||||||
|
filtered = append(filtered, sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.save(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) load() ([]Subscriber, error) {
|
||||||
|
raw, err := os.ReadFile(s.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var subs []Subscriber
|
||||||
|
if err := json.Unmarshal(raw, &subs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) save(subs []Subscriber) error {
|
||||||
|
if subs == nil {
|
||||||
|
subs = []Subscriber{}
|
||||||
|
}
|
||||||
|
raw, err := json.MarshalIndent(subs, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.path, raw, 0600)
|
||||||
|
}
|
||||||
91
internal/ratelimit/ratelimit.go
Normal file
91
internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Package ratelimit provides a simple in-memory per-IP sliding-window rate limiter.
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limiter tracks request counts per IP within a sliding window.
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string][]time.Time
|
||||||
|
window time.Duration
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Limiter that allows at most max requests per window per IP.
|
||||||
|
func New(window time.Duration, max int) *Limiter {
|
||||||
|
l := &Limiter{
|
||||||
|
entries: make(map[string][]time.Time),
|
||||||
|
window: window,
|
||||||
|
max: max,
|
||||||
|
}
|
||||||
|
go l.cleanup()
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow returns true if the IP is within its rate limit, recording the attempt.
|
||||||
|
func (l *Limiter) Allow(ip string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-l.window)
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
times := l.entries[ip]
|
||||||
|
recent := times[:0]
|
||||||
|
for _, t := range times {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
recent = append(recent, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recent) >= l.max {
|
||||||
|
l.entries[ip] = recent
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
l.entries[ip] = append(recent, now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware wraps a handler, rejecting over-limit requests with 429.
|
||||||
|
func (l *Limiter) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
ip = r.RemoteAddr
|
||||||
|
}
|
||||||
|
if !l.Allow(ip) {
|
||||||
|
http.Error(w, "Too many requests. Please wait and try again.", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup periodically removes expired entries to prevent unbounded growth.
|
||||||
|
func (l *Limiter) cleanup() {
|
||||||
|
for {
|
||||||
|
time.Sleep(5 * time.Minute)
|
||||||
|
cutoff := time.Now().Add(-l.window)
|
||||||
|
l.mu.Lock()
|
||||||
|
for ip, times := range l.entries {
|
||||||
|
recent := times[:0]
|
||||||
|
for _, t := range times {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
recent = append(recent, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(recent) == 0 {
|
||||||
|
delete(l.entries, ip)
|
||||||
|
} else {
|
||||||
|
l.entries[ip] = recent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,19 @@ package status
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var mu sync.RWMutex
|
||||||
|
|
||||||
// Service represents a single monitored service.
|
// Service represents a single monitored service.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Status string `json:"status"` // "up", "degraded", "down", "unknown"
|
CheckURL string `json:"check_url,omitempty"` // HTTP URL probed automatically; empty = manual
|
||||||
|
Status string `json:"status"` // "up", "degraded", "down", "unknown"
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +28,12 @@ type Page struct {
|
|||||||
|
|
||||||
// Load reads and parses the status JSON from path.
|
// Load reads and parses the status JSON from path.
|
||||||
func Load(path string) (*Page, error) {
|
func Load(path string) (*Page, error) {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return load(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(path string) (*Page, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -37,6 +47,8 @@ func Load(path string) (*Page, error) {
|
|||||||
|
|
||||||
// Save writes the status page data back to path.
|
// Save writes the status page data back to path.
|
||||||
func Save(path string, p *Page) error {
|
func Save(path string, p *Page) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
p.LastChecked = time.Now().UTC()
|
p.LastChecked = time.Now().UTC()
|
||||||
raw, err := json.MarshalIndent(p, "", " ")
|
raw, err := json.MarshalIndent(p, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1328,6 +1328,157 @@ blockquote {
|
|||||||
.resume-skills dd { color: #333 !important; }
|
.resume-skills dd { color: #333 !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Honeypot field (hidden from humans) === */
|
||||||
|
|
||||||
|
.hp-field {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
tab-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Subscribe widget === */
|
||||||
|
|
||||||
|
.subscribe-section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-section h2 { margin-bottom: 0.3em; }
|
||||||
|
|
||||||
|
.subscribe-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-form input[type="email"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-dark);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-form input[type="email"]:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Uses Page === */
|
||||||
|
|
||||||
|
.uses-page { max-width: var(--max-w); }
|
||||||
|
|
||||||
|
.uses-section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-section h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.35em;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-item {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-name {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-role {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-item p { margin: 0; font-size: 0.9rem; line-height: 1.7; }
|
||||||
|
|
||||||
|
.uses-list {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uses-list li { margin-bottom: 0.4em; }
|
||||||
|
|
||||||
|
/* === Projects Page === */
|
||||||
|
|
||||||
|
.projects-page { max-width: var(--max-w); }
|
||||||
|
|
||||||
|
.project-list { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
|
||||||
|
.project-item {
|
||||||
|
padding: 1.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item:first-child { padding-top: 0; }
|
||||||
|
.project-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.project-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Responsive === */
|
/* === Responsive === */
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<a href="/admin/new" class="btn">New Post</a>
|
<a href="/admin/new" class="btn">New Post</a>
|
||||||
<a href="/admin/status" class="btn btn-outline">Edit Status</a>
|
<a href="/admin/status" class="btn btn-outline">Edit Status</a>
|
||||||
<a href="/admin/uploads" class="btn btn-outline">Uploads</a>
|
<a href="/admin/uploads" class="btn btn-outline">Uploads</a>
|
||||||
|
<a href="/admin/newsletter" class="btn btn-outline">Newsletter</a>
|
||||||
<a href="/" class="btn btn-outline">View Site</a>
|
<a href="/" class="btn btn-outline">View Site</a>
|
||||||
<form method="POST" action="/admin/logout" class="inline-form">
|
<form method="POST" action="/admin/logout" class="inline-form">
|
||||||
<button type="submit" class="btn btn-outline">Logout</button>
|
<button type="submit" class="btn btn-outline">Logout</button>
|
||||||
|
|||||||
45
templates/admin/newsletter.html
Normal file
45
templates/admin/newsletter.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{define "title"}}Newsletter — Admin{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="admin-wrap">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Newsletter Subscribers</h1>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<a href="/admin" class="btn btn-outline">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Flash}}<p class="flash-msg">{{.Flash}}</p>{{end}}
|
||||||
|
|
||||||
|
<p class="text-muted">{{.Count}} subscriber{{if ne .Count 1}}s{{end}}</p>
|
||||||
|
|
||||||
|
{{if not .Subscribers}}
|
||||||
|
<p class="empty-state">No subscribers yet.</p>
|
||||||
|
{{else}}
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Subscribed</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Subscribers}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Email}}</td>
|
||||||
|
<td class="text-muted mono">{{formatDate .CreatedAt}}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<form method="POST" action="/admin/newsletter" class="inline-form"
|
||||||
|
onsubmit="return confirm('Remove {{.Email}}?')">
|
||||||
|
<input type="hidden" name="email" value="{{.Email}}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -49,6 +49,19 @@
|
|||||||
Pricing by project or hourly — contact me for details.
|
Pricing by project or hourly — contact me for details.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="subscribe-section">
|
||||||
|
<h2>Stay updated</h2>
|
||||||
|
<p>Occasional posts on OpenBSD, homelab builds, and infrastructure work. No spam.</p>
|
||||||
|
<form method="POST" action="/newsletter" class="subscribe-form">
|
||||||
|
<div class="hp-field" aria-hidden="true">
|
||||||
|
<label for="url">URL</label>
|
||||||
|
<input type="text" id="url" name="url" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<input type="email" name="email" placeholder="your@email.com" required autocomplete="email">
|
||||||
|
<button type="submit" class="btn btn-outline">Subscribe</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="contact-section">
|
<section class="contact-section">
|
||||||
<h2>Get in touch</h2>
|
<h2>Get in touch</h2>
|
||||||
<p>Tell me about your project or problem. I'll respond within one business day.</p>
|
<p>Tell me about your project or problem. I'll respond within one business day.</p>
|
||||||
@@ -61,6 +74,12 @@
|
|||||||
{{if .Error}}<p class="form-error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="form-error">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/hire" class="contact-form">
|
<form method="POST" action="/hire" class="contact-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
{{/* Honeypot: hidden from humans, bots fill it in */}}
|
||||||
|
<div class="hp-field" aria-hidden="true">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="name">Name <span class="required-mark">*</span></label>
|
<label for="name">Name <span class="required-mark">*</span></label>
|
||||||
<input type="text" id="name" name="name" value="{{.Name}}" required autocomplete="name" placeholder="Jane Smith">
|
<input type="text" id="name" name="name" value="{{.Name}}" required autocomplete="name" placeholder="Jane Smith">
|
||||||
|
|||||||
87
templates/projects.html
Normal file
87
templates/projects.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{{define "title"}}Projects — Ridgway Systems{{end}}
|
||||||
|
{{define "meta-desc"}}Infrastructure projects and builds by Blake Ridgway — homelab, monitoring systems, security tooling, and more.{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="projects-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Projects</h1>
|
||||||
|
<p class="page-desc">Things built, broken, and rebuilt.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-list">
|
||||||
|
|
||||||
|
<div class="project-item">
|
||||||
|
<div class="project-header">
|
||||||
|
<h2 class="project-title">ridgwaysystems.org</h2>
|
||||||
|
<div class="project-tags">
|
||||||
|
<span class="tag">Go</span>
|
||||||
|
<span class="tag">OpenBSD</span>
|
||||||
|
<span class="tag">self-hosted</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>This site. A single Go binary serving a blog, status page, hire page, and admin panel — no database, no Docker, no external dependencies at runtime. Flat Markdown files on disk, HMAC-signed sessions, chroma syntax highlighting. Deployed on OpenBSD behind relayd. The build log covers the whole thing.</p>
|
||||||
|
<div class="project-links">
|
||||||
|
<a href="/blog">Build log →</a>
|
||||||
|
<a href="https://git.ridgwaysystems.org">Source →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-item">
|
||||||
|
<div class="project-header">
|
||||||
|
<h2 class="project-title">Policy-as-Code Firewall Framework</h2>
|
||||||
|
<div class="project-tags">
|
||||||
|
<span class="tag">pf</span>
|
||||||
|
<span class="tag">IaC</span>
|
||||||
|
<span class="tag">security</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>A policy-as-code system for managing pf firewall rules across multiple OpenBSD hosts. Rules defined in structured configuration, rendered to pf.conf via templating, with automated geo-location blocking and rule validation before deployment. Deployed at Triangle Insurance to manage ~200 rules across three firewall segments.</p>
|
||||||
|
<div class="project-links">
|
||||||
|
<a href="/blog/pf-vlans">Related post →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-item">
|
||||||
|
<div class="project-header">
|
||||||
|
<h2 class="project-title">ISP Network Monitoring</h2>
|
||||||
|
<div class="project-tags">
|
||||||
|
<span class="tag">Prometheus</span>
|
||||||
|
<span class="tag">Grafana</span>
|
||||||
|
<span class="tag">Go</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>Custom Prometheus exporter that continuously measures ISP throughput, latency, and packet loss across multiple WAN connections. Exports to Grafana for real-time dashboards and alerting. Replaced manual speed tests that only caught outages after users complained. Cut time-to-detect WAN degradation from hours to minutes.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-item">
|
||||||
|
<div class="project-header">
|
||||||
|
<h2 class="project-title">Homelab Infrastructure</h2>
|
||||||
|
<div class="project-tags">
|
||||||
|
<span class="tag">OpenBSD</span>
|
||||||
|
<span class="tag">Ansible</span>
|
||||||
|
<span class="tag">Terraform</span>
|
||||||
|
<span class="tag">homelab</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>The homelab: fw01 running OpenBSD with pf and WireGuard, two Dell rack servers, VLAN-segmented network (management, servers, IoT, guest), self-hosted Gitea, Matrix, Jellyfin, Prometheus, and Grafana. Fully documented, IaC'd where possible, and used as a test bed before anything touches production.</p>
|
||||||
|
<div class="project-links">
|
||||||
|
<a href="/infrastructure">Infrastructure diagram →</a>
|
||||||
|
<a href="/uses">What I run →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-item">
|
||||||
|
<div class="project-header">
|
||||||
|
<h2 class="project-title">Zero-Touch Provisioning System</h2>
|
||||||
|
<div class="project-tags">
|
||||||
|
<span class="tag">PXE</span>
|
||||||
|
<span class="tag">Ansible</span>
|
||||||
|
<span class="tag">automation</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>PXE boot + Ansible-based provisioning pipeline for deploying standardized workstation images across Air Force Training bases. Reduced per-machine setup time by 75% and eliminated configuration drift between deployments. Machines boot, pull config from the server, and are production-ready without a human touching them after the initial PXE boot.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
99
templates/uses.html
Normal file
99
templates/uses.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{{define "title"}}Uses — Ridgway Systems{{end}}
|
||||||
|
{{define "meta-desc"}}Hardware, software, and tools Blake Ridgway uses in the homelab and day-to-day work.{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="uses-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Uses</h1>
|
||||||
|
<p class="page-desc">Hardware, software, and tools — homelab and daily driver.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="uses-section">
|
||||||
|
<h2>Hardware</h2>
|
||||||
|
|
||||||
|
<div class="uses-item">
|
||||||
|
<div class="uses-item-header">
|
||||||
|
<span class="uses-name">fw01</span>
|
||||||
|
<span class="uses-role">Firewall / Router</span>
|
||||||
|
</div>
|
||||||
|
<p>SuperMicro 1U, Intel E3-1230v2, 16GB ECC RAM. Running OpenBSD. Handles all pf firewall rules, VLANs, WireGuard VPN, unbound DNS, and relayd reverse proxy. The critical piece everything else depends on.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uses-item">
|
||||||
|
<div class="uses-item-header">
|
||||||
|
<span class="uses-name">srv01</span>
|
||||||
|
<span class="uses-role">Primary Services</span>
|
||||||
|
</div>
|
||||||
|
<p>Dell PowerEdge R720, dual Xeon E5-2600, 64GB RAM. Main workload server — runs Prometheus, Grafana, Gitea, OpenSMTPD, Matrix/Conduit. Loud and power-hungry, but handles everything without complaint.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uses-item">
|
||||||
|
<div class="uses-item-header">
|
||||||
|
<span class="uses-name">srv02</span>
|
||||||
|
<span class="uses-role">Media / Secondary</span>
|
||||||
|
</div>
|
||||||
|
<p>Dell PowerEdge R710. Jellyfin media server, game server VMs, secondary storage, authoritative DNS (nsd). The workhorse for anything that doesn't need to be bulletproof.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uses-item">
|
||||||
|
<div class="uses-item-header">
|
||||||
|
<span class="uses-name">ws01</span>
|
||||||
|
<span class="uses-role">Workstation</span>
|
||||||
|
</div>
|
||||||
|
<p>Desktop, AMD Ryzen. Daily driver for development, terminal sessions, and homelab management. Running Fedora Linux.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="uses-section">
|
||||||
|
<h2>Operating Systems</h2>
|
||||||
|
<ul class="uses-list">
|
||||||
|
<li><strong>OpenBSD</strong> — fw01, this web server. Chosen for its security defaults, pf, and the fact that it does exactly what it says on the tin.</li>
|
||||||
|
<li><strong>AlmaLinux / Rocky</strong> — srv01, srv02. RHEL-compatible for production workloads where SELinux and systemd are expected.</li>
|
||||||
|
<li><strong>Fedora</strong> — Workstation. Stays close to bleeding-edge tooling without being Arch.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="uses-section">
|
||||||
|
<h2>Networking</h2>
|
||||||
|
<ul class="uses-list">
|
||||||
|
<li><strong>pf</strong> — OpenBSD packet filter. VLANs, NAT, geo-blocking, antispoof. The whole reason fw01 runs OpenBSD.</li>
|
||||||
|
<li><strong>WireGuard</strong> — VPN for remote access. Simple, fast, auditable.</li>
|
||||||
|
<li><strong>unbound</strong> — Recursive DNS resolver on fw01. Validates DNSSEC, blocks ad/tracking domains.</li>
|
||||||
|
<li><strong>nsd</strong> — Authoritative DNS on srv02 for the ridgwaysystems.org zone.</li>
|
||||||
|
<li><strong>relayd</strong> — OpenBSD reverse proxy in front of this site and internal services.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="uses-section">
|
||||||
|
<h2>Infrastructure & Automation</h2>
|
||||||
|
<ul class="uses-list">
|
||||||
|
<li><strong>Terraform</strong> — Cloud infrastructure (Azure, AWS). Anything that touches a cloud API gets IaC'd.</li>
|
||||||
|
<li><strong>Ansible</strong> — Configuration management for Linux servers. Idempotent, no agent required.</li>
|
||||||
|
<li><strong>Gitea</strong> — Self-hosted git at <a href="https://git.ridgwaysystems.org">git.ridgwaysystems.org</a>. Lightweight, fast, no subscription required.</li>
|
||||||
|
<li><strong>Prometheus + Grafana</strong> — Metrics and dashboards for everything. Custom exporters for pf counters, ISP throughput, and hardware sensors.</li>
|
||||||
|
<li><strong>Nagios</strong> — Service alerting. Opinionated but reliable — been running since before dashboards were cool.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="uses-section">
|
||||||
|
<h2>Development</h2>
|
||||||
|
<ul class="uses-list">
|
||||||
|
<li><strong>VS Code</strong> — Primary editor. Remote SSH extension makes working directly on servers seamless.</li>
|
||||||
|
<li><strong>Go</strong> — Preferred language for infrastructure tooling and this site. Fast to compile, easy to deploy a single binary.</li>
|
||||||
|
<li><strong>Python</strong> — Scripting, automation, quick data processing.</li>
|
||||||
|
<li><strong>Bash / ksh</strong> — Bash on Linux, ksh on OpenBSD. Shell scripts for anything that doesn't need to outlast the week.</li>
|
||||||
|
<li><strong>tmux</strong> — Terminal multiplexer. Multiple panes across multiple SSH sessions, always.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="uses-section">
|
||||||
|
<h2>Self-hosted Services</h2>
|
||||||
|
<ul class="uses-list">
|
||||||
|
<li><strong>OpenSMTPD</strong> — Mail server. Handles inbound and outbound for ridgwaysystems.org.</li>
|
||||||
|
<li><strong>Matrix / Conduit</strong> — Self-hosted chat. Federated, encrypted. Currently migrating.</li>
|
||||||
|
<li><strong>Jellyfin</strong> — Media server. No subscription, no phone-home, streams anywhere on the LAN.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user