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