Monorepo for Tangled tangled.org
6

Configure Feed

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

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