Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 0b37381

Browse files
dschmidtrhafer
authored andcommitted
feat(graph): add MS Graph colon-syntax path lookup middleware
Adds a chi middleware that detects MS Graph colon-syntax URLs and rewrites them to the canonical /items/{itemID}/... form before chi performs route matching. Existing handlers, routes, and GetDriveAndItemIDParam stay unchanged. Two URL shapes are recognized at both /v1.0 and /v1beta1: /drives/{driveID}/root:/<path>[:/<suffix>][:] /drives/{driveID}/items/{itemID}:/<relativePath>[:/<suffix>][:] Path resolution runs as the request user via CS3 Stat. Both NOT_FOUND and PERMISSION_DENIED collapse to a 404 response so existence isn't disclosed to unauthorized callers. URLs without colon syntax fast-path through with a single substring check. The original URL is stashed in request context under OriginalPathContextKey for downstream tracing/logging. The middleware is registered as a top-level mux.Use so it runs before any route matching: chi middleware on a sub-router runs after the prefix is matched but cannot redirect to a different leaf route. Top-level middleware lets URL rewriting actually re-route the request. Tests cover regex matching across versions, all rewrite variants (root/items anchored, with/without suffix, with/without trailing colon, deep paths), NOT_FOUND -> 404, PERMISSION_DENIED -> 404 (security: no existence disclosure), and original-URL preservation in request context.
1 parent 93fb9cb commit 0b37381

3 files changed

