diff --git a/config/system.trig b/config/system.trig index 21cd69615..19c6a0979 100644 --- a/config/system.trig +++ b/config/system.trig @@ -15,41 +15,59 @@ # root admin - a lapp:Application, lapp:AdminApplication ; - dct:title "LinkedDataHub admin" ; - # ldt:base ; - lapp:origin ; - ldt:ontology ; - ldt:service ; - ac:stylesheet ; - lapp:endUserApplication ; - lapp:frontendProxy . - - a sd:Service ; - dct:title "LinkedDataHub admin service" ; - sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; - sd:endpoint ; - a:graphStore ; - a:quadStore ; - lapp:backendProxy . + +{ + a lapp:Application, lapp:AdminApplication ; + dct:title "LinkedDataHub admin" ; + # ldt:base ; + lapp:origin ; + ldt:ontology ; + ldt:service ; + ac:stylesheet ; + lapp:endUserApplication ; + lapp:frontendProxy . + +} + + +{ + + a sd:Service ; + dct:title "LinkedDataHub admin service" ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; + sd:endpoint ; + a:graphStore ; + a:quadStore ; + lapp:backendProxy . + +} # root end-user - a lapp:Application, lapp:EndUserApplication ; - dct:title "LinkedDataHub" ; - # ldt:base ; - lapp:origin ; - ldt:ontology ; - ldt:service ; - ac:stylesheet ; - lapp:adminApplication ; - lapp:frontendProxy ; - lapp:public true . - - a sd:Service ; - dct:title "LinkedDataHub service" ; - sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; - sd:endpoint ; - a:graphStore ; - a:quadStore ; - lapp:backendProxy . + +{ + a lapp:Application, lapp:EndUserApplication ; + dct:title "LinkedDataHub" ; + # ldt:base ; + lapp:origin ; + ldt:ontology ; + ldt:service ; + ac:stylesheet ; + lapp:adminApplication ; + lapp:frontendProxy ; + lapp:public true . + +} + + +{ + + a sd:Service ; + dct:title "LinkedDataHub service" ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; + sd:endpoint ; + a:graphStore ; + a:quadStore ; + lapp:backendProxy . + +} diff --git a/docker-compose.yml b/docker-compose.yml index 7b8fffd36..8f16be46b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -93,7 +93,7 @@ services: - ./datasets/secretary:/var/linkeddatahub/datasets/secretary - ./uploads:/var/www/linkeddatahub/uploads - ./config/dev.log4j.properties:/usr/local/tomcat/webapps/ROOT/WEB-INF/classes/log4j.properties:ro - - ./config/system.trig:/var/linkeddatahub/datasets/system.trig:ro + - ./config/system.trig:/var/linkeddatahub/datasets/system.trig fuseki-admin: image: atomgraph/fuseki:4.7.0 user: root # otherwise fuseki user does not have permissions to the mounted folder which is owner by root diff --git a/http-tests/config/system.trig b/http-tests/config/system.trig index 277499e5d..428f205bf 100644 --- a/http-tests/config/system.trig +++ b/http-tests/config/system.trig @@ -15,76 +15,104 @@ # root admin - a lapp:Application, lapp:AdminApplication ; - dct:title "LinkedDataHub admin" ; - # ldt:base ; - lapp:origin ; - ldt:ontology ; - ldt:service ; - lapp:endUserApplication ; - lapp:frontendProxy . + +{ + a lapp:Application, lapp:AdminApplication ; + dct:title "LinkedDataHub admin" ; + # ldt:base ; + lapp:origin ; + ldt:ontology ; + ldt:service ; + ac:stylesheet ; + lapp:endUserApplication ; + lapp:frontendProxy . +} - a sd:Service ; - dct:title "LinkedDataHub admin service" ; - sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; - sd:endpoint ; - a:graphStore ; - a:quadStore ; - lapp:backendProxy . + +{ + a sd:Service ; + dct:title "LinkedDataHub admin service" ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; + sd:endpoint ; + a:graphStore ; + a:quadStore ; + lapp:backendProxy . +} # root end-user - a lapp:Application, lapp:EndUserApplication ; - dct:title "LinkedDataHub" ; - # ldt:base ; - lapp:origin ; - ldt:ontology ; - ldt:service ; - lapp:adminApplication ; - lapp:frontendProxy ; - lapp:public true . + +{ + a lapp:Application, lapp:EndUserApplication ; + dct:title "LinkedDataHub" ; + # ldt:base ; + lapp:origin ; + ldt:ontology ; + ldt:service ; + ac:stylesheet ; + lapp:adminApplication ; + lapp:frontendProxy ; + lapp:public true . +} - a sd:Service ; - dct:title "LinkedDataHub service" ; - sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; - sd:endpoint ; - a:graphStore ; - a:quadStore ; - lapp:backendProxy . + +{ + a sd:Service ; + dct:title "LinkedDataHub service" ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; + sd:endpoint ; + a:graphStore ; + a:quadStore ; + lapp:backendProxy . +} # test admin - a lapp:Application, lapp:AdminApplication ; - dct:title "Test admin" ; - lapp:origin ; - ldt:ontology ; - ldt:service ; - lapp:endUserApplication ; - lapp:frontendProxy . + +{ + a lapp:Application, lapp:AdminApplication ; + dct:title "Test admin" ; + lapp:origin ; + ldt:ontology ; + ldt:service ; + ac:stylesheet ; + lapp:endUserApplication ; + lapp:frontendProxy . +} - a sd:Service ; - dct:title "Test admin service" ; - sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; - sd:endpoint ; - a:graphStore ; - a:quadStore ; - lapp:backendProxy . + +{ + a sd:Service ; + dct:title "Test admin service" ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; + sd:endpoint ; + a:graphStore ; + a:quadStore ; + lapp:backendProxy . +} # test end-user - a lapp:Application, lapp:EndUserApplication ; - dct:title "Test" ; - lapp:origin ; - ldt:ontology ; - ldt:service ; - lapp:adminApplication ; - lapp:frontendProxy ; - lapp:public true . + +{ + a lapp:Application, lapp:EndUserApplication ; + dct:title "Test" ; + lapp:origin ; + ldt:ontology ; + ldt:service ; + ac:stylesheet ; + lapp:adminApplication ; + lapp:frontendProxy ; + lapp:public true . +} - a sd:Service ; - dct:title "Test service" ; - sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; - sd:endpoint ; - a:graphStore ; - a:quadStore ; - lapp:backendProxy . + +{ + a sd:Service ; + dct:title "Test service" ; + sd:supportedLanguage sd:SPARQL11Query, sd:SPARQL11Update ; + sd:endpoint ; + a:graphStore ; + a:quadStore ; + lapp:backendProxy . +} diff --git a/http-tests/misc/GET-settings-etag.sh b/http-tests/misc/GET-settings-etag.sh new file mode 100755 index 000000000..d1d837aaf --- /dev/null +++ b/http-tests/misc/GET-settings-etag.sh @@ -0,0 +1,27 @@ +#!/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: GET /settings with If-None-Match - Conditional GET with matching ETag + +# First GET to obtain ETag +response=$(curl -i -k -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings") + +# Extract ETag +etag=$(echo "$response" | grep -i "ETag:" | sed 's/ETag: //i' | tr -d '\r\n') + +# Second GET with If-None-Match +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + -H "If-None-Match: $etag" \ + "${END_USER_BASE_URL}settings" \ +| grep -q "$STATUS_NOT_MODIFIED" diff --git a/http-tests/misc/GET-settings.sh b/http-tests/misc/GET-settings.sh new file mode 100755 index 000000000..d28ee1fcc --- /dev/null +++ b/http-tests/misc/GET-settings.sh @@ -0,0 +1,41 @@ +#!/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: GET /settings - Retrieve current application settings + +response=$(curl -k -w "%{http_code}\n" -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings") + +# Extract status code (last line) and body (everything else) +status=$(echo "$response" | tail -n 1) +body=$(echo "$response" | sed '$d') + +# Verify 200 OK response +if [ "$status" != "$STATUS_OK" ]; then + exit 1 +fi + +# Verify response contains expected application data +if ! echo "$body" | grep -q ''; then + exit 1 +fi + +if ! echo "$body" | grep -q ''; then + exit 1 +fi + +if ! echo "$body" | grep -q '"LinkedDataHub"'; then + exit 1 +fi + +if ! echo "$body" | grep -q ''; then + exit 1 +fi diff --git a/http-tests/misc/PATCH-settings-422.sh b/http-tests/misc/PATCH-settings-422.sh new file mode 100755 index 000000000..8da3567f9 --- /dev/null +++ b/http-tests/misc/PATCH-settings-422.sh @@ -0,0 +1,20 @@ +#!/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: PATCH /settings - Remove mandatory property (should fail validation) + +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 "PREFIX lapp: +DELETE { ?app lapp:origin ?origin } +WHERE { ?app lapp:origin ?origin }" \ + "${END_USER_BASE_URL}settings" \ +| grep -q "$STATUS_UNPROCESSABLE_ENTITY" diff --git a/http-tests/misc/PATCH-settings-empty-422.sh b/http-tests/misc/PATCH-settings-empty-422.sh new file mode 100755 index 000000000..7f62c6361 --- /dev/null +++ b/http-tests/misc/PATCH-settings-empty-422.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" + +# Test: PATCH /settings - Empty result (should fail with 422) + +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 WHERE { ?s ?p ?o }" \ + "${END_USER_BASE_URL}settings" \ +| grep -q "$STATUS_UNPROCESSABLE_ENTITY" diff --git a/http-tests/misc/PATCH-settings.sh b/http-tests/misc/PATCH-settings.sh new file mode 100755 index 000000000..712bf6315 --- /dev/null +++ b/http-tests/misc/PATCH-settings.sh @@ -0,0 +1,64 @@ +#!/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: PATCH /settings - Valid update (change title) + +# Get initial ETag +initial_response=$(curl -i -k -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings") + +etag=$(echo "$initial_response" | grep -i "ETag:" | sed 's/ETag: //i' | tr -d '\r\n') + +# PATCH to update title +( +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 "PREFIX dct: +DELETE { ?app dct:title ?title } +INSERT { ?app dct:title \"Updated Title\" } +WHERE { ?app dct:title ?title }" \ + "${END_USER_BASE_URL}settings" +) \ +| grep -q "$STATUS_NO_CONTENT" + +# Verify changes were persisted by GET +verify_response=$(curl -k -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings") + +if ! echo "$verify_response" | grep -q "Updated Title"; then + exit 1 +fi + +# Verify ETag changed after update +verify_etag_response=$(curl -i -k -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "${END_USER_BASE_URL}settings") + +new_etag=$(echo "$verify_etag_response" | grep -i "ETag:" | sed 's/ETag: //i' | tr -d '\r\n') + +if [ "$etag" = "$new_etag" ]; then + exit 1 +fi + +# Restore original title for subsequent tests +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 "PREFIX dct: +DELETE { ?app dct:title ?title } +INSERT { ?app dct:title \"LinkedDataHub\" } +WHERE { ?app dct:title ?title }" \ + "${END_USER_BASE_URL}settings" > /dev/null diff --git a/platform/datasets/end-user.trig b/platform/datasets/end-user.trig index 4c3574b08..2608b6a39 100644 --- a/platform/datasets/end-user.trig +++ b/platform/datasets/end-user.trig @@ -389,6 +389,14 @@ WHERE } + +{ + + a foaf:Document ; + dct:title "Settings endpoint" . + +} + { diff --git a/platform/entrypoint.sh b/platform/entrypoint.sh index 6a4bb2e6d..1fbe571cf 100755 --- a/platform/entrypoint.sh +++ b/platform/entrypoint.sh @@ -625,10 +625,10 @@ for app in "${apps[@]}"; do # append ownership metadata to apps if it's not present (apps have to be URI resources!) if [ -z "$end_user_owner" ]; then - echo "<${end_user_app}> <${OWNER_URI}> ." >> "$based_context_dataset" + echo "<${end_user_app}> <${OWNER_URI}> <${end_user_app}> ." >> "$based_context_dataset" fi if [ -z "$admin_owner" ]; then - echo "<${admin_app}> <${OWNER_URI}> ." >> "$based_context_dataset" + echo "<${admin_app}> <${OWNER_URI}> <${admin_app}> ." >> "$based_context_dataset" fi printf "\n### Quad store URL of the root end-user service: %s\n" "$end_user_quad_store_url" diff --git a/platform/namespace-ontology.trig.template b/platform/namespace-ontology.trig.template index c62472479..a3531ccb8 100644 --- a/platform/namespace-ontology.trig.template +++ b/platform/namespace-ontology.trig.template @@ -107,7 +107,7 @@ 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 <${end_user_origin}/sparql>, <${end_user_origin}/importer>, <${end_user_origin}/add>, <${end_user_origin}/generate>, <${end_user_origin}/ns> ; + acl:accessTo <${end_user_origin}/sparql>, <${end_user_origin}/importer>, <${end_user_origin}/add>, <${end_user_origin}/generate>, <${end_user_origin}/ns>, <${end_user_origin}/settings> ; acl:mode acl:Read, acl:Append, acl:Write, acl:Control ; acl:agentGroup <${admin_origin}/acl/groups/owners/#this> . diff --git a/platform/select-root-services.rq b/platform/select-root-services.rq index 4c6d67546..30477551d 100644 --- a/platform/select-root-services.rq +++ b/platform/select-root-services.rq @@ -7,31 +7,45 @@ PREFIX foaf: SELECT ?endUserApp ?endUserOrigin ?endUserQuadStore ?endUserEndpoint ?endUserAuthUser ?endUserAuthPwd ?endUserMaker ?adminApp ?adminOrigin ?adminQuadStore ?adminEndpoint ?adminAuthUser ?adminAuthPwd ?adminMaker { - ?endUserApp lapp:origin ?endUserOrigin ; - ldt:service ?endUserService ; - lapp:adminApplication ?adminApp . - ?adminApp ldt:service ?adminService ; - lapp:origin ?adminOrigin . - ?endUserService a:quadStore ?endUserQuadStore ; - sd:endpoint ?endUserEndpoint . - ?adminService a:quadStore ?adminQuadStore ; - sd:endpoint ?adminEndpoint . - OPTIONAL + GRAPH ?endUserAppGraph { - ?endUserService a:authUser ?endUserAuthUser ; - a:authPwd ?endUserAuthPwd . + ?endUserApp lapp:origin ?endUserOrigin ; + ldt:service ?endUserService ; + lapp:adminApplication ?adminApp . + + GRAPH ?endUserServiceGraph + { + ?endUserService a:quadStore ?endUserQuadStore ; + sd:endpoint ?endUserEndpoint . + OPTIONAL + { + ?endUserService a:authUser ?endUserAuthUser ; + a:authPwd ?endUserAuthPwd . + } + OPTIONAL + { + ?endUserService foaf:maker ?endUserMaker + } + } } - OPTIONAL + GRAPH ?adminAppGraph { - ?adminService a:authUser ?adminAuthUser ; - a:authPwd ?adminAuthPwd . - } - OPTIONAL - { - ?endUserService foaf:maker ?endUserMaker - } - OPTIONAL - { - ?adminService foaf:maker ?adminMaker + ?adminApp ldt:service ?adminService ; + lapp:origin ?adminOrigin . + + GRAPH ?adminServiceGraph + { + ?adminService a:quadStore ?adminQuadStore ; + sd:endpoint ?adminEndpoint . + OPTIONAL + { + ?adminService a:authUser ?adminAuthUser ; + a:authPwd ?adminAuthPwd . + } + OPTIONAL + { + ?adminService foaf:maker ?adminMaker + } + } } } \ No newline at end of file diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index e28066e34..e3b0f30b3 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -294,9 +294,9 @@ public class Application extends ResourceConfig private final boolean enableWebIDSignUp; private final String oidcRefreshTokensPropertiesPath; private final Properties oidcRefreshTokens; + private final URI contextDatasetURI; + private final Dataset contextDataset; - private Dataset contextDataset; - /** * Constructs system application and configures it using sevlet config. * @@ -313,6 +313,7 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti servletConfig.getServletContext().getInitParameter(A.cacheModelLoads.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(A.cacheModelLoads.getURI())) : true, servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(A.preemptiveAuth.getURI())) : false, new PrefixMapper(servletConfig.getServletContext().getInitParameter(AC.prefixMapping.getURI()) != null ? servletConfig.getServletContext().getInitParameter(AC.prefixMapping.getURI()) : null), + servletConfig.getServletContext().getInitParameter(LDHC.contextDataset.getURI()) != null ? servletConfig.getServletContext().getInitParameter(LDHC.contextDataset.getURI()) : null, com.atomgraph.client.Application.getSource(servletConfig.getServletContext(), servletConfig.getServletContext().getInitParameter(AC.stylesheet.getURI()) != null ? servletConfig.getServletContext().getInitParameter(AC.stylesheet.getURI()) : null), servletConfig.getServletContext().getInitParameter(AC.cacheStylesheet.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(AC.cacheStylesheet.getURI())) : false, servletConfig.getServletContext().getInitParameter(AC.resolvingUncached.getURI()) != null ? Boolean.parseBoolean(servletConfig.getServletContext().getInitParameter(AC.resolvingUncached.getURI())) : true, @@ -355,14 +356,6 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti servletConfig.getServletContext().getInitParameter(ORCID.clientID.getURI()) != null ? servletConfig.getServletContext().getInitParameter(ORCID.clientID.getURI()) : null, servletConfig.getServletContext().getInitParameter(ORCID.clientSecret.getURI()) != null ? servletConfig.getServletContext().getInitParameter(ORCID.clientSecret.getURI()) : null ); - - URI contextDatasetURI = servletConfig.getServletContext().getInitParameter(LDHC.contextDataset.getURI()) != null ? new URI(servletConfig.getServletContext().getInitParameter(LDHC.contextDataset.getURI())) : null; - if (contextDatasetURI == null) - { - if (log.isErrorEnabled()) log.error("Context dataset URI '{}' not configured", LDHC.contextDataset.getURI()); - throw new ConfigurationException(LDHC.contextDataset); - } - this.contextDataset = getDataset(servletConfig.getServletContext(), contextDatasetURI); } /** @@ -374,6 +367,7 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti * @param cacheModelLoads true if model loads should be cached * @param preemptiveAuth true if HTTP Basic auth credentials should be sent preemptively * @param locationMapper Jena's LocationMapper instance + * @param contextDatasetURIString location of the context dataset * @param stylesheet stylesheet URI * @param cacheStylesheet true if stylesheet should be cached * @param resolvingUncached true if XLST processor should dereference URLs that are not cached @@ -418,7 +412,8 @@ public Application(@Context ServletConfig servletConfig) throws URISyntaxExcepti */ public Application(final ServletConfig servletConfig, final MediaTypes mediaTypes, final Integer maxGetRequestSize, final boolean cacheModelLoads, final boolean preemptiveAuth, - final LocationMapper locationMapper, final Source stylesheet, final boolean cacheStylesheet, final boolean resolvingUncached, + final LocationMapper locationMapper, final String contextDatasetURIString, + final Source stylesheet, final boolean cacheStylesheet, final boolean resolvingUncached, final String clientKeyStoreURIString, final String clientKeyStorePassword, final String secretaryCertAlias, final String clientTrustStoreURIString, final String clientTrustStorePassword, @@ -433,6 +428,13 @@ public Application(final ServletConfig servletConfig, final MediaTypes mediaType final String googleClientID, final String googleClientSecret, final String orcidClientID, final String orcidClientSecret) { + if (contextDatasetURIString == null) + { + if (log.isErrorEnabled()) log.error("Context dataset URI '{}' not configured", LDHC.contextDataset.getURI()); + throw new ConfigurationException(LDHC.contextDataset); + } + this.contextDatasetURI = URI.create(contextDatasetURIString); + if (clientKeyStoreURIString == null) { if (log.isErrorEnabled()) log.error("Client key store ({}) not configured", LDHC.clientKeyStore.getURI()); @@ -664,6 +666,8 @@ public Application(final ServletConfig servletConfig, final MediaTypes mediaType try { + this.contextDataset = getDataset(servletConfig.getServletContext(), contextDatasetURI); + keyStore = KeyStore.getInstance("PKCS12"); try (FileInputStream keyStoreInputStream = new FileInputStream(new java.io.File(new URI(clientKeyStoreURIString)))) { @@ -1974,7 +1978,7 @@ public URI getUploadRoot() /** * Returns RDF dataset with LinkedDataHub application descriptions. - * + * * @return RDF dataset */ protected Dataset getContextDataset() @@ -1982,19 +1986,81 @@ protected Dataset getContextDataset() return contextDataset; } + /** + * Returns the URI of the context dataset file. + * + * @return context dataset URI + */ + protected URI getContextDatasetURI() + { + return contextDatasetURI; + } + /** * Returns RDF model with LinkedDataHub application descriptions. - * - * @return RDF model + * This method returns a union of all named graphs from the context dataset. + * + * @return RDF model (read-only union of all named graphs) */ public Model getContextModel() { - return ModelFactory.createModelForGraph(new GraphReadOnly(getContextDataset().getDefaultModel().getGraph())); + return ModelFactory.createModelForGraph(new GraphReadOnly(getContextDataset().getUnionModel().getGraph())); + } + + /** + * Retrieves a dataspace model by application from the context dataset. + * + * @param application the dataspace application + * @return the model for the specified dataspace, or null if not found + */ + public Model getDataspaceModel(com.atomgraph.linkeddatahub.apps.model.Application application) + { + if (application == null) throw new IllegalArgumentException("Application cannot be null"); + return ModelFactory.createModelForGraph(new GraphReadOnly(getContextDataset().getNamedModel(application.getURI()).getGraph())); + } + + /** + * Updates a dataspace by replacing its named graph with a new Model. + * This is a template method that can be overridden by subclasses to provide alternative implementations + * (e.g., HTTP-based updates using GraphStoreClient to a remote triplestore). + * + * Default implementation uses file-based operations via SystemConfigFileManager. + * + * @param application the dataspace application to update + * @param newModel the new RDF model to replace the existing named graph + * @throws IOException if an I/O error occurs + */ + public void updateApp(com.atomgraph.linkeddatahub.apps.model.Application application, Model newModel) throws IOException + { + if (application == null) throw new IllegalArgumentException("Application cannot be null"); + if (newModel == null) throw new IllegalArgumentException("Model cannot be null"); + + synchronized (getContextDataset()) + { + String dataspaceURI = application.getURI(); + + // Update the named graph in the dataset + getContextDataset().removeNamedModel(dataspaceURI). + addNamedModel(dataspaceURI, newModel); + + // Write the updated dataset back to file using RDFDataMgr + // Support both absolute file:// URIs and relative webapp paths (like getDataset does) + try (java.io.OutputStream out = (getContextDatasetURI().isAbsolute() ? + new FileOutputStream(new java.io.File(getContextDatasetURI())) : + new FileOutputStream(getServletConfig().getServletContext().getRealPath(getContextDatasetURI().toString())))) + { + Lang lang = RDFDataMgr.determineLang(getContextDatasetURI().toString(), null, null); + if (lang == null) throw new IOException("Could not determine RDF format from dataset URI: " + getContextDatasetURI().toString()); + + RDFDataMgr.write(out, getContextDataset(), lang); + if (log.isInfoEnabled()) log.info("Updated dataspace <{}> in context dataset: {}", dataspaceURI, getContextDatasetURI()); + } + } } /** * Returns true if configured to invalidate HTTP proxy cache of triplestore results. - * + * * @return true if invalidated */ public boolean isInvalidateCache() diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java b/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java new file mode 100644 index 000000000..ea051eec8 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/Settings.java @@ -0,0 +1,220 @@ +/** + * 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; + +import com.atomgraph.core.util.ModelUtils; +import com.atomgraph.linkeddatahub.apps.model.Application; +import com.atomgraph.linkeddatahub.server.io.ValidatingModelProvider; +import com.atomgraph.linkeddatahub.vocabulary.LAPP; +import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.WebApplicationException; +import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.Request; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.MessageBodyReader; +import jakarta.ws.rs.ext.Providers; +import java.io.IOException; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.DatasetFactory; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.update.UpdateAction; +import org.apache.jena.update.UpdateRequest; +import org.apache.jena.vocabulary.RDF; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JAX-RS resource for updating dataspace settings. + * Handles POST requests with RDF data representing the updated dataspace configuration. + * + * @author Martynas Jusevičius {@literal } + */ +public class Settings +{ + private static final Logger log = LoggerFactory.getLogger(Settings.class); + + private final Application application; + private final com.atomgraph.linkeddatahub.Application system; + private final Providers providers; + private final Request request; + + /** + * Constructs the Settings endpoint. + * + * @param application the current dataspace application + * @param system the system application + * @param providers JAX-RS provider registry + * @param request JAX-RS request context + */ + @Inject + public Settings(Application application, com.atomgraph.linkeddatahub.Application system, @Context Providers providers, @Context Request request) + { + this.application = application; + this.system = system; + this.providers = providers; + this.request = request; + } + + /** + * Retrieves the dataspace settings from the context dataset. + * + * @return the dataspace resource as RDF + */ + @GET + public Response get() + { + Model dataspaceModel = getSystem().getDataspaceModel(getApplication()); + + if (dataspaceModel == null || dataspaceModel.isEmpty()) + { + if (log.isWarnEnabled()) log.warn("No settings found for dataspace <{}> in context dataset", getApplication().getURI()); + return Response.status(Response.Status.NOT_FOUND).build(); + } + + if (log.isDebugEnabled()) log.debug("Retrieved settings for dataspace <{}>", getApplication().getURI()); + + EntityTag entityTag = getEntityTag(dataspaceModel); + Response.ResponseBuilder rb = getRequest().evaluatePreconditions(entityTag); + if (rb != null) return rb.build(); + + return Response.ok(dataspaceModel). + tag(entityTag). + build(); + } + + /** + * Updates the dataspace settings by executing a SPARQL UPDATE request. + * Accepts SPARQL update as the request body which is executed in the context of the dataspace named graph. + * + * @param updateRequest SPARQL update + * @return response indicating success or failure + * @throws java.io.IOException + */ + @PATCH + public Response patch(UpdateRequest updateRequest) throws IOException + { + if (updateRequest == null) throw new BadRequestException("SPARQL update not specified"); + + if (log.isDebugEnabled()) log.debug("PATCH request for dataspace <{}>", getApplication().getURI()); + if (log.isDebugEnabled()) log.debug("PATCH update string: {}", updateRequest.toString()); + + Model dataspaceModel = getSystem().getDataspaceModel(getApplication()); + if (dataspaceModel == null || dataspaceModel.isEmpty()) + throw new NotFoundException("No settings found for dataspace <" + getApplication().getURI() + "> in context dataset"); + + // Create a mutable copy since getDataspaceModel() returns a read-only view + Model mutableModel = ModelFactory.createDefaultModel().add(dataspaceModel); + + // Execute the SPARQL UPDATE on the dataspace model in memory + Dataset dataset = DatasetFactory.wrap(mutableModel); + UpdateAction.execute(updateRequest, dataset); + + // Verify the application resource still exists with correct type after PATCH + Resource appResource = ResourceFactory.createResource(getApplication().getURI()); + if (!mutableModel.contains(appResource, RDF.type, LAPP.EndUserApplication)) + { + if (log.isWarnEnabled()) log.warn("PATCH removed application resource or its type for <{}>", getApplication().getURI()); + throw new WebApplicationException("PATCH cannot remove the application resource or its type", UNPROCESSABLE_ENTITY.getStatusCode()); // 422 Unprocessable Entity + } + + // validate the updated model + validate(mutableModel); + + // Write the updated model back to the context dataset file + getSystem().updateApp(getApplication(), mutableModel); + + if (log.isInfoEnabled()) log.info("Updated settings for dataspace <{}> via PATCH", getApplication().getURI()); + + return Response.noContent().build(); + } + + /** + * Returns the current dataspace application. + * + * @return the application + */ + public Application getApplication() + { + return application; + } + + /** + * Returns the system application. + * + * @return the system application + */ + public com.atomgraph.linkeddatahub.Application getSystem() + { + return system; + } + + /** + * Returns the JAX-RS providers registry. + * + * @return the providers + */ + public Providers getProviders() + { + return providers; + } + + /** + * Validates model against SPIN and SHACL constraints. + * + * @param model RDF model + * @return validated model + */ + public Model validate(Model model) + { + MessageBodyReader reader = getProviders().getMessageBodyReader(Model.class, null, null, com.atomgraph.core.MediaType.APPLICATION_NTRIPLES_TYPE); + if (reader instanceof ValidatingModelProvider validatingModelProvider) return validatingModelProvider.processRead(model); + + throw new InternalServerErrorException("Could not obtain ValidatingModelProvider instance"); + } + + /** + * Returns the JAX-RS request context. + * + * @return the request + */ + public Request getRequest() + { + return request; + } + + /** + * Generates an ETag for the given model. + * + * @param model RDF model + * @return entity tag + */ + public EntityTag getEntityTag(Model model) + { + return new EntityTag(Long.toHexString(ModelUtils.hashModel(model))); + } + +} 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 430427a60..451dc874f 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 @@ -25,6 +25,7 @@ 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.Graph; import com.atomgraph.linkeddatahub.resource.acl.Access; @@ -249,6 +250,17 @@ public Class getUninstallPackageEndpoint() return getProxyClass().orElse(UninstallPackage.class); } + /** + * Returns the endpoint for updating dataspace settings. + * + * @return endpoint resource + */ + @Path("settings") + public Class getSettingsEndpoint() + { + return getProxyClass().orElse(Settings.class); + } + /** * Returns the default JAX-RS resource class. * diff --git a/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl b/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl index 819120f9b..887c75f9c 100644 --- a/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl +++ b/src/main/resources/com/atomgraph/linkeddatahub/lapp.ttl @@ -23,7 +23,21 @@ # PROPERTIES +:origin a owl:ObjectProperty, owl:FunctionalProperty, owl:InverseFunctionalProperty ; + rdfs:domain :Application ; + rdfs:range rdfs:Resource ; + rdfs:label "Origin" ; + rdfs:comment "The origin URI of an application, which serves as the base URI for all resources in the application's dataspace" ; + rdfs:isDefinedBy : . + +:application a owl:ObjectProperty, owl:FunctionalProperty ; + rdfs:range :Application ; + rdfs:label "Application" ; + rdfs:comment "Links a resource to an application" ; + rdfs:isDefinedBy : . + :adminApplication a owl:ObjectProperty, owl:FunctionalProperty, owl:InverseFunctionalProperty ; + rdfs:subPropertyOf :application ; owl:inverseOf :endUserApplication ; rdfs:domain :EndUserApplication ; rdfs:range :AdminApplication ; @@ -32,6 +46,7 @@ rdfs:isDefinedBy : . :endUserApplication a owl:ObjectProperty, owl:FunctionalProperty, owl:InverseFunctionalProperty ; + rdfs:subPropertyOf :application ; owl:inverseOf :adminApplication ; rdfs:domain :AdminApplication ; rdfs:range :EndUserApplication ; diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css index 4a8f2e490..a19d99fc9 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/css/bootstrap.css @@ -9,6 +9,7 @@ body.embed { padding-top: 0; } .btn.dropdown-toggle.btn-apps { background-image: url('../icons/ic_apps_white_24px.svg'); background-position: center center; background-repeat: no-repeat; width: 34px; height: 34px; } .btn.dropdown-toggle.btn-settings { background-image: url('../icons/settings_white_24dp.svg'); background-position: center center; background-repeat: no-repeat; width: 34px; height: 34px; } .btn.dropdown-toggle.btn-agent { background-image: url('../icons/ic_account_circle_white_24px.svg'); background-position: center center; background-repeat: no-repeat; width: 34px; height: 34px; } +.dropdown-menu .btn-app-settings { background-color: inherit; display: block; text-align: left; width: 100%; padding-left: 20px; } .navbar-form .input-append { margin-top: 10px; } .navbar-form .input-append select { margin-top: 0; height: 34px; } .navbar-form .btn-search { background-image: url('../icons/ic_search_white_24px.svg'); background-position: center center; background-repeat: no-repeat; width: 34px; height: 34px; } 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 9e71186f8..ee16a428c 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 @@ -457,7 +457,8 @@ WHERE @@ -514,7 +515,7 @@ WHERE - + @@ -652,13 +653,13 @@ WHERE + - 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 4f63577a7..0ea02080d 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 @@ -362,7 +362,11 @@ LIMIT 10 -

Ask for access to a restricted resource by making a request. It will be reviewed by the application's administrators.

+

+ + + +