aboutsummaryrefslogtreecommitdiffstats
path: root/internal/web/server.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/web/server.go')
-rw-r--r--internal/web/server.go251
1 files changed, 251 insertions, 0 deletions
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..e8a5e6d
--- /dev/null
+++ b/internal/web/server.go
@@ -0,0 +1,251 @@
+// Package web is the HTTP layer: host routing, handlers and templates.
+package web
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "strings"
+
+ "github.com/gramanas/blogspace/internal/auth"
+ "github.com/gramanas/blogspace/internal/config"
+ "github.com/gramanas/blogspace/internal/store"
+)
+
+// Subdomains that can never be blogs (kept free for infrastructure).
+var reservedSubdomains = map[string]bool{"www": true, "admin": true, "api": true, "mail": true, "static": true, "media": true, "ftp": true, "smtp": true}
+
+type Server struct {
+ cfg *config.Config
+ st *store.Store
+ tpl *templates
+ root http.Handler
+ blog http.Handler
+}
+
+func NewServer(cfg *config.Config, st *store.Store) *Server {
+ s := &Server{cfg: cfg, st: st, tpl: newTemplates(cfg.Dev, funcs)}
+ s.root = s.rootRoutes()
+ s.blog = s.blogRoutes()
+ return s
+}
+
+// ServeHTTP dispatches on the Host header: the base domain is the management
+// site, one label below it is a blog, anything else is a 404.
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ host := hostname(r.Host)
+ switch {
+ case host == s.cfg.BaseDomain, host == "www."+s.cfg.BaseDomain:
+ s.root.ServeHTTP(w, r)
+ case strings.HasSuffix(host, "."+s.cfg.BaseDomain):
+ sub := strings.TrimSuffix(host, "."+s.cfg.BaseDomain)
+ if strings.Contains(sub, ".") || reservedSubdomains[sub] {
+ http.NotFound(w, r)
+ return
+ }
+ blog, err := s.st.BlogBySubdomain(r.Context(), sub)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ s.plainError(w, http.StatusNotFound, "No blog here (yet).")
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ s.blog.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog)))
+ default:
+ s.plainError(w, http.StatusNotFound, fmt.Sprintf("Unknown host %q. Blogs live at <name>.%s", host, s.cfg.BaseDomain))
+ }
+}
+
+func hostname(h string) string {
+ if host, _, err := net.SplitHostPort(h); err == nil {
+ h = host
+ }
+ return strings.ToLower(strings.TrimSuffix(h, "."))
+}
+
+// ---- context keys --------------------------------------------------------
+
+type ctxKey int
+
+const (
+ ctxUser ctxKey = iota
+ ctxBlog
+)
+
+func currentUser(r *http.Request) *store.User {
+ u, _ := r.Context().Value(ctxUser).(*store.User)
+ return u
+}
+
+func currentBlog(r *http.Request) *store.Blog {
+ b, _ := r.Context().Value(ctxBlog).(*store.Blog)
+ return b
+}
+
+// ---- middleware ----------------------------------------------------------
+
+// session loads the user from the JWT cookie (if any) into the context.
+func (s *Server) session(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(auth.CookieName)
+ if err != nil || c.Value == "" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ claims, err := auth.ParseToken(s.cfg.JWTSecret, c.Value)
+ if err != nil {
+ auth.ClearSessionCookie(w)
+ next.ServeHTTP(w, r)
+ return
+ }
+ u, err := s.st.UserByID(r.Context(), claims.UserID)
+ if err != nil || u.Disabled || u.TokenVersion != claims.TokenVersion {
+ auth.ClearSessionCookie(w)
+ next.ServeHTTP(w, r)
+ return
+ }
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxUser, u)))
+ })
+}
+
+// requireAuth redirects anonymous users to the login page.
+func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ u := currentUser(r)
+ if u == nil {
+ http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
+ return
+ }
+ if r.Method == http.MethodPost {
+ // Cap the request body before any form parsing (uploads included).
+ r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+1<<20)
+ if err := parseForm(r); err != nil {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ s.plainError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("Upload too large: the limit is %d MB.", s.cfg.MaxUploadBytes>>20))
+ return
+ }
+ s.plainError(w, http.StatusBadRequest, "Could not read the form.")
+ return
+ }
+ if !auth.CheckCSRF(s.cfg.JWTSecret, u.ID, u.TokenVersion, r.FormValue("_csrf")) {
+ s.plainError(w, http.StatusForbidden, "Form expired or invalid. Go back, reload the page and try again.")
+ return
+ }
+ }
+ next(w, r)
+ }
+}
+
+// parseForm parses urlencoded or multipart bodies, surfacing size errors.
+func parseForm(r *http.Request) error {
+ ct := r.Header.Get("Content-Type")
+ if strings.HasPrefix(ct, "multipart/form-data") {
+ return r.ParseMultipartForm(1 << 20)
+ }
+ return r.ParseForm()
+}
+
+func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
+ return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
+ if !currentUser(r).IsSuperadmin() {
+ s.plainError(w, http.StatusForbidden, "Superadmin only.")
+ return
+ }
+ next(w, r)
+ })
+}
+
+// withBlog resolves /b/{sub}/... and enforces owner-or-superadmin.
+func (s *Server) withBlog(next http.HandlerFunc) http.HandlerFunc {
+ return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
+ u := currentUser(r)
+ blog, err := s.st.BlogBySubdomain(r.Context(), r.PathValue("sub"))
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ http.NotFound(w, r)
+ return
+ }
+ s.serverError(w, err)
+ return
+ }
+ if blog.OwnerID != u.ID && !u.IsSuperadmin() {
+ s.plainError(w, http.StatusForbidden, "This is not your blog.")
+ return
+ }
+ next(w, r.WithContext(context.WithValue(r.Context(), ctxBlog, blog)))
+ })
+}
+
+// ---- rendering helpers ---------------------------------------------------
+
+// view is the common data every template receives; page data goes in Data.
+type view struct {
+ User *store.User
+ CSRF string
+ Flash string
+ Error string
+ Blog *store.Blog
+ BlogURL string
+ RootURL string
+ Path string
+ Data map[string]any
+}
+
+func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
+ s.renderStatus(w, r, http.StatusOK, name, data)
+}
+
+func (s *Server) renderStatus(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) {
+ if data == nil {
+ data = map[string]any{}
+ }
+ v := view{User: currentUser(r), Blog: currentBlog(r), RootURL: s.cfg.RootURL(), Path: r.URL.Path, Data: data}
+ if v.User != nil {
+ v.CSRF = auth.CSRFToken(s.cfg.JWTSecret, v.User.ID, v.User.TokenVersion)
+ }
+ if v.Blog != nil {
+ v.BlogURL = s.cfg.BlogURL(v.Blog.Subdomain)
+ }
+ v.Flash = r.URL.Query().Get("ok")
+ if e, ok := data["error"].(string); ok {
+ v.Error = e
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := s.tpl.render(w, name, v); err != nil {
+ log.Printf("render %s: %v", name, err)
+ fmt.Fprintf(w, "<pre>template error: %v</pre>", err)
+ }
+}
+
+func (s *Server) serverError(w http.ResponseWriter, err error) {
+ log.Printf("error: %v", err)
+ s.plainError(w, http.StatusInternalServerError, "Something went wrong.")
+}
+
+func (s *Server) plainError(w http.ResponseWriter, status int, msg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ fmt.Fprintf(w, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>%d</title></head><body style="font-family:sans-serif;max-width:40em;margin:4em auto;padding:0 1em"><h1>%d %s</h1><p>%s</p><p><a href="/">Home</a></p></body></html>`,
+ status, status, http.StatusText(status), htmlEscape(msg))
+}
+
+func htmlEscape(s string) string {
+ r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
+ return r.Replace(s)
+}
+
+// redirectOK redirects with a flash message shown by the layout.
+func redirectOK(w http.ResponseWriter, r *http.Request, to, msg string) {
+ sep := "?"
+ if strings.Contains(to, "?") {
+ sep = "&"
+ }
+ http.Redirect(w, r, to+sep+"ok="+urlQuery(msg), http.StatusSeeOther)
+}