package handlers import ( "bytes" "fmt" "log/slog" "net/http" "net/url" "strings" "time" "polar/internal/auth" "polar/internal/config" "polar/internal/db" "polar/internal/i18n" "polar/internal/repo" "polar/internal/user" "polar/internal/web/middleware" "polar/internal/web/static" "polar/internal/web/views" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/filesystem" ) type Handler struct { cfg config.Config db *db.DB authSvc *auth.Service userSvc *user.Service repoSvc *repo.Service views *views.Engine logger *slog.Logger } func New( cfg config.Config, database *db.DB, authSvc *auth.Service, userSvc *user.Service, repoSvc *repo.Service, viewsEngine *views.Engine, logger *slog.Logger, ) *Handler { return &Handler{ cfg: cfg, db: database, authSvc: authSvc, userSvc: userSvc, repoSvc: repoSvc, views: viewsEngine, logger: logger, } } func (h *Handler) RegisterRoutes(app *fiber.App) { app.Use(middleware.RecoverAndLog(h.logger)) app.Use(middleware.SecurityHeaders()) app.Get("/healthz", h.Healthz) app.Use("/static", filesystem.New(filesystem.Config{ Root: http.FS(static.FS), Browse: false, MaxAge: 0, })) app.Use(middleware.LocaleAndTheme(h.cfg.Landing.DefaultLang)) app.Use(middleware.SessionAuth(h.authSvc, h.userSvc)) app.Use(middleware.CSRFProtection()) authLimiter := middleware.NewRateLimiter(20, time.Minute) // Public & Root routes app.Get("/", h.Home) app.Get("/explore", h.Explore) app.Get("/agents", h.AgentsPage) // Auth routes app.Get("/login", h.LoginPage) app.Post("/login", authLimiter.Handler(), h.LoginSubmit) app.Get("/register", h.RegisterPage) app.Post("/register", authLimiter.Handler(), h.RegisterSubmit) app.Post("/logout", h.LogoutSubmit) app.Get("/login/device", h.DeviceVerifyPage) app.Post("/login/device", h.DeviceVerifySubmit) app.Post("/api/v1/device/code", h.DeviceCodeAPI) app.Post("/api/v1/device/token", h.DeviceTokenAPI) // Authenticated user routes app.Get("/repos", h.LegacyReposRedirect) app.Get("/new", middleware.RequireAuth(), h.NewRepoPage) app.Post("/new", middleware.RequireAuth(), h.NewRepoSubmit) app.Get("/settings", middleware.RequireAuth(), h.SettingsPage) app.Post("/settings/profile", middleware.RequireAuth(), h.UpdateProfileSubmit) app.Post("/settings/password", middleware.RequireAuth(), h.UpdatePasswordSubmit) app.Post("/settings/tokens", middleware.RequireAuth(), h.CreateTokenSubmit) app.Post("/settings/tokens/:id/revoke", middleware.RequireAuth(), h.RevokeTokenSubmit) // Admin routes app.Get("/admin", middleware.RequireAdmin(), h.AdminPage) app.Post("/admin/settings", middleware.RequireAdmin(), h.AdminSettingsSubmit) // Git Smart HTTP endpoints (supports both /:owner/:repo.git/* and /:owner/:repo/*) app.Get("/:owner/:repo/info/refs", h.GitInfoRefs) app.Post("/:owner/:repo/git-upload-pack", h.GitUploadPack) app.Post("/:owner/:repo/git-receive-pack", h.GitReceivePack) // Repository Web UI routes app.Get("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsPage) app.Post("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsSubmit) app.Post("/:owner/:repo/settings/rename", middleware.RequireAuth(), h.RepoRenameSubmit) app.Post("/:owner/:repo/settings/delete", middleware.RequireAuth(), h.RepoDeleteSubmit) app.Post("/:owner/:repo/settings/collaborators/add", middleware.RequireAuth(), h.RepoCollaboratorAddSubmit) app.Post("/:owner/:repo/settings/collaborators/remove", middleware.RequireAuth(), h.RepoCollaboratorRemoveSubmit) app.Get("/:owner/:repo/branches", h.RepoBranchesPage) app.Post("/:owner/:repo/branches", middleware.RequireAuth(), h.RepoCreateBranchSubmit) app.Post("/:owner/:repo/branches/delete", middleware.RequireAuth(), h.RepoDeleteBranchSubmit) app.Post("/:owner/:repo/tags", middleware.RequireAuth(), h.RepoCreateTagSubmit) app.Post("/:owner/:repo/tags/delete", middleware.RequireAuth(), h.RepoDeleteTagSubmit) app.Get("/:owner/:repo/archive/*", h.RepoArchiveZip) app.Get("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoNewFilePage) app.Post("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoCreateFileSubmit) app.Get("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoNewFolderPage) app.Post("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoCreateFolderSubmit) app.Get("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadPage) app.Post("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadSubmit) app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit) app.Get("/:owner/:repo/forks", h.RepoForksPage) app.Get("/:owner/:repo/issues", h.RepoIssuesPage) app.Get("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssuePage) app.Post("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssueSubmit) app.Get("/:owner/:repo/issues/:number", h.RepoIssueDetailPage) app.Post("/:owner/:repo/issues/:number/comments", middleware.RequireAuth(), h.RepoIssueCommentSubmit) app.Post("/:owner/:repo/issues/:number/state", middleware.RequireAuth(), h.RepoIssueStateSubmit) // Pull Requests app.Get("/:owner/:repo/pulls", h.RepoPullsPage) app.Get("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullPage) app.Post("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullSubmit) app.Get("/:owner/:repo/pulls/:number", h.RepoPullDetailPage) app.Post("/:owner/:repo/pulls/:number/comments", middleware.RequireAuth(), h.RepoPullCommentSubmit) app.Post("/:owner/:repo/pulls/:number/state", middleware.RequireAuth(), h.RepoPullStateSubmit) app.Post("/:owner/:repo/pulls/:number/merge", middleware.RequireAuth(), h.RepoPullMergeSubmit) app.Post("/:owner/:repo/pulls/:number/review", middleware.RequireAuth(), h.RepoPullReviewSubmit) // Releases app.Get("/:owner/:repo/releases", h.RepoReleasesPage) app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage) app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit) app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit) app.Get("/:owner/:repo/releases/:id/assets/:assetID/download", h.RepoReleaseAssetDownload) app.Post("/:owner/:repo/releases/:id/assets/:assetID/delete", middleware.RequireAuth(), h.RepoDeleteReleaseAssetSubmit) // Activity app.Get("/:owner/:repo/activity", h.RepoActivityPage) app.Get("/:owner/:repo/commits/:ref", h.RepoCommitsPage) app.Get("/:owner/:repo/commit/:hash", h.RepoCommitDetailPage) app.Get("/:owner/:repo/tree/:ref/*", h.RepoTreePage) app.Get("/:owner/:repo/tree/:ref", h.RepoTreePage) app.Get("/:owner/:repo/blob/:ref/*", h.RepoBlobPage) app.Get("/:owner/:repo/raw/:ref/*", h.RepoRawBlob) app.Get("/:owner/:repo", h.RepoHomePage) // User profile page app.Get("/:username", h.UserProfilePage) } func (h *Handler) Healthz(c *fiber.Ctx) error { if err := h.db.PingContext(c.UserContext()); err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "status": "error", "db": "unreachable", }) } return c.Status(fiber.StatusOK).JSON(fiber.Map{ "status": "ok", "service": "polar", }) } func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any { u := middleware.CurrentUser(c) lang := h.langOf(c) theme, _ := c.Locals("theme").(string) if theme == "" { theme = "system" } csrf, _ := c.Locals("csrf_token").(string) flash := c.Cookies("polar_flash") if flash != "" { if decoded, err := url.QueryUnescape(flash); err == nil { flash = decoded } c.ClearCookie("polar_flash") } ctx := c.UserContext() anonRead := h.db.IsAnonymousReadEnabled(ctx) var paletteRepos []repo.Repository if u != nil || anonRead { paletteRepos, _ = h.repoSvc.ListAccessible(ctx, u, 25) } data := map[string]any{ "Title": title, "Lang": lang, "Theme": theme, "CSRFToken": csrf, "CurrentUser": u, "Flash": flash, "RegistrationEnabled": h.isRegistrationAllowed(c), "ExploreEnabled": h.db.IsExploreEnabled(ctx), "LandingEnabled": h.db.IsLandingEnabled(ctx, h.cfg.Landing.Enabled), "AnonymousReadEnabled": anonRead, "IssuesEnabled": h.db.IsIssuesEnabled(ctx), "PaletteRepos": paletteRepos, } if extra := h.repoNavData(c); extra != nil { for k, v := range extra { data[k] = v } } return data } // repoNavData enriches template data for /:owner/:repo routes with fork // navigation state used by the repository header. func (h *Handler) repoNavData(c *fiber.Ctx) map[string]any { owner, name := c.Params("owner"), strings.TrimSuffix(c.Params("repo"), ".git") if owner == "" || name == "" { return nil } ctx := c.UserContext() nr, err := h.repoSvc.GetByOwnerAndName(ctx, owner, name) if err != nil || nr == nil { return nil } u := middleware.CurrentUser(c) data := map[string]any{"NavRepo": nr} data["IsRepoOwner"] = u != nil && u.ID == nr.OwnerID if u != nil && u.ID != nr.OwnerID { if fork, ferr := h.repoSvc.FindForkByOwner(ctx, nr.ID, u.ID); ferr == nil && fork != nil { data["ViewerFork"] = fork } } return data } func (h *Handler) setFlash(c *fiber.Ctx, msg string) { c.Cookie(&fiber.Cookie{ Name: "polar_flash", Value: url.QueryEscape(msg), Path: "/", MaxAge: 30, HTTPOnly: true, SameSite: "Lax", }) } // langOf resolves the active UI language for the current request. func (h *Handler) langOf(c *fiber.Ctx) i18n.Lang { lang, _ := c.Locals("lang").(i18n.Lang) if lang == "" { lang = i18n.Normalize(h.cfg.Landing.DefaultLang) } return lang } // tr translates key for the current request and formats it with args. func (h *Handler) tr(c *fiber.Ctx, key string, args ...any) string { return fmt.Sprintf(i18n.T(h.langOf(c), key), args...) } // setFlashT stores a localized flash message. func (h *Handler) setFlashT(c *fiber.Ctx, key string, args ...any) { h.setFlash(c, h.tr(c, key, args...)) } // renderErrorT renders an error page with localized title and message. func (h *Handler) renderErrorT(c *fiber.Ctx, status int, titleKey, msgKey string, args ...any) error { return h.renderError(c, status, i18n.T(h.langOf(c), titleKey), h.tr(c, msgKey, args...)) } func (h *Handler) isRegistrationAllowed(c *fiber.Ctx) bool { if cnt, err := h.userSvc.Count(c.UserContext()); err == nil && cnt == 0 { return true } return h.db.IsRegistrationEnabled(c.UserContext(), h.cfg.Auth.RegistrationEnabled) } func (h *Handler) render(c *fiber.Ctx, status int, page string, data map[string]any) error { var buf bytes.Buffer if err := h.views.Render(&buf, page, data); err != nil { h.logger.Error("render template failed", "page", page, "err", err) return c.Status(fiber.StatusInternalServerError).SendString("Template render error") } c.Status(status) c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) return c.Send(buf.Bytes()) } func (h *Handler) renderError(c *fiber.Ctx, status int, title, message string) error { data := h.baseData(c, title) data["StatusCode"] = status data["Title"] = title data["Message"] = message return h.render(c, status, "error", data) } func (h *Handler) Home(c *fiber.Ctx) error { if !h.db.IsLandingEnabled(c.UserContext(), h.cfg.Landing.Enabled) { if u := middleware.CurrentUser(c); u != nil { if h.db.IsExploreEnabled(c.UserContext()) { return c.Redirect("/explore", fiber.StatusSeeOther) } return c.Redirect("/"+u.Username, fiber.StatusSeeOther) } return c.Redirect("/login", fiber.StatusSeeOther) } data := h.baseData(c, "") if h.db.IsExploreEnabled(c.UserContext()) && (middleware.CurrentUser(c) != nil || h.db.IsAnonymousReadEnabled(c.UserContext())) { repos, _ := h.repoSvc.ListPublic(c.UserContext(), 10) data["Repos"] = repos } return h.render(c, fiber.StatusOK, "landing", data) } func (h *Handler) Explore(c *fiber.Ctx) error { if !h.db.IsExploreEnabled(c.UserContext()) { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.explore_disabled") } if middleware.CurrentUser(c) == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) { return c.Redirect("/login", fiber.StatusSeeOther) } lang, _ := c.Locals("lang").(i18n.Lang) data := h.baseData(c, i18n.T(lang, "nav.explore")) repos, _ := h.repoSvc.ListPublic(c.UserContext(), 100) data["PageHeading"] = i18n.T(lang, "nav.explore") data["Repos"] = repos return h.render(c, fiber.StatusOK, "repos", data) } // LegacyReposRedirect keeps old /repos links working: the repository // catalog is served by Explore. func (h *Handler) LegacyReposRedirect(c *fiber.Ctx) error { return c.Redirect("/explore", fiber.StatusSeeOther) } func (h *Handler) cloneURL(c *fiber.Ctx, ownerUsername, repoName string) string { base := strings.TrimRight(h.cfg.Server.BaseURL, "/") if base == "" || strings.Contains(base, "localhost:3000") { if reqBase := c.BaseURL(); reqBase != "" { base = strings.TrimRight(reqBase, "/") } } return base + "/" + ownerUsername + "/" + repoName + ".git" }