larc r28

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