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}