jolheiser
·
2025-04-07
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
30//go:embed tmpl/*
31var tmplFS embed.FS
32
33//go:embed static/*
34var embedStaticFS embed.FS
35
36type WebCtx struct {
37 Pr *PrCmd
38 Backend *Backend
39 Formatter *formatterHtml.Formatter
40 Logger *slog.Logger
41 Theme *chroma.Style
42}
43
44type ctxWeb struct{}
45
46func getWebCtx(r *http.Request) (*WebCtx, error) {
47 data, ok := r.Context().Value(ctxWeb{}).(*WebCtx)
48 if data == nil || !ok {
49 return data, fmt.Errorf("webCtx not set on `r.Context()` for connection")
50 }
51 return data, nil
52}
53
54func setWebCtx(ctx context.Context, web *WebCtx) context.Context {
55 return context.WithValue(ctx, ctxWeb{}, web)
56}
57
58// converts contents of files in git tree to pretty formatted code.
59func parseText(formatter *formatterHtml.Formatter, theme *chroma.Style, text string) (string, error) {
60 lexer := lexers.Get("diff")
61 iterator, err := lexer.Tokenise(nil, text)
62 if err != nil {
63 return text, err
64 }
65 var buf bytes.Buffer
66 err = formatter.Format(&buf, theme, iterator)
67 if err != nil {
68 return text, err
69 }
70 return buf.String(), nil
71}
72
73func ctxMdw(ctx context.Context, handler http.HandlerFunc) http.HandlerFunc {
74 return func(w http.ResponseWriter, r *http.Request) {
75 handler(w, r.WithContext(ctx))
76 }
77}
78
79func shaFn(sha string) string {
80 if sha == "" {
81 return "(none)"
82 }
83 return truncateSha(sha)
84}
85
86func getTemplate(file string) *template.Template {
87 tmpl, err := template.New("").Funcs(template.FuncMap{
88 "sha": shaFn,
89 }).ParseFS(
90 tmplFS,
91 filepath.Join("tmpl", file),
92 filepath.Join("tmpl", "user-pill.html"),
93 filepath.Join("tmpl", "patchset.html"),
94 filepath.Join("tmpl", "range-diff.html"),
95 filepath.Join("tmpl", "pr-header.html"),
96 filepath.Join("tmpl", "pr-list-item.html"),
97 filepath.Join("tmpl", "pr-table.html"),
98 filepath.Join("tmpl", "pr-status.html"),
99 filepath.Join("tmpl", "base.html"),
100 )
101 if err != nil {
102 panic(err)
103 }
104 return tmpl
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(a.Status)
149 statusB := strings.ToLower(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 := strings.ToLower(query.Get("status"))
195 if status == "" {
196 status = "open"
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 tmpl := getTemplate("index.html")
327 err = tmpl.ExecuteTemplate(w, "index.html", 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 string
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 tmpl := getTemplate("user-detail.html")
426 err = tmpl.ExecuteTemplate(w, "user-detail.html", UserDetailData{
427 Prs: prdata,
428 NumOpen: numOpen,
429 NumAccepted: numAccepted,
430 NumClosed: numClosed,
431 UserData: UserData{
432 UserID: user.ID,
433 Name: user.Name,
434 Pubkey: user.Pubkey,
435 CreatedAt: user.CreatedAt.Format(time.RFC3339),
436 IsAdmin: isAdmin,
437 },
438 MetaData: MetaData{
439 URL: web.Backend.Cfg.Url,
440 },
441 })
442 if err != nil {
443 web.Backend.Logger.Error("cannot execute template", "err", err)
444 }
445}
446
447func repoDetailHandler(w http.ResponseWriter, r *http.Request) {
448 userName := r.PathValue("user")
449 repoName := r.PathValue("repo")
450
451 web, err := getWebCtx(r)
452 if err != nil {
453 web.Logger.Error("fetch web", "err", err)
454 w.WriteHeader(http.StatusInternalServerError)
455 return
456 }
457
458 user, err := web.Pr.GetUserByName(userName)
459 if err != nil {
460 web.Logger.Error("cannot find user", "user", user, "err", err)
461 w.WriteHeader(http.StatusNotFound)
462 return
463 }
464
465 repo, err := web.Pr.GetRepoByName(user, repoName)
466 if err != nil {
467 web.Logger.Error("cannot find repo", "user", user, "err", err)
468 w.WriteHeader(http.StatusNotFound)
469 return
470 }
471
472 prs, err := web.Pr.GetPatchRequestsByRepoID(repo.ID)
473 if err != nil {
474 web.Logger.Error("cannot get prs", "err", err)
475 w.WriteHeader(http.StatusInternalServerError)
476 return
477 }
478
479 prdata, err := getPrTableData(web, prs, r.URL.Query())
480 if err != nil {
481 web.Logger.Error("cannot get pr table data", "err", err)
482 w.WriteHeader(http.StatusInternalServerError)
483 return
484 }
485
486 numOpen := 0
487 numAccepted := 0
488 numClosed := 0
489 for _, pr := range prs {
490 switch pr.Status {
491 case "open":
492 numOpen += 1
493 case "accepted":
494 numAccepted += 1
495 case "closed":
496 numClosed += 1
497 }
498 }
499
500 w.Header().Set("content-type", "text/html")
501 tmpl := getTemplate("repo-detail.html")
502 err = tmpl.ExecuteTemplate(w, "repo-detail.html", RepoDetailData{
503 Name: repo.Name,
504 UserID: user.ID,
505 Username: userName,
506 Prs: prdata,
507 NumOpen: numOpen,
508 NumAccepted: numAccepted,
509 NumClosed: numClosed,
510 MetaData: MetaData{
511 URL: web.Backend.Cfg.Url,
512 },
513 })
514 if err != nil {
515 web.Backend.Logger.Error("cannot execute template", "err", err)
516 }
517}
518
519type PrData struct {
520 UserData
521 ID int64
522 Title string
523 Date string
524 Status string
525}
526
527type PatchFile struct {
528 *gitdiff.File
529 Adds int64
530 Dels int64
531 DiffText template.HTML
532}
533
534type PatchData struct {
535 *Patch
536 PatchFiles []*PatchFile
537 PatchHeader *gitdiff.PatchHeader
538 Url template.URL
539 Review bool
540 FormattedAuthorDate string
541}
542
543type EventLogData struct {
544 *EventLog
545 UserData
546 *Patchset
547 FormattedPatchsetID string
548 Date string
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 Repo LinkData
562 Pr PrData
563 Patchset *Patchset
564 PatchsetData *PatchsetData
565 Patches []PatchData
566 Branch string
567 Logs []EventLogData
568 Patchsets []PatchsetData
569 IsRangeDiff bool
570 MetaData
571}
572
573func createPrDetail(page string) http.HandlerFunc {
574 return func(w http.ResponseWriter, r *http.Request) {
575 id := r.PathValue("id")
576 prID, err := strconv.Atoi(id)
577 if err != nil {
578 w.WriteHeader(http.StatusUnprocessableEntity)
579 return
580 }
581
582 web, err := getWebCtx(r)
583 if err != nil {
584 w.WriteHeader(http.StatusInternalServerError)
585 return
586 }
587
588 var pr *PatchRequest
589 var ps *Patchset
590 if page == "pr" {
591 pr, err = web.Pr.GetPatchRequestByID(int64(prID))
592 if err != nil {
593 web.Pr.Backend.Logger.Error("cannot get prs", "err", err)
594 w.WriteHeader(http.StatusInternalServerError)
595 return
596 }
597 } else if page == "ps" || page == "rd" {
598 ps, err = web.Pr.GetPatchsetByID(int64(prID))
599 if err != nil {
600 web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
601 w.WriteHeader(http.StatusInternalServerError)
602 return
603 }
604
605 pr, err = web.Pr.GetPatchRequestByID(int64(ps.PatchRequestID))
606 if err != nil {
607 web.Pr.Backend.Logger.Error("cannot get pr", "err", err)
608 w.WriteHeader(http.StatusInternalServerError)
609 return
610 }
611 }
612
613 patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
614 if err != nil {
615 web.Logger.Error("cannot get latest patchset", "err", err)
616 w.WriteHeader(http.StatusInternalServerError)
617 return
618 }
619
620 // get patchsets and diff from previous patchset
621 patchsetsData := []PatchsetData{}
622 var selectedPatchsetData *PatchsetData
623 for idx, patchset := range patchsets {
624 user, err := web.Pr.GetUserByID(patchset.UserID)
625 if err != nil {
626 web.Logger.Error("could not get user for patch", "err", err)
627 continue
628 }
629
630 var prevPatchset *Patchset
631 if idx > 0 {
632 prevPatchset = patchsets[idx-1]
633 }
634
635 var rangeDiff []*RangeDiffOutput
636 if idx > 0 {
637 rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
638 if err != nil {
639 web.Logger.Error("could not diff patchset", "err", err)
640 continue
641 }
642 }
643
644 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
645 if err != nil {
646 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
647 w.WriteHeader(http.StatusUnprocessableEntity)
648 return
649 }
650
651 // set selected patchset to latest when no ps already set
652 if ps == nil && idx == len(patchsets)-1 {
653 ps = patchset
654 }
655
656 data := PatchsetData{
657 Patchset: patchset,
658 FormattedID: getFormattedPatchsetID(patchset.ID),
659 UserData: UserData{
660 UserID: user.ID,
661 Name: user.Name,
662 IsAdmin: web.Backend.IsAdmin(pk),
663 Pubkey: user.Pubkey,
664 CreatedAt: user.CreatedAt.Format(time.RFC3339),
665 },
666 Date: patchset.CreatedAt.Format(time.RFC3339),
667 RangeDiff: rangeDiff,
668 }
669 patchsetsData = append(patchsetsData, data)
670 if ps != nil && ps.ID == patchset.ID {
671 selectedPatchsetData = &data
672 }
673 }
674
675 patchesData := []PatchData{}
676 if len(patchsetsData) >= 1 {
677 psID := ps.ID
678 patches, err := web.Pr.GetPatchesByPatchsetID(psID)
679 if err != nil {
680 web.Logger.Error("cannot get patches", "err", err)
681 w.WriteHeader(http.StatusInternalServerError)
682 return
683 }
684
685 // TODO: a little hacky
686 reviewIDs := []int64{}
687 for _, data := range patchsetsData {
688 if psID != data.ID {
689 continue
690 }
691 if !data.Patchset.Review {
692 continue
693 }
694
695 for _, rdiff := range data.RangeDiff {
696 if rdiff.Type == "add" {
697 for _, patch := range patches {
698 commSha := truncateSha(patch.CommitSha)
699 if strings.Contains(rdiff.Header.String(), commSha) {
700 reviewIDs = append(reviewIDs, patch.ID)
701 break
702 }
703 }
704 }
705 }
706 break
707 }
708
709 for _, patch := range patches {
710 diffFiles, preamble, err := ParsePatch(patch.RawText)
711 if err != nil {
712 web.Logger.Error("cannot parse patch", "err", err)
713 w.WriteHeader(http.StatusUnprocessableEntity)
714 return
715 }
716 header, err := gitdiff.ParsePatchHeader(preamble)
717 if err != nil {
718 web.Logger.Error("cannot parse patch", "err", err)
719 w.WriteHeader(http.StatusUnprocessableEntity)
720 return
721 }
722
723 // highlight review
724 isReview := false
725 for _, pID := range reviewIDs {
726 if pID == patch.ID {
727 isReview = true
728 break
729 }
730 }
731
732 patchFiles := []*PatchFile{}
733 for _, file := range diffFiles {
734 var adds int64 = 0
735 var dels int64 = 0
736 for _, frag := range file.TextFragments {
737 adds += frag.LinesAdded
738 dels += frag.LinesDeleted
739 }
740
741 diffStr, err := parseText(web.Formatter, web.Theme, file.String())
742 if err != nil {
743 web.Logger.Error("cannot parse patch", "err", err)
744 w.WriteHeader(http.StatusUnprocessableEntity)
745 return
746 }
747
748 patchFiles = append(patchFiles, &PatchFile{
749 File: file,
750 Adds: adds,
751 Dels: dels,
752 DiffText: template.HTML(diffStr),
753 })
754 }
755
756 timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
757 patchesData = append(patchesData, PatchData{
758 Patch: patch,
759 Url: template.URL(fmt.Sprintf("patch-%d", patch.ID)),
760 Review: isReview,
761 FormattedAuthorDate: timestamp,
762 PatchFiles: patchFiles,
763 PatchHeader: header,
764 })
765 }
766 }
767
768 user, err := web.Pr.GetUserByID(pr.UserID)
769 if err != nil {
770 w.WriteHeader(http.StatusNotFound)
771 return
772 }
773
774 w.Header().Set("content-type", "text/html")
775 tmpl := getTemplate("pr-detail.html")
776 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
777 if err != nil {
778 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
779 w.WriteHeader(http.StatusUnprocessableEntity)
780 return
781 }
782 isAdmin := web.Backend.IsAdmin(pk)
783 logs, err := web.Pr.GetEventLogsByPrID(pr.ID)
784 if err != nil {
785 web.Logger.Error("cannot get logs for pr", "err", err)
786 w.WriteHeader(http.StatusUnprocessableEntity)
787 return
788 }
789 slices.SortFunc(logs, func(a *EventLog, b *EventLog) int {
790 return a.CreatedAt.Compare(b.CreatedAt)
791 })
792
793 logData := []EventLogData{}
794 for _, eventlog := range logs {
795 user, _ := web.Pr.GetUserByID(eventlog.UserID)
796 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
797 if err != nil {
798 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
799 w.WriteHeader(http.StatusUnprocessableEntity)
800 return
801 }
802 var logps *Patchset
803 if eventlog.PatchsetID.Int64 > 0 {
804 logps, err = web.Pr.GetPatchsetByID(eventlog.PatchsetID.Int64)
805 if err != nil {
806 web.Logger.Error("cannot get patchset", "err", err, "ps", eventlog.PatchsetID)
807 w.WriteHeader(http.StatusUnprocessableEntity)
808 return
809 }
810 }
811
812 logData = append(logData, EventLogData{
813 EventLog: eventlog,
814 FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
815 Patchset: logps,
816 UserData: UserData{
817 UserID: user.ID,
818 Name: user.Name,
819 IsAdmin: web.Backend.IsAdmin(pk),
820 Pubkey: user.Pubkey,
821 CreatedAt: user.CreatedAt.Format(time.RFC3339),
822 },
823 Date: eventlog.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
824 })
825 }
826
827 repo, err := web.Pr.GetRepoByID(pr.RepoID)
828 if err != nil {
829 web.Logger.Error("cannot get repo for pr", "err", err)
830 w.WriteHeader(http.StatusUnprocessableEntity)
831 return
832 }
833
834 repoOwner, err := web.Pr.GetUserByID(repo.UserID)
835 if err != nil {
836 web.Logger.Error("cannot get repo for pr", "err", err)
837 w.WriteHeader(http.StatusUnprocessableEntity)
838 return
839 }
840
841 repoNs := web.Backend.CreateRepoNs(repoOwner.Name, repo.Name)
842 url := fmt.Sprintf("/r/%s/%s", repoOwner.Name, repo.Name)
843 err = tmpl.ExecuteTemplate(w, "pr-detail.html", PrDetailData{
844 Page: "pr",
845 Repo: LinkData{
846 Url: template.URL(url),
847 Text: repoNs,
848 },
849 Branch: "main",
850 Patchset: ps,
851 PatchsetData: selectedPatchsetData,
852 IsRangeDiff: page == "rd",
853 Patches: patchesData,
854 Patchsets: patchsetsData,
855 Logs: logData,
856 Pr: PrData{
857 ID: pr.ID,
858 UserData: UserData{
859 UserID: user.ID,
860 Name: user.Name,
861 IsAdmin: isAdmin,
862 Pubkey: user.Pubkey,
863 CreatedAt: user.CreatedAt.Format(time.RFC3339),
864 },
865 Title: pr.Name,
866 Date: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
867 Status: pr.Status,
868 },
869 MetaData: MetaData{
870 URL: web.Backend.Cfg.Url,
871 },
872 })
873 if err != nil {
874 web.Backend.Logger.Error("cannot execute template", "err", err)
875 }
876 }
877}
878
879func rssHandler(w http.ResponseWriter, r *http.Request) {
880 web, err := getWebCtx(r)
881 if err != nil {
882 w.WriteHeader(http.StatusUnprocessableEntity)
883 return
884 }
885
886 desc := fmt.Sprintf(
887 "Events related to git collaboration server %s",
888 web.Backend.Cfg.Url,
889 )
890 feed := &feeds.Feed{
891 Title: fmt.Sprintf("%s events", web.Backend.Cfg.Url),
892 Link: &feeds.Link{Href: web.Backend.Cfg.Url},
893 Description: desc,
894 Author: &feeds.Author{Name: "git collaboration server"},
895 Created: time.Now(),
896 }
897
898 var eventLogs []*EventLog
899 id := r.PathValue("id")
900 pubkey := r.URL.Query().Get("pubkey")
901 username := r.PathValue("user")
902 repoName := r.PathValue("repo")
903
904 if id != "" {
905 var prID int64
906 prID, err = getPrID(id)
907 if err != nil {
908 w.WriteHeader(http.StatusUnprocessableEntity)
909 return
910 }
911 eventLogs, err = web.Pr.GetEventLogsByPrID(prID)
912 } else if pubkey != "" {
913 user, perr := web.Pr.GetUserByPubkey(pubkey)
914 if perr != nil {
915 w.WriteHeader(http.StatusNotFound)
916 return
917 }
918 eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
919 } else if username != "" {
920 user, perr := web.Pr.GetUserByName(username)
921 if perr != nil {
922 w.WriteHeader(http.StatusNotFound)
923 return
924 }
925 eventLogs, err = web.Pr.GetEventLogsByUserID(user.ID)
926 } else if repoName != "" {
927 user, perr := web.Pr.GetUserByName(username)
928 if perr != nil {
929 w.WriteHeader(http.StatusNotFound)
930 return
931 }
932 eventLogs, err = web.Pr.GetEventLogsByRepoName(user, repoName)
933 } else {
934 eventLogs, err = web.Pr.GetEventLogs()
935 }
936
937 if err != nil {
938 web.Logger.Error("rss could not get eventLogs", "err", err)
939 w.WriteHeader(http.StatusInternalServerError)
940 return
941 }
942
943 var feedItems []*feeds.Item
944 for _, eventLog := range eventLogs {
945 user, err := web.Pr.GetUserByID(eventLog.UserID)
946 if err != nil {
947 web.Logger.Error("user not found for event log", "id", eventLog.ID, "err", err)
948 continue
949 }
950
951 repo := &Repo{Name: "unknown"}
952 if eventLog.RepoID.Valid {
953 repo, err = web.Pr.GetRepoByID(eventLog.RepoID.Int64)
954 if err != nil {
955 web.Logger.Error("repo not found for event log", "id", eventLog.ID, "err", err)
956 continue
957 }
958 }
959
960 realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID.Int64)
961 content := fmt.Sprintf(
962 "<div><div>RepoID: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
963 web.Backend.CreateRepoNs(user.Name, repo.Name),
964 eventLog.PatchRequestID.Int64,
965 eventLog.Event,
966 eventLog.CreatedAt.Format(time.RFC3339Nano),
967 eventLog.Data,
968 )
969 pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID.Int64)
970 if err != nil {
971 continue
972 }
973
974 title := fmt.Sprintf(
975 `%s in %s for PR "%s" (#%d)`,
976 eventLog.Event,
977 web.Backend.CreateRepoNs(user.Name, repo.Name),
978 pr.Name,
979 eventLog.PatchRequestID.Int64,
980 )
981 item := &feeds.Item{
982 Id: fmt.Sprintf("%d", eventLog.ID),
983 Title: title,
984 Link: &feeds.Link{Href: realUrl},
985 Content: content,
986 Created: eventLog.CreatedAt,
987 Description: title,
988 Author: &feeds.Author{Name: user.Name},
989 }
990
991 feedItems = append(feedItems, item)
992 }
993 feed.Items = feedItems
994
995 rss, err := feed.ToAtom()
996 if err != nil {
997 web.Logger.Error("could not generate atom rss feed", "err", err)
998 http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
999 }
1000
1001 w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
1002 _, err = w.Write([]byte(rss))
1003 if err != nil {
1004 web.Logger.Error("write error atom rss feed", "err", err)
1005 }
1006}
1007
1008func chromaStyleHandler(w http.ResponseWriter, r *http.Request) {
1009 web, err := getWebCtx(r)
1010 if err != nil {
1011 w.WriteHeader(http.StatusUnprocessableEntity)
1012 return
1013 }
1014 w.Header().Add("content-type", "text/css")
1015 err = web.Formatter.WriteCSS(w, web.Theme)
1016 if err != nil {
1017 web.Backend.Logger.Error("cannot write css file", "err", err)
1018 }
1019}
1020
1021func serveFile(userfs fs.FS, embedfs fs.FS) func(w http.ResponseWriter, r *http.Request) {
1022 return func(w http.ResponseWriter, r *http.Request) {
1023 web, err := getWebCtx(r)
1024 if err != nil {
1025 w.WriteHeader(http.StatusUnprocessableEntity)
1026 return
1027 }
1028 logger := web.Logger
1029
1030 file := r.PathValue("file")
1031
1032 logger.Info("serving file", "file", file)
1033 // merging both embedded fs and whatever user provides
1034 var reader fs.File
1035 if userfs == nil {
1036 reader, err = embedfs.Open(file)
1037 } else {
1038 reader, err = userfs.Open(file)
1039 if err != nil {
1040 // serve embeded static folder
1041 reader, err = embedfs.Open(file)
1042 }
1043 }
1044
1045 if err != nil {
1046 logger.Error(err.Error())
1047 http.Error(w, "file not found", 404)
1048 return
1049 }
1050
1051 contents, err := io.ReadAll(reader)
1052 if err != nil {
1053 logger.Error(err.Error())
1054 http.Error(w, "file not found", 404)
1055 return
1056 }
1057 contentType := mime.TypeByExtension(filepath.Ext(file))
1058 if contentType == "" {
1059 contentType = http.DetectContentType(contents)
1060 }
1061 w.Header().Add("Content-Type", contentType)
1062
1063 _, err = w.Write(contents)
1064 if err != nil {
1065 logger.Error(err.Error())
1066 http.Error(w, "server error", 500)
1067 return
1068 }
1069 }
1070}
1071
1072func getUserDefinedFS(datadir, dirName string) fs.FS {
1073 dir := filepath.Join(datadir, dirName)
1074 _, err := os.Stat(dir)
1075 if err != nil {
1076 return nil
1077 }
1078 return os.DirFS(dir)
1079}
1080
1081func getEmbedFS(ffs embed.FS, dirName string) (fs.FS, error) {
1082 fsys, err := fs.Sub(ffs, dirName)
1083 if err != nil {
1084 return nil, err
1085 }
1086 return fsys, nil
1087}
1088
1089func StartWebServer(cfg *GitCfg) {
1090 addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.WebPort)
1091
1092 dbpath := filepath.Join(cfg.DataDir, "pr.db?_fk=on")
1093 dbh, err := SqliteOpen("file:"+dbpath, cfg.Logger)
1094 if err != nil {
1095 panic(fmt.Sprintf("cannot find database file, check folder and perms: %s: %s", dbpath, err))
1096 }
1097
1098 be := &Backend{
1099 DB: dbh,
1100 Logger: cfg.Logger,
1101 Cfg: cfg,
1102 }
1103 prCmd := &PrCmd{
1104 Backend: be,
1105 }
1106 formatter := formatterHtml.New(
1107 formatterHtml.WithLineNumbers(true),
1108 formatterHtml.LineNumbersInTable(true),
1109 formatterHtml.WithClasses(true),
1110 formatterHtml.WithLinkableLineNumbers(true, "gitpr"),
1111 )
1112 web := &WebCtx{
1113 Pr: prCmd,
1114 Backend: be,
1115 Logger: cfg.Logger,
1116 Formatter: formatter,
1117 Theme: styles.Get(cfg.Theme),
1118 }
1119
1120 ctx := context.Background()
1121 ctx = setWebCtx(ctx, web)
1122
1123 // ensure legacy router is disabled
1124 // GODEBUG=httpmuxgo121=0
1125 http.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
1126 http.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
1127 http.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
1128 http.HandleFunc("GET /rd/{id}", ctxMdw(ctx, createPrDetail("rd")))
1129 http.HandleFunc("GET /r/{user}/{repo}/rss", ctxMdw(ctx, rssHandler))
1130 http.HandleFunc("GET /r/{user}/{repo}", ctxMdw(ctx, repoDetailHandler))
1131 http.HandleFunc("GET /r/{user}", ctxMdw(ctx, userDetailHandler))
1132 http.HandleFunc("GET /rss/{user}", ctxMdw(ctx, rssHandler))
1133 http.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
1134 http.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
1135 http.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
1136 embedFS, err := getEmbedFS(embedStaticFS, "static")
1137 if err != nil {
1138 panic(err)
1139 }
1140 userFS := getUserDefinedFS(cfg.DataDir, "static")
1141
1142 http.HandleFunc("GET /static/{file}", ctxMdw(ctx, serveFile(userFS, embedFS)))
1143
1144 cfg.Logger.Info("starting web server", "addr", addr)
1145 err = http.ListenAndServe(addr, nil)
1146 if err != nil {
1147 cfg.Logger.Error("listen", "err", err)
1148 }
1149}