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