arche / internal/archesrv/handlers_repo.go

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