repos / git-pr

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

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}