Skip to content

Commit f5ac098

Browse files
committed
feat: support Ipfs-Path-Affinity from IPIP-462
This is first stab at leveraging these hints withing existing boxo/gateway codebase. It is pretty blunt, but will enable smart clients fetching sub-DAGs to work around any content routing gaps For more info and header semantics see ipfs/specs#462
1 parent b101ba0 commit f5ac098

File tree

1 file changed

+100
-1
lines changed

1 file changed

+100
-1
lines changed

gateway/handler.go

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ func (i *handler) getOrHeadHandler(w http.ResponseWriter, r *http.Request) {
233233
return
234234
}
235235

236+
i.handlePathAffinityHints(w, r, contentPath, logger)
237+
236238
// Detect when explicit Accept header or ?format parameter are present
237239
responseFormat, formatParams, err := customResponseFormat(r)
238240
if err != nil {
@@ -752,7 +754,7 @@ func (i *handler) handleWebRequestErrors(w http.ResponseWriter, r *http.Request,
752754
}
753755

754756
// Detect 'Cache-Control: only-if-cached' in request and return data if it is already in the local datastore.
755-
// https://github.com/ipfs/specs/blob/main/http-gateways/PATH_GATEWAY.md#cache-control-request-header
757+
// https://specs.ipfs.tech/http-gateways/path-gateway/#cache-control-request-header
756758
func (i *handler) handleOnlyIfCached(w http.ResponseWriter, r *http.Request, contentPath path.Path) bool {
757759
if r.Header.Get("Cache-Control") == "only-if-cached" {
758760
if !i.backend.IsCached(r.Context(), contentPath) {
@@ -887,6 +889,103 @@ func (i *handler) handleSuperfluousNamespace(w http.ResponseWriter, r *http.Requ
887889
return true
888890
}
889891

892+
// Detect 'Ipfs-Path-Affinity' (IPIP-462) headers in request and use values as a content
893+
// routing hints if passed paths are not already in the local datastore.
894+
// These optional hints are mostly useful for trustless block requests.
895+
// See https://github.com/ipfs/specs/pull/462
896+
func (i *handler) handlePathAffinityHints(w http.ResponseWriter, r *http.Request, contentPath path.Path, logger *zap.SugaredLogger) {
897+
headerName := "Ipfs-Path-Affinity"
898+
// Skip if no header
899+
if r.Header.Get(headerName) == "" {
900+
return
901+
}
902+
// Skip if contentPath is already locally cached
903+
if i.backend.IsCached(r.Context(), contentPath) {
904+
return
905+
}
906+
// Check canonical header name
907+
// NOTE: we don't use r.Header.Get() because client can send this header more than once
908+
headerValues := r.Header[headerName]
909+
// If not found, try lowercase version.
910+
// NOTE: this is done manually because direct key access does not come with canonicalization, like Header.Get() does
911+
if len(headerValues) == 0 {
912+
headerValues = r.Header[strings.ToLower(headerName)]
913+
}
914+
915+
// Limit the headerValues to the first 3 items (abuse protection)
916+
if len(headerValues) > 3 {
917+
headerValues = headerValues[:3]
918+
}
919+
920+
// Process affinity hints
921+
for _, headerValue := range headerValues {
922+
// Non-ascii paths are percent-encoded.
923+
// Decode if the value starts with %2F (percent-encoded '/')
924+
if strings.HasPrefix(headerValue, "%2F") {
925+
decodedValue, err := url.PathUnescape(headerValue)
926+
if err != nil {
927+
logger.Debugw("skipping invalid Ipfs-Path-Affinity hint", "error", err)
928+
continue
929+
}
930+
headerValue = decodedValue
931+
}
932+
// Confirm it is a valid content path
933+
affinityPath, err := path.NewPath(headerValue)
934+
if err != nil {
935+
logger.Debugw("skipping invalid Ipfs-Path-Affinity hint", "error", err)
936+
continue
937+
}
938+
939+
// Skip duplicated work if immutable affinity hint is a subset of requested immutable contentPath
940+
// (protect against broken clients that use affinity incorrectly)
941+
if !contentPath.Mutable() && !affinityPath.Mutable() && strings.HasPrefix(contentPath.String(), affinityPath.String()) {
942+
logger.Debugw("skipping redundant Ipfs-Path-Affinity hint", "affinity", affinityPath)
943+
continue
944+
}
945+
946+
// Process hint in background without blocking response logic for contentPath
947+
go func(contentPath path.Path, affinityPath path.Path, logger *zap.SugaredLogger) {
948+
var immutableAffinityPath path.ImmutablePath
949+
logger.Debugw("async processing of Ipfs-Path-Affinity hint", "affinity", affinityPath)
950+
if affinityPath.Mutable() {
951+
// Skip work if mutable affinity hint is a subset of mutable contentPath
952+
if contentPath.Mutable() && strings.HasPrefix(contentPath.String(), affinityPath.String()) {
953+
logger.Debugw("skipping redundant Ipfs-Path-Affinity hint", "affinity", affinityPath)
954+
return
955+
}
956+
immutableAffinityPath, _, _, err = i.backend.ResolveMutable(r.Context(), affinityPath)
957+
if err != nil {
958+
logger.Debugw("error while resolving mutable Ipfs-Path-Affinity hint", "affinity", affinityPath, "error", err)
959+
return
960+
}
961+
} else {
962+
ipath, ok := affinityPath.(path.ImmutablePath)
963+
if !ok {
964+
return
965+
}
966+
immutableAffinityPath = ipath
967+
}
968+
// Skip if affinity path is already cached
969+
if !i.backend.IsCached(r.Context(), immutableAffinityPath) {
970+
// The intention of below code is to asynchronously preconnect
971+
// gateway with providers of the affinityPath in
972+
// Ipfs-Path-Affinity hint. Once connected, these peers can be
973+
// asked directly (via mechanism like bitswap) for blocks
974+
// related to main request for contentPath, and retrieve them,
975+
// even when no other routing system had them announced. If
976+
// original contentPath was received and returned to HTTP
977+
// client before below get is done, the work is cancelled.
978+
979+
logger.Debugw("started async search for providers of Ipfs-Path-Affinity hint", "affinity", affinityPath)
980+
_, _, err = i.backend.GetBlock(r.Context(), immutableAffinityPath)
981+
logger.Debugw("ended async search for providers of Ipfs-Path-Affinity hint", "affinity", affinityPath, "error", err)
982+
} else {
983+
logger.Debugw("skipping Ipfs-Path-Affinity hint due to data being locally cached", "affinity", affinityPath)
984+
}
985+
}(contentPath, affinityPath, logger)
986+
}
987+
}
988+
890989
// getTemplateGlobalData returns the global data necessary by most templates.
891990
func (i *handler) getTemplateGlobalData(r *http.Request, contentPath path.Path) assets.GlobalData {
892991
// gatewayURL is used to link to other root CIDs. THis will be blank unless

0 commit comments

Comments
 (0)