// Package server serves the published tree over HTTP. package server import ( "context" "encoding/json" "log/slog" "net/http" "strconv" "strings" "sync/atomic" "time" "git.pablomurad.com/pablo/patchwork/internal/render" ) // Server serves the latest tree. Swap replaces it atomically, so a request // always sees one consistent build. type Server struct { tree atomic.Pointer[render.Tree] log *slog.Logger mux *http.ServeMux } // New builds a server; call Swap before serving. func New(log *slog.Logger) *Server { s := &Server{log: log, mux: http.NewServeMux()} s.mux.HandleFunc("GET /healthz", s.health) for _, p := range []string{"pages", "revisions"} { s.mux.HandleFunc("GET /api/v1/"+p, s.file("api/v1/"+p+".json")) s.mux.HandleFunc("GET /api/v1/"+p+".json", s.file("api/v1/"+p+".json")) } // Slugs may contain slashes (the "path" slug rule), so page routes take // the rest of the path and split it themselves. s.mux.HandleFunc("GET /api/v1/pages/{rest...}", s.pageAPI) s.mux.HandleFunc("GET /feeds/{rest...}", s.feed) s.mux.HandleFunc("GET /text/revisions", s.textRevisions) s.mux.HandleFunc("GET /text/revisions.txt", s.textRevisions) s.mux.HandleFunc("GET /text/pages/{rest...}", s.textPage) s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET and HEAD") return } writeError(w, http.StatusNotFound, "not_found", "no such path") }) return s } // Swap publishes a new tree. func (s *Server) Swap(t *render.Tree) { s.tree.Store(t) } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} s.mux.ServeHTTP(sw, r) s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dur", time.Since(start)) } // ListenAndServe serves until ctx is done, then drains for a few seconds. func (s *Server) ListenAndServe(ctx context.Context, addr string) error { hs := &http.Server{Addr: addr, Handler: s, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 30 * time.Second} errc := make(chan error, 1) go func() { errc <- hs.ListenAndServe() }() select { case err := <-errc: return err case <-ctx.Done(): shut, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() return hs.Shutdown(shut) } } func (s *Server) health(w http.ResponseWriter, r *http.Request) { if s.tree.Load() == nil { writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished") return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte("ok\n")) } func (s *Server) file(path string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { s.serve(w, r, path) } } // splitVersion splits "/versions/" into its parts. func splitVersion(rest string) (slug, n string, ok bool) { i := strings.LastIndex(rest, "/versions/") if i < 0 { return rest, "", false } return rest[:i], rest[i+len("/versions/"):], true } func (s *Server) pageAPI(w http.ResponseWriter, r *http.Request) { rest := strings.TrimSuffix(r.PathValue("rest"), ".json") if slug, n, ok := splitVersion(rest); ok { s.serve(w, r, "api/v1/pages/"+slug+"/versions/"+n+".json") return } s.serve(w, r, "api/v1/pages/"+rest+".json") } func (s *Server) feed(w http.ResponseWriter, r *http.Request) { s.serve(w, r, "feeds/"+r.PathValue("rest")) } // serve writes one file of the tree with ETag and conditional GET. func (s *Server) serve(w http.ResponseWriter, r *http.Request, path string) { t := s.tree.Load() if t == nil { writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished") return } f, ok := t.Files[path] if !ok { writeError(w, http.StatusNotFound, "not_found", "no such path") return } h := w.Header() h.Set("Content-Type", f.ContentType) h.Set("ETag", f.ETag) h.Set("Cache-Control", "public, max-age=30") h.Set("X-Content-Type-Options", "nosniff") if match := r.Header.Get("If-None-Match"); match != "" && etagMatch(match, f.ETag) { w.WriteHeader(http.StatusNotModified) return } h.Set("Content-Length", strconv.Itoa(len(f.Body))) if r.Method == http.MethodHead { return } w.Write(f.Body) } // Text routes render on request: width and colors come from the query // (?w=100, ?plain), like curl-friendly sites expect. func textOptions(r *http.Request) render.TextOptions { o := render.TextOptions{Width: 80, Color: true} if w, err := strconv.Atoi(r.URL.Query().Get("w")); err == nil { o.Width = w } if r.URL.Query().Has("plain") { o.Color = false } return o } func (s *Server) textRevisions(w http.ResponseWriter, r *http.Request) { t := s.tree.Load() if t == nil { writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished") return } revs := t.Model.Revisions if limit := t.Model.Cfg.Feeds.Limit; len(revs) > limit { revs = revs[:limit] } writeText(w, r, t.Model.TextRevisions(revs, textOptions(r))) } // textPage serves /text/pages//history[.txt] and // /text/pages//versions/[.txt]. func (s *Server) textPage(w http.ResponseWriter, r *http.Request) { t := s.tree.Load() if t == nil { writeError(w, http.StatusServiceUnavailable, "not_ready", "first build not finished") return } rest := strings.TrimSuffix(r.PathValue("rest"), ".txt") if slug, ok := strings.CutSuffix(rest, "/history"); ok { if _, found := t.Model.Page(slug); !found { writeError(w, http.StatusNotFound, "not_found", "no such page") return } writeText(w, r, t.Model.TextHistory(slug, textOptions(r))) return } slug, ns, ok := splitVersion(rest) n, err := strconv.Atoi(ns) if !ok || err != nil { writeError(w, http.StatusNotFound, "not_found", "no such path") return } body, found := t.Model.TextVersion(slug, n, textOptions(r)) if !found { writeError(w, http.StatusNotFound, "not_found", "no such version") return } writeText(w, r, body) } func writeText(w http.ResponseWriter, r *http.Request, body string) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") if r.Method == http.MethodHead { return } w.Write([]byte(body)) } func writeError(w http.ResponseWriter, status int, code, msg string) { var e render.Error e.Error.Code, e.Error.Message = code, msg w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) json.NewEncoder(w).Encode(e) } func etagMatch(header, etag string) bool { if strings.TrimSpace(header) == "*" { return true } for _, part := range strings.Split(header, ",") { if strings.TrimPrefix(strings.TrimSpace(part), "W/") == etag { return true } } return false } type statusWriter struct { http.ResponseWriter status int } func (w *statusWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) }