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