Skip to content

Commit 9b45c97

Browse files
namedgraphclaude
andcommitted
Fix external URI proxy bypass and client-side rendering
- ProxyRequestFilter: use Core-only MediaTypes (no HTML) with combined Model+ResultSet writable variant list; selectVariant==null is the sole bypass signal so Accept:*/* correctly reaches the proxy instead of falling through to the HTML handler - Thread pre-computed Variant through all getResponse() overloads to avoid a second selectVariant call inside Core's Response constructor - client.xsl onsubmit: skip the XHTML round-trip for external URIs and call PushState + RDFDocumentLoad directly, advancing the progress bar to 66% between the two steps; fixes the double-click issue - client.xsl ldh:rdf-document-response: respect the #layout-modes mode selector for client-side rendered external resources; refactor the duplicate id('content-body') lookup out of both xsl:choose branches - ProxyRequestFilterTest: stub Request.selectVariant() to return a non-null Variant so both tests reach the logic they exercise Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e99d9e3 commit 9b45c97

File tree

3 files changed

+118
-78
lines changed

3 files changed

+118
-78
lines changed

src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ public class ProxyRequestFilter implements ContainerRequestFilter
9393
private static final Logger log = LoggerFactory.getLogger(ProxyRequestFilter.class);
9494

9595
@Inject com.atomgraph.linkeddatahub.Application system;
96-
@Inject MediaTypes mediaTypes;
9796
@Inject jakarta.inject.Provider<Optional<Ontology>> ontology;
9897
@Context Request request;
9998

@@ -105,17 +104,16 @@ public void filter(ContainerRequestContext requestContext) throws IOException
105104

106105
URI targetURI = targetOpt.get();
107106

108-
// do not proxy (X)HTML requests - let the downstream handler serve the standard app shell page;
109-
// Saxon-JS will fetch the target RDF client-side and complete the rendering.
110-
// Use proper content negotiation (same as getResponse()) so that a browser Accept header like
111-
// "text/html, application/xml;q=0.9, */*;q=0.8" correctly resolves to text/html.
107+
// do not proxy requests that don't accept any RDF/SPARQL type — let the downstream handler serve the response.
108+
// Core MediaTypes contains only RDF/SPARQL types so selectVariant returns null for HTML-only Accept headers.
109+
List<MediaType> writableTypes = new ArrayList<>(getMediaTypes().getWritable(Model.class));
110+
writableTypes.addAll(getMediaTypes().getWritable(ResultSet.class));
112111
List<Variant> variants = com.atomgraph.core.model.impl.Response.getVariants(
113-
getMediaTypes().getWritable(Model.class),
112+
writableTypes,
114113
getSystem().getSupportedLanguages(),
115114
new ArrayList<>());
116115
Variant selectedVariant = getRequest().selectVariant(variants);
117-
if (selectedVariant != null && new HTMLMediaTypePredicate().test(selectedVariant.getMediaType()))
118-
return;
116+
if (selectedVariant == null) return; // client accepts no RDF/SPARQL type
119117

120118
// strip #fragment (servers do not receive fragment identifiers)
121119
if (targetURI.getFragment() != null)
@@ -135,7 +133,7 @@ public void filter(ContainerRequestContext requestContext) throws IOException
135133
{
136134
if (log.isDebugEnabled()) log.debug("Serving mapped URI from DataManager cache: {}", targetURI);
137135
Model model = getSystem().getDataManager().loadModel(targetURI.toString());
138-
requestContext.abortWith(getResponse(model, Response.Status.OK));
136+
requestContext.abortWith(getResponse(model, Response.Status.OK, selectedVariant));
139137
return;
140138
}
141139

