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}