Lines changed: 422 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/url"
7+
"regexp"
8+
"strings"
9+
10+
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
11+
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
12+
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
13+
14+
"github.com/rs/zerolog"
15+
16+
"github.com/opencloud-eu/opencloud/pkg/log"
17+
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
18+
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
19+
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
20+
"github.com/opencloud-eu/reva/v2/pkg/utils"
21+
)
22+
23+
// {HTTP.Root}/{version}/drives/{driveID}/root:/<path>[:/<suffix>][:]
24+
// where {version} is "v1.0" or "v1beta1" — preserves the requested version
25+
// so it lands on a registered route.
26+
// group 1: prefix up to and including the driveID
27+
// group 2: driveID (URL-encoded)
28+
// group 3: path (with leading slash)
29+
// group 4: suffix (with leading slash) — empty for direct item lookup
30+
var rootColonRe = regexp.MustCompile(`^(.*?/v1(?:\.0|beta1)/drives/([^/]+))/root:(/.+?)(?::(/[^:]+))?:?$`)
31+
32+
// {HTTP.Root}/{version}/drives/{driveID}/items/{itemID}:/<path>[:/<suffix>][:]
33+
// group 1: prefix up to and including the driveID
34+
// group 2: driveID (URL-encoded)
35+
// group 3: anchor itemID (URL-encoded)
36+
// group 4: path (with leading slash)
37+
// group 5: suffix (with leading slash)
38+
var itemColonRe = regexp.MustCompile(`^(.*?/v1(?:\.0|beta1)/drives/([^/]+))/items/([^/]+):(/.+?)(?::(/[^:]+))?:?$`)
39+
40+
type contextKey string
41+
42+
// OriginalPathContextKey holds the pre-rewrite request path for downstream
43+
// tracing/logging consumers.
44+
const OriginalPathContextKey contextKey = "graph.original_path"
45+
46+
// ResolveGraphPath returns middleware that detects MS Graph colon-syntax
47+
// path lookup URLs and rewrites them to the canonical
48+
// /v1beta1/drives/{driveID}/items/{resolvedItemID}/{suffix} form before
49+
// chi performs route matching.
50+
//
51+
// Two URL shapes are recognized:
52+
//
53+
// /v1beta1/drives/{driveID}/root:/<path>[:/<suffix>][:]
54+
// /v1beta1/drives/{driveID}/items/{itemID}:/<path>[:/<suffix>][:]
55+
//
56+
// Path resolution runs as the request user via CS3 Stat. Both NOT_FOUND
57+
// and PERMISSION_DENIED collapse to a 404 response so existence isn't
58+
// disclosed to unauthorized callers.
59+
//
60+
// URLs without colon syntax fast-path through the middleware with a
61+
// single substring check.
62+
func ResolveGraphPath(gws pool.Selectable[gateway.GatewayAPIClient], logger log.Logger) func(http.Handler) http.Handler {
63+
l := logger.With().Str("middleware", "graphPathLookup").Logger()
64+
return func(next http.Handler) http.Handler {
65+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66+
// Fast-path: skip URLs that can't be colon-syntax
67+
if !strings.Contains(r.URL.Path, ":") {
68+
next.ServeHTTP(w, r)
69+
return
70+
}
71+
72+
original := r.URL.Path
73+
rewritten, matched := rewriteColonPath(r.Context(), gws, l, original)
74+
if !matched {
75+
next.ServeHTTP(w, r)
76+
return
77+
}
78+
if rewritten == "" {
79+
// Resolution failed: not found, permission denied, or invalid input.
80+
// Collapse to 404 to avoid disclosing existence.
81+
l.Debug().Str("original", original).Msg("colon-path resolution failed; returning 404")
82+
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "item not found")
83+
return
84+
}
85+
86+
l.Debug().
87+
Str("original", original).
88+
Str("rewritten", rewritten).
89+
Msg("rewrote MS Graph colon-syntax path")
90+
91+
// Stash original URL for downstream logging/tracing.
92+
ctx := context.WithValue(r.Context(), OriginalPathContextKey, original)
93+
r = r.WithContext(ctx)
94+
r.URL.Path = rewritten
95+
r.URL.RawPath = ""
96+
next.ServeHTTP(w, r)
97+
})
98+
}
99+
}
100+
101+
// rewriteColonPath returns:
102+
// - rewritten URL + true when the URL matched a colon-syntax pattern and resolution succeeded
103+
// - "" + true when the URL matched a colon-syntax pattern but resolution failed (caller should 404)
104+
// - "" + false when the URL did not match any colon-syntax pattern
105+
func rewriteColonPath(ctx context.Context, gws pool.Selectable[gateway.GatewayAPIClient], logger zerolog.Logger, urlPath string) (string, bool) {
106+
if m := rootColonRe.FindStringSubmatch(urlPath); m != nil {
107+
prefix, driveIDStr, relPath, suffix := m[1], m[2], m[3], m[4]
108+
driveID, err := decodeAndParseID(driveIDStr)
109+
if err != nil {
110+
logger.Debug().Err(err).Str("driveID", driveIDStr).Msg("invalid driveID in colon path")
111+
return "", true
112+
}
113+
itemID, ok := resolvePath(ctx, gws, logger, &driveID, relPath)
114+
if !ok {
115+
return "", true
116+
}
117+
return buildCanonicalPath(prefix, itemID, suffix), true
118+
}
119+
120+
if m := itemColonRe.FindStringSubmatch(urlPath); m != nil {
121+
prefix, _, anchorIDStr, relPath, suffix := m[1], m[2], m[3], m[4], m[5]
122+
anchorID, err := decodeAndParseID(anchorIDStr)
123+
if err != nil {
124+
logger.Debug().Err(err).Str("itemID", anchorIDStr).Msg("invalid item anchor in colon path")
125+
return "", true
126+
}
127+
itemID, ok := resolvePath(ctx, gws, logger, &anchorID, relPath)
128+
if !ok {
129+
return "", true
130+
}
131+
return buildCanonicalPath(prefix, itemID, suffix), true
132+
}
133+
134+
return "", false
135+
}
136+
137+
func resolvePath(ctx context.Context, gws pool.Selectable[gateway.GatewayAPIClient], logger zerolog.Logger, anchor *storageprovider.ResourceId, rawPath string) (string, bool) {
138+
gw, err := gws.Next()
139+
if err != nil {
140+
logger.Error().Err(err).Msg("could not select gateway client")
141+
return "", false
142+
}
143+
144+
decoded, err := url.PathUnescape(rawPath)
145+
if err != nil {
146+
logger.Debug().Err(err).Str("path", rawPath).Msg("failed to URL-decode path segment")
147+
return "", false
148+
}
149+
150+
statRes, err := gw.Stat(ctx, &storageprovider.StatRequest{
151+
Ref: &storageprovider.Reference{
152+
ResourceId: anchor,
153+
Path: utils.MakeRelativePath(decoded),
154+
},
155+
})
156+
if err != nil {
157+
logger.Error().Err(err).Msg("Stat call failed during colon-path resolution")
158+
return "", false
159+
}
160+
161+
switch statRes.GetStatus().GetCode() {
162+
case cs3rpc.Code_CODE_OK:
163+
// fall through
164+
case cs3rpc.Code_CODE_NOT_FOUND, cs3rpc.Code_CODE_PERMISSION_DENIED:
165+
// Both collapse to "not found" — never tell unauthorized callers
166+
// that the resource exists.
167+
return "", false
168+
default:
169+
logger.Debug().
170+
Str("code", statRes.GetStatus().GetCode().String()).
171+
Str("message", statRes.GetStatus().GetMessage()).
172+
Msg("unexpected Stat status during colon-path resolution")
173+
return "", false
174+
}
175+
176+
id := statRes.GetInfo().GetId()
177+
if id == nil {
178+
return "", false
179+
}
180+
return storagespace.FormatResourceID(id), true
181+
}
182+
183+
func decodeAndParseID(s string) (storageprovider.ResourceId, error) {
184+
decoded, err := url.PathUnescape(s)
185+
if err != nil {
186+
return storageprovider.ResourceId{}, err
187+
}
188+
return storagespace.ParseID(decoded)
189+
}
190+
191+
func buildCanonicalPath(prefix, itemID, suffix string) string {
192+
// r.URL.Path is the decoded form per Go's net/http convention; chi tree
193+
// matching reads it directly. Insert the raw itemID without escaping so
194+
// chi's {itemID} param captures the same string the handler will see
195+
// after parseIDParam unescapes (no-op for already-decoded chars).
196+
return prefix + "/items/" + itemID + suffix
197+
}

0 commit comments

Comments
 (0)