@@ -153,7 +151,7 @@ public void filter(ContainerRequestContext requestContext) throws IOException
153151
if (!description.isEmpty())
154152
{
155153
if (log.isDebugEnabled()) log.debug("Serving URI from namespace ontology: {}", targetURI);
156-
requestContext.abortWith(getResponse(description, Response.Status.OK));
154+
requestContext.abortWith(getResponse(description, Response.Status.OK, selectedVariant));
157155
return;
158156
}
159157
}
@@ -200,7 +198,7 @@ else if (agentContext instanceof IDTokenSecurityContext idTokenSecurityContext)
200198
{
201199
// provide the target URI as a base URI hint so ModelProvider / HtmlJsonLDReader can resolve relative references
202200
clientResponse.getHeaders().putSingle(com.atomgraph.core.io.ModelProvider.REQUEST_URI_HEADER, targetURI.toString());
203-
requestContext.abortWith(getResponse(clientResponse));
201+
requestContext.abortWith(getResponse(clientResponse, selectedVariant));
204202
}
205203
}
206204
catch (MessageBodyProviderNotFoundException ex)
@@ -255,83 +253,77 @@ protected Optional<URI> resolveTargetURI(ContainerRequestContext requestContext)
255253
* Converts a client response from the proxy target into a JAX-RS response.
256254
*
257255
* @param clientResponse response from the proxy target
256+
* @param selectedVariant pre-computed variant from content negotiation
258257
* @return JAX-RS response to return to the original caller
259258
*/
260-
protected Response getResponse(Response clientResponse)
259+
protected Response getResponse(Response clientResponse, Variant selectedVariant)
261260
{
262261
if (clientResponse.getMediaType() == null) return Response.status(clientResponse.getStatus()).build();
263-
return getResponse(clientResponse, clientResponse.getStatusInfo());
262+
return getResponse(clientResponse, clientResponse.getStatusInfo(), selectedVariant);
264263
}
265264

266265
/**
267266
* Converts a client response from the proxy target into a JAX-RS response with the given status.
268267
*
269268
* @param clientResponse response from the proxy target
270269
* @param statusType status to use in the returned response
270+
* @param selectedVariant pre-computed variant from content negotiation
271271
* @return JAX-RS response
272272
*/
273-
protected Response getResponse(Response clientResponse, Response.StatusType statusType)
273+
protected Response getResponse(Response clientResponse, Response.StatusType statusType, Variant selectedVariant)
274274
{
275275
MediaType formatType = new MediaType(clientResponse.getMediaType().getType(), clientResponse.getMediaType().getSubtype()); // discard charset param
276276

277277
Lang lang = RDFLanguages.contentTypeToLang(formatType.toString());
278278
if (lang != null && ResultSetReaderRegistry.isRegistered(lang))
279279
{
280280
ResultSetRewindable results = clientResponse.readEntity(ResultSetRewindable.class);
281-
return getResponse(results, statusType);
281+
return getResponse(results, statusType, selectedVariant);
282282
}
283283

284284
Model model = clientResponse.readEntity(Model.class);
285-
return getResponse(model, statusType);
285+
return getResponse(model, statusType, selectedVariant);
286286
}
287287

288288
/**
289-
* Builds a content-negotiated response for the given RDF model.
289+
* Builds a response for the given RDF model using a pre-computed variant.
290290
*
291291
* @param model RDF model
292292
* @param statusType response status
293+
* @param selectedVariant pre-computed variant from content negotiation
293294
* @return JAX-RS response
294295
*/
295-
protected Response getResponse(Model model, Response.StatusType statusType)
296+
protected Response getResponse(Model model, Response.StatusType statusType, Variant selectedVariant)
296297
{
297-
List<Variant> variants = com.atomgraph.core.model.impl.Response.getVariants(
298-
getMediaTypes().getWritable(Model.class),
299-
getSystem().getSupportedLanguages(),
300-
new ArrayList<>());
301-
302298
return new com.atomgraph.core.model.impl.Response(getRequest(),
303299
model,
304300
null,
305301
new EntityTag(Long.toHexString(ModelUtils.hashModel(model))),
306-
variants,
302+
selectedVariant,
307303
new HTMLMediaTypePredicate()).
308304
getResponseBuilder().
309305
status(statusType).
310306
build();
311307
}
312308

