Skip to content

Commit c11a0d1

Browse files
namedgraphclaude
andcommitted
Fix ContentMode block rendering for proxied external resources
Proxied resources' ContentMode blocks (charts, maps) were querying the local SPARQL endpoint instead of the remote one because ProxyRequestFilter discarded all external response headers and ResponseHeadersFilter then injected the local sd:endpoint Link. - ApplicationFilter: register external ?uri= target in request context (AC.uri property) as authoritative proxy marker - ProxyRequestFilter: forward all Link headers from external response - ResponseHeadersFilter: skip local sd:endpoint/ldt:ontology/ac:stylesheet for proxy requests; removes now-unused parseLinkHeaderValues/getLinksByRel - client.xsl (ldh:rdf-document-response): extract sd:endpoint from Link header and store in LinkedDataHub.endpoint, mirroring acl:mode pattern - functions.xsl (sd:endpoint()): return LinkedDataHub.endpoint when set, fall back to local /sparql — no changes needed in view.xsl or chart.xsl - CLAUDE.md: document the proxy/client-side rendering architecture Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 8d5c385 commit c11a0d1

File tree

8 files changed

+267
-265
lines changed

8 files changed

+267
-265
lines changed

CLAUDE.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,28 @@ The application runs as a multi-container setup:
9191
1. Requests come through nginx proxy
9292
2. Varnish provides caching layer
9393
3. LinkedDataHub application handles business logic
94-
4. Data persisted to appropriate Fuseki triplestore
95-
5. XSLT transforms data for client presentation
94+
4. RDF data is read/written via the **Graph Store Protocol** — each document in the hierarchy corresponds to a named graph in the triplestore; the document URI is the graph name
95+
5. Data persisted to appropriate Fuseki triplestore
96+
6. XSLT transforms data for client presentation
97+
98+
### Linked Data Proxy and Client-Side Rendering
99+
100+
LDH includes a Linked Data proxy that dereferences external URIs on behalf of the browser. The original design rendered proxied resources identically to local ones — server-side RDF fetch + XSLT. This created a DDoS/resource-exhaustion vector: scraper bots routing arbitrary external URIs through the proxy would trigger a full server-side pipeline (HTTP fetch → XSLT rendering) per request, exhausting HTTP connection pools and CPU.
101+
102+
The current design splits rendering by request origin:
103+
104+
- **Browser requests** (`Accept: text/html`): `ProxyRequestFilter` bypasses the proxy entirely. The server returns the local application shell. Saxon-JS then issues a second, RDF-typed request (`Accept: application/rdf+xml`) from the browser.
105+
- **RDF requests** (API clients, Saxon-JS second pass): `ProxyRequestFilter` fetches the external RDF, parses it, and returns it to the caller. No XSLT happens server-side.
106+
- **Client-side rendering**: Saxon-JS receives the raw RDF and applies the same XSLT 3 templates used server-side (shared stylesheet), so proxied resources look almost identical to local ones.
107+
108+
Key implementation files:
109+
- `ProxyRequestFilter.java` — intercepts `?uri=` and `lapp:Dataset` proxy requests; HTML bypass; forwards external `Link` headers
110+
- `ApplicationFilter.java` — registers external proxy target URI in request context (`AC.uri` property) as authoritative proxy marker
111+
- `ResponseHeadersFilter.java` — skips local-only hypermedia links (`sd:endpoint`, `ldt:ontology`, `ac:stylesheet`) for proxy requests; external ones are forwarded by `ProxyRequestFilter`
112+
- `client.xsl` (`ldh:rdf-document-response`) — receives the RDF proxy response client-side; extracts `sd:endpoint` from `Link` header; stores it in `LinkedDataHub.endpoint`
113+
- `functions.xsl` (`sd:endpoint()`) — returns `LinkedDataHub.endpoint` when set (external proxy), otherwise falls back to the local SPARQL endpoint
114+
115+
The SPARQL endpoint forwarding chain ensures ContentMode blocks (charts, maps) query the **remote** app's SPARQL endpoint, not the local one. `LinkedDataHub.endpoint` is reset to the local endpoint by `ldh:HTMLDocumentLoaded` on every HTML page navigation, so there is no stale state when navigating back to local documents.
96116

