diff --git a/backend/src/main/java/com/synbiohub/sbh3/controllers/AdminController.java b/backend/src/main/java/com/synbiohub/sbh3/controllers/AdminController.java index 2828a1c8..d4baf3e0 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/controllers/AdminController.java +++ b/backend/src/main/java/com/synbiohub/sbh3/controllers/AdminController.java @@ -173,6 +173,60 @@ public String updateMailSettings(@RequestParam Map allParams, Ht return "Mail settings updated"; } + /** + * Triplestore endpoints and auth mode from merged config ({@code data/config.local.json} overrides + * {@code src/main/resources/config.json} via {@link ConfigUtil#get(String)}). + */ + @Operation(summary = "Get database endpoints", description = "Returns sparqlEndpoint, graphStoreEndpoint, and triplestoreAuth from merged config.") + @GetMapping(value = "/admin/database", produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public String getDatabaseConfig() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode out = mapper.createObjectNode(); + JsonNode sparqlEndpoint = ConfigUtil.get("sparqlEndpoint"); + JsonNode graphStoreEndpoint = ConfigUtil.get("graphStoreEndpoint"); + JsonNode triplestoreAuth = ConfigUtil.get("triplestoreAuth"); + out.put("sparqlEndpoint", sparqlEndpoint != null && !sparqlEndpoint.isNull() + ? sparqlEndpoint.asText("") : ""); + out.put("graphStoreEndpoint", graphStoreEndpoint != null && !graphStoreEndpoint.isNull() + ? graphStoreEndpoint.asText("") : ""); + String auth = triplestoreAuth != null && !triplestoreAuth.isNull() + ? triplestoreAuth.asText("digest") : "digest"; + if (!"basic".equalsIgnoreCase(auth) && !"digest".equalsIgnoreCase(auth)) { + auth = "digest"; + } + out.put("triplestoreAuth", auth.toLowerCase()); + return mapper.writeValueAsString(out); + } + + /** + * Persists {@code sparqlEndpoint}, {@code graphStoreEndpoint}, and {@code triplestoreAuth} + * to {@code data/config.local.json}. + */ + @Operation(summary = "Update database endpoints", description = "Persists sparqlEndpoint, graphStoreEndpoint, and triplestoreAuth to config.local.json.") + @PostMapping(value = "/admin/database") + @ResponseBody + public String updateDatabaseConfig(@RequestParam Map allParams) throws IOException { + String sparqlEndpoint = allParams.getOrDefault("sparqlEndpoint", ""); + String graphStoreEndpoint = allParams.getOrDefault("graphStoreEndpoint", ""); + String triplestoreAuth = allParams.getOrDefault("triplestoreAuth", "digest").trim().toLowerCase(); + if (!"basic".equals(triplestoreAuth) && !"digest".equals(triplestoreAuth)) { + return "Invalid triplestoreAuth; use basic or digest"; + } + + ObjectNode local = (ObjectNode) ConfigUtil.getLocaljson(); + local.put("sparqlEndpoint", sparqlEndpoint); + local.put("graphStoreEndpoint", graphStoreEndpoint); + local.put("triplestoreAuth", triplestoreAuth); + + ObjectMapper mapper = new ObjectMapper(); + mapper.writerWithDefaultPrettyPrinter() + .writeValue(new File("data/config.local.json"), local); + ConfigUtil.refreshLocalJson(); + + return "Database configuration updated"; + } + //TODO: get admin plugins needs to be public, post admin plugins need to be admin only @Operation(summary = "Get plugins", description = "Returns all configured plugins.") @PreAuthorize("permitAll()") diff --git a/backend/src/main/java/com/synbiohub/sbh3/controllers/SubmitController.java b/backend/src/main/java/com/synbiohub/sbh3/controllers/SubmitController.java index 17ac9f9c..a11462b3 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/controllers/SubmitController.java +++ b/backend/src/main/java/com/synbiohub/sbh3/controllers/SubmitController.java @@ -15,6 +15,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.net.URISyntaxException; import java.util.Map; @Tag(name = "Submissions", description = "Endpoints for creating and submitting registry objects") @@ -36,7 +37,7 @@ public class SubmitController { @PreAuthorize("hasAnyAuthority('USER', 'CURATOR', 'ADMIN')") public ResponseEntity submit( @ModelAttribute SubmitPayload allParams, - @RequestPart(value = "file", required = false) MultipartFile file) throws IOException, SBOLValidationException { + @RequestPart(value = "file", required = false) MultipartFile file) throws IOException, SBOLValidationException, URISyntaxException { return submitService.submit(allParams, file); } diff --git a/backend/src/main/java/com/synbiohub/sbh3/dao/SparqlService.java b/backend/src/main/java/com/synbiohub/sbh3/dao/SparqlService.java index 423a8b7a..fe93c957 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/dao/SparqlService.java +++ b/backend/src/main/java/com/synbiohub/sbh3/dao/SparqlService.java @@ -14,6 +14,7 @@ import org.springframework.stereotype.Service; import java.io.IOException; +import java.net.URISyntaxException; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; @@ -32,28 +33,28 @@ public class SparqlService { * Virtuoso DELETE templates return one row per batch; loop until nothing remains. * Legacy {@code sparql.deleteStaggered}. */ - public void deleteCollection(Map params, String graphUri) throws IOException { + public void deleteCollection(Map params, String graphUri) throws IOException, URISyntaxException { deleteStaggered(sparqlRepository.REMOVE_COLLECTION_SPARQL, params, graphUri); } - public void delete(Map params, String graphUri) throws IOException { + public void delete(Map params, String graphUri) throws IOException, URISyntaxException { deleteStaggered(sparqlRepository.REMOVE_SPARQL, params, graphUri); } - public void uploadGraphStore(String graphUri, Path file) throws IOException { + public void uploadGraphStore(String graphUri, Path file) throws IOException, URISyntaxException { sparqlRepository.save(graphUri, file); } - public void uploadAttachment(Map params, String graphUri) throws IOException { + public void uploadAttachment(Map params, String graphUri) throws IOException, URISyntaxException { String query = new SPARQLQuery(sparqlRepository.ATTACHMENT_UPDATE_SPARQL).loadTemplate(params); sparqlRepository.update(query, graphUri, false); } - public void update(String query, String graphUri, boolean jsonResults) throws IOException { + public void update(String query, String graphUri, boolean jsonResults) throws IOException, URISyntaxException { sparqlRepository.update(query, graphUri, jsonResults); } - private void deleteStaggered(String queryTemplate, Map params, String graphUri) throws IOException { + private void deleteStaggered(String queryTemplate, Map params, String graphUri) throws IOException, URISyntaxException { String query = new SPARQLQuery(queryTemplate).loadTemplate(params); while (true) { String raw = sparqlRepository.update(query, graphUri, true); @@ -90,7 +91,7 @@ public Map loadAttachmentSources(String collectionUri, String gr return sources; } - public void attachUpload(Map params, String graphUri, boolean jsonResults) throws IOException { + public void attachUpload(Map params, String graphUri, boolean jsonResults) throws IOException, URISyntaxException { String query = new SPARQLQuery(sparqlRepository.ATTACH_UPLOAD_SPARQL).loadTemplate(params); update(query, graphUri, false); } @@ -99,7 +100,7 @@ public void attachUpload(Map params, String graphUri, boolean js * Replaces hash/size on an existing attachment when re-uploading the same {@code file:} source. */ public void updateAttachment(String graphUri, String attachmentUri, String uploadHash, long size) - throws IOException { + throws IOException, URISyntaxException { String query = new SPARQLQuery(sparqlRepository.UPDATE_ATTACHMENT_SPARQL).loadTemplate(Map.of( "attachmentURI", attachmentUri, "attachmentSource", attachmentUri + "/download", diff --git a/backend/src/main/java/com/synbiohub/sbh3/repo/SparqlRepository.java b/backend/src/main/java/com/synbiohub/sbh3/repo/SparqlRepository.java index d51b66e0..7f9d624a 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/repo/SparqlRepository.java +++ b/backend/src/main/java/com/synbiohub/sbh3/repo/SparqlRepository.java @@ -4,14 +4,19 @@ import com.synbiohub.sbh3.utils.ConfigUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.ContextBuilder; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.classic.methods.HttpPost; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.protocol.HttpClientContext; import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -21,6 +26,8 @@ import org.springframework.web.server.ResponseStatusException; import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -45,6 +52,9 @@ public class SparqlRepository { public static final String SHARED_VIEW_SPARQL = "src/main/java/com/synbiohub/sbh3/sparql/GetSharedCanView.sparql"; public static final String TOPLEVEL_METADATA_SPARQL = "src/main/java/com/synbiohub/sbh3/sparql/GetTopLevelMetadata.sparql"; + private static final String AUTH_BASIC = "basic"; + private static final String AUTH_DIGEST = "digest"; + private final RestClient restClient; @@ -85,11 +95,12 @@ public ResponseEntity postJson(String uri, Object requestBody, Class r } /** - * POST a SPARQL update to sparql-auth with digest auth (not preemptive basic auth). + * POST a SPARQL update to sparql-auth. + * Auth mode is {@code triplestoreAuth}: {@code digest} (Virtuoso) or {@code basic} (sbol-db). * * @param jsonResults when {@code true}, requests {@code application/sparql-results+json} */ - public String update(String query, String graphUri, boolean jsonResults) throws IOException { + public String update(String query, String graphUri, boolean jsonResults) throws IOException, URISyntaxException { StringBuilder url = new StringBuilder(sparqlAuthEndpoint()); url.append("?query=").append(URLEncoder.encode(query, StandardCharsets.UTF_8)); url.append("&default-graph-uri=").append(URLEncoder.encode(graphUri, StandardCharsets.UTF_8)); @@ -98,43 +109,40 @@ public String update(String query, String graphUri, boolean jsonResults) throws .append(URLEncoder.encode("application/sparql-results+json", StandardCharsets.UTF_8)); } - try (CloseableHttpClient client = virtuosoDigestClient()) { - HttpPost post = new HttpPost(url.toString()); - return client.execute(post, response -> { - int code = response.getCode(); - if (code >= 300) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, - "SPARQL update failed (" + code + "): " + readResponseBody(response)); - } - return readResponseBody(response); - }); - } + HttpPost post = new HttpPost(url.toString()); + return executeAuthenticated(post, response -> { + int code = response.getCode(); + if (code >= 300) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, + "SPARQL update failed (" + code + "): " + readResponseBody(response)); + } + return readResponseBody(response); + }); } /** - * POST RDF/XML to the Virtuoso graph store. Uses digest auth (not preemptive basic auth), - * matching legacy {@code sparql.uploadSmallFile}. + * POST RDF/XML to the graph store endpoint. + * Auth mode is {@code triplestoreAuth}: {@code digest} (Virtuoso) or {@code basic} (sbol-db). */ - public void save(String graphUri, Path file) throws IOException { + public void save(String graphUri, Path file) throws IOException, URISyntaxException { String endpoint = ConfigUtil.get("graphStoreEndpoint").asText(); String url = endpoint + (endpoint.contains("?") ? "&" : "?") + "graph-uri=" + URLEncoder.encode(graphUri, StandardCharsets.UTF_8); byte[] body = Files.readAllBytes(file); - try (CloseableHttpClient client = virtuosoDigestClient()) { - HttpPost post = new HttpPost(url); - post.setHeader(HttpHeaders.CONTENT_TYPE, "application/rdf+xml"); - post.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_XML)); - client.execute(post, response -> { - int code = response.getCode(); - if (code >= 300) { - throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, - "Graph store upload failed (" + code + "): " + readResponseBody(response)); - } - return null; - }); - } + HttpPost post = new HttpPost(url); + post.setHeader(HttpHeaders.CONTENT_TYPE, "application/rdf+xml"); + post.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_XML)); + executeAuthenticated(post, response -> { + int code = response.getCode(); + if (code >= 300) { + throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, + "Graph store upload failed (" + code + "): " + readResponseBody(response)); + } + EntityUtils.consume(response.getEntity()); + return null; + }); } private String sparqlAuthEndpoint() throws IOException { @@ -149,17 +157,54 @@ private String sparqlAuthEndpoint() throws IOException { return base.replaceAll("/sparql/?$", "/sparql-auth"); } - /** HttpClient configured for Virtuoso digest auth (waits for 401 challenge). */ - private static CloseableHttpClient virtuosoDigestClient() throws IOException { + /** + * Resolves {@code triplestoreAuth} from config ({@code basic} or {@code digest}; default {@code digest}). + */ + private static String resolveTriplestoreAuth() throws IOException { + JsonNode configured = ConfigUtil.get("triplestoreAuth"); + if (configured == null || configured.isNull()) { + return AUTH_DIGEST; + } + String value = configured.asText("").trim().toLowerCase(); + if (AUTH_BASIC.equals(value)) { + return AUTH_BASIC; + } + return AUTH_DIGEST; + } + + /** + * Authenticated POST for triplestore write endpoints. + *
    + *
  • {@code basic} — preemptive Basic auth for sbol-db (avoids 401 + large-body disconnects).
  • + *
  • {@code digest} — challenge Digest auth for Virtuoso (legacy behavior).
  • + *
