main
web.go
Eric Bower
·
2026-02-25
1package patchbin
2
3import (
4 "bytes"
5 "context"
6 "embed"
7 "fmt"
8 "html/template"
9 "io"
10 "io/fs"
11 "log/slog"
12 "mime"
13 "net/http"
14 "os"
15 "path/filepath"
16 "time"
17
18 "github.com/alecthomas/chroma/v2"
19 formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
20 "github.com/alecthomas/chroma/v2/lexers"
21 "github.com/alecthomas/chroma/v2/styles"
22 "github.com/gorilla/feeds"
23)
24
25//go:embed static/*
26var embedStaticFS embed.FS
27
28var (
29 //go:embed tmpl/*
30 tmplFS embed.FS
31 indexTmpl = getTemplate("index.html")
32 prTmpl = getTemplate("pr.html")
33 prsListTmpl = getTemplate("prs.html")
34)
35
36type BasicData struct {
37 MetaData
38}
39
40type MetaData struct {
41 URL string
42 Desc template.HTML
43 Tab TabStatus
44}
45
46type PrListItem struct {
47 ID int64
48 Name string
49 RepoName string
50 Status Status
51 FormattedDate string
52 NumPatchsets int
53}
54
55type PrListData struct {
56 PRs []PrListItem
57 MetaData
58}
59
60type WebCtx struct {
61 Pr *PrCmd
62 Backend *Backend
63 Formatter *formatterHtml.Formatter
64 Logger *slog.Logger
65 Theme *chroma.Style
66}
67
68type ctxWeb struct{}
69
70func getTemplate(page string) *template.Template {
71 tmpl, err := template.New("").Funcs(template.FuncMap{
72 "sha": shaFn,
73 }).ParseFS(
74 tmplFS,
75 filepath.Join("tmpl", "pages", page),
76 filepath.Join("tmpl", "components", "*.html"),
77 filepath.Join("tmpl", "base.html"),
78 )
79 if err != nil {
80 panic(err)
81 }
82 return tmpl.Lookup(page)
83}
84
85func getWebCtx(r *http.Request) (*WebCtx, error) {
86 data, ok := r.Context().Value(ctxWeb{}).(*WebCtx)
87 if data == nil || !ok {
88 return data, fmt.Errorf("webCtx not set on `r.Context()` for connection")
89 }
90 return data, nil
91}
92
93func setWebCtx(ctx context.Context, web *WebCtx) context.Context {
94 return context.WithValue(ctx, ctxWeb{}, web)
95}
96
97// converts contents of files in git tree to pretty formatted code.
98func parseText(formatter *formatterHtml.Formatter, theme *chroma.Style, text string) (string, error) {
99 lexer := lexers.Get("diff")
100 iterator, err := lexer.Tokenise(nil, text)
101 if err != nil {
102 return text, err
103 }
104 var buf bytes.Buffer
105 err = formatter.Format(&buf, theme, iterator)
106 if err != nil {
107 return text, err
108 }
109 return buf.String(), nil
110}
111
112func ctxMdw(ctx context.Context, handler http.HandlerFunc) http.HandlerFunc {
113 return func(w http.ResponseWriter, r *http.Request) {
114 handler(w, r.WithContext(ctx))
115 }
116}
117
118func indexHandler(w http.ResponseWriter, r *http.Request) {
119 web, err := getWebCtx(r)
120 if err != nil {
121 w.WriteHeader(http.StatusInternalServerError)
122 return
123 }
124
125 w.Header().Set("content-type", "text/html")
126 err = indexTmpl.Execute(w, BasicData{
127 MetaData: MetaData{
128 URL: web.Backend.Cfg.Url,
129 Desc: template.HTML(web.Backend.Cfg.Desc),
130 },
131 })
132 if err != nil {
133 web.Backend.Logger.Error("cannot execute template", "err", err)
134 }
135}
136
137type TabStatus string
138
139const (
140 TabStatusDraft TabStatus = "draft"
141 TabStatusActive TabStatus = "active"
142 TabStatusInactive TabStatus = "inactive"
143)
144
145func createPrListHandler(tab TabStatus) http.HandlerFunc {
146 return func(w http.ResponseWriter, r *http.Request) {
147 web, err := getWebCtx(r)
148 if err != nil {
149 w.WriteHeader(http.StatusInternalServerError)
150 return
151 }
152
153 var prs []*PatchRequest
154 switch TabStatus(tab) {
155 case TabStatusDraft:
156 prs, err = web.Pr.GetPatchRequestsByStatus(StatusDraft)
157 case TabStatusInactive:
158 prs, err = web.Pr.GetPatchRequestsInactive()
159 case TabStatusActive:
160 fallthrough
161 default:
162 prs, err = web.Pr.GetPatchRequestsActive()
163 }
164 if err != nil {
165 web.Backend.Logger.Error("cannot get patch requests", "err", err)
166 w.WriteHeader(http.StatusInternalServerError)
167 return
168 }
169
170 prItems := []PrListItem{}
171 for _, pr := range prs {
172 patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
173 if err != nil {
174 patchsets = nil
175 }
176 prItems = append(prItems, PrListItem{
177 ID: pr.ID,
178 Name: pr.Name,
179 RepoName: pr.RepoName,
180 Status: pr.Status,
181 FormattedDate: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
182 NumPatchsets: len(patchsets),
183 })
184 }
185
186 w.Header().Set("content-type", "text/html")
187 err = prsListTmpl.Execute(w, PrListData{
188 PRs: prItems,
189 MetaData: MetaData{
190 URL: web.Backend.Cfg.Url,
191 Desc: template.HTML(web.Backend.Cfg.Desc),
192 Tab: TabStatus(tab),
193 },
194 })
195 if err != nil {
196 web.Backend.Logger.Error("cannot execute template", "err", err)
197 }
198 }
199}
200
201func shaFn(sha string) string {
202 if sha == "" {
203 return "(none)"
204 }
205 return truncateSha(sha)
206}
207
208func rssHandler(w http.ResponseWriter, r *http.Request) {
209 web, err := getWebCtx(r)
210 if err != nil {
211 w.WriteHeader(http.StatusUnprocessableEntity)
212 return
213 }
214
215 desc := fmt.Sprintf(
216 "Events related to git collaboration server %s",
217 web.Backend.Cfg.Url,
218 )
219 feed := &feeds.Feed{
220 Title: fmt.Sprintf("%s events", web.Backend.Cfg.Url),
221 Link: &feeds.Link{Href: web.Backend.Cfg.Url},
222 Description: desc,
223 Author: &feeds.Author{Name: "git collaboration server"},
224 Created: time.Now(),
225 }
226
227 var eventLogs []*EventLog
228 id := r.PathValue("id")
229 pubkey := r.URL.Query().Get("pubkey")
230
231 if id != "" {
232 var prID int64
233 prID, err = getPrID(id)
234 if err != nil {
235 w.WriteHeader(http.StatusUnprocessableEntity)
236 return
237 }
238 eventLogs, err = web.Pr.GetEventLogsByPrID(prID)
239 } else if pubkey != "" {
240 user, perr := web.Pr.GetUserByPubkey(pubkey)
241 if perr != nil {
242 w.WriteHeader(http.StatusNotFound)
243 return
244 }
245 eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
246 } else {
247 eventLogs, err = web.Pr.GetEventLogs()
248 }
249
250 if err != nil {
251 web.Logger.Error("rss could not get eventLogs", "err", err)
252 w.WriteHeader(http.StatusInternalServerError)
253 return
254 }
255
256 var feedItems []*feeds.Item
257 for _, eventLog := range eventLogs {
258 user, err := web.Pr.GetUserByID(eventLog.UserID)
259 if err != nil {
260 web.Logger.Error("user not found for event log", "id", eventLog.ID, "err", err)
261 continue
262 }
263
264 pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID.Int64)
265 if err != nil {
266 continue
267 }
268
269 // Don't send RSS notifications for draft PRs
270 if pr.Status == StatusDraft {
271 continue
272 }
273
274 displayName := web.Backend.ComputeUserName(user.Pubkey)
275 realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID.Int64)
276 content := fmt.Sprintf(
277 "<div><div>Repo: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
278 pr.RepoName,
279 eventLog.PatchRequestID.Int64,
280 eventLog.Event,
281 eventLog.CreatedAt.Format(time.RFC3339Nano),
282 eventLog.Data,
283 )
284
285 title := fmt.Sprintf(
286 `%s in %s for PR "%s" (#%d)`,
287 eventLog.Event,
288 pr.RepoName,
289 pr.Name,
290 eventLog.PatchRequestID.Int64,
291 )
292 item := &feeds.Item{
293 Id: fmt.Sprintf("%d", eventLog.ID),
294 Title: title,
295 Link: &feeds.Link{Href: realUrl},
296 Content: content,
297 Created: eventLog.CreatedAt,
298 Description: title,
299 Author: &feeds.Author{Name: displayName},
300 }
301
302 feedItems = append(feedItems, item)
303 }
304 feed.Items = feedItems
305
306 rss, err := feed.ToAtom()
307 if err != nil {
308 web.Logger.Error("could not generate atom rss feed", "err", err)
309 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
310 }
311
312 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
313 _, err = w.Write([]byte(rss))
314 if err != nil {
315 web.Logger.Error("write error atom rss feed", "err", err)
316 }
317}
318
319func chromaStyleHandler(w http.ResponseWriter, r *http.Request) {
320 web, err := getWebCtx(r)
321 if err != nil {
322 w.WriteHeader(http.StatusUnprocessableEntity)
323 return
324 }
325 w.Header().Add("content-type", "text/css")
326 err = web.Formatter.WriteCSS(w, web.Theme)
327 if err != nil {
328 web.Backend.Logger.Error("cannot write css file", "err", err)
329 }
330}
331
332func serveFile(userfs fs.FS, embedfs fs.FS) func(w http.ResponseWriter, r *http.Request) {
333 return func(w http.ResponseWriter, r *http.Request) {
334 web, err := getWebCtx(r)
335 if err != nil {
336 w.WriteHeader(http.StatusUnprocessableEntity)
337 return
338 }
339 logger := web.Logger
340
341 file := r.PathValue("file")
342
343 logger.Info("serving file", "file", file)
344 // merging both embedded fs and whatever user provides
345 var reader fs.File
346 if userfs == nil {
347 reader, err = embedfs.Open(file)
348 } else {
349 reader, err = userfs.Open(file)
350 if err != nil {
351 // serve embeded static folder
352 reader, err = embedfs.Open(file)
353 }
354 }
355
356 if err != nil {
357 logger.Error(err.Error())
358 http.Error(w, "file not found", 404)
359 return
360 }
361
362 contents, err := io.ReadAll(reader)
363 if err != nil {
364 logger.Error(err.Error())
365 http.Error(w, "file not found", 404)
366 return
367 }
368 contentType := mime.TypeByExtension(filepath.Ext(file))
369 if contentType == "" {
370 contentType = http.DetectContentType(contents)
371 }
372 w.Header().Add("Content-Type", contentType)
373
374 _, err = w.Write(contents)
375 if err != nil {
376 logger.Error(err.Error())
377 http.Error(w, "server error", 500)
378 return
379 }
380 }
381}
382
383func getUserDefinedFS(datadir, dirName string) fs.FS {
384 dir := filepath.Join(datadir, dirName)
385 _, err := os.Stat(dir)
386 if err != nil {
387 return nil
388 }
389 return os.DirFS(dir)
390}
391
392func getEmbedFS(ffs embed.FS, dirName string) (fs.FS, error) {
393 fsys, err := fs.Sub(ffs, dirName)
394 if err != nil {
395 return nil, err
396 }
397 return fsys, nil
398}
399
400func GitWebServer(cfg *GitCfg) http.Handler {
401 dbpath := filepath.Join(cfg.DataDir, "pr.db?_fk=on")
402 dbh, err := SqliteOpen("file:"+dbpath, cfg.Logger)
403 if err != nil {
404 panic(fmt.Sprintf("cannot find database file, check folder and perms: %s: %s", dbpath, err))
405 }
406
407 be := &Backend{
408 DB: dbh,
409 Logger: cfg.Logger,
410 Cfg: cfg,
411 }
412 prCmd := &PrCmd{
413 Backend: be,
414 }
415 formatter := formatterHtml.New(
416 formatterHtml.WithClasses(true),
417 )
418 web := &WebCtx{
419 Pr: prCmd,
420 Backend: be,
421 Logger: cfg.Logger,
422 Formatter: formatter,
423 Theme: styles.Get(cfg.Theme),
424 }
425
426 ctx := context.Background()
427 ctx = setWebCtx(ctx, web)
428
429 // ensure legacy router is disabled
430 // GODEBUG=httpmuxgo121=0
431 mux := http.NewServeMux()
432 mux.HandleFunc("GET /prs/active", ctxMdw(ctx, createPrListHandler("active")))
433 mux.HandleFunc("GET /prs/draft", ctxMdw(ctx, createPrListHandler("draft")))
434 mux.HandleFunc("GET /prs/inactive", ctxMdw(ctx, createPrListHandler("inactive")))
435 mux.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
436 mux.HandleFunc("GET /prs/{id}/patches/{patchID}", ctxMdw(ctx, createPrDetail("pr")))
437 mux.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
438 mux.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
439 mux.HandleFunc("GET /ps/{id}/patches/{patchID}", ctxMdw(ctx, createPrDetail("ps")))
440 mux.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
441
442 mux.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
443 mux.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
444 embedFS, err := getEmbedFS(embedStaticFS, "static")
445 if err != nil {
446 panic(err)
447 }
448 userFS := getUserDefinedFS(cfg.DataDir, "static")
449
450 mux.HandleFunc("GET /static/{file}", ctxMdw(ctx, serveFile(userFS, embedFS)))
451 return mux
452}