97117
### Key Extension Points
98118
- **Vocabulary definitions** in `com.atomgraph.linkeddatahub.vocabulary`

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@ public void filter(ContainerRequestContext request) throws IOException
107107

108108
requestURI = builder.build();
109109
}
110-
else requestURI = request.getUriInfo().getRequestUri();
110+
else
111+
{
112+
request.setProperty(AC.uri.getURI(), graphURI); // authoritative external proxy marker
113+
requestURI = request.getUriInfo().getRequestUri();
114+
}
111115
}
112116
catch (URISyntaxException ex)
113117
{

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
import org.apache.jena.riot.RDFLanguages;
6666
import org.apache.jena.riot.resultset.ResultSetReaderRegistry;
6767
import org.glassfish.jersey.message.internal.MessageBodyProviderNotFoundException;
68+
import java.util.regex.Pattern;
6869
import org.slf4j.Logger;
6970
import org.slf4j.LoggerFactory;
7071

@@ -102,6 +103,7 @@ public class ProxyRequestFilter implements ContainerRequestFilter
102103

103104
private static final Logger log = LoggerFactory.getLogger(ProxyRequestFilter.class);
104105
private static final MediaTypes MEDIA_TYPES = new MediaTypes();
106+
private static final Pattern LINK_SPLITTER = Pattern.compile(",(?=\\s*<)");
105107

106108
@Inject com.atomgraph.linkeddatahub.Application system;
107109
@Inject jakarta.inject.Provider<Optional<Ontology>> ontology;
@@ -296,14 +298,31 @@ protected Response getResponse(Response clientResponse, Response.StatusType stat
296298
MediaType formatType = new MediaType(clientResponse.getMediaType().getType(), clientResponse.getMediaType().getSubtype()); // discard charset param
297299

298300
Lang lang = RDFLanguages.contentTypeToLang(formatType.toString());
301+
Response response;
299302
if (lang != null && ResultSetReaderRegistry.isRegistered(lang))
300303
{
301304
ResultSetRewindable results = clientResponse.readEntity(ResultSetRewindable.class);
302-
return getResponse(results, statusType, selectedVariant);
305+
response = getResponse(results, statusType, selectedVariant);
306+
}
307+
else
308+
{
309+
Model model = clientResponse.readEntity(Model.class);
310+
response = getResponse(model, statusType, selectedVariant);
311+
}
312+
313+
// forward all Link headers from the external response so the client receives remote hypermedia
314+
// (e.g. sd:endpoint pointing to the remote SPARQL endpoint);
315+
// ResponseHeadersFilter will see sd:endpoint already present and skip injecting the local one
316+
String linkHeader = clientResponse.getHeaderString(HttpHeaders.LINK);
317+
if (linkHeader != null)
318+
{
319+
Response.ResponseBuilder builder = Response.fromResponse(response);
320+
for (String part : LINK_SPLITTER.split(linkHeader))
321+
builder.header(HttpHeaders.LINK, part.trim());
322+
response = builder.build();
303323
}
304324

305-
Model model = clientResponse.readEntity(Model.class);
306-
return getResponse(model, statusType, selectedVariant);
325+
return response;
307326
}
308327

309328
/**

src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java

Lines changed: 5 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@
2828
import com.atomgraph.linkeddatahub.vocabulary.ACL;
2929
import java.io.IOException;
3030
import java.net.URI;
31-
import java.net.URISyntaxException;
32-
import java.util.ArrayList;
33-
import java.util.List;
3431
import java.util.Optional;
3532
import jakarta.annotation.Priority;
3633
import jakarta.inject.Inject;
@@ -40,7 +37,6 @@
4037
import jakarta.ws.rs.container.ContainerResponseFilter;
4138
import jakarta.ws.rs.core.HttpHeaders;
4239
import jakarta.ws.rs.core.Response;
43-
import java.util.regex.Pattern;
4440
import org.slf4j.Logger;
4541
import org.slf4j.LoggerFactory;
4642

@@ -54,7 +50,6 @@ public class ResponseHeadersFilter implements ContainerResponseFilter
5450
{
5551

5652
private static final Logger log = LoggerFactory.getLogger(ResponseHeadersFilter.class);
57-
private static final Pattern LINK_SPLITTER = Pattern.compile(",(?=\\s*<)"); // split on commas before next '<'
5853

5954
@Inject jakarta.inject.Provider<Optional<Application>> app;
6055
@Inject jakarta.inject.Provider<Optional<Dataset>> dataset;
@@ -75,16 +70,14 @@ public void filter(ContainerRequestContext request, ContainerResponseContext res
7570
if (getAuthorizationContext().isPresent())
7671
getAuthorizationContext().get().getModeURIs().forEach(mode -> response.getHeaders().add(HttpHeaders.LINK, new Link(mode, ACL.mode.getURI(), null)));
7772

78-
List<Object> linkValues = response.getHeaders().get(HttpHeaders.LINK);
79-
List<Link> links = parseLinkHeaderValues(linkValues);
73+
// for proxy requests the external Link headers are forwarded by ProxyRequestFilter; suppress local-only hypermedia
74+
boolean isProxyRequest = request.getProperty(AC.uri.getURI()) != null;
8075

81-
if (getLinksByRel(links, SD.endpoint.getURI()).isEmpty())
82-
// add Link rel=sd:endpoint.
83-
// TO-DO: The external SPARQL endpoint URL is different from the internal one currently specified as sd:endpoint in the context dataset
76+
if (!isProxyRequest)
8477
response.getHeaders().add(HttpHeaders.LINK, new Link(request.getUriInfo().getBaseUriBuilder().path(Dispatcher.class, "getSPARQLEndpoint").build(), SD.endpoint.getURI(), null));
8578

86-
// Only add application-specific links if application is present
87-
if (getApplication().isPresent())
79+
// Only add application-specific links if application is present and this is not a proxy request
80+
if (!isProxyRequest && getApplication().isPresent())
8881
{
8982
Application application = getApplication().get();
9083
// add Link rel=ldt:ontology, if the ontology URI is specified
@@ -103,55 +96,6 @@ public void filter(ContainerRequestContext request, ContainerResponseContext res
10396
}
10497
}
10598

106-
/**
107-
* Parses HTTP <code>Link</code> headers into individual {@link Link} objects.
108-
*
109-
* Handles both multiple header fields and comma-separated values
110-
* within a single header field.
111-
*
112-
* @param linkValues raw <code>Link</code> header values (may contain multiple entries)
113-
* @return flat list of parsed {@link Link} objects
114-
*/
115-
protected List<Link> parseLinkHeaderValues(List<Object> linkValues)
116-
{
117-
List<Link> out = new ArrayList<>();
118-
if (linkValues == null) return out;
119-
120-
for (Object hv : linkValues)
121-
{
122-
String[] parts = LINK_SPLITTER.split(hv.toString());
123-
for (String part : parts)
124-
{
125-
try
126-
{
127-
out.add(Link.valueOf(part.trim()));
128-
}
129-
catch (URISyntaxException e)
130-
{
131-
// ignore invalid entries
132-
}
133-
}
134-
}
135-
136-
return out;
137-
}
138-
139-
/**
140-
* Returns all <code>Link</code> headers that match the given <code>rel</code> attribute.
141-
*
142-
* @param links link list
143-
* @param rel <code>rel</code> value
144-
* @return filtered header list
145-
*/
146-
protected List<Link> getLinksByRel(List<Link> links, String rel)
147-
{
148-
return links == null
149-
? List.of()
150-
: links.stream()
151-
.filter(link -> rel.equals(link.getRel()))
152-
.toList();
153-
}
154-
15599
/**
156100
* Returns the current application.
157101
*

src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ exclude-result-prefixes="#all"
9393
</xsl:function>
9494

9595
<xsl:function name="sd:endpoint" as="xs:anyURI">
96-
<xsl:sequence select="resolve-uri('sparql', ldt:base())"/>
96+
<xsl:sequence select="if (ixsl:contains(ixsl:get(ixsl:window(), 'LinkedDataHub'), 'endpoint'))
97+
then xs:anyURI(ixsl:get(ixsl:get(ixsl:window(), 'LinkedDataHub'), 'endpoint'))
98+
else resolve-uri('sparql', ldt:base())"/>
9799
</xsl:function>
98100

99101
<xsl:function name="ldh:query-type" as="xs:string?">

src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/document.xsl

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,80 @@ extension-element-prefixes="ixsl"
180180
</div>
181181
</xsl:template>
182182

183+
<!-- CONTENT BODY -->
184+
185+
<xsl:template match="rdf:RDF" mode="bs2:ContentBody">
186+
<xsl:param name="id" select="'content-body'" as="xs:string?"/>
187+
<xsl:param name="class" select="'container-fluid'" as="xs:string?"/>
188+
<xsl:param name="about" select="ac:absolute-path(ldh:base-uri(.))" as="xs:anyURI?"/>
189+
<xsl:param name="typeof" select="key('resources', ac:absolute-path(ldh:base-uri(.)))/rdf:type/@rdf:resource/xs:anyURI(.)" as="xs:anyURI*"/>
190+
<xsl:param name="mode" as="xs:anyURI"/>
191+
<xsl:param name="ldh:requestUri" select="$ldh:requestUri" as="xs:anyURI?"/>
192+
193+
<div>
194+
<xsl:if test="$id">
195+
<xsl:attribute name="id" select="$id"/>
196+
</xsl:if>
197+
<xsl:if test="$class">
198+
<xsl:attribute name="class" select="$class"/>
199+
</xsl:if>
200+
<xsl:if test="$about">
201+
<xsl:attribute name="about" select="$about"/>
202+
</xsl:if>
203+
<xsl:if test="exists($typeof)">
204+
<xsl:attribute name="typeof" select="string-join($typeof, ' ')"/>
205+
</xsl:if>
206+
207+
<xsl:choose>
208+
<!-- error responses always rendered in bs2:Row mode, no matter what $mode specifies -->
209+
<xsl:when test="exists($ldh:requestUri) and key('resources-by-type', '&http;Response') and not(key('resources-by-type', '&spin;ConstraintViolation')) and not(key('resources-by-type', '&sh;ValidationResult'))">
210+
<xsl:apply-templates select="." mode="bs2:Row">
211+
<xsl:sort select="ac:label(.)"/>
212+
</xsl:apply-templates>
213+
</xsl:when>
214+
<!-- the request is proxied using ?uri, render it client-side in client.xsl -->
215+
<xsl:when test="exists($ldh:requestUri) and not(ldh:base-uri(.) = $ldh:requestUri)">
216+
<div class="row-fluid">
217+
<div class="span12 progress progress-striped active">
218+
<div style="width: 33%;" class="bar"></div>
219+
</div>
220+
</div>
221+
</xsl:when>
222+
<xsl:otherwise>
223+
<xsl:choose>
224+
<xsl:when test="$mode = '&ldh;ContentMode'">
225+
<xsl:apply-templates select="." mode="ldh:ContentList"/>
226+
</xsl:when>
227+
<xsl:when test="$mode = '&ac;MapMode'">
228+
<xsl:apply-templates select="." mode="bs2:Map">
229+
<xsl:with-param name="id" select="generate-id() || '-map-canvas'"/>
230+
<xsl:sort select="ac:label(.)"/>
231+
</xsl:apply-templates>
232+
</xsl:when>
233+
<xsl:when test="$mode = '&ac;ChartMode'">
234+
<xsl:apply-templates select="." mode="bs2:Chart">
235+
<xsl:with-param name="canvas-id" select="generate-id() || '-chart-canvas'"/>
236+
<xsl:with-param name="show-save" select="false()"/>
237+
<xsl:sort select="ac:label(.)"/>
238+
</xsl:apply-templates>
239+
</xsl:when>
240+
<xsl:when test="$mode = '&ac;GraphMode'">
241+
<xsl:variable name="canvas-id" select="generate-id() || '-graph-canvas'" as="xs:string"/>
242+
<div id="{$canvas-id}" class="graph-3d-canvas"/>
243+
</xsl:when>
244+
<xsl:otherwise>
245+
<xsl:apply-templates select="." mode="bs2:Row">
246+
<xsl:sort select="ac:label(.)"/>
247+
</xsl:apply-templates>
248+
</xsl:otherwise>
249+
</xsl:choose>
250+
</xsl:otherwise>
251+
</xsl:choose>
252+
</div>
253+
</xsl:template>
254+
183255
<!-- CONTENT LIST -->
184-
256+
185257
<xsl:template match="rdf:RDF" mode="ldh:ContentList">
186258
<xsl:apply-templates select="key('resources', ac:absolute-path(ldh:base-uri(.)))" mode="#current"/>
187259

0 commit comments

Comments
 (0)