This repository has no description
0

Configure Feed

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

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