Monorepo for Tangled
tangled.org
1package middleware
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "slices"
12 "strconv"
13 "strings"
14
15 "github.com/bluesky-social/indigo/atproto/identity"
16 "github.com/bluesky-social/indigo/atproto/syntax"
17 "github.com/go-chi/chi/v5"
18 "tangled.org/core/appview/cache"
19 "tangled.org/core/appview/db"
20 "tangled.org/core/appview/knotacl"
21 "tangled.org/core/appview/models"
22 "tangled.org/core/appview/oauth"
23 "tangled.org/core/appview/pages"
24 "tangled.org/core/appview/pagination"
25 "tangled.org/core/appview/reporesolver"
26 "tangled.org/core/appview/state/userutil"
27 "tangled.org/core/idresolver"
28 "tangled.org/core/orm"
29 "tangled.org/core/rbac"
30)
31
32type Middleware struct {
33 oauth *oauth.OAuth
34 db *db.DB
35 enforcer *rbac.Enforcer
36 acl *knotacl.Service
37 repoResolver *reporesolver.RepoResolver
38 idResolver *idresolver.Resolver
39 pages *pages.Pages
40 rdb *cache.Cache
41 logger *slog.Logger
42}
43
44func New(oauth *oauth.OAuth, db *db.DB, enforcer *rbac.Enforcer, acl *knotacl.Service, repoResolver *reporesolver.RepoResolver, idResolver *idresolver.Resolver, pages *pages.Pages, rdb *cache.Cache, logger *slog.Logger) Middleware {
45 return Middleware{
46 oauth: oauth,
47 db: db,
48 enforcer: enforcer,
49 acl: acl,
50 repoResolver: repoResolver,
51 idResolver: idResolver,
52 pages: pages,
53 rdb: rdb,
54 logger: logger,
55 }
56}
57
58type middlewareFunc func(http.Handler) http.Handler
59
60func AuthMiddleware(o *oauth.OAuth) middlewareFunc {
61 return func(next http.Handler) http.Handler {
62 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63 returnURL := "/"
64 if u, err := url.Parse(r.Header.Get("Referer")); err == nil {
65 returnURL = u.RequestURI()
66 }
67
68 loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL))
69
70 redirectFunc := func(w http.ResponseWriter, r *http.Request) {
71 http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
72 }
73 if r.Header.Get("HX-Request") == "true" {
74 redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
75 w.Header().Set("HX-Redirect", loginURL)
76 w.WriteHeader(http.StatusOK)
77 }
78 }
79
80 sess, err := o.ResumeSession(r)
81 if err != nil {
82 slog.Default().Warn("failed to resume session, redirecting", "err", err, "url", r.URL.String())
83 redirectFunc(w, r)
84 return
85 }
86
87 if sess == nil {
88 slog.Default().Warn("session is nil, redirecting")
89 redirectFunc(w, r)
90 return
91 }
92
93 next.ServeHTTP(w, r)
94 })
95 }
96}
97
98func (m *Middleware) InjectBaseParams(next http.Handler) http.Handler {
99 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 user := m.oauth.GetMultiAccountUser(r)
101 bp := pages.BaseParams{
102 LoggedInUser: user,
103 }
104 if user != nil {
105 if focusing, _ := db.GetFocusStatus(m.db, user.Did); focusing {
106 if item, _ := db.GetNextFocusItem(m.db, user.Did); item != nil {
107 count, _ := db.CountFocusNotifs(m.db, user.Did)
108 bp.FocusParams = pages.FocusParams{
109 Focusing: true,
110 FocusLink: item.URL(m.idResolver),
111 FocusNotificationID: item.ID,
112 CurrentPath: r.URL.Path,
113 FocusCount: int(count),
114 }
115 } else {
116 // queue exhausted — auto-exit focus mode
117 _ = db.EndFocus(m.db, user.Did)
118 }
119 }
120 }
121 ctx := pages.BaseParamsIntoContext(r.Context(), bp)
122 next.ServeHTTP(w, r.WithContext(ctx))
123 })
124}
125
126func Paginate(next http.Handler) http.Handler {
127 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128 page := pagination.FirstPage()
129
130 offsetVal := r.URL.Query().Get("offset")
131 if offsetVal != "" {
132 offset, err := strconv.Atoi(offsetVal)
133 if err != nil {
134 slog.Default().Warn("invalid offset", "value", offsetVal)
135 } else {
136 page.Offset = offset
137 }
138 }
139
140 limitVal := r.URL.Query().Get("limit")
141 if limitVal != "" {
142 limit, err := strconv.Atoi(limitVal)
143 if err != nil {
144 slog.Default().Warn("invalid limit", "value", limitVal)
145 } else {
146 page.Limit = limit
147 }
148 }
149
150 ctx := pagination.IntoContext(r.Context(), page)
151 next.ServeHTTP(w, r.WithContext(ctx))
152 })
153}
154
155func (mw Middleware) knotRoleMiddleware(group string) middlewareFunc {
156 return func(next http.Handler) http.Handler {
157 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158 l := mw.logger.With("middleware", "knotRoleMiddleware")
159 // requires auth also
160 actor := mw.oauth.GetMultiAccountUser(r)
161 if actor == nil {
162 // we need a logged in user
163 l.Warn("not logged in, redirecting")
164 http.Error(w, "Forbidden", http.StatusUnauthorized)
165 return
166 }
167 domain := chi.URLParam(r, "domain")
168 if domain == "" {
169 http.Error(w, "malformed url", http.StatusBadRequest)
170 return
171 }
172
173 ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain)
174 if err != nil || !ok {
175 l.Warn("permission denied", "did", actor.Did, "group", group, "domain", domain)
176 http.Error(w, "Forbidden", http.StatusUnauthorized)
177 return
178 }
179
180 next.ServeHTTP(w, r)
181 })
182 }
183}
184
185func (mw Middleware) KnotOwner() middlewareFunc {
186 return mw.knotRoleMiddleware("server:owner")
187}
188
189func (mw Middleware) RepoPermissionMiddleware(requiredPerm string) middlewareFunc {
190 return func(next http.Handler) http.Handler {
191 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192 l := mw.logger.With("middleware", "RepoPermissionMiddleware")
193 // requires auth also
194 actor := mw.oauth.GetMultiAccountUser(r)
195 if actor == nil {
196 // we need a logged in user
197 l.Warn("not logged in, redirecting")
198 http.Error(w, "Forbidden", http.StatusUnauthorized)
199 return
200 }
201 f, err := mw.repoResolver.Resolve(r)
202 if err != nil {
203 http.Error(w, "malformed url", http.StatusBadRequest)
204 return
205 }
206
207 if !mw.acl.HasRepoPermission(r.Context(), f, actor.Did, requiredPerm) {
208 l.Warn("permission denied", "did", actor.Did, "perm", requiredPerm, "repo", f.RepoIdentifier())
209 http.Error(w, "Forbidden", http.StatusUnauthorized)
210 return
211 }
212
213 next.ServeHTTP(w, r)
214 })
215 }
216}
217
218func (mw Middleware) ResolveIdent() middlewareFunc {
219 excluded := []string{"favicon.ico"}
220
221 return func(next http.Handler) http.Handler {
222 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
223 origSeg := chi.URLParam(req, "user")
224 didOrHandle := strings.TrimPrefix(origSeg, "@")
225
226 if slices.Contains(excluded, didOrHandle) {
227 next.ServeHTTP(w, req)
228 return
229 }
230
231 id, err := mw.idResolver.ResolveAtIdentifier(req.Context(), didOrHandle)
232 if err != nil {
233 if h, parseErr := syntax.ParseHandle(didOrHandle); parseErr == nil {
234 if did := cache.LookupDidByPreferredHandle(req.Context(), mw.rdb, mw.db, h); did != "" {
235 id, err = mw.idResolver.ResolveAtIdentifier(req.Context(), did)
236 }
237 }
238 }
239 if err != nil {
240 mw.logger.Error("failed to resolve did/handle", "didOrHandle", didOrHandle, "err", err)
241 mw.pages.Error404(w)
242 return
243 }
244
245 if req.Method == http.MethodGet && !userutil.IsDid(didOrHandle) {
246 if pref := cache.LookupPreferredHandle(req.Context(), mw.rdb, mw.db, id.DID.String()); pref != "" && didOrHandle != pref {
247 rest := strings.TrimPrefix(req.URL.Path, "/"+origSeg)
248 target := "/" + pref + rest
249 if req.URL.RawQuery != "" {
250 target += "?" + req.URL.RawQuery
251 }
252 http.Redirect(w, req, target, http.StatusFound)
253 return
254 }
255 }
256
257 ctx := context.WithValue(req.Context(), "resolvedId", *id)
258
259 next.ServeHTTP(w, req.WithContext(ctx))
260 })
261 }
262}
263
264func (mw Middleware) ResolveRepo() middlewareFunc {
265 return func(next http.Handler) http.Handler {
266 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
267 l := mw.logger.With("middleware", "ResolveRepo")
268 repoName := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git")
269 rkey := strings.ToLower(repoName)
270
271 id, ok := req.Context().Value("resolvedId").(identity.Identity)
272 if !ok {
273 l.Error("malformed middleware")
274 w.WriteHeader(http.StatusInternalServerError)
275 return
276 }
277
278 repo, isRename := resolveRepoForOwner(mw.db, id.DID.String(), repoName, rkey, l)
279 if repo == nil {
280 w.WriteHeader(http.StatusNotFound)
281 mw.pages.ErrorKnot404(w)
282 return
283 }
284 if isRename {
285 handle := id.Handle.String()
286 if id.Handle.IsInvalidHandle() || handle == "" {
287 handle = id.DID.String()
288 }
289 target := reporesolver.CanonicalRedirectTarget(req, reporesolver.CanonicalRepoPath(handle, repo))
290 http.Redirect(w, req, target, http.StatusMovedPermanently)
291 return
292 }
293
294 ctx := context.WithValue(req.Context(), "repo", repo)
295 next.ServeHTTP(w, req.WithContext(ctx))
296 })
297 }
298}
299
300func resolveRepoForOwner(d db.Execer, ownerDid, repoName, rkey string, l *slog.Logger) (*models.Repo, bool) {
301 repo, err := db.GetRepo(d, orm.FilterEq("did", ownerDid), orm.FilterEq("rkey", rkey))
302 if err == nil {
303 return repo, false
304 }
305 if !errors.Is(err, sql.ErrNoRows) {
306 l.Error("failed to resolve repo by rkey", "err", err)
307 return nil, false
308 }
309
310 hint, hintErr := db.LookupRepoRename(d, ownerDid, rkey)
311 if hintErr != nil && !errors.Is(hintErr, sql.ErrNoRows) {
312 l.Error("failed to lookup repo rename hint", "err", hintErr)
313 }
314 if hint != nil {
315 return hint, true
316 }
317
318 nameRepos, nameErr := db.GetRepos(d, orm.FilterEq("did", ownerDid), orm.FilterEq("name", repoName))
319 if nameErr != nil {
320 l.Error("failed to resolve repo by name", "err", nameErr)
321 return nil, false
322 }
323 if len(nameRepos) == 1 {
324 return &nameRepos[0], false
325 }
326 return nil, false
327}
328
329func (mw Middleware) CanonicalizeRepoURL() middlewareFunc {
330 return func(next http.Handler) http.Handler {
331 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
332 if req.Method != http.MethodGet && req.Method != http.MethodHead {
333 next.ServeHTTP(w, req)
334 return
335 }
336 id, idOk := req.Context().Value("resolvedId").(identity.Identity)
337 repo, repoOk := req.Context().Value("repo").(*models.Repo)
338 if !idOk || !repoOk || id.Handle.IsInvalidHandle() {
339 next.ServeHTTP(w, req)
340 return
341 }
342 handle := id.Handle.String()
343 if handle == "" {
344 next.ServeHTTP(w, req)
345 return
346 }
347 canonical := reporesolver.CanonicalRepoPath(handle, repo)
348 urlUser := chi.URLParam(req, "user")
349 urlRepo := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git")
350 if urlUser+"/"+urlRepo == canonical {
351 next.ServeHTTP(w, req)
352 return
353 }
354
355 http.Redirect(w, req, reporesolver.CanonicalRedirectTarget(req, canonical), http.StatusFound)
356 })
357 }
358}
359
360// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
361func (mw Middleware) ResolvePull() middlewareFunc {
362 return func(next http.Handler) http.Handler {
363 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
364 l := mw.logger.With("middleware", "ResolvePull")
365 f, err := mw.repoResolver.Resolve(r)
366 if err != nil {
367 l.Error("failed to fully resolve repo", "err", err)
368 w.WriteHeader(http.StatusNotFound)
369 mw.pages.ErrorKnot404(w)
370 return
371 }
372
373 prId := chi.URLParam(r, "pull")
374 prIdInt, err := strconv.Atoi(prId)
375 if err != nil {
376 l.Error("failed to parse pr id", "err", err)
377 mw.pages.Error404(w)
378 return
379 }
380
381 pr, err := db.GetPull(mw.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prIdInt))
382 if err != nil {
383 l.Error("failed to get pull and comments", "err", err)
384 mw.pages.Error404(w)
385 return
386 }
387
388 ctx := context.WithValue(r.Context(), "pull", pr)
389
390 stack, err := db.GetStack(mw.db, pr.AtUri())
391 if err != nil {
392 l.Error("failed to get stack", "err", err)
393 mw.pages.Error404(w)
394 return
395 }
396
397 ctx = context.WithValue(ctx, "stack", stack)
398
399 next.ServeHTTP(w, r.WithContext(ctx))
400 })
401 }
402}
403
404// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
405func (mw Middleware) ResolveIssue(next http.Handler) http.Handler {
406 l := mw.logger.With("middleware", "ResolveIssue")
407 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
408 f, err := mw.repoResolver.Resolve(r)
409 if err != nil {
410 l.Error("failed to fully resolve repo", "err", err)
411 w.WriteHeader(http.StatusNotFound)
412 mw.pages.ErrorKnot404(w)
413 return
414 }
415
416 issueIdStr := chi.URLParam(r, "issue")
417 issueId, err := strconv.Atoi(issueIdStr)
418 if err != nil {
419 l.Error("failed to fully resolve issue ID", "err", err)
420 mw.pages.Error404(w)
421 return
422 }
423
424 issue, err := db.GetIssue(mw.db, f.RepoDid, issueId)
425 if err != nil {
426 l.Error("failed to get issues", "err", err)
427 mw.pages.Error404(w)
428 return
429 }
430
431 ctx := context.WithValue(r.Context(), "issue", issue)
432 next.ServeHTTP(w, r.WithContext(ctx))
433 })
434}
435
436// this should serve the go-import meta tag even if the path is technically
437// a 404 like tangled.sh/oppi.li/go-git/v5
438//
439// we're keeping the tangled.sh go-import tag too to maintain backward
440// compatibility for modules that still point there. they will be redirected
441// to fetch source from tangled.org
442func (mw Middleware) GoImport() middlewareFunc {
443 return func(next http.Handler) http.Handler {
444 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
445 l := mw.logger.With("middleware", "GoImport")
446 f, err := mw.repoResolver.Resolve(r)
447 if err != nil {
448 l.Error("failed to fully resolve repo", "err", err)
449 w.WriteHeader(http.StatusNotFound)
450 mw.pages.ErrorKnot404(w)
451 return
452 }
453
454 fullName := reporesolver.GetBaseRepoPath(r, f)
455
456 if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
457 if r.URL.Query().Get("go-get") == "1" {
458 modulePath := userutil.FlattenDid(fullName)
459 if strings.Contains(modulePath, ":") {
460 modulePath = userutil.FlattenDid(f.Did) + "/" + f.Rkey
461 }
462 tags := []string{
463 fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, modulePath, fullName),
464 fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, modulePath, fullName),
465 }
466 if f.RepoDid != "" {
467 stable := userutil.FlattenDid(f.RepoDid)
468 if stable != modulePath {
469 tags = append(tags,
470 fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, stable, f.RepoDid),
471 fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, stable, f.RepoDid),
472 )
473 }
474 }
475 w.Header().Set("Content-Type", "text/html")
476 w.Write([]byte(strings.Join(tags, "\n")))
477 return
478 }
479 }
480
481 next.ServeHTTP(w, r)
482 })
483 }
484}