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}