Monorepo for Tangled
tangled.org
1package state
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "log/slog"
8 "net/http"
9 "strings"
10
11 "github.com/go-chi/chi/v5"
12 "tangled.org/core/appview/config"
13 "tangled.org/core/appview/db"
14 "tangled.org/core/appview/focus"
15 "tangled.org/core/appview/issues"
16 "tangled.org/core/appview/knotacl"
17 "tangled.org/core/appview/knots"
18 "tangled.org/core/appview/labels"
19 "tangled.org/core/appview/metrics"
20 "tangled.org/core/appview/middleware"
21 "tangled.org/core/appview/migration"
22 "tangled.org/core/appview/notifications"
23 "tangled.org/core/appview/pipelines"
24 "tangled.org/core/appview/pulls"
25 "tangled.org/core/appview/repo"
26 "tangled.org/core/appview/settings"
27 "tangled.org/core/appview/signup"
28 "tangled.org/core/appview/spindles"
29 "tangled.org/core/appview/state/userutil"
30 avstrings "tangled.org/core/appview/strings"
31 avtimeline "tangled.org/core/appview/timeline"
32 "tangled.org/core/blog"
33 "tangled.org/core/log"
34)
35
36// newDispatchHandler builds the /* catch-all handler. It is a standalone
37// function so that it can be tested without a full State.
38func newDispatchHandler(
39 cfg *config.Config,
40 execer db.Execer,
41 logger *slog.Logger,
42 userRouter, standardRouter http.Handler,
43) http.HandlerFunc {
44 return func(w http.ResponseWriter, r *http.Request) {
45 pat := chi.URLParam(r, "*")
46 pathParts := strings.SplitN(pat, "/", 2)
47
48 if len(pathParts) > 0 {
49 firstPart := pathParts[0]
50
51 if userutil.IsDid(firstPart) {
52 repo, err := db.GetRepoByDid(execer, firstPart)
53 switch {
54 case err == nil:
55 remaining := ""
56 if len(pathParts) > 1 {
57 remaining = "/" + pathParts[1]
58 }
59 rewritten := "/" + repo.Did + "/" + repo.Rkey + remaining
60 r2 := r.Clone(r.Context())
61 r2.URL.Path = rewritten
62 r2.URL.RawPath = rewritten
63 userRouter.ServeHTTP(w, r2)
64 case errors.Is(err, sql.ErrNoRows):
65 userRouter.ServeHTTP(w, r)
66 default:
67 logger.Error("db error looking up repo DID", "repoDid", firstPart, "err", err)
68 http.Error(w, "internal server error", http.StatusInternalServerError)
69 }
70 return
71 }
72
73 if userutil.IsHandle(firstPart) {
74 userRouter.ServeHTTP(w, r)
75 return
76 }
77
78 // if using a flattened DID (like you would in go modules), unflatten
79 if userutil.IsFlattenedDid(firstPart) {
80 unflattenedDid := userutil.UnflattenDid(firstPart)
81 redirectPath := strings.Join(append([]string{unflattenedDid}, pathParts[1:]...), "/")
82
83 redirectURL := *r.URL
84 redirectURL.Path = "/" + redirectPath
85
86 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
87 return
88 }
89
90 // if using a handle with @, rewrite to work without @
91 if normalized := strings.TrimPrefix(firstPart, "@"); userutil.IsHandle(normalized) {
92 redirectPath := strings.Join(append([]string{normalized}, pathParts[1:]...), "/")
93
94 redirectURL := *r.URL
95 redirectURL.Path = "/" + redirectPath
96
97 http.Redirect(w, r, redirectURL.String(), http.StatusFound)
98 return
99 }
100
101 // project mode: rewrite /{repo}/... → /{projectUser}/{repo}/...
102 // unless the first segment is a reserved standard-route prefix.
103 if cfg.Project.Enabled && cfg.Project.User != "" {
104 if firstPart == "" {
105 r2 := r.Clone(r.Context())
106 r2.URL.Path = "/" + cfg.Project.User
107 r2.URL.RawPath = "/" + cfg.Project.User
108 userRouter.ServeHTTP(w, r2)
109 return
110 }
111 if _, isStd := standardPrefixes[firstPart]; !isStd {
112 rewritten := "/" + cfg.Project.User + "/" + pat
113 r2 := r.Clone(r.Context())
114 r2.URL.Path = rewritten
115 r2.URL.RawPath = rewritten
116 userRouter.ServeHTTP(w, r2)
117 return
118 }
119 }
120 }
121
122 standardRouter.ServeHTTP(w, r)
123 }
124}
125
126// standardPrefixes is the set of first path segments that belong to the
127// standard (non-user) router. In project mode, any segment not in this set
128// is treated as a repo name and rewritten to /{ProjectUser}/{segment}.
129var standardPrefixes = map[string]struct{}{
130 "static": {}, "home": {}, "timeline": {}, "upgradeBanner": {},
131 "newsletter": {}, "core": {}, "login": {}, "logout": {},
132 "search": {}, "account": {}, "repo": {}, "goodfirstissues": {},
133 "follow": {}, "vouch": {}, "star": {}, "react": {},
134 "profile": {}, "settings": {}, "strings": {}, "notifications": {},
135 "signup": {}, "keys": {}, "terms": {}, "privacy": {}, "brand": {},
136 "oauth": {},
137}
138
139func (s *State) Router() http.Handler {
140 router := chi.NewRouter()
141 middleware := middleware.New(
142 s.oauth,
143 s.db,
144 s.enforcer,
145 s.aclService,
146 s.repoResolver,
147 s.idResolver,
148 s.pages,
149 s.rdb,
150 s.logger,
151 )
152
153 router.Use(metrics.Middleware)
154 router.Use(knotacl.MemoMiddleware)
155
156 if err := db.ReapStaleRunningMigrations(context.Background(), s.db); err != nil {
157 s.logger.Warn("failed to reap stale running migrations", "err", err)
158 }
159 m := migration.NewMigration(s.db, s.oauth, s.idResolver.Directory(), s.logger)
160 router.Use(m.BackgroundMigrationMiddleware)
161
162 router.Get("/pwa-manifest.json", s.WebAppManifest)
163 router.Get("/robots.txt", s.RobotsTxt)
164 router.Get("/.well-known/security.txt", s.SecurityTxt)
165
166 userRouter := s.UserRouter(&middleware)
167 standardRouter := s.StandardRouter(&middleware)
168
169 router.HandleFunc("/*", newDispatchHandler(s.config, s.db, s.logger, userRouter, standardRouter))
170
171 return router
172}
173
174func (s *State) UserRouter(mw *middleware.Middleware) http.Handler {
175 r := chi.NewRouter()
176 r.Use(mw.InjectBaseParams)
177
178 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) {
179 r.Get("/", s.Profile)
180 r.Get("/feed.atom", s.AtomFeedPage)
181
182 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) {
183 r.Use(mw.GoImport())
184
185 // These routes get proxied to the knot
186 r.Get("/info/refs", s.InfoRefs)
187 r.Post("/git-upload-archive", s.UploadArchive)
188 r.Post("/git-upload-pack", s.UploadPack)
189 r.Post("/git-receive-pack", s.ReceivePack)
190
191 r.Group(func(r chi.Router) {
192 r.Use(mw.CanonicalizeRepoURL())
193 r.Mount("/issues", s.IssuesRouter(mw))
194 r.Mount("/pulls", s.PullsRouter(mw))
195 r.Mount("/pipelines", s.PipelinesRouter(mw))
196 r.Mount("/labels", s.LabelsRouter())
197 r.Mount("/", s.RepoRouter(mw))
198 })
199 })
200 })
201
202 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
203 w.WriteHeader(http.StatusNotFound)
204 s.pages.Error404(w)
205 })
206
207 return r
208}
209
210func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler {
211 r := chi.NewRouter()
212 r.Use(mw.InjectBaseParams)
213
214 r.Handle("/static/*", s.pages.Static())
215
216 tl := avtimeline.New(s.oauth, s.db, s.config, s.pages, s.logger, blog.PostsFS)
217 r.Get("/", tl.HomeOrTimeline)
218 if s.config.Project.Enabled {
219 r.Get("/home", http.RedirectHandler("/", http.StatusFound).ServeHTTP)
220 r.Get("/timeline", http.RedirectHandler("/", http.StatusFound).ServeHTTP)
221 } else {
222 r.Get("/home", tl.Home)
223 r.Get("/timeline", tl.Timeline)
224 }
225 r.Get("/upgradeBanner", s.UpgradeBanner)
226 r.Post("/newsletter/signup", s.NewsletterSignup)
227 r.Post("/newsletter/dismiss", s.NewsletterDismiss)
228
229 // special-case handler for serving tangled.org/core
230 r.Get("/core", s.Core())
231
232 r.Get("/login", s.Login)
233 r.Post("/login", s.Login)
234 r.Post("/logout", s.Logout)
235
236 r.With(middleware.Paginate).Get("/search", s.Search)
237 r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick", s.SearchQuick)
238 r.With(middleware.AuthMiddleware(s.oauth)).Get("/search/quick/mobile", s.SearchQuickMobile)
239
240 r.Post("/account/switch", s.SwitchAccount)
241 r.With(middleware.AuthMiddleware(s.oauth)).Delete("/account/{did}", s.RemoveAccount)
242
243 r.Route("/repo", func(r chi.Router) {
244 r.Route("/new", func(r chi.Router) {
245 r.Use(middleware.AuthMiddleware(s.oauth))
246 r.Get("/", s.NewRepo)
247 r.Post("/", s.NewRepo)
248 })
249 // r.Post("/import", s.ImportRepo)
250 })
251
252 r.With(middleware.Paginate).Get("/goodfirstissues", s.GoodFirstIssues)
253
254 r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) {
255 r.Post("/", s.Follow)
256 r.Delete("/", s.Follow)
257 })
258
259 r.With(middleware.AuthMiddleware(s.oauth)).Route("/vouch", func(r chi.Router) {
260 r.Post("/", s.Vouch)
261 r.Post("/skip", s.SkipVouchSuggestion)
262 })
263
264 r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) {
265 r.Post("/", s.Star)
266 r.Delete("/", s.Star)
267 })
268
269 r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) {
270 r.Post("/", s.React)
271 r.Delete("/", s.React)
272 })
273
274 r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) {
275 r.Get("/", s.CommentBodyFragment)
276 r.Get("/edit", s.EditCommentFragment)
277 r.Get("/reply", s.NewReplyCommentFragment)
278 r.Get("/reply/placeholder", s.ReplyPlaceholderFragment)
279 r.Post("/", s.NewComment)
280 r.Patch("/", s.EditComment)
281 r.Delete("/", s.DeleteComment)
282 })
283
284 r.Get("/profile/popover", s.ProfilePopover)
285
286 r.Route("/profile", func(r chi.Router) {
287 r.Use(middleware.AuthMiddleware(s.oauth))
288 r.Get("/edit-bio", s.EditBioFragment)
289 r.Get("/edit-pins", s.EditPinsFragment)
290 r.Post("/bio", s.UpdateProfileBio)
291 r.Post("/pins", s.UpdateProfilePins)
292 r.Post("/avatar", s.UploadProfileAvatar)
293 r.Delete("/avatar", s.RemoveProfileAvatar)
294 r.Post("/punchcard", s.UpdateProfilePunchcardSetting)
295 })
296
297 r.Mount("/settings", s.SettingsRouter())
298 r.Mount("/strings", s.StringsRouter(mw))
299
300 r.Mount("/settings/knots", s.KnotsRouter())
301 r.Mount("/settings/spindles", s.SpindlesRouter())
302
303 r.Mount("/notifications", s.NotificationsRouter(mw))
304 r.Mount("/focus", s.FocusRouter(mw))
305
306 if s.config.Project.Enabled {
307 r.Mount("/signup", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308 http.Redirect(w, r, "/", http.StatusFound)
309 }))
310 } else {
311 r.Mount("/signup", s.SignupRouter())
312 }
313 r.Mount("/", s.oauth.Router())
314
315 r.Get("/keys/{user}", s.Keys)
316 r.Get("/terms", s.TermsOfService)
317 r.Get("/privacy", s.PrivacyPolicy)
318 r.Get("/brand", s.Brand)
319
320 r.NotFound(func(w http.ResponseWriter, r *http.Request) {
321 w.WriteHeader(http.StatusNotFound)
322 s.pages.Error404(w)
323 })
324 return r
325}
326
327// Core serves tangled.org/core go-import meta tags, and redirects
328// to the core repository if accessed normally.
329func (s *State) Core() http.HandlerFunc {
330 return func(w http.ResponseWriter, r *http.Request) {
331 if r.URL.Query().Get("go-get") == "1" {
332 w.Header().Set("Content-Type", "text/html")
333 w.Write([]byte(`<meta name="go-import" content="tangled.org/core git https://tangled.org/@tangled.org/core">`))
334 return
335 }
336
337 http.Redirect(w, r, "/@tangled.org/core", http.StatusFound)
338 }
339}
340
341func (s *State) SettingsRouter() http.Handler {
342 settings := &settings.Settings{
343 Db: s.db,
344 OAuth: s.oauth,
345 Pages: s.pages,
346 Config: s.config,
347 CfClient: s.cfClient,
348 Logger: log.SubLogger(s.logger, "settings"),
349 IdResolver: s.idResolver,
350 }
351
352 return settings.Router()
353}
354
355func (s *State) SpindlesRouter() http.Handler {
356 logger := log.SubLogger(s.logger, "spindles")
357
358 spindles := &spindles.Spindles{
359 Db: s.db,
360 OAuth: s.oauth,
361 Pages: s.pages,
362 Config: s.config,
363 Enforcer: s.enforcer,
364 IdResolver: s.idResolver,
365 Logger: logger,
366 }
367
368 return spindles.Router()
369}
370
371func (s *State) KnotsRouter() http.Handler {
372 logger := log.SubLogger(s.logger, "knots")
373
374 knots := &knots.Knots{
375 Db: s.db,
376 OAuth: s.oauth,
377 Pages: s.pages,
378 Config: s.config,
379 Enforcer: s.enforcer,
380 Acl: s.aclService,
381 IdResolver: s.idResolver,
382 Knotstream: s.knotstream,
383 Logger: logger,
384 }
385
386 return knots.Router()
387}
388
389func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler {
390 logger := log.SubLogger(s.logger, "strings")
391
392 strs := &avstrings.Strings{
393 Db: s.db,
394 OAuth: s.oauth,
395 Pages: s.pages,
396 IdResolver: s.idResolver,
397 Notifier: s.notifier,
398 Logger: logger,
399 }
400
401 return strs.Router(mw)
402}
403
404func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
405 issues := issues.New(
406 s.oauth,
407 s.repoResolver,
408 s.aclService,
409 s.pages,
410 s.idResolver,
411 s.mentionsResolver,
412 s.db,
413 s.config,
414 s.notifier,
415 s.validator,
416 s.indexer.Issues,
417 log.SubLogger(s.logger, "issues"),
418 )
419 return issues.Router(mw)
420}
421
422func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
423 pulls := pulls.New(
424 s.oauth,
425 s.repoResolver,
426 s.pages,
427 s.idResolver,
428 s.mentionsResolver,
429 s.db,
430 s.config,
431 s.notifier,
432 s.aclService,
433 s.validator,
434 s.indexer.Pulls,
435 log.SubLogger(s.logger, "pulls"),
436 )
437 return pulls.Router(mw)
438}
439
440func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler {
441 repo := repo.New(
442 s.oauth,
443 s.repoResolver,
444 s.pages,
445 s.spindlestream,
446 s.idResolver,
447 s.db,
448 s.config,
449 s.notifier,
450 s.enforcer,
451 s.aclService,
452 log.SubLogger(s.logger, "repo"),
453 s.validator,
454 s.cfClient,
455 )
456 return repo.Router(mw)
457}
458
459func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler {
460 pipes := pipelines.New(
461 s.oauth,
462 s.repoResolver,
463 s.pages,
464 s.spindlestream,
465 s.pipelineNotifier,
466 s.idResolver,
467 s.db,
468 s.config,
469 s.enforcer,
470 log.SubLogger(s.logger, "pipelines"),
471 )
472 return pipes.Router(mw)
473}
474
475func (s *State) LabelsRouter() http.Handler {
476 ls := labels.New(
477 s.oauth,
478 s.pages,
479 s.db,
480 s.validator,
481 s.enforcer,
482 s.notifier,
483 log.SubLogger(s.logger, "labels"),
484 )
485 return ls.Router()
486}
487
488func (s *State) NotificationsRouter(mw *middleware.Middleware) http.Handler {
489 notifs := notifications.New(s.db, s.oauth, s.pages, log.SubLogger(s.logger, "notifications"))
490 return notifs.Router(mw)
491}
492
493func (s *State) FocusRouter(mw *middleware.Middleware) http.Handler {
494 f := focus.New(s.db, s.oauth, s.idResolver, s.pages, log.SubLogger(s.logger, "focus"))
495 return f.Router(mw)
496}
497
498func (s *State) SignupRouter() http.Handler {
499 sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, log.SubLogger(s.logger, "signup"))
500 return sig.Router()
501}