package server import ( "fmt" "log/slog" "os" "path/filepath" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/recover" "github.com/lain/larc/internal/core" "github.com/lain/larc/internal/repo" ) /* Larc server (larcs) - serves repositories over HTTP. * Features: * - README.md rendered as HTML * - Basic Auth via htpasswd * - Per-repo access control * - JSON API for client operations */ // Server is the larc HTTP server type Server struct { app *fiber.App config *Config repos map[string]*repo.Repository // repo name -> repository } // New creates a new server instance func New(config *Config) (*Server, error) { /* setup logging */ setupLogging(config.Logging) /* validate config */ if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } /* create fiber app */ app := fiber.New(fiber.Config{ AppName: "larcs", DisableStartupMessage: true, ErrorHandler: errorHandler, }) /* global middleware */ app.Use(recover.New()) app.Use(LoggingMiddleware()) /* optional auth for all routes */ if config.Auth.Enabled { app.Use(OptionalAuthMiddleware(config.Auth.HtpasswdFile)) } else { app.Use(func(c *fiber.Ctx) error { c.Locals("username", "") return c.Next() }) } s := &Server{ app: app, config: config, repos: make(map[string]*repo.Repository), } /* load repositories */ if err := s.loadRepos(); err != nil { return nil, fmt.Errorf("load repos: %w", err) } /* setup routes */ s.setupRoutes() return s, nil } func (s *Server) loadRepos() error { for _, repoCfg := range s.config.Repos { repoPath := filepath.Join(s.config.Storage.Root, repoCfg.Path) /* check if repo exists */ larcDir := filepath.Join(repoPath, repo.LarcDir) if _, err := os.Stat(larcDir); os.IsNotExist(err) { slog.Warn("repository not initialized", "name", repoCfg.Name, "path", repoPath) continue } r, err := repo.Open(repoPath) if err != nil { slog.Error("failed to open repository", "name", repoCfg.Name, "error", err) continue } s.repos[repoCfg.Name] = r slog.Info("loaded repository", "name", repoCfg.Name, "path", repoPath) } return nil } func (s *Server) setupRoutes() { /* browser routes (HTML) */ s.app.Get("/:repo", s.handleRepoIndex) s.app.Get("/:repo/tree/:rev/*", s.handleTree) s.app.Get("/:repo/blob/:rev/*", s.handleBlob) s.app.Get("/:repo/log", s.handleLog) /* API routes (JSON) */ api := s.app.Group("/api") api.Get("/:repo/info", s.handleAPIInfo) api.Get("/:repo/rev/latest", s.handleAPILatestRev) api.Get("/:repo/rev/:num", s.handleAPIRevision) api.Get("/:repo/tree/:num", s.handleAPITree) api.Get("/:repo/branches", s.handleAPIBranches) api.Get("/:repo/blob/:hash", s.handleAPIBlob) /* write operations require auth */ if s.config.Auth.Enabled { writeAPI := api.Group("/:repo", RepoAccessMiddleware(s.config, true)) writeAPI.Post("/blob", s.handleAPIUploadBlob) writeAPI.Post("/commit", s.handleAPICommit) } } func (s *Server) getRepo(c *fiber.Ctx) (*repo.Repository, *RepoConfig, error) { repoName := c.Params("repo") repoCfg := s.config.GetRepoConfig(repoName) if repoCfg == nil { return nil, nil, fiber.NewError(fiber.StatusNotFound, "Repository not found") } r, ok := s.repos[repoName] if !ok { return nil, nil, fiber.NewError(fiber.StatusNotFound, "Repository not initialized") } /* check access */ username, _ := c.Locals("username").(string) if !repoCfg.IsUserAllowed(username, false) { if username == "" { c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`) return nil, nil, fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } return nil, nil, fiber.NewError(fiber.StatusForbidden, "Access denied") } return r, repoCfg, nil } // Start starts the server func (s *Server) Start() error { addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port) slog.Info("starting server", "addr", addr, "tls", s.config.Server.TLS.Enabled) if s.config.Server.TLS.Enabled { return s.app.ListenTLS(addr, s.config.Server.TLS.Cert, s.config.Server.TLS.Key) } return s.app.Listen(addr) } // Shutdown gracefully shuts down the server func (s *Server) Shutdown() error { /* close all repositories */ for name, r := range s.repos { if err := r.Close(); err != nil { slog.Error("failed to close repository", "name", name, "error", err) } } return s.app.Shutdown() } func setupLogging(cfg LoggingConfig) { var level slog.Level switch cfg.Level { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } var handler slog.Handler opts := &slog.HandlerOptions{Level: level} output := os.Stdout if cfg.File != "" { f, err := os.OpenFile(cfg.File, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err == nil { output = f } } if cfg.Format == "text" { handler = slog.NewTextHandler(output, opts) } else { handler = slog.NewJSONHandler(output, opts) } slog.SetDefault(slog.New(handler)) } func errorHandler(c *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError msg := "Internal Server Error" if e, ok := err.(*fiber.Error); ok { code = e.Code msg = e.Message } slog.Error("request error", "path", c.Path(), "code", code, "error", err) if c.Get("Accept") == "application/json" { return c.Status(code).JSON(fiber.Map{"error": msg}) } return c.Status(code).SendString(msg) } /* NOTE(kroot): suppress unused import */ var _ = core.DefaultBranchName