Eric Bower
·
2026-01-02
web.go
1package git
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 "net/url"
15 "os"
16 "path/filepath"
17 "slices"
18 "strconv"
19 "strings"
20 "time"
21
22 "github.com/alecthomas/chroma/v2"
23 formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
24 "github.com/alecthomas/chroma/v2/lexers"
25 "github.com/alecthomas/chroma/v2/styles"
26 "github.com/bluekeyes/go-gitdiff/gitdiff"
27 "github.com/gorilla/feeds"
28)
29
30var (
31 //go:embed tmpl/*
32 tmplFS embed.FS
33 indexTmpl = getTemplate("index.html")
34 prTmpl = getTemplate("pr.html")
35 userTmpl = getTemplate("user.html")
36 repoTmpl = getTemplate("repo.html")
37 toolTmpl = getTemplate("tool.html")
38)
39
40func getTemplate(page string) *template.Template {
41 tmpl, err := template.New("").Funcs(template.FuncMap{
42 "sha": shaFn,
43 }).ParseFS(
44 tmplFS,
45 filepath.Join("tmpl", "pages", page),
46 filepath.Join("tmpl", "components", "*.html"),
47 filepath.Join("tmpl", "base.html"),
48 )
49 if err != nil {
50 panic(err)
51 }
52 return tmpl.Lookup(page)
53}
54
55//go:embed static/*
56var embedStaticFS embed.FS
57
58type WebCtx struct {
59 Pr *PrCmd
60 Backend *Backend
61 Formatter *formatterHtml.Formatter
62 Logger *slog.Logger
63 Theme *chroma.Style
64}
65
66type ctxWeb struct{}
67
68func getWebCtx(r *http.Request) (*WebCtx, error) {
69 data, ok := r.Context().Value(ctxWeb{}).(*WebCtx)
70 if data == nil || !ok {
71 return data, fmt.Errorf("webCtx not set on `r.Context()` for connection")
72 }
73 return data, nil
74}
75
76func setWebCtx(ctx context.Context, web *WebCtx) context.Context {
77 return context.WithValue(ctx, ctxWeb{}, web)
78}
79
80// converts contents of files in git tree to pretty formatted code.
81func parseText(formatter *formatterHtml.Formatter, theme *chroma.Style, text string) (string, error) {
82 lexer := lexers.Get("diff")
83 iterator, err := lexer.Tokenise(nil, text)
84 if err != nil {
85 return text, err
86 }
87 var buf bytes.Buffer
88 err = formatter.Format(&buf, theme, iterator)
89 if err != nil {
90 return text, err
91 }
92 return buf.String(), nil
93}
94
95func ctxMdw(ctx context.Context, handler http.HandlerFunc) http.HandlerFunc {
96 return func(w http.ResponseWriter, r *http.Request) {
97 handler(w, r.WithContext(ctx))
98 }
99}
100
101func shaFn(sha string) string {
102 if sha == "" {
103 return "(none)"
104 }
105 return truncateSha(sha)
106}
107
108type LinkData struct {
109 Url template.URL
110 Text string
111}
112
113type BasicData struct {
114 MetaData
115}
116
117type PrTableData struct {
118 Prs []*PrListData
119 NumOpen int
120 NumAccepted int
121 NumClosed int
122 MetaData
123}
124
125type UserDetailData struct {
126 Prs []*PrListData
127 UserData UserData
128 NumOpen int
129 NumAccepted int
130 NumClosed int
131 MetaData
132}
133
134type RepoDetailData struct {
135 Name string
136 UserID int64
137 Username string
138 Branch string
139 Prs []*PrListData
140 NumOpen int
141 NumAccepted int
142 NumClosed int
143 MetaData
144}
145
146func createPrDataSorter(sort, sortDir string) func(a, b *PrListData) int {
147 return func(a *PrListData, b *PrListData) int {
148 if sort == "status" {
149 statusA := strings.ToLower(string(a.Status))
150 statusB := strings.ToLower(string(b.Status))
151 if sortDir == "asc" {
152 return strings.Compare(statusA, statusB)
153 } else {
154 return strings.Compare(statusB, statusA)
155 }
156 }
157
158 if sort == "title" {
159 titleA := strings.ToLower(a.Title)
160 titleB := strings.ToLower(b.Title)
161 if sortDir == "asc" {
162 return strings.Compare(titleA, titleB)
163 } else {
164 return strings.Compare(titleB, titleA)
165 }
166 }
167
168 if sort == "repo" {
169 repoA := strings.ToLower(a.RepoNs)
170 repoB := strings.ToLower(b.RepoNs)
171 if sortDir == "asc" {
172 return strings.Compare(repoA, repoB)
173 } else {
174 return strings.Compare(repoB, repoA)
175 }
176 }
177
178 if sort == "created_at" {
179 if sortDir == "asc" {
180 return a.DateOrig.Compare(b.DateOrig)
181 } else {
182 return b.DateOrig.Compare(a.DateOrig)
183 }
184 }
185
186 if sortDir == "desc" {
187 return int(b.ID) - int(a.ID)
188 }
189 return int(a.ID) - int(b.ID)
190 }
191}
192
193func getPrTableData(web *WebCtx, prs []*PatchRequest, query url.Values) ([]*PrListData, error) {
194 prdata := []*PrListData{}
195 status := Status(strings.ToLower(query.Get("status")))
196 if status == "" {
197 status = StatusOpen
198 }
199 username := strings.ToLower(query.Get("user"))
200 title := strings.ToLower(query.Get("title"))
201 sort := strings.ToLower(query.Get("sort"))
202 sortDir := strings.ToLower(query.Get("sort_dir"))
203 hasFilter := status != "" || username != "" || title != ""
204
205 for _, curpr := range prs {
206 user, err := web.Pr.GetUserByID(curpr.UserID)
207 if err != nil {
208 web.Logger.Error("cannot get user from pr", "err", err)
209 continue
210 }
211 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
212 if err != nil {
213 web.Logger.Error("cannot get pubkey from user public key", "err", err)
214 continue
215 }
216
217 repo, err := web.Pr.GetRepoByID(curpr.RepoID)
218 if err != nil {
219 web.Logger.Error("cannot get repo", "err", err)
220 continue
221 }
222
223 repoUser, err := web.Pr.GetUserByID(repo.UserID)
224 if err != nil {
225 web.Logger.Error("cannot get repo user", "err", err)
226 continue
227 }
228
229 ps, err := web.Pr.GetPatchsetsByPrID(curpr.ID)
230 if err != nil {
231 web.Logger.Error("cannot get patchsets for pr", "err", err)
232 continue
233 }
234
235 if hasFilter {
236 if status != "" {
237 if status != curpr.Status {
238 continue
239 }
240 }
241
242 if username != "" {
243 if username != strings.ToLower(user.Name) {
244 continue
245 }
246 }
247
248 if title != "" {
249 if !strings.Contains(strings.ToLower(curpr.Name), title) {
250 continue
251 }
252 }
253 }
254
255 isAdmin := web.Backend.IsAdmin(pk)
256 repoNs := web.Backend.CreateRepoNs(repoUser.Name, repo.Name)
257 prls := &PrListData{
258 RepoNs: repoNs,
259 ID: curpr.ID,
260 UserData: UserData{
261 Name: user.Name,
262 IsAdmin: isAdmin,
263 Pubkey: user.Pubkey,
264 },
265 RepoLink: LinkData{
266 Url: template.URL(fmt.Sprintf("/r/%s/%s", repoUser.Name, repo.Name)),
267 Text: repoNs,
268 },
269 PrLink: LinkData{
270 Url: template.URL(fmt.Sprintf("/prs/%d", curpr.ID)),
271 Text: curpr.Name,
272 },
273 NumPatchsets: len(ps),
274 DateOrig: curpr.CreatedAt,
275 Date: curpr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
276 Status: curpr.Status,
277 }
278 prdata = append(prdata, prls)
279 }
280
281 if sort != "" {
282 if sortDir == "" {
283 sortDir = "asc"
284 }
285 slices.SortFunc(prdata, createPrDataSorter(sort, sortDir))
286 }
287
288 return prdata, nil
289}
290
291func indexHandler(w http.ResponseWriter, r *http.Request) {
292 web, err := getWebCtx(r)
293 if err != nil {
294 w.WriteHeader(http.StatusInternalServerError)
295 return
296 }
297
298 prs, err := web.Pr.GetPatchRequests()
299 if err != nil {
300 web.Logger.Error("could not get prs", "err", err)
301 w.WriteHeader(http.StatusInternalServerError)
302 return
303 }
304
305 prdata, err := getPrTableData(web, prs, r.URL.Query())
306 if err != nil {
307 web.Logger.Error("could not get pr table data", "err", err)
308 w.WriteHeader(http.StatusInternalServerError)
309 return
310 }
311
312 numOpen := 0
313 numAccepted := 0
314 numClosed := 0
315 for _, pr := range prs {
316 switch pr.Status {
317 case "open":
318 numOpen += 1
319 case "accepted":
320 numAccepted += 1
321 case "closed":
322 numClosed += 1
323 }
324 }
325
326 w.Header().Set("content-type", "text/html")
327 err = indexTmpl.Execute(w, PrTableData{
328 NumOpen: numOpen,
329 NumAccepted: numAccepted,
330 NumClosed: numClosed,
331 Prs: prdata,
332 MetaData: MetaData{
333 URL: web.Backend.Cfg.Url,
334 Desc: template.HTML(web.Backend.Cfg.Desc),
335 },
336 })
337 if err != nil {
338 web.Backend.Logger.Error("cannot execute template", "err", err)
339 }
340}
341
342type UserData struct {
343 UserID int64
344 Name string
345 IsAdmin bool
346 Pubkey string
347 CreatedAt string
348}
349
350type MetaData struct {
351 URL string
352 Desc template.HTML
353}
354
355type PrListData struct {
356 UserData
357 RepoNs string
358 RepoLink LinkData
359 PrLink LinkData
360 Title string
361 NumPatchsets int
362 ID int64
363 DateOrig time.Time
364 Date string
365 Status Status
366}
367
368func userDetailHandler(w http.ResponseWriter, r *http.Request) {
369 userName := r.PathValue("user")
370
371 web, err := getWebCtx(r)
372 if err != nil {
373 web.Logger.Error("fetch web", "err", err)
374 w.WriteHeader(http.StatusInternalServerError)
375 return
376 }
377
378 user, err := web.Pr.GetUserByName(userName)
379 if err != nil {
380 web.Logger.Error("cannot find user by name", "err", err, "name", userName)
381 w.WriteHeader(http.StatusNotFound)
382 return
383 }
384
385 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
386 if err != nil {
387 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
388 w.WriteHeader(http.StatusUnprocessableEntity)
389 return
390 }
391 isAdmin := web.Backend.IsAdmin(pk)
392
393 prs, err := web.Pr.GetPatchRequestsByPubkey(user.Pubkey)
394 if err != nil {
395 web.Logger.Error("cannot get prs", "err", err)
396 w.WriteHeader(http.StatusInternalServerError)
397 return
398 }
399
400 query := r.URL.Query()
401 query.Set("user", userName)
402
403 prdata, err := getPrTableData(web, prs, query)
404 if err != nil {
405 web.Logger.Error("cannot get pr table data", "err", err)
406 w.WriteHeader(http.StatusInternalServerError)
407 return
408 }
409
410 numOpen := 0
411 numAccepted := 0
412 numClosed := 0
413 for _, pr := range prs {
414 switch pr.Status {
415 case "open":
416 numOpen += 1
417 case "accepted":
418 numAccepted += 1
419 case "closed":
420 numClosed += 1
421 }
422 }
423
424 w.Header().Set("content-type", "text/html")
425 err = userTmpl.Execute(w, UserDetailData{
426 Prs: prdata,
427 NumOpen: numOpen,
428 NumAccepted: numAccepted,
429 NumClosed: numClosed,
430 UserData: UserData{
431 UserID: user.ID,
432 Name: user.Name,
433 Pubkey: user.Pubkey,
434 CreatedAt: user.CreatedAt.Format(time.RFC3339),
435 IsAdmin: isAdmin,
436 },
437 MetaData: MetaData{
438 URL: web.Backend.Cfg.Url,
439 },
440 })
441 if err != nil {
442 web.Backend.Logger.Error("cannot execute template", "err", err)
443 }
444}
445
446func repoDetailHandler(w http.ResponseWriter, r *http.Request) {
447 userName := r.PathValue("user")
448 repoName := r.PathValue("repo")
449
450 web, err := getWebCtx(r)
451 if err != nil {
452 web.Logger.Error("fetch web", "err", err)
453 w.WriteHeader(http.StatusInternalServerError)
454 return
455 }
456
457 user, err := web.Pr.GetUserByName(userName)
458 if err != nil {
459 web.Logger.Error("cannot find user", "user", user, "err", err)
460 w.WriteHeader(http.StatusNotFound)
461 return
462 }
463
464 repo, err := web.Pr.GetRepoByName(user, repoName)
465 if err != nil {
466 web.Logger.Error("cannot find repo", "user", user, "err", err)
467 w.WriteHeader(http.StatusNotFound)
468 return
469 }
470
471 prs, err := web.Pr.GetPatchRequestsByRepoID(repo.ID)
472 if err != nil {
473 web.Logger.Error("cannot get prs", "err", err)
474 w.WriteHeader(http.StatusInternalServerError)
475 return
476 }
477
478 prdata, err := getPrTableData(web, prs, r.URL.Query())
479 if err != nil {
480 web.Logger.Error("cannot get pr table data", "err", err)
481 w.WriteHeader(http.StatusInternalServerError)
482 return
483 }
484
485 numOpen := 0
486 numAccepted := 0
487 numClosed := 0
488 for _, pr := range prs {
489 switch pr.Status {
490 case "open":
491 numOpen += 1
492 case "accepted":
493 numAccepted += 1
494 case "closed":
495 numClosed += 1
496 }
497 }
498
499 w.Header().Set("content-type", "text/html")
500 err = repoTmpl.Execute(w, RepoDetailData{
501 Name: repo.Name,
502 UserID: user.ID,
503 Username: userName,
504 Branch: "main",
505 Prs: prdata,
506 NumOpen: numOpen,
507 NumAccepted: numAccepted,
508 NumClosed: numClosed,
509 MetaData: MetaData{
510 URL: web.Backend.Cfg.Url,
511 },
512 })
513 if err != nil {
514 web.Backend.Logger.Error("cannot execute template", "err", err)
515 }
516}
517
518type PrData struct {
519 UserData
520 ID int64
521 Title string
522 Date string
523 Status Status
524}
525
526type PatchFile struct {
527 *gitdiff.File
528 Adds int64
529 Dels int64
530 DiffText template.HTML
531}
532
533type PatchData struct {
534 *Patch
535 PatchFiles []*PatchFile
536 PatchHeader *gitdiff.PatchHeader
537 Url template.URL
538 Review bool
539 FormattedAuthorDate string
540}
541
542type EventLogData struct {
543 *EventLog
544 UserData
545 *Patchset
546 FormattedPatchsetID string
547 Date string
548 RangeDiff []*RangeDiffOutput
549}
550
551type PatchsetData struct {
552 *Patchset
553 UserData
554 FormattedID string
555 Date string
556 RangeDiff []*RangeDiffOutput
557}
558
559type PrDetailData struct {
560 Page string
561 Tab string
562 Repo LinkData
563 Pr PrData
564 Patchset *Patchset
565 PatchsetData *PatchsetData
566 Patches []PatchData
567 Branch string
568 Logs []EventLogData
569 Patchsets []PatchsetData
570 IsRangeDiff bool
571 MetaData
572}
573
574type ToolData struct {
575 Patchset *Patchset
576 PatchsetData *PatchsetData
577 MetaData
578}
579
580func createPrDetail(page string) http.HandlerFunc {
581 return func(w http.ResponseWriter, r *http.Request) {
582 id := r.PathValue("id")
583 prID, err := strconv.Atoi(id)
584 if err != nil {
585 w.WriteHeader(http.StatusUnprocessableEntity)
586 return
587 }
588
589 web, err := getWebCtx(r)
590 if err != nil {
591 w.WriteHeader(http.StatusInternalServerError)
592 return
593 }
594
595 var pr *PatchRequest
596 var ps *Patchset
597 switch page {
598 case "pr":
599 {
600 pr, err = web.Pr.GetPatchRequestByID(int64(prID))
601 if err != nil {
602 web.Pr.Backend.Logger.Error("cannot get prs", "err", err)
603 w.WriteHeader(http.StatusInternalServerError)
604 return
605 }
606 }
607 case "ps":
608 fallthrough
609 case "rd":
610 {
611 ps, err = web.Pr.GetPatchsetByID(int64(prID))
612 if err != nil {
613 web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
614 w.WriteHeader(http.StatusInternalServerError)
615 return
616 }
617
618 pr, err = web.Pr.GetPatchRequestByID(int64(ps.PatchRequestID))
619 if err != nil {
620 web.Pr.Backend.Logger.Error("cannot get pr", "err", err)
621 w.WriteHeader(http.StatusInternalServerError)
622 return
623 }
624 }
625 }
626
627 patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
628 if err != nil {
629 web.Logger.Error("cannot get latest patchset", "err", err)
630 w.WriteHeader(http.StatusInternalServerError)
631 return
632 }
633
634 // get patchsets and diff from previous patchset
635 patchsetsData := []PatchsetData{}
636 var selectedPatchsetData *PatchsetData
637 for idx, patchset := range patchsets {
638 user, err := web.Pr.GetUserByID(patchset.UserID)
639 if err != nil {
640 web.Logger.Error("could not get user for patch", "err", err)
641 continue
642 }
643
644 var prevPatchset *Patchset
645 if idx > 0 {
646 prevPatchset = patchsets[idx-1]
647 }
648
649 var rangeDiff []*RangeDiffOutput
650 if idx > 0 {
651 rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
652 if err != nil {
653 web.Logger.Error("could not diff patchset", "err", err)
654 continue
655 }
656 }
657
658 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
659 if err != nil {
660 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
661 w.WriteHeader(http.StatusUnprocessableEntity)
662 return
663 }
664
665 // set selected patchset to latest when no ps already set
666 if ps == nil && idx == len(patchsets)-1 {
667 ps = patchset
668 }
669
670 data := PatchsetData{
671 Patchset: patchset,
672 FormattedID: getFormattedPatchsetID(patchset.ID),
673 UserData: UserData{
674 UserID: user.ID,
675 Name: user.Name,
676 IsAdmin: web.Backend.IsAdmin(pk),
677 Pubkey: user.Pubkey,
678 CreatedAt: user.CreatedAt.Format(time.RFC3339),
679 },
680 Date: patchset.CreatedAt.Format(time.RFC3339),
681 RangeDiff: rangeDiff,
682 }
683 patchsetsData = append(patchsetsData, data)
684 if ps != nil && ps.ID == patchset.ID {
685 selectedPatchsetData = &data
686 }
687 }
688
689 patchesData := []PatchData{}
690 if len(patchsetsData) >= 1 {
691 psID := ps.ID
692 patches, err := web.Pr.GetPatchesByPatchsetID(psID)
693 if err != nil {
694 web.Logger.Error("cannot get patches", "err", err)
695 w.WriteHeader(http.StatusInternalServerError)
696 return
697 }
698
699 // TODO: a little hacky
700 reviewIDs := []int64{}
701 for _, data := range patchsetsData {
702 if psID != data.ID {
703 continue
704 }
705 if !data.Review {
706 continue
707 }
708
709 for _, rdiff := range data.RangeDiff {
710 if rdiff.Type == "add" {
711 for _, patch := range patches {
712 commSha := truncateSha(patch.CommitSha)
713 if strings.Contains(rdiff.Header.String(), commSha) {
714 reviewIDs = append(reviewIDs, patch.ID)
715 break
716 }
717 }
718 }
719 }
720 break
721 }
722
723 for _, patch := range patches {
724 diffFiles, preamble, err := ParsePatch(patch.RawText)
725 if err != nil {
726 web.Logger.Error("cannot parse patch", "err", err)
727 w.WriteHeader(http.StatusUnprocessableEntity)
728 return
729 }
730 header, err := gitdiff.ParsePatchHeader(preamble)
731 if err != nil {
732 web.Logger.Error("cannot parse patch", "err", err)
733 w.WriteHeader(http.StatusUnprocessableEntity)
734 return
735 }
736
737 // highlight review
738 isReview := slices.Contains(reviewIDs, patch.ID)
739
740 patchFiles := []*PatchFile{}
741 for _, file := range diffFiles {
742 var adds int64 = 0
743 var dels int64 = 0
744 for _, frag := range file.TextFragments {
745 adds += frag.LinesAdded
746 dels += frag.LinesDeleted
747 }
748
749 diffStr, err := parseText(web.Formatter, web.Theme, file.String())
750 if err != nil {
751 web.Logger.Error("cannot parse patch", "err", err)
752 w.WriteHeader(http.StatusUnprocessableEntity)
753 return
754 }
755
756 patchFiles = append(patchFiles, &PatchFile{
757 File: file,
758 Adds: adds,
759 Dels: dels,
760 DiffText: template.HTML(diffStr),
761 })
762 }
763
764 timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
765 patchesData = append(patchesData, PatchData{
766 Patch: patch,
767 Url: template.URL(fmt.Sprintf("patch-%d", patch.ID)),
768 Review: isReview,
769 FormattedAuthorDate: timestamp,
770 PatchFiles: patchFiles,
771 PatchHeader: header,
772 })
773 }
774 }
775
776 user, err := web.Pr.GetUserByID(pr.UserID)
777 if err != nil {
778 w.WriteHeader(http.StatusNotFound)
779 return
780 }
781
782 w.Header().Set("content-type", "text/html")
783 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
784 if err != nil {
785 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
786 w.WriteHeader(http.StatusUnprocessableEntity)
787 return
788 }
789 isAdmin := web.Backend.IsAdmin(pk)
790 logs, err := web.Pr.GetEventLogsByPrID(pr.ID)
791 if err != nil {
792 web.Logger.Error("cannot get logs for pr", "err", err)
793 w.WriteHeader(http.StatusUnprocessableEntity)
794 return
795 }
796 slices.SortFunc(logs, func(a *EventLog, b *EventLog) int {
797 return a.CreatedAt.Compare(b.CreatedAt)
798 })
799
800 logData := []EventLogData{}
801 for _, eventlog := range logs {
802 user, _ := web.Pr.GetUserByID(eventlog.UserID)
803 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
804 if err != nil {
805 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
806 w.WriteHeader(http.StatusUnprocessableEntity)
807 return
808 }
809 var logps *Patchset
810 var rangeDiff []*RangeDiffOutput
811 if eventlog.PatchsetID.Int64 > 0 {
812 logps, err = web.Pr.GetPatchsetByID(eventlog.PatchsetID.Int64)
813 if err != nil {
814 web.Logger.Error("cannot get patchset", "err", err, "ps", eventlog.PatchsetID)
815 w.WriteHeader(http.StatusUnprocessableEntity)
816 return
817 }
818 for _, psData := range patchsetsData {
819 if psData.ID == eventlog.PatchsetID.Int64 {
820 rangeDiff = psData.RangeDiff
821 break
822 }
823 }
824 }
825
826 logData = append(logData, EventLogData{
827 EventLog: eventlog,
828 FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
829 Patchset: logps,
830 RangeDiff: rangeDiff,
831 UserData: UserData{
832 UserID: user.ID,
833 Name: user.Name,
834 IsAdmin: web.Backend.IsAdmin(pk),
835 Pubkey: user.Pubkey,
836 CreatedAt: user.CreatedAt.Format(time.RFC3339),
837 },
838 Date: eventlog.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
839 })
840 }
841
842 repo, err := web.Pr.GetRepoByID(pr.RepoID)
843 if err != nil {
844 web.Logger.Error("cannot get repo for pr", "err", err)
845 w.WriteHeader(http.StatusUnprocessableEntity)
846 return
847 }
848
849 repoOwner, err := web.Pr.GetUserByID(repo.UserID)
850 if err != nil {
851 web.Logger.Error("cannot get repo for pr", "err", err)
852 w.WriteHeader(http.StatusUnprocessableEntity)
853 return
854 }
855
856 repoNs := web.Backend.CreateRepoNs(repoOwner.Name, repo.Name)
857 url := fmt.Sprintf("/r/%s/%s", repoOwner.Name, repo.Name)
858 tab := "timeline"
859 if page == "ps" || page == "rd" {
860 tab = "patchsets"
861 }
862
863 err = prTmpl.Execute(w, PrDetailData{
864 Page: page,
865 Tab: tab,
866 Repo: LinkData{
867 Url: template.URL(url),
868 Text: repoNs,
869 },
870 Branch: "main",
871 Patchset: ps,
872 PatchsetData: selectedPatchsetData,
873 IsRangeDiff: page == "rd",
874 Patches: patchesData,
875 Patchsets: patchsetsData,
876 Logs: logData,
877 Pr: PrData{
878 ID: pr.ID,
879 UserData: UserData{
880 UserID: user.ID,
881 Name: user.Name,
882 IsAdmin: isAdmin,
883 Pubkey: user.Pubkey,
884 CreatedAt: user.CreatedAt.Format(time.RFC3339),
885 },
886 Title: pr.Name,
887 Date: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
888 Status: pr.Status,
889 },
890 MetaData: MetaData{
891 URL: web.Backend.Cfg.Url,
892 },
893 })
894 if err != nil {
895 web.Backend.Logger.Error("cannot execute template", "err", err)
896 }
897 }
898}
899
900func toolHandlerGet(w http.ResponseWriter, r *http.Request) {
901 web, err := getWebCtx(r)
902 if err != nil {
903 w.WriteHeader(http.StatusUnprocessableEntity)
904 return
905 }
906
907 err = toolTmpl.Execute(w, ToolData{
908 MetaData: MetaData{
909 URL: web.Backend.Cfg.Url,
910 },
911 Patchset: &Patchset{
912 ID: 0,
913 },
914 PatchsetData: &PatchsetData{
915 RangeDiff: []*RangeDiffOutput{},
916 },
917 })
918 if err != nil {
919 web.Backend.Logger.Error("cannot execute template", "err", err)
920 }
921}
922
923func toolHandlerPost(w http.ResponseWriter, r *http.Request) {
924 web, err := getWebCtx(r)
925 if err != nil {
926 web.Backend.Logger.Error("web ctx not found", "err", err)
927 w.WriteHeader(http.StatusUnprocessableEntity)
928 return
929 }
930
931 if err := r.ParseForm(); err != nil {
932 web.Backend.Logger.Error("parse form", "err", err)
933 http.Error(w, "Failed to parse form", http.StatusBadRequest)
934 return
935 }
936
937 prevPs := r.PostFormValue("prev_patchset")
938 prevPs = strings.ReplaceAll(prevPs, "\r", "")
939 nextPs := r.PostFormValue("next_patchset")
940 nextPs = strings.ReplaceAll(nextPs, "\r", "")
941
942 prevPatchset, err := ParsePatchset(strings.NewReader(prevPs))
943 if err != nil {
944 web.Backend.Logger.Error("parse prev patchset", "err", err)
945 w.WriteHeader(http.StatusUnprocessableEntity)
946 return
947 }
948 nextPatchset, err := ParsePatchset(strings.NewReader(nextPs))
949 if err != nil {
950 web.Backend.Logger.Error("parse next patchset", "err", err)
951 w.WriteHeader(http.StatusUnprocessableEntity)
952 return
953 }
954 rangeDiff := RangeDiff(prevPatchset, nextPatchset)
955
956 err = toolTmpl.Execute(w, ToolData{
957 MetaData: MetaData{
958 URL: web.Backend.Cfg.Url,
959 },
960 Patchset: &Patchset{
961 ID: 0,
962 },
963 PatchsetData: &PatchsetData{
964 RangeDiff: rangeDiff,
965 },
966 })
967 if err != nil {
968 web.Backend.Logger.Error("cannot execute template", "err", err)
969 }
970}
971
972func rssHandler(w http.ResponseWriter, r *http.Request) {
973 web, err := getWebCtx(r)
974 if err != nil {
975 w.WriteHeader(http.StatusUnprocessableEntity)
976 return
977 }
978
979 desc := fmt.Sprintf(
980 "Events related to git collaboration server %s",
981 web.Backend.Cfg.Url,
982 )
983 feed := &feeds.Feed{
984 Title: fmt.Sprintf("%s events", web.Backend.Cfg.Url),
985 Link: &feeds.Link{Href: web.Backend.Cfg.Url},
986 Description: desc,
987 Author: &feeds.Author{Name: "git collaboration server"},
988 Created: time.Now(),
989 }
990
991 var eventLogs []*EventLog
992 id := r.PathValue("id")
993 pubkey := r.URL.Query().Get("pubkey")
994 username := r.PathValue("user")
995 repoName := r.PathValue("repo")
996
997 if id != "" {
998 var prID int64
999 prID, err = getPrID(id)
1000 if err != nil {
1001 w.WriteHeader(http.StatusUnprocessableEntity)
1002 return
1003 }
1004 eventLogs, err = web.Pr.GetEventLogsByPrID(prID)
1005 } else if pubkey != "" {
1006 user, perr := web.Pr.GetUserByPubkey(pubkey)
1007 if perr != nil {
1008 w.WriteHeader(http.StatusNotFound)
1009 return
1010 }
1011 eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
1012 } else if username != "" {
1013 user, perr := web.Pr.GetUserByName(username)
1014 if perr != nil {
1015 w.WriteHeader(http.StatusNotFound)
1016 return
1017 }
1018 eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
1019 } else if repoName != "" {
1020 user, perr := web.Pr.GetUserByName(username)
1021 if perr != nil {
1022 w.WriteHeader(http.StatusNotFound)
1023 return
1024 }
1025 eventLogs, err = web.Pr.GetEventLogsByRepoName(user, repoName)
1026 } else {
1027 eventLogs, err = web.Pr.GetEventLogs()
1028 }
1029
1030 if err != nil {
1031 web.Logger.Error("rss could not get eventLogs", "err", err)
1032 w.WriteHeader(http.StatusInternalServerError)
1033 return
1034 }
1035
1036 var feedItems []*feeds.Item
1037 for _, eventLog := range eventLogs {
1038 user, err := web.Pr.GetUserByID(eventLog.UserID)
1039 if err != nil {
1040 web.Logger.Error("user not found for event log", "id", eventLog.ID, "err", err)
1041 continue
1042 }
1043
1044 repo := &Repo{Name: "unknown"}
1045 if eventLog.RepoID.Valid {
1046 repo, err = web.Pr.GetRepoByID(eventLog.RepoID.Int64)
1047 if err != nil {
1048 web.Logger.Error("repo not found for event log", "id", eventLog.ID, "err", err)
1049 continue
1050 }
1051 }
1052
1053 realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID.Int64)
1054 content := fmt.Sprintf(
1055 "<div><div>RepoID: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
1056 web.Backend.CreateRepoNs(user.Name, repo.Name),
1057 eventLog.PatchRequestID.Int64,
1058 eventLog.Event,
1059 eventLog.CreatedAt.Format(time.RFC3339Nano),
1060 eventLog.Data,
1061 )
1062 pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID.Int64)
1063 if err != nil {
1064 continue
1065 }
1066
1067 title := fmt.Sprintf(
1068 `%s in %s for PR "%s" (#%d)`,
1069 eventLog.Event,
1070 web.Backend.CreateRepoNs(user.Name, repo.Name),
1071 pr.Name,
1072 eventLog.PatchRequestID.Int64,
1073 )
1074 item := &feeds.Item{
1075 Id: fmt.Sprintf("%d", eventLog.ID),
1076 Title: title,
1077 Link: &feeds.Link{Href: realUrl},
1078 Content: content,
1079 Created: eventLog.CreatedAt,
1080 Description: title,
1081 Author: &feeds.Author{Name: user.Name},
1082 }
1083
1084 feedItems = append(feedItems, item)
1085 }
1086 feed.Items = feedItems
1087
1088 rss, err := feed.ToAtom()
1089 if err != nil {
1090 web.Logger.Error("could not generate atom rss feed", "err", err)
1091 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
1092 }
1093
1094 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
1095 _, err = w.Write([]byte(rss))
1096 if err != nil {
1097 web.Logger.Error("write error atom rss feed", "err", err)
1098 }
1099}
1100
1101func chromaStyleHandler(w http.ResponseWriter, r *http.Request) {
1102 web, err := getWebCtx(r)
1103 if err != nil {
1104 w.WriteHeader(http.StatusUnprocessableEntity)
1105 return
1106 }
1107 w.Header().Add("content-type", "text/css")
1108 err = web.Formatter.WriteCSS(w, web.Theme)
1109 if err != nil {
1110 web.Backend.Logger.Error("cannot write css file", "err", err)
1111 }
1112}
1113
1114func serveFile(userfs fs.FS, embedfs fs.FS) func(w http.ResponseWriter, r *http.Request) {
1115 return func(w http.ResponseWriter, r *http.Request) {
1116 web, err := getWebCtx(r)
1117 if err != nil {
1118 w.WriteHeader(http.StatusUnprocessableEntity)
1119 return
1120 }
1121 logger := web.Logger
1122
1123 file := r.PathValue("file")
1124
1125 logger.Info("serving file", "file", file)
1126 // merging both embedded fs and whatever user provides
1127 var reader fs.File
1128 if userfs == nil {
1129 reader, err = embedfs.Open(file)
1130 } else {
1131 reader, err = userfs.Open(file)
1132 if err != nil {
1133 // serve embeded static folder
1134 reader, err = embedfs.Open(file)
1135 }
1136 }
1137
1138 if err != nil {
1139 logger.Error(err.Error())
1140 http.Error(w, "file not found", 404)
1141 return
1142 }
1143
1144 contents, err := io.ReadAll(reader)
1145 if err != nil {
1146 logger.Error(err.Error())
1147 http.Error(w, "file not found", 404)
1148 return
1149 }
1150 contentType := mime.TypeByExtension(filepath.Ext(file))
1151 if contentType == "" {
1152 contentType = http.DetectContentType(contents)
1153 }
1154 w.Header().Add("Content-Type", contentType)
1155
1156 _, err = w.Write(contents)
1157 if err != nil {
1158 logger.Error(err.Error())
1159 http.Error(w, "server error", 500)
1160 return
1161 }
1162 }
1163}
1164
1165func getUserDefinedFS(datadir, dirName string) fs.FS {
1166 dir := filepath.Join(datadir, dirName)
1167 _, err := os.Stat(dir)
1168 if err != nil {
1169 return nil
1170 }
1171 return os.DirFS(dir)
1172}
1173
1174func getEmbedFS(ffs embed.FS, dirName string) (fs.FS, error) {
1175 fsys, err := fs.Sub(ffs, dirName)
1176 if err != nil {
1177 return nil, err
1178 }
1179 return fsys, nil
1180}
1181
1182func GitWebServer(cfg *GitCfg) http.Handler {
1183 dbpath := filepath.Join(cfg.DataDir, "pr.db?_fk=on")
1184 dbh, err := SqliteOpen("file:"+dbpath, cfg.Logger)
1185 if err != nil {
1186 panic(fmt.Sprintf("cannot find database file, check folder and perms: %s: %s", dbpath, err))
1187 }
1188
1189 be := &Backend{
1190 DB: dbh,
1191 Logger: cfg.Logger,
1192 Cfg: cfg,
1193 }
1194 prCmd := &PrCmd{
1195 Backend: be,
1196 }
1197 formatter := formatterHtml.New(
1198 formatterHtml.WithLineNumbers(true),
1199 formatterHtml.LineNumbersInTable(true),
1200 formatterHtml.WithClasses(true),
1201 formatterHtml.WithLinkableLineNumbers(true, "gitpr"),
1202 )
1203 web := &WebCtx{
1204 Pr: prCmd,
1205 Backend: be,
1206 Logger: cfg.Logger,
1207 Formatter: formatter,
1208 Theme: styles.Get(cfg.Theme),
1209 }
1210
1211 ctx := context.Background()
1212 ctx = setWebCtx(ctx, web)
1213
1214 // ensure legacy router is disabled
1215 // GODEBUG=httpmuxgo121=0
1216 mux := http.NewServeMux()
1217 mux.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
1218 mux.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
1219 mux.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
1220 mux.HandleFunc("GET /rd/{id}", ctxMdw(ctx, createPrDetail("rd")))
1221 mux.HandleFunc("GET /r/{user}/{repo}/rss", ctxMdw(ctx, rssHandler))
1222 mux.HandleFunc("GET /r/{user}/{repo}", ctxMdw(ctx, repoDetailHandler))
1223 mux.HandleFunc("GET /r/{user}", ctxMdw(ctx, userDetailHandler))
1224 mux.HandleFunc("GET /rss/{user}", ctxMdw(ctx, rssHandler))
1225 mux.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
1226 mux.HandleFunc("GET /tool", ctxMdw(ctx, toolHandlerGet))
1227 mux.HandleFunc("POST /tool", ctxMdw(ctx, toolHandlerPost))
1228 mux.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
1229 mux.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
1230 embedFS, err := getEmbedFS(embedStaticFS, "static")
1231 if err != nil {
1232 panic(err)
1233 }
1234 userFS := getUserDefinedFS(cfg.DataDir, "static")
1235
1236 mux.HandleFunc("GET /static/{file}", ctxMdw(ctx, serveFile(userFS, embedFS)))
1237 return mux
1238}