handlers.go
·
362 строки
·
12.8 KB
| 1 | package handlers |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "polar/internal/auth" |
| 13 | "polar/internal/config" |
| 14 | "polar/internal/db" |
| 15 | "polar/internal/i18n" |
| 16 | "polar/internal/repo" |
| 17 | "polar/internal/user" |
| 18 | "polar/internal/web/middleware" |
| 19 | "polar/internal/web/static" |
| 20 | "polar/internal/web/views" |
| 21 | |
| 22 | "github.com/gofiber/fiber/v2" |
| 23 | "github.com/gofiber/fiber/v2/middleware/filesystem" |
| 24 | ) |
| 25 | |
| 26 | type Handler struct { |
| 27 | cfg config.Config |
| 28 | db *db.DB |
| 29 | authSvc *auth.Service |
| 30 | userSvc *user.Service |
| 31 | repoSvc *repo.Service |
| 32 | views *views.Engine |
| 33 | logger *slog.Logger |
| 34 | } |
| 35 | |
| 36 | func New( |
| 37 | cfg config.Config, |
| 38 | database *db.DB, |
| 39 | authSvc *auth.Service, |
| 40 | userSvc *user.Service, |
| 41 | repoSvc *repo.Service, |
| 42 | viewsEngine *views.Engine, |
| 43 | logger *slog.Logger, |
| 44 | ) *Handler { |
| 45 | return &Handler{ |
| 46 | cfg: cfg, |
| 47 | db: database, |
| 48 | authSvc: authSvc, |
| 49 | userSvc: userSvc, |
| 50 | repoSvc: repoSvc, |
| 51 | views: viewsEngine, |
| 52 | logger: logger, |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func (h *Handler) RegisterRoutes(app *fiber.App) { |
| 57 | app.Use(middleware.RecoverAndLog(h.logger)) |
| 58 | app.Use(middleware.SecurityHeaders()) |
| 59 | |
| 60 | app.Get("/healthz", h.Healthz) |
| 61 | |
| 62 | app.Use("/static", filesystem.New(filesystem.Config{ |
| 63 | Root: http.FS(static.FS), |
| 64 | Browse: false, |
| 65 | MaxAge: 0, |
| 66 | })) |
| 67 | |
| 68 | app.Use(middleware.LocaleAndTheme(h.cfg.Landing.DefaultLang)) |
| 69 | app.Use(middleware.SessionAuth(h.authSvc, h.userSvc)) |
| 70 | app.Use(middleware.CSRFProtection()) |
| 71 | |
| 72 | authLimiter := middleware.NewRateLimiter(20, time.Minute) |
| 73 | |
| 74 | // Public & Root routes |
| 75 | app.Get("/", h.Home) |
| 76 | app.Get("/explore", h.Explore) |
| 77 | |
| 78 | // Auth routes |
| 79 | app.Get("/login", h.LoginPage) |
| 80 | app.Post("/login", authLimiter.Handler(), h.LoginSubmit) |
| 81 | app.Get("/register", h.RegisterPage) |
| 82 | app.Post("/register", authLimiter.Handler(), h.RegisterSubmit) |
| 83 | app.Post("/logout", h.LogoutSubmit) |
| 84 | app.Get("/login/device", h.DeviceVerifyPage) |
| 85 | app.Post("/login/device", h.DeviceVerifySubmit) |
| 86 | app.Post("/api/v1/device/code", h.DeviceCodeAPI) |
| 87 | app.Post("/api/v1/device/token", h.DeviceTokenAPI) |
| 88 | |
| 89 | // Authenticated user routes |
| 90 | app.Get("/repos", h.LegacyReposRedirect) |
| 91 | app.Get("/new", middleware.RequireAuth(), h.NewRepoPage) |
| 92 | app.Post("/new", middleware.RequireAuth(), h.NewRepoSubmit) |
| 93 | app.Get("/settings", middleware.RequireAuth(), h.SettingsPage) |
| 94 | app.Post("/settings/profile", middleware.RequireAuth(), h.UpdateProfileSubmit) |
| 95 | app.Post("/settings/password", middleware.RequireAuth(), h.UpdatePasswordSubmit) |
| 96 | app.Post("/settings/tokens", middleware.RequireAuth(), h.CreateTokenSubmit) |
| 97 | app.Post("/settings/tokens/:id/revoke", middleware.RequireAuth(), h.RevokeTokenSubmit) |
| 98 | |
| 99 | // Admin routes |
| 100 | app.Get("/admin", middleware.RequireAdmin(), h.AdminPage) |
| 101 | app.Post("/admin/settings", middleware.RequireAdmin(), h.AdminSettingsSubmit) |
| 102 | |
| 103 | // Git Smart HTTP endpoints (supports both /:owner/:repo.git/* and /:owner/:repo/*) |
| 104 | app.Get("/:owner/:repo/info/refs", h.GitInfoRefs) |
| 105 | app.Post("/:owner/:repo/git-upload-pack", h.GitUploadPack) |
| 106 | app.Post("/:owner/:repo/git-receive-pack", h.GitReceivePack) |
| 107 | |
| 108 | // Repository Web UI routes |
| 109 | app.Get("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsPage) |
| 110 | app.Post("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsSubmit) |
| 111 | app.Post("/:owner/:repo/settings/rename", middleware.RequireAuth(), h.RepoRenameSubmit) |
| 112 | app.Post("/:owner/:repo/settings/delete", middleware.RequireAuth(), h.RepoDeleteSubmit) |
| 113 | app.Post("/:owner/:repo/settings/collaborators/add", middleware.RequireAuth(), h.RepoCollaboratorAddSubmit) |
| 114 | app.Post("/:owner/:repo/settings/collaborators/remove", middleware.RequireAuth(), h.RepoCollaboratorRemoveSubmit) |
| 115 | |
| 116 | app.Get("/:owner/:repo/branches", h.RepoBranchesPage) |
| 117 | app.Post("/:owner/:repo/branches", middleware.RequireAuth(), h.RepoCreateBranchSubmit) |
| 118 | app.Post("/:owner/:repo/branches/delete", middleware.RequireAuth(), h.RepoDeleteBranchSubmit) |
| 119 | app.Post("/:owner/:repo/tags", middleware.RequireAuth(), h.RepoCreateTagSubmit) |
| 120 | app.Post("/:owner/:repo/tags/delete", middleware.RequireAuth(), h.RepoDeleteTagSubmit) |
| 121 | app.Get("/:owner/:repo/archive/*", h.RepoArchiveZip) |
| 122 | app.Get("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoNewFilePage) |
| 123 | app.Post("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoCreateFileSubmit) |
| 124 | app.Get("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoNewFolderPage) |
| 125 | app.Post("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoCreateFolderSubmit) |
| 126 | app.Get("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadPage) |
| 127 | app.Post("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadSubmit) |
| 128 | |
| 129 | app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit) |
| 130 | app.Get("/:owner/:repo/forks", h.RepoForksPage) |
| 131 | |
| 132 | app.Get("/:owner/:repo/issues", h.RepoIssuesPage) |
| 133 | app.Get("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssuePage) |
| 134 | app.Post("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssueSubmit) |
| 135 | app.Get("/:owner/:repo/issues/:number", h.RepoIssueDetailPage) |
| 136 | app.Post("/:owner/:repo/issues/:number/comments", middleware.RequireAuth(), h.RepoIssueCommentSubmit) |
| 137 | app.Post("/:owner/:repo/issues/:number/state", middleware.RequireAuth(), h.RepoIssueStateSubmit) |
| 138 | |
| 139 | // Pull Requests |
| 140 | app.Get("/:owner/:repo/pulls", h.RepoPullsPage) |
| 141 | app.Get("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullPage) |
| 142 | app.Post("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullSubmit) |
| 143 | app.Get("/:owner/:repo/pulls/:number", h.RepoPullDetailPage) |
| 144 | app.Post("/:owner/:repo/pulls/:number/comments", middleware.RequireAuth(), h.RepoPullCommentSubmit) |
| 145 | app.Post("/:owner/:repo/pulls/:number/state", middleware.RequireAuth(), h.RepoPullStateSubmit) |
| 146 | app.Post("/:owner/:repo/pulls/:number/merge", middleware.RequireAuth(), h.RepoPullMergeSubmit) |
| 147 | app.Post("/:owner/:repo/pulls/:number/review", middleware.RequireAuth(), h.RepoPullReviewSubmit) |
| 148 | |
| 149 | // Releases |
| 150 | app.Get("/:owner/:repo/releases", h.RepoReleasesPage) |
| 151 | app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage) |
| 152 | app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit) |
| 153 | app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit) |
| 154 | app.Get("/:owner/:repo/releases/:id/assets/:assetID/download", h.RepoReleaseAssetDownload) |
| 155 | app.Post("/:owner/:repo/releases/:id/assets/:assetID/delete", middleware.RequireAuth(), h.RepoDeleteReleaseAssetSubmit) |
| 156 | |
| 157 | // Activity |
| 158 | app.Get("/:owner/:repo/activity", h.RepoActivityPage) |
| 159 | |
| 160 | app.Get("/:owner/:repo/commits/:ref", h.RepoCommitsPage) |
| 161 | app.Get("/:owner/:repo/commit/:hash", h.RepoCommitDetailPage) |
| 162 | app.Get("/:owner/:repo/tree/:ref/*", h.RepoTreePage) |
| 163 | app.Get("/:owner/:repo/tree/:ref", h.RepoTreePage) |
| 164 | app.Get("/:owner/:repo/blob/:ref/*", h.RepoBlobPage) |
| 165 | app.Get("/:owner/:repo/raw/:ref/*", h.RepoRawBlob) |
| 166 | app.Get("/:owner/:repo", h.RepoHomePage) |
| 167 | |
| 168 | // User profile page |
| 169 | app.Get("/:username", h.UserProfilePage) |
| 170 | } |
| 171 | |
| 172 | func (h *Handler) Healthz(c *fiber.Ctx) error { |
| 173 | if err := h.db.PingContext(c.UserContext()); err != nil { |
| 174 | return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ |
| 175 | "status": "error", |
| 176 | "db": "unreachable", |
| 177 | }) |
| 178 | } |
| 179 | return c.Status(fiber.StatusOK).JSON(fiber.Map{ |
| 180 | "status": "ok", |
| 181 | "service": "polar", |
| 182 | }) |
| 183 | } |
| 184 | |
| 185 | func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any { |
| 186 | u := middleware.CurrentUser(c) |
| 187 | lang := h.langOf(c) |
| 188 | theme, _ := c.Locals("theme").(string) |
| 189 | if theme == "" { |
| 190 | theme = "system" |
| 191 | } |
| 192 | csrf, _ := c.Locals("csrf_token").(string) |
| 193 | |
| 194 | flash := c.Cookies("polar_flash") |
| 195 | if flash != "" { |
| 196 | if decoded, err := url.QueryUnescape(flash); err == nil { |
| 197 | flash = decoded |
| 198 | } |
| 199 | c.ClearCookie("polar_flash") |
| 200 | } |
| 201 | |
| 202 | ctx := c.UserContext() |
| 203 | anonRead := h.db.IsAnonymousReadEnabled(ctx) |
| 204 | var paletteRepos []repo.Repository |
| 205 | if u != nil || anonRead { |
| 206 | paletteRepos, _ = h.repoSvc.ListAccessible(ctx, u, 25) |
| 207 | } |
| 208 | |
| 209 | data := map[string]any{ |
| 210 | "Title": title, |
| 211 | "Lang": lang, |
| 212 | "Theme": theme, |
| 213 | "CSRFToken": csrf, |
| 214 | "CurrentUser": u, |
| 215 | "Flash": flash, |
| 216 | "RegistrationEnabled": h.isRegistrationAllowed(c), |
| 217 | "ExploreEnabled": h.db.IsExploreEnabled(ctx), |
| 218 | "LandingEnabled": h.db.IsLandingEnabled(ctx, h.cfg.Landing.Enabled), |
| 219 | "AnonymousReadEnabled": anonRead, |
| 220 | "IssuesEnabled": h.db.IsIssuesEnabled(ctx), |
| 221 | "PaletteRepos": paletteRepos, |
| 222 | } |
| 223 | if extra := h.repoNavData(c); extra != nil { |
| 224 | for k, v := range extra { |
| 225 | data[k] = v |
| 226 | } |
| 227 | } |
| 228 | return data |
| 229 | } |
| 230 | |
| 231 | // repoNavData enriches template data for /:owner/:repo routes with fork |
| 232 | // navigation state used by the repository header. |
| 233 | func (h *Handler) repoNavData(c *fiber.Ctx) map[string]any { |
| 234 | owner, name := c.Params("owner"), strings.TrimSuffix(c.Params("repo"), ".git") |
| 235 | if owner == "" || name == "" { |
| 236 | return nil |
| 237 | } |
| 238 | ctx := c.UserContext() |
| 239 | nr, err := h.repoSvc.GetByOwnerAndName(ctx, owner, name) |
| 240 | if err != nil || nr == nil { |
| 241 | return nil |
| 242 | } |
| 243 | u := middleware.CurrentUser(c) |
| 244 | data := map[string]any{"NavRepo": nr} |
| 245 | data["IsRepoOwner"] = u != nil && u.ID == nr.OwnerID |
| 246 | if u != nil && u.ID != nr.OwnerID { |
| 247 | if fork, ferr := h.repoSvc.FindForkByOwner(ctx, nr.ID, u.ID); ferr == nil && fork != nil { |
| 248 | data["ViewerFork"] = fork |
| 249 | } |
| 250 | } |
| 251 | return data |
| 252 | } |
| 253 | |
| 254 | func (h *Handler) setFlash(c *fiber.Ctx, msg string) { |
| 255 | c.Cookie(&fiber.Cookie{ |
| 256 | Name: "polar_flash", |
| 257 | Value: url.QueryEscape(msg), |
| 258 | Path: "/", |
| 259 | MaxAge: 30, |
| 260 | HTTPOnly: true, |
| 261 | SameSite: "Lax", |
| 262 | }) |
| 263 | } |
| 264 | |
| 265 | // langOf resolves the active UI language for the current request. |
| 266 | func (h *Handler) langOf(c *fiber.Ctx) i18n.Lang { |
| 267 | lang, _ := c.Locals("lang").(i18n.Lang) |
| 268 | if lang == "" { |
| 269 | lang = i18n.Normalize(h.cfg.Landing.DefaultLang) |
| 270 | } |
| 271 | return lang |
| 272 | } |
| 273 | |
| 274 | // tr translates key for the current request and formats it with args. |
| 275 | func (h *Handler) tr(c *fiber.Ctx, key string, args ...any) string { |
| 276 | return fmt.Sprintf(i18n.T(h.langOf(c), key), args...) |
| 277 | } |
| 278 | |
| 279 | // setFlashT stores a localized flash message. |
| 280 | func (h *Handler) setFlashT(c *fiber.Ctx, key string, args ...any) { |
| 281 | h.setFlash(c, h.tr(c, key, args...)) |
| 282 | } |
| 283 | |
| 284 | // renderErrorT renders an error page with localized title and message. |
| 285 | func (h *Handler) renderErrorT(c *fiber.Ctx, status int, titleKey, msgKey string, args ...any) error { |
| 286 | return h.renderError(c, status, i18n.T(h.langOf(c), titleKey), h.tr(c, msgKey, args...)) |
| 287 | } |
| 288 | |
| 289 | func (h *Handler) isRegistrationAllowed(c *fiber.Ctx) bool { |
| 290 | if cnt, err := h.userSvc.Count(c.UserContext()); err == nil && cnt == 0 { |
| 291 | return true |
| 292 | } |
| 293 | return h.db.IsRegistrationEnabled(c.UserContext(), h.cfg.Auth.RegistrationEnabled) |
| 294 | } |
| 295 | |
| 296 | func (h *Handler) render(c *fiber.Ctx, status int, page string, data map[string]any) error { |
| 297 | var buf bytes.Buffer |
| 298 | if err := h.views.Render(&buf, page, data); err != nil { |
| 299 | h.logger.Error("render template failed", "page", page, "err", err) |
| 300 | return c.Status(fiber.StatusInternalServerError).SendString("Template render error") |
| 301 | } |
| 302 | c.Status(status) |
| 303 | c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) |
| 304 | return c.Send(buf.Bytes()) |
| 305 | } |
| 306 | |
| 307 | func (h *Handler) renderError(c *fiber.Ctx, status int, title, message string) error { |
| 308 | data := h.baseData(c, title) |
| 309 | data["StatusCode"] = status |
| 310 | data["Title"] = title |
| 311 | data["Message"] = message |
| 312 | return h.render(c, status, "error", data) |
| 313 | } |
| 314 | |
| 315 | func (h *Handler) Home(c *fiber.Ctx) error { |
| 316 | if !h.db.IsLandingEnabled(c.UserContext(), h.cfg.Landing.Enabled) { |
| 317 | if u := middleware.CurrentUser(c); u != nil { |
| 318 | if h.db.IsExploreEnabled(c.UserContext()) { |
| 319 | return c.Redirect("/explore", fiber.StatusSeeOther) |
| 320 | } |
| 321 | return c.Redirect("/"+u.Username, fiber.StatusSeeOther) |
| 322 | } |
| 323 | return c.Redirect("/login", fiber.StatusSeeOther) |
| 324 | } |
| 325 | data := h.baseData(c, "") |
| 326 | if h.db.IsExploreEnabled(c.UserContext()) && (middleware.CurrentUser(c) != nil || h.db.IsAnonymousReadEnabled(c.UserContext())) { |
| 327 | repos, _ := h.repoSvc.ListPublic(c.UserContext(), 10) |
| 328 | data["Repos"] = repos |
| 329 | } |
| 330 | return h.render(c, fiber.StatusOK, "landing", data) |
| 331 | } |
| 332 | |
| 333 | func (h *Handler) Explore(c *fiber.Ctx) error { |
| 334 | if !h.db.IsExploreEnabled(c.UserContext()) { |
| 335 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.explore_disabled") |
| 336 | } |
| 337 | if middleware.CurrentUser(c) == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) { |
| 338 | return c.Redirect("/login", fiber.StatusSeeOther) |
| 339 | } |
| 340 | lang, _ := c.Locals("lang").(i18n.Lang) |
| 341 | data := h.baseData(c, i18n.T(lang, "nav.explore")) |
| 342 | repos, _ := h.repoSvc.ListPublic(c.UserContext(), 100) |
| 343 | data["PageHeading"] = i18n.T(lang, "nav.explore") |
| 344 | data["Repos"] = repos |
| 345 | return h.render(c, fiber.StatusOK, "repos", data) |
| 346 | } |
| 347 | |
| 348 | // LegacyReposRedirect keeps old /repos links working: the repository |
| 349 | // catalog is served by Explore. |
| 350 | func (h *Handler) LegacyReposRedirect(c *fiber.Ctx) error { |
| 351 | return c.Redirect("/explore", fiber.StatusSeeOther) |
| 352 | } |
| 353 | |
| 354 | func (h *Handler) cloneURL(c *fiber.Ctx, ownerUsername, repoName string) string { |
| 355 | base := strings.TrimRight(h.cfg.Server.BaseURL, "/") |
| 356 | if base == "" || strings.Contains(base, "localhost:3000") { |
| 357 | if reqBase := c.BaseURL(); reqBase != "" { |
| 358 | base = strings.TrimRight(reqBase, "/") |
| 359 | } |
| 360 | } |
| 361 | return base + "/" + ownerUsername + "/" + repoName + ".git" |
| 362 | } |
| 363 |