internal/web/server.go

204 lines · 5271 bytes

  1// Package web serves gallery pages and images.
  2package web
  3
  4import (
  5	"bytes"
  6	"embed"
  7	"fmt"
  8	"html/template"
  9	"io/fs"
 10	"log"
 11	"net/http"
 12	"os"
 13	"strconv"
 14	"strings"
 15
 16	"gitbay.org/cmc/gallery/internal/library"
 17	"gitbay.org/cmc/gallery/internal/render"
 18)
 19
 20//go:embed templates/*.html
 21var embeddedTemplates embed.FS
 22
 23//go:embed assets/theme.css
 24var embeddedTheme []byte
 25
 26//go:embed assets/gallery.js
 27var embeddedScript []byte
 28
 29type Options struct {
 30	Title     string
 31	ThemePath string // replaces the embedded theme.css
 32	Templates fs.FS  // replaces the embedded templates; same file names
 33}
 34
 35var pageNames = []string{"home", "album", "photo", "info", "timeline", "error"}
 36
 37type server struct {
 38	store *library.Store
 39	rend  *render.Renderer
 40	title string
 41	theme []byte
 42	pages map[string]*template.Template
 43}
 44
 45type page struct {
 46	Title   string
 47	Lib     *library.Library
 48	Current string
 49	Album   *library.Album
 50	Item    *library.Item
 51	Index   int
 52	Prev    *library.Item
 53	Next    *library.Item
 54	Code    int
 55	Message string
 56}
 57
 58func New(store *library.Store, rend *render.Renderer, opt Options) (http.Handler, error) {
 59	s := &server{store: store, rend: rend, title: opt.Title, theme: embeddedTheme, pages: map[string]*template.Template{}}
 60	if opt.ThemePath != "" {
 61		b, err := os.ReadFile(opt.ThemePath)
 62		if err != nil {
 63			return nil, err
 64		}
 65		s.theme = b
 66	}
 67	tfs := opt.Templates
 68	if tfs == nil {
 69		sub, err := fs.Sub(embeddedTemplates, "templates")
 70		if err != nil {
 71			return nil, err
 72		}
 73		tfs = sub
 74	}
 75	for _, name := range pageNames {
 76		t, err := template.New("base.html").Funcs(funcs).ParseFS(tfs, "base.html", name+".html")
 77		if err != nil {
 78			return nil, fmt.Errorf("template %s: %w", name, err)
 79		}
 80		s.pages[name] = t
 81	}
 82
 83	mux := http.NewServeMux()
 84	mux.HandleFunc("GET /{$}", s.home)
 85	mux.HandleFunc("GET /info/{$}", s.info)
 86	mux.HandleFunc("GET /timeline/{$}", s.timeline)
 87	mux.HandleFunc("GET /_/theme.css", s.themeCSS)
 88	mux.HandleFunc("GET /_/gallery.js", s.script)
 89	mux.HandleFunc("GET /img/{album}/{file}/{width}", s.image)
 90	mux.HandleFunc("GET /{album}/{$}", s.album)
 91	mux.HandleFunc("GET /{album}/{file}/{$}", s.photo)
 92	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.error(w, http.StatusNotFound) })
 93	return mux, nil
 94}
 95
 96func (s *server) render(w http.ResponseWriter, name string, status int, p page) {
 97	p.Title = s.title
 98	if p.Lib == nil {
 99		p.Lib = s.store.Load()
100	}
101	var buf bytes.Buffer
102	if err := s.pages[name].Execute(&buf, p); err != nil {
103		log.Printf("render %s: %v", name, err)
104		http.Error(w, "internal error", http.StatusInternalServerError)
105		return
106	}
107	w.Header().Set("Content-Type", "text/html; charset=utf-8")
108	w.WriteHeader(status)
109	buf.WriteTo(w)
110}
111
112func (s *server) error(w http.ResponseWriter, code int) {
113	s.render(w, "error", code, page{Code: code, Message: http.StatusText(code)})
114}
115
116func (s *server) home(w http.ResponseWriter, r *http.Request) {
117	s.render(w, "home", http.StatusOK, page{})
118}
119
120func (s *server) info(w http.ResponseWriter, r *http.Request) {
121	lib := s.store.Load()
122	if lib.About == "" {
123		s.error(w, http.StatusNotFound)
124		return
125	}
126	s.render(w, "info", http.StatusOK, page{Lib: lib, Current: "info"})
127}
128
129func (s *server) timeline(w http.ResponseWriter, r *http.Request) {
130	s.render(w, "timeline", http.StatusOK, page{Current: "timeline"})
131}
132
133func (s *server) album(w http.ResponseWriter, r *http.Request) {
134	lib := s.store.Load()
135	a := lib.Album(r.PathValue("album"))
136	if a == nil {
137		s.error(w, http.StatusNotFound)
138		return
139	}
140	s.render(w, "album", http.StatusOK, page{Lib: lib, Current: a.Slug, Album: a})
141}
142
143func (s *server) photo(w http.ResponseWriter, r *http.Request) {
144	lib := s.store.Load()
145	a := lib.Album(r.PathValue("album"))
146	if a == nil {
147		s.error(w, http.StatusNotFound)
148		return
149	}
150	i, it := a.Item(r.PathValue("file"))
151	if it == nil {
152		s.error(w, http.StatusNotFound)
153		return
154	}
155	p := page{Lib: lib, Current: a.Slug, Album: a, Item: it, Index: i}
156	if i > 0 {
157		p.Prev = a.Items[i-1]
158	}
159	if i < len(a.Items)-1 {
160		p.Next = a.Items[i+1]
161	}
162	s.render(w, "photo", http.StatusOK, p)
163}
164
165func (s *server) image(w http.ResponseWriter, r *http.Request) {
166	lib := s.store.Load()
167	a := lib.Album(r.PathValue("album"))
168	if a == nil {
169		http.NotFound(w, r)
170		return
171	}
172	_, it := a.Item(r.PathValue("file"))
173	if it == nil {
174		http.NotFound(w, r)
175		return
176	}
177	ws, ok := strings.CutSuffix(r.PathValue("width"), ".jpg")
178	n, err := strconv.Atoi(ws)
179	if !ok || err != nil || !render.Allowed(n) {
180		http.Error(w, "bad width", http.StatusBadRequest)
181		return
182	}
183	p, err := s.rend.Get(it, n)
184	if err != nil {
185		log.Printf("render %s/%s@%d: %v", it.Album, it.Name, n, err)
186		http.Error(w, "render failed", http.StatusInternalServerError)
187		return
188	}
189	w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
190	w.Header().Set("Content-Type", "image/jpeg")
191	http.ServeFile(w, r, p)
192}
193
194func (s *server) themeCSS(w http.ResponseWriter, r *http.Request) {
195	w.Header().Set("Content-Type", "text/css; charset=utf-8")
196	w.Header().Set("Cache-Control", "no-cache")
197	w.Write(s.theme)
198}
199
200func (s *server) script(w http.ResponseWriter, r *http.Request) {
201	w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
202	w.Header().Set("Cache-Control", "no-cache")
203	w.Write(embeddedScript)
204}