larc r8

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