From 737fed81f74dbf51c75d859c93d386b8b8688f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Wed, 19 Aug 2026 20:18:31 +0200 Subject: [PATCH 01/18] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index d98a1c83e..372c0ac13 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.atomgraph linkeddatahub - 5.9.1 + 5.9.2-SNAPSHOT ${packaging.type} AtomGraph LinkedDataHub @@ -46,7 +46,7 @@ https://github.com/AtomGraph/LinkedDataHub scm:git:git://github.com/AtomGraph/LinkedDataHub.git scm:git:git@github.com:AtomGraph/LinkedDataHub.git - linkeddatahub-5.9.1 + linkeddatahub-5.5.4 From 5e5fb816f6b264d8c911eb63ab853ffe47fb8745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 23 Aug 2026 22:45:45 +0200 Subject: [PATCH 02/18] Client-side constructor instantiation: one prototype per instance (#357) * Client-side constructor instantiation: one prototype per instance An edited resource is one rdf:Description with N rdf:types, so its constructor template must be one rdf:Description with those N types and the union of their constructor properties - the template mirrors the instance. /ns?forClass= minted a fresh bnode prototype per class, and every consumer downstream assumed the single-prototype shape: document-order-dependent property picks (the intermittently vanishing app-settings Description field), parent-type match misses, ldh:SetResourceID orphaning N-1 prototypes in EDIT flows, and xs:anyURI? cardinality errors on multi-range predicates. Instead of asking the server to construct, the client fetches the constructor queries with one SPARQL SELECT (rdfs:subClassOf* closure, DISTINCT - cycle-safe, shared constructors dedup) and expands the CONSTRUCT templates onto a single instance: - ldh:constructor-query builds the SELECT; ldh:construct-instance parses each sp:text with SPARQL.js (no execution - constructors must have an empty WHERE clause; others are skipped with a warning), substitutes ?this with one shared instance label, prefixes marker bnodes per constructor, collapses same-(predicate, range) duplicates, and serializes through ldh:triples-to-descriptions - ldh:load-constructed-doc/set-constructed-doc rewired to the SELECT + instantiation; all EDIT/violation/CREATE chains covered unchanged - ldh:construct-forClass split by use-when: SAXON keeps /ns?forClass= (SSR), SaxonJS instantiates client-side - covers the tunnel-less bs2:FormControl default (SSR EditMode, ldh:DocumentForm, ldh:AppSettingsForm) - add-value flow tokenizes multi-type @typeof (was a latent 500) - forClass cardinalities relaxed to xs:anyURI* where a predicate can have several ranges across the type set (spin:query - sp:Query vs sp:Select); bs2:FormControlTypeLabel iterates all ranges like its marker-bnode sibling - GET-ns-constructors.sh pins the constructor SELECT server contract Co-Authored-By: Claude Fable 5 * Remove /ns?forClass= - constructor instantiation is client-side only The client-side instantiation left server-side rendering as the last consumer of /ns?forClass=. SSR's constructor controls were transient paint - the client re-render replaces the form on load - so the SAXON ldh:construct-forClass becomes an empty-document stub (the ac:construct stub pattern): server-rendered edit forms show data properties, the client supplies the constructor controls. - BREAKING: Namespace no longer serves ?forClass= constructed instances; it serves SPARQL queries and the raw ontology graph only. Removes the Constructor usage and the unmapped-IRIException 500 on malformed class URIs - GET-namespace-forClass-rdfs.sh removed with the feature; the constructor SELECT contract is covered by GET-ns-constructors.sh - CacheInvalidationFilter's forClass ban stays: it serves the typeahead ?forClass= filtering on document URLs, not the removed construct - $ac:forClass in client.xsl stays: the shadowed Web-Client templates compiled into the SEF still reference it Co-Authored-By: Claude Fable 5 * Read SPARQL.js parse output via JSON round-trip SaxonJS does not marshal plain JS arrays for ixsl:get() access - the 'where' array came back as an empty sequence and the next ixsl:get() failed with a cardinality error. Serialize the parse tree with JSON.stringify + json-to-xml (the form.xsl SELECT-builder idiom) and read the CONSTRUCT template as json:map elements instead. Co-Authored-By: Claude Fable 5 * Keep violation machinery types out of the row-form instance type set ldh:row-form-submit-violation swept rdf:types from every non-document Description in the violation response, including the spin:ConstraintViolation / http:Response ones. With per-class prototypes that was harmless (junk classes just yielded no prototype), but the union-typed single prototype inherits those types and then fails bs2:FormControl's every-type-satisfies subset test against the edited resource - constructor controls vanished from the violation re-render. Filter the swept Descriptions with the same type list the bs2:RowForm/bs2:Form suppression templates use. Co-Authored-By: Claude Fable 5 * Single-source the violation machinery type list as $system-types One global drives the bs2:RowForm/bs2:Form suppression matches and the row-form violation type sweep, so the list cannot silently diverge. Co-Authored-By: Claude Fable 5 * Drop base-uri override from the modal violation re-render ldh:render-modal-form-violation forced base-uri to the violation response's base URI, a carry-over from before the render dispatchers gained the about-fallback (both from 197ab9cd3). In the creation/edit flows the two values coincide, but in the app-settings flow the subject is a urn: outside the response's HTTP space, so the override flipped bs2:FormControl's $show-subject default and exposed the URI control on constraint violations while the initial render kept it hidden. The submit-handler context carries no base-uri, so the dispatchers now fall back to $ctx('about') on the violation path exactly as on the initial render. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 8 ++ .../GET-namespace-forClass-rdfs.sh | 22 --- .../query/GET-ns-constructors.sh | 32 +++++ .../linkeddatahub/resource/Namespace.java | 48 +------ .../xsl/bootstrap/2.3.2/client/form.xsl | 12 +- .../xsl/bootstrap/2.3.2/client/functions.xsl | 126 +++++++++++++++++- .../xsl/bootstrap/2.3.2/client/modal.xsl | 2 +- .../xsl/bootstrap/2.3.2/imports/default.xsl | 50 ++++--- 8 files changed, 205 insertions(+), 95 deletions(-) delete mode 100644 http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh create mode 100755 http-tests/sparql-protocol/query/GET-ns-constructors.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 079089152..7d4c1033f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [Unreleased] +### Changed +- Constructor instances are instantiated client-side: one SPARQL SELECT fetches the type set's `spin:constructor` queries (subclass closure, deduplicated) and their CONSTRUCT templates are expanded onto a single instance typed with all the resource's classes — the template mirrors the instance. Fixes nondeterministically missing constructor-supplied inputs (the intermittently vanishing app-settings Description field) and multi-range predicate cardinality errors; same-range duplicate properties are collapsed. Constructors must have an empty `WHERE` clause to be client-instantiable +- Server-rendered edit forms show data properties only; the client re-render supplies the constructor controls (`ldh:construct-forClass` is an empty stub under SAXON, the `ac:construct` stub pattern) + +### Removed +- **BREAKING**: `/ns?forClass=` constructed-instance responses — the client-side instantiation is the only consumer path; the `Namespace` endpoint serves SPARQL queries and the raw ontology graph only + ## [5.9.1] - 2026-08-19 ### Added - Inline creation in views: views carrying the new `ldh:container` metadata render a Create button that creates a linked instance in that container (#351) diff --git a/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh b/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh deleted file mode 100644 index e363dfbcf..000000000 --- a/http-tests/document-hierarchy/GET-namespace-forClass-rdfs.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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" - -# sp:Describe is declared only as rdfs:Class (not owl:Class) in sp.ttl. -# OntologyFilter must promote rdfs:Class to owl:Class during materialization so -# that OWL2 profiles recognise third-party vocab terms and return their SPIN constructors. - -response=$(curl -k -f -s \ - -G \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -H "Accept: application/rdf+xml" \ - --data-urlencode "forClass=http://spinrdf.org/sp#Describe" \ - "${END_USER_BASE_URL}ns") - -# response must be non-empty: sp:Describe must be recognised as an OntClass -echo "$response" | grep -q "http://spinrdf.org/sp#Describe" diff --git a/http-tests/sparql-protocol/query/GET-ns-constructors.sh b/http-tests/sparql-protocol/query/GET-ns-constructors.sh new file mode 100755 index 000000000..349e1fe3a --- /dev/null +++ b/http-tests/sparql-protocol/query/GET-ns-constructors.sh @@ -0,0 +1,32 @@ +#!/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" + +# the constructor SELECT the client-side instantiation relies on: for a type set it returns the +# spin:constructor texts of the classes and their superclasses, deduplicated + +query='SELECT DISTINCT ?constructor ?text WHERE { VALUES ?type { } ?type * ?class . ?class ?constructor . ?constructor ?text . }' + +results=$(curl -k -f -s -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/sparql-results+xml" \ + "${END_USER_BASE_URL}ns" \ + --data-urlencode "query=${query}") + +# the end-user app class's own constructor is returned +echo "$results" | grep -q "https://w3id.org/atomgraph/linkeddatahub/apps#EndUserApplicationConstructor" + +# the generic constructors attached to lapp:Application by the default ontology are returned +echo "$results" | grep -q "https://w3id.org/atomgraph/linkeddatahub#TitleConstructor" + +# the constructor texts are returned (CONSTRUCT templates the client instantiates) +count=$(echo "$results" | xmllint --xpath "count(//*[local-name() = 'binding'][@name = 'text']/*[local-name() = 'literal'][contains(., 'CONSTRUCT')])" -) +if [ "$count" -lt 3 ]; then + echo "DEBUG: Expected at least 3 constructor texts, got: $count" + exit 1 +fi diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java index 50cd157ca..ac3278e2c 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java @@ -16,8 +16,6 @@ */ package com.atomgraph.linkeddatahub.resource; -import com.atomgraph.client.util.Constructor; -import com.atomgraph.client.vocabulary.AC; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Request; import jakarta.ws.rs.core.Response; @@ -46,13 +44,10 @@ import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.SecurityContext; import jakarta.ws.rs.core.UriInfo; -import org.apache.jena.irix.IRIx; import org.apache.jena.ontapi.model.OntModel; import org.apache.jena.query.DatasetFactory; import org.apache.jena.query.Query; import org.apache.jena.query.QueryFactory; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.ModelFactory; import org.apache.jena.update.UpdateRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -99,18 +94,17 @@ public Namespace(@Context Request request, @Context UriInfo uriInfo, /** * If SPARQL query is provided, returns its result over the in-memory namespace ontology graph. - * If query is not provided - *
    - *
  • returns constructed instance if forClass URL param value (ontology class URI) is provided
  • - *
  • otherwise, returns the namespace ontology graph (which is standalone, i.e. not the full ontology imports closure)
  • - *
