larc r22

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