313309
/**
314-
* Builds a content-negotiated response for the given SPARQL result set.
310+
* Builds a response for the given SPARQL result set using a pre-computed variant.
315311
*
316312
* @param resultSet SPARQL result set
317313
* @param statusType response status
314+
* @param selectedVariant pre-computed variant from content negotiation
318315
* @return JAX-RS response
319316
*/
320-
protected Response getResponse(ResultSetRewindable resultSet, Response.StatusType statusType)
317+
protected Response getResponse(ResultSetRewindable resultSet, Response.StatusType statusType, Variant selectedVariant)
321318
{
322319
long hash = ResultSetUtils.hashResultSet(resultSet);
323320
resultSet.reset();
324321

325-
List<Variant> variants = com.atomgraph.core.model.impl.Response.getVariants(
326-
getMediaTypes().getWritable(ResultSet.class),
327-
getSystem().getSupportedLanguages(),
328-
new ArrayList<>());
329-
330322
return new com.atomgraph.core.model.impl.Response(getRequest(),
331323
resultSet,
332324
null,
333325
new EntityTag(Long.toHexString(hash)),
334-
variants,
326+
selectedVariant,
335327
new HTMLMediaTypePredicate()).
336328
getResponseBuilder().
337329
status(statusType).
@@ -360,12 +352,13 @@ public Optional<Ontology> getOntology()
360352

361353
/**
362354
* Returns the media types registry.
355+
* Core MediaTypes do not include (X)HTML types, which is what we want here.
363356
*
364357
* @return media types
365358
*/
366359
public MediaTypes getMediaTypes()
367360
{
368-
return mediaTypes;
361+
return new MediaTypes();
369362
}
370363

371364
/**

src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl

Lines changed: 89 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -559,41 +559,65 @@ WHERE
559559
<!-- store ETag header value under window.LinkedDataHub.contents[$uri].etag -->
560560
<ixsl:set-property name="etag" select="$etag" object="ixsl:get(ixsl:get(ixsl:window(), 'LinkedDataHub.contents'), '`' || $uri || '`')"/>
561561

562-
<xsl:choose>
563-
<xsl:when test="not(starts-with($uri, $ldt:base))">
564-
<!-- external ?uri= resource: replace home page content-body with resource description;
565-
Saxon-JS fetches the RDF data client-side (ldh:RDFDocumentLoad) and renders it here -->
566-
<xsl:for-each select="id('content-body', ixsl:page())">
562+
<xsl:for-each select="id('content-body', ixsl:page())">
563+
<xsl:choose>
564+
<xsl:when test="not(starts-with($uri, $ldt:base))">
565+
<!-- external ?uri= resource: replace home page content-body with resource description;
566+
Saxon-JS fetches the RDF data client-side (ldh:RDFDocumentLoad) and renders it here -->
567567
<xsl:result-document href="?." method="ixsl:replace-content">
568-
<xsl:apply-templates select="$results/rdf:RDF" mode="bs2:Row">
569-
<xsl:with-param name="create-resource" select="false()"/>
570-
<xsl:with-param name="classes" select="()"/>
571-
</xsl:apply-templates>
568+
<xsl:choose>
569+
<xsl:when test="ac:mode() = '&ldh;ContentMode'">
570+
<xsl:apply-templates select="$results/rdf:RDF" mode="ldh:ContentList"/>
571+
</xsl:when>
572+
<xsl:when test="ac:mode() = '&ac;MapMode'">
573+
<xsl:apply-templates select="$results/rdf:RDF" mode="bs2:Map">
574+
<xsl:with-param name="id" select="generate-id() || '-map-canvas'"/>
575+
<xsl:sort select="ac:label(.)"/>
576+
</xsl:apply-templates>
577+
</xsl:when>
578+
<xsl:when test="ac:mode() = '&ac;ChartMode'">
579+
<xsl:apply-templates select="$results/rdf:RDF" mode="bs2:Chart">
580+
<xsl:with-param name="canvas-id" select="generate-id() || '-chart-canvas'"/>
581+
<xsl:with-param name="show-save" select="false()"/>
582+
<xsl:sort select="ac:label(.)"/>
583+
</xsl:apply-templates>
584+
</xsl:when>
585+
<xsl:when test="ac:mode() = '&ac;GraphMode'">
586+
<xsl:variable name="canvas-id" select="generate-id() || '-graph-canvas'" as="xs:string"/>
587+
<div id="{$canvas-id}" class="graph-3d-canvas"/>
588+
</xsl:when>
589+
<xsl:otherwise>
590+
<xsl:apply-templates select="$results/rdf:RDF" mode="bs2:Row">
591+
<xsl:with-param name="create-resource" select="false()"/>
592+
<xsl:with-param name="classes" select="()"/>
593+
</xsl:apply-templates>
594+
</xsl:otherwise>
595+
</xsl:choose>
572596
</xsl:result-document>
573-
</xsl:for-each>
574-
</xsl:when>
575-
<xsl:otherwise>
576-
<!-- this has to go after <xsl:result-document href="#{$container-id}"> because otherwise new elements will be injected and the lookup will not work anymore -->
577-
<!-- load top-level content blocks -->
578-
<xsl:for-each select="id('content-body', ixsl:page())/div">
579-
<!-- container could be hidden server-side -->
580-
<ixsl:set-style name="display" select="'block'"/>
581-
582-
<!-- one top-level <div> can contain multiple blocks that need to be rendered via factory -->
583-
<xsl:variable name="factories" as="(function(item()?) as item()*)*">
584-
<xsl:apply-templates select="." mode="ldh:RenderRow">
585-
<xsl:with-param name="refresh-content" select="$refresh-content"/>
586-
</xsl:apply-templates>
587-
</xsl:variable>
588-
589-
<xsl:for-each select="$factories">
590-
<xsl:variable name="factory" select="."/>
591-
<!-- fire the promise chain once, passing a dummy start value -->
592-
<ixsl:promise select="$factory(())" on-failure="ldh:promise-failure#1"/>
597+
</xsl:when>
598+
<xsl:otherwise>
599+
<!-- this has to go after <xsl:result-document href="#{$container-id}"> because otherwise new elements will be injected and the lookup will not work anymore -->
600+
<!-- load top-level content blocks -->
601+
<xsl:for-each select="div">
602+
<!-- container could be hidden server-side -->
603+
<ixsl:set-style name="display" select="'block'"/>
604+
605+
<!-- one top-level <div> can contain multiple blocks that need to be rendered via factory -->
606+
<xsl:variable name="factories" as="(function(item()?) as item()*)*">
607+
<xsl:apply-templates select="." mode="ldh:RenderRow">
608+
<xsl:with-param name="refresh-content" select="$refresh-content"/>
609+
</xsl:apply-templates>
610+
</xsl:variable>
611+
612+
<xsl:for-each select="$factories">
613+
<xsl:variable name="factory" select="."/>
614+
<!-- fire the promise chain once, passing a dummy start value -->
615+
<ixsl:promise select="$factory(())" on-failure="ldh:promise-failure#1"/>
616+
</xsl:for-each>
593617
</xsl:for-each>
594-
</xsl:for-each>
595-
</xsl:otherwise>
596-
</xsl:choose>
618+
</xsl:otherwise>
619+
</xsl:choose>
620+
</xsl:for-each>
597621

598622
<!-- is a new instance of Service was created, reload the LinkedDataHub.apps data and re-render the service dropdown -->
599623
<xsl:if test="//ldt:base or //sd:endpoint">
@@ -978,19 +1002,39 @@ WHERE
9781002
<xsl:variable name="controller" select="ixsl:abort-controller()"/>
9791003
<ixsl:set-property name="saxonController" select="$controller" object="ixsl:get(ixsl:window(), 'LinkedDataHub')"/>
9801004

981-
<xsl:variable name="request" select="map{ 'method': 'GET', 'href': $href, 'headers': map{ 'Accept': 'application/xhtml+xml' } }" as="map(*)"/>
982-
<xsl:variable name="context" select="
983-
map{
984-
'request': $request,
985-
'href': $href,
986-
'push-state': true()
987-
}" as="map(*)"/>
988-
<ixsl:promise select="
989-
ixsl:http-request($context('request'), $controller)
990-
=> ixsl:then(ldh:rethread-response($context, ?))
991-
=> ixsl:then(ldh:handle-response#1)
992-
=> ixsl:then(ldh:xhtml-document-loaded#1)
993-
" on-failure="ldh:promise-failure#1"/>
1005+
<xsl:choose>
1006+
<!-- external URI: LDH no longer proxies HTML requests (ProxyRequestFilter), so fetching XHTML
1007+
would just return the same app shell already loaded. Skip that round-trip and load RDF directly. -->
1008+
<xsl:when test="not(starts-with($uri, $ldt:base))">
1009+
<xsl:call-template name="ldh:PushState">
1010+
<xsl:with-param name="href" select="$href"/>
1011+
<xsl:with-param name="title" select="()"/>
1012+
<xsl:with-param name="container" select="id($body-id, ixsl:page())"/>
1013+
</xsl:call-template>
1014+
<xsl:for-each select="id('content-body', ixsl:page())//div[contains-token(@class, 'bar')]">
1015+
<ixsl:set-style name="width" select="'66%'"/>
1016+
</xsl:for-each>
1017+
<xsl:call-template name="ldh:RDFDocumentLoad">
1018+
<xsl:with-param name="uri" select="$uri"/>
1019+
</xsl:call-template>
1020+
</xsl:when>
1021+
<!-- internal URI: fetch updated page shell and replace content -->
1022+
<xsl:otherwise>
1023+
<xsl:variable name="request" select="map{ 'method': 'GET', 'href': $href, 'headers': map{ 'Accept': 'application/xhtml+xml' } }" as="map(*)"/>
1024+
<xsl:variable name="context" select="
1025+
map{
1026+
'request': $request,
1027+
'href': $href,
1028+
'push-state': true()
1029+
}" as="map(*)"/>
1030+
<ixsl:promise select="
1031+
ixsl:http-request($context('request'), $controller)
1032+
=> ixsl:then(ldh:rethread-response($context, ?))
1033+
=> ixsl:then(ldh:handle-response#1)
1034+
=> ixsl:then(ldh:xhtml-document-loaded#1)
1035+
" on-failure="ldh:promise-failure#1"/>
1036+
</xsl:otherwise>
1037+
</xsl:choose>
9941038
</xsl:if>
9951039
</xsl:template>
9961040

src/test/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilterTest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import jakarta.ws.rs.core.Request;
3333
import jakarta.ws.rs.core.Response;
3434
import jakarta.ws.rs.core.UriInfo;
35+
import jakarta.ws.rs.core.Variant;
3536
import java.io.IOException;
3637
import java.net.URI;
3738
import java.util.List;
@@ -62,6 +63,7 @@ public class ProxyRequestFilterTest
6263
@Mock com.atomgraph.linkeddatahub.Application system;
6364
@Mock MediaTypes mediaTypes;
6465
@Mock Request request;
66+
@Mock Variant selectedVariant;
6567
@Mock Ontology ontology;
6668

6769
@InjectMocks ProxyRequestFilter filter;
@@ -88,6 +90,7 @@ public void setUp()
8890
when(system.getDataManager()).thenReturn(dataManager);
8991
when(dataManager.isMapped(anyString())).thenReturn(false);
9092
when(system.isEnableLinkedDataProxy()).thenReturn(false);
93+
when(request.selectVariant(any())).thenReturn(selectedVariant);
9194
filter.ontology = () -> Optional.empty();
9295
}
9396

0 commit comments

Comments
 (0)