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