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