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