|
| 1 | +package api |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "net/http" |
| 6 | + "net/url" |
| 7 | + "strings" |
| 8 | + |
| 9 | + "github.com/gastownhall/wasteland/internal/sdk" |
| 10 | +) |
| 11 | + |
| 12 | +const ( |
| 13 | + localCacheUpstream = "local" |
| 14 | + publicCacheViewer = "anon" |
| 15 | +) |
| 16 | + |
| 17 | +type readIdentityContextKey string |
| 18 | + |
| 19 | +type readCacheScope struct { |
| 20 | + upstream string |
| 21 | + viewer string |
| 22 | + mode string |
| 23 | + impersonate string |
| 24 | + cacheable bool |
| 25 | +} |
| 26 | + |
| 27 | +// ResolvedReadIdentity is the canonical hosted read identity used for cache |
| 28 | +// scoping after middleware or public-read fallback resolves the active upstream |
| 29 | +// and viewer bucket. |
| 30 | +type ResolvedReadIdentity struct { |
| 31 | + Upstream string |
| 32 | + Viewer string |
| 33 | + Public bool |
| 34 | +} |
| 35 | + |
| 36 | +const resolvedReadIdentityContextKey readIdentityContextKey = "resolved-read-identity" |
| 37 | + |
| 38 | +// WithResolvedReadIdentity annotates a request context with the canonical |
| 39 | +// hosted upstream and viewer identity for downstream cache scoping. |
| 40 | +func WithResolvedReadIdentity(ctx context.Context, identity ResolvedReadIdentity) context.Context { |
| 41 | + identity.Upstream = canonicalHostedCacheUpstream(identity.Upstream) |
| 42 | + identity.Viewer = strings.TrimSpace(identity.Viewer) |
| 43 | + return context.WithValue(ctx, resolvedReadIdentityContextKey, identity) |
| 44 | +} |
| 45 | + |
| 46 | +// ResolvedReadIdentityFromContext returns the hosted read identity when one has |
| 47 | +// been established by middleware or the public-read fallback. |
| 48 | +func ResolvedReadIdentityFromContext(ctx context.Context) (ResolvedReadIdentity, bool) { |
| 49 | + identity, ok := ctx.Value(resolvedReadIdentityContextKey).(ResolvedReadIdentity) |
| 50 | + return identity, ok |
| 51 | +} |
| 52 | + |
| 53 | +func (s *Server) readCacheScope(r *http.Request, client *sdk.Client) readCacheScope { |
| 54 | + scope := readCacheScope{ |
| 55 | + mode: strings.TrimSpace(client.Mode()), |
| 56 | + impersonate: strings.TrimSpace(r.Header.Get("X-Impersonate")), |
| 57 | + cacheable: true, |
| 58 | + } |
| 59 | + |
| 60 | + if !s.hosted { |
| 61 | + scope.upstream = strings.TrimSpace(client.Upstream()) |
| 62 | + scope.viewer = strings.TrimSpace(client.RigHandle()) |
| 63 | + return normalizeReadCacheScope(scope, false) |
| 64 | + } |
| 65 | + |
| 66 | + if identity, ok := ResolvedReadIdentityFromContext(r.Context()); ok { |
| 67 | + if identity.Public { |
| 68 | + if identity.Upstream == "" { |
| 69 | + scope.cacheable = false |
| 70 | + return normalizeReadCacheScope(scope, true) |
| 71 | + } |
| 72 | + scope.upstream = identity.Upstream |
| 73 | + return normalizeReadCacheScope(scope, true) |
| 74 | + } |
| 75 | + if identity.Upstream == "" || identity.Viewer == "" { |
| 76 | + scope.cacheable = false |
| 77 | + return normalizeReadCacheScope(scope, true) |
| 78 | + } |
| 79 | + scope.upstream = identity.Upstream |
| 80 | + scope.viewer = identity.Viewer |
| 81 | + return normalizeReadCacheScope(scope, true) |
| 82 | + } |
| 83 | + |
| 84 | + // Hosted reads without canonical identity should not populate or reuse |
| 85 | + // shared cache entries. This fails closed on miswired handlers instead of |
| 86 | + // trusting request-derived client state. |
| 87 | + scope.cacheable = false |
| 88 | + return normalizeReadCacheScope(scope, true) |
| 89 | +} |
| 90 | + |
| 91 | +func normalizeReadCacheScope(scope readCacheScope, hosted bool) readCacheScope { |
| 92 | + if scope.upstream == "" { |
| 93 | + scope.upstream = localCacheUpstream |
| 94 | + } |
| 95 | + |
| 96 | + if hosted { |
| 97 | + scope.viewer = canonicalHostedCacheViewer(scope.viewer) |
| 98 | + } |
| 99 | + |
| 100 | + return scope |
| 101 | +} |
| 102 | + |
| 103 | +func browseCacheKey(scope readCacheScope, r *http.Request) string { |
| 104 | + return strings.Join([]string{ |
| 105 | + "browse", |
| 106 | + cacheKeyPart(scope.upstream), |
| 107 | + cacheKeyPart(scope.viewer), |
| 108 | + cacheKeyPart(scope.mode), |
| 109 | + cacheKeyPart(scope.impersonate), |
| 110 | + cacheKeyPart(canonicalBrowseKey(r)), |
| 111 | + }, ":") |
| 112 | +} |
| 113 | + |
| 114 | +func browseCachePrefix(upstream string) string { |
| 115 | + return strings.Join([]string{"browse", cacheKeyPart(upstream)}, ":") + ":" |
| 116 | +} |
| 117 | + |
| 118 | +func detailCacheKey(scope readCacheScope, wantedID string) string { |
| 119 | + return strings.Join([]string{ |
| 120 | + "detail", |
| 121 | + cacheKeyPart(scope.upstream), |
| 122 | + cacheKeyPart(scope.viewer), |
| 123 | + cacheKeyPart(scope.mode), |
| 124 | + cacheKeyPart(scope.impersonate), |
| 125 | + cacheKeyPart(wantedID), |
| 126 | + }, ":") |
| 127 | +} |
| 128 | + |
| 129 | +func detailCachePrefix(upstream string) string { |
| 130 | + return strings.Join([]string{"detail", cacheKeyPart(upstream)}, ":") + ":" |
| 131 | +} |
| 132 | + |
| 133 | +func detailCacheSuffix(wantedID string) string { |
| 134 | + return ":" + cacheKeyPart(wantedID) |
| 135 | +} |
| 136 | + |
| 137 | +func cacheKeyPart(value string) string { |
| 138 | + return url.QueryEscape(strings.TrimSpace(value)) |
| 139 | +} |
| 140 | + |
| 141 | +func canonicalHostedCacheUpstream(upstream string) string { |
| 142 | + upstream = strings.TrimSpace(upstream) |
| 143 | + switch upstream { |
| 144 | + case "hop/wl-commons": |
| 145 | + return "wasteland/wl-commons" |
| 146 | + default: |
| 147 | + return upstream |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +func canonicalHostedCacheViewer(viewer string) string { |
| 152 | + viewer = strings.TrimSpace(viewer) |
| 153 | + if viewer == "" { |
| 154 | + return publicCacheViewer |
| 155 | + } |
| 156 | + return "user:" + viewer |
| 157 | +} |
0 commit comments