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 didOrHandle = strings.TrimSuffix(didOrHandle, ".keys")
226
227 if slices.Contains(excluded, didOrHandle) {
228 next.ServeHTTP(w, req)
229 return
230 }
231
232 id, err := mw.idResolver.ResolveAtIdentifier(req.Context(), didOrHandle)
233 if err != nil {
234 if h, parseErr := syntax.ParseHandle(didOrHandle); parseErr == nil {
235 if did := cache.LookupDidByPreferredHandle(req.Context(), mw.rdb, mw.db, h); did != "" {
236 id, err = mw.idResolver.ResolveAtIdentifier(req.Context(), did)
237 }
238 }
239 }
240 if err != nil {
241 mw.logger.Error("failed to resolve did/handle", "didOrHandle", didOrHandle, "err", err)
242 mw.pages.Error404(w)
243 return
244 }
245
246 if req.Method == http.MethodGet && !userutil.IsDid(didOrHandle) {
247 if pref := cache.LookupPreferredHandle(req.Context(), mw.rdb, mw.db, id.DID.String()); pref != "" && didOrHandle != pref {
248 rest := strings.TrimPrefix(req.URL.Path, "/"+origSeg)
249 target := "/" + pref + rest
250 if req.URL.RawQuery != "" {
251 target += "?" + req.URL.RawQuery
252 }
253 http.Redirect(w, req, target, http.StatusFound)
254 return
255 }
256 }
257
258 ctx := context.WithValue(req.Context(), "resolvedId", *id)
259
260 next.ServeHTTP(w, req.WithContext(ctx))
261 })
262 }
263}
264
265func (mw Middleware) ResolveRepo() middlewareFunc {
266 return func(next http.Handler) http.Handler {
267 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
268 l := mw.logger.With("middleware", "ResolveRepo")
269 repoName := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git")
270 rkey := strings.ToLower(repoName)
271
272 id, ok := req.Context().Value("resolvedId").(identity.Identity)
273 if !ok {
274 l.Error("malformed middleware")
275 w.WriteHeader(http.StatusInternalServerError)
276 return
277 }
278
279 repo, isRename := resolveRepoForOwner(mw.db, id.DID.String(), repoName, rkey, l)
280 if repo == nil {
281 w.WriteHeader(http.StatusNotFound)
282 mw.pages.ErrorKnot404(w)
283 return
284 }
285 if isRename {
286 handle := id.Handle.String()
287 if id.Handle.IsInvalidHandle() || handle == "" {
288 handle = id.DID.String()
289 }
290 target := reporesolver.CanonicalRedirectTarget(req, reporesolver.CanonicalRepoPath(handle, repo))
291 http.Redirect(w, req, target, http.StatusMovedPermanently)
292 return
293 }
294
295 ctx := context.WithValue(req.Context(), "repo", repo)
296 next.ServeHTTP(w, req.WithContext(ctx))
297 })
298 }
299}
300
301func resolveRepoForOwner(d db.Execer, ownerDid, repoName, rkey string, l *slog.Logger) (*models.Repo, bool) {
302 repo, err := db.GetRepo(d, orm.FilterEq("did", ownerDid), orm.FilterEq("rkey", rkey))
303 if err == nil {
304 return repo, false
305 }
306 if !errors.Is(err, sql.ErrNoRows) {
307 l.Error("failed to resolve repo by rkey", "err", err)
308 return nil, false
309 }
310
311 hint, hintErr := db.LookupRepoRename(d, ownerDid, rkey)
312 if hintErr != nil && !errors.Is(hintErr, sql.ErrNoRows) {
313 l.Error("failed to lookup repo rename hint", "err", hintErr)
314 }
315 if hint != nil {
316 return hint, true
317 }
318
319 nameRepos, nameErr := db.GetRepos(d, orm.FilterEq("did", ownerDid), orm.FilterEq("name", repoName))
320 if nameErr != nil {
321 l.Error("failed to resolve repo by name", "err", nameErr)
322 return nil, false
323 }
324 if len(nameRepos) == 1 {
325 return &nameRepos[0], false
326 }
327 return nil, false
328}
329
330func (mw Middleware) CanonicalizeRepoURL() middlewareFunc {
331 return func(next http.Handler) http.Handler {
332 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
333 if req.Method != http.MethodGet && req.Method != http.MethodHead {
334 next.ServeHTTP(w, req)
335 return
336 }
337 id, idOk := req.Context().Value("resolvedId").(identity.Identity)
338 repo, repoOk := req.Context().Value("repo").(*models.Repo)
339 if !idOk || !repoOk || id.Handle.IsInvalidHandle() {
340 next.ServeHTTP(w, req)
341 return
342 }
343 handle := id.Handle.String()
344 if handle == "" {
345 next.ServeHTTP(w, req)
346 return
347 }
348 canonical := reporesolver.CanonicalRepoPath(handle, repo)
349 urlUser := chi.URLParam(req, "user")
350 urlRepo := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git")
351 if urlUser+"/"+urlRepo == canonical {
352 next.ServeHTTP(w, req)
353 return
354 }
355
356 http.Redirect(w, req, reporesolver.CanonicalRedirectTarget(req, canonical), http.StatusFound)
357 })
358 }
359}
360
361// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
362func (mw Middleware) ResolvePull() middlewareFunc {
363 return func(next http.Handler) http.Handler {
364 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
365 l := mw.logger.With("middleware", "ResolvePull")
366 f, err := mw.repoResolver.Resolve(r)
367 if err != nil {
368 l.Error("failed to fully resolve repo", "err", err)
369 w.WriteHeader(http.StatusNotFound)
370 mw.pages.ErrorKnot404(w)
371 return
372 }
373
374 prId := chi.URLParam(r, "pull")
375 prIdInt, err := strconv.Atoi(prId)
376 if err != nil {
377 l.Error("failed to parse pr id", "err", err)
378 mw.pages.Error404(w)
379 return
380 }
381
382 pr, err := db.GetPull(mw.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prIdInt))
383 if err != nil {
384 l.Error("failed to get pull and comments", "err", err)
385 mw.pages.Error404(w)
386 return
387 }
388
389 ctx := context.WithValue(r.Context(), "pull", pr)
390
391 stack, err := db.GetStack(mw.db, pr.AtUri())
392 if err != nil {
393 l.Error("failed to get stack", "err", err)
394 mw.pages.Error404(w)
395 return
396 }
397
398 ctx = context.WithValue(ctx, "stack", stack)
399
400 next.ServeHTTP(w, r.WithContext(ctx))
401 })
402 }
403}
404
405// middleware that is tacked on top of /{user}/{repo}/issues/{issue}
406func (mw Middleware) ResolveIssue(next http.Handler) http.Handler {
407 l := mw.logger.With("middleware", "ResolveIssue")
408 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
409 f, err := mw.repoResolver.Resolve(r)
410 if err != nil {
411 l.Error("failed to fully resolve repo", "err", err)
412 w.WriteHeader(http.StatusNotFound)
413 mw.pages.ErrorKnot404(w)
414 return
415 }
416
417 issueIdStr := chi.URLParam(r, "issue")
418 issueId, err := strconv.Atoi(issueIdStr)
419 if err != nil {
420 l.Error("failed to fully resolve issue ID", "err", err)
421 mw.pages.Error404(w)
422 return
423 }
424
425 issue, err := db.GetIssue(mw.db, f.RepoDid, issueId)
426 if err != nil {
427 l.Error("failed to get issues", "err", err)
428 mw.pages.Error404(w)
429 return
430 }
431
432 ctx := context.WithValue(r.Context(), "issue", issue)
433 next.ServeHTTP(w, r.WithContext(ctx))
434 })
435}
436
437// this should serve the go-import meta tag even if the path is technically
438// a 404 like tangled.sh/oppi.li/go-git/v5
439//
440// we're keeping the tangled.sh go-import tag too to maintain backward
441// compatibility for modules that still point there. they will be redirected
442// to fetch source from tangled.org
443func (mw Middleware) GoImport() middlewareFunc {
444 return func(next http.Handler) http.Handler {
445 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
446 l := mw.logger.With("middleware", "GoImport")
447 f, err := mw.repoResolver.Resolve(r)
448 if err != nil {
449 l.Error("failed to fully resolve repo", "err", err)
450 w.WriteHeader(http.StatusNotFound)
451 mw.pages.ErrorKnot404(w)
452 return
453 }
454
455 fullName := reporesolver.GetBaseRepoPath(r, f)
456
457 if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
458 if r.URL.Query().Get("go-get") == "1" {
459 modulePath := userutil.FlattenDid(fullName)
460 if strings.Contains(modulePath, ":") {
461 modulePath = userutil.FlattenDid(f.Did) + "/" + f.Rkey
462 }
463 tags := []string{
464 fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, modulePath, fullName),
465 fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, modulePath, fullName),
466 }
467 if f.RepoDid != "" {
468 stable := userutil.FlattenDid(f.RepoDid)
469 if stable != modulePath {
470 tags = append(tags,
471 fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, stable, f.RepoDid),
472 fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, stable, f.RepoDid),
473 )
474 }
475 }
476 w.Header().Set("Content-Type", "text/html")
477 w.Write([]byte(strings.Join(tags, "\n")))
478 return
479 }
480 }
481
482 next.ServeHTTP(w, r)
483 })
484 }
485}