larc r9

537 lines ยท 12.9 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 // handleIndex renders the server home page.
22 // If README.md exists in storage.root, it will be rendered.
23 // Otherwise, shows list of available repositories.
24 func (s *Server) handleIndex(c *fiber.Ctx) error {
25 /* check for README.md in storage.root */
26 readmePath := filepath.Join(s.config.Storage.Root, "README.md")
27 content, err := os.ReadFile(readmePath)
28
29 if err == nil && len(content) > 0 {
30 /* render custom README.md */
31 html, err := RenderHomePage(content, s.config.Repos)
32 if err != nil {
33 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render README")
34 }
35 c.Set("Content-Type", "text/html; charset=utf-8")
36 return c.Send(html)
37 }
38
39 /* no custom README, show repo list */
40 html, err := RenderRepoList(s.config.Repos)
41 if err != nil {
42 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render page")
43 }
44
45 c.Set("Content-Type", "text/html; charset=utf-8")
46 return c.Send(html)
47 }
48
49 // handleRepoIndex renders README.md as HTML
50 func (s *Server) handleRepoIndex(c *fiber.Ctx) error {
51 r, repoCfg, err := s.getRepo(c)
52 if err != nil {
53 return err
54 }
55
56 /* get latest revision */
57 latestRev, err := r.Meta.GetLatestRevision()
58 if err != nil {
59 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
60 }
61
62 if latestRev == 0 {
63 /* empty repo */
64 html, err := RenderPage(&PageData{
65 Title: repoCfg.Name,
66 RepoName: repoCfg.Name,
67 Content: "<p>Empty repository. No commits yet.</p>",
68 })
69 if err != nil {
70 return err
71 }
72 c.Set("Content-Type", "text/html; charset=utf-8")
73 return c.Send(html)
74 }
75
76 /* get tree for latest revision */
77 rev, err := r.Meta.GetRevision(latestRev)
78 if err != nil {
79 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
80 }
81
82 tree, err := r.GetTree(rev.TreeHash)
83 if err != nil {
84 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
85 }
86
87 /* find README.md */
88 var readmeHash string
89 for _, entry := range tree.Entries {
90 lower := strings.ToLower(entry.Path)
91 if lower == "readme.md" || lower == "readme" || lower == "readme.txt" {
92 readmeHash = entry.BlobHash
93 break
94 }
95 }
96
97 if readmeHash == "" {
98 /* no README */
99 html, err := RenderPage(&PageData{
100 Title: repoCfg.Name,
101 RepoName: repoCfg.Name,
102 Revision: latestRev,
103 Content: "<p>No README.md found in this repository.</p>",
104 })
105 if err != nil {
106 return err
107 }
108 c.Set("Content-Type", "text/html; charset=utf-8")
109 return c.Send(html)
110 }
111
112 /* read README content */
113 readmeContent, err := r.Blobs.Read(readmeHash)
114 if err != nil {
115 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read README")
116 }
117
118 /* render HTML */
119 html, err := RenderReadme(repoCfg.Name, latestRev, readmeContent)
120 if err != nil {
121 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render README")
122 }
123
124 c.Set("Content-Type", "text/html; charset=utf-8")
125 return c.Send(html)
126 }
127
128 // handleTree renders directory listing
129 func (s *Server) handleTree(c *fiber.Ctx) error {
130 r, repoCfg, err := s.getRepo(c)
131 if err != nil {
132 return err
133 }
134
135 revParam := c.Params("rev")
136 revNum, err := strconv.ParseInt(revParam, 10, 64)
137 if err != nil {
138 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
139 }
140
141 rev, err := r.Meta.GetRevision(revNum)
142 if err != nil {
143 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
144 }
145
146 tree, err := r.GetTree(rev.TreeHash)
147 if err != nil {
148 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
149 }
150
151 /* current path in tree */
152 currentPath := c.Params("*")
153 currentPath = strings.TrimSuffix(currentPath, "/")
154
155 /* build breadcrumbs */
156 var breadcrumbs []Breadcrumb
157 if currentPath != "" {
158 parts := strings.Split(currentPath, "/")
159 for i, part := range parts {
160 path := strings.Join(parts[:i+1], "/")
161 breadcrumbs = append(breadcrumbs, Breadcrumb{
162 Name: part,
163 URL: "/" + repoCfg.Name + "/tree/" + revParam + "/" + path,
164 })
165 }
166 }
167
168 /* filter entries for current directory */
169 dirsMap := make(map[string]bool)
170 var files []TreeEntryView
171
172 for _, entry := range tree.Entries {
173 /* check if entry is in current path */
174 entryPath := entry.Path
175
176 if currentPath != "" {
177 if !strings.HasPrefix(entryPath, currentPath+"/") {
178 continue
179 }
180 entryPath = strings.TrimPrefix(entryPath, currentPath+"/")
181 }
182
183 /* check if it's a direct child or in subdirectory */
184 parts := strings.SplitN(entryPath, "/", 2)
185 if len(parts) > 1 {
186 /* it's in a subdirectory */
187 dirName := parts[0]
188 if !dirsMap[dirName] {
189 dirsMap[dirName] = true
190 }
191 } else {
192 /* direct file */
193 files = append(files, TreeEntryView{
194 Name: parts[0],
195 Path: entry.Path,
196 Size: entry.Size,
197 SizeStr: formatSize(entry.Size),
198 IsDir: false,
199 })
200 }
201 }
202
203 /* convert dirs map to slice */
204 var dirs []TreeEntryView
205 for dirName := range dirsMap {
206 dirPath := dirName
207 if currentPath != "" {
208 dirPath = currentPath + "/" + dirName
209 }
210 dirs = append(dirs, TreeEntryView{
211 Name: dirName,
212 Path: dirPath,
213 IsDir: true,
214 })
215 }
216
217 /* sort dirs and files */
218 sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name })
219 sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
220
221 data := &TreeData{
222 RepoName: repoCfg.Name,
223 Revision: revNum,
224 Path: currentPath,
225 Breadcrumbs: breadcrumbs,
226 Entries: len(dirs) > 0 || len(files) > 0,
227 Dirs: dirs,
228 Files: files,
229 }
230
231 html, err := RenderTree(data)
232 if err != nil {
233 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render tree")
234 }
235
236 c.Set("Content-Type", "text/html; charset=utf-8")
237 return c.Send(html)
238 }
239
240 // handleBlob returns file content
241 func (s *Server) handleBlob(c *fiber.Ctx) error {
242 r, _, err := s.getRepo(c)
243 if err != nil {
244 return err
245 }
246
247 revParam := c.Params("rev")
248 revNum, err := strconv.ParseInt(revParam, 10, 64)
249 if err != nil {
250 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
251 }
252
253 path := c.Params("*")
254
255 rev, err := r.Meta.GetRevision(revNum)
256 if err != nil {
257 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
258 }
259
260 tree, err := r.GetTree(rev.TreeHash)
261 if err != nil {
262 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
263 }
264
265 /* find file in tree */
266 var blobHash string
267 for _, entry := range tree.Entries {
268 if entry.Path == path {
269 blobHash = entry.BlobHash
270 break
271 }
272 }
273
274 if blobHash == "" {
275 return fiber.NewError(fiber.StatusNotFound, "File not found")
276 }
277
278 content, err := r.Blobs.Read(blobHash)
279 if err != nil {
280 return fiber.NewError(fiber.StatusInternalServerError, "Failed to read file")
281 }
282
283 /* TODO(kroot): detect content type */
284 c.Set("Content-Type", "text/plain; charset=utf-8")
285 return c.Send(content)
286 }
287
288 // handleLog renders commit history
289 func (s *Server) handleLog(c *fiber.Ctx) error {
290 r, repoCfg, err := s.getRepo(c)
291 if err != nil {
292 return err
293 }
294
295 limit := c.QueryInt("limit", 30)
296 offset := c.QueryInt("offset", 0)
297 branch := c.Query("branch", "")
298
299 revs, err := r.Meta.ListRevisions(branch, limit+1, offset) // +1 to check if more exist
300 if err != nil {
301 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list revisions")
302 }
303
304 latestRev, _ := r.Meta.GetLatestRevision()
305
306 /* check pagination */
307 hasNext := len(revs) > limit
308 if hasNext {
309 revs = revs[:limit]
310 }
311 hasPrev := offset > 0
312
313 /* convert to view */
314 var commits []CommitView
315 for _, rev := range revs {
316 commits = append(commits, CommitView{
317 Number: rev.Number,
318 Branch: rev.Branch,
319 Author: rev.Author,
320 Message: rev.Message,
321 DateStr: time.Unix(rev.Timestamp, 0).Format("Jan 2, 2006 15:04"),
322 })
323 }
324
325 data := &LogData{
326 RepoName: repoCfg.Name,
327 LatestRev: latestRev,
328 Commits: commits,
329 HasPrev: hasPrev,
330 HasNext: hasNext,
331 PrevOffset: offset - limit,
332 NextOffset: offset + limit,
333 }
334
335 if data.PrevOffset < 0 {
336 data.PrevOffset = 0
337 }
338
339 html, err := RenderLog(data)
340 if err != nil {
341 return fiber.NewError(fiber.StatusInternalServerError, "Failed to render log")
342 }
343
344 c.Set("Content-Type", "text/html; charset=utf-8")
345 return c.Send(html)
346 }
347
348 // handleAPIInfo returns repository info
349 func (s *Server) handleAPIInfo(c *fiber.Ctx) error {
350 r, repoCfg, err := s.getRepo(c)
351 if err != nil {
352 return err
353 }
354
355 latestRev, _ := r.Meta.GetLatestRevision()
356 branches, _ := r.Meta.ListBranches()
357
358 c.Set("Content-Type", "application/json")
359 return c.JSON(fiber.Map{
360 "name": repoCfg.Name,
361 "latest_rev": latestRev,
362 "branch_count": len(branches),
363 "default_branch": core.DefaultBranchName,
364 })
365 }
366
367 // handleAPILatestRev returns the latest revision number
368 func (s *Server) handleAPILatestRev(c *fiber.Ctx) error {
369 r, _, err := s.getRepo(c)
370 if err != nil {
371 return err
372 }
373
374 latestRev, err := r.Meta.GetLatestRevision()
375 if err != nil {
376 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get revision")
377 }
378
379 c.Set("Content-Type", "application/json")
380 return c.JSON(fiber.Map{"revision": latestRev})
381 }
382
383 // handleAPIRevision returns revision details
384 func (s *Server) handleAPIRevision(c *fiber.Ctx) error {
385 r, _, err := s.getRepo(c)
386 if err != nil {
387 return err
388 }
389
390 numParam := c.Params("num")
391 revNum, err := strconv.ParseInt(numParam, 10, 64)
392 if err != nil {
393 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
394 }
395
396 rev, err := r.Meta.GetRevision(revNum)
397 if err != nil {
398 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
399 }
400
401 c.Set("Content-Type", "application/json")
402 return c.JSON(rev)
403 }
404
405 // handleAPITree returns tree for a revision
406 func (s *Server) handleAPITree(c *fiber.Ctx) error {
407 r, _, err := s.getRepo(c)
408 if err != nil {
409 return err
410 }
411
412 numParam := c.Params("num")
413 revNum, err := strconv.ParseInt(numParam, 10, 64)
414 if err != nil {
415 return fiber.NewError(fiber.StatusBadRequest, "Invalid revision number")
416 }
417
418 rev, err := r.Meta.GetRevision(revNum)
419 if err != nil {
420 return fiber.NewError(fiber.StatusNotFound, "Revision not found")
421 }
422
423 tree, err := r.GetTree(rev.TreeHash)
424 if err != nil {
425 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get tree")
426 }
427
428 c.Set("Content-Type", "application/json")
429 return c.JSON(tree)
430 }
431
432 // handleAPIBranches returns all branches
433 func (s *Server) handleAPIBranches(c *fiber.Ctx) error {
434 r, _, err := s.getRepo(c)
435 if err != nil {
436 return err
437 }
438
439 branches, err := r.Meta.ListBranches()
440 if err != nil {
441 return fiber.NewError(fiber.StatusInternalServerError, "Failed to list branches")
442 }
443
444 c.Set("Content-Type", "application/json")
445 return c.JSON(branches)
446 }
447
448 // handleAPIBlob returns blob content
449 func (s *Server) handleAPIBlob(c *fiber.Ctx) error {
450 r, _, err := s.getRepo(c)
451 if err != nil {
452 return err
453 }
454
455 hash := c.Params("hash")
456 if len(hash) != 16 {
457 return fiber.NewError(fiber.StatusBadRequest, "Invalid blob hash")
458 }
459
460 content, err := r.Blobs.Read(hash)
461 if err != nil {
462 return fiber.NewError(fiber.StatusNotFound, "Blob not found")
463 }
464
465 c.Set("Content-Type", "application/octet-stream")
466 return c.Send(content)
467 }
468
469 // handleAPIUploadBlob uploads a new blob
470 func (s *Server) handleAPIUploadBlob(c *fiber.Ctx) error {
471 r, _, err := s.getRepo(c)
472 if err != nil {
473 return err
474 }
475
476 body := c.Body()
477 if len(body) == 0 {
478 return fiber.NewError(fiber.StatusBadRequest, "Empty body")
479 }
480
481 hash, err := r.Blobs.Write(body)
482 if err != nil {
483 return fiber.NewError(fiber.StatusInternalServerError, "Failed to store blob")
484 }
485
486 c.Set("Content-Type", "application/json")
487 return c.JSON(fiber.Map{"hash": hash, "size": len(body)})
488 }
489
490 // CommitRequest is the request body for creating a commit
491 type CommitRequest struct {
492 Branch string `json:"branch"`
493 Message string `json:"message"`
494 Author string `json:"author"`
495 Entries []core.TreeEntry `json:"entries"`
496 }
497
498 // handleAPICommit creates a new revision
499 func (s *Server) handleAPICommit(c *fiber.Ctx) error {
500 r, _, err := s.getRepo(c)
501 if err != nil {
502 return err
503 }
504
505 var req CommitRequest
506 if err := sonic.Unmarshal(c.Body(), &req); err != nil {
507 return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
508 }
509
510 if req.Message == "" {
511 return fiber.NewError(fiber.StatusBadRequest, "Message required")
512 }
513 if len(req.Entries) == 0 {
514 return fiber.NewError(fiber.StatusBadRequest, "Entries required")
515 }
516
517 /* use authenticated user as author if not specified */
518 author := req.Author
519 if author == "" {
520 author, _ = c.Locals("username").(string)
521 }
522 if author == "" {
523 author = "anonymous"
524 }
525
526 rev, err := r.Commit(author, req.Message, req.Entries)
527 if err != nil {
528 return fiber.NewError(fiber.StatusInternalServerError, "Failed to create commit")
529 }
530
531 c.Set("Content-Type", "application/json")
532 return c.JSON(rev)
533 }
534
535 /* NOTE(kroot): suppress unused import */
536 var _ = repo.LarcDir
537