Files
rs_website/internal/handler/public.go
2026-04-13 03:49:24 -05:00

421 lines
11 KiB
Go

package handler
import (
"encoding/xml"
"fmt"
"log"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/changelog"
"ridgwaysystems.org/website/internal/feed"
"ridgwaysystems.org/website/internal/gitea"
"ridgwaysystems.org/website/internal/mailer"
"ridgwaysystems.org/website/internal/status"
"ridgwaysystems.org/website/internal/uptime"
)
// streamData is passed to the stream template.
type streamData struct {
Channel string
Live bool
Parent string // hostname for Twitch embed parent= param
}
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,
})
}
// 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 == "" {
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
}
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) {
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 FreeBSD.", 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)
}
func (h *Handler) Stream(w http.ResponseWriter, r *http.Request) {
if h.twitch == nil {
h.renderErr(w, http.StatusNotFound, "Stream page not configured.")
return
}
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
h.render(w, "stream", streamData{
Channel: h.twitch.Channel(),
Live: h.twitch.IsLive(),
Parent: host,
})
}
// serviceHistory bundles per-service uptime data for the status template.
type serviceHistory struct {
Blocks []uptime.DayBlock
UptimePct float64
}
// statusData is passed to the status template.
type statusData struct {
Page *status.Page
LastChecked string
History map[string]serviceHistory // keyed by service name
PlannedOutages []gitea.Issue
}
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")
}
uptimePath := filepath.Join(h.dataDir, "uptime.json")
history := make(map[string]serviceHistory, len(p.Services))
for _, svc := range p.Services {
history[svc.Name] = serviceHistory{
Blocks: uptime.ServiceHistory(uptimePath, svc.Name),
UptimePct: uptime.UptimePct(uptimePath, svc.Name),
}
}
// Fetch planned outages from Gitea — best-effort, failure is non-fatal.
var plannedOutages []gitea.Issue
if h.gitea != nil && h.giteaOwner != "" && h.giteaRepo != "" {
if issues, err := h.gitea.ListIssuesByLabel(h.giteaOwner, h.giteaRepo, h.giteaLabel); err == nil {
plannedOutages = issues
} else {
log.Printf("status: gitea planned outages: %v", err)
}
}
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked, History: history, PlannedOutages: plannedOutages})
}
// changelogData is passed to the changelog template.
type changelogData struct {
Log *changelog.Log
}
func (h *Handler) Changelog(w http.ResponseWriter, r *http.Request) {
l, err := changelog.Load(filepath.Join(h.dataDir, "changelog.json"))
if err != nil {
l = &changelog.Log{}
}
h.render(w, "changelog", changelogData{Log: l})
}
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)
}
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
CSRFToken string
}
func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) {
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")),
CSRFToken: csrfToken(),
}
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)
}
// --- 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 {
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 + "/hire", Freq: "monthly", Prio: "0.9"},
{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"},
}
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)
}