larc r3

226 lines ยท 5.5 KB Raw
1 package server
2
3 import (
4 "fmt"
5 "log/slog"
6 "os"
7 "path/filepath"
8
9 "github.com/gofiber/fiber/v2"
10 "github.com/gofiber/fiber/v2/middleware/recover"
11
12 "github.com/lain/larc/internal/core"
13 "github.com/lain/larc/internal/repo"
14 )
15
16 /* Larc server (larcs) - serves repositories over HTTP.
17 * Features:
18 * - README.md rendered as HTML
19 * - Basic Auth via htpasswd
20 * - Per-repo access control
21 * - JSON API for client operations */
22
23 // Server is the larc HTTP server
24 type Server struct {
25 app *fiber.App
26 config *Config
27 repos map[string]*repo.Repository // repo name -> repository
28 }
29
30 // New creates a new server instance
31 func New(config *Config) (*Server, error) {
32 /* setup logging */
33 setupLogging(config.Logging)
34
35 /* validate config */
36 if err := config.Validate(); err != nil {
37 return nil, fmt.Errorf("invalid config: %w", err)
38 }
39
40 /* create fiber app */
41 app := fiber.New(fiber.Config{
42 AppName: "larcs",
43 DisableStartupMessage: true,
44 ErrorHandler: errorHandler,
45 })
46
47 /* global middleware */
48 app.Use(recover.New())
49 app.Use(LoggingMiddleware())
50
51 /* optional auth for all routes */
52 if config.Auth.Enabled {
53 app.Use(OptionalAuthMiddleware(config.Auth.HtpasswdFile))
54 } else {
55 app.Use(func(c *fiber.Ctx) error {
56 c.Locals("username", "")
57 return c.Next()
58 })
59 }
60
61 s := &Server{
62 app: app,
63 config: config,
64 repos: make(map[string]*repo.Repository),
65 }
66
67 /* load repositories */
68 if err := s.loadRepos(); err != nil {
69 return nil, fmt.Errorf("load repos: %w", err)
70 }
71
72 /* setup routes */
73 s.setupRoutes()
74
75 return s, nil
76 }
77
78 func (s *Server) loadRepos() error {
79 for _, repoCfg := range s.config.Repos {
80 repoPath := filepath.Join(s.config.Storage.Root, repoCfg.Path)
81
82 /* check if repo exists */
83 larcDir := filepath.Join(repoPath, repo.LarcDir)
84 if _, err := os.Stat(larcDir); os.IsNotExist(err) {
85 slog.Warn("repository not initialized", "name", repoCfg.Name, "path", repoPath)
86 continue
87 }
88
89 r, err := repo.Open(repoPath)
90 if err != nil {
91 slog.Error("failed to open repository", "name", repoCfg.Name, "error", err)
92 continue
93 }
94
95 s.repos[repoCfg.Name] = r
96 slog.Info("loaded repository", "name", repoCfg.Name, "path", repoPath)
97 }
98
99 return nil
100 }
101
102 func (s *Server) setupRoutes() {
103 /* browser routes (HTML) */
104 s.app.Get("/:repo", s.handleRepoIndex)
105 s.app.Get("/:repo/tree/:rev/*", s.handleTree)
106 s.app.Get("/:repo/blob/:rev/*", s.handleBlob)
107 s.app.Get("/:repo/log", s.handleLog)
108
109 /* API routes (JSON) */
110 api := s.app.Group("/api")
111 api.Get("/:repo/info", s.handleAPIInfo)
112 api.Get("/:repo/rev/latest", s.handleAPILatestRev)
113 api.Get("/:repo/rev/:num", s.handleAPIRevision)
114 api.Get("/:repo/tree/:num", s.handleAPITree)
115 api.Get("/:repo/branches", s.handleAPIBranches)
116 api.Get("/:repo/blob/:hash", s.handleAPIBlob)
117
118 /* write operations require auth */
119 if s.config.Auth.Enabled {
120 writeAPI := api.Group("/:repo", RepoAccessMiddleware(s.config, true))
121 writeAPI.Post("/blob", s.handleAPIUploadBlob)
122 writeAPI.Post("/commit", s.handleAPICommit)
123 }
124 }
125
126 func (s *Server) getRepo(c *fiber.Ctx) (*repo.Repository, *RepoConfig, error) {
127 repoName := c.Params("repo")
128
129 repoCfg := s.config.GetRepoConfig(repoName)
130 if repoCfg == nil {
131 return nil, nil, fiber.NewError(fiber.StatusNotFound, "Repository not found")
132 }
133
134 r, ok := s.repos[repoName]
135 if !ok {
136 return nil, nil, fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
137 }
138
139 /* check access */
140 username, _ := c.Locals("username").(string)
141 if !repoCfg.IsUserAllowed(username, false) {
142 if username == "" {
143 c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`)
144 return nil, nil, fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
145 }
146 return nil, nil, fiber.NewError(fiber.StatusForbidden, "Access denied")
147 }
148
149 return r, repoCfg, nil
150 }
151
152 // Start starts the server
153 func (s *Server) Start() error {
154 addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port)
155 slog.Info("starting server", "addr", addr, "tls", s.config.Server.TLS.Enabled)
156
157 if s.config.Server.TLS.Enabled {
158 return s.app.ListenTLS(addr, s.config.Server.TLS.Cert, s.config.Server.TLS.Key)
159 }
160 return s.app.Listen(addr)
161 }
162
163 // Shutdown gracefully shuts down the server
164 func (s *Server) Shutdown() error {
165 /* close all repositories */
166 for name, r := range s.repos {
167 if err := r.Close(); err != nil {
168 slog.Error("failed to close repository", "name", name, "error", err)
169 }
170 }
171 return s.app.Shutdown()
172 }
173
174 func setupLogging(cfg LoggingConfig) {
175 var level slog.Level
176 switch cfg.Level {
177 case "debug":
178 level = slog.LevelDebug
179 case "warn":
180 level = slog.LevelWarn
181 case "error":
182 level = slog.LevelError
183 default:
184 level = slog.LevelInfo
185 }
186
187 var handler slog.Handler
188 opts := &slog.HandlerOptions{Level: level}
189
190 output := os.Stdout
191 if cfg.File != "" {
192 f, err := os.OpenFile(cfg.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
193 if err == nil {
194 output = f
195 }
196 }
197
198 if cfg.Format == "text" {
199 handler = slog.NewTextHandler(output, opts)
200 } else {
201 handler = slog.NewJSONHandler(output, opts)
202 }
203
204 slog.SetDefault(slog.New(handler))
205 }
206
207 func errorHandler(c *fiber.Ctx, err error) error {
208 code := fiber.StatusInternalServerError
209 msg := "Internal Server Error"
210
211 if e, ok := err.(*fiber.Error); ok {
212 code = e.Code
213 msg = e.Message
214 }
215
216 slog.Error("request error", "path", c.Path(), "code", code, "error", err)
217
218 if c.Get("Accept") == "application/json" {
219 return c.Status(code).JSON(fiber.Map{"error": msg})
220 }
221 return c.Status(code).SendString(msg)
222 }
223
224 /* NOTE(kroot): suppress unused import */
225 var _ = core.DefaultBranchName
226