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}