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