larc r27

599 lines ยท 14.4 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 returns file content
303 func (s *Server) handleBlob(c *fiber.Ctx) error {
304 r, _, 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 path := 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 for _, entry := range tree.Entries {
330 if entry.Path == path {
331 blobHash = entry.BlobHash
332 break
333 }
334 }
335
336 if blobHash == "" {
337 return fiber.NewError(fiber.StatusNotFound, "File not found")
338 }
339
340 content, err := r.Blobs.Read(blobHash)
341 if err != nil {
342 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read file")
343 }
344
345 /* TODO(kroot): detect content type */
346 c.Set("Content-Type", "text/plain; charset=utf-8")
347 return c.Send(content)
348 }
349
350 // handleLog renders commit history
351 func (s *Server) handleLog(c *fiber.Ctx) error {
352 r, repoCfg, err := s.getRepo(c)
353 if err != nil {
354 return err
355 }
356
357 limit := c.QueryInt("limit", 30)
358 offset := c.QueryInt("offset", 0)
359 branch := c.Query("branch", "")
360
361 revs, err := r.Meta.ListRevisions(branch, limit+1, offset) // +1 to check if more exist
362 if err != nil {
363 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list revisions")
364 }
365
366 latestRev, _ := r.Meta.GetLatestRevision()
367
368 /* check pagination */
369 hasNext := len(revs) > limit
370 if hasNext {
371 revs = revs[:limit]
372 }
373 hasPrev := offset > 0
374
375 /* convert to view */
376 var commits []CommitView
377 for _, rev := range revs {
378 commits = append(commits, CommitView{
379 Number: rev.Number,
380 Branch: rev.Branch,
381 Author: rev.Author,
382 Message: rev.Message,
383 DateStr: time.Unix(rev.Timestamp, 0).Format("Jan 2, 2006 15:04"),
384 })
385 }
386
387 data := &LogData{
388 RepoName: repoCfg.Name,
389 LatestRev: latestRev,
390 Commits: commits,
391 HasPrev: hasPrev,
392 HasNext: hasNext,
393 PrevOffset: offset - limit,
394 NextOffset: offset + limit,
395 }
396
397 if data.PrevOffset < 0 {
398 data.PrevOffset = 0
399 }
400
401 html, err := RenderLog(data)
402 if err != nil {
403 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render log")
404 }
405
406 c.Set("Content-Type", "text/html; charset=utf-8")
407 return c.Send(html)
408 }
409
410 // handleAPIInfo returns repository info
411 func (s *Server) handleAPIInfo(c *fiber.Ctx) error {
412 r, repoCfg, err := s.getRepo(c)
413 if err != nil {
414 return err
415 }
416
417 latestRev, _ := r.Meta.GetLatestRevision()
418 branches, _ := r.Meta.ListBranches()
419
420 c.Set("Content-Type", "application/json")
421 return c.JSON(fiber.Map{
422 "name": repoCfg.Name,
423 "latest_rev": latestRev,
424 "branch_count": len(branches),
425 "default_branch": core.DefaultBranchName,
426 })
427 }
428
429 // handleAPILatestRev returns the latest revision number
430 func (s *Server) handleAPILatestRev(c *fiber.Ctx) error {
431 r, _, err := s.getRepo(c)
432 if err != nil {
433 return err
434 }
435
436 latestRev, err := r.Meta.GetLatestRevision()
437 if err != nil {
438 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
439 }
440
441 c.Set("Content-Type", "application/json")
442 return c.JSON(fiber.Map{"revision": latestRev})
443 }
444
445 // handleAPIRevision returns revision details
446 func (s *Server) handleAPIRevision(c *fiber.Ctx) error {
447 r, _, err := s.getRepo(c)
448 if err != nil {
449 return err
450 }
451
452 numParam := c.Params("num")
453 revNum, err := strconv.ParseInt(numParam, 10, 64)
454 if err != nil {
455 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
456 }
457
458 rev, err := r.Meta.GetRevision(revNum)
459 if err != nil {
460 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
461 }
462
463 c.Set("Content-Type", "application/json")
464 return c.JSON(rev)
465 }
466
467 // handleAPITree returns tree for a revision
468 func (s *Server) handleAPITree(c *fiber.Ctx) error {
469 r, _, err := s.getRepo(c)
470 if err != nil {
471 return err
472 }
473
474 numParam := c.Params("num")
475 revNum, err := strconv.ParseInt(numParam, 10, 64)
476 if err != nil {
477 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
478 }
479
480 rev, err := r.Meta.GetRevision(revNum)
481 if err != nil {
482 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
483 }
484
485 tree, err := r.GetTree(rev.TreeHash)
486 if err != nil {
487 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
488 }
489
490 c.Set("Content-Type", "application/json")
491 return c.JSON(tree)
492 }
493
494 // handleAPIBranches returns all branches
495 func (s *Server) handleAPIBranches(c *fiber.Ctx) error {
496 r, _, err := s.getRepo(c)
497 if err != nil {
498 return err
499 }
500
501 branches, err := r.Meta.ListBranches()
502 if err != nil {
503 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list branches")
504 }
505
506 c.Set("Content-Type", "application/json")
507 return c.JSON(branches)
508 }
509
510 // handleAPIBlob returns blob content
511 func (s *Server) handleAPIBlob(c *fiber.Ctx) error {
512 r, _, err := s.getRepo(c)
513 if err != nil {
514 return err
515 }
516
517 hash := c.Params("hash")
518 if len(hash) != 16 {
519 return fiber.NewError(fiber.StatusBadRequest, "Invalid blob hash")
520 }
521
522 content, err := r.Blobs.Read(hash)
523 if err != nil {
524 return fiber.NewError(fiber.StatusNotFound, "Blob not found")
525 }
526
527 c.Set("Content-Type", "application/octet-stream")
528 return c.Send(content)
529 }
530
531 // handleAPIUploadBlob uploads a new blob
532 func (s *Server) handleAPIUploadBlob(c *fiber.Ctx) error {
533 r, _, err := s.getRepo(c)
534 if err != nil {
535 return err
536 }
537
538 body := c.Body()
539 if len(body) == 0 {
540 return fiber.NewError(fiber.StatusBadRequest, "Empty body")
541 }
542
543 hash, err := r.Blobs.Write(body)
544 if err != nil {
545 return fiber.NewError(fiber.StatusInternalServerError, "Failed to store blob")
546 }
547
548 c.Set("Content-Type", "application/json")
549 return c.JSON(fiber.Map{"hash": hash, "size": len(body)})
550 }
551
552 // CommitRequest is the request body for creating a commit
553 type CommitRequest struct {
554 Branch string `json:"branch"`
555 Message string `json:"message"`
556 Author string `json:"author"`
557 Entries []core.TreeEntry `json:"entries"`
558 }
559
560 // handleAPICommit creates a new revision
561 func (s *Server) handleAPICommit(c *fiber.Ctx) error {
562 r, _, err := s.getRepo(c)
563 if err != nil {
564 return err
565 }
566
567 var req CommitRequest
568 if err := sonic.Unmarshal(c.Body(), &req); err != nil {
569 return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
570 }
571
572 if req.Message == "" {
573 return fiber.NewError(fiber.StatusBadRequest, "Message required")
574 }
575 if len(req.Entries) == 0 {
576 return fiber.NewError(fiber.StatusBadRequest, "Entries required")
577 }
578
579 /* use authenticated user as author if not specified */
580 author := req.Author
581 if author == "" {
582 author, _ = c.Locals("username").(string)
583 }
584 if author == "" {
585 author = "anonymous"
586 }
587
588 rev, err := r.Commit(author, req.Message, req.Entries)
589 if err != nil {
590 return fiber.NewError(fiber.StatusInternalServerError, "Failed to create commit")
591 }
592
593 c.Set("Content-Type", "application/json")
594 return c.JSON(rev)
595 }
596
597 /* NOTE(kroot): suppress unused import */
598 var _ = repo.LarcDir
599