355 lines
8.8 KiB
Go
355 lines
8.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/xml"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"ridgwaysystems.org/website/internal/blog"
|
|
"ridgwaysystems.org/website/internal/feed"
|
|
"ridgwaysystems.org/website/internal/mailer"
|
|
"ridgwaysystems.org/website/internal/status"
|
|
)
|
|
|
|
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 OpenBSD.", 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)
|
|
}
|
|
|
|
// statusData is passed to the status template.
|
|
type statusData struct {
|
|
Page *status.Page
|
|
LastChecked string
|
|
}
|
|
|
|
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")
|
|
}
|
|
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked})
|
|
}
|
|
|
|
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)
|
|
}
|