diff --git a/bin/admin/install-package.sh b/bin/admin/install-package.sh new file mode 100755 index 000000000..fb2ce3d9a --- /dev/null +++ b/bin/admin/install-package.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +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}install-package" + +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 install-package endpoint +curl -k -w "%{http_code}\n" -E "${cert_pem_file}":"${cert_password}" \ + -H "Accept: text/turtle" \ + -d "package-uri=${package_uri}" \ + "${final_url}" diff --git a/http-tests/admin/packages/install-package-400.sh b/http-tests/admin/packages/install-package-400.sh new file mode 100755 index 000000000..d77736d15 --- /dev/null +++ b/http-tests/admin/packages/install-package-400.sh @@ -0,0 +1,16 @@ +#!/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 new file mode 100755 index 000000000..6cba48572 --- /dev/null +++ b/http-tests/admin/packages/install-package-403.sh @@ -0,0 +1,16 @@ +#!/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-404.sh b/http-tests/admin/packages/install-package-404.sh new file mode 100755 index 000000000..aafcb92b3 --- /dev/null +++ b/http-tests/admin/packages/install-package-404.sh @@ -0,0 +1,18 @@ +#!/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 404 Not Found +# (the HTTP client error from the remote package server is re-thrown) +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=https://packages.linkeddatahub.com/nonexistent/#package" \ + "${ADMIN_BASE_URL}packages/install" \ +| grep -q "$STATUS_NOT_FOUND" diff --git a/http-tests/admin/packages/install-package.sh b/http-tests/admin/packages/install-package.sh new file mode 100755 index 000000000..83842c4bf --- /dev/null +++ b/http-tests/admin/packages/install-package.sh @@ -0,0 +1,28 @@ +#!/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 via POST to packages/install endpoint +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "package-uri=$package_uri" \ + "${ADMIN_BASE_URL}packages/install" \ +| grep -q "$STATUS_SEE_OTHER" + +# verify package stylesheet was installed (should return 200) +curl -k -f -s -o /dev/null \ + "$END_USER_BASE_URL"static/com/linkeddatahub/packages/skos/layout.xsl + +# verify master stylesheet was regenerated and includes package import +curl -k -s "${END_USER_BASE_URL}static/localhost/layout.xsl" \ + | grep -q "com/linkeddatahub/packages/skos/layout.xsl" diff --git a/http-tests/admin/packages/install-uninstall-package.sh b/http-tests/admin/packages/install-uninstall-package.sh new file mode 100755 index 000000000..88a055130 --- /dev/null +++ b/http-tests/admin/packages/install-uninstall-package.sh @@ -0,0 +1,46 @@ +#!/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 +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "package-uri=$package_uri" \ + "$ADMIN_BASE_URL"packages/install \ +| grep -q "$STATUS_SEE_OTHER" + +# verify package stylesheet was installed (should return 200) +curl -k -f -s -o /dev/null \ + "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl" + +# verify master stylesheet includes package +curl -k -s "$END_USER_BASE_URL"static/localhost/layout.xsl \ + | grep -q "com/linkeddatahub/packages/skos/layout.xsl" + +# uninstall package +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "package-uri=$package_uri" \ + "$ADMIN_BASE_URL"packages/uninstall \ +| grep -q "$STATUS_SEE_OTHER" + +# 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 +curl -k -s "$END_USER_BASE_URL"static/localhost/layout.xsl \ + | grep -v -q "com/linkeddatahub/packages/skos/layout.xsl" diff --git a/http-tests/admin/packages/uninstall-package-400.sh b/http-tests/admin/packages/uninstall-package-400.sh new file mode 100755 index 000000000..98a861c60 --- /dev/null +++ b/http-tests/admin/packages/uninstall-package-400.sh @@ -0,0 +1,16 @@ +#!/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/admin/packages/uninstall-package.sh b/http-tests/admin/packages/uninstall-package.sh new file mode 100755 index 000000000..027b11ef3 --- /dev/null +++ b/http-tests/admin/packages/uninstall-package.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# test package URI (SKOS package) +package_uri="https://packages.linkeddatahub.com/skos/#this" + +# first install the package +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "package-uri=$package_uri" \ + "${ADMIN_BASE_URL}packages/install" \ +| grep -q "$STATUS_SEE_OTHER" + +# verify package stylesheet exists before uninstall (should return 200) +curl -k -f -s -o /dev/null \ + "${END_USER_BASE_URL}static/com/linkeddatahub/packages/skos/layout.xsl" + +# uninstall package via POST to packages/uninstall endpoint +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "package-uri=$package_uri" \ + "${ADMIN_BASE_URL}packages/uninstall" \ +| grep -q "$STATUS_SEE_OTHER" + +# 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 was regenerated without package import +curl -k -s "$END_USER_BASE_URL"static/localhost/layout.xsl \ + | grep -v -q "com/linkeddatahub/packages/skos/layout.xsl" diff --git a/platform/datasets/admin.trig b/platform/datasets/admin.trig index 76fa774bb..80655f4bb 100644 --- a/platform/datasets/admin.trig +++ b/platform/datasets/admin.trig @@ -821,6 +821,42 @@ 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 82083649a..47d34609c 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -715,6 +715,7 @@ public Application(final ServletConfig servletConfig, final MediaTypes mediaType BuiltinPersonalities.model.add(EndUserApplication.class, new com.atomgraph.linkeddatahub.apps.model.end_user.impl.ApplicationImplementation()); BuiltinPersonalities.model.add(com.atomgraph.linkeddatahub.apps.model.Application.class, new com.atomgraph.linkeddatahub.apps.model.impl.ApplicationImplementation()); BuiltinPersonalities.model.add(com.atomgraph.linkeddatahub.apps.model.Dataset.class, new com.atomgraph.linkeddatahub.apps.model.impl.DatasetImplementation()); + BuiltinPersonalities.model.add(com.atomgraph.linkeddatahub.apps.model.Package.class, new com.atomgraph.linkeddatahub.apps.model.impl.PackageImplementation()); BuiltinPersonalities.model.add(Service.class, new com.atomgraph.linkeddatahub.model.impl.ServiceImplementation(noCertClient, mediaTypes, maxGetRequestSize)); BuiltinPersonalities.model.add(Import.class, ImportImpl.factory); BuiltinPersonalities.model.add(RDFImport.class, RDFImportImpl.factory); diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/Application.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Application.java index dcb914c46..e328fdebb 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/apps/model/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Application.java @@ -29,11 +29,6 @@ public interface Application extends Resource, com.atomgraph.core.model.Application { - /** - * The relative path of the content-addressed file container. - */ - public static final String UPLOADS_PATH = "uploads"; - /** * Returns the application's namespace ontology. * @@ -100,9 +95,16 @@ public interface Application extends Resource, com.atomgraph.core.model.Applicat /** * Returns frontend proxy's cache URI resource. - * + * * @return RDF resource */ Resource getFrontendProxy(); - + + /** + * Returns the set of packages imported by this application. + * + * @return set of package resources + */ + java.util.Set getImportedPackages(); + } diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java new file mode 100644 index 000000000..4ad8b71e5 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/Package.java @@ -0,0 +1,54 @@ +/** + * 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.apps.model; + +import org.apache.jena.rdf.model.Resource; + +/** + * A LinkedDataHub package containing an ontology and optional XSLT stylesheet. + * Packages provide reusable vocabulary support with custom templates and rendering. + * + * @author Martynas Jusevičius {@literal } + */ +public interface Package extends Resource +{ + + /** + * Returns the package's ontology resource. + * The ontology file (ns.ttl) contains RDF vocabulary classes/properties and template blocks. + * + * @return ontology resource, or null if not specified + */ + Resource getOntology(); + + /** + * Returns the package's stylesheet resource. + * The stylesheet file (layout.xsl) contains XSLT templates for custom rendering. + * + * @return stylesheet resource, or null if not specified + */ + Resource getStylesheet(); + + /** + * Returns the packages imported by this package. + * Packages can transitively import other packages via ldh:import property. + * + * @return set of imported package resources + */ + java.util.Set getImportedPackages(); + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/ApplicationImpl.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/ApplicationImpl.java index 649291121..e70848d81 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/ApplicationImpl.java +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/ApplicationImpl.java @@ -26,9 +26,12 @@ import org.apache.jena.enhanced.EnhGraph; import org.apache.jena.graph.Node; import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.StmtIterator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; +import java.util.HashSet; +import java.util.Set; import org.apache.jena.rdf.model.Statement; import org.apache.jena.rdf.model.impl.ResourceImpl; @@ -117,10 +120,31 @@ public Resource getFrontendProxy() public boolean isReadAllowed() { Statement stmt = getProperty(LAPP.allowRead); - + if (stmt != null) return stmt.getBoolean(); - + return false; } + @Override + public Set getImportedPackages() + { + Set packages = new HashSet<>(); + StmtIterator it = listProperties(LDH.importPackage); + try + { + while (it.hasNext()) + { + Statement stmt = it.next(); + if (stmt.getObject().isResource()) + packages.add(stmt.getResource()); + } + } + finally + { + it.close(); + } + return packages; + } + } 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 new file mode 100644 index 000000000..a431ee262 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImpl.java @@ -0,0 +1,88 @@ +/** + * 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.apps.model.impl; + +import com.atomgraph.client.vocabulary.AC; +import com.atomgraph.linkeddatahub.apps.model.Package; +import com.atomgraph.linkeddatahub.vocabulary.LDH; +import com.atomgraph.server.vocabulary.LDT; +import org.apache.jena.enhanced.EnhGraph; +import org.apache.jena.graph.Node; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.Statement; +import org.apache.jena.rdf.model.StmtIterator; +import org.apache.jena.rdf.model.impl.ResourceImpl; + +import java.util.HashSet; +import java.util.Set; + + +/** + * LinkedDataHub package implementation. + * + * @author Martynas Jusevičius {@literal } + */ +public class PackageImpl extends ResourceImpl implements Package +{ + + /** + * Constructs instance from node and graph. + * + * @param n node + * @param g graph + */ + public PackageImpl(Node n, EnhGraph g) + { + super(n, g); + } + + @Override + public Resource getOntology() + { + return getPropertyResourceValue(LDT.ontology); + } + + @Override + public Resource getStylesheet() + { + return getPropertyResourceValue(AC.stylesheet); + } + + @Override + public Set getImportedPackages() + { + Set packages = new HashSet<>(); + StmtIterator it = listProperties(LDH.importPackage); + + try + { + while (it.hasNext()) + { + Statement stmt = it.next(); + if (stmt.getObject().isResource()) + packages.add(stmt.getResource()); + } + } + finally + { + it.close(); + } + + return packages; + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImplementation.java b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImplementation.java new file mode 100644 index 000000000..09ebb5e9d --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/apps/model/impl/PackageImplementation.java @@ -0,0 +1,56 @@ +/** + * 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.apps.model.impl; + +import com.atomgraph.linkeddatahub.vocabulary.LAPP; +import org.apache.jena.enhanced.EnhGraph; +import org.apache.jena.enhanced.EnhNode; +import org.apache.jena.enhanced.Implementation; +import org.apache.jena.graph.Node; +import org.apache.jena.ontology.ConversionException; +import org.apache.jena.vocabulary.RDF; + +/** + * Jena's implementation factory for Package. + * + * @author Martynas Jusevičius {@literal } + */ +public class PackageImplementation extends Implementation +{ + + @Override + public EnhNode wrap(Node node, EnhGraph enhGraph) + { + if (canWrap(node, enhGraph)) + { + return new PackageImpl(node, enhGraph); + } + else + { + throw new ConversionException("Cannot convert node " + node.toString() + " to Package: it does not have rdf:type lapp:Package"); + } + } + + @Override + public boolean canWrap(Node node, EnhGraph eg) + { + if (eg == null) throw new IllegalArgumentException("EnhGraph cannot be null"); + + return eg.asGraph().contains(node, RDF.type.asNode(), LAPP.Package.asNode()); + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/Install.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/Install.java new file mode 100644 index 000000000..02c8ab01b --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/Install.java @@ -0,0 +1,360 @@ +/** + * 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.client.util.DataManager; +import com.atomgraph.linkeddatahub.apps.model.AdminApplication; +import com.atomgraph.linkeddatahub.apps.model.EndUserApplication; +import com.atomgraph.linkeddatahub.client.LinkedDataClient; +import com.atomgraph.linkeddatahub.server.util.UriPath; +import com.atomgraph.linkeddatahub.server.util.XsltMasterUpdater; +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.InternalServerErrorException; +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.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +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.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.apache.jena.util.FileManager; + +/** + * JAX-RS resource that installs a LinkedDataHub package. + * Package installation involves: + * 1. Fetching package metadata + * 2. Downloading package ontology (ns.ttl) and posting to namespace graph + * 3. Downloading package stylesheet (layout.xsl) and saving to /static/packages/ + * 4. Regenerating application master stylesheet + * 5. Adding ldh:import triple to application + * + * @author Martynas Jusevičius {@literal } + */ +public class Install +{ + private static final Logger log = LoggerFactory.getLogger(Install.class); + + private final com.atomgraph.linkeddatahub.apps.model.Application application; + private final com.atomgraph.linkeddatahub.Application system; + private final DataManager dataManager; + + @Context ServletContext servletContext; + + /** + * Constructs endpoint. + * + * @param application matched application (admin app) + * @param system system application + * @param dataManager data manager + */ + @Inject + public Install(com.atomgraph.linkeddatahub.apps.model.Application application, + com.atomgraph.linkeddatahub.Application system, + DataManager dataManager) + { + this.application = application; + this.system = system; + this.dataManager = dataManager; + } + + /** + * 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) throw new BadRequestException("Package URI not specified"); + + try + { + EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); + + if (log.isInfoEnabled()) log.info("Installing package: {}", packageURI); + + // 1. Fetch package + com.atomgraph.linkeddatahub.apps.model.Package pkg = getPackage(packageURI); + if (pkg == null) throw new BadRequestException("Package not found: " + packageURI); + + Resource ontology = pkg.getOntology(); + Resource stylesheet = pkg.getStylesheet(); + + if (ontology == null) throw new BadRequestException("Package ontology not found"); + + URI stylesheetURI = (stylesheet != null) ? URI.create(stylesheet.getURI()) : null; + + String packagePath = UriPath.convert(packageURI); + + // 2. Download and install ontology + if (log.isDebugEnabled()) log.debug("Downloading package ontology from: {}", ontology.getURI()); + Model ontologyModel = downloadOntology(ontology.getURI()); + installOntology(endUserApp, ontologyModel); + + // 3. Download and install stylesheet if present + if (stylesheetURI != null) + { + if (log.isDebugEnabled()) log.debug("Downloading package stylesheet from: {}", stylesheetURI); + String stylesheetContent = downloadStylesheet(stylesheetURI); + installStylesheet(packagePath, stylesheetContent); + } + + // 4. Regenerate master stylesheet + regenerateMasterStylesheet(endUserApp, packagePath); + + // 5. Add ldh:import triple to application (in system.trig) + addImportToApplication(endUserApp, packageURI); + + 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 (BadRequestException | IOException e) + { + log.error("Failed to install package: {}", packageURI, e); + throw new InternalServerErrorException("Package installation failed: " + e.getMessage(), e); + } + } + + /** + * Loads package metadata from its URI using LinkedDataClient. + * 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 + * @throws NotFoundException if package cannot be found (404) + * @throws InternalServerErrorException if package cannot be loaded for other reasons + */ + private com.atomgraph.linkeddatahub.apps.model.Package getPackage(String packageURI) + { + try + { + 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 (((FileManager)getDataManager()).hasCachedModel(packageURI) || + (getDataManager().isResolvingMapped() && getDataManager().isMapped(packageURI))) // read mapped URIs (such as system ontologies) from a file + { + if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", packageURI, ((FileManager)getDataManager()).hasCachedModel(packageURI)); + if (log.isDebugEnabled()) log.debug("isMapped({}): {}", packageURI, getDataManager().isMapped(packageURI)); + model = getDataManager().loadModel(packageURI); + } + else + { + LinkedDataClient ldc = LinkedDataClient.create(getSystem().getClient(), getSystem().getMediaTypes()); + model = ldc.getModel(packageURI); + } + + return model.getResource(packageURI).as(com.atomgraph.linkeddatahub.apps.model.Package.class); + } + catch (WebApplicationException e) + { + // Re-throw HTTP client errors from LinkedDataClient as-is (404, 403, etc.) + log.error("HTTP error loading package from: {}", packageURI, e); + throw e; + } + catch (Exception e) + { + log.error("Failed to load package from: {}", packageURI, e); + throw new InternalServerErrorException("Failed to load package from: " + packageURI, e); + } + } + + /** + * Downloads RDF from a URI using LinkedDataClient. + */ + private Model downloadOntology(String uri) throws IOException + { + 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 (((FileManager)getDataManager()).hasCachedModel(uri) || + (getDataManager().isResolvingMapped() && getDataManager().isMapped(uri))) // read mapped URIs (such as system ontologies) from a file + { + if (log.isDebugEnabled()) log.debug("hasCachedModel({}): {}", uri, ((FileManager)getDataManager()).hasCachedModel(uri)); + if (log.isDebugEnabled()) log.debug("isMapped({}): {}", uri, getDataManager().isMapped(uri)); + return getDataManager().loadModel(uri); + } + else + { + LinkedDataClient ldc = LinkedDataClient.create(getSystem().getClient(), getSystem().getMediaTypes()); + return ldc.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)) + throw new IOException("Failed to download XSLT from " + uri + ": " + response.getStatus()); + + return response.readEntity(String.class); + } + } + + /** + * Installs ontology by POSTing to namespace graph. + */ + private void installOntology(EndUserApplication app, Model ontologyModel) throws IOException + { + if (log.isDebugEnabled()) log.debug("Posting package ontology to namespace graph"); + + // POST to admin namespace graph + AdminApplication adminApp = app.getAdminApplication(); + String namespaceGraphURI = UriBuilder.fromUri(adminApp.getBaseURI()).path("model/ontologies/namespace").build().toString(); + + // Use Graph Store Protocol to add ontology to namespace graph + adminApp.getService().getGraphStoreClient().add(namespaceGraphURI, ontologyModel); + } + + /** + * Installs stylesheet to /static//layout.xsl + */ + private void installStylesheet(String packagePath, String stylesheetContent) throws IOException + { + Path staticDir = Paths.get(getServletContext().getRealPath("/static")); + Path packageDir = staticDir.resolve(packagePath); + Files.createDirectories(packageDir); + + Path stylesheetFile = packageDir.resolve("layout.xsl"); + Files.writeString(stylesheetFile, stylesheetContent); + + if (log.isDebugEnabled()) log.debug("Installed package stylesheet at: {}", stylesheetFile); + } + + /** + * Regenerates master stylesheet for the application. + */ + private void regenerateMasterStylesheet(EndUserApplication app, String newPackagePath) throws IOException + { + // Get all currently installed packages + Set packages = app.getImportedPackages(); + List packagePaths = new ArrayList<>(); + + for (Resource pkg : packages) + packagePaths.add(UriPath.convert(pkg.getURI())); + + // Add the new package + if (!packagePaths.contains(newPackagePath)) + packagePaths.add(newPackagePath); + + // Regenerate master stylesheet + String hostname = app.getBaseURI().getHost(); + XsltMasterUpdater updater = new XsltMasterUpdater(getServletContext()); + updater.regenerateMasterStylesheet(hostname, packagePaths); + } + + /** + * Adds ldh:import triple to the end-user application resource. + */ + private void addImportToApplication(EndUserApplication app, String packageURI) + { + // This would need to modify system.trig via SPARQL UPDATE + // For now, log a warning that this needs manual configuration + if (log.isWarnEnabled()) + { + log.warn("TODO: Add ldh:import triple to application. Manual edit required:"); + log.warn(" <{}> ldh:import <{}> .", app.getURI(), packageURI); + } + } + + /** + * 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 RDF data manager. + * + * @return RDF data manager + */ + public DataManager getDataManager() + { + return dataManager; + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/Uninstall.java b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/Uninstall.java new file mode 100644 index 000000000..1bc5b169f --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/admin/pkg/Uninstall.java @@ -0,0 +1,226 @@ +/** + * 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.server.util.UriPath; +import com.atomgraph.linkeddatahub.server.util.XsltMasterUpdater; +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.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.update.UpdateExecutionFactory; +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.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * JAX-RS resource that uninstalls a LinkedDataHub package. + * Package uninstallation involves: + * 1. Removing package ontology triples from namespace graph + * 2. Deleting package stylesheet from /static/packages/ + * 3. Regenerating application master stylesheet + * 4. Removing ldh:import triple from application + * + * @author Martynas Jusevičius {@literal } + */ +public class Uninstall +{ + private static final Logger log = LoggerFactory.getLogger(Uninstall.class); + + private final com.atomgraph.linkeddatahub.apps.model.Application application; + + @Context ServletContext servletContext; + + /** + * Constructs endpoint. + * + * @param application matched application (admin app) + */ + @Inject + public Uninstall(com.atomgraph.linkeddatahub.apps.model.Application application) + { + this.application = application; + } + + /** + * 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) throw new BadRequestException("Package URI not specified"); + + try + { + EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); + + if (log.isInfoEnabled()) log.info("Uninstalling package: {}", packageURI); + + String packagePath = UriPath.convert(packageURI); + + // 1. Remove ontology triples from namespace graph + uninstallOntology(endUserApp, packagePath); + + // 2. Delete stylesheet from /static// + uninstallStylesheet(packagePath); + + // 3. Regenerate master stylesheet + regenerateMasterStylesheet(endUserApp, packagePath); + + // 4. Remove ldh:import triple from application + removeImportFromApplication(endUserApp, packageURI); + + if (log.isInfoEnabled()) log.info("Successfully uninstalled package: {}", packageURI); + + // Redirect back to referer or application base + URI redirectURI = (referer != null) ? referer : endUserApp.getBaseURI(); + return Response.seeOther(redirectURI).build(); + } + catch (Exception e) + { + log.error("Failed to uninstall package: {}", packageURI, e); + throw new jakarta.ws.rs.InternalServerErrorException("Package uninstallation failed: " + e.getMessage(), e); + } + } + + /** + * Uninstalls ontology by removing triples from namespace graph. + * This is a simplified version - a real implementation would track which triples belong to which package. + */ + private void uninstallOntology(EndUserApplication app, String packagePath) throws IOException + { + if (log.isWarnEnabled()) + { + log.warn("TODO: Remove package ontology triples from namespace graph"); + log.warn(" This requires tracking which triples belong to package: {}", packagePath); + } + // For now, we don't remove ontology triples as it's complex to track ownership + // A future enhancement could use named graphs per package + } + + /** + * Deletes stylesheet from /static// + */ + private void uninstallStylesheet(String packagePath) throws IOException + { + Path staticDir = Paths.get(getServletContext().getRealPath("/static")); + Path packageDir = staticDir.resolve(packagePath); + + if (Files.exists(packageDir)) + { + // Delete layout.xsl + Path stylesheetFile = packageDir.resolve("layout.xsl"); + if (Files.exists(stylesheetFile)) + { + Files.delete(stylesheetFile); + if (log.isDebugEnabled()) log.debug("Deleted package stylesheet: {}", stylesheetFile); + } + + // Delete directory if empty + if (Files.list(packageDir).count() == 0) + { + Files.delete(packageDir); + if (log.isDebugEnabled()) log.debug("Deleted package directory: {}", packageDir); + } + } + } + + /** + * Regenerates master stylesheet for the application without the uninstalled package. + */ + private void regenerateMasterStylesheet(EndUserApplication app, String removedPackagePath) throws IOException + { + // Get all currently installed packages + Set packages = app.getImportedPackages(); + List packagePaths = new ArrayList<>(); + + for (Resource pkg : packages) + { + String pkgPath = UriPath.convert(pkg.getURI()); + // Exclude the package being removed + if (!pkgPath.equals(removedPackagePath)) + { + packagePaths.add(pkgPath); + } + } + + // Regenerate master stylesheet + String hostname = app.getBaseURI().getHost(); + XsltMasterUpdater updater = new XsltMasterUpdater(getServletContext()); + updater.regenerateMasterStylesheet(hostname, packagePaths); + } + + /** + * Removes ldh:import triple from the end-user application resource. + */ + private void removeImportFromApplication(EndUserApplication app, String packageURI) + { + // This would need to modify system.trig via SPARQL UPDATE + // For now, log a warning that this needs manual configuration + if (log.isWarnEnabled()) + { + log.warn("TODO: Remove ldh:import triple from application. Manual edit required:"); + log.warn(" DELETE DATA {{ <{}> ldh:import <{}> }}", app.getURI(), packageURI); + } + } + + /** + * 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; + } + +} 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 c3cb7bc7e..d3fc3d750 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 @@ -23,6 +23,8 @@ import com.atomgraph.linkeddatahub.resource.Namespace; import com.atomgraph.linkeddatahub.resource.Transform; import com.atomgraph.linkeddatahub.resource.admin.Clear; +import com.atomgraph.linkeddatahub.resource.admin.pkg.Install; +import com.atomgraph.linkeddatahub.resource.admin.pkg.Uninstall; import com.atomgraph.linkeddatahub.resource.admin.SignUp; import com.atomgraph.linkeddatahub.resource.Graph; import com.atomgraph.linkeddatahub.resource.acl.Access; @@ -216,7 +218,7 @@ public Class getGenerateEndpoint() /** * Returns the endpoint that allows clearing ontologies from cache by URI. - * + * * @return endpoint resource */ @Path("clear") @@ -224,10 +226,32 @@ public Class getClearEndpoint() { return getProxyClass().orElse(Clear.class); } - + + /** + * Returns the endpoint for installing LinkedDataHub packages. + * + * @return endpoint resource + */ + @Path("packages/install") + public Class getInstallPackageEndpoint() + { + return getProxyClass().orElse(Install.class); + } + + /** + * Returns the endpoint for uninstalling LinkedDataHub packages. + * + * @return endpoint resource + */ + @Path("packages/uninstall") + public Class getUninstallPackageEndpoint() + { + return getProxyClass().orElse(Uninstall.class); + } + /** * Returns Google OAuth endpoint. - * + * * @return endpoint resource */ @Path("oauth2/authorize/google") diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/GraphStoreImpl.java b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/GraphStoreImpl.java index 6dc3c2f10..1c25cd2a2 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/GraphStoreImpl.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/GraphStoreImpl.java @@ -18,7 +18,6 @@ import com.atomgraph.core.MediaTypes; import com.atomgraph.core.riot.lang.RDFPostReader; -import static com.atomgraph.linkeddatahub.apps.model.Application.UPLOADS_PATH; import com.atomgraph.linkeddatahub.model.Service; import com.atomgraph.linkeddatahub.server.security.AgentContext; import java.net.URI; @@ -57,6 +56,11 @@ public abstract class GraphStoreImpl extends com.atomgraph.core.model.impl.Graph { private static final Logger log = LoggerFactory.getLogger(GraphStoreImpl.class); + + /** + * The relative path of the content-addressed file container. + */ + public static final String UPLOADS_PATH = "uploads"; private final UriInfo uriInfo; private final com.atomgraph.linkeddatahub.apps.model.Application application; diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/UriPath.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/UriPath.java new file mode 100644 index 000000000..aa59c9273 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/UriPath.java @@ -0,0 +1,76 @@ +/** + * 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 java.net.URI; + +/** + * Utility for converting URIs to filesystem paths. + * Reverses hostname components following Java package naming conventions. + * + * @author Martynas Jusevičius {@literal } + */ +public class UriPath +{ + + /** + * Converts a URI to a filesystem path by reversing hostname components. + * Example: https://packages.linkeddatahub.com/skos/#this -> com/linkeddatahub/packages/skos + * + * @param uri the URI string + * @return filesystem path relative to static directory + * @throws IllegalArgumentException if URI is invalid + */ + public static String convert(String uri) + { + if (uri == null) + throw new IllegalArgumentException("URI cannot be null"); + + try + { + URI uriObj = URI.create(uri); + String host = uriObj.getHost(); + String path = uriObj.getPath(); + + if (host == null) + throw new IllegalArgumentException("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 URI: " + uri, e); + } + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/XsltMasterUpdater.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/XsltMasterUpdater.java new file mode 100644 index 000000000..0804ce7b4 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/XsltMasterUpdater.java @@ -0,0 +1,244 @@ +/** + * 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.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +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.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * 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 static final String XS_NS = "http://www.w3.org/2001/XMLSchema"; + + private final ServletContext servletContext; + + /** + * Constructs updater with servlet context. + * + * @param servletContext the servlet context + */ + public XsltMasterUpdater(ServletContext servletContext) + { + this.servletContext = servletContext; + } + + /** + * Regenerates the master stylesheet for an application hostname. + * The master stylesheet must exist at /static//layout.xsl. + * This method loads it and adds/updates xsl:import elements for packages. + * + * @param hostname the application hostname (e.g., "localhost") + * @param packagePaths list of package paths to import (e.g., ["com/linkeddatahub/packages/skos"]) + * @throws IOException if file operations fail + */ + public void regenerateMasterStylesheet(String hostname, List packagePaths) throws IOException + { + if (hostname == null) throw new IllegalArgumentException("Hostname cannot be null"); + + try + { + Path staticDir = getStaticPath(); + Path hostnameDir = staticDir.resolve(hostname); + Path masterFile = hostnameDir.resolve("layout.xsl"); + + // Master stylesheet must exist + if (!Files.exists(masterFile)) + { + throw new jakarta.ws.rs.InternalServerErrorException("Master stylesheet does not exist: " + masterFile); + } + + // Load existing master stylesheet + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + + if (log.isDebugEnabled()) log.debug("Loading master stylesheet: {}", masterFile); + Document doc = builder.parse(masterFile.toFile()); + + // Get stylesheet root element + Element stylesheet = doc.getDocumentElement(); + if (!stylesheet.getLocalName().equals("stylesheet") || !XSL_NS.equals(stylesheet.getNamespaceURI())) + { + throw new IllegalStateException("Root element must be xsl:stylesheet"); + } + + // Remove all existing xsl:import elements for packages + removePackageImports(stylesheet); + + // Add xsl:import elements for packages (after system import, before everything else) + Element systemImport = findSystemImport(stylesheet); + Node insertAfter = systemImport; + + if (packagePaths != null && !packagePaths.isEmpty()) + { + for (String packagePath : packagePaths) + { + Element importElement = doc.createElementNS(XSL_NS, "xsl:import"); + importElement.setAttribute("href", "../" + packagePath + "/layout.xsl"); + + // Add comment + org.w3c.dom.Comment comment = doc.createComment(" Package: " + packagePath + " "); + if (insertAfter.getNextSibling() != null) + { + stylesheet.insertBefore(comment, insertAfter.getNextSibling()); + stylesheet.insertBefore(importElement, insertAfter.getNextSibling()); + } + else + { + stylesheet.appendChild(comment); + stylesheet.appendChild(importElement); + } + insertAfter = importElement; + + if (log.isDebugEnabled()) log.debug("Added xsl:import for package: {}", packagePath); + } + } + + // Write to file + Files.createDirectories(hostnameDir); + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); + + DOMSource source = new DOMSource(doc); + StreamResult result = new StreamResult(masterFile.toFile()); + transformer.transform(source, result); + + if (log.isDebugEnabled()) log.debug("Regenerated master stylesheet for hostname '{}' at: {}", hostname, masterFile); + } + catch (Exception e) + { + throw new IOException("Failed to regenerate master stylesheet", e); + } + } + + /** + * Finds the system stylesheet import element. + */ + private Element findSystemImport(Element stylesheet) + { + NodeList imports = stylesheet.getElementsByTagNameNS(XSL_NS, "import"); + for (int i = 0; i < imports.getLength(); i++) + { + Element importElem = (Element) imports.item(i); + String href = importElem.getAttribute("href"); + if (href.contains("/com/atomgraph/linkeddatahub/xsl/bootstrap/")) + { + return importElem; + } + } + throw new IllegalStateException("System stylesheet import not found"); + } + + /** + * Removes all xsl:import elements for packages. + * Identifies package imports by checking if they have a preceding comment containing "Package:". + */ + private void removePackageImports(Element stylesheet) + { + NodeList imports = stylesheet.getElementsByTagNameNS(XSL_NS, "import"); + List toRemove = new ArrayList<>(); + + for (int i = 0; i < imports.getLength(); i++) + { + Element importElem = (Element) imports.item(i); + String href = importElem.getAttribute("href"); + + // Check if this is a package import by looking for "../" prefix and preceding comment + if (href.startsWith("../") && !href.contains("/com/atomgraph/linkeddatahub/xsl/")) + { + // Verify it has a package comment + Node prev = importElem.getPreviousSibling(); + while (prev != null && prev.getNodeType() == Node.TEXT_NODE) + { + prev = prev.getPreviousSibling(); + } + if (prev != null && prev.getNodeType() == Node.COMMENT_NODE) + { + String commentText = prev.getNodeValue(); + if (commentText.contains("Package:")) + { + toRemove.add(importElem); + } + } + } + } + + for (Element elem : toRemove) + { + // Remove preceding comment if it's a package comment + Node prev = elem.getPreviousSibling(); + while (prev != null && prev.getNodeType() == Node.TEXT_NODE) + { + prev = prev.getPreviousSibling(); + } + if (prev != null && prev.getNodeType() == Node.COMMENT_NODE) + { + String commentText = prev.getNodeValue(); + if (commentText.contains("Package:")) + { + stylesheet.removeChild(prev); + } + } + + stylesheet.removeChild(elem); + } + } + + /** + * Gets the path to the webapp's /static/ directory. + * + * @return path to static directory + */ + private Path getStaticPath() + { + String realPath = servletContext.getRealPath("/static"); + if (realPath == null) + throw new IllegalStateException("Could not resolve real path for /static directory"); + return Paths.get(realPath); + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java index 6e0c49a11..f5ebcb232 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java +++ b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LAPP.java @@ -63,7 +63,10 @@ public static String getURI() /** End-user application class */ public static final OntClass EndUserApplication = m_model.createClass( NS + "EndUserApplication" ); - + + /** Package class */ + public static final OntClass Package = m_model.createClass( NS + "Package" ); + /** Admin application class */ public static final ObjectProperty adminApplication = m_model.createObjectProperty( NS + "adminApplication" ); diff --git a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java index cea639a6a..26774c726 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java +++ b/src/main/java/com/atomgraph/linkeddatahub/vocabulary/LDH.java @@ -109,7 +109,7 @@ public static String getURI() public static final ObjectProperty forShape = m_model.createObjectProperty( NS + "forShape" ); /** - * Graph URI property */ - //public static final ObjectProperty graph = m_model.createObjectProperty( NS + "graph" ); + * Import property - used to import packages into an application */ + public static final ObjectProperty importPackage = m_model.createObjectProperty( NS + "import" ); } diff --git a/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl b/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl index 81402fb3d..ebe8fb223 100644 --- a/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl +++ b/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl @@ -163,6 +163,13 @@ rdfs:label "Admin application constructor" ; rdfs:isDefinedBy : . +# package + +:Package a rdfs:Class, owl:Class ; + rdfs:label "Package" ; + rdfs:comment "A reusable package containing RDF ontology and optional XSLT stylesheet for vocabulary support" ; + rdfs:isDefinedBy : . + # CONSTRAINTS :StartsWithHTTPS a sp:Construct ; diff --git a/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl b/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl new file mode 100644 index 000000000..bea9ee244 --- /dev/null +++ b/src/main/resources/com/linkeddatahub/packages/skos/layout.xsl @@ -0,0 +1,61 @@ + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/com/linkeddatahub/packages/skos/ns.ttl b/src/main/resources/com/linkeddatahub/packages/skos/ns.ttl new file mode 100644 index 000000000..60bf73711 --- /dev/null +++ b/src/main/resources/com/linkeddatahub/packages/skos/ns.ttl @@ -0,0 +1,123 @@ +@prefix : <#> . +@prefix ns: . +@prefix ldh: . +@prefix rdfs: . +@prefix sp: . +@prefix spin: . +@prefix dct: . +@prefix skos: . + +# Concept + +# narrower + +skos:Concept ldh:template ns:NarrowerConcepts. + +ns:NarrowerConcepts a ldh:View ; + dct:title "Narrower concepts" ; + spin:query ns:SelectNarrowerConcepts ; + rdfs:isDefinedBy ns: . + +ns:SelectNarrowerConcepts a sp:Select ; + rdfs:label "Select narrower concepts" ; + sp:text """ +PREFIX skos: + +SELECT DISTINCT ?narrower +WHERE + { GRAPH ?graph + { $about skos:narrower ?narrower . + GRAPH ?narrowerGraph + { + ?narrower skos:prefLabel ?prefLabel . + FILTER (langMatches(lang(?prefLabel), "en")) + } + } + } +ORDER BY ?prefLabel +""" ; + rdfs:isDefinedBy ns: . + +# broader + +skos:Concept ldh:template ns:BroaderConcepts. + +ns:BroaderConcepts a ldh:View ; + dct:title "Broader concepts" ; + spin:query ns:SelectBroaderConcepts ; + rdfs:isDefinedBy ns: . + +ns:SelectBroaderConcepts a sp:Select ; + rdfs:label "Select broader concepts" ; + sp:text """ +PREFIX skos: + +SELECT DISTINCT ?broader +WHERE + { GRAPH ?graph + { $about skos:broader ?broader . + GRAPH ?broaderGraph + { + ?broader skos:prefLabel ?prefLabel . + FILTER (langMatches(lang(?prefLabel), "en")) + } + } + } +ORDER BY ?prefLabel +""" ; + rdfs:isDefinedBy ns: . + +# Collection + +skos:Collection ldh:template ns:CollectionMembers. + +ns:CollectionMembers a ldh:View ; + dct:title "Collection members" ; + spin:query ns:SelectCollectionMembers ; + rdfs:isDefinedBy ns: . + +ns:SelectCollectionMembers a sp:Select ; + rdfs:label "Select collection members" ; + sp:text """ +PREFIX skos: + +SELECT DISTINCT ?member +WHERE + { GRAPH ?graph + { $about skos:member ?member . + GRAPH ?memberGraph + { + ?member skos:prefLabel ?prefLabel . + FILTER (langMatches(lang(?prefLabel), "en")) + } + } + } +ORDER BY ?prefLabel +""" ; + rdfs:isDefinedBy ns: . + +# ConceptScheme + +skos:ConceptScheme ldh:template ns:ConceptsInScheme. + +ns:ConceptsInScheme a ldh:View ; + dct:title "Concepts in scheme" ; + spin:query ns:SelectConceptsInScheme ; + rdfs:isDefinedBy ns: . + +ns:SelectConceptsInScheme a sp:Select ; + rdfs:label "Select concepts in scheme" ; + sp:text """ +PREFIX skos: + +SELECT DISTINCT ?concept +WHERE + { GRAPH ?graph + { ?concept skos:inScheme $about ; + skos:prefLabel ?prefLabel . + FILTER (langMatches(lang(?prefLabel), "en")) + } + } +ORDER BY ?prefLabel +""" ; + rdfs:isDefinedBy ns: . diff --git a/src/main/resources/com/linkeddatahub/packages/skos/package.ttl b/src/main/resources/com/linkeddatahub/packages/skos/package.ttl new file mode 100644 index 000000000..8240eccda --- /dev/null +++ b/src/main/resources/com/linkeddatahub/packages/skos/package.ttl @@ -0,0 +1,20 @@ +@base . +@prefix : <#> . +@prefix lapp: . +@prefix ldt: . +@prefix ac: . +@prefix rdfs: . +@prefix dct: . +@prefix foaf: . + + a lapp:Package ; + rdfs:label "SKOS Package" ; + dct:title "SKOS Package for LinkedDataHub" ; + dct:description "Provides SKOS (Simple Knowledge Organization System) vocabulary support with custom templates for concept hierarchies, schemes, and collections." ; + dct:creator ; + ldt:ontology ; + ac:stylesheet . + + a foaf:Organization ; + foaf:name "AtomGraph" ; + foaf:homepage . diff --git a/src/main/resources/location-mapping.ttl b/src/main/resources/location-mapping.ttl index 6db880201..91856575c 100644 --- a/src/main/resources/location-mapping.ttl +++ b/src/main/resources/location-mapping.ttl @@ -16,5 +16,7 @@ [ lm:name "http://spinrdf.org/spin#" ; lm:altName "etc/spin.ttl" ] , [ lm:name "http://spinrdf.org/spin" ; lm:altName "etc/spin.ttl" ] , [ lm:name "http://spinrdf.org/spl#" ; lm:altName "etc/spl.spin.ttl" ] , - [ lm:name "http://spinrdf.org/spl" ; lm:altName "etc/spl.spin.ttl" ] + [ lm:name "http://spinrdf.org/spl" ; lm:altName "etc/spl.spin.ttl" ] , + [ lm:name "https://packages.linkeddatahub.com/skos/" ; lm:altName "com/linkeddatahub/packages/skos/package.ttl" ] , + [ lm:name "https://raw.githubusercontent.com/AtomGraph/LinkedDataHub-Apps/refs/heads/develop/packages/skos/ns.ttl" ; lm:altName "com/linkeddatahub/packages/skos/ns.ttl" ] . \ No newline at end of file diff --git a/src/main/resources/prefix-mapping.ttl b/src/main/resources/prefix-mapping.ttl index d82a1f554..85e7c9a84 100644 --- a/src/main/resources/prefix-mapping.ttl +++ b/src/main/resources/prefix-mapping.ttl @@ -73,5 +73,7 @@ [ lm:prefix "http://purl.org/goodrelations/v1" ; lm:altName "com/atomgraph/client/goodrelations.owl" ] , [ 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 "http://spinrdf.org/spl" ; lm:altName "etc/spl.spin.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/develop/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/localhost/layout.xsl b/src/main/webapp/static/localhost/layout.xsl new file mode 100644 index 000000000..ceb6d2b13 --- /dev/null +++ b/src/main/webapp/static/localhost/layout.xsl @@ -0,0 +1,12 @@ + + + + + + + + +