Monorepo for Tangled tangled.org
9

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