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