1package middleware
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "net/http"
8 "slices"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/bluesky-social/indigo/atproto/identity"
14 "github.com/go-chi/chi/v5"
15 "tangled.sh/tangled.sh/core/appview/db"
16 "tangled.sh/tangled.sh/core/appview/idresolver"
17 "tangled.sh/tangled.sh/core/appview/oauth"
18 "tangled.sh/tangled.sh/core/appview/pages"
19 "tangled.sh/tangled.sh/core/appview/pagination"
20 "tangled.sh/tangled.sh/core/appview/reporesolver"
21 "tangled.sh/tangled.sh/core/rbac"
22)
23
24type Middleware struct {
25 oauth *oauth.OAuth
26 db *db.DB
27 enforcer *rbac.Enforcer
28 repoResolver *reporesolver.RepoResolver
29 idResolver *idresolver.Resolver
30 pages *pages.Pages
31}
32
33func New(oauth *oauth.OAuth, db *db.DB, enforcer *rbac.Enforcer, repoResolver *reporesolver.RepoResolver, idResolver *idresolver.Resolver, pages *pages.Pages) Middleware {
34 return Middleware{
35 oauth: oauth,
36 db: db,
37 enforcer: enforcer,
38 repoResolver: repoResolver,
39 idResolver: idResolver,
40 pages: pages,
41 }
42}
43
44type middlewareFunc func(http.Handler) http.Handler
45
46func AuthMiddleware(a *oauth.OAuth) middlewareFunc {
47 return func(next http.Handler) http.Handler {
48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49 redirectFunc := func(w http.ResponseWriter, r *http.Request) {
50 http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
51 }
52 if r.Header.Get("HX-Request") == "true" {
53 redirectFunc = func(w http.ResponseWriter, _ *http.Request) {
54 w.Header().Set("HX-Redirect", "/login")
55 w.WriteHeader(http.StatusOK)
56 }
57 }
58
59 _, auth, err := a.GetSession(r)
60 if err != nil {
61 log.Println("not logged in, redirecting", "err", err)
62 redirectFunc(w, r)
63 return
64 }
65
66 if !auth {
67 log.Printf("not logged in, redirecting")
68 redirectFunc(w, r)
69 return
70 }
71
72 next.ServeHTTP(w, r)
73 })
74 }
75}
76
77func Paginate(next http.Handler) http.Handler {
78 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
79 page := pagination.FirstPage()
80
81 offsetVal := r.URL.Query().Get("offset")
82 if offsetVal != "" {
83 offset, err := strconv.Atoi(offsetVal)
84 if err != nil {
85 log.Println("invalid offset")
86 } else {
87 page.Offset = offset
88 }
89 }
90
91 limitVal := r.URL.Query().Get("limit")
92 if limitVal != "" {
93 limit, err := strconv.Atoi(limitVal)
94 if err != nil {
95 log.Println("invalid limit")
96 } else {
97 page.Limit = limit
98 }
99 }
100
101 ctx := context.WithValue(r.Context(), "page", page)
102 next.ServeHTTP(w, r.WithContext(ctx))
103 })
104}
105
106func (mw Middleware) knotRoleMiddleware(group string) middlewareFunc {
107 return func(next http.Handler) http.Handler {
108 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109 // requires auth also
110 actor := mw.oauth.GetUser(r)
111 if actor == nil {
112 // we need a logged in user
113 log.Printf("not logged in, redirecting")
114 http.Error(w, "Forbiden", http.StatusUnauthorized)
115 return
116 }
117 domain := chi.URLParam(r, "domain")
118 if domain == "" {
119 http.Error(w, "malformed url", http.StatusBadRequest)
120 return
121 }
122
123 ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain)
124 if err != nil || !ok {
125 // we need a logged in user
126 log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain)
127 http.Error(w, "Forbiden", http.StatusUnauthorized)
128 return
129 }
130
131 next.ServeHTTP(w, r)
132 })
133 }
134}
135
136func (mw Middleware) KnotOwner() middlewareFunc {
137 return mw.knotRoleMiddleware("server:owner")
138}
139
140func (mw Middleware) RepoPermissionMiddleware(requiredPerm string) middlewareFunc {
141 return func(next http.Handler) http.Handler {
142 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143 // requires auth also
144 actor := mw.oauth.GetUser(r)
145 if actor == nil {
146 // we need a logged in user
147 log.Printf("not logged in, redirecting")
148 http.Error(w, "Forbiden", http.StatusUnauthorized)
149 return
150 }
151 f, err := mw.repoResolver.Resolve(r)
152 if err != nil {
153 http.Error(w, "malformed url", http.StatusBadRequest)
154 return
155 }
156
157 ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm)
158 if err != nil || !ok {
159 // we need a logged in user
160 log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo())
161 http.Error(w, "Forbiden", http.StatusUnauthorized)
162 return
163 }
164
165 next.ServeHTTP(w, r)
166 })
167 }
168}
169
170func StripLeadingAt(next http.Handler) http.Handler {
171 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
172 path := req.URL.EscapedPath()
173 if strings.HasPrefix(path, "/@") {
174 req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@")
175 }
176 next.ServeHTTP(w, req)
177 })
178}
179
180func (mw Middleware) ResolveIdent() middlewareFunc {
181 excluded := []string{"favicon.ico"}
182
183 return func(next http.Handler) http.Handler {
184 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
185 didOrHandle := chi.URLParam(req, "user")
186 if slices.Contains(excluded, didOrHandle) {
187 next.ServeHTTP(w, req)
188 return
189 }
190
191 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
192 if err != nil {
193 // invalid did or handle
194 log.Println("failed to resolve did/handle:", err)
195 w.WriteHeader(http.StatusNotFound)
196 return
197 }
198
199 ctx := context.WithValue(req.Context(), "resolvedId", *id)
200
201 next.ServeHTTP(w, req.WithContext(ctx))
202 })
203 }
204}
205
206func (mw Middleware) ResolveRepo() middlewareFunc {
207 return func(next http.Handler) http.Handler {
208 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
209 repoName := chi.URLParam(req, "repo")
210 id, ok := req.Context().Value("resolvedId").(identity.Identity)
211 if !ok {
212 log.Println("malformed middleware")
213 w.WriteHeader(http.StatusInternalServerError)
214 return
215 }
216
217 repo, err := db.GetRepo(mw.db, id.DID.String(), repoName)
218 if err != nil {
219 // invalid did or handle
220 log.Println("failed to resolve repo")
221 mw.pages.Error404(w)
222 return
223 }
224
225 ctx := context.WithValue(req.Context(), "knot", repo.Knot)
226 ctx = context.WithValue(ctx, "repoAt", repo.AtUri)
227 ctx = context.WithValue(ctx, "repoDescription", repo.Description)
228 ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339))
229 next.ServeHTTP(w, req.WithContext(ctx))
230 })
231 }
232}
233
234// middleware that is tacked on top of /{user}/{repo}/pulls/{pull}
235func (mw Middleware) ResolvePull() middlewareFunc {
236 return func(next http.Handler) http.Handler {
237 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
238 f, err := mw.repoResolver.Resolve(r)
239 if err != nil {
240 log.Println("failed to fully resolve repo", err)
241 http.Error(w, "invalid repo url", http.StatusNotFound)
242 return
243 }
244
245 prId := chi.URLParam(r, "pull")
246 prIdInt, err := strconv.Atoi(prId)
247 if err != nil {
248 http.Error(w, "bad pr id", http.StatusBadRequest)
249 log.Println("failed to parse pr id", err)
250 return
251 }
252
253 pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt)
254 if err != nil {
255 log.Println("failed to get pull and comments", err)
256 return
257 }
258
259 ctx := context.WithValue(r.Context(), "pull", pr)
260
261 if pr.IsStacked() {
262 stack, err := db.GetStack(mw.db, pr.StackId)
263 if err != nil {
264 log.Println("failed to get stack", err)
265 return
266 }
267 abandonedPulls, err := db.GetAbandonedPulls(mw.db, pr.StackId)
268 if err != nil {
269 log.Println("failed to get abandoned pulls", err)
270 return
271 }
272
273 ctx = context.WithValue(ctx, "stack", stack)
274 ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls)
275 }
276
277 next.ServeHTTP(w, r.WithContext(ctx))
278 })
279 }
280}
281
282// this should serve the go-import meta tag even if the path is technically
283// a 404 like tangled.sh/oppi.li/go-git/v5
284func (mw Middleware) GoImport() middlewareFunc {
285 return func(next http.Handler) http.Handler {
286 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
287 f, err := mw.repoResolver.Resolve(r)
288 if err != nil {
289 log.Println("failed to fully resolve repo", err)
290 http.Error(w, "invalid repo url", http.StatusNotFound)
291 return
292 }
293
294 fullName := f.OwnerHandle() + "/" + f.RepoName
295
296 if r.Header.Get("User-Agent") == "Go-http-client/1.1" {
297 if r.URL.Query().Get("go-get") == "1" {
298 html := fmt.Sprintf(
299 `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`,
300 fullName,
301 fullName,
302 )
303 w.Header().Set("Content-Type", "text/html")
304 w.Write([]byte(html))
305 return
306 }
307 }
308
309 next.ServeHTTP(w, r)
310 })
311 }
312}