larc r10

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