repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

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}