add hire/resume pages, contact form, security middleware, and admin improvements
This commit is contained in:
@@ -60,6 +60,9 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
|
||||
case path == "/admin/upload":
|
||||
h.requireAuth(h.adminUpload)(w, r)
|
||||
|
||||
case path == "/admin/uploads":
|
||||
h.requireAuth(h.adminUploads)(w, r)
|
||||
|
||||
default:
|
||||
h.renderErr(w, http.StatusNotFound, "Admin page not found.")
|
||||
}
|
||||
@@ -337,6 +340,40 @@ func (h *Handler) adminUpload(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, `{"url":"%s","markdown":""}`, url, url)
|
||||
}
|
||||
|
||||
// --- Uploads browser ---
|
||||
|
||||
type uploadFile struct {
|
||||
Name string
|
||||
URL string
|
||||
Markdown string
|
||||
}
|
||||
|
||||
type uploadsData struct {
|
||||
Files []uploadFile
|
||||
Flash string
|
||||
}
|
||||
|
||||
func (h *Handler) adminUploads(w http.ResponseWriter, r *http.Request) {
|
||||
const dir = "static/uploads"
|
||||
entries, err := os.ReadDir(dir)
|
||||
var files []uploadFile
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
url := "/static/uploads/" + name
|
||||
files = append(files, uploadFile{
|
||||
Name: name,
|
||||
URL: url,
|
||||
Markdown: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
h.render(w, "admin-uploads", uploadsData{Files: files, Flash: r.URL.Query().Get("flash")})
|
||||
}
|
||||
|
||||
// sanitizeSlug ensures a slug is filesystem-safe.
|
||||
func sanitizeSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -13,20 +14,22 @@ import (
|
||||
|
||||
// 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
|
||||
store *blog.Store
|
||||
dataDir string
|
||||
templates map[string]*template.Template
|
||||
siteURL string
|
||||
contactEmail 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",
|
||||
store: store,
|
||||
dataDir: dataDir,
|
||||
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
|
||||
contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"),
|
||||
devMode: os.Getenv("DEV") == "1",
|
||||
}
|
||||
if !h.devMode {
|
||||
h.templates = mustLoadTemplates()
|
||||
@@ -69,10 +72,14 @@ func mustLoadTemplates() map[string]*template.Template {
|
||||
{"infrastructure", "templates/infrastructure.html"},
|
||||
{"status", "templates/status.html"},
|
||||
{"about", "templates/about.html"},
|
||||
{"hire", "templates/hire.html"},
|
||||
{"resume", "templates/resume.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"},
|
||||
}
|
||||
|
||||
for _, p := range pages {
|
||||
@@ -98,8 +105,24 @@ func (h *Handler) render(w http.ResponseWriter, name string, data any) {
|
||||
}
|
||||
}
|
||||
|
||||
// errorData is passed to the error template.
|
||||
type errorData struct {
|
||||
Code int
|
||||
Title string
|
||||
Message string
|
||||
}
|
||||
|
||||
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>"))
|
||||
data := errorData{Code: code, Title: http.StatusText(code), Message: msg}
|
||||
t := h.tmpl("error")
|
||||
if t == nil {
|
||||
fmt.Fprintf(w, "<html><body><h1>%d %s</h1><p>%s</p><a href='/'>Home</a></body></html>",
|
||||
code, http.StatusText(code), msg)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
log.Printf("renderErr %d: %v", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
54
internal/handler/middleware.go
Normal file
54
internal/handler/middleware.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Chain wraps h with each middleware in order (first applied outermost).
|
||||
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(mw) - 1; i >= 0; i-- {
|
||||
h = mw[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// LoggingMiddleware logs method, path, status code, and duration.
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
lw := &loggingResponseWriter{ResponseWriter: w, code: http.StatusOK}
|
||||
next.ServeHTTP(lw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.RequestURI(), lw.code, time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
type loggingResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
}
|
||||
|
||||
func (lw *loggingResponseWriter) WriteHeader(code int) {
|
||||
lw.code = code
|
||||
lw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// SecurityHeadersMiddleware sets security-related HTTP response headers.
|
||||
// Admin paths get script-src 'self'; all other paths get script-src 'none'.
|
||||
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scriptSrc := "'none'"
|
||||
if strings.HasPrefix(r.URL.Path, "/admin") {
|
||||
scriptSrc = "'self'"
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; script-src "+scriptSrc+"; style-src 'self'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/feed"
|
||||
"ridgwaysystems.org/website/internal/mailer"
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
)
|
||||
|
||||
@@ -107,6 +110,13 @@ func (h *Handler) BlogList(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// postPageData is passed to the post template.
|
||||
type postPageData struct {
|
||||
*blog.Post
|
||||
Older *blog.Post
|
||||
Newer *blog.Post
|
||||
}
|
||||
|
||||
func (h *Handler) BlogPost(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
@@ -123,7 +133,8 @@ func (h *Handler) BlogPost(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderErr(w, http.StatusNotFound, "Post not found.")
|
||||
return
|
||||
}
|
||||
h.render(w, "post", post)
|
||||
older, newer, _ := h.store.Neighbors(slug)
|
||||
h.render(w, "post", postPageData{Post: post, Older: older, Newer: newer})
|
||||
}
|
||||
|
||||
func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -167,6 +178,62 @@ func (h *Handler) About(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "about", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "resume", nil)
|
||||
}
|
||||
|
||||
// --- Hire / Contact ---
|
||||
|
||||
type hireData struct {
|
||||
Name string
|
||||
Email string
|
||||
Company string
|
||||
Message string
|
||||
Error string
|
||||
Success bool
|
||||
}
|
||||
|
||||
func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "hire", hireData{})
|
||||
}
|
||||
|
||||
func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
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")),
|
||||
}
|
||||
if d.Name == "" || d.Email == "" || d.Message == "" {
|
||||
d.Error = "Name, email, and message are required."
|
||||
h.render(w, "hire", d)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(d.Email, "@") {
|
||||
d.Error = "Please enter a valid email address."
|
||||
h.render(w, "hire", d)
|
||||
return
|
||||
}
|
||||
|
||||
subject := "Hire inquiry from " + d.Name
|
||||
body := fmt.Sprintf("Name: %s\nEmail: %s\nCompany: %s\n\nMessage:\n%s\n",
|
||||
d.Name, d.Email, d.Company, d.Message)
|
||||
|
||||
if err := mailer.Send(h.contactEmail, subject, body); err != nil {
|
||||
log.Printf("contact form mail error: %v", err)
|
||||
d.Error = "Could not send message. Please email hire@ridgwaysystems.org directly."
|
||||
h.render(w, "hire", d)
|
||||
return
|
||||
}
|
||||
|
||||
d.Success = true
|
||||
h.render(w, "hire", d)
|
||||
}
|
||||
|
||||
// --- Sitemap ---
|
||||
|
||||
type urlset struct {
|
||||
@@ -188,6 +255,8 @@ func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) {
|
||||
urls := []sitemapURL{
|
||||
{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 + "/infrastructure", Freq: "monthly", Prio: "0.7"},
|
||||
{Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"},
|
||||
{Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"},
|
||||
|
||||
Reference in New Issue
Block a user