larc r30

691 lines ยท 16.7 KB Raw
1 package server
2
3 import (
4 "os"
5 "path/filepath"
6 "sort"
7 "strconv"
8 "strings"
9 "time"
10
11 "github.com/bytedance/sonic"
12 "github.com/gofiber/fiber/v2"
13
14 "larc.wejust.rest/larc/internal/core"
15 "larc.wejust.rest/larc/internal/repo"
16 )
17
18 /* HTTP handlers for larc server.
19 * Browser routes return HTML, API routes return JSON. */
20
21 // handleGoGet returns go-import meta tag for Go module discovery.
22 // Called when ?go-get=1 query param is present.
23 // See: https://go.dev/ref/mod#vcs-find
24 func (s *Server) handleGoGet(c *fiber.Ctx, repoName string) error {
25 /* build base URL from config or construct from request */
26 baseURL := s.config.Server.BaseURL
27 if baseURL == "" {
28 proto := "https"
29 if c.Protocol() == "http" {
30 proto = "http"
31 }
32 baseURL = proto + "://" + c.Hostname()
33 }
34
35 /* strip trailing slash */
36 baseURL = strings.TrimSuffix(baseURL, "/")
37
38 /* format: <import-path> <vcs> <repo-url>
39 * e.g.: larc.wejust.rest/larc git https://larc.wejust.rest/larc */
40 host := c.Hostname()
41 importPath := host + "/" + repoName
42 repoURL := baseURL + "/" + repoName
43
44 html := `<!DOCTYPE html>
45 <html>
46 <head>
47 <meta name="go-import" content="` + importPath + ` git ` + repoURL + `">
48 </head>
49 <body>
50 go get ` + importPath + `
51 </body>
52 </html>`
53
54 c.Set("Content-Type", "text/html; charset=utf-8")
55 return c.SendString(html)
56 }
57
58 // handleIndex renders the server home page.
59 // If README exists in storage.root, it will be rendered.
60 // Otherwise, shows list of available repositories.
61 func (s *Server) handleIndex(c *fiber.Ctx) error {
62 /* filter to only public repos */
63 var publicRepos []RepoConfig
64 for _, repo := range s.config.Repos {
65 if repo.Public {
66 publicRepos = append(publicRepos, repo)
67 }
68 }
69
70 /* check for README in storage.root (try multiple names) */
71 readmeNames := []string{"README.md", "README", "README.txt"}
72 var content []byte
73
74 for _, name := range readmeNames {
75 readmePath := filepath.Join(s.config.Storage.Root, name)
76 data, err := os.ReadFile(readmePath)
77 if err == nil && len(data) > 0 {
78 content = data
79 break
80 }
81 }
82
83 if len(content) > 0 {
84 /* render custom README */
85 html, err := RenderHomePage(content, publicRepos)
86 if err != nil {
87 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render README")
88 }
89 c.Set("Content-Type", "text/html; charset=utf-8")
90 return c.Send(html)
91 }
92
93 /* no custom README, show repo list */
94 html, err := RenderRepoList(publicRepos)
95 if err != nil {
96 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render page")
97 }
98
99 c.Set("Content-Type", "text/html; charset=utf-8")
100 return c.Send(html)
101 }
102
103 // handleRepoIndex renders README.md as HTML
104 // If ?go-get=1 is present, returns go-import meta tag for Go module discovery
105 func (s *Server) handleRepoIndex(c *fiber.Ctx) error {
106 repoName := c.Params("repo")
107
108 /* handle go-get=1 for Go module discovery */
109 if c.Query("go-get") == "1" {
110 return s.handleGoGet(c, repoName)
111 }
112
113 r, repoCfg, err := s.getRepo(c)
114 if err != nil {
115 return err
116 }
117
118 /* get latest revision */
119 latestRev, err := r.Meta.GetLatestRevision()
120 if err != nil {
121 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
122 }
123
124 if latestRev == 0 {
125 /* empty repo */
126 html, err := RenderPage(&PageData{
127 Title: repoCfg.Name,
128 RepoName: repoCfg.Name,
129 Content: "<p>Empty repository. No commits yet.</p>",
130 })
131 if err != nil {
132 return err
133 }
134 c.Set("Content-Type", "text/html; charset=utf-8")
135 return c.Send(html)
136 }
137
138 /* get tree for latest revision */
139 rev, err := r.Meta.GetRevision(latestRev)
140 if err != nil {
141 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
142 }
143
144 tree, err := r.GetTree(rev.TreeHash)
145 if err != nil {
146 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
147 }
148
149 /* find README.md */
150 var readmeHash string
151 for _, entry := range tree.Entries {
152 lower := strings.ToLower(entry.Path)
153 if lower == "readme.md" || lower == "readme" || lower == "readme.txt" {
154 readmeHash = entry.BlobHash
155 break
156 }
157 }
158
159 if readmeHash == "" {
160 /* no README */
161 html, err := RenderPage(&PageData{
162 Title: repoCfg.Name,
163 RepoName: repoCfg.Name,
164 Revision: latestRev,
165 Content: "<p>No README.md found in this repository.</p>",
166 })
167 if err != nil {
168 return err
169 }
170 c.Set("Content-Type", "text/html; charset=utf-8")
171 return c.Send(html)
172 }
173
174 /* read README content */
175 readmeContent, err := r.Blobs.Read(readmeHash)
176 if err != nil {
177 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read README")
178 }
179
180 /* render HTML */
181 html, err := RenderReadme(repoCfg.Name, latestRev, readmeContent)
182 if err != nil {
183 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render README")
184 }
185
186 c.Set("Content-Type", "text/html; charset=utf-8")
187 return c.Send(html)
188 }
189
190 // handleTree renders directory listing
191 func (s *Server) handleTree(c *fiber.Ctx) error {
192 r, repoCfg, err := s.getRepo(c)
193 if err != nil {
194 return err
195 }
196
197 revParam := c.Params("rev")
198 revNum, err := strconv.ParseInt(revParam, 10, 64)
199 if err != nil {
200 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
201 }
202
203 rev, err := r.Meta.GetRevision(revNum)
204 if err != nil {
205 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
206 }
207
208 tree, err := r.GetTree(rev.TreeHash)
209 if err != nil {
210 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
211 }
212
213 /* current path in tree */
214 currentPath := c.Params("*")
215 currentPath = strings.TrimSuffix(currentPath, "/")
216
217 /* build breadcrumbs */
218 var breadcrumbs []Breadcrumb
219 if currentPath != "" {
220 parts := strings.Split(currentPath, "/")
221 for i, part := range parts {
222 path := strings.Join(parts[:i+1], "/")
223 breadcrumbs = append(breadcrumbs, Breadcrumb{
224 Name: part,
225 URL: "/" + repoCfg.Name + "/tree/" + revParam + "/" + path,
226 })
227 }
228 }
229
230 /* filter entries for current directory */
231 dirsMap := make(map[string]bool)
232 var files []TreeEntryView
233
234 for _, entry := range tree.Entries {
235 /* check if entry is in current path */
236 entryPath := entry.Path
237
238 if currentPath != "" {
239 if !strings.HasPrefix(entryPath, currentPath+"/") {
240 continue
241 }
242 entryPath = strings.TrimPrefix(entryPath, currentPath+"/")
243 }
244
245 /* check if it's a direct child or in subdirectory */
246 parts := strings.SplitN(entryPath, "/", 2)
247 if len(parts) > 1 {
248 /* it's in a subdirectory */
249 dirName := parts[0]
250 if !dirsMap[dirName] {
251 dirsMap[dirName] = true
252 }
253 } else {
254 /* direct file */
255 files = append(files, TreeEntryView{
256 Name: parts[0],
257 Path: entry.Path,
258 Size: entry.Size,
259 SizeStr: formatSize(entry.Size),
260 IsDir: false,
261 })
262 }
263 }
264
265 /* convert dirs map to slice */
266 var dirs []TreeEntryView
267 for dirName := range dirsMap {
268 dirPath := dirName
269 if currentPath != "" {
270 dirPath = currentPath + "/" + dirName
271 }
272 dirs = append(dirs, TreeEntryView{
273 Name: dirName,
274 Path: dirPath,
275 IsDir: true,
276 })
277 }
278
279 /* sort dirs and files */
280 sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name })
281 sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
282
283 data := &TreeData{
284 RepoName: repoCfg.Name,
285 Revision: revNum,
286 Path: currentPath,
287 Breadcrumbs: breadcrumbs,
288 Entries: len(dirs) > 0 || len(files) > 0,
289 Dirs: dirs,
290 Files: files,
291 }
292
293 html, err := RenderTree(data)
294 if err != nil {
295 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render tree")
296 }
297
298 c.Set("Content-Type", "text/html; charset=utf-8")
299 return c.Send(html)
300 }
301
302 // handleBlob renders file content with syntax highlighting
303 func (s *Server) handleBlob(c *fiber.Ctx) error {
304 r, repoCfg, err := s.getRepo(c)
305 if err != nil {
306 return err
307 }
308
309 revParam := c.Params("rev")
310 revNum, err := strconv.ParseInt(revParam, 10, 64)
311 if err != nil {
312 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
313 }
314
315 filePath := c.Params("*")
316
317 rev, err := r.Meta.GetRevision(revNum)
318 if err != nil {
319 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
320 }
321
322 tree, err := r.GetTree(rev.TreeHash)
323 if err != nil {
324 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
325 }
326
327 /* find file in tree */
328 var blobHash string
329 var fileSize int64
330 for _, entry := range tree.Entries {
331 if entry.Path == filePath {
332 blobHash = entry.BlobHash
333 fileSize = entry.Size
334 break
335 }
336 }
337
338 if blobHash == "" {
339 return fiber.NewError(fiber.StatusNotFound, "File not found")
340 }
341
342 content, err := r.Blobs.Read(blobHash)
343 if err != nil {
344 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read file")
345 }
346
347 /* build breadcrumbs */
348 parts := strings.Split(filePath, "/")
349 var breadcrumbs []Breadcrumb
350 for i := 0; i < len(parts)-1; i++ {
351 path := strings.Join(parts[:i+1], "/")
352 breadcrumbs = append(breadcrumbs, Breadcrumb{
353 Name: parts[i],
354 URL: "/" + repoCfg.Name + "/tree/" + revParam + "/" + path,
355 })
356 }
357 /* add file itself (non-linked) */
358 breadcrumbs = append(breadcrumbs, Breadcrumb{
359 Name: parts[len(parts)-1],
360 URL: "", /* current file, no link */
361 })
362
363 /* split content into lines */
364 lines := strings.Split(string(content), "\n")
365 var blobLines []BlobLine
366 for i, line := range lines {
367 blobLines = append(blobLines, BlobLine{
368 Num: i + 1,
369 Content: line,
370 })
371 }
372
373 fileName := parts[len(parts)-1]
374
375 data := &BlobData{
376 RepoName: repoCfg.Name,
377 Revision: revNum,
378 FilePath: filePath,
379 FileName: fileName,
380 Breadcrumbs: breadcrumbs[:len(breadcrumbs)-1], /* exclude file itself */
381 Lines: blobLines,
382 LineCount: len(lines),
383 SizeStr: formatSize(fileSize),
384 }
385
386 html, err := RenderBlob(data)
387 if err != nil {
388 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render blob")
389 }
390
391 c.Set("Content-Type", "text/html; charset=utf-8")
392 return c.Send(html)
393 }
394
395 // handleRaw returns raw file content
396 func (s *Server) handleRaw(c *fiber.Ctx) error {
397 r, _, err := s.getRepo(c)
398 if err != nil {
399 return err
400 }
401
402 revParam := c.Params("rev")
403 revNum, err := strconv.ParseInt(revParam, 10, 64)
404 if err != nil {
405 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
406 }
407
408 filePath := c.Params("*")
409
410 rev, err := r.Meta.GetRevision(revNum)
411 if err != nil {
412 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
413 }
414
415 tree, err := r.GetTree(rev.TreeHash)
416 if err != nil {
417 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
418 }
419
420 /* find file in tree */
421 var blobHash string
422 for _, entry := range tree.Entries {
423 if entry.Path == filePath {
424 blobHash = entry.BlobHash
425 break
426 }
427 }
428
429 if blobHash == "" {
430 return fiber.NewError(fiber.StatusNotFound, "File not found")
431 }
432
433 content, err := r.Blobs.Read(blobHash)
434 if err != nil {
435 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read file")
436 }
437
438 c.Set("Content-Type", "text/plain; charset=utf-8")
439 return c.Send(content)
440 }
441
442 // handleLog renders commit history
443 func (s *Server) handleLog(c *fiber.Ctx) error {
444 r, repoCfg, err := s.getRepo(c)
445 if err != nil {
446 return err
447 }
448
449 limit := c.QueryInt("limit", 30)
450 offset := c.QueryInt("offset", 0)
451 branch := c.Query("branch", "")
452
453 revs, err := r.Meta.ListRevisions(branch, limit+1, offset) // +1 to check if more exist
454 if err != nil {
455 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list revisions")
456 }
457
458 latestRev, _ := r.Meta.GetLatestRevision()
459
460 /* check pagination */
461 hasNext := len(revs) > limit
462 if hasNext {
463 revs = revs[:limit]
464 }
465 hasPrev := offset > 0
466
467 /* convert to view */
468 var commits []CommitView
469 for _, rev := range revs {
470 commits = append(commits, CommitView{
471 Number: rev.Number,
472 Branch: rev.Branch,
473 Author: rev.Author,
474 Message: rev.Message,
475 DateStr: time.Unix(rev.Timestamp, 0).Format("Jan 2, 2006 15:04"),
476 })
477 }
478
479 data := &LogData{
480 RepoName: repoCfg.Name,
481 LatestRev: latestRev,
482 Commits: commits,
483 HasPrev: hasPrev,
484 HasNext: hasNext,
485 PrevOffset: offset - limit,
486 NextOffset: offset + limit,
487 }
488
489 if data.PrevOffset < 0 {
490 data.PrevOffset = 0
491 }
492
493 html, err := RenderLog(data)
494 if err != nil {
495 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render log")
496 }
497
498 c.Set("Content-Type", "text/html; charset=utf-8")
499 return c.Send(html)
500 }
501
502 // handleAPIInfo returns repository info
503 func (s *Server) handleAPIInfo(c *fiber.Ctx) error {
504 r, repoCfg, err := s.getRepo(c)
505 if err != nil {
506 return err
507 }
508
509 latestRev, _ := r.Meta.GetLatestRevision()
510 branches, _ := r.Meta.ListBranches()
511
512 c.Set("Content-Type", "application/json")
513 return c.JSON(fiber.Map{
514 "name": repoCfg.Name,
515 "latest_rev": latestRev,
516 "branch_count": len(branches),
517 "default_branch": core.DefaultBranchName,
518 })
519 }
520
521 // handleAPILatestRev returns the latest revision number
522 func (s *Server) handleAPILatestRev(c *fiber.Ctx) error {
523 r, _, err := s.getRepo(c)
524 if err != nil {
525 return err
526 }
527
528 latestRev, err := r.Meta.GetLatestRevision()
529 if err != nil {
530 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
531 }
532
533 c.Set("Content-Type", "application/json")
534 return c.JSON(fiber.Map{"revision": latestRev})
535 }
536
537 // handleAPIRevision returns revision details
538 func (s *Server) handleAPIRevision(c *fiber.Ctx) error {
539 r, _, err := s.getRepo(c)
540 if err != nil {
541 return err
542 }
543
544 numParam := c.Params("num")
545 revNum, err := strconv.ParseInt(numParam, 10, 64)
546 if err != nil {
547 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
548 }
549
550 rev, err := r.Meta.GetRevision(revNum)
551 if err != nil {
552 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
553 }
554
555 c.Set("Content-Type", "application/json")
556 return c.JSON(rev)
557 }
558
559 // handleAPITree returns tree for a revision
560 func (s *Server) handleAPITree(c *fiber.Ctx) error {
561 r, _, err := s.getRepo(c)
562 if err != nil {
563 return err
564 }
565
566 numParam := c.Params("num")
567 revNum, err := strconv.ParseInt(numParam, 10, 64)
568 if err != nil {
569 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
570 }
571
572 rev, err := r.Meta.GetRevision(revNum)
573 if err != nil {
574 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
575 }
576
577 tree, err := r.GetTree(rev.TreeHash)
578 if err != nil {
579 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
580 }
581
582 c.Set("Content-Type", "application/json")
583 return c.JSON(tree)
584 }
585
586 // handleAPIBranches returns all branches
587 func (s *Server) handleAPIBranches(c *fiber.Ctx) error {
588 r, _, err := s.getRepo(c)
589 if err != nil {
590 return err
591 }
592
593 branches, err := r.Meta.ListBranches()
594 if err != nil {
595 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list branches")
596 }
597
598 c.Set("Content-Type", "application/json")
599 return c.JSON(branches)
600 }
601
602 // handleAPIBlob returns blob content
603 func (s *Server) handleAPIBlob(c *fiber.Ctx) error {
604 r, _, err := s.getRepo(c)
605 if err != nil {
606 return err
607 }
608
609 hash := c.Params("hash")
610 if len(hash) != 16 {
611 return fiber.NewError(fiber.StatusBadRequest, "Invalid blob hash")
612 }
613
614 content, err := r.Blobs.Read(hash)
615 if err != nil {
616 return fiber.NewError(fiber.StatusNotFound, "Blob not found")
617 }
618
619 c.Set("Content-Type", "application/octet-stream")
620 return c.Send(content)
621 }
622
623 // handleAPIUploadBlob uploads a new blob
624 func (s *Server) handleAPIUploadBlob(c *fiber.Ctx) error {
625 r, _, err := s.getRepo(c)
626 if err != nil {
627 return err
628 }
629
630 body := c.Body()
631 if len(body) == 0 {
632 return fiber.NewError(fiber.StatusBadRequest, "Empty body")
633 }
634
635 hash, err := r.Blobs.Write(body)
636 if err != nil {
637 return fiber.NewError(fiber.StatusInternalServerError, "Failed to store blob")
638 }
639
640 c.Set("Content-Type", "application/json")
641 return c.JSON(fiber.Map{"hash": hash, "size": len(body)})
642 }
643
644 // CommitRequest is the request body for creating a commit
645 type CommitRequest struct {
646 Branch string `json:"branch"`
647 Message string `json:"message"`
648 Author string `json:"author"`
649 Entries []core.TreeEntry `json:"entries"`
650 }
651
652 // handleAPICommit creates a new revision
653 func (s *Server) handleAPICommit(c *fiber.Ctx) error {
654 r, _, err := s.getRepo(c)
655 if err != nil {
656 return err
657 }
658
659 var req CommitRequest
660 if err := sonic.Unmarshal(c.Body(), &req); err != nil {
661 return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
662 }
663
664 if req.Message == "" {
665 return fiber.NewError(fiber.StatusBadRequest, "Message required")
666 }
667 if len(req.Entries) == 0 {
668 return fiber.NewError(fiber.StatusBadRequest, "Entries required")
669 }
670
671 /* use authenticated user as author if not specified */
672 author := req.Author
673 if author == "" {
674 author, _ = c.Locals("username").(string)
675 }
676 if author == "" {
677 author = "anonymous"
678 }
679
680 rev, err := r.Commit(author, req.Message, req.Entries)
681 if err != nil {
682 return fiber.NewError(fiber.StatusInternalServerError, "Failed to create commit")
683 }
684
685 c.Set("Content-Type", "application/json")
686 return c.JSON(rev)
687 }
688
689 /* NOTE(kroot): suppress unused import */
690 var _ = repo.LarcDir
691