larc r1

225 lines ยท 5.4 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/branches", s.handleAPIBranches)
115 api.Get("/:repo/blob/:hash", s.handleAPIBlob)
116
117 /* write operations require auth */
118 if s.config.Auth.Enabled {
119 writeAPI := api.Group("/:repo", RepoAccessMiddleware(s.config, true))
120 writeAPI.Post("/blob", s.handleAPIUploadBlob)
121 writeAPI.Post("/commit", s.handleAPICommit)
122 }
123 }
124
125 func (s *Server) getRepo(c *fiber.Ctx) (*repo.Repository, *RepoConfig, error) {
126 repoName := c.Params("repo")
127
128 repoCfg := s.config.GetRepoConfig(repoName)
129 if repoCfg == nil {
130 return nil, nil, fiber.NewError(fiber.StatusNotFound, "Repository not found")
131 }
132
133 r, ok := s.repos[repoName]
134 if !ok {
135 return nil, nil, fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
136 }
137
138 /* check access */
139 username, _ := c.Locals("username").(string)
140 if !repoCfg.IsUserAllowed(username, false) {
141 if username == "" {
142 c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`)
143 return nil, nil, fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
144 }
145 return nil, nil, fiber.NewError(fiber.StatusForbidden, "Access denied")
146 }
147
148 return r, repoCfg, nil
149 }
150
151 // Start starts the server
152 func (s *Server) Start() error {
153 addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port)
154 slog.Info("starting server", "addr", addr, "tls", s.config.Server.TLS.Enabled)
155
156 if s.config.Server.TLS.Enabled {
157 return s.app.ListenTLS(addr, s.config.Server.TLS.Cert, s.config.Server.TLS.Key)
158 }
159 return s.app.Listen(addr)
160 }
161
162 // Shutdown gracefully shuts down the server
163 func (s *Server) Shutdown() error {
164 /* close all repositories */
165 for name, r := range s.repos {
166 if err := r.Close(); err != nil {
167 slog.Error("failed to close repository", "name", name, "error", err)
168 }
169 }
170 return s.app.Shutdown()
171 }
172
173 func setupLogging(cfg LoggingConfig) {
174 var level slog.Level
175 switch cfg.Level {
176 case "debug":
177 level = slog.LevelDebug
178 case "warn":
179 level = slog.LevelWarn
180 case "error":
181 level = slog.LevelError
182 default:
183 level = slog.LevelInfo
184 }
185
186 var handler slog.Handler
187 opts := &slog.HandlerOptions{Level: level}
188
189 output := os.Stdout
190 if cfg.File != "" {
191 f, err := os.OpenFile(cfg.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
192 if err == nil {
193 output = f
194 }
195 }
196
197 if cfg.Format == "text" {
198 handler = slog.NewTextHandler(output, opts)
199 } else {
200 handler = slog.NewJSONHandler(output, opts)
201 }
202
203 slog.SetDefault(slog.New(handler))
204 }
205
206 func errorHandler(c *fiber.Ctx, err error) error {
207 code := fiber.StatusInternalServerError
208 msg := "Internal Server Error"
209
210 if e, ok := err.(*fiber.Error); ok {
211 code = e.Code
212 msg = e.Message
213 }
214
215 slog.Error("request error", "path", c.Path(), "code", code, "error", err)
216
217 if c.Get("Accept") == "application/json" {
218 return c.Status(code).JSON(fiber.Map{"error": msg})
219 }
220 return c.Status(code).SendString(msg)
221 }
222
223 /* NOTE(kroot): suppress unused import */
224 var _ = core.DefaultBranchName
225