Monorepo for Tangled tangled.org
6

Configure Feed

Select the types of activity you want to include in your feed.

at icy/kxpzqo 14 kB View raw
1package middleware 2 3import ( 4 "context" 5 "database/sql" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "net/url" 11 "slices" 12 "strconv" 13 "strings" 14 15 "github.com/bluesky-social/indigo/atproto/identity" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 "github.com/go-chi/chi/v5" 18 "tangled.org/core/appview/cache" 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/knotacl" 21 "tangled.org/core/appview/models" 22 "tangled.org/core/appview/oauth" 23 "tangled.org/core/appview/pages" 24 "tangled.org/core/appview/pagination" 25 "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/state/userutil" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/orm" 29 "tangled.org/core/rbac" 30) 31 32type Middleware struct { 33 oauth *oauth.OAuth 34 db *db.DB 35 enforcer *rbac.Enforcer 36 acl *knotacl.Service 37 repoResolver *reporesolver.RepoResolver 38 idResolver *idresolver.Resolver 39 pages *pages.Pages 40 rdb *cache.Cache 41 logger *slog.Logger 42} 43 44func New(oauth *oauth.OAuth, db *db.DB, enforcer *rbac.Enforcer, acl *knotacl.Service, repoResolver *reporesolver.RepoResolver, idResolver *idresolver.Resolver, pages *pages.Pages, rdb *cache.Cache, logger *slog.Logger) Middleware { 45 return Middleware{ 46 oauth: oauth, 47 db: db, 48 enforcer: enforcer, 49 acl: acl, 50 repoResolver: repoResolver, 51 idResolver: idResolver, 52 pages: pages, 53 rdb: rdb, 54 logger: logger, 55 } 56} 57 58type middlewareFunc func(http.Handler) http.Handler 59 60func AuthMiddleware(o *oauth.OAuth) middlewareFunc { 61 return func(next http.Handler) http.Handler { 62 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 returnURL := "/" 64 if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 65 returnURL = u.RequestURI() 66 } 67 68 loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 69 70 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 71 http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 72 } 73 if r.Header.Get("HX-Request") == "true" { 74 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 75 w.Header().Set("HX-Redirect", loginURL) 76 w.WriteHeader(http.StatusOK) 77 } 78 } 79 80 sess, err := o.ResumeSession(r) 81 if err != nil { 82 slog.Default().Warn("failed to resume session, redirecting", "err", err, "url", r.URL.String()) 83 redirectFunc(w, r) 84 return 85 } 86 87 if sess == nil { 88 slog.Default().Warn("session is nil, redirecting") 89 redirectFunc(w, r) 90 return 91 } 92 93 next.ServeHTTP(w, r) 94 }) 95 } 96} 97 98func Paginate(next http.Handler) http.Handler { 99 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 100 page := pagination.FirstPage() 101 102 offsetVal := r.URL.Query().Get("offset") 103 if offsetVal != "" { 104 offset, err := strconv.Atoi(offsetVal) 105 if err != nil { 106 slog.Default().Warn("invalid offset", "value", offsetVal) 107 } else { 108 page.Offset = offset 109 } 110 } 111 112 limitVal := r.URL.Query().Get("limit") 113 if limitVal != "" { 114 limit, err := strconv.Atoi(limitVal) 115 if err != nil { 116 slog.Default().Warn("invalid limit", "value", limitVal) 117 } else { 118 page.Limit = limit 119 } 120 } 121 122 ctx := pagination.IntoContext(r.Context(), page) 123 next.ServeHTTP(w, r.WithContext(ctx)) 124 }) 125} 126 127func (mw Middleware) knotRoleMiddleware(group string) middlewareFunc { 128 return func(next http.Handler) http.Handler { 129 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 130 l := mw.logger.With("middleware", "knotRoleMiddleware") 131 // requires auth also 132 actor := mw.oauth.GetMultiAccountUser(r) 133 if actor == nil { 134 // we need a logged in user 135 l.Warn("not logged in, redirecting") 136 http.Error(w, "Forbidden", http.StatusUnauthorized) 137 return 138 } 139 domain := chi.URLParam(r, "domain") 140 if domain == "" { 141 http.Error(w, "malformed url", http.StatusBadRequest) 142 return 143 } 144 145 ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 146 if err != nil || !ok { 147 l.Warn("permission denied", "did", actor.Did, "group", group, "domain", domain) 148 http.Error(w, "Forbidden", http.StatusUnauthorized) 149 return 150 } 151 152 next.ServeHTTP(w, r) 153 }) 154 } 155} 156 157func (mw Middleware) KnotOwner() middlewareFunc { 158 return mw.knotRoleMiddleware("server:owner") 159} 160 161func (mw Middleware) RepoPermissionMiddleware(requiredPerm string) middlewareFunc { 162 return func(next http.Handler) http.Handler { 163 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 164 l := mw.logger.With("middleware", "RepoPermissionMiddleware") 165 // requires auth also 166 actor := mw.oauth.GetMultiAccountUser(r) 167 if actor == nil { 168 // we need a logged in user 169 l.Warn("not logged in, redirecting") 170 http.Error(w, "Forbidden", http.StatusUnauthorized) 171 return 172 } 173 f, err := mw.repoResolver.Resolve(r) 174 if err != nil { 175 http.Error(w, "malformed url", http.StatusBadRequest) 176 return 177 } 178 179 if !mw.acl.HasRepoPermission(r.Context(), f, actor.Did, requiredPerm) { 180 l.Warn("permission denied", "did", actor.Did, "perm", requiredPerm, "repo", f.RepoIdentifier()) 181 http.Error(w, "Forbidden", http.StatusUnauthorized) 182 return 183 } 184 185 next.ServeHTTP(w, r) 186 }) 187 } 188} 189 190func (mw Middleware) ResolveIdent() middlewareFunc { 191 excluded := []string{"favicon.ico"} 192 193 return func(next http.Handler) http.Handler { 194 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 195 origSeg := chi.URLParam(req, "user") 196 didOrHandle := strings.TrimPrefix(origSeg, "@") 197 198 if slices.Contains(excluded, didOrHandle) { 199 next.ServeHTTP(w, req) 200 return 201 } 202 203 id, err := mw.idResolver.ResolveAtIdentifier(req.Context(), didOrHandle) 204 if err != nil { 205 if h, parseErr := syntax.ParseHandle(didOrHandle); parseErr == nil { 206 if did := cache.LookupDidByPreferredHandle(req.Context(), mw.rdb, mw.db, h); did != "" { 207 id, err = mw.idResolver.ResolveAtIdentifier(req.Context(), did) 208 } 209 } 210 } 211 if err != nil { 212 mw.logger.Error("failed to resolve did/handle", "didOrHandle", didOrHandle, "err", err) 213 mw.pages.Error404(w) 214 return 215 } 216 217 if req.Method == http.MethodGet && !userutil.IsDid(didOrHandle) { 218 if pref := cache.LookupPreferredHandle(req.Context(), mw.rdb, mw.db, id.DID.String()); pref != "" && didOrHandle != pref { 219 rest := strings.TrimPrefix(req.URL.Path, "/"+origSeg) 220 target := "/" + pref + rest 221 if req.URL.RawQuery != "" { 222 target += "?" + req.URL.RawQuery 223 } 224 http.Redirect(w, req, target, http.StatusFound) 225 return 226 } 227 } 228 229 ctx := context.WithValue(req.Context(), "resolvedId", *id) 230 231 next.ServeHTTP(w, req.WithContext(ctx)) 232 }) 233 } 234} 235 236func (mw Middleware) ResolveRepo() middlewareFunc { 237 return func(next http.Handler) http.Handler { 238 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 239 l := mw.logger.With("middleware", "ResolveRepo") 240 repoName := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git") 241 rkey := strings.ToLower(repoName) 242 243 id, ok := req.Context().Value("resolvedId").(identity.Identity) 244 if !ok { 245 l.Error("malformed middleware") 246 w.WriteHeader(http.StatusInternalServerError) 247 return 248 } 249 250 repo, isRename := resolveRepoForOwner(mw.db, id.DID.String(), repoName, rkey, l) 251 if repo == nil { 252 w.WriteHeader(http.StatusNotFound) 253 mw.pages.ErrorKnot404(w) 254 return 255 } 256 if isRename { 257 handle := id.Handle.String() 258 if id.Handle.IsInvalidHandle() || handle == "" { 259 handle = id.DID.String() 260 } 261 target := reporesolver.CanonicalRedirectTarget(req, reporesolver.CanonicalRepoPath(handle, repo)) 262 http.Redirect(w, req, target, http.StatusMovedPermanently) 263 return 264 } 265 266 ctx := context.WithValue(req.Context(), "repo", repo) 267 next.ServeHTTP(w, req.WithContext(ctx)) 268 }) 269 } 270} 271 272func resolveRepoForOwner(d db.Execer, ownerDid, repoName, rkey string, l *slog.Logger) (*models.Repo, bool) { 273 repo, err := db.GetRepo(d, orm.FilterEq("did", ownerDid), orm.FilterEq("rkey", rkey)) 274 if err == nil { 275 return repo, false 276 } 277 if !errors.Is(err, sql.ErrNoRows) { 278 l.Error("failed to resolve repo by rkey", "err", err) 279 return nil, false 280 } 281 282 hint, hintErr := db.LookupRepoRename(d, ownerDid, rkey) 283 if hintErr != nil && !errors.Is(hintErr, sql.ErrNoRows) { 284 l.Error("failed to lookup repo rename hint", "err", hintErr) 285 } 286 if hint != nil { 287 return hint, true 288 } 289 290 nameRepos, nameErr := db.GetRepos(d, orm.FilterEq("did", ownerDid), orm.FilterEq("name", repoName)) 291 if nameErr != nil { 292 l.Error("failed to resolve repo by name", "err", nameErr) 293 return nil, false 294 } 295 if len(nameRepos) == 1 { 296 return &nameRepos[0], false 297 } 298 return nil, false 299} 300 301func (mw Middleware) CanonicalizeRepoURL() middlewareFunc { 302 return func(next http.Handler) http.Handler { 303 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 304 if req.Method != http.MethodGet && req.Method != http.MethodHead { 305 next.ServeHTTP(w, req) 306 return 307 } 308 id, idOk := req.Context().Value("resolvedId").(identity.Identity) 309 repo, repoOk := req.Context().Value("repo").(*models.Repo) 310 if !idOk || !repoOk || id.Handle.IsInvalidHandle() { 311 next.ServeHTTP(w, req) 312 return 313 } 314 handle := id.Handle.String() 315 if handle == "" { 316 next.ServeHTTP(w, req) 317 return 318 } 319 canonical := reporesolver.CanonicalRepoPath(handle, repo) 320 urlUser := chi.URLParam(req, "user") 321 urlRepo := strings.TrimSuffix(chi.URLParam(req, "repo"), ".git") 322 if urlUser+"/"+urlRepo == canonical { 323 next.ServeHTTP(w, req) 324 return 325 } 326 327 http.Redirect(w, req, reporesolver.CanonicalRedirectTarget(req, canonical), http.StatusFound) 328 }) 329 } 330} 331 332// middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 333func (mw Middleware) ResolvePull() middlewareFunc { 334 return func(next http.Handler) http.Handler { 335 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 336 l := mw.logger.With("middleware", "ResolvePull") 337 f, err := mw.repoResolver.Resolve(r) 338 if err != nil { 339 l.Error("failed to fully resolve repo", "err", err) 340 w.WriteHeader(http.StatusNotFound) 341 mw.pages.ErrorKnot404(w) 342 return 343 } 344 345 prId := chi.URLParam(r, "pull") 346 prIdInt, err := strconv.Atoi(prId) 347 if err != nil { 348 l.Error("failed to parse pr id", "err", err) 349 mw.pages.Error404(w) 350 return 351 } 352 353 pr, err := db.GetPull(mw.db, orm.FilterEq("repo_did", f.RepoDid), orm.FilterEq("pull_id", prIdInt)) 354 if err != nil { 355 l.Error("failed to get pull and comments", "err", err) 356 mw.pages.Error404(w) 357 return 358 } 359 360 ctx := context.WithValue(r.Context(), "pull", pr) 361 362 stack, err := db.GetStack(mw.db, pr.AtUri()) 363 if err != nil { 364 l.Error("failed to get stack", "err", err) 365 mw.pages.Error404(w) 366 return 367 } 368 369 ctx = context.WithValue(ctx, "stack", stack) 370 371 next.ServeHTTP(w, r.WithContext(ctx)) 372 }) 373 } 374} 375 376// middleware that is tacked on top of /{user}/{repo}/issues/{issue} 377func (mw Middleware) ResolveIssue(next http.Handler) http.Handler { 378 l := mw.logger.With("middleware", "ResolveIssue") 379 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 380 f, err := mw.repoResolver.Resolve(r) 381 if err != nil { 382 l.Error("failed to fully resolve repo", "err", err) 383 w.WriteHeader(http.StatusNotFound) 384 mw.pages.ErrorKnot404(w) 385 return 386 } 387 388 issueIdStr := chi.URLParam(r, "issue") 389 issueId, err := strconv.Atoi(issueIdStr) 390 if err != nil { 391 l.Error("failed to fully resolve issue ID", "err", err) 392 mw.pages.Error404(w) 393 return 394 } 395 396 issue, err := db.GetIssue(mw.db, f.RepoDid, issueId) 397 if err != nil { 398 l.Error("failed to get issues", "err", err) 399 mw.pages.Error404(w) 400 return 401 } 402 403 ctx := context.WithValue(r.Context(), "issue", issue) 404 next.ServeHTTP(w, r.WithContext(ctx)) 405 }) 406} 407 408// this should serve the go-import meta tag even if the path is technically 409// a 404 like tangled.sh/oppi.li/go-git/v5 410// 411// we're keeping the tangled.sh go-import tag too to maintain backward 412// compatibility for modules that still point there. they will be redirected 413// to fetch source from tangled.org 414func (mw Middleware) GoImport() middlewareFunc { 415 return func(next http.Handler) http.Handler { 416 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 417 l := mw.logger.With("middleware", "GoImport") 418 f, err := mw.repoResolver.Resolve(r) 419 if err != nil { 420 l.Error("failed to fully resolve repo", "err", err) 421 w.WriteHeader(http.StatusNotFound) 422 mw.pages.ErrorKnot404(w) 423 return 424 } 425 426 fullName := reporesolver.GetBaseRepoPath(r, f) 427 428 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 429 if r.URL.Query().Get("go-get") == "1" { 430 modulePath := userutil.FlattenDid(fullName) 431 if strings.Contains(modulePath, ":") { 432 modulePath = userutil.FlattenDid(f.Did) + "/" + f.Rkey 433 } 434 tags := []string{ 435 fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, modulePath, fullName), 436 fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, modulePath, fullName), 437 } 438 if f.RepoDid != "" { 439 stable := userutil.FlattenDid(f.RepoDid) 440 if stable != modulePath { 441 tags = append(tags, 442 fmt.Sprintf(`<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, stable, f.RepoDid), 443 fmt.Sprintf(`<meta name="go-import" content="tangled.org/%s git https://tangled.org/%s"/>`, stable, f.RepoDid), 444 ) 445 } 446 } 447 w.Header().Set("Content-Type", "text/html") 448 w.Write([]byte(strings.Join(tags, "\n"))) 449 return 450 } 451 } 452 453 next.ServeHTTP(w, r) 454 }) 455 } 456}