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