larc r29

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