+ */ + private T executeAuthenticated( + HttpPost post, + HttpClientResponseHandler handler) throws IOException, URISyntaxException { + UsernamePasswordCredentials credentials = new UsernamePasswordCredentials( + ConfigUtil.get("username").asText(), + ConfigUtil.get("password").asText().toCharArray()); + BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials( - new AuthScope(null, -1), - new UsernamePasswordCredentials( - ConfigUtil.get("username").asText(), - ConfigUtil.get("password").asText().toCharArray())); - return HttpClients.custom() + credsProvider.setCredentials(new AuthScope(null, -1), credentials); + + String authMode = resolveTriplestoreAuth(); + try (CloseableHttpClient client = HttpClients.custom() .setDefaultCredentialsProvider(credsProvider) - .build(); + .build()) { + if (AUTH_BASIC.equals(authMode)) { + URI uri = post.getUri(); + HttpHost target = HttpHost.create(uri); + HttpClientContext context = ContextBuilder.create() + .useCredentialsProvider(credsProvider) + .preemptiveBasicAuth(target, credentials) + .build(); + return client.execute(target, post, context, handler); + } + // Digest (Virtuoso): wait for WWW-Authenticate challenge, then retry with Digest. + return client.execute(post, handler); + } } private String readResponseBody(org.apache.hc.core5.http.ClassicHttpResponse response) diff --git a/backend/src/main/java/com/synbiohub/sbh3/services/AttachmentService.java b/backend/src/main/java/com/synbiohub/sbh3/services/AttachmentService.java index dd23ba3a..93bb9485 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/services/AttachmentService.java +++ b/backend/src/main/java/com/synbiohub/sbh3/services/AttachmentService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import java.io.IOException; +import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -36,7 +37,7 @@ public class AttachmentService { * For each non-SBOL attachment: gzip+hash to {@code ./uploads/}, insert or update triples, * then rewrite {@code file:filename} placeholders to real attachment URIs. */ - public void uploadAttachments(SubmitPayload payload, String graphUri) throws IOException { + public void uploadAttachments(SubmitPayload payload, String graphUri) throws IOException, URISyntaxException { String collectionUri = payload.getCollectionUri(); String baseUri = attachmentBaseUri(payload); Map existingSources = sparqlService.loadAttachmentSources(collectionUri, graphUri); @@ -86,7 +87,7 @@ private String attachmentBaseUri(SubmitPayload payload) throws IOException { /** Inserts attachment triples and links them to the root collection ({@code AttachUpload.sparql}). */ private String addAttachmentToTopLevel(String graphUri, String baseUri, String topLevelUri, String name, String uploadHash, long size, String attachmentType, - String owner) throws IOException { + String owner) throws IOException, URISyntaxException { String displayId = "attachment_" + UUID.randomUUID().toString().replace("-", ""); String persistentIdentity = baseUri + "/" + displayId; String version = "1"; diff --git a/backend/src/main/java/com/synbiohub/sbh3/services/DownloadService.java b/backend/src/main/java/com/synbiohub/sbh3/services/DownloadService.java index e06acbb9..821b37a6 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/services/DownloadService.java +++ b/backend/src/main/java/com/synbiohub/sbh3/services/DownloadService.java @@ -537,6 +537,8 @@ private SBOLDocument readModelAsSbolDocument(Model model) { if (model == null || model.isEmpty()) { return null; } + // Drop invalid xmlns (e.g. sbol-db's its=…/its) that fail SBOL sbol-10106. + applyLegacySynbiohubRdfXmlPrefixes(model); var modelOutput = new ByteArrayOutputStream(); RDFDataMgr.write(modelOutput, model, RDFFormat.RDFXML_PLAIN); try { diff --git a/backend/src/main/java/com/synbiohub/sbh3/services/SearchService.java b/backend/src/main/java/com/synbiohub/sbh3/services/SearchService.java index 886a3696..0a72b09f 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/services/SearchService.java +++ b/backend/src/main/java/com/synbiohub/sbh3/services/SearchService.java @@ -329,22 +329,22 @@ public byte[] SPARQLRDFXMLQuery(String query, String resourceUriForDefaultGraph) RestTemplate restTemplate = new RestTemplate(); HashMap params = new HashMap<>(); params.put("query", query); - params.put("format", "application/rdf+xml"); String user = usernameFromUserResourceUri(resourceUriForDefaultGraph); boolean privateResource = user != null && !user.isBlank(); String url; if (privateResource) { - url = ConfigUtil.get("sparqlEndpoint").asText() + "?query={query}&format={format}"; + url = ConfigUtil.get("sparqlEndpoint").asText() + "?query={query}"; } else { params.put("default-graph-uri", ConfigUtil.get("defaultGraph").asText()); url = ConfigUtil.get("sparqlEndpoint").asText() - + "?default-graph-uri={default-graph-uri}&query={query}&format={format}"; + + "?default-graph-uri={default-graph-uri}&query={query}"; } + // Negotiate RDF/XML via Accept (avoids format=application/rdf+xml '+' / encoding issues with sbol-db). HttpHeaders headers = new HttpHeaders(); - headers.add("Accept", "*/*"); + headers.setAccept(List.of(MediaType.parseMediaType("application/rdf+xml"))); HttpEntity entity = new HttpEntity<>(headers); ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, byte[].class, params); @@ -407,13 +407,13 @@ private String usernameFromUserResourceUri(String resourceUri) throws IOExceptio /** * Hit the /sparql endpoint on other SBH instances (Web of Registries). - * Aligns with {@link #SPARQLRDFXMLQuery}: CONSTRUCT needs {@code format} + default graph + permissive Accept; + * Aligns with {@link #SPARQLRDFXMLQuery}: CONSTRUCT via Accept RDF/XML + default graph; * {@code String} responses often end up null for RDF MIME types. */ public byte[] queryOldSBHSparqlEndpoint(String WOREndpoint, String query) throws IOException { RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); - headers.add("Accept", "*/*"); + headers.setAccept(List.of(MediaType.parseMediaType("application/rdf+xml"))); String base = WOREndpoint.endsWith("/") ? WOREndpoint.substring(0, WOREndpoint.length() - 1) : WOREndpoint; String remoteDefaultGraph = base + "/public"; @@ -421,9 +421,8 @@ public byte[] queryOldSBHSparqlEndpoint(String WOREndpoint, String query) throws HashMap params = new HashMap<>(); params.put("default-graph-uri", remoteDefaultGraph); params.put("query", query); - params.put("format", "application/rdf+xml"); - String url = base + "/sparql?default-graph-uri={default-graph-uri}&query={query}&format={format}"; + String url = base + "/sparql?default-graph-uri={default-graph-uri}&query={query}"; HttpEntity entity = new HttpEntity<>(headers); ResponseEntity rest; diff --git a/backend/src/main/java/com/synbiohub/sbh3/services/SubmitService.java b/backend/src/main/java/com/synbiohub/sbh3/services/SubmitService.java index c0249336..e1a46f8d 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/services/SubmitService.java +++ b/backend/src/main/java/com/synbiohub/sbh3/services/SubmitService.java @@ -22,6 +22,7 @@ import org.springframework.web.server.ResponseStatusException; import java.io.IOException; +import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -54,7 +55,7 @@ public class SubmitService { /** * Main submit entry point. Each step mutates {@code payload} in place. */ - public ResponseEntity submit(SubmitPayload allParams, MultipartFile file) throws IOException, SBOLValidationException { + public ResponseEntity submit(SubmitPayload allParams, MultipartFile file) throws IOException, SBOLValidationException, URISyntaxException { SubmitPayload payload = parse(allParams, file); collectionService.sanitize(payload); submitPluginService.applySubmitPlugin(payload); // optional transform of uploaded file @@ -113,7 +114,7 @@ private static Map webOfRegistriesMap() throws IOException { * Overwrite mode: stagger-delete all objects under the collection URI prefix, then remove * the collection itself. Mirrors {@code submit.js} after prepareSubmission succeeds. */ - private void prepare(SubmitPayload payload) throws IOException { + private void prepare(SubmitPayload payload) throws IOException, URISyntaxException { if (!"1".equals(payload.getOverwriteMerge())) { return; } @@ -161,7 +162,7 @@ private void notifyExplorerRemoveCollection(String collectionUri, String uriPref * Posts prepared SBOL XML to Virtuoso, stores attachment binaries, rewrites {@code file:} * sources in the graph, then deletes temp files. */ - private void upload(SubmitPayload payload) throws IOException { + private void upload(SubmitPayload payload) throws IOException, URISyntaxException { String graphUri = collectionService.graphUriForCollection(payload.getCollectionUri(), payload); String resultPath = payload.getResultFilePath(); if (resultPath == null || resultPath.isBlank()) { diff --git a/backend/src/main/java/com/synbiohub/sbh3/sparql/searchCount.sparql b/backend/src/main/java/com/synbiohub/sbh3/sparql/searchCount.sparql index 43775ffb..735c93d9 100644 --- a/backend/src/main/java/com/synbiohub/sbh3/sparql/searchCount.sparql +++ b/backend/src/main/java/com/synbiohub/sbh3/sparql/searchCount.sparql @@ -16,10 +16,11 @@ PREFIX so: PREFIX bench: PREFIX genbank: -select (sum(?tempcount) as ?count) WHERE { +select (sum(?tempcount) as ?count) +$from +WHERE { { SELECT (count(distinct ?subject) as ?tempcount) - $from WHERE { $criteria diff --git a/backend/src/main/resources/config.json b/backend/src/main/resources/config.json index ddd59681..7a21db94 100644 --- a/backend/src/main/resources/config.json +++ b/backend/src/main/resources/config.json @@ -18,6 +18,7 @@ "graphPrefix": "https://synbiohub.org/", "username": "dba", "password": "dba", + "triplestoreAuth": "digest", "virtuosoINI": "/etc/virtuoso-opensource-7/virtuoso.ini", "virtuosoDB": "/var/lib/virtuoso-opensource-7/db", "isql": "isql-vt", diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..741b27d4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + virtuoso: + image: tenforce/virtuoso:virtuoso7.2.5 + ports: + - "8890:8890" + volumes: + - virtuoso-data:/data + environment: + DBA_PASSWORD: dba + SPARQL_UPDATE: "true" + DEFAULT_GRAPH: "https://synbiohub.org/public" + healthcheck: + test: ["CMD-SHELL", "timeout 4 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/8890'"] + interval: 10s + timeout: 8s + retries: 8 + start_period: 90s + restart: unless-stopped + migrate: + image: ghcr.io/marpaia/sbol-db:v0.1.5 + environment: + DATABASE_URL: rocksdb:///var/lib/sbol-db/store.rocksdb + SBOL_DB_BACKEND: rocksdb + command: ["db", "migrate"] + volumes: + - sbol-db-data:/var/lib/sbol-db + restart: "no" + + sbol-db: + image: ghcr.io/marpaia/sbol-db:v0.1.5 + depends_on: + migrate: + condition: service_completed_successfully + environment: + DATABASE_URL: rocksdb:///var/lib/sbol-db/store.rocksdb + SBOL_DB_BACKEND: rocksdb + SBOL_DB_DATA_DIR: /var/lib/sbol-db + SBOL_DB_BLOB_ROOT: /var/lib/sbol-db/blobs + SBOL_DB_TEXT_INDEX_PATH: /var/lib/sbol-db/text-index + command: ["server", "--bind", "0.0.0.0:8080"] + ports: + - "8080:8080" + volumes: + - sbol-db-data:/var/lib/sbol-db + restart: unless-stopped + +volumes: + virtuoso-data: + sbol-db-data: + name: sbol-db-rocksdb-data \ No newline at end of file diff --git a/frontend/components/Admin/Content.js b/frontend/components/Admin/Content.js index 9b04f987..e525f37b 100644 --- a/frontend/components/Admin/Content.js +++ b/frontend/components/Admin/Content.js @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import styles from '../../styles/admin.module.css'; +import Database from './Database'; import Graphs from './Graphs'; import Log from './Log'; import Mail from './Mail'; @@ -17,6 +18,9 @@ export default function Content(properties) { const [content, setContent] = useState(properties.selected); useEffect(() => { switch (properties.selected) { + case 'database': + setContent(); + break; case 'graphs': setContent(); break; diff --git a/frontend/components/Admin/Database.js b/frontend/components/Admin/Database.js new file mode 100644 index 00000000..51209d1a --- /dev/null +++ b/frontend/components/Admin/Database.js @@ -0,0 +1,147 @@ +import axios from 'axios'; +import getConfig from 'next/config'; +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import useSWR, { mutate } from 'swr'; + +import styles from '../../styles/admin.module.css'; +import { addError } from '../../redux/actions'; +import Loading from '../Reusable/Loading'; +import ErrorMessage from './Reusable/ErrorMessage'; +import SaveButton from './Reusable/SaveButton'; + +const { publicRuntimeConfig } = getConfig(); + +export default function Database() { + const token = useSelector(state => state.user.token); + const dispatch = useDispatch(); + const { config, loading } = useDatabaseConfig(token, dispatch); + const [sparqlEndpoint, setSparqlEndpoint] = useState(''); + const [graphStoreEndpoint, setGraphStoreEndpoint] = useState(''); + const [triplestoreAuth, setTriplestoreAuth] = useState('digest'); + const [error, setError] = useState(''); + + useEffect(() => { + if (config) { + setSparqlEndpoint(config.sparqlEndpoint || ''); + setGraphStoreEndpoint(config.graphStoreEndpoint || ''); + setTriplestoreAuth(config.triplestoreAuth === 'basic' ? 'basic' : 'digest'); + } + }, [config]); + + if (loading) { + return ; + } + if (!config) { + return
Error
; + } + + return ( +
+

Database Configuration

+ {error && } + +

SPARQL Endpoint

+ setSparqlEndpoint(e.target.value)} + /> + +

Graph Store Endpoint

+ setGraphStoreEndpoint(e.target.value)} + /> + +

