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) }