diff --git a/http-tests/proxy/GET-proxied-accept-forwarded.sh b/http-tests/proxy/GET-proxied-accept-forwarded.sh
new file mode 100755
index 000000000..f33938a39
--- /dev/null
+++ b/http-tests/proxy/GET-proxied-accept-forwarded.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
+initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
+purge_cache "$END_USER_VARNISH_SERVICE"
+purge_cache "$ADMIN_VARNISH_SERVICE"
+purge_cache "$FRONTEND_VARNISH_SERVICE"
+
+# add agent to the readers group to be able to read documents
+
+add-agent-to-group.sh \
+ -f "$OWNER_CERT_FILE" \
+ -p "$OWNER_CERT_PWD" \
+ --agent "$AGENT_URI" \
+ "${ADMIN_BASE_URL}acl/groups/readers/"
+
+# Regression: ProxyRequestFilter must forward the client's Accept header verbatim to the
+# upstream, NOT substitute its own readable-types list. Previously the filter built its
+# outbound Accept from MediaTypes.getReadable(Model.class) + getReadable(ResultSet.class)
+# (everything Jena could ingest, all q=1.0), discarding what the client actually asked for.
+# The upstream then content-negotiated against that broad list and could legally pick any
+# RDF format — e.g. application/rdf+thrift — even when the client (e.g. SaxonJS document())
+# explicitly requested application/rdf+xml or application/xml.
+#
+# Verify by requesting one specific RDF type and asserting the response matches it.
+
+for accept in 'application/rdf+xml' 'text/turtle' 'application/n-triples'; do
+ content_type=$(curl -k -f -s -G -w "%{content_type}" -o /dev/null \
+ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
+ -H "Accept: $accept" \
+ --data-urlencode "uri=${END_USER_BASE_URL}" \
+ "$ADMIN_BASE_URL")
+
+ case "$content_type" in
+ "$accept"*) ;;
+ *) exit 1 ;;
+ esac
+done
diff --git a/http-tests/proxy/GET-proxied-accept-html-not-preferred.sh b/http-tests/proxy/GET-proxied-accept-html-not-preferred.sh
new file mode 100755
index 000000000..3e2946156
--- /dev/null
+++ b/http-tests/proxy/GET-proxied-accept-html-not-preferred.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
+initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
+purge_cache "$END_USER_VARNISH_SERVICE"
+purge_cache "$ADMIN_VARNISH_SERVICE"
+purge_cache "$FRONTEND_VARNISH_SERVICE"
+
+# add agent to the readers group to be able to read documents
+
+add-agent-to-group.sh \
+ -f "$OWNER_CERT_FILE" \
+ -p "$OWNER_CERT_PWD" \
+ --agent "$AGENT_URI" \
+ "${ADMIN_BASE_URL}acl/groups/readers/"
+
+# Regression: when a client lists application/xhtml+xml (or text/html) in Accept at a
+# LOWER q-value than another supported type, the proxy must treat the request as
+# API-client intent and forward — not as browser navigation that wants the app shell.
+# Previously, ProxyRequestFilter bypassed on anyMatch(HTML or XHTML in Accept) without
+# checking q-rank, so it false-fired on any Accept that mentioned HTML at all and
+# returned the local app shell instead of the proxied response.
+#
+# Discriminator is HTTP status — content-type cannot tell bypass from forward because
+# admin and end-user share writer configs (same Accept → same negotiated type on both).
+# A UUID-named path that doesn't exist on either origin disambiguates:
+# - bypass: ApplicationFilter strips ?uri= → request URI becomes admin root → 200
+# - forward: proxy forwards the actual UUID path to end-user → 404
+
+accept_header='application/xml, text/xml;q=0.9, application/xhtml+xml;q=0.8, */*;q=0.7'
+non_existing_uri="${END_USER_BASE_URL}$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)/"
+
+status=$(curl -k -s -G -o /dev/null -w "%{http_code}" \
+ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
+ -H "Accept: $accept_header" \
+ --data-urlencode "uri=${non_existing_uri}" \
+ "$ADMIN_BASE_URL")
+
+[ "$status" = "$STATUS_NOT_FOUND" ] || exit 1
diff --git a/http-tests/proxy/GET-proxied-external-502.sh b/http-tests/proxy/GET-proxied-external-502.sh
index 3e297c65b..c0c6bfc45 100755
--- a/http-tests/proxy/GET-proxied-external-502.sh
+++ b/http-tests/proxy/GET-proxied-external-502.sh
@@ -19,6 +19,7 @@ add-agent-to-group.sh \
curl -k -w "%{http_code}\n" -o /dev/null -s \
-G \
+ -H "Accept: application/n-triples" \
-E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
--data-urlencode "uri=http://f1d2d4cf-90bb-4f5b-ae4b-921e584b6edd.org" \
"$END_USER_BASE_URL" \
diff --git a/http-tests/proxy/HEAD-proxied-etag.sh b/http-tests/proxy/HEAD-proxied-etag.sh
new file mode 100755
index 000000000..251a7be93
--- /dev/null
+++ b/http-tests/proxy/HEAD-proxied-etag.sh
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
+initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
+purge_cache "$END_USER_VARNISH_SERVICE"
+purge_cache "$ADMIN_VARNISH_SERVICE"
+purge_cache "$FRONTEND_VARNISH_SERVICE"
+
+# add agent to the readers group to be able to read documents
+
+add-agent-to-group.sh \
+ -f "$OWNER_CERT_FILE" \
+ -p "$OWNER_CERT_PWD" \
+ --agent "$AGENT_URI" \
+ "${ADMIN_BASE_URL}acl/groups/readers/"
+
+extract_etag() {
+ grep -i '^etag:' \
+ | tr -d '\r' \
+ | sed 's/^[Ee][Tt][Aa][Gg]:[[:space:]]*//'
+}
+
+# fetch the end-user root directly to capture its ETag
+
+direct_etag=$(curl --head -k -f -s \
+ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
+ -H 'Accept: application/n-triples' \
+ "$END_USER_BASE_URL" \
+| extract_etag)
+
+# fetch the same document via the admin proxy
+
+proxied_etag=$(curl -G --head -k -f -s \
+ -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
+ -H 'Accept: application/n-triples' \
+ --data-urlencode "uri=${END_USER_BASE_URL}" \
+ "$ADMIN_BASE_URL" \
+| extract_etag)
+
+[ -n "$proxied_etag" ] || exit 1
+[ "$proxied_etag" = "$direct_etag" ] || exit 1
diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java
index c7fd1832a..ebf390e75 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/Application.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java
@@ -842,7 +842,6 @@ protected PasswordAuthentication getPasswordAuthentication()
xsltProc.registerExtensionFunction(new UUID());
xsltProc.registerExtensionFunction(new DecodeURI());
xsltProc.registerExtensionFunction(new com.atomgraph.linkeddatahub.writer.function.URLDecode());
- xsltProc.registerExtensionFunction(new com.atomgraph.linkeddatahub.writer.function.Construct(xsltProc));
xsltProc.registerExtensionFunction(new com.atomgraph.linkeddatahub.writer.function.SendHTTPRequest(xsltProc, client));
Model mappingModel = locationMapper.toModel();
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java
index 23f635a00..d8657beb9 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java
@@ -16,12 +16,11 @@
*/
package com.atomgraph.linkeddatahub.server.filter.request;
-import com.atomgraph.core.MediaTypes;
+import com.atomgraph.client.MediaTypes;
import com.atomgraph.client.util.HTMLMediaTypePredicate;
import com.atomgraph.client.vocabulary.AC;
import com.atomgraph.core.exception.BadGatewayException;
import com.atomgraph.core.util.ModelUtils;
-import com.atomgraph.core.util.ResultSetUtils;
import com.atomgraph.linkeddatahub.apps.model.Dataset;
import org.apache.jena.ontology.Ontology;
import com.atomgraph.linkeddatahub.client.GraphStoreClient;
@@ -32,16 +31,17 @@
import com.atomgraph.linkeddatahub.server.security.WebIDSecurityContext;
import com.atomgraph.linkeddatahub.vocabulary.LAPP;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import java.util.Set;
import org.apache.jena.query.QueryExecution;
import org.apache.jena.query.QueryFactory;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
-import jakarta.ws.rs.NotAcceptableException;
import jakarta.ws.rs.NotAllowedException;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.ProcessingException;
@@ -57,15 +57,10 @@
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Request;
import jakarta.ws.rs.core.Response;
-import jakarta.ws.rs.core.Variant;
-import org.apache.jena.query.ResultSet;
-import org.apache.jena.query.ResultSetRewindable;
import org.apache.jena.rdf.model.Model;
-import org.apache.jena.riot.Lang;
-import org.apache.jena.riot.RDFLanguages;
-import org.apache.jena.riot.resultset.ResultSetReaderRegistry;
import org.glassfish.jersey.message.internal.MessageBodyProviderNotFoundException;
import java.util.regex.Pattern;
+import jakarta.ws.rs.NotAcceptableException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -84,15 +79,19 @@
* ACL is not checked for proxy requests: the proxy is a global transport function, not a document
* operation. Access control is enforced by the target endpoint.
*
- * This filter intentionally does not proxy requests from clients that explicitly accept
- * (X)HTML. Rendering arbitrary external URIs as (X)HTML through the full server-side pipeline
- * (SPARQL DESCRIBE + XSLT) for every browser-originated proxy request would cause unbounded resource
- * exhaustion — a connection-pool and CPU amplification attack vector. Instead, requests whose
- * {@code Accept} header contains a non-wildcard {@code text/html} or {@code application/xhtml+xml}
- * type fall through to the downstream handler, which serves the LDH application shell; the
- * client-side Saxon-JS layer then issues a second, RDF-typed request that does hit this
- * filter and is handled cheaply. Pure API clients that send only {@code *}{@code /*} (e.g. curl)
- * reach the proxy because they do not list an explicit HTML type.
+ * This filter does not proxy requests from clients that explicitly accept (X)HTML.
+ * Rendering arbitrary external URIs as (X)HTML through the full server-side pipeline
+ * (SPARQL DESCRIBE + XSLT) is expensive and creates a resource-exhaustion attack vector.
+ * When the {@code Accept} header contains a non-wildcard {@code text/html} or
+ * {@code application/xhtml+xml} type, the filter returns immediately so the downstream handler
+ * serves the LDH application shell; the client-side Saxon-JS layer then issues a second, RDF-typed
+ * request that hits this filter and is proxied cheaply. Pure API clients that send only
+ * {@code *}{@code /*} (e.g. curl) reach the proxy because they do not list an explicit HTML type.
+ *
+ * External HTTP responses are piped through as raw {@link InputStream} — the filter does not
+ * parse or re-serialize the body. The upstream already negotiates content type via the forwarded
+ * {@code Accept} header. Local responses (DataManager cache, namespace ontology DESCRIBE) are
+ * the only paths that still build a typed {@link Model} response with full content negotiation.
*
* @author Martynas Jusevičius {@literal }
*/
@@ -102,11 +101,28 @@ public class ProxyRequestFilter implements ContainerRequestFilter
{
private static final Logger log = LoggerFactory.getLogger(ProxyRequestFilter.class);
- private static final MediaTypes MEDIA_TYPES = new MediaTypes();
private static final Pattern LINK_SPLITTER = Pattern.compile(",(?=\\s*<)");
+ /**
+ * End-to-end response headers forwarded verbatim from the upstream. Excludes hop-by-hop headers
+ * (RFC 7230 §6.1), framing headers re-emitted by the container, origin-bound security headers
+ * (CSP, HSTS, CORS), cookies, and {@code Content-Type}/{@code Link} which are set explicitly.
+ */
+ private static final Set FORWARDED_RESPONSE_HEADERS = Set.of(
+ HttpHeaders.ETAG,
+ HttpHeaders.LAST_MODIFIED,
+ HttpHeaders.CACHE_CONTROL,
+ HttpHeaders.VARY,
+ HttpHeaders.EXPIRES,
+ HttpHeaders.CONTENT_LANGUAGE,
+ HttpHeaders.CONTENT_DISPOSITION,
+ HttpHeaders.CONTENT_LOCATION,
+ HttpHeaders.LOCATION,
+ HttpHeaders.RETRY_AFTER,
+ "Age");
@Inject com.atomgraph.linkeddatahub.Application system;
@Inject jakarta.inject.Provider> ontology;
+ @Inject MediaTypes mediaTypes;
@Context Request request;
@Override
@@ -117,26 +133,21 @@ public void filter(ContainerRequestContext requestContext) throws IOException
URI targetURI = targetOpt.get();
- // do not proxy requests from clients that explicitly accept (X)HTML — they expect the app shell,
- // which the downstream handler serves. Browsers list text/html as a non-wildcard type; pure API
- // clients (curl etc.) send only */* and must reach the proxy.
- // Defending against resource exhaustion: proxying + full server-side XSLT rendering for arbitrary
- // external URIs on every browser request would amplify CPU and connection-pool load unboundedly.
- boolean clientAcceptsHtml = requestContext.getAcceptableMediaTypes().stream()
- .anyMatch(mt -> !mt.isWildcardType() && !mt.isWildcardSubtype() &&
- (mt.isCompatible(MediaType.TEXT_HTML_TYPE) ||
- mt.isCompatible(MediaType.APPLICATION_XHTML_XML_TYPE)));
- if (clientAcceptsHtml) return;
-
- // negotiate the response format from RDF/SPARQL writable types
- List writableTypes = new ArrayList<>(getMediaTypes().getWritable(Model.class));
- writableTypes.addAll(getMediaTypes().getWritable(ResultSet.class));
- List variants = com.atomgraph.core.model.impl.Response.getVariants(
- writableTypes,
- getSystem().getSupportedLanguages(),
- new ArrayList<>());
- Variant selectedVariant = getRequest().selectVariant(variants);
- if (selectedVariant == null) return; // client accepts no RDF/SPARQL type
+ // do not proxy requests from clients whose top-ranked acceptable type is (X)HTML — they
+ // expect the app shell, which the downstream handler serves. Browsers list text/html (or
+ // application/xhtml+xml) at q=1.0; API clients that happen to also accept (X)HTML at a
+ // lower q (e.g. SaxonJS document() sends application/xml q=1.0, application/xhtml+xml q=0.8)
+ // must still reach the proxy.
+ // (X)HTML is not offered for proxied documents — rendering external RDF as HTML server-side
+ // (SPARQL DESCRIBE + XSLT) is expensive and creates a resource-exhaustion attack vector.
+ // Per the JAX-RS spec, getAcceptableMediaTypes() is sorted by q descending, so the first
+ // non-wildcard type is the top-ranked one.
+ for (MediaType mt : requestContext.getAcceptableMediaTypes())
+ {
+ if (mt.isWildcardType() || mt.isWildcardSubtype()) continue;
+ if (mt.isCompatible(MediaType.TEXT_HTML_TYPE) || mt.isCompatible(MediaType.APPLICATION_XHTML_XML_TYPE)) return;
+ break; // first non-wildcard wasn't (X)HTML — proceed with proxy
+ }
// strip #fragment (servers do not receive fragment identifiers)
if (targetURI.getFragment() != null)
@@ -156,7 +167,7 @@ public void filter(ContainerRequestContext requestContext) throws IOException
{
if (log.isDebugEnabled()) log.debug("Serving mapped URI from DataManager cache: {}", targetURI);
Model model = getSystem().getDataManager().loadModel(targetURI.toString());
- requestContext.abortWith(getResponse(model, Response.Status.OK, selectedVariant));
+ requestContext.abortWith(getResponse(model, Response.Status.OK));
return;
}
@@ -174,7 +185,7 @@ public void filter(ContainerRequestContext requestContext) throws IOException
if (!description.isEmpty())
{
if (log.isDebugEnabled()) log.debug("Serving URI from namespace ontology: {}", targetURI);
- requestContext.abortWith(getResponse(description, Response.Status.OK, selectedVariant));
+ requestContext.abortWith(getResponse(description, Response.Status.OK));
return;
}
}
@@ -199,17 +210,13 @@ else if (agentContext instanceof IDTokenSecurityContext idTokenSecurityContext)
idTokenSecurityContext.getJWTToken(), requestContext.getUriInfo().getBaseUri().getPath(), null));
}
- List readableMediaTypesList = new ArrayList<>();
- readableMediaTypesList.addAll(getMediaTypes().getReadable(Model.class));
- readableMediaTypesList.addAll(getMediaTypes().getReadable(ResultSet.class));
- MediaType[] readableMediaTypesArray = readableMediaTypesList.toArray(MediaType[]::new);
-
+ MediaType[] clientAcceptTypes = requestContext.getAcceptableMediaTypes().toArray(MediaType[]::new);
if (log.isDebugEnabled()) log.debug("Proxying {} {} → {}", requestContext.getMethod(), requestContext.getUriInfo().getRequestUri(), targetURI);
try
{
Invocation.Builder builder = target.request().
- accept(readableMediaTypesArray).
+ accept(clientAcceptTypes).
header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT);
Response clientResponse = requestContext.hasEntity()
@@ -219,9 +226,7 @@ else if (agentContext instanceof IDTokenSecurityContext idTokenSecurityContext)
try (clientResponse)
{
- // provide the target URI as a base URI hint so ModelProvider / HtmlJsonLDReader can resolve relative references
- clientResponse.getHeaders().putSingle(com.atomgraph.core.io.ModelProvider.REQUEST_URI_HEADER, targetURI.toString());
- requestContext.abortWith(getResponse(clientResponse, selectedVariant));
+ requestContext.abortWith(getResponse(clientResponse));
}
}
catch (MessageBodyProviderNotFoundException ex)
@@ -263,97 +268,67 @@ protected Optional resolveTargetURI(ContainerRequestContext requestContext)
}
/**
- * Converts a client response from the proxy target into a JAX-RS response.
+ * Pipes the proxy target's HTTP response through as a raw byte stream.
+ * No body parsing or re-serialization is performed; the upstream's {@code Content-Type}
+ * and status code are forwarded verbatim. {@code Link} headers from the external response
+ * are also forwarded so the client receives remote hypermedia (e.g. {@code sd:endpoint}).
*
* @param clientResponse response from the proxy target
- * @param selectedVariant pre-computed variant from content negotiation
* @return JAX-RS response to return to the original caller
*/
- protected Response getResponse(Response clientResponse, Variant selectedVariant)
+ protected Response getResponse(Response clientResponse)
{
- if (clientResponse.getMediaType() == null) return Response.status(clientResponse.getStatus()).build();
- return getResponse(clientResponse, clientResponse.getStatusInfo(), selectedVariant);
- }
+ if (clientResponse.getMediaType() == null)
+ return Response.status(clientResponse.getStatus()).build();
- /**
- * Converts a client response from the proxy target into a JAX-RS response with the given status.
- *
- * @param clientResponse response from the proxy target
- * @param statusType status to use in the returned response
- * @param selectedVariant pre-computed variant from content negotiation
- * @return JAX-RS response
- */
- protected Response getResponse(Response clientResponse, Response.StatusType statusType, Variant selectedVariant)
- {
- MediaType formatType = new MediaType(clientResponse.getMediaType().getType(), clientResponse.getMediaType().getSubtype()); // discard charset param
+ // buffer so the stream remains readable after try-with-resources closes the client response
+ clientResponse.bufferEntity();
+ InputStream entity = clientResponse.readEntity(InputStream.class);
- Lang lang = RDFLanguages.contentTypeToLang(formatType.toString());
- Response response;
- if (lang != null && ResultSetReaderRegistry.isRegistered(lang))
- {
- ResultSetRewindable results = clientResponse.readEntity(ResultSetRewindable.class);
- response = getResponse(results, statusType, selectedVariant);
- }
- else
- {
- Model model = clientResponse.readEntity(Model.class);
- response = getResponse(model, statusType, selectedVariant);
- }
+ Response.ResponseBuilder rb = Response.status(clientResponse.getStatus()).
+ type(clientResponse.getMediaType()).
+ entity(entity);
// forward all Link headers from the external response so the client receives remote hypermedia
// (e.g. sd:endpoint pointing to the remote SPARQL endpoint);
// ResponseHeadersFilter will see sd:endpoint already present and skip injecting the local one
String linkHeader = clientResponse.getHeaderString(HttpHeaders.LINK);
if (linkHeader != null)
- {
- Response.ResponseBuilder builder = Response.fromResponse(response);
for (String part : LINK_SPLITTER.split(linkHeader))
- builder.header(HttpHeaders.LINK, part.trim());
- response = builder.build();
+ rb.header(HttpHeaders.LINK, part.trim());
+
+ // forward end-to-end content/cache headers so the client can do conditional requests,
+ // caching, redirects, and download negotiation against the upstream
+ for (String name : FORWARDED_RESPONSE_HEADERS)
+ {
+ String value = clientResponse.getHeaderString(name);
+ if (value != null) rb.header(name, value);
}
- return response;
+ return rb.build();
}
/**
- * Builds a response for the given RDF model using a pre-computed variant.
+ * Builds a response for the given RDF model with type-appropriate content negotiation.
+ * Used for locally-served responses (DataManager cache, namespace ontology DESCRIBE).
*
* @param model RDF model
* @param statusType response status
- * @param selectedVariant pre-computed variant from content negotiation
* @return JAX-RS response
*/
- protected Response getResponse(Model model, Response.StatusType statusType, Variant selectedVariant)
+ protected Response getResponse(Model model, Response.StatusType statusType)
{
+ List writableTypes = new ArrayList<>(getMediaTypes().getWritable(Model.class));
+ writableTypes.removeIf(mt -> mt.isCompatible(MediaType.TEXT_HTML_TYPE) ||
+ mt.isCompatible(MediaType.APPLICATION_XHTML_XML_TYPE));
+
return new com.atomgraph.core.model.impl.Response(getRequest(),
model,
null,
new EntityTag(Long.toHexString(ModelUtils.hashModel(model))),
- selectedVariant,
- new HTMLMediaTypePredicate()).
- getResponseBuilder().
- status(statusType).
- build();
- }
-
- /**
- * Builds a response for the given SPARQL result set using a pre-computed variant.
- *
- * @param resultSet SPARQL result set
- * @param statusType response status
- * @param selectedVariant pre-computed variant from content negotiation
- * @return JAX-RS response
- */
- protected Response getResponse(ResultSetRewindable resultSet, Response.StatusType statusType, Variant selectedVariant)
- {
- long hash = ResultSetUtils.hashResultSet(resultSet);
- resultSet.reset();
-
- return new com.atomgraph.core.model.impl.Response(getRequest(),
- resultSet,
- null,
- new EntityTag(Long.toHexString(hash)),
- selectedVariant,
+ writableTypes,
+ getSystem().getSupportedLanguages(),
+ new ArrayList<>(),
new HTMLMediaTypePredicate()).
getResponseBuilder().
status(statusType).
@@ -381,14 +356,13 @@ public Optional getOntology()
}
/**
- * Returns the media types registry.
- * Core MediaTypes do not include (X)HTML types, which is what we want here.
+ * Returns the media types registry used for content negotiation and outbound {@code Accept} headers.
*
* @return media types
*/
public MediaTypes getMediaTypes()
{
- return MEDIA_TYPES;
+ return mediaTypes;
}
/**
diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java
index 5c9bc4785..770a3dbd2 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/ResponseHeadersFilter.java
@@ -26,6 +26,7 @@
import com.atomgraph.linkeddatahub.server.model.impl.Dispatcher;
import com.atomgraph.linkeddatahub.server.security.AuthorizationContext;
import com.atomgraph.linkeddatahub.vocabulary.ACL;
+import com.atomgraph.linkeddatahub.vocabulary.LAPP;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;
@@ -80,6 +81,8 @@ public void filter(ContainerRequestContext request, ContainerResponseContext res
if (!isProxyRequest && getApplication().isPresent())
{
Application application = getApplication().get();
+ // add Link rel=lapp:application
+ response.getHeaders().add(HttpHeaders.LINK, new Link(URI.create(application.getURI()), LAPP.application.getURI(), null));
// add Link rel=ldt:ontology, if the ontology URI is specified
if (application.getOntology() != null)
response.getHeaders().add(HttpHeaders.LINK, new Link(URI.create(application.getOntology().getURI()), LDT.ontology.getURI(), null));
diff --git a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java
index 479bf44d3..7ef0a28f1 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java
@@ -91,4 +91,7 @@ public static String getURI()
/** Origin property for subdomain-based application matching */
public static final ObjectProperty origin = m_model.createObjectProperty(NS + "origin");
+ /** Application property (for Link header rel) */
+ public static final ObjectProperty application = m_model.createObjectProperty( NS + "application" );
+
}
diff --git a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java
index 17414f681..e8dcaebfe 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java
@@ -92,9 +92,6 @@ public static String getURI()
/** Violation value property */
public static final DatatypeProperty violationValue = m_model.createDatatypeProperty( NS + "violationValue" );
- /** Access to property */
- public static final ObjectProperty access_to = m_model.createObjectProperty(NS + "access-to"); // TO-DO: move to client-side?
-
/** Request URI property */
public static final ObjectProperty requestUri = m_model.createObjectProperty(NS + "requestUri");
diff --git a/src/main/java/com/atomgraph/linkeddatahub/writer/XSLTWriterBase.java b/src/main/java/com/atomgraph/linkeddatahub/writer/XSLTWriterBase.java
index 00818c9de..f8a744e4d 100644
--- a/src/main/java/com/atomgraph/linkeddatahub/writer/XSLTWriterBase.java
+++ b/src/main/java/com/atomgraph/linkeddatahub/writer/XSLTWriterBase.java
@@ -27,7 +27,6 @@
import com.atomgraph.linkeddatahub.vocabulary.LAPP;
import com.atomgraph.client.vocabulary.LDT;
import com.atomgraph.core.util.Link;
-import com.atomgraph.core.vocabulary.SD;
import com.atomgraph.linkeddatahub.server.security.AuthorizationContext;
import com.atomgraph.linkeddatahub.vocabulary.FOAF;
import com.atomgraph.linkeddatahub.vocabulary.LDHC;
@@ -90,7 +89,6 @@ public abstract class XSLTWriterBase extends com.atomgraph.client.writer.XSLTWri
@Inject jakarta.inject.Provider> application;
@Inject jakarta.inject.Provider dataManager;
@Inject jakarta.inject.Provider xsltExecSupplier;
- @Inject jakarta.inject.Provider> modes;
@Inject jakarta.inject.Provider crc;
@Inject jakarta.inject.Provider> authorizationContext;
@@ -131,15 +129,6 @@ public Map getParameters(MultivaluedMap Map getParameters(MultivaluedMap getModes(Set namespaces)
- {
- return getModes().stream().map(Mode::get).collect(Collectors.toList());
- }
-
- /**
- * Returns a list of enabled layout modes.
- *
- * @return list of modes
- */
- public List getModes()
- {
- return modes.get();
- }
-
@Override
public Set getSupportedNamespaces()
{
diff --git a/src/main/java/com/atomgraph/linkeddatahub/writer/function/Construct.java b/src/main/java/com/atomgraph/linkeddatahub/writer/function/Construct.java
deleted file mode 100644
index 18b487fb9..000000000
--- a/src/main/java/com/atomgraph/linkeddatahub/writer/function/Construct.java
+++ /dev/null
@@ -1,148 +0,0 @@
-/**
- * Copyright 2022 Martynas Jusevičius
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-package com.atomgraph.linkeddatahub.writer.function;
-
-import com.atomgraph.client.vocabulary.SPIN;
-import com.atomgraph.linkeddatahub.vocabulary.LDH;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import javax.xml.transform.stream.StreamSource;
-import net.sf.saxon.s9api.ExtensionFunction;
-import net.sf.saxon.s9api.ItemType;
-import net.sf.saxon.s9api.ItemTypeFactory;
-import net.sf.saxon.s9api.OccurrenceIndicator;
-import net.sf.saxon.s9api.Processor;
-import net.sf.saxon.s9api.QName;
-import net.sf.saxon.s9api.SaxonApiException;
-import net.sf.saxon.s9api.SequenceType;
-import net.sf.saxon.s9api.XdmValue;
-import org.apache.jena.query.QueryExecution;
-import org.apache.jena.query.QuerySolutionMap;
-import org.apache.jena.rdf.model.Model;
-import org.apache.jena.rdf.model.ModelFactory;
-import org.apache.jena.rdf.model.Resource;
-import org.apache.jena.rdf.model.ResourceFactory;
-import org.apache.jena.riot.RDFLanguages;
-import org.apache.jena.vocabulary.RDF;
-
-/**
- * Constructs RDF instances from a given forClass -> constructor map.
- *
- * @author {@literal Martynas Jusevičius }
- */
-public class Construct implements ExtensionFunction
-{
-
- private final Processor processor;
-
- /**
- * Constructs function from the specified XSLT processor.
- *
- * @param processor Saxon processor
- */
- public Construct(Processor processor)
- {
- this.processor = processor;
- }
-
- @Override
- public QName getName()
- {
- return new QName(LDH.NS, "construct");
- }
-
- @Override
- public SequenceType getResultType()
- {
- return SequenceType.makeSequenceType(ItemType.DOCUMENT_NODE, OccurrenceIndicator.ONE);
- }
-
- @Override
- public SequenceType[] getArgumentTypes()
- {
- return new SequenceType[] /* map(xs:anyURI, xs:string*) */
- {
- SequenceType.makeSequenceType(new ItemTypeFactory(getProcessor()).getMapType(ItemType.ANY_URI,
- SequenceType.makeSequenceType(ItemType.STRING, OccurrenceIndicator.ZERO_OR_MORE)), OccurrenceIndicator.ONE)
- };
-
- }
-
- @Override
- public XdmValue call(XdmValue[] arguments) throws SaxonApiException
- {
- try
- {
- Model model = ModelFactory.createDefaultModel();
-
- if (!arguments[0].isEmpty())
- arguments[0].itemAt(0).asMap().forEach((forClass, constructors) ->
- {
- Resource instance = model.createResource();
- QuerySolutionMap qsm = new QuerySolutionMap();
- qsm.add(SPIN.THIS_VAR_NAME, instance);
-
- instance.addProperty(RDF.type, ResourceFactory.createResource(forClass.getStringValue()));
- constructors.stream().forEach(constructor ->
- {
- try (QueryExecution qex = QueryExecution.model(model).query(constructor.getStringValue()).initialBinding(qsm).build())
- {
- qex.execConstruct(model);
- }
- });
- }
- );
-
- return getProcessor().newDocumentBuilder().build(getSource(model));
- }
- catch (IOException ex)
- {
- throw new SaxonApiException(ex);
- }
- }
-
- /**
- * Creates stream source from RDF model.
- * The model is serialized using the RDF/XML syntax.
- *
- * @param model RDF model
- * @return XML stream source
- * @throws IOException I/O error
- */
- public StreamSource getSource(Model model) throws IOException
- {
- if (model == null) throw new IllegalArgumentException("Model cannot be null");
-
- try (ByteArrayOutputStream stream = new ByteArrayOutputStream())
- {
- model.write(stream, RDFLanguages.RDFXML.getName(), null);
- return new StreamSource(new ByteArrayInputStream(stream.toByteArray()));
- }
- }
-
- /**
- * Returns the associated XSLT processor.
- *
- * @return processor
- */
- public Processor getProcessor()
- {
- return processor;
- }
-
-}
diff --git a/src/main/java/com/atomgraph/server/mapper/BadGatewayExceptionMapper.java b/src/main/java/com/atomgraph/server/mapper/BadGatewayExceptionMapper.java
deleted file mode 100644
index 6ffb15faa..000000000
--- a/src/main/java/com/atomgraph/server/mapper/BadGatewayExceptionMapper.java
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2015 Martynas Jusevičius .
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.atomgraph.server.mapper;
-
-import com.atomgraph.core.MediaTypes;
-import com.atomgraph.core.exception.BadGatewayException;
-import jakarta.inject.Inject;
-import org.apache.jena.rdf.model.ResourceFactory;
-import jakarta.ws.rs.core.Response;
-import jakarta.ws.rs.ext.ExceptionMapper;
-import org.apache.jena.rdf.model.Resource;
-
-/**
- *
- * @author Martynas Jusevičius {@literal }
- */
-public class BadGatewayExceptionMapper extends ExceptionMapperBase implements ExceptionMapper
-{
-
- @Inject
- public BadGatewayExceptionMapper(MediaTypes mediaTypes)
- {
- super(mediaTypes);
- }
-
- @Override
- public Response toResponse(BadGatewayException ex)
- {
- Resource exRes = toResource(ex, Response.Status.INTERNAL_SERVER_ERROR,
- ResourceFactory.createResource("http://www.w3.org/2011/http-statusCodes#BadGateway"));
-
- return getResponseBuilder(exRes.getModel()).
- status(Response.Status.INTERNAL_SERVER_ERROR).
- build();
- }
-
-}
diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css
index c4e40f8f1..f1c7f6e02 100644
--- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css
+++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css
@@ -1,5 +1,17 @@
-html { height: 100%; }
-body { height: calc(100% - 120px); padding-top: 120px; padding-bottom: 0; }
+html { height: 100%; overscroll-behavior-y: none; }
+/* only the main navbar is fixed-top now; tab bar + action bar are sticky inside #tab-body */
+body { height: calc(100% - 51px); padding-top: 51px; padding-bottom: 0; }
+#tab-bar { position: sticky; top: 51px; z-index: 1000; margin-bottom: 0; }
+/* Override Bootstrap's overflow:auto which would make action-bar sticky relative to #tab-content instead of the viewport */
+#tab-content { overflow: visible; }
+#tab-bar.navbar-inner { min-height: 36px; padding: 4px 0; background: #ccc; box-shadow: none; }
+#tab-bar-list.nav-tabs { margin-bottom: 0; border-bottom: none; }
+#tab-bar-list.nav-tabs > li > a { color: #007fff; padding: 8px 12px; text-shadow: none; border-color: #aaa #aaa transparent; }
+#tab-bar-list.nav-tabs > li.active > a, #tab-bar .nav-tabs > li.active > a:hover { color: #555555; background: rgb(223, 223, 223); border-color: #aaa #aaa transparent; }
+#tab-bar-list > li { position: relative; }
+#tab-bar-list.nav-tabs > li > a { padding-right: 22px; }
+#tab-bar-list .tab-close { position: absolute; top: 6px; right: 6px; line-height: 1; font-size: 11px; color: #888; cursor: pointer; padding: 1px 3px; }
+#tab-bar-list .tab-close:hover { color: #333; }
body.embed { padding-top: 0; }
ul.dropdown-menu { max-height: 26em; overflow-x: hidden; overflow-y: auto; }
ul.dropdown-menu ul { margin: 0; }
@@ -14,19 +26,20 @@ ul.dropdown-menu ul { margin: 0; }
.dropdown-menu .btn-app-settings { background-color: inherit; display: block; text-align: left; width: 100%; padding-left: 20px; }
.dropdown-menu li form { margin-bottom: 0; }
.dropdown-menu li form button { width: 100%; text-align: left; }
-.navbar-form .input-append { margin-top: 10px; }
-.navbar-form .input-append select { margin-top: 0; height: 34px; }
+#collapsing-top-navbar .brand.context { display: inline-block; vertical-align: middle; }
+.navbar-form input#uri { width: calc(100% - 50px); }
.navbar-form .btn-search { background-image: url('../icons/ic_search_white_24px.svg'); background-position: center center; background-repeat: no-repeat; width: 34px; height: 34px; }
-.action-bar { background-color: #dfdfdf; }
+.action-bar { position: sticky; top: var(--action-bar-top, 51px); z-index: 999; background: #dfdfdf; padding: 0; box-shadow: none; }
.action-bar form { margin-bottom: 0; }
.action-bar .span7 .row-fluid > * { margin-top: 10px; }
+.action-bar .row-fluid > .span2, .action-bar .row-fluid > .span3 { margin-top: 10px; }
.action-bar .add-constructor, .dropdown-menu .add-constructor { background-color: inherit; display: block; text-align: left; width: 100%; }
.action-bar .add-constructor:hover { color: #ffffff; background-color: #007af5; }
.action-bar .breadcrumb { background-color: inherit; margin-bottom: 0; padding-left: 0; padding-top: 5px; }
.action-bar .breadcrumb .container-logo { background-image: url('../icons/folder.svg'); background-position: left center; background-repeat: no-repeat; padding-left: 28px; }
.action-bar .breadcrumb .item-logo { background-image: url('../icons/file.svg'); background-position: left center; background-repeat: no-repeat; padding-left: 28px; }
.action-bar .breadcrumb .btn-group { margin-top: -4px; }
-.action-bar #breadcrumb-nav > .label-info { padding: 8px 15px; margin-right: 8px; font-size: inherit; background-color: #9954bb; }
+.action-bar .breadcrumb-nav > .label-info { padding: 8px 15px; margin-right: 8px; font-size: inherit; background-color: #9954bb; }
.action-bar #doc-controls { text-align: right; padding-top: 5px; }
.action-bar #doc-controls .btn-edit { margin-top: -5px; margin-left: 10px; }
.action-bar p.alert { margin-bottom: 0; }
@@ -82,19 +95,19 @@ li button.btn-edit-constructors, li button.btn-add-data, li button.btn-add-ontol
.dropdown-menu > li > a.btn-list { background-image: url('../icons/view_list_black_24dp.svg'); background-position: 12px center; background-repeat: no-repeat; padding: 5px 5px 5px 40px; }
.dropdown-menu > li > a.btn-table { background-image: url('../icons/ic_border_all_black_24px.svg'); background-position: 12px center; background-repeat: no-repeat; padding: 5px 5px 5px 40px; }
.dropdown-menu > li > a.btn-grid { background-image: url('../icons/ic_grid_on_black_24px.svg'); background-position: 12px center; background-repeat: no-repeat; padding: 5px 5px 5px 40px; }
-#left-sidebar { display: none; width: 15%; position: fixed; left: 0; top: 106px; height: calc(100% - 106px); }
+.left-sidebar { display: none; width: 15%; position: fixed; left: 0; top: var(--action-bar-top, 51px); height: calc(100% - var(--action-bar-top, 51px)); z-index: 1001; }
@media (max-width: 979px)
{
body { padding-top: 0; }
- #left-sidebar { display: block; width: auto; position: unset; top: unset; height: auto; }
- #left-sidebar .nav { max-height: 20em; overflow: auto; }
+ .left-sidebar { display: block; width: auto; position: unset; top: unset; height: auto; z-index: auto; }
+ .left-sidebar .nav { max-height: 20em; overflow: auto; }
}
-#left-sidebar .nav-list > li > a.btn-container { padding-left: 24px; }
-#left-sidebar .nav-list > li > a { margin-left: 0; margin-right: 0; }
-#left-sidebar ul { max-height: 22em; overflow: auto; }
-#left-sidebar li > a { display: inline-block; }
-#left-sidebar .btn-latest { background-image: url('../icons/ic_new_releases_black_24px.svg'); background-color: inherit; }
-#left-sidebar .btn-geo { background-image: url('../icons/ic_location_on_black_24px.svg'); background-color: inherit; }
+.left-sidebar .nav-list > li > a.btn-container { padding-left: 24px; }
+.left-sidebar .nav-list > li > a { margin-left: 0; margin-right: 0; }
+.left-sidebar ul { max-height: 22em; overflow: auto; }
+.left-sidebar li > a { display: inline-block; }
+.left-sidebar .btn-latest { background-image: url('../icons/ic_new_releases_black_24px.svg'); background-color: inherit; }
+.left-sidebar .btn-geo { background-image: url('../icons/ic_location_on_black_24px.svg'); background-color: inherit; }
.btn.btn-expand-tree { height: 24px; width: 24px; background-image: url('../icons/expand_more_black_24dp.svg'); }
.btn.btn-expand-tree:hover, .btn.btn-expand-tree:focus { background-position: 0 0; }
@@ -107,10 +120,10 @@ li button.btn-edit-constructors, li button.btn-add-data, li button.btn-add-ontol
.caret.caret-reversed { border-bottom: 4px solid #000000; border-top-width: 0; }
.faceted-nav input[type=checkbox]:checked + span { font-weight: bold; }
.parallax-nav a { cursor: pointer; }
-#content-body { min-height: calc(100% - 14em); }
-#content-body > [about].row-fluid { overflow-x: auto; margin-bottom: 20px; }
-#content-body > [about].row-fluid, .constructor-triple.row-fluid { border-bottom: 2px solid rgb(223, 223, 223); }
-#content-body > [about].row-fluid.drag-over { border-bottom: 4px dotted #0f82f5; }
+.content-body { min-height: calc(100% - 14em); margin-top: 14px; }
+.content-body > [about].row-fluid.block { overflow-x: auto; margin-bottom: 20px; }
+.content-body > [about].row-fluid.block, .constructor-triple.row-fluid { border-bottom: 2px solid rgb(223, 223, 223); }
+.content-body > [about].row-fluid.block.drag-over { border-bottom: 4px dotted #0f82f5; }
.row-fluid.block { max-height: 80em; }
.row-fluid.block .drag-handle { display: none; width: 30px; background-color: #149bdf; background-image: radial-gradient(circle at 3px 3px, #0480be 1px, transparent 1.5px); background-size: 6px 6px; border-radius: 2px; cursor: move; }
.list-mode.active { background-image: url('../icons/ic_navigate_before_black_24px.svg'); }
diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/admin/acl/layout.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/admin/acl/layout.xsl
index 0c4a69c4f..08664947c 100644
--- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/admin/acl/layout.xsl
+++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/admin/acl/layout.xsl
@@ -32,18 +32,6 @@ exclude-result-prefixes="#all">