Monorepo for Tangled tangled.org
6

Configure Feed

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

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