repos / git-pr

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

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}