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