- * + * If query is not provided, returns the namespace ontology graph (which is standalone, i.e. + * not the full ontology imports closure). + * Constructor instances are instantiated client-side from the ontology's spin:constructor + * queries (fetched with a SPARQL query on this endpoint). + * * @param query SPARQL query string (optional) * @param defaultGraphUris default graph URI (ignored) * @param namedGraphUris named graph URIs (ignored) - * + * * {@link com.atomgraph.linkeddatahub.server.model.impl.Dispatcher#getNamespace()} - * + * * @return response */ @Override @@ -121,20 +115,6 @@ public Response get(@QueryParam(QUERY) Query query, // if query param is not provided and the app is end-user, return the namespace ontology associated with this document if (query == null) { - // construct instances for a list of ontology classes whose URIs are provided as ?forClass - if (getUriInfo().getQueryParameters().containsKey(AC.forClass.getLocalName())) - { - List forClasses = getUriInfo().getQueryParameters().get(AC.forClass.getLocalName()); - Model instances = ModelFactory.createDefaultModel(); - - forClasses.stream(). - map(forClass -> Optional.ofNullable(getOntology().getOntClass(checkURI(forClass).toString()))). - flatMap(Optional::stream). - forEach(forClass -> new Constructor().construct(forClass, instances, getApplication().getBase().getURI())); - - return getResponseBuilder(instances).build(); - } - if (getApplication().canAs(EndUserApplication.class)) { // the application ontology MUST use a URI! This is the URI this ontology endpoint is deployed on by the Dispatcher class @@ -175,20 +155,6 @@ public Response post(UpdateRequest update, @QueryParam(USING_GRAPH_URI) List endpoint", Status.METHOD_NOT_ALLOWED); } - /** - * Checks URI syntax. Throws exception if invalid. - * - * @param classIRIStr URI string - * @return IRI - */ - public static IRIx checkURI(String classIRIStr) - { - if (classIRIStr == null) throw new IllegalArgumentException("URI String cannot be null"); - - // IRIx.create() validates and throws IRIException on bad URIs - return IRIx.create(classIRIStr); - } - /** * Returns URI of this resource. * diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl index 81913d637..b71cb241f 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl @@ -101,13 +101,16 @@ WHERE + + + - + - + @@ -1208,7 +1211,7 @@ WHERE - + @@ -1342,7 +1345,8 @@ WHERE ldh:row-form-submit-violation - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Constructor skipped: a non-empty WHERE clause cannot be instantiated client-side: + + + + + + + Constructor template triple skipped: variable predicate + + + + + + + + + + + + + + + + + + + + + + + + + + + _:instance + &rdf;type + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -579,13 +687,18 @@ exclude-result-prefixes="#all" "/> - + - + - + @@ -594,8 +707,9 @@ exclude-result-prefixes="#all" - - + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl index 984db0b64..57a5c1e41 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl @@ -2217,10 +2217,10 @@ LIMIT 10 + - + + - - - - + + + + + + @@ -1066,25 +1072,27 @@ exclude-result-prefixes="#all" - + - - - - + - - - - - - - - - - + + + + + + + + + + + + + + + @@ -1130,7 +1138,7 @@ exclude-result-prefixes="#all" - + From 98370aa4733ed3f4e9e4f21e2cc76b8cc6ee805a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 23 Aug 2026 22:59:35 +0200 Subject: [PATCH 03/18] Align modal violation re-render context with the initial-render chains (#359) Remaining leftovers of the 197ab9cd3 shared-chain refactor, sibling fixes to the base-uri override drop: - property-uris harvested from everything except the edited resource (inverted predicate, contradicting the function's own comment and ldh:load-edited-resource) - property labels degraded to the local-name fallback on modal violation re-renders while metadata was fetched for spin:/sh: machinery terms instead. Flip to @rdf:about = $about. - object-uris (modal + row-form chains) and the row-form property-uris swept the whole response body including the violation machinery Descriptions, causing junk label lookups. Exclude $system-types-typed Descriptions, mirroring the instance type harvest. - the shared violation render-ctx stamped the Container/Item required function for all flows while the app-settings initial chain passes always-true; masked today by ldh:AppSettingsForm's hardcoded required=true(). Stamp 'required' per flow in the response handlers alongside 'render-fn', and correct the stale 'only used for Container/Item instances' comment that hid this. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 4 ++++ .../xsl/bootstrap/2.3.2/client/form.xsl | 4 ++-- .../xsl/bootstrap/2.3.2/client/modal.xsl | 21 +++++++++---------- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4c1033f..e0e174d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ### Removed - **BREAKING**: `/ns?forClass=` constructed-instance responses — the client-side instantiation is the only consumer path; the `Namespace` endpoint serves SPARQL queries and the raw ontology graph only +### Fixed +- Modal violation re-renders harvested `property-uris` from everything except the edited resource, degrading property labels to their local-name fallback; violation/response machinery no longer pollutes the `property-uris`/`object-uris` metadata harvests +- The `required` function on the modal violation context is stamped per flow by the response handlers, matching each flow's initial-render chain (the shared Container/Item test disagreed with the app-settings chain) + ## [5.9.1] - 2026-08-19 ### Added - Inline creation in views: views carrying the new `ldh:container` metadata render a Create button that creates a linked instance in that container (#351) diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl index b71cb241f..fdfd47c65 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl @@ -1355,8 +1355,8 @@ WHERE 'types': $types, 'forClass': $types, 'endpoint': sd:endpoint(), - 'property-uris': distinct-values($body/rdf:RDF/*[not(@rdf:about = $doc-uri)]/*/concat(namespace-uri(), local-name())), - 'object-uris': distinct-values($body/rdf:RDF/*/*/@rdf:resource[not(key('resources', .))]) + 'property-uris': distinct-values($body/rdf:RDF/*[not(@rdf:about = $doc-uri)][not(rdf:type/@rdf:resource = $system-types)]/*/concat(namespace-uri(), local-name())), + 'object-uris': distinct-values($body/rdf:RDF/*[not(rdf:type/@rdf:resource = $system-types)]/*/@rdf:resource[not(key('resources', .))]) } ), map{ 'duplicates': 'use-last' })"/> diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl index 57a5c1e41..f5730c669 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl @@ -1515,15 +1515,15 @@ LIMIT 10 - + - + - + @@ -1792,9 +1792,9 @@ LIMIT 10 - + - + @@ -2190,8 +2190,8 @@ LIMIT 10 'body': $body, 'types': $types, 'endpoint': sd:endpoint(), - 'property-uris': distinct-values($body/rdf:RDF/*[not(@rdf:about = $about)]/*/concat(namespace-uri(), local-name())), - 'object-uris': distinct-values($body/rdf:RDF/*/*/@rdf:resource[not(key('resources', .))]) + 'property-uris': distinct-values($body/rdf:RDF/*[@rdf:about = $about]/*/concat(namespace-uri(), local-name())), + 'object-uris': distinct-values($body/rdf:RDF/*[not(rdf:type/@rdf:resource = $system-types)]/*/@rdf:resource[not(key('resources', .))]) } ), map{ 'duplicates': 'use-last' })"/> @@ -2210,20 +2210,19 @@ LIMIT 10 on-failure="ldh:promise-failure#1"/> - + - + From 2637e5ca9988f259c65665b5b553d11d61affc46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Mon, 24 Aug 2026 14:37:47 +0200 Subject: [PATCH 04/18] Self-federation: cross-instance browse, query, and write (#360) * Self-federation: origin validators through the proxy, foreign write targets, federation suite The federation test (First Principles ch. 18): one instance's client browses, queries and writes against another, the wire carrying only spec-terms. Most of the machinery already existed - the proxy forwards arbitrary methods with bodies and delegated agent identity, panes carry data-base/data-endpoint from forwarded Link headers so ldt:base() and sd:endpoint() (and with them the ns-bound constructor SELECT and metadata fetches) already resolve against the remote app on remote panes. What was missing: - the proxy's Model branch stamped re-serialization validators, so a client editing a proxied document sent an If-Match the origin had never issued - every cross-instance write 412ed. The origin's ETag/Last-Modified now forward (replacing locally stamped values); the ResultSet branch keeps builder validators. - the Add-data flow rejected foreign targets with a guard written when the remote end refused proxied writes; with delegation the target instance's ACL is the arbiter, so the guard is gone and a remote 403 surfaces as the form error. Import-ontology keeps its local-target guard for a different reason, now stated: the constructor derivation is scoped to the local /sparql endpoint. - nothing proved any of it. New http-tests/federation/ suite runs the browse/query/write triad between the root and test dataspaces at two origins: Link-header endpoint discovery, SPARQL against the discovered endpoint, constructor SELECT against the remote ns, ETag parity between proxied and direct reads, a graph-scoped SPARQL Update PATCH under the origin's If-Match (stale precondition 412s), and the unauthenticated negative. The dataspaces share a triplestore below the HTTP surface in the test config; the meeting surface exercised is the full HTTP stack. Co-Authored-By: Claude Fable 5 * Forward conditional request headers through the proxy; fix federation negative CI caught two gaps in the cross-instance write path: - the proxy set only Accept and User-Agent on the outbound request, so If-Match never reached the origin - a proxied preconditioned write ran unconditionally (stale If-Match got 204 instead of 412, losing optimistic concurrency). Forward the conditional request headers (If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since); identity stays delegated (not via Authorization/Cookie) and Range is excluded (byte offsets don't survive Model re-serialization). - the negative test used an anonymous request, which is not actually anonymous at the origin: a proxied request with no user certificate rides the server's own credential on the proxy->origin leg. Replace it with a delegated write by the signed-up agent, which is a known federation identity not authorized on B - so B's ACL refuses it, which is the property the test means to assert. Both PATCH tests now echo the actual status codes (debug-first). Co-Authored-By: Claude Fable 5 * Relay proxied 4xx/5xx error responses verbatim The header-forwarding fix made the origin actually evaluate proxied write preconditions - and exposed that the proxy could not relay the resulting error responses. A 4xx/5xx from the origin carries a diagnostic body; getResponse routed it through the Model branch, where readEntity(Model.class) throws on the non-RDF/error body, and the catch turned it into 502. So a proxied stale If-Match (origin 412) and an unauthorized delegated write (origin 403) both surfaced as 502. Relay CLIENT_ERROR/SERVER_ERROR responses raw - status, Content-Type and origin validators forwarded, body piped - skipping the re-serialization branches that only make sense for negotiable success representations. Existing proxy error tests assert status only and are unaffected; the acl#agent leak checks are on direct (non-proxied) requests. Co-Authored-By: Claude Fable 5 * Match Accept on the conditional PATCH to the read's variant The valid If-Match returned 412 because LDH ETags are variant-specific (getVariantEntityTag folds the negotiated media type into the tag): the test read the ETag with Accept: application/rdf+xml but the conditional PATCH sent none, so the origin computed the precondition tag for a different variant. The local edit flow already sends Accept: application/rdf+xml on its PATCH for this reason. Align the test's conditional PATCHes with the read. Not a proxy defect - the proxy forwards Accept and If-Match faithfully (stale correctly 412s). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 5 + .../federation/GET-remote-document-links.sh | 51 ++++++++++ .../PATCH-remote-document-unauthorized.sh | 66 +++++++++++++ .../federation/PATCH-remote-document.sh | 97 +++++++++++++++++++ .../federation/POST-remote-endpoint-query.sh | 51 ++++++++++ .../federation/POST-remote-ns-constructors.sh | 37 +++++++ http-tests/run.sh | 2 + .../filter/request/ProxyRequestFilter.java | 59 +++++++++-- .../xsl/bootstrap/2.3.2/client/modal.xsl | 38 +++----- 9 files changed, 375 insertions(+), 31 deletions(-) create mode 100755 http-tests/federation/GET-remote-document-links.sh create mode 100755 http-tests/federation/PATCH-remote-document-unauthorized.sh create mode 100755 http-tests/federation/PATCH-remote-document.sh create mode 100755 http-tests/federation/POST-remote-endpoint-query.sh create mode 100755 http-tests/federation/POST-remote-ns-constructors.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e174d10..9408573a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ ## [Unreleased] +### Added +- `http-tests/federation/` self-federation suite: one dataspace's client browses, queries and writes against another origin's dataspace through the Linked Data proxy — runtime endpoint discovery from forwarded `Link` headers, the constructor SELECT against the remote `ns`, and a graph-scoped SPARQL Update `PATCH` under the origin's `If-Match` precondition, plus the unauthenticated negative + ### Changed - Constructor instances are instantiated client-side: one SPARQL SELECT fetches the type set's `spin:constructor` queries (subclass closure, deduplicated) and their CONSTRUCT templates are expanded onto a single instance typed with all the resource's classes — the template mirrors the instance. Fixes nondeterministically missing constructor-supplied inputs (the intermittently vanishing app-settings Description field) and multi-range predicate cardinality errors; same-range duplicate properties are collapsed. Constructors must have an empty `WHERE` clause to be client-instantiable - Server-rendered edit forms show data properties only; the client re-render supplies the constructor controls (`ldh:construct-forClass` is an empty stub under SAXON, the `ac:construct` stub pattern) +- "Add data" accepts foreign target documents: the proxied append carries the delegated agent identity, so the target instance's access control arbitrates and its refusal surfaces as the form error (the local-only guard is gone); "Import ontology" keeps the local-target requirement because its constructor derivation is scoped to the local `/sparql` endpoint ### Removed - **BREAKING**: `/ns?forClass=` constructed-instance responses — the client-side instantiation is the only consumer path; the `Namespace` endpoint serves SPARQL queries and the raw ontology graph only @@ -9,6 +13,7 @@ ### Fixed - Modal violation re-renders harvested `property-uris` from everything except the edited resource, degrading property labels to their local-name fallback; violation/response machinery no longer pollutes the `property-uris`/`object-uris` metadata harvests - The `required` function on the modal violation context is stamped per flow by the response handlers, matching each flow's initial-render chain (the shared Container/Item test disagreed with the app-settings chain) +- The Linked Data proxy forwards the origin's `ETag`/`Last-Modified` on RDF responses instead of stamping re-serialization validators, forwards conditional request headers (`If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`) to the origin, and relays the origin's `4xx`/`5xx` error responses verbatim rather than parsing their bodies as RDF (which turned a proxied `412`/`403` into a `502`) — so `If-Match`-preconditioned and access-controlled writes against proxied documents are evaluated at the origin and their real status reaches the client ## [5.9.1] - 2026-08-19 ### Added diff --git a/http-tests/federation/GET-remote-document-links.sh b/http-tests/federation/GET-remote-document-links.sh new file mode 100755 index 000000000..0417656a8 --- /dev/null +++ b/http-tests/federation/GET-remote-document-links.sh @@ -0,0 +1,51 @@ +#!/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" + +# Federation browse leg: instance A's client dereferences instance B's document through A's +# Linked Data proxy. The wire carries a conneg GET; B's hypermedia (Link headers) is forwarded +# so the client discovers B's SPARQL endpoint and application at runtime, and B's ETag is +# forwarded so preconditioned writes against B validate. The two dataspaces share a triplestore +# below the HTTP surface (test config), but meet only through the full HTTP stack here. + +remote_base="https://test.localhost:4443/" + +headers=$(mktemp) +trap 'rm -f "$headers"' EXIT + +# dereference B's root document through A's proxy + +curl -k -f -s -o /dev/null -D "$headers" \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + --data-urlencode "uri=${remote_base}" \ + "$END_USER_BASE_URL" + +# B's SPARQL endpoint is discovered from the forwarded Link header, not configured + +grep -i '^link:' "$headers" | tr ',' '\n' | grep 'sparql-service-description#endpoint' | grep -q "${remote_base}sparql" + +# B's application URI is forwarded too (it marks the remote as a Linked Data application) + +grep -i '^link:' "$headers" | tr ',' '\n' | grep -q 'linkeddatahub/apps#application' + +# the proxied response carries B's own ETag (resource-state validator), enabling If-Match writes + +proxied_etag=$(grep -i '^etag:' "$headers" | tr -d '\r' | awk '{print $2}') +direct_etag=$(curl -k -f -s -I \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + "$remote_base" \ +| grep -i '^etag:' | tr -d '\r' | awk '{print $2}') + +echo "DEBUG: proxied ETag: $proxied_etag direct ETag: $direct_etag" +if [ -z "$proxied_etag" ] || [ "$proxied_etag" != "$direct_etag" ]; then + echo "DEBUG: proxied ETag does not match the origin's ETag" + exit 1 +fi diff --git a/http-tests/federation/PATCH-remote-document-unauthorized.sh b/http-tests/federation/PATCH-remote-document-unauthorized.sh new file mode 100755 index 000000000..56ced89a7 --- /dev/null +++ b/http-tests/federation/PATCH-remote-document-unauthorized.sh @@ -0,0 +1,66 @@ +#!/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" + +# Federation negative: B's access control arbitrates the meeting. The signed-up agent is a +# federation identity that is NOT granted write on B (no authorization is created for it on +# test.localhost). Its delegated cross-instance PATCH is refused - the proxy forwards the +# identity, but B's ACL, not the proxy, decides. A truly anonymous request cannot express this: +# a proxied request with no user certificate rides the server's own credential to the origin. + +remote_base="https://test.localhost:4443/" + +# create the target on B as the owner (authorized), so only the *writer* differs from the +# positive test + +item=$(create-item.sh \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + -b "$remote_base" \ + --title "Federation unauthorized target" \ + --slug "federation-unauthorized-$(date +%s)" \ + --container "$remote_base") + +update=$(cat < + +INSERT +{ + <${item}> dct:description "Should not land" . +} +WHERE {} +EOF +) + +# the agent's delegated write is refused by B (401 if B declines the identity, 403 if it is +# recognised but unauthorized - either way not a success) + +code=$(curl -k -w "%{http_code}" -o /dev/null -s \ + -X PATCH \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H 'Content-Type: application/sparql-update' \ + --url-query "uri=${item}" \ + --data-binary "$update" \ + "$END_USER_BASE_URL") + +echo "DEBUG: unauthorized cross-instance PATCH returned: $code" +if ! echo "$code" | grep -qE "^($STATUS_UNAUTHORIZED|$STATUS_FORBIDDEN)$"; then + echo "DEBUG: expected 401 or 403 for the unauthorized delegated write, got: $code" + exit 1 +fi + +# the delta did not land + +if curl -k -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "$item" \ +| grep -q "Should not land"; then + echo "DEBUG: unauthorized delta landed on the remote document" + exit 1 +fi diff --git a/http-tests/federation/PATCH-remote-document.sh b/http-tests/federation/PATCH-remote-document.sh new file mode 100755 index 000000000..74d9dbb76 --- /dev/null +++ b/http-tests/federation/PATCH-remote-document.sh @@ -0,0 +1,97 @@ +#!/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" + +# Federation write leg: A's client submits a graph-scoped SPARQL Update delta as a PATCH against +# B's document, through A's proxy, under an If-Match precondition using B's own ETag (forwarded +# by the proxy on the read). The proxy forwards the method, body and the agent's identity; B's +# ACL arbitrates. This is the read-write half of the federation test: browse, query, and write +# crossing the wire on spec-terms only. + +remote_base="https://test.localhost:4443/" + +# create the document on B (the owner is authorized on both dataspaces in the test setup) + +item=$(create-item.sh \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + -b "$remote_base" \ + --title "Federation write target" \ + --slug "federation-patch-$(date +%s)" \ + --container "$remote_base") + +# read the document through A's proxy, capturing B's ETag for the precondition + +etag=$(curl -k -f -s -o /dev/null -D - \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + --data-urlencode "uri=${item}" \ + "$END_USER_BASE_URL" \ +| grep -i '^etag:' | tr -d '\r' | awk '{print $2}') + +echo "DEBUG: ETag for If-Match: $etag" +if [ -z "$etag" ]; then + echo "DEBUG: no ETag on the proxied response" + exit 1 +fi + +update=$(cat < + +INSERT +{ + <${item}> dct:description "Updated across instances" . +} +WHERE {} +EOF +) + +# a stale precondition is rejected by B - proves the proxy forwards If-Match and B evaluates it. +# Accept must match the read: LDH ETags are variant-specific (the negotiated media type folds into +# the tag), so the conditional PATCH negotiates the same rdf+xml variant the ETag above was read for. + +stale_code=$(curl -k -w "%{http_code}" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Content-Type: application/sparql-update' \ + -H 'Accept: application/rdf+xml' \ + -H 'If-Match: "stale"' \ + --url-query "uri=${item}" \ + --data-binary "$update" \ + "$END_USER_BASE_URL") + +echo "DEBUG: stale If-Match returned: $stale_code (expected $STATUS_PRECONDITION_FAILED)" +if [ "$stale_code" != "$STATUS_PRECONDITION_FAILED" ]; then + exit 1 +fi + +# the delta with B's current ETag succeeds + +valid_code=$(curl -k -w "%{http_code}" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Content-Type: application/sparql-update' \ + -H 'Accept: application/rdf+xml' \ + -H "If-Match: $etag" \ + --url-query "uri=${item}" \ + --data-binary "$update" \ + "$END_USER_BASE_URL") + +echo "DEBUG: valid If-Match returned: $valid_code (expected $STATUS_NO_CONTENT)" +if [ "$valid_code" != "$STATUS_NO_CONTENT" ]; then + exit 1 +fi + +# the delta landed on B - confirmed on B directly, not through the proxy + +curl -k -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "$item" \ +| grep "Updated across instances" > /dev/null diff --git a/http-tests/federation/POST-remote-endpoint-query.sh b/http-tests/federation/POST-remote-endpoint-query.sh new file mode 100755 index 000000000..c057a4b32 --- /dev/null +++ b/http-tests/federation/POST-remote-endpoint-query.sh @@ -0,0 +1,51 @@ +#!/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" + +# Federation query leg: A's client poses a SPARQL Protocol query to B's endpoint, with the +# endpoint URL taken from B's forwarded Link header (runtime discovery, not configuration). +# The query request rides A's proxy, which forwards the method, body and media type. + +remote_base="https://test.localhost:4443/" + +headers=$(mktemp) +trap 'rm -f "$headers"' EXIT + +curl -k -f -s -o /dev/null -D "$headers" \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/rdf+xml" \ + --data-urlencode "uri=${remote_base}" \ + "$END_USER_BASE_URL" + +endpoint=$(grep -i '^link:' "$headers" | tr ',' '\n' | grep 'sparql-service-description#endpoint' | sed 's/.*<\([^>]*\)>.*/\1/') + +echo "DEBUG: discovered endpoint: $endpoint" +if [ -z "$endpoint" ]; then + echo "DEBUG: no sd:endpoint Link header forwarded" + exit 1 +fi + +# query B's root document graph on the discovered endpoint, through A's proxy + +query="SELECT * WHERE { GRAPH <${remote_base}> { ?s ?p ?o } } LIMIT 1" + +count=$(curl -k -f -s \ + -X POST \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-query" \ + -H "Accept: application/sparql-results+xml" \ + --url-query "uri=${endpoint}" \ + --data-binary "$query" \ + "$END_USER_BASE_URL" \ +| xmllint --xpath "count(//*[local-name() = 'result'])" -) + +if [ "$count" != "1" ]; then + echo "DEBUG: Expected 1 result from the discovered remote endpoint, got: $count" + exit 1 +fi diff --git a/http-tests/federation/POST-remote-ns-constructors.sh b/http-tests/federation/POST-remote-ns-constructors.sh new file mode 100755 index 000000000..c15ece244 --- /dev/null +++ b/http-tests/federation/POST-remote-ns-constructors.sh @@ -0,0 +1,37 @@ +#!/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" + +# Federation ontology leg: the constructor SELECT that drives A's client-side form derivation, +# posed against B's ns endpoint through A's proxy. On a remote pane the client resolves ns +# against the pane's data-base (B's base from the forwarded lapp:application Link), so forms +# for B's resources derive from B's ontology closure - this pins that contract on the wire. + +remote_base="https://test.localhost:4443/" +remote_ns="${remote_base}ns" + +query='SELECT DISTINCT ?constructor ?text WHERE { VALUES ?type { } ?type * ?class . ?class ?constructor . ?constructor ?text . }' + +results=$(curl -k -f -s \ + -X POST \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-query" \ + -H "Accept: application/sparql-results+xml" \ + --url-query "uri=${remote_ns}" \ + --data-binary "$query" \ + "$END_USER_BASE_URL") + +# the default LDH ontology is in every app's closure, so its constructors are returned by B + +echo "$results" | grep -q "https://w3id.org/atomgraph/linkeddatahub#TitleConstructor" + +count=$(echo "$results" | xmllint --xpath "count(//*[local-name() = 'binding'][@name = 'text']/*[local-name() = 'literal'][contains(., 'CONSTRUCT')])" -) +if [ "$count" -lt 1 ]; then + echo "DEBUG: Expected at least 1 constructor text from the remote ns, got: $count" + exit 1 +fi diff --git a/http-tests/run.sh b/http-tests/run.sh index bd69cd180..1745dcd4d 100755 --- a/http-tests/run.sh +++ b/http-tests/run.sh @@ -264,6 +264,8 @@ run_tests "misc" $(find ./misc/ -type f -name '*.sh') (( error_count += $? )) run_tests "proxy" $(find ./proxy/ -type f -name '*.sh') (( error_count += $? )) +run_tests "federation" $(find ./federation/ -type f -name '*.sh') +(( error_count += $? )) run_tests "sparql-protocol" $(find ./sparql-protocol/ -type f -name '*.sh') (( error_count += $? )) run_tests "versioning" $(find ./versioning/ -type f -name '*.sh') 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 da1050384..e299dcb7f 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 @@ -128,6 +128,19 @@ public class ProxyRequestFilter implements ContainerRequestFilter HttpHeaders.LOCATION, HttpHeaders.RETRY_AFTER, "Age"); + /** + * Conditional request headers forwarded verbatim to the upstream so preconditions are evaluated + * at the origin: {@code If-Match}/{@code If-Unmodified-Since} carry optimistic-concurrency + * validators on writes, {@code If-None-Match}/{@code If-Modified-Since} carry cache validation on + * reads. Excludes {@code Authorization}/{@code Cookie} (agent identity is delegated explicitly via + * {@link WebIDDelegationFilter}/{@link IDTokenDelegationFilter}) and {@code Range}, whose byte + * offsets do not survive the Model re-serialization the proxy performs. + */ + private static final Set FORWARDED_REQUEST_HEADERS = Set.of( + HttpHeaders.IF_MATCH, + HttpHeaders.IF_NONE_MATCH, + HttpHeaders.IF_MODIFIED_SINCE, + HttpHeaders.IF_UNMODIFIED_SINCE); @Inject com.atomgraph.linkeddatahub.Application system; @Inject MediaTypes mediaTypes; @@ -210,6 +223,15 @@ else if (agentContext instanceof IDTokenSecurityContext idTokenSecurityContext) accept(clientAcceptTypes). header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT); + // forward conditional request headers so preconditions reach the origin, which owns the + // validators - without this the origin sees an unconditional request and a proxied If-Match + // write silently loses its optimistic-concurrency guard + for (String name : FORWARDED_REQUEST_HEADERS) + { + String value = requestContext.getHeaderString(name); + if (value != null) builder.header(name, value); + } + Response clientResponse = requestContext.hasEntity() ? builder.method(requestContext.getMethod(), Entity.entity(requestContext.getEntityStream(), requestContext.getMediaType())) @@ -289,9 +311,9 @@ protected Optional resolveTargetURI(ContainerRequestContext requestContext) * entity class is known. *