Triplestore Auth

+ + +
+ + saveDatabaseConfig( + sparqlEndpoint, + graphStoreEndpoint, + triplestoreAuth, + token, + setError, + dispatch + ) + } + /> +
+
+ ); +} + +const useDatabaseConfig = (token, dispatch) => { + const { data, error } = useSWR( + [`${publicRuntimeConfig.backend}/admin/database`, token, dispatch], + fetcher + ); + return { + config: data, + loading: !error && !data, + error + }; +}; + +const fetcher = (url, token, dispatch) => + axios + .get(url, { + headers: { + Accept: 'application/json', + 'X-authorization': token + } + }) + .then(response => response.data) + .catch(error => { + error.customMessage = 'Error fetching database config'; + error.fullUrl = url; + dispatch(addError(error)); + }); + +const saveDatabaseConfig = async ( + sparqlEndpoint, + graphStoreEndpoint, + triplestoreAuth, + token, + setError, + dispatch +) => { + const url = `${publicRuntimeConfig.backend}/admin/database`; + const parameters = new URLSearchParams(); + parameters.append('sparqlEndpoint', sparqlEndpoint); + parameters.append('graphStoreEndpoint', graphStoreEndpoint); + parameters.append('triplestoreAuth', triplestoreAuth); + + try { + const response = await axios.post(url, parameters, { + headers: { + Accept: 'text/plain', + 'X-authorization': token + } + }); + setError(''); + alert(typeof response.data === 'string' ? response.data : 'Saved'); + mutate([url, token, dispatch]); + } catch (error) { + const message = + typeof error.response?.data === 'string' + ? error.response.data + : error.message || 'Update failed'; + setError(message); + alert(`Could not save: ${message}`); + } +}; diff --git a/frontend/components/Admin/Menu.js b/frontend/components/Admin/Menu.js index ce75250b..c99174c1 100644 --- a/frontend/components/Admin/Menu.js +++ b/frontend/components/Admin/Menu.js @@ -9,6 +9,7 @@ import { faPlug, faProjectDiagram, faSearch, + faServer, faUsers } from '@fortawesome/free-solid-svg-icons'; @@ -25,6 +26,13 @@ export default function Menu(properties) { route="status" icon={faInfoCircle} /> + ${rootPredicate} ${rootId}`); columns.forEach(column => { if (!column.predicates && !column.bindTo) { @@ -86,8 +92,10 @@ function getTableQuery( subqueries.push(`OPTIONAL { ${topLevelId} ${predicate} ${predicateId}`); topLevelId = predicateId; if (isLastPredicate && groupResults) { + hasAggregate = true; + // STR() so IRI values concatenate on sbol-db/Oxigraph; GROUP BY added by caller. items.push( - `(group_concat(${predicateId}; separator=", ") AS ${predicateId})` + `(GROUP_CONCAT(DISTINCT STR(${predicateId}); separator=", ") AS ${predicateId})` ); } else { items.push(predicateId); @@ -97,10 +105,32 @@ function getTableQuery( } }); subqueries.push('}'); - return ( - 'SELECT\n' + - [...new Set(items)].join('\n') + - `${!nestedQuery ? '\n{' : '\n'}` + - subqueries.join('\n') - ); + const uniqueItems = [...new Set(items)]; + const groupByVars = hasAggregate + ? [...new Set(uniqueItems.flatMap(groupByVarsFromSelectItem))] + : []; + return { + query: + 'SELECT\n' + + uniqueItems.join('\n') + + `${!nestedQuery ? '\n{' : '\n'}` + + subqueries.join('\n'), + groupByVars + }; +} + +/** + * Non-aggregate SELECT items contribute their underlying variable(s) to GROUP BY. + * Aggregate projections (GROUP_CONCAT) are skipped. + */ +function groupByVarsFromSelectItem(item) { + if (/GROUP_CONCAT/i.test(item)) { + return []; + } + const asMatch = /^\((.+) AS (.+)\)$/.exec(item.trim()); + if (asMatch) { + const source = asMatch[1].trim(); + return source.startsWith('?') ? [source] : []; + } + return item.startsWith('?') ? [item] : []; } diff --git a/frontend/sparql/getMetadata.js b/frontend/sparql/getMetadata.js index 822111a1..6a73cd06 100644 --- a/frontend/sparql/getMetadata.js +++ b/frontend/sparql/getMetadata.js @@ -12,13 +12,13 @@ SELECT DISTINCT ?version ?name ?description - (GROUP_CONCAT(DISTINCT ?type; separator=", ") AS ?types) - (GROUP_CONCAT(DISTINCT ?uploadedBy; separator=", ") AS ?uploadedBys) - (GROUP_CONCAT(DISTINCT ?creator; separator=", ") AS ?creators) - (GROUP_CONCAT(DISTINCT ?created; separator=", ") AS ?createdDates) - (GROUP_CONCAT(DISTINCT ?modified; separator=", ") AS ?modifiedDates) - (GROUP_CONCAT(DISTINCT ?wasDerivedFrom; separator=", ") AS ?wasDerivedFroms) - (GROUP_CONCAT(DISTINCT ?wasGeneratedBy; separator=", ") AS ?wasGeneratedBys) + (GROUP_CONCAT(DISTINCT STR(?type); separator=", ") AS ?types) + (GROUP_CONCAT(DISTINCT STR(?uploadedBy); separator=", ") AS ?uploadedBys) + (GROUP_CONCAT(DISTINCT STR(?creator); separator=", ") AS ?creators) + (GROUP_CONCAT(DISTINCT STR(?created); separator=", ") AS ?createdDates) + (GROUP_CONCAT(DISTINCT STR(?modified); separator=", ") AS ?modifiedDates) + (GROUP_CONCAT(DISTINCT STR(?wasDerivedFrom); separator=", ") AS ?wasDerivedFroms) + (GROUP_CONCAT(DISTINCT STR(?wasGeneratedBy); separator=", ") AS ?wasGeneratedBys) WHERE { <$uri> a ?type . OPTIONAL { <$uri> sbol2:persistentIdentity ?persistentIdentity . }