rin
Публичный Open-source lightweight git hosting.
handlers.go · 374 строки · 13.7 KB
Raw
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 app.Get("/agents", h.AgentsPage)
78
79 // Auth routes
80 app.Get("/login", h.LoginPage)
81 app.Post("/login", authLimiter.Handler(), h.LoginSubmit)
82 app.Get("/register", h.RegisterPage)
83 app.Post("/register", authLimiter.Handler(), h.RegisterSubmit)
84 app.Post("/logout", h.LogoutSubmit)
85 app.Get("/login/device", h.DeviceVerifyPage)
86 app.Post("/login/device", h.DeviceVerifySubmit)
87 app.Post("/api/v1/device/code", h.DeviceCodeAPI)
88 app.Post("/api/v1/device/token", h.DeviceTokenAPI)
89
90 // Authenticated user routes
91 app.Get("/repos", h.LegacyReposRedirect)
92 app.Get("/new", middleware.RequireAuth(), h.NewRepoPage)
93 app.Post("/new", middleware.RequireAuth(), h.NewRepoSubmit)
94 app.Get("/settings", middleware.RequireAuth(), h.SettingsPage)
95 app.Post("/settings/profile", middleware.RequireAuth(), h.UpdateProfileSubmit)
96 app.Post("/settings/delete", middleware.RequireAuth(), h.DeleteAccountSubmit)
97 app.Post("/settings/password", middleware.RequireAuth(), h.UpdatePasswordSubmit)
98 app.Post("/settings/tokens", middleware.RequireAuth(), h.CreateTokenSubmit)
99 app.Post("/settings/tokens/:id/revoke", middleware.RequireAuth(), h.RevokeTokenSubmit)
100
101 // Admin routes
102 app.Get("/admin", middleware.RequireAdmin(), h.AdminPage)
103 app.Post("/admin/settings", middleware.RequireAdmin(), h.AdminSettingsSubmit)
104 app.Post("/admin/users/:id/approve", middleware.RequireAdmin(), h.AdminApproveUserSubmit)
105 app.Post("/admin/users/:id/admin", middleware.RequireAdmin(), h.AdminToggleUserAdminSubmit)
106 app.Post("/admin/users/:id/delete", middleware.RequireAdmin(), h.AdminDeleteUserSubmit)
107 app.Post("/admin/repos/:id/delete", middleware.RequireAdmin(), h.AdminDeleteRepoSubmit)
108
109 // Git Smart HTTP endpoints (supports both /:owner/:repo.git/* and /:owner/:repo/*)
110 app.Get("/:owner/:repo/info/refs", h.GitInfoRefs)
111 app.Post("/:owner/:repo/git-upload-pack", h.GitUploadPack)
112 app.Post("/:owner/:repo/git-receive-pack", h.GitReceivePack)
113
114 // Repository Web UI routes
115 app.Get("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsPage)
116 app.Post("/:owner/:repo/settings", middleware.RequireAuth(), h.RepoSettingsSubmit)
117 app.Post("/:owner/:repo/settings/rename", middleware.RequireAuth(), h.RepoRenameSubmit)
118 app.Post("/:owner/:repo/settings/delete", middleware.RequireAuth(), h.RepoDeleteSubmit)
119 app.Post("/:owner/:repo/settings/collaborators/add", middleware.RequireAuth(), h.RepoCollaboratorAddSubmit)
120 app.Post("/:owner/:repo/settings/collaborators/remove", middleware.RequireAuth(), h.RepoCollaboratorRemoveSubmit)
121
122 app.Get("/:owner/:repo/branches", h.RepoBranchesPage)
123 app.Post("/:owner/:repo/branches", middleware.RequireAuth(), h.RepoCreateBranchSubmit)
124 app.Post("/:owner/:repo/branches/delete", middleware.RequireAuth(), h.RepoDeleteBranchSubmit)
125 app.Post("/:owner/:repo/tags", middleware.RequireAuth(), h.RepoCreateTagSubmit)
126 app.Post("/:owner/:repo/tags/delete", middleware.RequireAuth(), h.RepoDeleteTagSubmit)
127 app.Get("/:owner/:repo/archive/*", h.RepoArchiveZip)
128 app.Get("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoNewFilePage)
129 app.Post("/:owner/:repo/new-file", middleware.RequireAuth(), h.RepoCreateFileSubmit)
130 app.Get("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoNewFolderPage)
131 app.Post("/:owner/:repo/new-folder", middleware.RequireAuth(), h.RepoCreateFolderSubmit)
132 app.Get("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadPage)
133 app.Post("/:owner/:repo/upload", middleware.RequireAuth(), h.RepoUploadSubmit)
134
135 app.Post("/:owner/:repo/fork", middleware.RequireAuth(), h.RepoForkSubmit)
136 app.Get("/:owner/:repo/forks", h.RepoForksPage)
137
138 app.Get("/:owner/:repo/issues", h.RepoIssuesPage)
139 app.Get("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssuePage)
140 app.Post("/:owner/:repo/issues/new", middleware.RequireAuth(), h.RepoNewIssueSubmit)
141 app.Get("/:owner/:repo/issues/:number", h.RepoIssueDetailPage)
142 app.Post("/:owner/:repo/issues/:number/comments", middleware.RequireAuth(), h.RepoIssueCommentSubmit)
143 app.Post("/:owner/:repo/issues/:number/state", middleware.RequireAuth(), h.RepoIssueStateSubmit)
144
145 // Pull Requests
146 app.Get("/:owner/:repo/pulls", h.RepoPullsPage)
147 app.Get("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullPage)
148 app.Post("/:owner/:repo/pulls/new", middleware.RequireAuth(), h.RepoNewPullSubmit)
149 app.Get("/:owner/:repo/pulls/:number", h.RepoPullDetailPage)
150 app.Post("/:owner/:repo/pulls/:number/comments", middleware.RequireAuth(), h.RepoPullCommentSubmit)
151 app.Post("/:owner/:repo/pulls/:number/state", middleware.RequireAuth(), h.RepoPullStateSubmit)
152 app.Post("/:owner/:repo/pulls/:number/merge", middleware.RequireAuth(), h.RepoPullMergeSubmit)
153 app.Post("/:owner/:repo/pulls/:number/review", middleware.RequireAuth(), h.RepoPullReviewSubmit)
154
155 // Releases
156 app.Get("/:owner/:repo/releases", h.RepoReleasesPage)
157 app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage)
158 app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit)
159 app.Get("/:owner/:repo/releases/:id/edit", middleware.RequireAuth(), h.RepoEditReleasePage)
160 app.Post("/:owner/:repo/releases/:id/edit", middleware.RequireAuth(), h.RepoEditReleaseSubmit)
161 app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit)
162 app.Get("/:owner/:repo/releases/:id/assets/:assetID/download", h.RepoReleaseAssetDownload)
163 app.Post("/:owner/:repo/releases/:id/assets/:assetID/delete", middleware.RequireAuth(), h.RepoDeleteReleaseAssetSubmit)
164
165 // Activity
166 app.Get("/:owner/:repo/activity", h.RepoActivityPage)
167
168 app.Get("/:owner/:repo/commits/:ref", h.RepoCommitsPage)
169 app.Get("/:owner/:repo/commit/:hash", h.RepoCommitDetailPage)
170 app.Get("/:owner/:repo/tree/:ref/*", h.RepoTreePage)
171 app.Get("/:owner/:repo/tree/:ref", h.RepoTreePage)
172 app.Get("/:owner/:repo/blob/:ref/*", h.RepoBlobPage)
173 app.Get("/:owner/:repo/raw/:ref/*", h.RepoRawBlob)
174 app.Get("/:owner/:repo", h.RepoHomePage)
175
176 // User profile page
177 app.Get("/:username", h.UserProfilePage)
178 }
179
180 func (h *Handler) Healthz(c *fiber.Ctx) error {
181 if err := h.db.PingContext(c.UserContext()); err != nil {
182 return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
183 "status": "error",
184 "db": "unreachable",
185 })
186 }
187 return c.Status(fiber.StatusOK).JSON(fiber.Map{
188 "status": "ok",
189 "service": "polar",
190 })
191 }
192
193 func (h *Handler) baseData(c *fiber.Ctx, title string) map[string]any {
194 u := middleware.CurrentUser(c)
195 lang := h.langOf(c)
196 theme, _ := c.Locals("theme").(string)
197 if theme == "" {
198 theme = "system"
199 }
200 csrf, _ := c.Locals("csrf_token").(string)
201
202 flash := c.Cookies("polar_flash")
203 if flash != "" {
204 if decoded, err := url.QueryUnescape(flash); err == nil {
205 flash = decoded
206 }
207 c.ClearCookie("polar_flash")
208 }
209
210 ctx := c.UserContext()
211 anonRead := h.db.IsAnonymousReadEnabled(ctx)
212 var paletteRepos []repo.Repository
213 if u != nil || anonRead {
214 paletteRepos, _ = h.repoSvc.ListAccessible(ctx, u, 25)
215 }
216
217 registrationMode := h.db.GetRegistrationMode(ctx, h.cfg.Auth.RegistrationEnabled)
218 registrationOpen := h.isRegistrationAllowed(c)
219 data := map[string]any{
220 "Title": title,
221 "Lang": lang,
222 "Theme": theme,
223 "CSRFToken": csrf,
224 "CurrentUser": u,
225 "Flash": flash,
226 "RegistrationMode": registrationMode,
227 "RegistrationOpen": registrationOpen,
228 "RegistrationEnabled": registrationOpen,
229 "ExploreEnabled": h.db.IsExploreEnabled(ctx),
230 "LandingEnabled": h.db.IsLandingEnabled(ctx, h.cfg.Landing.Enabled),
231 "AnonymousReadEnabled": anonRead,
232 "IssuesEnabled": h.db.IsIssuesEnabled(ctx),
233 "PaletteRepos": paletteRepos,
234 }
235 if extra := h.repoNavData(c); extra != nil {
236 for k, v := range extra {
237 data[k] = v
238 }
239 }
240 return data
241 }
242
243 // repoNavData enriches template data for /:owner/:repo routes with fork
244 // navigation state used by the repository header.
245 func (h *Handler) repoNavData(c *fiber.Ctx) map[string]any {
246 owner, name := c.Params("owner"), strings.TrimSuffix(c.Params("repo"), ".git")
247 if owner == "" || name == "" {
248 return nil
249 }
250 ctx := c.UserContext()
251 nr, err := h.repoSvc.GetByOwnerAndName(ctx, owner, name)
252 if err != nil || nr == nil {
253 return nil
254 }
255 u := middleware.CurrentUser(c)
256 data := map[string]any{"NavRepo": nr}
257 data["IsRepoOwner"] = u != nil && u.ID == nr.OwnerID
258 if u != nil && u.ID != nr.OwnerID {
259 if fork, ferr := h.repoSvc.FindForkByOwner(ctx, nr.ID, u.ID); ferr == nil && fork != nil {
260 data["ViewerFork"] = fork
261 }
262 }
263 return data
264 }
265
266 func (h *Handler) setFlash(c *fiber.Ctx, msg string) {
267 c.Cookie(&fiber.Cookie{
268 Name: "polar_flash",
269 Value: url.QueryEscape(msg),
270 Path: "/",
271 MaxAge: 30,
272 HTTPOnly: true,
273 SameSite: "Lax",
274 })
275 }
276
277 // langOf resolves the active UI language for the current request.
278 func (h *Handler) langOf(c *fiber.Ctx) i18n.Lang {
279 lang, _ := c.Locals("lang").(i18n.Lang)
280 if lang == "" {
281 lang = i18n.Normalize(h.cfg.Landing.DefaultLang)
282 }
283 return lang
284 }
285
286 // tr translates key for the current request and formats it with args.
287 func (h *Handler) tr(c *fiber.Ctx, key string, args ...any) string {
288 return fmt.Sprintf(i18n.T(h.langOf(c), key), args...)
289 }
290
291 // setFlashT stores a localized flash message.
292 func (h *Handler) setFlashT(c *fiber.Ctx, key string, args ...any) {
293 h.setFlash(c, h.tr(c, key, args...))
294 }
295
296 // renderErrorT renders an error page with localized title and message.
297 func (h *Handler) renderErrorT(c *fiber.Ctx, status int, titleKey, msgKey string, args ...any) error {
298 return h.renderError(c, status, i18n.T(h.langOf(c), titleKey), h.tr(c, msgKey, args...))
299 }
300
301 func (h *Handler) isRegistrationAllowed(c *fiber.Ctx) bool {
302 if cnt, err := h.userSvc.Count(c.UserContext()); err == nil && cnt == 0 {
303 return true
304 }
305 return h.db.GetRegistrationMode(c.UserContext(), h.cfg.Auth.RegistrationEnabled) != db.RegistrationModeClosed
306 }
307
308 func (h *Handler) render(c *fiber.Ctx, status int, page string, data map[string]any) error {
309 var buf bytes.Buffer
310 if err := h.views.Render(&buf, page, data); err != nil {
311 h.logger.Error("render template failed", "page", page, "err", err)
312 return c.Status(fiber.StatusInternalServerError).SendString("Template render error")
313 }
314 c.Status(status)
315 c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
316 return c.Send(buf.Bytes())
317 }
318
319 func (h *Handler) renderError(c *fiber.Ctx, status int, title, message string) error {
320 data := h.baseData(c, title)
321 data["StatusCode"] = status
322 data["Title"] = title
323 data["Message"] = message
324 return h.render(c, status, "error", data)
325 }
326
327 func (h *Handler) Home(c *fiber.Ctx) error {
328 if !h.db.IsLandingEnabled(c.UserContext(), h.cfg.Landing.Enabled) {
329 if u := middleware.CurrentUser(c); u != nil {
330 if h.db.IsExploreEnabled(c.UserContext()) {
331 return c.Redirect("/explore", fiber.StatusSeeOther)
332 }
333 return c.Redirect("/"+u.Username, fiber.StatusSeeOther)
334 }
335 return c.Redirect("/login", fiber.StatusSeeOther)
336 }
337 data := h.baseData(c, "")
338 if h.db.IsExploreEnabled(c.UserContext()) && (middleware.CurrentUser(c) != nil || h.db.IsAnonymousReadEnabled(c.UserContext())) {
339 repos, _ := h.repoSvc.ListPublic(c.UserContext(), 10)
340 data["Repos"] = repos
341 }
342 return h.render(c, fiber.StatusOK, "landing", data)
343 }
344
345 func (h *Handler) Explore(c *fiber.Ctx) error {
346 if !h.db.IsExploreEnabled(c.UserContext()) {
347 return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.explore_disabled")
348 }
349 if middleware.CurrentUser(c) == nil && !h.db.IsAnonymousReadEnabled(c.UserContext()) {
350 return c.Redirect("/login", fiber.StatusSeeOther)
351 }
352 lang, _ := c.Locals("lang").(i18n.Lang)
353 data := h.baseData(c, i18n.T(lang, "nav.explore"))
354 repos, _ := h.repoSvc.ListPublic(c.UserContext(), 100)
355 data["PageHeading"] = i18n.T(lang, "nav.explore")
356 data["Repos"] = repos
357 return h.render(c, fiber.StatusOK, "repos", data)
358 }
359
360 // LegacyReposRedirect keeps old /repos links working: the repository
361 // catalog is served by Explore.
362 func (h *Handler) LegacyReposRedirect(c *fiber.Ctx) error {
363 return c.Redirect("/explore", fiber.StatusSeeOther)
364 }
365
366 func (h *Handler) cloneURL(c *fiber.Ctx, ownerUsername, repoName string) string {
367 base := strings.TrimRight(h.cfg.Server.BaseURL, "/")
368 if base == "" || strings.Contains(base, "localhost:3000") {
369 if reqBase := c.BaseURL(); reqBase != "" {
370 base = strings.TrimRight(reqBase, "/")
371 }
372 }
373 return base + "/" + ownerUsername + "/" + repoName + ".git"
374 }
375