* {@code Link} headers and end-to-end cache/content headers from upstream are overlaid on top - * of all three branches; {@code ETag}/{@code Last-Modified} are skipped on the typed branches - * because the Model/ResultSet builders stamp their own validators that describe the - * re-serialized representation, not the upstream bytes. + * of all three branches. The Model and raw branches forward the origin's {@code ETag}/{@code Last-Modified} + * (writes against the proxied document send {@code If-Match} through this proxy to the origin, which + * compares against its own validator); only the ResultSet branch keeps the builder-stamped validators. * * @param clientResponse response from the proxy target * @param targetURI upstream URI (used as the parse base URI hint for {@code ModelProvider}) @@ -323,6 +345,21 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me return rb.build(); } + // error responses relay verbatim: the body is a diagnostic representation, not negotiable + // content, so it must not go through the Model/ResultSet re-serialization branches - parsing a + // non-RDF or empty error body there throws and masks the origin's status as 502/406. A proxied + // write that the origin rejects (412 on a stale If-Match, 401/403 on an unauthorized delta) + // must reach the client as that status, with the origin's validators forwarded + Response.Status.Family family = clientResponse.getStatusInfo().getFamily(); + if (family == Response.Status.Family.CLIENT_ERROR || family == Response.Status.Family.SERVER_ERROR) + { + clientResponse.bufferEntity(); + Response.ResponseBuilder rb = Response.status(clientResponse.getStatus()). + type(clientResponse.getMediaType()). + entity(clientResponse.readEntity(InputStream.class)); + return overlayHeaders(rb.build(), clientResponse, true); + } + // dispatch on the live Jena RIOT registry — same predicate ModelProvider.isReadable uses, // so any RDF lang Jersey can read into a Model (including HTML via HtmlJsonLDReader and // RDFPOST) routes to the Model branch. We can't use MediaTypes.getReadable(Model.class) @@ -344,7 +381,12 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me // base URI hint so ModelProvider (and HtmlJsonLDReader through it) resolve relative IRIs against the upstream URI clientResponse.getHeaders().putSingle(ModelProvider.REQUEST_URI_HEADER, targetURI.toString()); Model model = clientResponse.readEntity(Model.class); - return overlayHeaders(getResponse(model, clientResponse.getStatusInfo()), clientResponse, false); + // forward the origin's validators (replacing the ones the Model builder stamps off the re-serialized + // bytes): a client editing the proxied document sends If-Match through this proxy to the origin, which + // compares against its own ETag - a re-serialization validator would 412 every proxied write. The proxy + // performs no byte-validator-dependent features of its own (no Range, no conditional evaluation), so the + // origin's resource-state validator is the correct one to surface + return overlayHeaders(getResponse(model, clientResponse.getStatusInfo()), clientResponse, true); } // upstream is neither RDF nor SPARQL results — pipe raw bytes @@ -361,9 +403,10 @@ protected Response getResponse(Response clientResponse, URI targetURI, String me /** * Copies the upstream {@code Link} and end-to-end cache/content headers onto the given - * built response. {@code ETag}/{@code Last-Modified} are skipped when {@code copyValidators} - * is {@code false} (typed branches), because the Model/ResultSet builders stamp their own - * validators that describe the re-serialized representation rather than the upstream bytes. + * built response, replacing any locally stamped values. {@code ETag}/{@code Last-Modified} + * are skipped when {@code copyValidators} is {@code false} (the ResultSet branch), where the + * builder-stamped validators stand. The Model branch forwards the origin's validators so + * {@code If-Match} preconditions on proxied writes validate against the origin's own ETag. * * @param response the response built by the typed or raw branch * @param clientResponse upstream response to copy headers from @@ -386,7 +429,7 @@ private Response overlayHeaders(Response response, Response clientResponse, bool { if (!copyValidators && (HttpHeaders.ETAG.equalsIgnoreCase(name) || HttpHeaders.LAST_MODIFIED.equalsIgnoreCase(name))) continue; String value = clientResponse.getHeaderString(name); - if (value != null) rb.header(name, value); + if (value != null) rb.header(name, null).header(name, value); // replace, not append - the upstream value overlays any locally stamped one } return rb.build(); diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl index f5730c669..7bdacb66a 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl @@ -1295,7 +1295,7 @@ LIMIT 10 - + @@ -1347,28 +1347,20 @@ LIMIT 10 - - - - - - - - - - - - + + + + From 45f4d6538fe7bbeaa3b28f13f4c090a67cc5c6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Mon, 24 Aug 2026 18:11:28 +0200 Subject: [PATCH 05/18] Declarative packages: ldh:import replaces install endpoints (#361) * Declarative packages: ldh:import in dataspace settings replaces install endpoints An application imports a package with a single ldh:import triple - in config/dataspaces.trig (permanent, applied on restart) or live via PATCH /settings. Package components are discovered from the package's Linked Data description; the stylesheet is loaded from its source URL and composed into the application stylesheet in memory at compile time, per dataspace, with no restart and no webapp file mutation. - XsltExecutableFilter composes the app stylesheet document with package xsl:imports (SecureXML-parsed, import-set-derived cache key, fallback to the plain stylesheet on compile failure) - LocalStylesheetResolver serves app-origin /static/ xsl:import URLs from the webapp instead of HTTPS round-trips through nginx - packages/install and packages/uninstall endpoints, the admin packages/ container and ACL entries, the package Actions UI, CLI scripts and their tests removed; XSLTMasterUpdater and Package.getStylesheetPath() deleted - Package catalog is data at https://packages.linkeddatahub.com/ (bundled one-entry copy served via the proxy's mapped-URI resolution) - Application settings modal lists the packages with Installed checkboxes; Save applies the change set as one DELETE/INSERT/WHERE PATCH on /settings - ac:stylesheet values in dataspaces.trig are absolute per-origin URLs - Known limitation: package ontologies are not yet auto-imported from ldh:import (manual owl:imports for now) Co-Authored-By: Claude Fable 5 * Package checkboxes join the settings form as RDF/POST inputs - one Save The packages fieldset moves inside the application settings form: each row's Installed checkbox is a pu/ou input pair that serializes an ldh:import triple, so the form's single Save submits settings and package imports as one PATCH. The separate packages Save button, the change-set computation and the SPARQL-generating handlers are removed. - ldh:parse-rdf-post skips unchecked checkboxes (successful-controls rule of HTML form submission, which client-side DOM serialization bypasses) - the ldh:import hidden-input round-trip is suppressed in the settings form - the checkboxes are the sole representation, so unchecking uninstalls - the settings form response reloads the page on success: the composed stylesheet only manifests through a full server-side render Co-Authored-By: Claude Fable 5 * Keep package checkboxes across the settings violation re-render The modal violation chain had no package-catalog load pair, so the re-rendered settings form lost the package table - and since the checkboxes are the sole ldh:import representation (the hidden-input round-trip is suppressed), a Save after fixing a validation error would submit no ldh:import triples and uninstall every package. ldh:settings-form-response stamps the package-catalog load pair as 'load-pairs' alongside 'render-fn'/'required', and ldh:modal-form-submit-violation joins stamped pairs onto its shared list. Every pair must bake a request, so optional fetches ride per flow rather than sitting in the shared list. The re-rendered checkboxes reflect the submitted state: PackageList's installed set comes from the echoed ldh:import triples in the violation body. Co-Authored-By: Claude Fable 5 * Package ontologies join the imports closure, derived from ldh:import The ontology half of declarative packages, mirroring the stylesheet composition: when OntologyFilter assembles an application's ontology closure, each imported package's ontology (from ldt:ontology in the package description) is assembled as its own owl:imports closure and added as a union member. Nothing is materialized - no owl:imports triple is written anywhere, and the raw ontology graph served by /ns stays untouched. A package ontology that fails to load is skipped so a broken package cannot take the application ontology down. Invalidation stays on the plain ontology-URI key that ClearOntology already removes and rebuilds (now package-aware): a /settings PATCH - the only way the ldh:import set changes live - evicts the assembled closure, and the next request re-derives it. The trig-file route implies a restart, which starts from empty caches anyway. getPackage moves from XsltExecutableFilter to the system Application (the filter delegates) so both composition halves share the package description resolution. New http-test asserts the skos:Concept constructor appears on /ns after the ldh:import PATCH and disappears after removal - closure derivation and eviction in one round trip. Co-Authored-By: Claude Fable 5 * Instrument package-ontology removal path (temporary diagnostics) PATCH-settings-package-ontology.sh fails only on removal: the package ontology's skos:Concept constructor stays in the closure after the ldh:import is deleted (adding works). Eviction fires and updateApp persists to the live context dataset, so static reading can't explain it. Log what getPackageOntologies actually sees after the DELETE, and have the test report whether /settings still carries the ldh:import triple - one CI run then tells us persist-vs-cache. To be reverted once the cause is fixed. Co-Authored-By: Claude Fable 5 * Settings PATCH evicts the ontology from the repository too, not just the closure Root cause of PATCH-settings-package-ontology.sh failing only on removal, confirmed by the CI diagnostics: after the DELETE, /settings carries 0 ldh:import triples and getPackageOntologies returns [] - the derivation input is correct - yet the skos:Concept constructor stays on /ns. The OWL2 imports build materializes the package's terms into the cached base graph in the OntologyRepository; Settings evicted only the closure union (getOntologyGraphs), leaving that polluted graph cached, so the remove-phase rebuild read skos straight back. Adding worked because the first build ran over a clean cache. Clear both caches, matching ClearOntology (repository graph + fragment- stripped document alias + closure union), minus the proxy purge since settings responses are not cached. Revert the temporary getPackageOntologies logging. Co-Authored-By: Claude Fable 5 * Settings delegates ontology invalidation to ClearOntology via ResourceContext Replaces the inline in-memory eviction with a call-through to the shared ClearOntology logic (the pattern the old InstallPackage used): getResourceContext().getResource(ClearOntology.class).post(uri, null). This gets the full invalidation - repository graph + closure union + varnish BAN/XKEY-PURGE + eager reload - so a settings-driven package add/remove no longer leaves stale /ns SPARQL responses in the proxy caches (the in-memory-only eviction fixed the closure but not varnish; the http-test masked it with a manual purge). ClearOntology becomes context-agnostic: it resolved the end-user app by assuming the current app is admin (getApplication().as(AdminApplication)), which threw for the end-user-scoped /settings. It now resolves both apps from whichever the request matched and derives the admin backend from the resolved admin app rather than the current one, so both backends are purged either way. Co-Authored-By: Claude Fable 5 * Import ontology persists annotations only - vocabulary via scratch document (#362) The flow's output becomes the artifact packages ship statically: an annotation ontology holding the derived class constructors plus owl:imports of the canonical vocabulary URI. The fetched vocabulary is scaffolding: PUT into a scratch document (UUID-slugged, under the app root) that scopes the construct-constructors CONSTRUCT, then deleted - on the error paths too (client: error wrapper armed once the scratch exists; CLI: EXIT trap). The vocabulary resolves live through the graph repository (bundled mapping or HTTP), identically to package ontologies. This also fixes constructor derivation for bundled vocabularies: previously the shipped file shadowed the local copy that held the constructors, so they never reached the closure (the old test's TO-DO documented it); now the annotation document enters the closure under its own URI and imports the vocabulary. Wiring changes accordingly: add-ontology-import.sh --import , not the source. ontologyQuery in web.xml is untouched - the describing-document branch keeps resolving own-origin ontologies; this flow simply stops minting shadowing copies. Co-authored-by: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 10 + bin/admin/ontologies/import-ontology.sh | 46 +- bin/admin/packages/install-package.sh | 104 ---- bin/admin/packages/uninstall-package.sh | 104 ---- config/dataspaces.trig | 8 +- http-tests/admin/model/import-ontology.sh | 49 +- .../admin/packages/install-package-400.sh | 16 - .../admin/packages/install-package-403.sh | 16 - .../admin/packages/install-package-422.sh | 18 - .../packages/install-package-document.sh | 40 -- .../install-package-internal-url-400.sh | 44 -- ...install-package-stylesheet-no-duplicate.sh | 60 -- .../install-uninstall-package-ontology.sh | 61 -- .../install-uninstall-package-stylesheet.sh | 71 --- .../admin/packages/uninstall-package-400.sh | 16 - http-tests/config/dataspaces.trig | 8 +- .../misc/PATCH-settings-package-import.sh | 58 ++ .../misc/PATCH-settings-package-ontology.sh | 82 +++ .../system/admin/POST-packages-install-401.sh | 18 - .../system/admin/POST-packages-install-403.sh | 25 - .../POST-packages-install-readers-403.sh | 25 - .../admin/POST-packages-uninstall-401.sh | 18 - .../admin/POST-packages-uninstall-403.sh | 25 - .../POST-packages-uninstall-readers-403.sh | 25 - platform/datasets/admin.trig | 38 +- .../atomgraph/linkeddatahub/Application.java | 75 ++- .../linkeddatahub/apps/model/Package.java | 10 - .../apps/model/impl/PackageImpl.java | 41 -- .../linkeddatahub/resource/Settings.java | 24 +- .../resource/admin/ClearOntology.java | 19 +- .../resource/admin/pkg/InstallPackage.java | 540 ------------------ .../resource/admin/pkg/UninstallPackage.java | 369 ------------ .../server/filter/request/OntologyFilter.java | 35 +- .../filter/response/XsltExecutableFilter.java | 243 +++++++- .../server/model/impl/Dispatcher.java | 24 - .../server/util/LocalStylesheetResolver.java | 124 ++++ .../server/util/XSLTMasterUpdater.java | 247 -------- .../com/linkeddatahub/packages/packages.ttl | 12 + .../linkeddatahub/packages/skos/layout.xsl | 63 -- src/main/resources/prefix-mapping.ttl | 1 + .../xsl/bootstrap/2.3.2/client/form.xsl | 1 + .../xsl/bootstrap/2.3.2/client/functions.xsl | 1 + .../xsl/bootstrap/2.3.2/client/modal.xsl | 154 +++-- .../xsl/bootstrap/2.3.2/client/packages.xsl | 135 +++++ .../xsl/bootstrap/2.3.2/imports/lapp.xsl | 63 -- .../xsl/bootstrap/2.3.2/layout.xsl | 1 - .../xsl/bootstrap/2.3.2/translations.rdf | 8 + .../atomgraph/linkeddatahub/xsl/client.xsl | 1 + src/main/webapp/static/xsl/admin/layout.xsl | 2 +- src/main/webapp/static/xsl/layout.xsl | 2 +- .../response/XsltExecutableFilterTest.java | 207 +++++++ .../util/LocalStylesheetResolverTest.java | 123 ++++ 52 files changed, 1345 insertions(+), 2165 deletions(-) delete mode 100755 bin/admin/packages/install-package.sh delete mode 100755 bin/admin/packages/uninstall-package.sh delete mode 100755 http-tests/admin/packages/install-package-400.sh delete mode 100755 http-tests/admin/packages/install-package-403.sh delete mode 100755 http-tests/admin/packages/install-package-422.sh delete mode 100755 http-tests/admin/packages/install-package-document.sh delete mode 100755 http-tests/admin/packages/install-package-internal-url-400.sh delete mode 100755 http-tests/admin/packages/install-package-stylesheet-no-duplicate.sh delete mode 100755 http-tests/admin/packages/install-uninstall-package-ontology.sh delete mode 100755 http-tests/admin/packages/install-uninstall-package-stylesheet.sh delete mode 100755 http-tests/admin/packages/uninstall-package-400.sh create mode 100755 http-tests/misc/PATCH-settings-package-import.sh create mode 100755 http-tests/misc/PATCH-settings-package-ontology.sh delete mode 100755 http-tests/system/admin/POST-packages-install-401.sh delete mode 100755 http-tests/system/admin/POST-packages-install-403.sh delete mode 100755 http-tests/system/admin/POST-packages-install-readers-403.sh delete mode 100755 http-tests/system/admin/POST-packages-uninstall-401.sh delete mode 100755 http-tests/system/admin/POST-packages-uninstall-403.sh delete mode 100755 http-tests/system/admin/POST-packages-uninstall-readers-403.sh delete mode 100644 src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java delete mode 100644 src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java create mode 100644 src/main/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolver.java delete mode 100644 src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java create mode 100644 src/main/resources/com/linkeddatahub/packages/packages.ttl delete mode 100644 src/main/resources/com/linkeddatahub/packages/skos/layout.xsl create mode 100644 src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/packages.xsl delete mode 100644 src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/imports/lapp.xsl create mode 100644 src/test/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilterTest.java create mode 100644 src/test/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolverTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9408573a0..ff4c06fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,25 @@ - Constructor instances are instantiated client-side: one SPARQL SELECT fetches the type set's `spin:constructor` queries (subclass closure, deduplicated) and their CONSTRUCT templates are expanded onto a single instance typed with all the resource's classes — the template mirrors the instance. Fixes nondeterministically missing constructor-supplied inputs (the intermittently vanishing app-settings Description field) and multi-range predicate cardinality errors; same-range duplicate properties are collapsed. Constructors must have an empty `WHERE` clause to be client-instantiable - Server-rendered edit forms show data properties only; the client re-render supplies the constructor controls (`ldh:construct-forClass` is an empty stub under SAXON, the `ac:construct` stub pattern) - "Add data" accepts foreign target documents: the proxied append carries the delegated agent identity, so the target instance's access control arbitrates and its refusal surfaces as the form error (the local-only guard is gone); "Import ontology" keeps the local-target requirement because its constructor derivation is scoped to the local `/sparql` endpoint +- Packages are declarative: an application imports a package with a single ` ldh:import ` triple in its dataspace settings — in `config/dataspaces.trig` (permanent, applied on restart) or live via `PATCH /settings` (effective on the next request, no restart). The package's components are discovered from its Linked Data description (bundled descriptions resolve from the classpath); its stylesheet is loaded from its source URL and composed into the application stylesheet in memory at compile time, per dataspace. Nothing is downloaded ahead, copied into the webapp, or registered anywhere — `/static/` is never modified +- The available-package catalog is data at the registry URI `https://packages.linkeddatahub.com/` (bundled one-entry copy listing the SKOS package, served through the Linked Data proxy's mapped-URI resolution until the registry is live) +- The application settings modal lists the available packages with a per-row Installed checkbox serialized as an RDF/POST `ldh:import` input — the form's single Save submits settings and package imports as one PATCH through `/settings` and reloads the page +- The package's ontology joins the application's ontology imports closure automatically, derived from `ldh:import` at ontology-load time: each package ontology is assembled as its own `owl:imports` closure and added as a union member — no `owl:imports` triple is materialized anywhere. A `/settings` PATCH evicts the assembled closure, so package installs/uninstalls take effect on the next request; a package ontology that fails to load is skipped +- "Import ontology" persists only the derived annotation ontology — generated class constructors plus `owl:imports` of the canonical vocabulary URI, the artifact shape a package ontology ships; the fetched vocabulary is scaffolding in a scratch document deleted after the derivation (failure paths included). The vocabulary resolves live through the graph repository, so constructors derived for bundled vocabularies now reach the ontology closure (the shipped file previously shadowed the local copy holding them); wire the annotation document itself into the namespace ontology (`add-ontology-import.sh --import `) +- XSLT compilation resolves `xsl:import` URLs under an application origin's `/static/` path to local webapp files (`LocalStylesheetResolver`) instead of HTTPS round-trips through nginx, and modules imported via different routes deduplicate under one URL +- `ac:stylesheet` values in `config/dataspaces.trig` are absolute URLs on the application's own origin (previously relative, absolutized against the root base URI) ### Removed - **BREAKING**: `/ns?forClass=` constructed-instance responses — the client-side instantiation is the only consumer path; the `Namespace` endpoint serves SPARQL queries and the raw ontology graph only +- `packages/install` and `packages/uninstall` endpoints, the admin `packages/` container and their ACL entries, the package Actions UI (`imports/lapp.xsl`), and `bin/admin/packages/` CLI scripts — the `ldh:import` declaration itself is the installation. Packages installed with earlier releases were webapp-file mutations and do not carry over: re-declare them with `ldh:import` +- `XSLTMasterUpdater`, `Package.getStylesheetPath()` and the bundled `packages/skos/layout.xsl` copy — dead now that the webapp-file installation path is gone ### Fixed - Modal violation re-renders harvested `property-uris` from everything except the edited resource, degrading property labels to their local-name fallback; violation/response machinery no longer pollutes the `property-uris`/`object-uris` metadata harvests - The `required` function on the modal violation context is stamped per flow by the response handlers, matching each flow's initial-render chain (the shared Container/Item test disagreed with the app-settings chain) - The Linked Data proxy forwards the origin's `ETag`/`Last-Modified` on RDF responses instead of stamping re-serialization validators, forwards conditional request headers (`If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since`) to the origin, and relays the origin's `4xx`/`5xx` error responses verbatim rather than parsing their bodies as RDF (which turned a proxied `412`/`403` into a `502`) — so `If-Match`-preconditioned and access-controlled writes against proxied documents are evaluated at the origin and their real status reaches the client + ## [5.9.1] - 2026-08-19 ### Added - Inline creation in views: views carrying the new `ldh:container` metadata render a Create button that creates a linked instance in that container (#351) diff --git a/bin/admin/ontologies/import-ontology.sh b/bin/admin/ontologies/import-ontology.sh index a8038b4fe..5a22efb51 100755 --- a/bin/admin/ontologies/import-ontology.sh +++ b/bin/admin/ontologies/import-ontology.sh @@ -3,9 +3,9 @@ set -eo pipefail print_usage() { - printf "Imports an external ontology: appends its triples to a document and derives class constructors from them.\n" - printf "The CONSTRUCT transformation runs on the /sparql endpoint, scoped to the document graph via the SPARQL Protocol dataset specification.\n" - printf "Use add-ontology-import.sh and clear-ontology.sh to make the imported document part of the application ontology.\n" + printf "Imports an external ontology: derives class constructors from its triples and appends them, together with an owl:imports of the source, to a document.\n" + printf "The vocabulary itself is fetched into a scratch document (deleted afterwards) that scopes the CONSTRUCT transformation on the /sparql endpoint via the SPARQL Protocol dataset specification - only the derived annotations persist; the vocabulary resolves live through the graph repository.\n" + printf "Use add-ontology-import.sh and clear-ontology.sh to make the annotation document part of the application ontology.\n" printf "\n" printf "Usage: %s options\n" "$0" printf "\n" @@ -21,6 +21,7 @@ print_usage() hash curl 2>/dev/null || { echo >&2 "curl not on \$PATH. Aborting."; exit 1; } hash xmllint 2>/dev/null || { echo >&2 "xmllint not on \$PATH. Aborting."; exit 1; } +hash uuidgen 2>/dev/null || { echo >&2 "uuidgen not on \$PATH. Aborting."; exit 1; } args=() while [[ $# -gt 0 ]] @@ -112,14 +113,35 @@ curl -f -s -k \ -H "Accept: application/rdf+xml" \ > "$tmp_source" -# append the raw ontology to the document graph +# create the scratch document that holds the vocabulary during the constructor derivation + +scratch="${base}$(uuidgen | tr '[:upper:]' '[:lower:]')/" +scratch_url="$scratch" + +if [ -n "$proxy" ]; then + scratch_url="${scratch/$base_host/$proxy_host}" +fi + +printf '@prefix dh:\t .\n@prefix dct:\t .\n<%s> a dh:Item ;\n dct:title "Import ontology scratch" .\n' "$scratch" \ +| curl -f -s -k -o /dev/null \ + -E "$cert_pem_file":"$cert_password" \ + -X PUT --data-binary @- \ + -H "Content-Type: text/turtle" \ + -H "Accept: application/rdf+xml" \ + "$scratch_url" + +# the scratch document must not outlive the derivation - delete it on exit, the failure paths included + +trap 'rm -f "$tmp_source" "$tmp_query" "$tmp_constructors"; curl -s -k -o /dev/null -E "$cert_pem_file":"$cert_password" -X DELETE "$scratch_url"' EXIT + +# append the raw ontology to the scratch document graph curl -f -s -k -o /dev/null \ -E "$cert_pem_file":"$cert_password" \ -X POST --data-binary "@$tmp_source" \ -H "Content-Type: application/rdf+xml" \ -H "Accept: application/rdf+xml" \ - "$graph_url" + "$scratch_url" # read the construct-constructors query text, scoped to its own document graph @@ -137,13 +159,13 @@ if [ ! -s "$tmp_query" ]; then exit 1 fi -# run the CONSTRUCT over the document graph via the SPARQL Protocol dataset specification +# run the CONSTRUCT over the scratch graph via the SPARQL Protocol dataset specification curl -f -s -k \ -E "$cert_pem_file":"$cert_password" \ -X POST "${endpoint_base}sparql" \ --data-urlencode "query@${tmp_query}" \ - --data-urlencode "default-graph-uri=${graph}" \ + --data-urlencode "default-graph-uri=${scratch}" \ -H "Accept: application/rdf+xml" \ > "$tmp_constructors" @@ -155,3 +177,13 @@ curl -f -s -k -o /dev/null \ -H "Content-Type: application/rdf+xml" \ -H "Accept: application/rdf+xml" \ "$graph_url" + +# append the annotation-ontology header: the document imports the source vocabulary, which resolves live through the graph repository + +printf '@prefix owl:\t .\n<%s> a owl:Ontology ;\n owl:imports <%s> .\n' "$graph" "$source" \ +| curl -f -s -k -o /dev/null \ + -E "$cert_pem_file":"$cert_password" \ + -X POST --data-binary @- \ + -H "Content-Type: text/turtle" \ + -H "Accept: application/rdf+xml" \ + "$graph_url" diff --git a/bin/admin/packages/install-package.sh b/bin/admin/packages/install-package.sh deleted file mode 100755 index 86f597c59..000000000 --- a/bin/admin/packages/install-package.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -print_usage() -{ - printf "Installs a LinkedDataHub package.\n" - printf "\n" - printf "Usage: %s options\n" "$0" - printf "\n" - printf "Options:\n" - printf " -b, --base BASE_URL Base URL of the application\n" - printf " -f, --cert-pem-file CERT_FILE .pem file with the WebID certificate of the agent\n" - printf " -p, --cert-password CERT_PASSWORD Password of the WebID certificate\n" - printf " --proxy PROXY_URL The host this request will be proxied through (optional)\n" - printf " --package PACKAGE_URI URI of the package to install (e.g., https://packages.linkeddatahub.com/skos/#this)\n" - printf "\n" - printf "Example:\n" - printf " %s -b https://localhost:4443/ -f ssl/owner/cert.pem -p Password --package https://packages.linkeddatahub.com/skos/#this\n" "$0" -} - -hash curl 2>/dev/null || { echo >&2 "curl not on \$PATH. Aborting."; exit 1; } - -unknown=() -while [[ $# -gt 0 ]] -do - key="$1" - - case $key in - -b|--base) - base="$2" - shift # past argument - shift # past value - ;; - -f|--cert-pem-file) - cert_pem_file="$2" - shift # past argument - shift # past value - ;; - -p|--cert-password) - cert_password="$2" - shift # past argument - shift # past value - ;; - --proxy) - proxy="$2" - shift # past argument - shift # past value - ;; - --package) - package_uri="$2" - shift # past argument - shift # past value - ;; - *) # unknown option - unknown+=("$1") # save it in an array for later - shift # past argument - ;; - esac -done -set -- "${unknown[@]}" # restore args - -if [ -z "$base" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_pem_file" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_password" ] ; then - print_usage - exit 1 -fi -if [ -z "$package_uri" ] ; then - print_usage - exit 1 -fi - -# Convert base URL to admin base URL -admin_uri() { - local uri="$1" - echo "$uri" | sed 's|://|://admin.|' -} - -admin_base=$(admin_uri "$base") -target_url="${admin_base}packages/install" - -if [ -n "$proxy" ]; then - admin_proxy=$(admin_uri "$proxy") - # rewrite target hostname to proxy hostname - url_host=$(echo "$target_url" | cut -d '/' -f 1,2,3) - proxy_host=$(echo "$admin_proxy" | cut -d '/' -f 1,2,3) - final_url="${target_url/$url_host/$proxy_host}" -else - final_url="$target_url" -fi - -# POST to packages/install endpoint -curl -k -f -s -w "%{http_code}\n" -E "$cert_pem_file":"$cert_password" \ - -X POST \ - -H "Accept: text/turtle" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=${package_uri}" \ - "${final_url}" diff --git a/bin/admin/packages/uninstall-package.sh b/bin/admin/packages/uninstall-package.sh deleted file mode 100755 index 065e48cb8..000000000 --- a/bin/admin/packages/uninstall-package.sh +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -print_usage() -{ - printf "Uninstalls a LinkedDataHub package.\n" - printf "\n" - printf "Usage: %s options\n" "$0" - printf "\n" - printf "Options:\n" - printf " -b, --base BASE_URL Base URL of the application\n" - printf " -f, --cert-pem-file CERT_FILE .pem file with the WebID certificate of the agent\n" - printf " -p, --cert-password CERT_PASSWORD Password of the WebID certificate\n" - printf " --proxy PROXY_URL The host this request will be proxied through (optional)\n" - printf " --package PACKAGE_URI URI of the package to uninstall (e.g., https://packages.linkeddatahub.com/skos/#this)\n" - printf "\n" - printf "Example:\n" - printf " %s -b https://localhost:4443/ -f ssl/owner/cert.pem -p Password --package https://packages.linkeddatahub.com/skos/#this\n" "$0" -} - -hash curl 2>/dev/null || { echo >&2 "curl not on \$PATH. Aborting."; exit 1; } - -unknown=() -while [[ $# -gt 0 ]] -do - key="$1" - - case $key in - -b|--base) - base="$2" - shift # past argument - shift # past value - ;; - -f|--cert-pem-file) - cert_pem_file="$2" - shift # past argument - shift # past value - ;; - -p|--cert-password) - cert_password="$2" - shift # past argument - shift # past value - ;; - --proxy) - proxy="$2" - shift # past argument - shift # past value - ;; - --package) - package_uri="$2" - shift # past argument - shift # past value - ;; - *) # unknown option - unknown+=("$1") # save it in an array for later - shift # past argument - ;; - esac -done -set -- "${unknown[@]}" # restore args - -if [ -z "$base" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_pem_file" ] ; then - print_usage - exit 1 -fi -if [ -z "$cert_password" ] ; then - print_usage - exit 1 -fi -if [ -z "$package_uri" ] ; then - print_usage - exit 1 -fi - -# Convert base URL to admin base URL -admin_uri() { - local uri="$1" - echo "$uri" | sed 's|://|://admin.|' -} - -admin_base=$(admin_uri "$base") -target_url="${admin_base}packages/uninstall" - -if [ -n "$proxy" ]; then - admin_proxy=$(admin_uri "$proxy") - # rewrite target hostname to proxy hostname - url_host=$(echo "$target_url" | cut -d '/' -f 1,2,3) - proxy_host=$(echo "$admin_proxy" | cut -d '/' -f 1,2,3) - final_url="${target_url/$url_host/$proxy_host}" -else - final_url="$target_url" -fi - -# POST to packages/uninstall endpoint -curl -k -f -s -w "%{http_code}\n" -E "$cert_pem_file":"$cert_password" \ - -X POST \ - -H "Accept: text/turtle" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=${package_uri}" \ - "${final_url}" diff --git a/config/dataspaces.trig b/config/dataspaces.trig index c710fc65d..12c44d8a6 100644 --- a/config/dataspaces.trig +++ b/config/dataspaces.trig @@ -14,7 +14,7 @@ dct:title "LinkedDataHub admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } @@ -26,7 +26,7 @@ dct:title "LinkedDataHub" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } @@ -39,7 +39,7 @@ dct:title "Northwind Traders admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } @@ -51,7 +51,7 @@ dct:title "Northwind Traders" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } diff --git a/http-tests/admin/model/import-ontology.sh b/http-tests/admin/model/import-ontology.sh index de7177911..5b8ef94fe 100755 --- a/http-tests/admin/model/import-ontology.sh +++ b/http-tests/admin/model/import-ontology.sh @@ -23,7 +23,8 @@ item=$(create-item.sh \ --slug "$slug" \ --container "${ADMIN_BASE_URL}ontologies/") -# import the ontology into the item document and derive class constructors from it +# import the ontology: derive class constructors into the item document; the vocabulary itself only +# passes through a scratch document and is not persisted import-ontology.sh \ -f "$OWNER_CERT_FILE" \ @@ -32,16 +33,21 @@ import-ontology.sh \ --source "$import_uri" \ --graph "$item" -# check that the item graph holds the raw ontology, using a query scoped to it via the SPARQL Protocol dataset specification +# check that the item graph does NOT hold the raw vocabulary, using a query scoped to it via the +# SPARQL Protocol dataset specification -curl -k -f -s \ +result=$(curl -k -f -s \ -G \ -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ -H 'Accept: application/sparql-results+xml' \ --data-urlencode "query=SELECT * { <${import_uri}> ?p ?o }" \ --data-urlencode "default-graph-uri=${item}" \ - "${ADMIN_BASE_URL}sparql" \ -| grep 'SKOS Vocabulary' > /dev/null + "${ADMIN_BASE_URL}sparql") +count=$(echo "$result" | xmllint --xpath "count(//*[local-name() = 'result'])" -) +if [ "$count" != "0" ]; then + echo "DEBUG: Expected 0 raw vocabulary triples in the item graph, got: $count" + exit 1 +fi # check that constructors were derived into the item graph @@ -54,12 +60,24 @@ curl -k -f -s \ "${ADMIN_BASE_URL}sparql" \ | grep '' > /dev/null -# add ontology import +# check that the item carries the annotation-ontology header importing the source vocabulary + +curl -k -f -s \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Accept: application/sparql-results+xml' \ + --data-urlencode "query=SELECT * { <${item}> a ; <${import_uri}> }" \ + --data-urlencode "default-graph-uri=${item}" \ + "${ADMIN_BASE_URL}sparql" \ +| grep '' > /dev/null + +# make the annotation document part of the application ontology (the vocabulary rides in via the +# document's own owl:imports) add-ontology-import.sh \ -f "$OWNER_CERT_FILE" \ -p "$OWNER_CERT_PWD" \ - --import "$import_uri" \ + --import "$item" \ "$ontology_doc" # clear the namespace ontology from memory @@ -70,10 +88,8 @@ clear-ontology.sh \ -b "$ADMIN_BASE_URL" \ --ontology "$namespace" -# check that the imported ontology is present in the ontology model TO-DO: replace with an ASK query when #118 is fixed -# (SKOS is a bundled vocabulary: OntologyRepository serves the shipped file authoritatively, so the closure carries -# its terms but not the constructors derived into the local document — those reach the closure only for -# ontologies that are not bundled. The constructor derivation itself is asserted on the document graph above.) +# check that the vocabulary is present in the ontology closure (resolved through the graph +# repository - SKOS is a bundled vocabulary - via the annotation document's owl:imports) curl -k -f -s \ -G \ @@ -82,3 +98,14 @@ curl -k -f -s \ --data-urlencode "query=SELECT * { <${import_uri}> ?p ?o }" \ "$namespace_doc" \ | grep 'SKOS Vocabulary' > /dev/null + +# check that the derived constructors reached the closure too - impossible under the old model for +# bundled vocabularies, where the shipped file shadowed the local copy that held the constructors + +curl -k -f -s \ + -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H 'Accept: application/sparql-results+xml' \ + --data-urlencode "query=SELECT * { ?constructor }" \ + "$namespace_doc" \ +| grep '' > /dev/null diff --git a/http-tests/admin/packages/install-package-400.sh b/http-tests/admin/packages/install-package-400.sh deleted file mode 100755 index d77736d15..000000000 --- a/http-tests/admin/packages/install-package-400.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/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" - -# Missing package-uri parameter should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" diff --git a/http-tests/admin/packages/install-package-403.sh b/http-tests/admin/packages/install-package-403.sh deleted file mode 100755 index 6cba48572..000000000 --- a/http-tests/admin/packages/install-package-403.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/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" - -# Unauthorized access (without certificate) should return 403 Forbidden -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/admin/packages/install-package-422.sh b/http-tests/admin/packages/install-package-422.sh deleted file mode 100755 index b5891998e..000000000 --- a/http-tests/admin/packages/install-package-422.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/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" - -# Invalid/non-existent package URI should return 422 Unprocessable Entity -# (package loading failed) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=${END_USER_BASE_URL}static/nonexistent/#package" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_UNPROCESSABLE_ENTITY" diff --git a/http-tests/admin/packages/install-package-document.sh b/http-tests/admin/packages/install-package-document.sh deleted file mode 100755 index f12073a66..000000000 --- a/http-tests/admin/packages/install-package-document.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/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" - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" - -# install package -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify package document was created (hash of package URI) -package_hash=$(echo -n "$package_uri" | shasum -a 1 | cut -d' ' -f1) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}packages/${package_hash}/" \ -| grep -qE "^($STATUS_OK|$STATUS_NOT_MODIFIED)$" - -# uninstall package -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify package document was deleted -#curl -k -w "%{http_code}\n" -o /dev/null -s \ -# -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ -# "${ADMIN_BASE_URL}packages/${package_hash}/" \ -#| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/admin/packages/install-package-internal-url-400.sh b/http-tests/admin/packages/install-package-internal-url-400.sh deleted file mode 100755 index a5ba23d36..000000000 --- a/http-tests/admin/packages/install-package-internal-url-400.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/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" - -# Test SSRF protection: package-uri with link-local address (169.254.0.0/16) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://169.254.1.1/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" - -# Test SSRF protection: package-uri with private class A address (10.0.0.0/8) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://10.0.0.1/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" - -# Test SSRF protection: package-uri with private class B address (172.16.0.0/12) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://172.16.0.0/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" - -# Test SSRF protection: package-uri with private class C address (192.168.0.0/16) should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=http://192.168.1.1/package#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_BAD_REQUEST" diff --git a/http-tests/admin/packages/install-package-stylesheet-no-duplicate.sh b/http-tests/admin/packages/install-package-stylesheet-no-duplicate.sh deleted file mode 100755 index 6eca6f823..000000000 --- a/http-tests/admin/packages/install-package-stylesheet-no-duplicate.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/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" - -# Clean up any leftover package stylesheet files from previous test runs -docker compose exec -T linkeddatahub rm -rf /usr/local/tomcat/webapps/ROOT/static/com/linkeddatahub/packages/skos 2>/dev/null || true -docker compose exec -T linkeddatahub sed -i '/linkeddatahub\/packages\/skos\/layout.xsl/d' /usr/local/tomcat/webapps/ROOT/static/xsl/layout.xsl 2>/dev/null || true - -# Tomcat caches static files with default cacheTtl=5000ms (5 seconds) -# See: https://tomcat.apache.org/tomcat-10.1-doc/config/resources.html#Attributes -default_ttl=5 - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" - -# first install -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify exactly one import after first install -import_count=$(curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" \ - | grep -c "com/linkeddatahub/packages/skos/layout.xsl" || true) -if [ "$import_count" -ne 1 ]; then - exit 1 -fi - -# second install (same package) -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify still exactly one import after second install (deduplication guard) -import_count=$(curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" \ - | grep -c "com/linkeddatahub/packages/skos/layout.xsl" || true) -if [ "$import_count" -ne 1 ]; then - exit 1 -fi - -# cleanup -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" diff --git a/http-tests/admin/packages/install-uninstall-package-ontology.sh b/http-tests/admin/packages/install-uninstall-package-ontology.sh deleted file mode 100755 index 7623b577c..000000000 --- a/http-tests/admin/packages/install-uninstall-package-ontology.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/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" - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" -package_ontology_uri="https://raw.githubusercontent.com/AtomGraph/LinkedDataHub-Apps/refs/heads/master/packages/skos/ns.ttl#" -namespace_ontology_uri="${END_USER_BASE_URL}ns#" - -# verify owl:imports triple does NOT exist before install -if curl -k -s -H "Accept: application/n-triples" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "${ADMIN_BASE_URL}ontologies/namespace/" \ -| grep -q "<${namespace_ontology_uri}> <${package_ontology_uri}>"; then - exit 1 -fi - -# install package -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify owl:imports triple was added (check graph store directly, not cached endpoint) -curl -k -s \ - -H "Accept: application/n-triples" \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}ontologies/namespace/" \ -| grep -q "<${namespace_ontology_uri}> <${package_ontology_uri}>" - -# verify package ontology document exists -package_ontology_hash=$(echo -n "$package_ontology_uri" | shasum -a 1 | cut -d' ' -f1) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}ontologies/${package_ontology_hash}/" \ -| grep -qE "^($STATUS_OK|$STATUS_NOT_MODIFIED)$" - -# uninstall package -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" \ -| grep -q "$STATUS_SEE_OTHER" - -# verify owl:imports triple was removed (check graph store directly, not cached endpoint) -ns_after=$(curl -k -s -H "Accept: application/n-triples" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "${ADMIN_BASE_URL}ontologies/namespace/") -if echo "$ns_after" | grep -q "<${namespace_ontology_uri}> <${package_ontology_uri}>"; then - exit 1 -fi - -# verify package ontology document was deleted -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - "${ADMIN_BASE_URL}ontologies/${package_ontology_hash}/" \ -| grep -q "$STATUS_NOT_FOUND" diff --git a/http-tests/admin/packages/install-uninstall-package-stylesheet.sh b/http-tests/admin/packages/install-uninstall-package-stylesheet.sh deleted file mode 100755 index 02452d711..000000000 --- a/http-tests/admin/packages/install-uninstall-package-stylesheet.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/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" - -# Clean up any leftover package stylesheet files from previous test runs -docker compose exec -T linkeddatahub rm -rf /usr/local/tomcat/webapps/ROOT/static/com/linkeddatahub/packages/skos 2>/dev/null || true -docker compose exec -T linkeddatahub sed -i '/linkeddatahub\/packages\/skos\/layout.xsl/d' /usr/local/tomcat/webapps/ROOT/static/xsl/layout.xsl 2>/dev/null || true - -# Tomcat caches static files with default cacheTtl=5000ms (5 seconds) -# See: https://tomcat.apache.org/tomcat-10.1-doc/config/resources.html#Attributes -default_ttl=5 - -# test package URI (SKOS package) -package_uri="https://packages.linkeddatahub.com/skos/#this" - -# verify package stylesheet does NOT exist initially (should return 404) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl" \ -| grep -q "$STATUS_NOT_FOUND" - -# verify master stylesheet does NOT include package initially -if curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" | grep -q "com/linkeddatahub/packages/skos/layout.xsl"; then - exit 1 -fi - -# install package -install-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify package stylesheet was installed (should return 200) -install_status=$(curl -k -w "%{http_code}\n" -o /dev/null -s \ - "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl") -if [ "$install_status" != "200" ]; then - exit 1 -fi - -# verify master stylesheet includes package -if ! curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" | grep -q "com/linkeddatahub/packages/skos/layout.xsl"; then - exit 1 -fi - -# uninstall package -uninstall-package.sh \ - -b "$END_USER_BASE_URL" \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --package "$package_uri" - -# Wait for Tomcat's static resource cache to expire -sleep $default_ttl - -# verify package stylesheet was deleted (should return 404) -curl -k -w "%{http_code}\n" -o /dev/null -s \ - "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl" \ -| grep -q "$STATUS_NOT_FOUND" - -# verify master stylesheet no longer includes package -if curl -k -s "${END_USER_BASE_URL}static/xsl/layout.xsl" | grep -q "com/linkeddatahub/packages/skos/layout.xsl"; then - exit 1 -fi diff --git a/http-tests/admin/packages/uninstall-package-400.sh b/http-tests/admin/packages/uninstall-package-400.sh deleted file mode 100755 index 50129dd8d..000000000 --- a/http-tests/admin/packages/uninstall-package-400.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/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" - -# Missing package-uri parameter should return 400 Bad Request -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_BAD_REQUEST" diff --git a/http-tests/config/dataspaces.trig b/http-tests/config/dataspaces.trig index a1f212417..bede0dd54 100644 --- a/http-tests/config/dataspaces.trig +++ b/http-tests/config/dataspaces.trig @@ -19,7 +19,7 @@ dct:title "LinkedDataHub admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } # root end-user @@ -30,7 +30,7 @@ dct:title "LinkedDataHub" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } @@ -42,7 +42,7 @@ dct:title "Test admin" ; lapp:origin ; ldt:ontology ; - ac:stylesheet . + ac:stylesheet . } # test end-user @@ -53,6 +53,6 @@ dct:title "Test" ; lapp:origin ; ldt:ontology ; - ac:stylesheet ; + ac:stylesheet ; lapp:public true . } diff --git a/http-tests/misc/PATCH-settings-package-import.sh b/http-tests/misc/PATCH-settings-package-import.sh new file mode 100755 index 000000000..1e5df3286 --- /dev/null +++ b/http-tests/misc/PATCH-settings-package-import.sh @@ -0,0 +1,58 @@ +#!/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" + +# Test: declarative package install — a single ldh:import triple PATCHed into the dataspace +# settings composes the package stylesheet into the app stylesheet on the next request. +# The update strings mirror the ones generated by the settings modal's package Save button. + +app_uri="urn:linkeddatahub:apps/end-user" +package_uri="https://packages.linkeddatahub.com/skos/#this" + +# stylesheet marker injected into every page by the SKOS package layout.xsl +marker="com/linkeddatahub/demo/skos/css/bootstrap.css" + +# verify the homepage is not rendered with the package stylesheet initially +if curl -k -s -H "Accept: text/html" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "$END_USER_BASE_URL" \ + | grep -q "$marker"; then + echo "DEBUG: package stylesheet marker present before import" + exit 1 +fi + +# declare the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "INSERT { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +# verify the homepage is rendered with the package stylesheet — no restart, no sleep +curl -k -s -H "Accept: text/html" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "$END_USER_BASE_URL" \ +| grep -q "$marker" + +# remove the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "DELETE { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +# verify the homepage is no longer rendered with the package stylesheet +if curl -k -s -H "Accept: text/html" -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" "$END_USER_BASE_URL" \ + | grep -q "$marker"; then + echo "DEBUG: package stylesheet marker still present after removal" + exit 1 +fi diff --git a/http-tests/misc/PATCH-settings-package-ontology.sh b/http-tests/misc/PATCH-settings-package-ontology.sh new file mode 100755 index 000000000..6d415ba91 --- /dev/null +++ b/http-tests/misc/PATCH-settings-package-ontology.sh @@ -0,0 +1,82 @@ +#!/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" + +# Test: the ldh:import declaration alone puts the package ontology into the application's +# ontology imports closure - the SKOS package's spin:constructor for skos:Concept becomes +# visible on the /ns endpoint after the PATCH and disappears again after removal. + +app_uri="urn:linkeddatahub:apps/end-user" +package_uri="https://packages.linkeddatahub.com/skos/#this" + +query='SELECT ?text WHERE { ?constructor . ?constructor ?text . }' + +constructor_count() { + curl -k -f -s -G \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/sparql-results+xml" \ + "${END_USER_BASE_URL}ns" \ + --data-urlencode "query=${query}" \ + | xmllint --xpath "count(//*[local-name() = 'result'])" - +} + +# the skos:Concept constructor is not in the app ontology closure initially +count=$(constructor_count) +if [ "$count" != "0" ]; then + echo "DEBUG: Expected 0 skos:Concept constructors before import, got: $count" + exit 1 +fi + +# declare the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "INSERT { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +# the /ns query URL is identical across the phases, so evict any cached response +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# the package ontology joined the closure - no restart, no sleep +count=$(constructor_count) +if [ "$count" != "1" ]; then + echo "DEBUG: Expected 1 skos:Concept constructor after import, got: $count" + exit 1 +fi + +# remove the package import +( +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -X PATCH \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/sparql-update" \ + -d "DELETE { <${app_uri}> <${package_uri}> . } WHERE { }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# the package ontology left the closure +count=$(constructor_count) +if [ "$count" != "0" ]; then + echo "DEBUG: Expected 0 skos:Concept constructors after removal, got: $count" + echo "DEBUG: does /settings still carry the ldh:import triple after the DELETE?" + curl -k -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings" \ + | grep -c "linkeddatahub#import" | sed 's/^/DEBUG: ldh:import triple count in settings = /' + exit 1 +fi diff --git a/http-tests/system/admin/POST-packages-install-401.sh b/http-tests/system/admin/POST-packages-install-401.sh deleted file mode 100755 index 8720c139f..000000000 --- a/http-tests/system/admin/POST-packages-install-401.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/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" - -# POST /packages/install without a certificate should return 401 -# Only owners have access to /packages/install via full-control authorization in admin.trig - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_UNAUTHORIZED" diff --git a/http-tests/system/admin/POST-packages-install-403.sh b/http-tests/system/admin/POST-packages-install-403.sh deleted file mode 100755 index f1a3eeee2..000000000 --- a/http-tests/system/admin/POST-packages-install-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/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" - -# POST /packages/install with a writer (not owner) should return 403 -# /packages/install is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/writers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/system/admin/POST-packages-install-readers-403.sh b/http-tests/system/admin/POST-packages-install-readers-403.sh deleted file mode 100755 index 5d19c435c..000000000 --- a/http-tests/system/admin/POST-packages-install-readers-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/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" - -# POST /packages/install with a reader should return 403 -# /packages/install is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/readers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/install" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/system/admin/POST-packages-uninstall-401.sh b/http-tests/system/admin/POST-packages-uninstall-401.sh deleted file mode 100755 index 5d12c86b0..000000000 --- a/http-tests/system/admin/POST-packages-uninstall-401.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/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" - -# POST /packages/uninstall without a certificate should return 401 -# Only owners have access to /packages/uninstall via full-control authorization in admin.trig - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_UNAUTHORIZED" diff --git a/http-tests/system/admin/POST-packages-uninstall-403.sh b/http-tests/system/admin/POST-packages-uninstall-403.sh deleted file mode 100755 index bb4911877..000000000 --- a/http-tests/system/admin/POST-packages-uninstall-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/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" - -# POST /packages/uninstall with a writer (not owner) should return 403 -# /packages/uninstall is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/writers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/http-tests/system/admin/POST-packages-uninstall-readers-403.sh b/http-tests/system/admin/POST-packages-uninstall-readers-403.sh deleted file mode 100755 index a8a3933ee..000000000 --- a/http-tests/system/admin/POST-packages-uninstall-readers-403.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/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" - -# POST /packages/uninstall with a reader should return 403 -# /packages/uninstall is only in the full-control authorization which is restricted to owners - -add-agent-to-group.sh \ - -f "$OWNER_CERT_FILE" \ - -p "$OWNER_CERT_PWD" \ - --agent "$AGENT_URI" \ - "${ADMIN_BASE_URL}acl/groups/readers/" - -curl -k -w "%{http_code}\n" -o /dev/null -s \ - -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ - -X POST \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "package-uri=https://packages.linkeddatahub.com/skos/#this" \ - "${ADMIN_BASE_URL}packages/uninstall" \ -| grep -q "$STATUS_FORBIDDEN" diff --git a/platform/datasets/admin.trig b/platform/datasets/admin.trig index 4c0fbf4b8..45511f7b7 100644 --- a/platform/datasets/admin.trig +++ b/platform/datasets/admin.trig @@ -332,7 +332,7 @@ WHERE rdfs:label "Full control" ; rdfs:comment "Allows full read/write access to all application resources" ; acl:accessToClass dh:Item, dh:Container, def:Root ; - acl:accessTo , , ; + acl:accessTo ; acl:mode acl:Read, acl:Append, acl:Write, acl:Control ; acl:agentGroup . @@ -388,42 +388,6 @@ WHERE } -### PACKAGES ### - -# ENDPOINTS - - -{ - - a foaf:Document ; - dct:title "Install package endpoint" . - -} - - -{ - - a foaf:Document ; - dct:title "Uninstall package endpoint" . - -} - -# CONTAINERS - - -{ - - a dh:Container ; - sioc:has_parent <> ; - dct:title "Packages" ; - dct:description "Manage installed packages" ; - rdf:_1 . - - a ldh:Object ; - rdf:value ldh:ChildrenView . - -} - ### ONTOLOGIES ### # CONTAINERS diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 1dcfe4095..60f7aabe1 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -17,7 +17,7 @@ package com.atomgraph.linkeddatahub; import com.atomgraph.client.util.jena.PrefixGraphRepository; -import com.atomgraph.client.util.StylesheetResolver; +import com.atomgraph.linkeddatahub.server.util.LocalStylesheetResolver; import com.atomgraph.linkeddatahub.writer.impl.SameSiteSourceResolver; import com.atomgraph.linkeddatahub.server.util.OntologyRepository; import org.apache.jena.riot.RDFParser; @@ -134,6 +134,7 @@ import com.google.common.eventbus.EventBus; import com.google.common.eventbus.Subscribe; import org.apache.jena.enhanced.BuiltinPersonalities; +import org.apache.jena.ontology.ConversionException; import org.apache.jena.riot.RDFParserRegistry; import org.slf4j.Logger; import java.net.URI; @@ -182,6 +183,7 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.Optional; import java.util.TreeMap; import java.util.concurrent.ExecutorService; @@ -257,12 +259,6 @@ public class Application extends ResourceConfig private static final Logger log = LoggerFactory.getLogger(Application.class); - /** - * Path to the master XSLT stylesheet for server-side transformations. - * Package stylesheets are imported into this master stylesheet. - */ - public static final String MASTER_STYLESHEET_PATH = "/static/xsl/layout.xsl"; - private final ExecutorService importThreadPool; private final ServletConfig servletConfig; private final EventBus eventBus = new EventBus(); @@ -860,7 +856,7 @@ protected PasswordAuthentication getPasswordAuthentication() xsltComp = xsltProc.newXsltCompiler(); xsltComp.setParameter(new QName("ldh", LDH.base.getNameSpace(), LDH.base.getLocalName()), new XdmAtomicValue(baseURI)); - xsltComp.setURIResolver(new StylesheetResolver(client)); // resolves xsl:import to raw stylesheet sources + xsltComp.setURIResolver(new LocalStylesheetResolver(this, servletConfig.getServletContext(), client)); // resolves xsl:import to raw stylesheet sources, app-origin /static/ URLs locally xsltExec = xsltComp.compile(stylesheet); } catch (FileNotFoundException ex) @@ -1930,6 +1926,69 @@ public Map getOntologyGraphs() return ontologyGraphs; } + /** + * Loads the package description from its URI. + * Mapped locations (e.g. bundled package descriptions) and cached graphs are read from the graph + * repository; other URIs are dereferenced over HTTP. + * + * @param packageURI package URI + * @return package resource, or null if the description could not be resolved + */ + public com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) + { + final Model model; + + if (getRepository().isCached(packageURI) || getRepository().isMapped(packageURI)) + model = ModelFactory.createModelForGraph(getRepository().get(packageURI)); + else + { + try + { + // validate package URI to prevent SSRF attacks + getURLValidator().validate(URI.create(packageURI)); + + model = GraphStoreClient.create(getClient(), getMediaTypes()).getModel(packageURI); + } + catch (RuntimeException ex) // invalid URI, 404 from the package server, connection refused, timeout... + { + if (log.isErrorEnabled()) log.error("Loading package description failed: {}", packageURI, ex); + return null; + } + } + + try + { + return model.getResource(packageURI).as(com.atomgraph.linkeddatahub.apps.model.Package.class); + } + catch (ConversionException ex) + { + if (log.isErrorEnabled()) log.error("Resource <{}> cannot be converted to a Package", packageURI, ex); + return null; + } + } + + /** + * Resolves the descriptions of the packages imported by the application and returns their + * ontology URIs, ordered by package URI. Packages whose description cannot be resolved, or + * without an ontology (stylesheet-only), are skipped. + * + * @param app application resource + * @return list of package ontology URIs + */ + public List getPackageOntologies(com.atomgraph.linkeddatahub.apps.model.Application app) + { + return app.getImportedPackages().stream(). + filter(Resource::isURIResource). + map(Resource::getURI). + sorted(). + map(this::getPackage). + filter(Objects::nonNull). + map(com.atomgraph.linkeddatahub.apps.model.Package::getOntology). + filter(Objects::nonNull). + map(ontology -> URI.create(ontology.getURI())). + collect(Collectors.toList()); + } + /** * Returns a registry of readable and writeable media types. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java index 1d2d02f9b..4ad8b71e5 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java @@ -51,14 +51,4 @@ public interface Package extends Resource */ java.util.Set getImportedPackages(); - /** - * Returns the filesystem resource path for this package. - * Converts the package URI to a path by reversing hostname components. - * Example: https://packages.linkeddatahub.com/skos/#this -> com/linkeddatahub/packages/skos - * - * @return filesystem path relative to static directory - * @throws IllegalArgumentException if package URI is invalid - */ - String getStylesheetPath(); - } diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java index 311a98a78..a431ee262 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java @@ -27,7 +27,6 @@ import org.apache.jena.rdf.model.StmtIterator; import org.apache.jena.rdf.model.impl.ResourceImpl; -import java.net.URI; import java.util.HashSet; import java.util.Set; @@ -86,44 +85,4 @@ public Set getImportedPackages() return packages; } - @Override - public String getStylesheetPath() - { - String uri = getURI(); - if (uri == null) - throw new IllegalArgumentException("Package URI cannot be null"); - - try - { - URI uriObj = URI.create(uri); - String host = uriObj.getHost(); - String path = uriObj.getPath(); - - if (host == null) - throw new IllegalArgumentException("Package URI must have a host: " + uri); - - // Reverse hostname components: packages.linkeddatahub.com -> com/linkeddatahub/packages - String[] hostParts = host.split("\\."); - StringBuilder reversedHost = new StringBuilder(); - for (int i = hostParts.length - 1; i >= 0; i--) - { - reversedHost.append(hostParts[i]); - if (i > 0) reversedHost.append("/"); - } - - // Append path without leading/trailing slashes and fragment - if (path != null && !path.isEmpty() && !path.equals("/")) - { - String cleanPath = path.replaceAll("^/+|/+$", ""); // Remove leading/trailing slashes - return reversedHost + "/" + cleanPath; - } - - return reversedHost.toString(); - } - catch (IllegalArgumentException e) - { - throw new IllegalArgumentException("Invalid package URI: " + uri, e); - } - } - } diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java index ea051eec8..c39802234 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java @@ -18,8 +18,10 @@ import com.atomgraph.core.util.ModelUtils; import com.atomgraph.linkeddatahub.apps.model.Application; +import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; import com.atomgraph.linkeddatahub.server.io.ValidatingModelProvider; import com.atomgraph.linkeddatahub.vocabulary.LAPP; +import jakarta.ws.rs.container.ResourceContext; import jakarta.inject.Inject; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.GET; @@ -61,6 +63,7 @@ public class Settings private final com.atomgraph.linkeddatahub.Application system; private final Providers providers; private final Request request; + private final ResourceContext resourceContext; /** * Constructs the Settings endpoint. @@ -69,14 +72,16 @@ public class Settings * @param system the system application * @param providers JAX-RS provider registry * @param request JAX-RS request context + * @param resourceContext JAX-RS resource context (for delegating to sub-resources) */ @Inject - public Settings(Application application, com.atomgraph.linkeddatahub.Application system, @Context Providers providers, @Context Request request) + public Settings(Application application, com.atomgraph.linkeddatahub.Application system, @Context Providers providers, @Context Request request, @Context ResourceContext resourceContext) { this.application = application; this.system = system; this.providers = providers; this.request = request; + this.resourceContext = resourceContext; } /** @@ -147,6 +152,13 @@ public Response patch(UpdateRequest updateRequest) throws IOException // Write the updated model back to the context dataset file getSystem().updateApp(getApplication(), mutableModel); + // clear and reload the ontology so the next request re-derives with the updated ldh:import set. + // Delegate to ClearOntology (context-agnostic) for the full eviction - repository graph + closure + // union + proxy cache purges - rather than duplicating it: clearing only the in-memory caches + // would leave stale /ns SPARQL responses in varnish after a package add/remove + if (getApplication().getOntology() != null) + getResourceContext().getResource(ClearOntology.class).post(getApplication().getOntology().getURI(), null); + if (log.isInfoEnabled()) log.info("Updated settings for dataspace <{}> via PATCH", getApplication().getURI()); return Response.noContent().build(); @@ -182,6 +194,16 @@ public Providers getProviders() return providers; } + /** + * Returns the JAX-RS resource context, used to obtain fully-injected sub-resource instances. + * + * @return the resource context + */ + public ResourceContext getResourceContext() + { + return resourceContext; + } + /** * Validates model against SPIN and SHACL constraints. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java index 190fbd346..c69db8d3b 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java @@ -76,7 +76,20 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer { if (ontologyURI == null) throw new BadRequestException("Ontology URI not specified"); - EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); // we're assuming the current app is admin + // resolve both apps regardless of which one the request matched: /clear is admin, but Settings + // delegates here on the end-user app (its PATCH origin), and both backends need purging either way + final EndUserApplication endUserApp; + final AdminApplication adminApp; + if (getApplication().canAs(AdminApplication.class)) + { + adminApp = getApplication().as(AdminApplication.class); + endUserApp = adminApp.getEndUserApplication(); + } + else + { + endUserApp = getApplication().as(EndUserApplication.class); + adminApp = endUserApp.getAdminApplication(); + } OntologyRepository repository = getSystem().getRepository(endUserApp); if (repository.isCached(ontologyURI) || getSystem().getOntologyGraphs().containsKey(ontologyURI)) { @@ -94,7 +107,7 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer if (log.isDebugEnabled()) log.debug("Purge ontology document with URI '{}' from frontend proxy cache", ontologyDocURI); ban(frontendProxy, ontologyDocURI.toString(), false); } - URI adminBackendProxy = getSystem().getServiceContext(getApplication().getService()).getBackendProxy(); + URI adminBackendProxy = getSystem().getServiceContext(adminApp.getService()).getBackendProxy(); if (adminBackendProxy != null) { // URL-pattern BAN of the ontology URI is a no-op on the SPARQL proxy (its req.url namespace is /ds/?query=..., @@ -112,7 +125,7 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer } // !!! we need to reload the ontology model before returning a response, to make sure the next request already gets the new version !!! - getSystem().getOntologyGraphs().put(ontologyURI, OntologyFilter.loadOntology(repository, ontologyURI)); + getSystem().getOntologyGraphs().put(ontologyURI, OntologyFilter.loadOntology(repository, ontologyURI, getSystem().getPackageOntologies(endUserApp))); } if (referer != null) return Response.seeOther(referer).build(); diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java deleted file mode 100644 index fdebf6bb8..000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/InstallPackage.java +++ /dev/null @@ -1,540 +0,0 @@ -/** - * Copyright 2025 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.resource.admin.pkg; - -import static com.atomgraph.client.MediaType.TEXT_XSL; -import com.atomgraph.linkeddatahub.apps.model.AdminApplication; -import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; -import com.atomgraph.linkeddatahub.client.GraphStoreClient; -import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; -import com.atomgraph.linkeddatahub.server.security.AgentContext; -import com.atomgraph.linkeddatahub.server.util.XSLTMasterUpdater; -import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY; -import jakarta.inject.Inject; -import jakarta.servlet.ServletContext; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.FormParam; -import jakarta.ws.rs.HeaderParam; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.client.Client; -import jakarta.ws.rs.client.WebTarget; -import jakarta.ws.rs.container.ResourceContext; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; -import org.apache.commons.codec.binary.Hex; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Optional; -import jakarta.ws.rs.NotFoundException; -import jakarta.ws.rs.ProcessingException; -import org.apache.jena.ontology.ConversionException; -import org.apache.jena.update.UpdateFactory; -import org.apache.jena.update.UpdateRequest; -import com.atomgraph.linkeddatahub.vocabulary.DH; -import com.atomgraph.linkeddatahub.vocabulary.FOAF; -import com.atomgraph.linkeddatahub.vocabulary.SIOC; -import com.atomgraph.linkeddatahub.server.util.Skolemizer; -import org.apache.jena.rdf.model.ModelFactory; -import org.apache.jena.vocabulary.DCTerms; -import org.apache.jena.vocabulary.RDF; - -/** - * JAX-RS resource that installs a LinkedDataHub package. - * Package installation involves: - * 1. Fetching package metadata - * 2. Downloading and validating package resources (ontology and stylesheet) - * 3. Creating item document under packages/ container with package metadata - * 4. PUTting package ontology as new document under ontologies/{hash}/ - * 5. Adding owl:imports of package ontology to namespace ontology - * 6. Saving package stylesheet (layout.xsl) to /static/{package-path}/ - * 7. Regenerating application master stylesheet - * - * @author Martynas Jusevičius {@literal } - */ -public class InstallPackage -{ - private static final Logger log = LoggerFactory.getLogger(InstallPackage.class); - - private final com.atomgraph.linkeddatahub.apps.model.Application application; - private final com.atomgraph.linkeddatahub.Application system; - private final Optional agentContext; - - @Context ServletContext servletContext; - @Context ResourceContext resourceContext; - - /** - * Constructs endpoint. - * - * @param application matched application (admin app) - * @param system system application - * @param agentContext authenticated agent context - */ - @Inject - public InstallPackage(com.atomgraph.linkeddatahub.apps.model.Application application, - com.atomgraph.linkeddatahub.Application system, - Optional agentContext) - { - this.application = application; - this.system = system; - this.agentContext = agentContext; - } - - /** - * Installs a package into the current dataspace. - * - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @param referer the referring URL - * @return JAX-RS response - */ - @POST - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response post(@FormParam("package-uri") String packageURI, @HeaderParam("Referer") URI referer) - { - if (packageURI == null) - { - if (log.isErrorEnabled()) log.error("Package URI not specified"); - throw new BadRequestException("Package URI not specified"); - } - - // Validate package URI to prevent SSRF attacks - getSystem().getURLValidator().validate(URI.create(packageURI)); - - if (log.isInfoEnabled()) log.info("Installing package: {}", packageURI); - com.atomgraph.linkeddatahub.apps.model.Package pkg = getPackage(packageURI); - if (pkg == null) - { - if (log.isErrorEnabled()) log.error("Loading package failed: {}", packageURI); - throw new WebApplicationException("Loading package failed", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - Resource ontology = pkg.getOntology(); - Resource stylesheet = pkg.getStylesheet(); - - if (ontology == null && stylesheet == null) - { - if (log.isErrorEnabled()) log.error("Package ontology and stylesheet are both unspecified for package: {}", packageURI); - throw new WebApplicationException("Package ontology and stylesheet are both unspecified", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - try - { - EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); - AdminApplication adminApp = endUserApp.getAdminApplication(); - - if (ontology != null) - { - // Validate ontology URI to prevent SSRF attacks - getSystem().getURLValidator().validate(URI.create(ontology.getURI())); - - if (log.isDebugEnabled()) log.debug("Downloading package ontology from: {}", ontology.getURI()); - Model ontologyModel = downloadOntology(ontology.getURI()); - - installOntology(endUserApp, ontologyModel, ontology.getURI()); - } - - if (stylesheet != null) - { - URI stylesheetURI = URI.create(stylesheet.getURI()); - String packagePath = pkg.getStylesheetPath(); - - // Validate stylesheet URI to prevent SSRF attacks - getSystem().getURLValidator().validate(stylesheetURI); - - if (log.isDebugEnabled()) log.debug("Downloading package stylesheet from: {}", stylesheetURI); - String stylesheetContent = downloadStylesheet(stylesheetURI); - - installStylesheet(Paths.get(getServletContext().getRealPath("/static")).resolve(packagePath).resolve("layout.xsl"), stylesheetContent); - - // Purge package stylesheet from frontend proxy cache - String stylesheetURL = "/static/" + packagePath + "/layout.xsl"; - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging package stylesheet from frontend proxy cache: {}", stylesheetURL); - getSystem().ban(getSystem().getFrontendProxy(), stylesheetURL, false); - } - - regenerateMasterStylesheet(endUserApp, pkg); - - // Purge master stylesheet from frontend proxy cache - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging master stylesheet from frontend proxy cache: {}", com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH); - getSystem().ban(getSystem().getFrontendProxy(), com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH, false); - } - } - - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - if (getAgentContext().isPresent()) gsc = gsc.delegation(adminApp.getBaseURI(), getAgentContext().get()); - - String slug = hashURI(packageURI); - URI packageDocumentURI = adminApp.getUriBuilder(). - path("packages/"). - path("{slug}/"). - build(slug); - Model packageDocModel = ModelFactory.createDefaultModel(); - packageDocModel.add(pkg.getModel()); - Resource container = packageDocModel.createResource(adminApp.getBaseURI().resolve("packages/").toString()); - createPackageDocument(packageDocModel, packageDocumentURI, container, pkg, slug); - new Skolemizer(packageDocumentURI.toString()).apply(packageDocModel); - putPackageDocument(gsc, packageDocumentURI, packageDocModel); - - if (log.isInfoEnabled()) log.info("Successfully installed package: {}", packageURI); - - // Redirect back to referer or application base - URI redirectURI = (referer != null) ? referer : endUserApp.getBaseURI(); - return Response.seeOther(redirectURI).build(); - } - catch (IOException e) - { - log.error("Failed to install package: {}", packageURI, e); - throw new WebApplicationException("Package installation failed", e); - } - } - - /** - * Loads package metadata from its URI using GraphStoreClient. - * Package metadata is expected to be available as Linked Data. - * - * @param gsc the graph store client - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @return Package instance - * @throws NotFoundException if package cannot be found (404) - */ - private com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) - { - if (log.isDebugEnabled()) log.debug("Loading package from: {}", packageURI); - - final Model model; - - // check if we have the model in the cache first and if yes, return it from there instead making an HTTP request - if (getSystem().getRepository().isCached(packageURI) || - (getSystem().getRepository().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file - { - if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, getSystem().getRepository().isCached(packageURI)); - if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getSystem().getRepository().isMapped(packageURI)); - model = ModelFactory.createModelForGraph(getSystem().getRepository().get(packageURI)); - } - else - { - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - try - { - model = gsc.getModel(packageURI); - } - catch (NotFoundException ex) // 404 from the package server - { - return null; - } - catch (ProcessingException ex) // connection refused, timeout, etc. - { - return null; - } - } - - try - { - return model.getResource(packageURI).as(com.atomgraph.linkeddatahub.apps.model.Package.class); - } - catch (ConversionException ex) - { - return null; - } - } - - /** - * Downloads RDF from a URI using GraphStoreClient. - */ - private Model downloadOntology(String uri) - { - if (log.isDebugEnabled()) log.debug("Downloading ontology from: {}", uri); - - // check if we have the model in the cache first and if yes, return it from there instead making an HTTP request - if (getSystem().getRepository().isCached(uri) || - (getSystem().getRepository().isMapped(uri))) // read mapped URIs (such as system ontologies) from a file - { - if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", uri, getSystem().getRepository().isCached(uri)); - if (log.isDebugEnabled()) log.debug("isMapped({}): {}", uri, getSystem().getRepository().isMapped(uri)); - return ModelFactory.createModelForGraph(getSystem().getRepository().get(uri)); - } - else - { - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - return gsc.getModel(uri); - } - } - - /** - * Downloads XSLT stylesheet content from a URI using Jersey Client. - * Prioritizes text/xsl, falls back to text/*. - */ - private String downloadStylesheet(URI uri) throws IOException - { - if (log.isDebugEnabled()) log.debug("Downloading XSLT stylesheet from: {}", uri); - - WebTarget target = getClient().target(uri); - // Prioritize text/xsl (q=1.0), then any text/* (q=0.8) - try (Response response = target.request(TEXT_XSL, "text/*;q=0.8").get()) - { - if (!response.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to download XSLT from {}: {}", uri, response.getStatus()); - throw new IOException("Failed to download XSLT from " + uri + ": " + response.getStatus()); - } - - return response.readEntity(String.class); - } - } - - /** - * Hashes a URI using SHA-1 to create a unique document slug. - * - * @param uri the URI to hash - * @return the SHA-1 hash as a hexadecimal string - * @throws IOException if hashing fails - */ - private String hashURI(String uri) throws IOException - { - try - { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - md.update(uri.getBytes(StandardCharsets.UTF_8)); - String hash = Hex.encodeHexString(md.digest()); - if (log.isDebugEnabled()) log.debug("URI '{}' hashed to '{}'", uri, hash); - return hash; - } - catch (NoSuchAlgorithmException e) - { - if (log.isErrorEnabled()) log.error("Failed to hash URI: {}", uri, e); - throw new IOException("Failed to hash URI", e); - } - } - - /** - * Installs ontology by PUTting as a new document and adding owl:imports to namespace ontology. - * - * @param app the end-user application - * @param ontologyModel the package ontology model - * @param packageOntologyURI the package ontology URI - * @throws IOException if installation fails - */ - private void installOntology(EndUserApplication app, Model ontologyModel, String packageOntologyURI) throws IOException - { - AdminApplication adminApp = app.getAdminApplication(); - - // 1. Create hash of package ontology URI to use as document slug - String hash = hashURI(packageOntologyURI); - - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - - // Delegate agent credentials if authenticated - if (getAgentContext().isPresent()) - { - if (log.isDebugEnabled()) log.debug("Delegating agent credentials for PUT request"); - gsc = gsc.delegation(adminApp.getBaseURI(), getAgentContext().get()); - } - - // 2. PUT package ontology as a document under model/ontologies/{hash}/ (overwrites if exists) - URI ontologyDocumentURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/{hash}/").build(hash); - if (log.isDebugEnabled()) log.debug("PUTting package ontology to document: {}", ontologyDocumentURI); - - try (Response putResponse = gsc.put(ontologyDocumentURI, ontologyModel)) - { - if (!putResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PUT package ontology to {}: {}", ontologyDocumentURI, putResponse.getStatus()); - throw new IOException("Failed to PUT package ontology to " + ontologyDocumentURI + ": " + putResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Package ontology PUT response status: {}", putResponse.getStatus()); - } - - // 3. Add owl:imports triple to namespace ontology in namespace graph - String namespaceOntologyURI = app.getOntology().getURI(); - URI namespaceGraphURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/namespace/").build(); - - if (log.isDebugEnabled()) log.debug("Adding owl:imports from namespace ontology '{}' to package ontology '{}'", namespaceOntologyURI, packageOntologyURI); - - String updateString = String.format( - "PREFIX owl: " + - "INSERT { <%s> owl:imports <%s> } WHERE { }", - namespaceOntologyURI, packageOntologyURI - ); - UpdateRequest updateRequest = UpdateFactory.create(updateString); - - try (Response patchResponse = gsc.patch(namespaceGraphURI, updateRequest)) - { - if (!patchResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PATCH namespace graph {}: {}", namespaceGraphURI, patchResponse.getStatus()); - throw new IOException("Failed to PATCH namespace graph " + namespaceGraphURI + ": " + patchResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Namespace graph PATCH response status: {}", patchResponse.getStatus()); - } - - if (log.isDebugEnabled()) log.debug("Clearing and reloading namespace ontology '{}'", namespaceOntologyURI); - getResourceContext().getResource(ClearOntology.class).post(namespaceOntologyURI, null); - } - - /** - * Installs stylesheet to /static//layout.xsl - */ - private void installStylesheet(Path stylesheetFile, String stylesheetContent) throws IOException - { - Files.createDirectories(stylesheetFile.getParent()); - Files.writeString(stylesheetFile, stylesheetContent); - - if (log.isDebugEnabled()) log.debug("Installed package stylesheet at: {}", stylesheetFile); - } - - /** - * Regenerates master stylesheet for the application. - * - * @param app the application - * @param newPackage the package being installed - * @throws IOException if regeneration fails - */ - private void regenerateMasterStylesheet(EndUserApplication app, com.atomgraph.linkeddatahub.apps.model.Package newPackage) throws IOException - { - XSLTMasterUpdater updater = new XSLTMasterUpdater(getServletContext()); - updater.addPackageImport(newPackage.getStylesheetPath()); - } - - /** - * Creates a package document item. - * - * @param model the model to populate - * @param packageDocumentURI the document URI - * @param container the container resource - * @param pkg the package resource - * @param slug the document slug - * @return the document item resource - */ - private Resource createPackageDocument(Model model, - URI packageDocumentURI, - Resource container, - com.atomgraph.linkeddatahub.apps.model.Package pkg, - String slug) - { - return model.createResource(packageDocumentURI.toString()). - addProperty(RDF.type, DH.Item). - addProperty(SIOC.HAS_CONTAINER, container). - addLiteral(DH.slug, slug). - addLiteral(DCTerms.title, pkg.getProperty(DCTerms.title).getString()). - addProperty(FOAF.primaryTopic, pkg); - } - - /** - * PUTs a package document to the specified URI. - * - * @param gsc the graph store client - * @param packageDocumentURI the document URI - * @param packageDocModel the package document model - * @throws IOException if PUT fails - */ - private void putPackageDocument(GraphStoreClient gsc, URI packageDocumentURI, Model packageDocModel) throws IOException - { - if (log.isDebugEnabled()) log.debug("PUTting package document to: {}", packageDocumentURI); - - try (Response putResponse = gsc.put(packageDocumentURI, packageDocModel)) - { - if (!putResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PUT package document to {}: {}", packageDocumentURI, putResponse.getStatus()); - throw new IOException("Failed to PUT package document to " + packageDocumentURI + ": " + putResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Package document PUT response status: {}", putResponse.getStatus()); - } - - if (log.isInfoEnabled()) log.info("Successfully created package document at: {}", packageDocumentURI); - } - - /** - * Returns the current application. - * - * @return application resource - */ - public com.atomgraph.linkeddatahub.apps.model.Application getApplication() - { - return application; - } - - /** - * Returns the system application. - * - * @return system application - */ - public com.atomgraph.linkeddatahub.Application getSystem() - { - return system; - } - - /** - * Returns Jersey HTTP client. - * - * @return HTTP client - */ - public Client getClient() - { - return getSystem().getClient(); - } - - /** - * Returns servlet context. - * - * @return servlet context - */ - public ServletContext getServletContext() - { - return servletContext; - } - - - /** - * Returns JAX-RS resource context. - * - * @return resource context - */ - public ResourceContext getResourceContext() - { - return resourceContext; - } - - /** - * Returns the authenticated agent context. - * - * @return agent context - */ - public Optional getAgentContext() - { - return agentContext; - } - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java deleted file mode 100644 index a00539a11..000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/UninstallPackage.java +++ /dev/null @@ -1,369 +0,0 @@ -/** - * Copyright 2025 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.resource.admin.pkg; - -import com.atomgraph.linkeddatahub.apps.model.AdminApplication; -import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; -import com.atomgraph.linkeddatahub.client.GraphStoreClient; -import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; -import com.atomgraph.linkeddatahub.server.security.AgentContext; -import com.atomgraph.linkeddatahub.server.util.XSLTMasterUpdater; -import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY; -import jakarta.inject.Inject; -import jakarta.servlet.ServletContext; -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.FormParam; -import jakarta.ws.rs.HeaderParam; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.container.ResourceContext; -import jakarta.ws.rs.core.Context; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.UriBuilder; -import org.apache.commons.codec.binary.Hex; -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.update.UpdateFactory; -import org.apache.jena.update.UpdateRequest; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Optional; -import org.apache.jena.ontology.ConversionException; - -/** - * JAX-RS resource that uninstalls a LinkedDataHub package. - * Package uninstallation involves: - * 1. DELETEing package ontology document from ontologies/{hash}/ - * 2. Removing owl:imports triple from namespace graph - * 3. Clearing and reloading namespace ontology from cache - * 4. Deleting package stylesheet from /static/{package-path}/ - * 5. Regenerating application master stylesheet - * - * @author Martynas Jusevičius {@literal } - */ -public class UninstallPackage -{ - private static final Logger log = LoggerFactory.getLogger(UninstallPackage.class); - - private final com.atomgraph.linkeddatahub.apps.model.Application application; - private final com.atomgraph.linkeddatahub.Application system; - private final Optional agentContext; - - @Context ServletContext servletContext; - @Context ResourceContext resourceContext; - - /** - * Constructs endpoint. - * - * @param application matched application (admin app) - * @param system system application - * @param agentContext authenticated agent context - */ - @Inject - public UninstallPackage(com.atomgraph.linkeddatahub.apps.model.Application application, - com.atomgraph.linkeddatahub.Application system, - Optional agentContext) - { - this.application = application; - this.system = system; - this.agentContext = agentContext; - } - - /** - * Uninstalls a package from the current dataspace. - * - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @param referer the referring URL - * @return JAX-RS response - */ - @POST - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - public Response post(@FormParam("package-uri") String packageURI, @HeaderParam("Referer") URI referer) - { - if (packageURI == null) - { - if (log.isErrorEnabled()) log.error("Package URI not specified"); - throw new BadRequestException("Package URI not specified"); - } - - try - { - EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); - - if (log.isInfoEnabled()) log.info("Uninstalling package: {}", packageURI); - - com.atomgraph.linkeddatahub.apps.model.Package pkg = getPackage(packageURI); - if (pkg == null) - { - if (log.isErrorEnabled()) log.error("Loading package failed: {}", packageURI); - throw new WebApplicationException("Loading package failed", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - Resource ontology = pkg.getOntology(); - Resource stylesheet = pkg.getStylesheet(); - - // either ontology or stylesheet need to be specified, or both - if (ontology == null && stylesheet == null) - { - if (log.isErrorEnabled()) log.error("Package ontology and stylesheet are both unspecified for package: {}", packageURI); - throw new WebApplicationException("Package ontology and stylesheet are both unspecified", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity - } - - if (ontology != null) uninstallOntology(endUserApp, ontology.getURI()); - - if (stylesheet != null) - { - String packagePath = pkg.getStylesheetPath(); - Path packageDir = Paths.get(getServletContext().getRealPath("/static")).resolve(packagePath); - Path stylesheetFile = packageDir.resolve("layout.xsl"); - - uninstallStylesheet(stylesheetFile, packagePath, endUserApp); - regenerateMasterStylesheet(endUserApp, pkg); - } - - if (log.isInfoEnabled()) log.info("Successfully uninstalled package: {}", packageURI); - - URI redirectURI = (referer != null) ? referer : endUserApp.getBaseURI(); - return Response.seeOther(redirectURI).build(); - } - catch (IOException e) - { - if (log.isErrorEnabled()) log.error("Failed to uninstall package: {}", packageURI, e); - throw new WebApplicationException("Package uninstallation failed", e); - } - } - - /** - * Uninstalls ontology by deleting the package ontology document. - * - * @param app the end-user application - * @param packageOntologyURI the package ONTOLOGY URI - * @throws IOException if uninstallation fails - */ - private void uninstallOntology(EndUserApplication app, String packageOntologyURI) throws IOException - { - AdminApplication adminApp = app.getAdminApplication(); - - String hash; - try - { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - md.update(packageOntologyURI.getBytes(StandardCharsets.UTF_8)); - hash = Hex.encodeHexString(md.digest()); - if (log.isDebugEnabled()) log.debug("Package ontology URI '{}' hashed to '{}'", packageOntologyURI, hash); - } - catch (NoSuchAlgorithmException e) - { - if (log.isErrorEnabled()) log.error("Failed to hash package ontology URI: {}", packageOntologyURI, e); - throw new IOException("Failed to hash package ontology URI", e); - } - - // 3. DELETE package ontology document at ontologies/{hash}/ - URI ontologyDocumentURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/{hash}/").build(hash); - if (log.isDebugEnabled()) log.debug("DELETEing package ontology document: {}", ontologyDocumentURI); - - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - - // Delegate agent credentials if authenticated - if (getAgentContext().isPresent()) - { - if (log.isDebugEnabled()) log.debug("Delegating agent credentials for DELETE request"); - gsc = gsc.delegation(adminApp.getBaseURI(), getAgentContext().get()); - } - - try (Response deleteResponse = gsc.delete(ontologyDocumentURI)) - { - if (!deleteResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to DELETE package ontology document {}: {}", ontologyDocumentURI, deleteResponse.getStatus()); - throw new IOException("Failed to DELETE package ontology document " + ontologyDocumentURI + ": " + deleteResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Package ontology DELETE response status: {}", deleteResponse.getStatus()); - } - - // 4. Remove owl:imports triple from namespace ontology in namespace graph - String namespaceOntologyURI = app.getOntology().getURI(); - URI namespaceGraphURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("ontologies/namespace/").build(); - - if (log.isDebugEnabled()) log.debug("Removing owl:imports from namespace ontology '{}' to package ontology '{}'", namespaceOntologyURI, packageOntologyURI); - - String updateString = String.format( - "PREFIX owl: " + - "DELETE WHERE { <%s> owl:imports <%s> }", - namespaceOntologyURI, packageOntologyURI - ); - UpdateRequest updateRequest = UpdateFactory.create(updateString); - - try (Response patchResponse = gsc.patch(namespaceGraphURI, updateRequest)) - { - if (!patchResponse.getStatusInfo().getFamily().equals(Response.Status.Family.SUCCESSFUL)) - { - if (log.isErrorEnabled()) log.error("Failed to PATCH namespace graph {}: {}", namespaceGraphURI, patchResponse.getStatus()); - throw new IOException("Failed to PATCH namespace graph " + namespaceGraphURI + ": " + patchResponse.getStatus()); - } - if (log.isDebugEnabled()) log.debug("Namespace graph PATCH response status: {}", patchResponse.getStatus()); - } - - // 5. Clear and reload namespace ontology from cache - if (log.isDebugEnabled()) log.debug("Clearing and reloading namespace ontology '{}'", namespaceOntologyURI); - getResourceContext().getResource(ClearOntology.class).post(namespaceOntologyURI, null); - } - - /** - * Deletes stylesheet from /static// - */ - private void uninstallStylesheet(Path stylesheetFile, String packagePath, EndUserApplication endUserApp) throws IOException - { - Files.delete(stylesheetFile); - if (log.isDebugEnabled()) log.debug("Deleted package stylesheet: {}", stylesheetFile); - - // Purge stylesheet from frontend proxy cache - String stylesheetURL = "/static/" + packagePath + "/layout.xsl"; - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging stylesheet from frontend proxy cache: {}", stylesheetURL); - getSystem().ban(getSystem().getFrontendProxy(), stylesheetURL, false); - } - - // Delete directory if empty - if (Files.list(stylesheetFile.getParent()).count() == 0) - { - Files.delete(stylesheetFile.getParent()); - if (log.isDebugEnabled()) log.debug("Deleted package directory: {}", stylesheetFile.getParent()); - } - } - - /** - * Regenerates master stylesheet for the application without the uninstalled package. - * - * @param app the application - * @param removedPackage the package being uninstalled - * @throws IOException if regeneration fails - */ - private void regenerateMasterStylesheet(EndUserApplication app, com.atomgraph.linkeddatahub.apps.model.Package removedPackage) throws IOException - { - XSLTMasterUpdater updater = new XSLTMasterUpdater(getServletContext()); - updater.removePackageImport(removedPackage.getStylesheetPath()); - - // Purge master stylesheet from cache - if (getSystem().getFrontendProxy() != null) - { - if (log.isDebugEnabled()) log.debug("Purging master stylesheet from frontend proxy cache: {}", com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH); - getSystem().ban(getSystem().getFrontendProxy(), com.atomgraph.linkeddatahub.Application.MASTER_STYLESHEET_PATH, false); - } - } - - /** - * Returns the current application. - * - * @return application resource - */ - public com.atomgraph.linkeddatahub.apps.model.Application getApplication() - { - return application; - } - - /** - * Returns servlet context. - * - * @return servlet context - */ - public ServletContext getServletContext() - { - return servletContext; - } - - /** - * Loads package metadata from its URI using GraphStoreClient. - * Package metadata is expected to be available as Linked Data. - * - * @param packageURI the package URI (e.g., https://packages.linkeddatahub.com/skos/#this) - * @return Package instance, or null if package cannot be loaded - */ - private com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) - { - if (log.isDebugEnabled()) log.debug("Loading package from: {}", packageURI); - - final Model model; - - // check if we have the model in the cache first and if yes, return it from there instead making an HTTP request - if (getSystem().getRepository().isCached(packageURI) || - (getSystem().getRepository().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file - { - if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, getSystem().getRepository().isCached(packageURI)); - if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getSystem().getRepository().isMapped(packageURI)); - model = ModelFactory.createModelForGraph(getSystem().getRepository().get(packageURI)); - } - else - { - GraphStoreClient gsc = GraphStoreClient.create(getSystem().getClient(), getSystem().getMediaTypes()); - model = gsc.getModel(packageURI); - } - - try - { - return model.getResource(packageURI).as(com.atomgraph.linkeddatahub.apps.model.Package.class); - } - catch (ConversionException ex) - { - return null; - } - } - - /** - * Returns the system application. - * - * @return system application - */ - public com.atomgraph.linkeddatahub.Application getSystem() - { - return system; - } - - - /** - * Returns JAX-RS resource context. - * - * @return resource context - */ - public ResourceContext getResourceContext() - { - return resourceContext; - } - - /** - * Returns the authenticated agent context. - * - * @return agent context - */ - public Optional getAgentContext() - { - return agentContext; - } - -} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java index 319cf68e3..40cf8efcd 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; +import java.util.List; import java.util.Optional; import jakarta.annotation.Priority; import jakarta.inject.Inject; @@ -158,7 +159,7 @@ public OntModel getOntology(Application app, String uri) union = getSystem().getOntologyGraphs().get(uri); if (union == null) { - union = loadOntology(repository, uri); + union = loadOntology(repository, uri, getSystem().getPackageOntologies(app)); getSystem().getOntologyGraphs().put(uri, union); } } @@ -178,6 +179,38 @@ public OntModel getOntology(Application app, String uri) * @param uri ontology URI * @return closure union graph */ + /** + * Assembles the ontology's owl:imports closure composed with the ontologies of the imported + * packages. Each package ontology is assembled as its own closure union (so its owl:imports + * resolve too) and added as a member of the application ontology's union — derived in memory, + * mirroring the stylesheet composition in {@code XsltExecutableFilter}; no owl:imports triple + * is materialized anywhere. A package ontology that fails to load is skipped so a broken + * package cannot take the application ontology down. + * + * @param repository graph repository + * @param uri ontology URI + * @param packageOntologies package ontology URIs + * @return closure union graph + */ + public static UnionGraph loadOntology(PrefixGraphRepository repository, String uri, List packageOntologies) + { + UnionGraph union = loadOntology(repository, uri); + + for (URI packageOntology : packageOntologies) + { + try + { + union.addSubGraph(loadOntology(repository, packageOntology.toString())); + } + catch (RuntimeException ex) + { + if (log.isErrorEnabled()) log.error("Could not load package ontology '{}', skipping it", packageOntology, ex); + } + } + + return union; + } + public static UnionGraph loadOntology(PrefixGraphRepository repository, String uri) { if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}'", uri); diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java index 427cd2382..be71ed7d3 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilter.java @@ -18,13 +18,20 @@ import com.atomgraph.client.vocabulary.AC; import com.atomgraph.linkeddatahub.MediaType; +import com.atomgraph.linkeddatahub.server.util.SecureXML; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.MalformedURLException; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; import java.util.Map; import jakarta.annotation.Priority; import jakarta.inject.Inject; +import jakarta.servlet.ServletContext; import jakarta.ws.rs.InternalServerErrorException; import jakarta.ws.rs.Priorities; import jakarta.ws.rs.client.Client; @@ -36,15 +43,26 @@ import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriInfo; +import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; +import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.Source; +import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamSource; import net.sf.saxon.s9api.SaxonApiException; import net.sf.saxon.s9api.XsltCompiler; import net.sf.saxon.s9api.XsltExecutable; +import org.apache.commons.codec.binary.Hex; import org.apache.commons.io.IOUtils; +import org.apache.jena.rdf.model.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; /** * Response filter that loads and compiles the XSLT stylesheet of the application. @@ -57,11 +75,14 @@ public class XsltExecutableFilter implements ContainerResponseFilter private static final Logger log = LoggerFactory.getLogger(XsltExecutableFilter.class); + private static final String XSL_NS = "http://www.w3.org/1999/XSL/Transform"; + @Inject com.atomgraph.linkeddatahub.Application system; @Inject jakarta.inject.Provider> application; @Context UriInfo uriInfo; - + @Context ServletContext servletContext; + @Override public void filter(ContainerRequestContext req, ContainerResponseContext resp) throws IOException { @@ -73,12 +94,212 @@ public void filter(ContainerRequestContext req, ContainerResponseContext resp) t if (getApplication().isPresent() && getApplication().get().getStylesheet() != null) stylesheet = URI.create(getApplication().get().getStylesheet().getURI()); - if (stylesheet != null) req.setProperty(AC.stylesheet.getURI(), getXsltExecutable(stylesheet)); + if (stylesheet != null) + { + List packages = getPackages(getApplication().get()); + + if (packages.isEmpty()) req.setProperty(AC.stylesheet.getURI(), getXsltExecutable(stylesheet)); + else req.setProperty(AC.stylesheet.getURI(), getXsltExecutable(getApplication().get(), stylesheet, packages)); + } else req.setProperty(AC.stylesheet.getURI(), getSystem().getXsltExecutable()); - + } } - + + /** + * Returns URIs of the packages imported by the application, ordered by URI. + * + * @param app application resource + * @return list of package URIs + */ + public List getPackages(com.atomgraph.linkeddatahub.apps.model.Application app) + { + return app.getImportedPackages().stream(). + filter(Resource::isURIResource). + map(pkg -> URI.create(pkg.getURI())). + sorted(). + collect(Collectors.toList()); + } + + /** + * Returns XSLT executable of the application stylesheet composed with the stylesheets of the + * imported packages. Falls back to the executable of the stylesheet alone if the composed + * stylesheet fails to compile (e.g. a package stylesheet URL cannot be loaded). + * + * @param app application resource + * @param stylesheet stylesheet URI + * @param packages imported package URIs + * @return XSLT executable + */ + public XsltExecutable getXsltExecutable(com.atomgraph.linkeddatahub.apps.model.Application app, URI stylesheet, List packages) + { + try + { + URI key = getCacheKey(stylesheet, packages); + Map xsltExecCache = getXsltExecutableCache(); + + if (isCacheStylesheet()) + { + // create cache entry if it does not exist + if (!xsltExecCache.containsKey(key)) + xsltExecCache.put(key, getXsltExecutable(getComposedSource(app, stylesheet, packages))); + + return xsltExecCache.get(key); + } + + return getXsltExecutable(getComposedSource(app, stylesheet, packages)); + } + catch (SaxonApiException | IOException | ParserConfigurationException | SAXException ex) + { + if (log.isErrorEnabled()) log.error("Could not compile stylesheet '{}' composed with packages {}, falling back to the stylesheet alone", stylesheet, packages, ex); + return getXsltExecutable(stylesheet); + } + } + + /** + * Composes the application stylesheet document with the stylesheets of the imported packages. + * The stylesheet URLs are read from the package descriptions, which are resolved from the package + * URIs. The xsl:import elements are inserted after the last existing import, so package + * imports rank below the stylesheet's own declarations in import precedence. + * The source's system ID is the stylesheet's public URL, so its relative imports resolve on the + * application's origin. + * + * @param app application resource + * @param stylesheet stylesheet URI + * @param packages imported package URIs + * @return composed stylesheet source + * @throws IOException I/O error + * @throws ParserConfigurationException parser configuration error + * @throws SAXException XML parsing error + */ + public Source getComposedSource(com.atomgraph.linkeddatahub.apps.model.Application app, URI stylesheet, List packages) throws IOException, ParserConfigurationException, SAXException + { + Source source = getSource(stylesheet.toString()); + if (!(source instanceof StreamSource)) throw new IOException("XSLT stylesheet could not be loaded from URI: " + stylesheet); + + Document doc = SecureXML.newDocumentBuilderFactory().newDocumentBuilder().parse(((StreamSource)source).getInputStream()); + appendImports(doc, getStylesheets(packages)); + + return new DOMSource(doc, getPublicURI(app, stylesheet).toString()); + } + + /** + * Resolves the package descriptions and returns their stylesheet URLs, in package order. + * Packages whose description cannot be resolved, or without a stylesheet (ontology-only), + * are skipped. + * + * @param packages package URIs + * @return list of stylesheet URLs + */ + public List getStylesheets(List packages) + { + return packages.stream(). + map(pkg -> getPackage(pkg.toString())). + filter(Objects::nonNull). + map(com.atomgraph.linkeddatahub.apps.model.Package::getStylesheet). + filter(Objects::nonNull). + map(stylesheet -> URI.create(stylesheet.getURI())). + collect(Collectors.toList()); + } + + /** + * Loads the package description from its URI. + * Mapped locations (e.g. bundled package descriptions) and cached graphs are read from the graph + * repository; other URIs are dereferenced over HTTP. + * + * @param packageURI package URI + * @return package resource, or null if the description could not be resolved + */ + public com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) + { + return getSystem().getPackage(packageURI); + } + + /** + * Appends xsl:import elements for the given stylesheet URLs to the stylesheet document, + * after the last existing import. + * + * @param doc stylesheet document + * @param imports stylesheet URLs to import + */ + public void appendImports(Document doc, List imports) + { + Element stylesheetElem = doc.getDocumentElement(); + + Node lastImport = null; + NodeList children = stylesheetElem.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) + { + Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE && + XSL_NS.equals(child.getNamespaceURI()) && + "import".equals(child.getLocalName())) + lastImport = child; + } + + for (URI importURI : imports) + { + Element newImport = doc.createElementNS(XSL_NS, "xsl:import"); + newImport.setAttribute("href", importURI.toString()); + + Node anchor = (lastImport != null) ? lastImport.getNextSibling() : stylesheetElem.getFirstChild(); + stylesheetElem.insertBefore(newImport, anchor); + lastImport = newImport; + } + } + + /** + * Maps the stylesheet URI to its public URL on the application's origin. + * The inverse of the absolutization of relative stylesheet URIs against the webapp root at context + * dataset parse time. URIs that are already HTTP(S), or fall outside the webapp root, are returned as-is. + * + * @param app application resource + * @param stylesheet stylesheet URI + * @return public stylesheet URL + * @throws MalformedURLException URL error + */ + public URI getPublicURI(com.atomgraph.linkeddatahub.apps.model.Application app, URI stylesheet) throws MalformedURLException + { + if ("http".equals(stylesheet.getScheme()) || "https".equals(stylesheet.getScheme())) return stylesheet; + + URI root = URI.create(getServletContext().getResource("/").toString()); + URI relative = root.relativize(stylesheet); + if (relative.isAbsolute()) return stylesheet; + + return app.getBaseURI().resolve(relative); + } + + /** + * Returns the cache key for a stylesheet composed with package imports. + * The key is derived from the stylesheet URI and the sorted package URIs, so a changed import set + * yields a new key and a fresh compilation on the next lookup. + * + * @param stylesheet stylesheet URI + * @param packages imported package URIs + * @return cache key + */ + public URI getCacheKey(URI stylesheet, List packages) + { + if (packages.isEmpty()) return stylesheet; + + try + { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(stylesheet.toString().getBytes(StandardCharsets.UTF_8)); + for (URI packageURI : packages.stream().sorted().collect(Collectors.toList())) + { + md.update((byte)'\n'); + md.update(packageURI.toString().getBytes(StandardCharsets.UTF_8)); + } + + return URI.create("urn:sha1:" + Hex.encodeHexString(md.digest())); + } + catch (NoSuchAlgorithmException ex) + { + throw new InternalServerErrorException(ex); + } + } + /** * Returns XSLT executable for the given stylesheet URI. * @@ -244,12 +465,22 @@ public Optional getApplicati /** * Returns URI info of the current request. - * + * * @return URI info */ public UriInfo getUriInfo() { return uriInfo; } - + + /** + * Returns servlet context. + * + * @return servlet context + */ + public ServletContext getServletContext() + { + return servletContext; + } + } diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java index c2bba34d1..811c41e2b 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java @@ -18,8 +18,6 @@ import com.atomgraph.linkeddatahub.resource.Namespace; import com.atomgraph.linkeddatahub.resource.admin.ClearOntology; -import com.atomgraph.linkeddatahub.resource.admin.pkg.InstallPackage; -import com.atomgraph.linkeddatahub.resource.admin.pkg.UninstallPackage; import com.atomgraph.linkeddatahub.resource.Settings; import com.atomgraph.linkeddatahub.resource.admin.SignUp; import com.atomgraph.linkeddatahub.resource.acl.Access; @@ -142,28 +140,6 @@ public Class getClearEndpoint() return ClearOntology.class; } - /** - * Returns the endpoint for installing LinkedDataHub packages. - * - * @return endpoint resource - */ - @Path("packages/install") - public Class getInstallPackageEndpoint() - { - return InstallPackage.class; - } - - /** - * Returns the endpoint for uninstalling LinkedDataHub packages. - * - * @return endpoint resource - */ - @Path("packages/uninstall") - public Class getUninstallPackageEndpoint() - { - return UninstallPackage.class; - } - /** * Returns the endpoint for updating dataspace settings. * diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolver.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolver.java new file mode 100644 index 000000000..1f6745df8 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolver.java @@ -0,0 +1,124 @@ +/** + * Copyright 2026 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.server.util; + +import com.atomgraph.client.util.StylesheetResolver; +import com.atomgraph.linkeddatahub.vocabulary.LAPP; +import jakarta.servlet.ServletContext; +import jakarta.ws.rs.client.Client; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import javax.xml.transform.Source; +import javax.xml.transform.TransformerException; +import javax.xml.transform.stream.StreamSource; +import org.apache.commons.io.IOUtils; +import org.apache.jena.rdf.model.Resource; + +/** + * Resolves {@code xsl:import}/{@code xsl:include} URLs on the origins of this instance's applications + * to local webapp resources, avoiding HTTP round-trips back into the same server during XSLT compilation. + * URLs under /static/ of a known application origin are read via {@link ServletContext}, + * keeping the URL as the source's system ID so that nested relative imports stay on the origin and + * identical modules deduplicate regardless of which stylesheet imported them. + * All other locations are delegated to {@link StylesheetResolver}. + * + * @author Martynas Jusevičius {@literal } + */ +public class LocalStylesheetResolver extends StylesheetResolver +{ + + private static final String STATIC_PATH = "/static/"; + + private final com.atomgraph.linkeddatahub.Application system; + private final ServletContext servletContext; + + /** + * Constructs the resolver. + * + * @param system system application + * @param servletContext servlet context of the webapp + * @param client SSL-configured JAX-RS client for HTTP(S) stylesheet retrieval + */ + public LocalStylesheetResolver(com.atomgraph.linkeddatahub.Application system, ServletContext servletContext, Client client) + { + super(client); + this.system = system; + this.servletContext = servletContext; + } + + @Override + public Source resolve(String href, String base) throws TransformerException + { + URI baseURI = URI.create(base); + URI uri = href.isEmpty() ? baseURI : baseURI.resolve(href); + + if (("http".equals(uri.getScheme()) || "https".equals(uri.getScheme())) && + uri.getPath() != null && uri.getPath().startsWith(STATIC_PATH) && + getApp(uri) != null) + { + try (InputStream is = getServletContext().getResourceAsStream(uri.getPath())) + { + if (is != null) + { + // buffer the bytes so the stream can be closed + byte[] bytes = IOUtils.toByteArray(is); + return new StreamSource(new ByteArrayInputStream(bytes), uri.toString()); + } + } + catch (IOException ex) + { + throw new TransformerException(ex); + } + } + + return super.resolve(href, base); + } + + /** + * Matches an application of this instance by the URI's origin. + * + * @param uri stylesheet URI + * @return application resource or null, if none matched + */ + public Resource getApp(URI uri) + { + return getSystem().getAppByOrigin(getSystem().getContextModel(), LAPP.Application, uri); + } + + /** + * Returns system application. + * + * @return JAX-RS application + */ + public com.atomgraph.linkeddatahub.Application getSystem() + { + return system; + } + + /** + * Returns servlet context. + * + * @return servlet context + */ + public ServletContext getServletContext() + { + return servletContext; + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java deleted file mode 100644 index d193455dc..000000000 --- a/src/main/java/com/atomgraph/linkeddatahub/server/util/XSLTMasterUpdater.java +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Copyright 2025 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.server.util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import jakarta.servlet.ServletContext; -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.transform.TransformerException; -import org.w3c.dom.DOMException; -import org.xml.sax.SAXException; - -/** - * Updates master XSLT stylesheets with package import chains. - * Writes master stylesheets to the webapp's /static/ directory. - * - * @author Martynas Jusevičius {@literal } - */ -public class XSLTMasterUpdater -{ - private static final Logger log = LoggerFactory.getLogger(XSLTMasterUpdater.class); - - private static final String XSL_NS = "http://www.w3.org/1999/XSL/Transform"; - - private final ServletContext servletContext; - - /** - * Constructs updater with servlet context. - * - * @param servletContext the servlet context - */ - public XSLTMasterUpdater(ServletContext servletContext) - { - this.servletContext = servletContext; - } - - /** - * Adds a package import to the master stylesheet, preserving all existing content. - * Inserts a new xsl:import after the last existing import element. - * - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void addPackageImport(String packagePath) throws IOException - { - addPackageImport(getStaticPath().resolve("xsl").resolve("layout.xsl"), packagePath); - } - - /** - * Adds a package import to the specified master stylesheet, preserving all existing content. - * - * @param masterFile path to the master stylesheet - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void addPackageImport(Path masterFile, String packagePath) throws IOException - { - try - { - Document doc = parseDocument(masterFile); - Element stylesheet = doc.getDocumentElement(); - String href = "../" + packagePath + "/layout.xsl"; - - // Find the last xsl:import child element as insertion anchor, checking for duplicates - Node lastImport = null; - NodeList children = stylesheet.getChildNodes(); - for (int i = 0; i < children.getLength(); i++) - { - Node child = children.item(i); - if (child.getNodeType() == Node.ELEMENT_NODE - && XSL_NS.equals(child.getNamespaceURI()) - && "import".equals(child.getLocalName())) - { - if (href.equals(((Element) child).getAttribute("href"))) - { - if (log.isWarnEnabled()) log.warn("xsl:import href=\"{}\" already present in master stylesheet, skipping", href); - return; - } - lastImport = child; - } - } - - Element newImport = doc.createElementNS(XSL_NS, "xsl:import"); - newImport.setAttribute("href", href); - - if (lastImport != null) - { - // Capture anchor before any insertion — getNextSibling() shifts after insertBefore - Node anchor = lastImport.getNextSibling(); - stylesheet.insertBefore(newImport, anchor); - stylesheet.insertBefore(doc.createTextNode("\n "), newImport); - } - else - { - // No existing imports — prepend at start of stylesheet - Node firstChild = stylesheet.getFirstChild(); - stylesheet.insertBefore(newImport, firstChild); - stylesheet.insertBefore(doc.createTextNode("\n "), newImport); - } - - serializeDocument(doc, masterFile); - - if (log.isDebugEnabled()) log.debug("Added xsl:import href=\"{}\" to master stylesheet: {}", href, masterFile); - } - catch (ParserConfigurationException | SAXException | TransformerException | DOMException e) - { - throw new IOException("Failed to add package import to master stylesheet", e); - } - } - - /** - * Removes a package import from the master stylesheet, preserving all other content. - * - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void removePackageImport(String packagePath) throws IOException - { - removePackageImport(getStaticPath().resolve("xsl").resolve("layout.xsl"), packagePath); - } - - /** - * Removes a package import from the specified master stylesheet, preserving all other content. - * - * @param masterFile path to the master stylesheet - * @param packagePath the package path (e.g., "com/linkeddatahub/packages/skos") - * @throws IOException if file operations fail - */ - public void removePackageImport(Path masterFile, String packagePath) throws IOException - { - try - { - Document doc = parseDocument(masterFile); - Element stylesheet = doc.getDocumentElement(); - String href = "../" + packagePath + "/layout.xsl"; - - // Find and remove the matching xsl:import element - Node targetImport = null; - NodeList children = stylesheet.getChildNodes(); - for (int i = 0; i < children.getLength(); i++) - { - Node child = children.item(i); - if (child.getNodeType() == Node.ELEMENT_NODE - && XSL_NS.equals(child.getNamespaceURI()) - && "import".equals(child.getLocalName()) - && href.equals(((Element) child).getAttribute("href"))) - { - targetImport = child; - break; - } - } - - if (targetImport == null) - { - if (log.isWarnEnabled()) log.warn("xsl:import href=\"{}\" not found in master stylesheet: {}", href, masterFile); - return; - } - - // Also remove the preceding text node (whitespace/newline) if present - Node prev = targetImport.getPreviousSibling(); - if (prev != null && prev.getNodeType() == Node.TEXT_NODE) - stylesheet.removeChild(prev); - - stylesheet.removeChild(targetImport); - - serializeDocument(doc, masterFile); - - if (log.isDebugEnabled()) log.debug("Removed xsl:import href=\"{}\" from master stylesheet: {}", href, masterFile); - } - catch (ParserConfigurationException | SAXException | TransformerException | DOMException e) - { - throw new IOException("Failed to remove package import from master stylesheet", e); - } - } - - private Document parseDocument(Path file) throws ParserConfigurationException, SAXException, IOException - { - DocumentBuilder builder = SecureXML.newDocumentBuilderFactory().newDocumentBuilder(); - return builder.parse(file.toFile()); - } - - private void serializeDocument(Document doc, Path file) throws TransformerException - { - TransformerFactory transformerFactory = TransformerFactory.newInstance(); - transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); - Transformer transformer = transformerFactory.newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "no"); - transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); - transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no"); - - DOMSource source = new DOMSource(doc); - StreamResult result = new StreamResult(file.toFile()); - transformer.transform(source, result); - } - - /** - * Gets the path to the webapp's /static/ directory. - * - * @return path to static directory - */ - private Path getStaticPath() - { - String realPath = getServletContext().getRealPath("/static"); - if (realPath == null) - throw new IllegalStateException("Could not resolve real path for /static directory"); - return Paths.get(realPath); - } - - /** - * Returns servlet context. - * - * @return servlet context - */ - public ServletContext getServletContext() - { - return servletContext; - } - -} diff --git a/src/main/resources/com/linkeddatahub/packages/packages.ttl b/src/main/resources/com/linkeddatahub/packages/packages.ttl new file mode 100644 index 000000000..5b8c2c80a --- /dev/null +++ b/src/main/resources/com/linkeddatahub/packages/packages.ttl @@ -0,0 +1,12 @@ +@base . +@prefix rdfs: . +@prefix dct: . +@prefix foaf: . + + a foaf:Document ; + dct:title "LinkedDataHub packages" ; + dct:description "Catalog of packages available for LinkedDataHub applications" ; + rdfs:member . + + dct:title "SKOS" ; + dct:description "Simple Knowledge Organization System vocabulary support with custom templates for concept hierarchies, schemes, and collections" . diff --git a/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl b/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl deleted file mode 100644 index b38b64851..000000000 --- a/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - -]> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/prefix-mapping.ttl b/src/main/resources/prefix-mapping.ttl index cf4565809..f9ed70c1f 100644 --- a/src/main/resources/prefix-mapping.ttl +++ b/src/main/resources/prefix-mapping.ttl @@ -72,6 +72,7 @@ [ lm:prefix "http://spinrdf.org/sp" ; lm:altName "etc/sp.ttl" ] , [ lm:prefix "http://spinrdf.org/spin" ; lm:altName "etc/spin.ttl" ] , [ lm:prefix "http://spinrdf.org/spl" ; lm:altName "etc/spl.spin.ttl" ] , + [ lm:prefix "https://packages.linkeddatahub.com/" ; lm:altName "com/linkeddatahub/packages/packages.ttl" ] , [ lm:prefix "https://packages.linkeddatahub.com/skos/" ; lm:altName "com/linkeddatahub/packages/skos/package.ttl" ] , [ lm:prefix "https://raw.githubusercontent.com/AtomGraph/LinkedDataHub-Apps/refs/heads/master/packages/skos/ns.ttl" ; lm:altName "com/linkeddatahub/packages/skos/ns.ttl" ] . \ No newline at end of file diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl index fdfd47c65..ca9d80e25 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl @@ -981,6 +981,7 @@ WHERE + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl index 1b5380000..54ec48396 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/functions.xsl @@ -198,6 +198,7 @@ exclude-result-prefixes="#all" + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl index 7bdacb66a..3a58d85b8 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl @@ -34,6 +34,7 @@ xmlns:lapp="&lapp;" xmlns:ac="∾" xmlns:ldh="&ldh;" xmlns:rdf="&rdf;" +xmlns:owl="&owl;" xmlns:acl="&acl;" xmlns:srx="&srx;" xmlns:ldt="&ldt;" @@ -1107,7 +1108,8 @@ LIMIT 10 [ ldh:load-property-metadata#1, 'property-metadata-request', 'property-metadata-response', ldh:set-property-metadata#1 ], [ ldh:load-constraints#1, 'constraints-request', 'constraints-response', ldh:set-constraints#1 ], [ ldh:load-object-metadata#1, 'metadata-request', 'metadata-response', ldh:set-object-metadata#1 ], - [ ldh:load-object-metadata#1, 'ns-metadata-request', 'ns-metadata-response', ldh:set-object-metadata-ns#1 ] + [ ldh:load-object-metadata#1, 'ns-metadata-request', 'ns-metadata-response', ldh:set-object-metadata-ns#1 ], + [ ldh:load-package-catalog#1, 'package-catalog-request', 'package-catalog-response', ldh:set-package-catalog#1 ] ])) => ixsl:then(ldh:merge-object-metadata#1) => ixsl:then(ldh:render-app-settings-form#1) @@ -1127,12 +1129,18 @@ LIMIT 10 + + + + + + @@ -1166,6 +1174,9 @@ LIMIT 10 + + + @@ -1267,7 +1278,7 @@ LIMIT 10 - + @@ -1306,8 +1317,10 @@ LIMIT 10 map{ 'request': $request, 'form': $form, + 'source-uri': $source, 'target-uri': $target, - 'query-uri': $query-uri + 'query-uri': $query-uri, + 'scratch-uri': resolve-uri(ac:uuid() || '/', ldt:base()) }"/> - - - - - + - + - + @@ -1965,7 +1974,7 @@ LIMIT 10 - + @@ -1975,15 +1984,27 @@ LIMIT 10 ldh:import-ontology-source-response - + - - - - + + + + + + + + Import ontology scratch + + + + + + + + @@ -1999,7 +2020,7 @@ LIMIT 10 - + @@ -2007,7 +2028,7 @@ LIMIT 10 ldh:import-ontology-query-thunk - + @@ -2019,12 +2040,12 @@ LIMIT 10 "/> - + - + @@ -2034,8 +2055,8 @@ LIMIT 10 - - + + - + - + @@ -2059,20 +2080,68 @@ LIMIT 10 ldh:import-ontology-constructors-response - + - + + + + + + + + + + + + + + + + + + + + + + + + + ldh:import-ontology-scratch-delete + + + + + + - - - + + + + + + + + + + + + + @@ -2080,7 +2149,15 @@ LIMIT 10 - + + + + + + + + + @@ -2187,15 +2264,18 @@ LIMIT 10 } ), map{ 'duplicates': 'use-last' })"/> - - + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/imports/lapp.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/imports/lapp.xsl deleted file mode 100644 index 001885565..000000000 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/imports/lapp.xsl +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - -]> - - - - - - -
- - -
- - -
- -
diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl index 79c32f80f..dba5b258c 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl @@ -93,7 +93,6 @@ exclude-result-prefixes="#all"> - diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/translations.rdf b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/translations.rdf index 37167f175..f2b0293bc 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/translations.rdf +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/translations.rdf @@ -364,6 +364,14 @@ Package Paquete + + Packages + Paquetes + + + Installed + Instalado + Ontology Ontología diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl index af5fcd11c..dfd9f15b6 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client.xsl @@ -118,6 +118,7 @@ extension-element-prefixes="ixsl" + diff --git a/src/main/webapp/static/xsl/admin/layout.xsl b/src/main/webapp/static/xsl/admin/layout.xsl index 8673fc777..e9c9d6d11 100644 --- a/src/main/webapp/static/xsl/admin/layout.xsl +++ b/src/main/webapp/static/xsl/admin/layout.xsl @@ -7,6 +7,6 @@ - + diff --git a/src/main/webapp/static/xsl/layout.xsl b/src/main/webapp/static/xsl/layout.xsl index ceb6d2b13..f09c83f14 100644 --- a/src/main/webapp/static/xsl/layout.xsl +++ b/src/main/webapp/static/xsl/layout.xsl @@ -7,6 +7,6 @@ - + diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilterTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilterTest.java new file mode 100644 index 000000000..3dd911bc6 --- /dev/null +++ b/src/test/java/com/atomgraph/linkeddatahub/server/filter/response/XsltExecutableFilterTest.java @@ -0,0 +1,207 @@ +/** + * Copyright 2026 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.server.filter.response; + +import com.atomgraph.client.vocabulary.AC; +import com.atomgraph.linkeddatahub.apps.model.impl.PackageImpl; +import com.atomgraph.linkeddatahub.server.util.SecureXML; +import jakarta.servlet.ServletContext; +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import org.apache.jena.enhanced.EnhGraph; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +/** + * Tests the composition of the application stylesheet with package stylesheet imports. + * + * @author Martynas Jusevičius {@literal } + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class XsltExecutableFilterTest +{ + + private static final String XSL_NS = "http://www.w3.org/1999/XSL/Transform"; + private static final URI STYLESHEET_URI = URI.create("file:/usr/local/tomcat/webapps/ROOT/static/xsl/layout.xsl"); + private static final URI A_XSL_URI = URI.create("https://packages.example.org/a/layout.xsl"); + private static final URI B_XSL_URI = URI.create("https://packages.example.org/b/layout.xsl"); + + @Mock private ServletContext servletContext; + @Mock private com.atomgraph.linkeddatahub.apps.model.Application application; + + private XsltExecutableFilter filter; + private Model model; + + @BeforeEach + public void setUp() + { + filter = new XsltExecutableFilter(); + filter.servletContext = servletContext; + model = ModelFactory.createDefaultModel(); + } + + @Test + public void testGetPackagesOrderedByURI() + { + Resource pkgB = model.createResource("https://packages.example.org/b#this"); + Resource pkgA = model.createResource("https://packages.example.org/a#this"); + when(application.getImportedPackages()).thenReturn(new HashSet<>(List.of(pkgB, pkgA))); + + assertEquals(List.of(URI.create(pkgA.getURI()), URI.create(pkgB.getURI())), filter.getPackages(application)); + } + + @Test + public void testGetStylesheetsSkipsUnresolvedAndOntologyOnlyPackages() + { + URI pkgA = URI.create("https://packages.example.org/a#this"); + URI pkgB = URI.create("https://packages.example.org/b#this"); + URI pkgC = URI.create("https://packages.example.org/c#this"); + model.createResource(pkgA.toString()).addProperty(AC.stylesheet, model.createResource(A_XSL_URI.toString())); + model.createResource(pkgC.toString()); // ontology-only: no ac:stylesheet + + XsltExecutableFilter spied = spy(filter); + doReturn(asPackage(pkgA)).when(spied).getPackage(pkgA.toString()); + doReturn(null).when(spied).getPackage(pkgB.toString()); // description could not be resolved + doReturn(asPackage(pkgC)).when(spied).getPackage(pkgC.toString()); + + assertEquals(List.of(A_XSL_URI), spied.getStylesheets(List.of(pkgA, pkgB, pkgC))); + } + + private com.atomgraph.linkeddatahub.apps.model.Package asPackage(URI uri) + { + return new PackageImpl(model.createResource(uri.toString()).asNode(), (EnhGraph)model); + } + + @Test + public void testAppendImportsAfterExistingImport() throws Exception + { + Document doc = parse("" + + "" + + "" + + ""); + + filter.appendImports(doc, List.of(A_XSL_URI, B_XSL_URI)); + + List children = childElements(doc); + assertEquals(4, children.size()); + assertEquals("../com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl", children.get(0).getAttribute("href")); + assertEquals(A_XSL_URI.toString(), children.get(1).getAttribute("href")); + assertEquals(B_XSL_URI.toString(), children.get(2).getAttribute("href")); + assertEquals("template", children.get(3).getLocalName()); + } + + @Test + public void testAppendImportsWithoutExistingImports() throws Exception + { + Document doc = parse("" + + "" + + ""); + + filter.appendImports(doc, List.of(A_XSL_URI)); + + List children = childElements(doc); + assertEquals(2, children.size()); + assertEquals("import", children.get(0).getLocalName()); + assertEquals(A_XSL_URI.toString(), children.get(0).getAttribute("href")); + assertEquals("template", children.get(1).getLocalName()); + } + + @Test + public void testCacheKeyWithoutImportsIsStylesheetURI() + { + assertEquals(STYLESHEET_URI, filter.getCacheKey(STYLESHEET_URI, List.of())); + } + + @Test + public void testCacheKeyIsImportOrderInvariant() + { + assertEquals(filter.getCacheKey(STYLESHEET_URI, List.of(A_XSL_URI, B_XSL_URI)), + filter.getCacheKey(STYLESHEET_URI, List.of(B_XSL_URI, A_XSL_URI))); + } + + @Test + public void testCacheKeyDependsOnImportSet() + { + assertNotEquals(filter.getCacheKey(STYLESHEET_URI, List.of(A_XSL_URI, B_XSL_URI)), + filter.getCacheKey(STYLESHEET_URI, List.of(A_XSL_URI))); + assertNotEquals(STYLESHEET_URI, filter.getCacheKey(STYLESHEET_URI, List.of(A_XSL_URI))); + } + + @Test + public void testPublicURIOfWebappStylesheet() throws Exception + { + when(servletContext.getResource("/")).thenReturn(new URL("file:/usr/local/tomcat/webapps/ROOT/")); + when(application.getBaseURI()).thenReturn(URI.create("https://localhost:4443/")); + + assertEquals(URI.create("https://localhost:4443/static/xsl/layout.xsl"), filter.getPublicURI(application, STYLESHEET_URI)); + } + + @Test + public void testPublicURIPassesThroughHTTPS() throws Exception + { + assertEquals(A_XSL_URI, filter.getPublicURI(application, A_XSL_URI)); + } + + @Test + public void testPublicURIPassesThroughOutsideWebappRoot() throws Exception + { + when(servletContext.getResource("/")).thenReturn(new URL("file:/usr/local/tomcat/webapps/ROOT/")); + URI outside = URI.create("file:/etc/xsl/layout.xsl"); + + assertEquals(outside, filter.getPublicURI(application, outside)); + } + + private Document parse(String xml) throws Exception + { + return SecureXML.newDocumentBuilderFactory().newDocumentBuilder(). + parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + private List childElements(Document doc) + { + List elements = new ArrayList<>(); + NodeList children = doc.getDocumentElement().getChildNodes(); + for (int i = 0; i < children.getLength(); i++) + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) elements.add((Element)children.item(i)); + + return elements; + } + +} diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolverTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolverTest.java new file mode 100644 index 000000000..ed6d58321 --- /dev/null +++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/LocalStylesheetResolverTest.java @@ -0,0 +1,123 @@ +/** + * Copyright 2026 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.server.util; + +import jakarta.servlet.ServletContext; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.core.Response; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import javax.xml.transform.Source; +import javax.xml.transform.stream.StreamSource; +import org.apache.commons.io.IOUtils; +import org.apache.jena.rdf.model.Resource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * Tests the local resolution of app-origin stylesheet URLs. + * + * @author Martynas Jusevičius {@literal } + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LocalStylesheetResolverTest +{ + + private static final String XSL_CONTENT = ""; + private static final String BASE = "https://localhost:4443/static/xsl/layout.xsl"; + + @Mock private com.atomgraph.linkeddatahub.Application system; + @Mock private ServletContext servletContext; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) private Client client; + @Mock private Resource app; + @Mock private Response response; + + private LocalStylesheetResolver resolver; + + @BeforeEach + public void setUp() + { + resolver = new LocalStylesheetResolver(system, servletContext, client); + } + + @Test + public void testResolvesAppOriginStaticURLLocally() throws Exception + { + String url = "https://localhost:4443/static/xsl/layout.xsl"; + when(system.getAppByOrigin(any(), any(), eq(URI.create(url)))).thenReturn(app); + when(servletContext.getResourceAsStream("/static/xsl/layout.xsl")).thenReturn(stream(XSL_CONTENT)); + + Source source = resolver.resolve(url, "https://localhost:4443/"); + + assertInstanceOf(StreamSource.class, source); + assertEquals(url, source.getSystemId()); + assertEquals(XSL_CONTENT, IOUtils.toString(((StreamSource)source).getInputStream(), StandardCharsets.UTF_8)); + } + + @Test + public void testResolvesRelativeHrefAgainstHTTPSBase() throws Exception + { + String resolved = "https://localhost:4443/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl"; + when(system.getAppByOrigin(any(), any(), eq(URI.create(resolved)))).thenReturn(app); + when(servletContext.getResourceAsStream("/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl")).thenReturn(stream(XSL_CONTENT)); + + Source source = resolver.resolve("../com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/layout.xsl", BASE); + + assertEquals(resolved, source.getSystemId()); + } + + @Test + public void testReturnsNullForFileScheme() throws Exception + { + assertNull(resolver.resolve("../layout.xsl", "file:/usr/local/tomcat/webapps/ROOT/static/xsl/layout.xsl")); + } + + @Test + public void testDelegatesUnknownOriginToRemoteFetch() throws Exception + { + String url = "https://packages.example.org/static/xsl/layout.xsl"; + when(system.getAppByOrigin(any(), any(), any())).thenReturn(null); + when(client.target(URI.create(url)).request().accept(com.atomgraph.client.MediaType.TEXT_XSL_TYPE).get()).thenReturn(response); + when(response.getStatusInfo()).thenReturn(Response.Status.OK); + when(response.readEntity(InputStream.class)).thenReturn(stream(XSL_CONTENT)); + + Source source = resolver.resolve(url, "https://localhost:4443/"); + + assertEquals(url, source.getSystemId()); + } + + private InputStream stream(String content) + { + return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + } + +} From 0daef5eec42622f29b4f4ede2fda7d6679eda9b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Mon, 24 Aug 2026 23:22:28 +0200 Subject: [PATCH 06/18] Fix GraphMode rendering for ldh:Object blocks The bs2:Row GraphMode branch applied bs2:Graph without the required canvas-id param, crashing the block render with a cardinality error. Pass it, and initialize the 3D force graph after the row is rendered by reusing ldh:InitDocumentGraph3D, fed the whole loaded document to match the bs2:Graph rendering. Co-Authored-By: Claude Fable 5 --- .../xsl/bootstrap/2.3.2/client/block/object.xsl | 14 ++++++++++++++ .../linkeddatahub/xsl/bootstrap/2.3.2/resource.xsl | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl index 514ff7b1b..173b274e9 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/block/object.xsl @@ -364,6 +364,20 @@ exclude-result-prefixes="#all" + + + + + + + + + + + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/resource.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/resource.xsl index 2c62181da..ee593ba16 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/resource.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/resource.xsl @@ -690,7 +690,10 @@ extension-element-prefixes="ixsl" - + + + + From 6ede2b32497baaa5a355d00fab24e47b5a38d7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Mon, 24 Aug 2026 23:56:58 +0200 Subject: [PATCH 07/18] Constructor save clears the annotation document, not rdfs:isDefinedBy Since #362 constructors live in the annotation ontology document, but onConstructorUpdate still cleared the ontology derived from the class' rdfs:isDefinedBy - the canonical vocabulary URI, where constructors lived only while the import flow minted shadowing copies. The annotation graph stayed cached in the OntologyRepository, so the namespace ontology reload rebuilt its closure from the stale graph and constructor edits never surfaced in instance forms. The callback now clears ac:document-uri($constructor-uri) - the very document its PATCH just updated - before proceeding to the namespace ontology clear. The rdfs:isDefinedBy DESCRIBE round-trip, the namespace-guessing fallback and the now-unused $type wiring are removed. Co-Authored-By: Claude Fable 5 --- .../xsl/bootstrap/2.3.2/client/constructor.xsl | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl index 86a2526bc..e5b9fd4fe 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl @@ -538,7 +538,6 @@ exclude-result-prefixes="#all" - @@ -593,7 +592,7 @@ exclude-result-prefixes="#all" - + @@ -608,7 +607,7 @@ exclude-result-prefixes="#all" - + @@ -617,17 +616,13 @@ exclude-result-prefixes="#all" - + - - - - - + - + From b16db647cb72ebcc8179dfc154fff2a49341bf07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 25 Aug 2026 00:07:00 +0200 Subject: [PATCH 08/18] Open editing forms reconcile with the constructor after a constructor edit Saving a constructor now updates the instance forms already open on the page instead of leaving them stale until a reload. Once the namespace ontology clear completes (the point where the constructor closure is fresh again), every fieldset with property control groups re-runs the constructor instantiation chain (ldh:load-constructed-doc / ldh:set-constructed-doc) for its type set and diffs the prototype against its controls: - properties the form is missing get bs2:FormControl groups appended at the end, before the re-appended property picker - mirroring the add-value flow, ordering deferred to the next full render - value-less control groups whose property the constructor no longer asserts are removed; controls holding entered values and rdf:type controls are never touched, so user input survives the sync - a failed constructor fetch leaves the form as it was - removals only act on status-200 data The ns SPARQL response carries a model-derived ETag and no freshness lifetime, so the re-fetch always revalidates and cannot be served stale from the browser cache. ldh:ClearNamespace's bogus ldh:NoOp callback is replaced by the sync fan-out and the NoOp template is deleted. Co-Authored-By: Claude Fable 5 --- .../bootstrap/2.3.2/client/constructor.xsl | 8 +-- .../xsl/bootstrap/2.3.2/client/form.xsl | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl index e5b9fd4fe..3eb915b80 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/constructor.xsl @@ -713,11 +713,9 @@ exclude-result-prefixes="#all" - - + + - - - + \ No newline at end of file diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl index ca9d80e25..30fc3973a 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/form.xsl @@ -1232,6 +1232,75 @@ WHERE on-failure="ldh:promise-failure#1"/>
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 1c6a05544ff7fa8a2b10696abefccf12db4c3009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Tue, 25 Aug 2026 11:09:56 +0200 Subject: [PATCH 09/18] Modal document save re-renders with the active layout mode (#363) * Modal document save re-renders with the active layout mode ldh:modal-form-response dropped the URL's ?mode= on the post-save DocumentNavigate, so saving the btn-edit modal re-rendered the document in the default mode instead of the active one (e.g. ac:GraphMode). Pass the current mode through query-params, guarded to same-document reloads so a PUT overwrite landing in the same branch navigates clean. Snapshot params (?version/?timemap) deliberately don't survive the save. Co-Authored-By: Claude Fable 5 * Remove vestigial forClass URL param usages No LDH flow puts forClass in a URL anymore: the add-constructor onclick reads @data-for-class (the button @href was inert HTML), chart save PATCHes the document URI instead of POSTing to charts/?forClass=, and the ns?forClass= constructor endpoint was replaced by ns?query=. Drop the button @hrefs, the ldh:build-query forClass arity, and CacheInvalidationFilter's unreachable forClass ban branch. The chart form loses @action entirely rather than being repointed: no charts/ container exists in the app structure, the form is never submitted (btn-save-chart is type=button and PATCHes the current document), and bs2:Chart cannot derive the host document anyway -- its context node is the data document, whose base URI is the SPARQL request URL for CSR-fetched results. An absent @action targets the current page URL, which is where the save actually writes. $type and $doc-type go with it, being unused once the action is gone. $ac:forClass in client.xsl stays: the imported Web-Client resource.xsl and document.xsl reference it and the declaring Web-Client layout.xsl is not part of the CSR import tree. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../filter/response/CacheInvalidationFilter.java | 9 --------- .../xsl/bootstrap/2.3.2/client/modal.xsl | 6 ++++-- .../linkeddatahub/xsl/bootstrap/2.3.2/document.xsl | 10 ++-------- .../xsl/bootstrap/2.3.2/imports/default.xsl | 11 ++--------- 4 files changed, 8 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java index 58af006be..26536ddd8 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/filter/response/CacheInvalidationFilter.java @@ -16,7 +16,6 @@ */ package com.atomgraph.linkeddatahub.server.filter.response; -import com.atomgraph.client.vocabulary.AC; import com.atomgraph.linkeddatahub.apps.model.AdminApplication; import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; import java.io.IOException; @@ -84,14 +83,6 @@ public void filter(ContainerRequestContext req, ContainerResponseContext resp) t banIfNotNull(getSystem().getFrontendProxy(), relativeParentURI.toString()); banIfNotNull(getSystem().getServiceContext(getApplication().get().getService()).getBackendProxy(), relativeParentURI.toString()); } - - // ban all results of queries that use forClass type - if (req.getUriInfo().getQueryParameters().containsKey(AC.forClass.getLocalName())) - { - String forClass = req.getUriInfo().getQueryParameters().getFirst(AC.forClass.getLocalName()); - banIfNotNull(getSystem().getFrontendProxy(), forClass); - banIfNotNull(getSystem().getServiceContext(getApplication().get().getService()).getBackendProxy(), forClass); - } } if (Set.of(HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.PATCH).contains(req.getMethod())) diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl index 3a58d85b8..8ef59c67e 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/bootstrap/2.3.2/client/modal.xsl @@ -151,14 +151,14 @@ LIMIT 10