Monorepo for Tangled
at master 376 lines 10 kB view raw
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}