arche / internal/archesrv/handlers_repo.go

commit a22ffc45
  1package archesrv
  2
  3import (
  4	"bytes"
  5	"encoding/hex"
  6	"fmt"
  7	"html/template"
  8	"net/http"
  9	"sort"
 10	"strings"
 11
 12	"arche/internal/diff"
 13	"arche/internal/markdown"
 14	"arche/internal/object"
 15	"arche/internal/repo"
 16	"arche/internal/revset"
 17	"arche/internal/syncpkg"
 18
 19	"github.com/alecthomas/chroma/v2"
 20	chrhtml "github.com/alecthomas/chroma/v2/formatters/html"
 21	"github.com/alecthomas/chroma/v2/lexers"
 22	"github.com/alecthomas/chroma/v2/styles"
 23	"golang.org/x/crypto/ssh"
 24)
 25
 26func (s *forgeServer) requireRepoAccess(w http.ResponseWriter, r *http.Request) (*repo.Repo, *RepoRecord, bool) {
 27	repoName := r.PathValue("repo")
 28	rec, err := s.db.GetRepo(repoName)
 29	if err != nil || rec == nil {
 30		http.NotFound(w, r)
 31		return nil, nil, false
 32	}
 33
 34	user := s.db.currentUser(r)
 35	if !s.db.CanRead(rec, user) {
 36		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 37		return nil, nil, false
 38	}
 39
 40	repoObj, err := openRepo(s.dataDir(), repoName)
 41	if err != nil {
 42		http.Error(w, "open repo: "+err.Error(), http.StatusInternalServerError)
 43		return nil, nil, false
 44	}
 45
 46	return repoObj, rec, true
 47}
 48
 49func (s *forgeServer) handleSyncProxy(w http.ResponseWriter, r *http.Request) {
 50	repoName := r.PathValue("repo")
 51	rec, err := s.db.GetRepo(repoName)
 52	if err != nil || rec == nil {
 53		http.Error(w, "repo not found", http.StatusNotFound)
 54		return
 55	}
 56
 57	user := s.db.currentUser(r)
 58
 59	isReadOp := r.Method == http.MethodGet ||
 60		strings.HasSuffix(r.URL.Path, "/arche/v1/bloom") ||
 61		strings.HasSuffix(r.URL.Path, "/arche/v1/fetch")
 62
 63	if !isReadOp && !s.db.CanWrite(rec, user) {
 64		user := s.db.currentUser(r)
 65		username := "anonymous"
 66		if user != nil {
 67			username = user.Username
 68		}
 69		s.log.Warn("sync write denied", "repo", repoName, "user", username)
 70		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 71		return
 72	}
 73	if isReadOp && !s.db.CanRead(rec, user) {
 74		s.log.Warn("sync read denied", "repo", repoName)
 75		http.Error(w, "Unauthorized", http.StatusUnauthorized)
 76		return
 77	}
 78
 79	repoObj, err := openRepo(s.dataDir(), repoName)
 80	if err != nil {
 81		http.Error(w, "open repo: "+err.Error(), http.StatusInternalServerError)
 82		return
 83	}
 84	defer repoObj.Close()
 85
 86	action := strings.TrimPrefix(r.URL.Path, "/"+repoName)
 87	r2 := r.Clone(r.Context())
 88	r2.URL.Path = action
 89
 90	user = s.db.currentUser(r)
 91	pusher := "anonymous"
 92	if user != nil {
 93		pusher = user.Username
 94	}
 95
 96	srv := syncpkg.NewServer(repoObj, "")
 97
 98	repoKey := repoName
 99	repoCfg := s.cfg.Repo[repoKey]
100	srv.PreUpdateHook = func(bm, oldHex, newHex string) error {
101		if s.cfg.Hooks.PreReceive != "" || s.cfg.Hooks.Update != "" {
102			if err := runPreReceiveHook(s.cfg.Hooks.PreReceive, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec); err != nil {
103				return err
104			}
105			if err := runPreReceiveHook(s.cfg.Hooks.Update, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec); err != nil {
106				return err
107			}
108		}
109		if repoCfg.RequireSignedCommits && user != nil {
110			for _, id := range collectNewCommitIDs(repoObj, oldHex, newHex) {
111				c, err := repoObj.ReadCommit(id)
112				if err != nil {
113					continue
114				}
115				if len(c.CommitSig) == 0 {
116					return fmt.Errorf("commit %s (ch:%s) is unsigned; this repository requires signed commits",
117						hex.EncodeToString(id[:8]), c.ChangeID)
118				}
119				body := object.CommitBodyForSigning(c)
120				keys, _ := s.db.ListSSHKeys(user.ID)
121				verified := false
122				for _, k := range keys {
123					pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey))
124					if err != nil {
125						continue
126					}
127					if object.VerifyCommitSig(body, c.CommitSig, pub) == nil {
128						verified = true
129						break
130					}
131				}
132				if !verified {
133					return fmt.Errorf("commit %s (ch:%s) has an unverifiable signature; this repository requires commits signed by a registered key",
134						hex.EncodeToString(id[:8]), c.ChangeID)
135				}
136			}
137		}
138		return nil
139	}
140
141	srv.OnBookmarkUpdated = func(bm, oldHex, newHex string) {
142		s.db.FirePushWebhooks(repoName, pusher, bm, oldHex, newHex, collectPushCommits(repoObj, oldHex, newHex))
143		runPostReceiveHook(s.cfg.Hooks.PostReceive, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec)
144
145		if user != nil {
146			for _, id := range collectNewCommitIDs(repoObj, oldHex, newHex) {
147				c, err := repoObj.ReadCommit(id)
148				if err != nil {
149					continue
150				}
151				_ = s.db.RecordCommitSignature(repoObj, id, c, user.ID)
152			}
153		}
154
155		if allowed, script, _ := s.db.GetRepoHookConfig(rec.ID); allowed && script != "" {
156			if !s.db.hasWriteCollaborator(rec.ID) {
157				runPostReceiveHook(script, bm, oldHex, newHex, s.cfg.Hooks.TimeoutSec)
158			}
159		}
160	}
161	srv.Handler().ServeHTTP(w, r2)
162}
163
164func collectPushCommits(r *repo.Repo, oldHex, newHex string) []CommitRef {
165	if len(newHex) != 64 {
166		return []CommitRef{}
167	}
168	newBytes, err := hex.DecodeString(newHex)
169	if err != nil || len(newBytes) != 32 {
170		return []CommitRef{}
171	}
172	var newID [32]byte
173	copy(newID[:], newBytes)
174
175	var oldID [32]byte
176	if len(oldHex) == 64 {
177		if oldBytes, err2 := hex.DecodeString(oldHex); err2 == nil && len(oldBytes) == 32 {
178			copy(oldID[:], oldBytes)
179		}
180	}
181
182	seen := make(map[[32]byte]bool)
183	queue := [][32]byte{newID}
184	var results []CommitRef
185	const maxCommits = 50
186
187	for len(queue) > 0 && len(results) < maxCommits {
188		id := queue[0]
189		queue = queue[1:]
190		if seen[id] || id == oldID {
191			continue
192		}
193		seen[id] = true
194		c, err := r.ReadCommit(id)
195		if err != nil {
196			break
197		}
198		author := c.Author.Name
199		if c.Author.Email != "" {
200			author += " <" + c.Author.Email + ">"
201		}
202		results = append(results, CommitRef{
203			ID:       hex.EncodeToString(id[:]),
204			ChangeID: "ch:" + c.ChangeID,
205			Message:  c.Message,
206			Author:   author,
207		})
208		for _, p := range c.Parents {
209			if !seen[p] && p != oldID {
210				queue = append(queue, p)
211			}
212		}
213	}
214	return results
215}
216
217type srvHomeData struct {
218	Repo       string
219	User       *User
220	CommitHex  string
221	ShortHex   string
222	ReadmeName string
223	ReadmeHTML template.HTML
224	Entries    []srvTreeEntry
225}
226
227func (s *forgeServer) handleRepoHome(w http.ResponseWriter, r *http.Request) {
228	repoObj, rec, ok := s.requireRepoAccess(w, r)
229	if !ok {
230		return
231	}
232	defer repoObj.Close()
233
234	commitID := resolveDefaultCommit(repoObj)
235	if commitID == ([32]byte{}) {
236		_, id, err := repoObj.HeadCommit()
237		if err != nil {
238			http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound)
239			return
240		}
241		commitID = id
242	}
243
244	c, err := repoObj.ReadCommit(commitID)
245	if err != nil {
246		http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound)
247		return
248	}
249	tree, err := repoObj.ReadTree(c.TreeID)
250	if err != nil {
251		http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound)
252		return
253	}
254
255	commitHex := fullHex(commitID)
256
257	var entries []srvTreeEntry
258	for _, e := range tree.Entries {
259		isDir := e.Mode == object.ModeDir
260		var link string
261		if isDir {
262			link = fmt.Sprintf("/%s/tree?id=%s&path=%s", rec.Name, commitHex, e.Name)
263		} else {
264			link = fmt.Sprintf("/%s/file?id=%s&path=%s", rec.Name, commitHex, e.Name)
265		}
266		entries = append(entries, srvTreeEntry{
267			Name:  e.Name,
268			IsDir: isDir,
269			Mode:  modeStr(e.Mode),
270			Link:  link,
271		})
272	}
273
274	var readmeName string
275	var readmeHTML template.HTML
276	for _, candidate := range []string{"README.md", "readme.md", "README", "readme"} {
277		for _, e := range tree.Entries {
278			if e.Name == candidate && e.Mode != object.ModeDir {
279				content, err := repoObj.ReadBlob(e.ObjectID)
280				if err != nil || isBinaryContent(content) {
281					break
282				}
283				readmeName = e.Name
284				if strings.HasSuffix(strings.ToLower(e.Name), ".md") {
285					readmeHTML = markdown.Render(string(content))
286				} else {
287					readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(content)) + "</pre>")
288				}
289				break
290			}
291		}
292		if readmeName != "" {
293			break
294		}
295	}
296
297	if readmeName == "" && len(entries) == 0 {
298		http.Redirect(w, r, "/"+rec.Name+"/log", http.StatusFound)
299		return
300	}
301
302	s.render(w, "srv_repo_home.html", srvHomeData{
303		Repo:       rec.Name,
304		User:       s.db.currentUser(r),
305		CommitHex:  commitHex,
306		ShortHex:   shortHex(commitID),
307		ReadmeName: readmeName,
308		ReadmeHTML: readmeHTML,
309		Entries:    entries,
310	})
311}
312
313type srvCommitRow struct {
314	HexID      string
315	ShortHex   string
316	ChangeID   string
317	Author     string
318	Date       string
319	Phase      string
320	PhaseClass string
321	Message    string
322	Bookmarks  []string
323	IsHead     bool
324}
325
326type srvLogData struct {
327	Repo           string
328	User           *User
329	Commits        []srvCommitRow
330	WhereExpr      string
331	WhereErr       string
332	BookmarkFilter string
333	AllBookmarks   []string
334}
335
336func (s *forgeServer) handleRepoLog(w http.ResponseWriter, r *http.Request) {
337	repoObj, rec, ok := s.requireRepoAccess(w, r)
338	if !ok {
339		return
340	}
341	defer repoObj.Close()
342
343	const maxCommits = 200
344	where := r.URL.Query().Get("where")
345	bookmarkFilter := r.URL.Query().Get("bookmark")
346
347	var whereFilter revset.Func
348	var whereErr string
349	if where != "" {
350		var err error
351		whereFilter, err = revset.Parse(where)
352		if err != nil {
353			whereErr = err.Error()
354		}
355	}
356
357	headCID, _ := repoObj.HeadChangeID()
358	bmMap := bookmarkMap(repoObj)
359
360	allBms, _ := repoObj.Store.ListBookmarks()
361	allBmNames := make([]string, 0, len(allBms))
362	for _, bm := range allBms {
363		allBmNames = append(allBmNames, bm.Name)
364	}
365
366	var candidateIDs [][32]byte
367	if bookmarkFilter != "" {
368		bm, err := repoObj.Store.GetBookmark(bookmarkFilter)
369		if err == nil && bm != nil {
370			visited := map[[32]byte]bool{}
371			queue := [][32]byte{bm.CommitID}
372			for len(queue) > 0 && len(candidateIDs) < maxCommits*2 {
373				id := queue[0]
374				queue = queue[1:]
375				if visited[id] {
376					continue
377				}
378				visited[id] = true
379				candidateIDs = append(candidateIDs, id)
380				c, err := repoObj.ReadCommit(id)
381				if err != nil {
382					continue
383				}
384				for _, p := range c.Parents {
385					if !visited[p] {
386						queue = append(queue, p)
387					}
388				}
389			}
390		}
391	} else {
392		visited := map[[32]byte]bool{}
393		var queue [][32]byte
394		allBms2, _ := repoObj.Store.ListBookmarks()
395		for _, bm := range allBms2 {
396			if !visited[bm.CommitID] {
397				queue = append(queue, bm.CommitID)
398				visited[bm.CommitID] = true
399			}
400		}
401		if _, headID, err := repoObj.HeadCommit(); err == nil && !visited[headID] {
402			queue = append(queue, headID)
403			visited[headID] = true
404		}
405		for len(queue) > 0 && len(candidateIDs) < maxCommits*2 {
406			id := queue[0]
407			queue = queue[1:]
408			candidateIDs = append(candidateIDs, id)
409			c, err := repoObj.ReadCommit(id)
410			if err != nil {
411				continue
412			}
413			for _, p := range c.Parents {
414				if !visited[p] {
415					visited[p] = true
416					queue = append(queue, p)
417				}
418			}
419		}
420	}
421
422	type rowWithTime struct {
423		row  srvCommitRow
424		time int64
425	}
426	var withTimes []rowWithTime
427	for _, id := range candidateIDs {
428		c, err := repoObj.ReadCommit(id)
429		if err != nil {
430			continue
431		}
432
433		phase, _ := repoObj.Store.GetPhase(id)
434		if bookmarkFilter != "" && phase != object.PhasePublic {
435			continue
436		}
437		if whereFilter != nil && !whereFilter(id, c, phase) {
438			continue
439		}
440		hexID := fullHex(id)
441		msg := c.Message
442		if idx := strings.IndexByte(msg, '\n'); idx >= 0 {
443			msg = msg[:idx]
444		}
445		withTimes = append(withTimes, rowWithTime{
446			row: srvCommitRow{
447				HexID:      hexID,
448				ShortHex:   shortHex(id),
449				ChangeID:   c.ChangeID,
450				Author:     c.Author.Name,
451				Date:       c.Author.Timestamp.Format("2006-01-02 15:04"),
452				Phase:      phase.String(),
453				PhaseClass: phaseClass(phase),
454				Message:    msg,
455				Bookmarks:  bmMap[hexID],
456				IsHead:     c.ChangeID == headCID,
457			},
458			time: c.Author.Timestamp.Unix(),
459		})
460	}
461
462	sort.Slice(withTimes, func(i, j int) bool {
463		return withTimes[i].time > withTimes[j].time
464	})
465	if len(withTimes) > maxCommits {
466		withTimes = withTimes[:maxCommits]
467	}
468	rows := make([]srvCommitRow, len(withTimes))
469	for i, wt := range withTimes {
470		rows[i] = wt.row
471	}
472
473	s.render(w, "srv_repo_log.html", srvLogData{
474		Repo:           rec.Name,
475		User:           s.db.currentUser(r),
476		Commits:        rows,
477		WhereExpr:      where,
478		WhereErr:       whereErr,
479		BookmarkFilter: bookmarkFilter,
480		AllBookmarks:   allBmNames,
481	})
482}
483
484type srvCommitData struct {
485	Repo       string
486	User       *User
487	HexID      string
488	ShortHex   string
489	ChangeID   string
490	Author     string
491	Committer  string
492	Date       string
493	Phase      string
494	PhaseClass string
495	SigStatus  string
496	SigKeyID   string
497	Message    string
498	Bookmarks  []string
499	Parents    []srvParentLink
500	Diffs      []srvFileDiff
501}
502
503type srvParentLink struct {
504	HexID    string
505	ShortHex string
506}
507
508type srvDiffLine struct {
509	Class string
510	Text  string
511}
512
513type srvFileDiff struct {
514	Path   string
515	Status string
516	Lines  []srvDiffLine
517}
518
519func (s *forgeServer) handleRepoCommit(w http.ResponseWriter, r *http.Request) {
520	repoObj, rec, ok := s.requireRepoAccess(w, r)
521	if !ok {
522		return
523	}
524	defer repoObj.Close()
525
526	idStr := r.URL.Query().Get("id")
527	raw, err := hex.DecodeString(idStr)
528	if err != nil || len(raw) != 32 {
529		http.Error(w, "invalid commit id", http.StatusBadRequest)
530		return
531	}
532	var id [32]byte
533	copy(id[:], raw)
534
535	c, err := repoObj.ReadCommit(id)
536	if err != nil {
537		http.NotFound(w, r)
538		return
539	}
540
541	phase, _ := repoObj.Store.GetPhase(id)
542	bmMap := bookmarkMap(repoObj)
543	hexID := fullHex(id)
544
545	var parents []srvParentLink
546	for _, p := range c.Parents {
547		parents = append(parents, srvParentLink{HexID: fullHex(p), ShortHex: shortHex(p)})
548	}
549
550	diffs, _ := diff.CommitDiff(repoObj, id)
551	var rendered []srvFileDiff
552	for _, fd := range diffs {
553		rendered = append(rendered, srvFileDiff{
554			Path:   fd.Path,
555			Status: string(fd.Status),
556			Lines:  parseSrvDiffLines(fd.Patch),
557		})
558	}
559
560	sigStatus := s.db.GetCommitSigStatus(id)
561
562	s.render(w, "srv_repo_commit.html", srvCommitData{
563		Repo:       rec.Name,
564		User:       s.db.currentUser(r),
565		HexID:      hexID,
566		ShortHex:   shortHex(id),
567		ChangeID:   c.ChangeID,
568		Author:     fmt.Sprintf("%s <%s>", c.Author.Name, c.Author.Email),
569		Committer:  fmt.Sprintf("%s <%s>", c.Committer.Name, c.Committer.Email),
570		Date:       c.Author.Timestamp.Format("2006-01-02 15:04:05"),
571		Phase:      phase.String(),
572		PhaseClass: phaseClass(phase),
573		SigStatus:  sigStatus,
574		Message:    c.Message,
575		Bookmarks:  bmMap[hexID],
576		Parents:    parents,
577		Diffs:      rendered,
578	})
579}
580
581func parseSrvDiffLines(patch string) []srvDiffLine {
582	var out []srvDiffLine
583	for _, line := range strings.Split(patch, "\n") {
584		var class string
585		switch {
586		case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"),
587			strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "@@"):
588			class = "diff-hdr"
589		case strings.HasPrefix(line, "+"):
590			class = "diff-add"
591		case strings.HasPrefix(line, "-"):
592			class = "diff-del"
593		}
594		out = append(out, srvDiffLine{Class: class, Text: line})
595	}
596	return out
597}
598
599type srvTreeData struct {
600	Repo      string
601	User      *User
602	CommitHex string
603	ShortHex  string
604	TreePath  string
605	PathParts []srvPathPart
606	Entries   []srvTreeEntry
607}
608
609type srvPathPart struct {
610	Name string
611	Link string
612}
613
614type srvTreeEntry struct {
615	Name  string
616	IsDir bool
617	Mode  string
618	Link  string
619}
620
621func resolveDefaultCommit(r *repo.Repo) [32]byte {
622	bms, err := r.Store.ListBookmarks()
623	if err != nil || len(bms) == 0 {
624		return [32]byte{}
625	}
626	for _, name := range []string{"main", "master"} {
627		for _, bm := range bms {
628			if bm.Name == name {
629				return bm.CommitID
630			}
631		}
632	}
633	return bms[0].CommitID
634}
635
636func (s *forgeServer) handleRepoTree(w http.ResponseWriter, r *http.Request) {
637	repoObj, rec, ok := s.requireRepoAccess(w, r)
638	if !ok {
639		return
640	}
641	defer repoObj.Close()
642
643	idStr := r.URL.Query().Get("id")
644	treePath := strings.Trim(r.URL.Query().Get("path"), "/")
645
646	var commitID [32]byte
647	if idStr != "" {
648		raw, err := hex.DecodeString(idStr)
649		if err != nil || len(raw) != 32 {
650			http.Error(w, "invalid id", http.StatusBadRequest)
651			return
652		}
653		copy(commitID[:], raw)
654	} else {
655		commitID = resolveDefaultCommit(repoObj)
656		if commitID == ([32]byte{}) {
657			_, id, err := repoObj.HeadCommit()
658			if err != nil {
659				http.Error(w, "HEAD: "+err.Error(), http.StatusInternalServerError)
660				return
661			}
662			commitID = id
663		}
664	}
665
666	c, err := repoObj.ReadCommit(commitID)
667	if err != nil {
668		http.NotFound(w, r)
669		return
670	}
671
672	tree, err := repoObj.ReadTree(c.TreeID)
673	if err != nil {
674		http.Error(w, "tree: "+err.Error(), http.StatusInternalServerError)
675		return
676	}
677
678	if treePath != "" {
679		for _, seg := range strings.Split(treePath, "/") {
680			var found *object.TreeEntry
681			for i := range tree.Entries {
682				if tree.Entries[i].Name == seg {
683					found = &tree.Entries[i]
684					break
685				}
686			}
687			if found == nil {
688				http.NotFound(w, r)
689				return
690			}
691			if found.Mode != object.ModeDir {
692				http.Redirect(w, r, fmt.Sprintf("/%s/file?id=%s&path=%s",
693					rec.Name, fullHex(commitID), treePath), http.StatusFound)
694				return
695			}
696			tree, err = repoObj.ReadTree(found.ObjectID)
697			if err != nil {
698				http.Error(w, "subtree: "+err.Error(), http.StatusInternalServerError)
699				return
700			}
701		}
702	}
703
704	commitHex := fullHex(commitID)
705	var parts []srvPathPart
706	if treePath != "" {
707		acc := ""
708		for _, seg := range strings.Split(treePath, "/") {
709			if acc != "" {
710				acc += "/"
711			}
712			acc += seg
713			parts = append(parts, srvPathPart{
714				Name: seg,
715				Link: fmt.Sprintf("/%s/tree?id=%s&path=%s", rec.Name, commitHex, acc),
716			})
717		}
718	}
719
720	var entries []srvTreeEntry
721	for _, e := range tree.Entries {
722		isDir := e.Mode == object.ModeDir
723		childPath := e.Name
724		if treePath != "" {
725			childPath = treePath + "/" + e.Name
726		}
727		var link string
728		if isDir {
729			link = fmt.Sprintf("/%s/tree?id=%s&path=%s", rec.Name, commitHex, childPath)
730		} else {
731			link = fmt.Sprintf("/%s/file?id=%s&path=%s", rec.Name, commitHex, childPath)
732		}
733		entries = append(entries, srvTreeEntry{
734			Name:  e.Name,
735			IsDir: isDir,
736			Mode:  modeStr(e.Mode),
737			Link:  link,
738		})
739	}
740
741	s.render(w, "srv_repo_tree.html", srvTreeData{
742		Repo:      rec.Name,
743		User:      s.db.currentUser(r),
744		CommitHex: commitHex,
745		ShortHex:  shortHex(commitID),
746		TreePath:  treePath,
747		PathParts: parts,
748		Entries:   entries,
749	})
750}
751
752func modeStr(m object.EntryMode) string {
753	switch m {
754	case object.ModeExec:
755		return "exec"
756	case object.ModeSymlink:
757		return "link"
758	default:
759		return "file"
760	}
761}
762
763type srvFileData struct {
764	Repo        string
765	User        *User
766	CommitHex   string
767	ShortHex    string
768	FilePath    string
769	Content     string
770	IsBinary    bool
771	Highlighted template.HTML
772}
773
774func highlightCode(filename, content string) template.HTML {
775	lexer := lexers.Match(filename)
776	if lexer == nil {
777		lexer = lexers.Analyse(content)
778	}
779	if lexer == nil {
780		lexer = lexers.Fallback
781	}
782	lexer = chroma.Coalesce(lexer)
783
784	style := styles.Get("github")
785	if style == nil {
786		style = styles.Fallback
787	}
788
789	fmt := chrhtml.New(
790		chrhtml.WithLineNumbers(true),
791		chrhtml.WithClasses(false),
792		chrhtml.TabWidth(4),
793	)
794
795	iterator, err := lexer.Tokenise(nil, content)
796	if err != nil {
797		return ""
798	}
799	var buf bytes.Buffer
800	if err := fmt.Format(&buf, style, iterator); err != nil {
801		return ""
802	}
803	return template.HTML(buf.String()) //nolint:gosec
804}
805
806func (s *forgeServer) handleRepoFile(w http.ResponseWriter, r *http.Request) {
807	repoObj, rec, ok := s.requireRepoAccess(w, r)
808	if !ok {
809		return
810	}
811	defer repoObj.Close()
812
813	idStr := r.URL.Query().Get("id")
814	filePath := strings.Trim(r.URL.Query().Get("path"), "/")
815
816	raw, err := hex.DecodeString(idStr)
817	if err != nil || len(raw) != 32 {
818		http.Error(w, "invalid id", http.StatusBadRequest)
819		return
820	}
821	var commitID [32]byte
822	copy(commitID[:], raw)
823
824	c, err := repoObj.ReadCommit(commitID)
825	if err != nil {
826		http.NotFound(w, r)
827		return
828	}
829
830	tree, err := repoObj.ReadTree(c.TreeID)
831	if err != nil {
832		http.Error(w, "tree: "+err.Error(), http.StatusInternalServerError)
833		return
834	}
835
836	parts := strings.Split(filePath, "/")
837	for i, seg := range parts {
838		var found *object.TreeEntry
839		for j := range tree.Entries {
840			if tree.Entries[j].Name == seg {
841				found = &tree.Entries[j]
842				break
843			}
844		}
845		if found == nil {
846			http.NotFound(w, r)
847			return
848		}
849
850		if i == len(parts)-1 {
851			if found.Mode == object.ModeDir {
852				http.Error(w, "not a file", http.StatusBadRequest)
853				return
854			}
855			content, err := repoObj.ReadBlob(found.ObjectID)
856			if err != nil {
857				http.Error(w, "blob: "+err.Error(), http.StatusInternalServerError)
858				return
859			}
860			isBin := isBinaryContent(content)
861			var highlighted template.HTML
862			if !isBin && len(content) < 512*1024 {
863				highlighted = highlightCode(filePath, string(content))
864			}
865			s.render(w, "srv_repo_file.html", srvFileData{
866				Repo:        rec.Name,
867				User:        s.db.currentUser(r),
868				CommitHex:   fullHex(commitID),
869				ShortHex:    shortHex(commitID),
870				FilePath:    filePath,
871				Content:     string(content),
872				IsBinary:    isBin,
873				Highlighted: highlighted,
874			})
875			return
876		}
877
878		if found.Mode != object.ModeDir {
879			http.Error(w, "not a directory", http.StatusBadRequest)
880			return
881		}
882		tree, err = repoObj.ReadTree(found.ObjectID)
883		if err != nil {
884			http.Error(w, "subtree: "+err.Error(), http.StatusInternalServerError)
885			return
886		}
887	}
888	http.NotFound(w, r)
889}
890
891func isBinaryContent(data []byte) bool {
892	for _, b := range data {
893		if b == 0 {
894			return true
895		}
896	}
897	return false
898}