diff --git a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml index 76b81502392..941e0e04f7d 100644 --- a/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml +++ b/exist-core/src/test/resources/standalone-webapp/WEB-INF/controller-config.xml @@ -26,6 +26,7 @@ + diff --git a/exist-distribution/pom.xml b/exist-distribution/pom.xml index 2369cda5832..10ceb64405c 100644 --- a/exist-distribution/pom.xml +++ b/exist-distribution/pom.xml @@ -64,6 +64,12 @@ ${project.version} runtime + + ${project.groupId} + exist-services + ${project.version} + runtime + ${project.groupId} exist-start diff --git a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml index cffb8bf1e86..7b4b3f839d4 100644 --- a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml +++ b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/controller-config.xml @@ -18,6 +18,9 @@ + + + diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml index 4bb83a9f548..68dbee9557d 100644 --- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml +++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/controller-config.xml @@ -28,6 +28,9 @@ --> + + + diff --git a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml index dc9885872ca..c4cd3606634 100644 --- a/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml +++ b/exist-jetty-config/src/main/resources/webapp/WEB-INF/web.xml @@ -179,6 +179,24 @@ 4 + + + PackageManagementServlet + org.exist.http.servlets.PackageManagementServlet + + use-default-user + false + + + 268435456 + 268435456 + + 3 + + diff --git a/exist-services/pom.xml b/exist-services/pom.xml new file mode 100644 index 00000000000..054e1ba66fe --- /dev/null +++ b/exist-services/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + + org.exist-db + exist-parent + 7.0.0-SNAPSHOT + ../exist-parent + + + exist-services + jar + + eXist-db Platform Services + + Built-in HTTP services for the eXist-db platform: package management REST API + and OpenAPI-based routing. These services are compiled into the platform and + require no XAR package installation. + + + + + + org.exist-db + exist-core + ${project.version} + + + + + org.apache.logging.log4j + log4j-api + + + diff --git a/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java new file mode 100644 index 00000000000..bcd40f75857 --- /dev/null +++ b/exist-services/src/main/java/org/exist/http/servlets/PackageManagementServlet.java @@ -0,0 +1,432 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.http.servlets; + +import com.fasterxml.jackson.core.JsonGenerator; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.Part; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.EXistException; +import org.exist.repo.PackageService; +import org.exist.security.Subject; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.expath.pkg.repo.PackageException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Built-in REST API for package management. + *

+ * This servlet provides package CRUD operations (list, get, install, remove, + * update-check) as a built-in Java endpoint that does not depend on any XAR + * package. This ensures package management remains available even during + * package upgrades, solving the self-upgrade problem that affects XQuery-based + * package management implementations. + *

+ *

+ * Endpoints: + *

    + *
  • GET /api/packages — list all installed packages
  • + *
  • GET /api/packages/{name} — get single package details
  • + *
  • GET /api/packages/{name}/icon — get package icon
  • + *
  • POST /api/packages/install — install from registry (JSON) or upload (multipart)
  • + *
  • POST /api/packages/update-check — check for available updates
  • + *
  • DELETE /api/packages/{name} — remove package
  • + *
+ *

+ */ +public class PackageManagementServlet extends AbstractExistHttpServlet { + + private static final Logger LOG = LogManager.getLogger(PackageManagementServlet.class); + + private final PackageService packageService = new PackageService(); + + @Override + public Logger getLog() { + return LOG; + } + + @Override + protected void doGet(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + + try { + if (pathInfo == null || pathInfo.isEmpty() || pathInfo.equals("/")) { + // GET /api/packages — list all + handleListPackages(response); + } else if (pathInfo.endsWith("/icon")) { + // GET /api/packages/{name}/icon + final String name = pathInfo.substring(1, pathInfo.length() - "/icon".length()); + handleGetIcon(response, name); + } else { + // GET /api/packages/{name} + final String name = pathInfo.substring(1); + handleGetPackage(response, name); + } + } catch (final PackageException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } + } + + @Override + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + if (!user.hasDbaRole()) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "FORBIDDEN", "DBA role required for package installation"); + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + + try { + if ("/install".equals(pathInfo)) { + handleInstall(request, response, user); + } else if ("/update-check".equals(pathInfo)) { + handleUpdateCheck(request, response); + } else { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Unknown POST endpoint: " + pathInfo); + } + } catch (final PackageException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } catch (final EXistException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "DATABASE_ERROR", e.getMessage()); + } + } + + @Override + protected void doDelete(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + final Subject user = authenticate(request, response); + if (user == null) { + return; + } + if (!user.hasDbaRole()) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "FORBIDDEN", "DBA role required for package removal"); + return; + } + + final String pathInfo = normalizePath(request.getPathInfo()); + if (pathInfo == null || pathInfo.length() <= 1) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Package name required"); + return; + } + + final String nameOrAbbrev = pathInfo.substring(1); + + try { + // Check for dependents first + final List dependents = packageService.findDependents(getPool(), nameOrAbbrev); + final boolean force = "true".equals(request.getParameter("force")); + if (!dependents.isEmpty() && !force) { + response.setStatus(HttpServletResponse.SC_CONFLICT); + response.setContentType("application/json"); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("status", "error"); + gen.writeStringField("code", "HAS_DEPENDENTS"); + gen.writeStringField("message", "Cannot remove: other packages depend on " + nameOrAbbrev); + gen.writeArrayFieldStart("dependents"); + for (final String dep : dependents) { + gen.writeString(dep); + } + gen.writeEndArray(); + gen.writeEndObject(); + } + return; + } + + try (final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.removePackage(broker, transaction, nameOrAbbrev); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } catch (final EXistException e) { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "DATABASE_ERROR", e.getMessage()); + } + } catch (final PackageException e) { + if (e.getMessage() != null && e.getMessage().contains("not found")) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "PACKAGE_NOT_FOUND", e.getMessage()); + } else { + writeError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + "PACKAGE_ERROR", e.getMessage()); + } + } + } + + @Override + protected void doOptions(final HttpServletRequest request, final HttpServletResponse response) + throws ServletException, IOException { + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.setStatus(HttpServletResponse.SC_OK); + } + + // --- Handler methods --- + + private void handleListPackages(final HttpServletResponse response) + throws IOException, PackageException { + final List> packages = packageService.listPackages(getPool()); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartArray(); + for (final Map pkg : packages) { + writeMapAsJson(gen, pkg); + } + gen.writeEndArray(); + } + } + + private void handleGetPackage(final HttpServletResponse response, final String nameOrAbbrev) + throws IOException, PackageException { + final Map pkg = packageService.getPackage(getPool(), nameOrAbbrev); + if (pkg == null) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "PACKAGE_NOT_FOUND", "Package not found: " + nameOrAbbrev); + return; + } + writeJsonMap(response, HttpServletResponse.SC_OK, pkg); + } + + private void handleGetIcon(final HttpServletResponse response, final String nameOrAbbrev) + throws IOException, PackageException { + final Object[] icon = packageService.getPackageIcon(getPool(), nameOrAbbrev); + if (icon == null) { + writeError(response, HttpServletResponse.SC_NOT_FOUND, + "ICON_NOT_FOUND", "No icon found for package: " + nameOrAbbrev); + return; + } + response.setContentType((String) icon[1]); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream()) { + os.write((byte[]) icon[0]); + } + } + + private void handleInstall(final HttpServletRequest request, final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + final String contentType = request.getContentType(); + + if (contentType != null && contentType.startsWith("multipart/")) { + // Upload a XAR file + handleInstallUpload(request, response, user); + } else { + // Install from registry (JSON body) + handleInstallFromRegistry(request, response, user); + } + } + + private void handleInstallFromRegistry(final HttpServletRequest request, + final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + // Parse JSON body manually (avoid adding a JSON parsing dependency) + final String body; + try (final InputStream is = request.getInputStream()) { + body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + final String name = PackageService.extractJsonStringValue(body, "name"); + final String url = PackageService.extractJsonStringValue(body, "url"); + final String version = PackageService.extractJsonStringValue(body, "version"); + + if (name == null || name.isEmpty() || url == null || url.isEmpty()) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Missing required fields: name, url"); + return; + } + + try (final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.installFromRegistry( + broker, transaction, name, url, version); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } + } + + private void handleInstallUpload(final HttpServletRequest request, + final HttpServletResponse response, + final Subject user) + throws IOException, PackageException, EXistException { + try { + final Part xarPart = request.getPart("xar"); + if (xarPart == null) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Missing 'xar' file part in multipart upload"); + return; + } + try (final InputStream is = xarPart.getInputStream(); + final DBBroker broker = getPool().get(Optional.of(user)); + final Txn transaction = getPool().getTransactionManager().beginTransaction()) { + final Map result = packageService.installFromUpload( + broker, transaction, is); + transaction.commit(); + writeJsonMap(response, HttpServletResponse.SC_OK, result); + } + } catch (final ServletException e) { + writeError(response, HttpServletResponse.SC_BAD_REQUEST, + "BAD_REQUEST", "Failed to process multipart upload: " + e.getMessage()); + } + } + + private void handleUpdateCheck(final HttpServletRequest request, + final HttpServletResponse response) + throws IOException, PackageException { + // Parse optional JSON body for registry URL + String registryUrl = "https://exist-db.org/exist/apps/public-repo"; + final String contentType = request.getContentType(); + if (contentType != null && contentType.contains("json")) { + final String body; + try (final InputStream is = request.getInputStream()) { + body = new String(is.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } + final String url = PackageService.extractJsonStringValue(body, "registry"); + if (url != null && !url.isEmpty()) { + registryUrl = url; + } + } + + final List> updates = packageService.checkUpdates(getPool(), registryUrl); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("registry", registryUrl); + gen.writeArrayFieldStart("updates"); + for (final Map update : updates) { + writeMapAsJson(gen, update); + } + gen.writeEndArray(); + gen.writeEndObject(); + } + } + + // --- JSON helpers --- + + private JsonGenerator createJsonGenerator(final OutputStream os) throws IOException { + final com.fasterxml.jackson.core.JsonFactory factory = new com.fasterxml.jackson.core.JsonFactory(); + final JsonGenerator gen = factory.createGenerator(os); + gen.useDefaultPrettyPrinter(); + return gen; + } + + private void writeError(final HttpServletResponse response, final int statusCode, + final String code, final String message) throws IOException { + response.setContentType("application/json"); + response.setStatus(statusCode); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + gen.writeStartObject(); + gen.writeStringField("status", "error"); + gen.writeStringField("code", code); + gen.writeStringField("message", message); + gen.writeEndObject(); + } + } + + private void writeJsonMap(final HttpServletResponse response, final int statusCode, + final Map data) throws IOException { + response.setContentType("application/json"); + response.setStatus(statusCode); + try (final OutputStream os = response.getOutputStream(); + final JsonGenerator gen = createJsonGenerator(os)) { + writeMapAsJson(gen, data); + } + } + + @SuppressWarnings("unchecked") + private void writeMapAsJson(final JsonGenerator gen, final Map map) throws IOException { + gen.writeStartObject(); + for (final Map.Entry entry : map.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (value == null) { + gen.writeNullField(key); + } else if (value instanceof String s) { + gen.writeStringField(key, s); + } else if (value instanceof Boolean b) { + gen.writeBooleanField(key, b); + } else if (value instanceof Number n) { + gen.writeNumberField(key, n.longValue()); + } else if (value instanceof List list) { + gen.writeArrayFieldStart(key); + for (final Object item : list) { + if (item instanceof Map itemMap) { + writeMapAsJson(gen, (Map) itemMap); + } else if (item instanceof String s) { + gen.writeString(s); + } else { + gen.writeString(String.valueOf(item)); + } + } + gen.writeEndArray(); + } else if (value instanceof Map nested) { + gen.writeFieldName(key); + writeMapAsJson(gen, (Map) nested); + } else { + gen.writeStringField(key, String.valueOf(value)); + } + } + gen.writeEndObject(); + } + + private String normalizePath(final String pathInfo) { + if (pathInfo == null) { + return null; + } + // URL-decode the path + return java.net.URLDecoder.decode(pathInfo, java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/exist-services/src/main/java/org/exist/repo/PackageService.java b/exist-services/src/main/java/org/exist/repo/PackageService.java new file mode 100644 index 00000000000..94be8e20ac8 --- /dev/null +++ b/exist-services/src/main/java/org/exist/repo/PackageService.java @@ -0,0 +1,470 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.repo; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.storage.BrokerPool; +import org.exist.storage.DBBroker; +import org.exist.storage.txn.Txn; +import org.exist.util.io.TemporaryFileManager; +import org.expath.pkg.repo.*; +import org.expath.pkg.repo.Package; +import org.expath.pkg.repo.tui.BatchUserInteraction; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.annotation.Nullable; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.*; + +/** + * Business logic for package management operations. + * Used by {@link org.exist.http.servlets.PackageManagementServlet} to provide + * a built-in REST API for package CRUD that does not depend on any XAR package. + */ +public class PackageService { + + private static final Logger LOG = LogManager.getLogger(PackageService.class); + + private static final String PKG_NAMESPACE = "http://expath.org/ns/pkg"; + private static final String REPO_NAMESPACE = "http://exist-db.org/xquery/repo"; + + /** + * List all installed packages with metadata. + */ + public List> listPackages(final BrokerPool pool) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final List> result = new ArrayList<>(); + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + result.add(buildPackageInfo(pkg)); + } + return result; + } + + /** + * Get metadata for a single package by name (URI) or abbreviation. + */ + @Nullable + public Map getPackage(final BrokerPool pool, final String nameOrAbbrev) throws PackageException { + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + return null; + } + return buildPackageInfo(pkg); + } + + /** + * Get a package icon file. + * Returns a two-element array: [byte[], contentType] or null if no icon found. + */ + @Nullable + public Object[] getPackageIcon(final BrokerPool pool, final String nameOrAbbrev) throws PackageException, IOException { + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + return null; + } + final Path pkgDir = getPackageDir(pkg); + // Try common icon filenames + for (final String iconName : new String[]{"icon.png", "icon.svg", "icon.jpg", "icon.gif"}) { + final Path iconFile = pkgDir.resolve(iconName); + if (Files.isReadable(iconFile)) { + final String contentType; + if (iconName.endsWith(".svg")) { + contentType = "image/svg+xml"; + } else if (iconName.endsWith(".png")) { + contentType = "image/png"; + } else if (iconName.endsWith(".jpg")) { + contentType = "image/jpeg"; + } else { + contentType = "image/gif"; + } + return new Object[]{Files.readAllBytes(iconFile), contentType}; + } + } + return null; + } + + /** + * Install a package from a remote registry. + */ + public Map installFromRegistry(final DBBroker broker, final Txn transaction, + final String name, final String registryUrl, + @Nullable final String version) throws PackageException, IOException { + final RepoPackageLoader loader = new RepoPackageLoader(registryUrl); + // Build version constraint + PackageLoader.Version pkgVersion = null; + if (version != null && !version.isEmpty()) { + pkgVersion = new PackageLoader.Version(version, true); + } + + // Download the XAR from the registry + final XarSource xar = loader.load(name, pkgVersion); + if (xar == null) { + throw new PackageException("Package not found in registry: " + name); + } + + // Install and deploy + final Deployment deployment = new Deployment(); + final Optional target = deployment.installAndDeploy(broker, transaction, xar, loader); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", name); + target.ifPresent(t -> result.put("target", t)); + return result; + } + + /** + * Install a package from an uploaded XAR file. + */ + public Map installFromUpload(final DBBroker broker, final Txn transaction, + final InputStream xarStream) throws PackageException, IOException { + // Save to temporary file + final TemporaryFileManager tempManager = TemporaryFileManager.getInstance(); + final Path tempFile = tempManager.getTemporaryFile(); + Files.copy(xarStream, tempFile, StandardCopyOption.REPLACE_EXISTING); + + final XarFileSource xarSource = new XarFileSource(tempFile); + final Deployment deployment = new Deployment(); + final Optional target = deployment.installAndDeploy(broker, transaction, xarSource, null); + + // Read package name from the XAR descriptor + final Optional descriptor = deployment.getDescriptor(broker, xarSource); + final String name = descriptor.map(d -> d.getDocumentElement().getAttribute("name")).orElse("unknown"); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", name); + target.ifPresent(t -> result.put("target", t)); + return result; + } + + /** + * Remove (undeploy + delete) a package. + */ + public Map removePackage(final DBBroker broker, final Txn transaction, + final String nameOrAbbrev) throws PackageException { + final BrokerPool pool = broker.getBrokerPool(); + final Package pkg = resolvePackage(pool, nameOrAbbrev); + if (pkg == null) { + throw new PackageException("Package not found: " + nameOrAbbrev); + } + final String pkgName = pkg.getName(); + + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + throw new PackageException("EXPath repository not available"); + } + + // Undeploy (remove database resources) + final Deployment deployment = new Deployment(); + deployment.undeploy(broker, transaction, pkgName, maybeRepo); + + // Remove from repository + final Repository parentRepo = maybeRepo.get().getParentRepo(); + parentRepo.removePackage(pkgName, false, new BatchUserInteraction()); + maybeRepo.get().reportAction(ExistRepository.Action.UNINSTALL, pkgName); + + // Clear XQuery cache + pool.getXQueryPool().clear(); + + final Map result = new LinkedHashMap<>(); + result.put("status", "ok"); + result.put("name", pkgName); + return result; + } + + /** + * Find packages that depend on the given package. + */ + public List findDependents(final BrokerPool pool, final String packageName) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final List dependents = new ArrayList<>(); + + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + if (pkg.getName().equals(packageName)) { + continue; + } + // Read expath-pkg.xml to check dependencies + final Path pkgDir = getPackageDir(pkg); + final Path expathFile = pkgDir.resolve("expath-pkg.xml"); + if (Files.isReadable(expathFile)) { + try { + final Document doc = parseXml(expathFile); + final NodeList deps = doc.getElementsByTagNameNS(PKG_NAMESPACE, "dependency"); + for (int i = 0; i < deps.getLength(); i++) { + final Element dep = (Element) deps.item(i); + if (packageName.equals(dep.getAttribute("package"))) { + dependents.add(pkg.getName()); + break; + } + } + } catch (final Exception e) { + LOG.warn("Failed to parse expath-pkg.xml for package {}", pkg.getName(), e); + } + } + } + return dependents; + } + + /** + * Check a remote registry for available updates. + */ + public List> checkUpdates(final BrokerPool pool, final String registryUrl) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return Collections.emptyList(); + } + final Repository repo = maybeRepo.get().getParentRepo(); + final String findUrl = registryUrl + "/find"; + final String processorVersion = org.exist.SystemProperties.getInstance() + .getSystemProperty("product-version", "7.0.0"); + + final List> updates = new ArrayList<>(); + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + final String abbrev = pkg.getAbbrev(); + final String installed = pkg.getVersion(); + if (abbrev == null || installed == null) { + continue; + } + try { + final String queryUrl = findUrl + + "?abbrev=" + URLEncoder.encode(abbrev, StandardCharsets.UTF_8) + + "&processor=http://exist-db.org" + + "&info=true" + + "&processorVersion=" + URLEncoder.encode(processorVersion, StandardCharsets.UTF_8); + + final HttpURLConnection conn = (HttpURLConnection) URI.create(queryUrl).toURL().openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestProperty("Accept", "application/json"); + conn.connect(); + + if (conn.getResponseCode() == 200) { + // Parse the simple JSON response to extract version + // The response format is: {"version": "x.y.z", ...} + final String body; + try (final InputStream is = conn.getInputStream()) { + body = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + final String available = extractJsonStringValue(body, "version"); + if (available != null && !available.equals(installed)) { + final Map update = new LinkedHashMap<>(); + update.put("name", pkg.getName()); + update.put("abbrev", abbrev); + update.put("installed", installed); + update.put("available", available); + updates.add(update); + } + } + } catch (final IOException e) { + LOG.debug("Failed to check updates for package {}: {}", abbrev, e.getMessage()); + } + } + return updates; + } + + // --- Private helpers --- + + @Nullable + private Package resolvePackage(final BrokerPool pool, final String nameOrAbbrev) throws PackageException { + final Optional maybeRepo = pool.getExpathRepo(); + if (maybeRepo.isEmpty()) { + return null; + } + final Repository repo = maybeRepo.get().getParentRepo(); + + // Try as package name (URI) first + final Packages byName = repo.getPackages(nameOrAbbrev); + if (byName != null) { + return byName.latest(); + } + + // Try as abbreviation + for (final Packages packages : repo.listPackages()) { + final Package pkg = packages.latest(); + if (nameOrAbbrev.equals(pkg.getAbbrev())) { + return pkg; + } + } + return null; + } + + private Path getPackageDir(final Package pkg) { + final FileSystemStorage.FileSystemResolver resolver = + (FileSystemStorage.FileSystemResolver) pkg.getResolver(); + return resolver.resolveResourceAsFile(""); + } + + private Map buildPackageInfo(final Package pkg) { + final Map info = new LinkedHashMap<>(); + info.put("name", pkg.getName()); + info.put("abbrev", pkg.getAbbrev()); + info.put("version", pkg.getVersion()); + + // Read additional metadata from descriptors on the filesystem + final Path pkgDir = getPackageDir(pkg); + readExpathMetadata(pkgDir, info); + readRepoMetadata(pkgDir, info); + return info; + } + + private void readExpathMetadata(final Path pkgDir, final Map info) { + final Path expathFile = pkgDir.resolve("expath-pkg.xml"); + if (!Files.isReadable(expathFile)) { + return; + } + try { + final Document doc = parseXml(expathFile); + final Element root = doc.getDocumentElement(); + // Title + final NodeList titles = root.getElementsByTagNameNS(PKG_NAMESPACE, "title"); + if (titles.getLength() > 0) { + info.put("title", titles.item(0).getTextContent()); + } + // Dependencies + final NodeList deps = root.getElementsByTagNameNS(PKG_NAMESPACE, "dependency"); + final List> depList = new ArrayList<>(); + for (int i = 0; i < deps.getLength(); i++) { + final Element dep = (Element) deps.item(i); + final Map depInfo = new LinkedHashMap<>(); + if (!dep.getAttribute("package").isEmpty()) { + depInfo.put("package", dep.getAttribute("package")); + } + if (!dep.getAttribute("processor").isEmpty()) { + depInfo.put("processor", dep.getAttribute("processor")); + } + if (!dep.getAttribute("semver-min").isEmpty()) { + depInfo.put("semverMin", dep.getAttribute("semver-min")); + } + if (!dep.getAttribute("semver-max").isEmpty()) { + depInfo.put("semverMax", dep.getAttribute("semver-max")); + } + if (!dep.getAttribute("semver").isEmpty()) { + depInfo.put("semver", dep.getAttribute("semver")); + } + if (!dep.getAttribute("version").isEmpty()) { + depInfo.put("version", dep.getAttribute("version")); + } + depList.add(depInfo); + } + info.put("dependencies", depList); + } catch (final Exception e) { + LOG.debug("Failed to read expath-pkg.xml from {}", pkgDir, e); + } + } + + private void readRepoMetadata(final Path pkgDir, final Map info) { + final Path repoFile = pkgDir.resolve("repo.xml"); + if (!Files.isReadable(repoFile)) { + return; + } + try { + final Document doc = parseXml(repoFile); + final Element root = doc.getDocumentElement(); + setTextElement(root, REPO_NAMESPACE, "description", "description", info); + setTextElement(root, REPO_NAMESPACE, "website", "website", info); + setTextElement(root, REPO_NAMESPACE, "license", "license", info); + setTextElement(root, REPO_NAMESPACE, "type", "type", info); + setTextElement(root, REPO_NAMESPACE, "target", "target", info); + setTextElement(root, REPO_NAMESPACE, "deployed", "deployed", info); + // Authors + final NodeList authors = root.getElementsByTagNameNS(REPO_NAMESPACE, "author"); + if (authors.getLength() > 0) { + final List authorList = new ArrayList<>(); + for (int i = 0; i < authors.getLength(); i++) { + authorList.add(authors.item(i).getTextContent().trim()); + } + info.put("authors", authorList); + } + } catch (final Exception e) { + LOG.debug("Failed to read repo.xml from {}", pkgDir, e); + } + } + + private void setTextElement(final Element root, final String ns, final String localName, + final String key, final Map info) { + final NodeList nodes = root.getElementsByTagNameNS(ns, localName); + if (nodes.getLength() > 0) { + final String value = nodes.item(0).getTextContent().trim(); + if (!value.isEmpty()) { + info.put(key, value); + } + } + } + + private Document parseXml(final Path file) throws Exception { + final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + final DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(file.toFile()); + } + + /** + * Simple extraction of a string value from a JSON object. + * Avoids pulling in a JSON parsing library for this single use case. + */ + @Nullable + public static String extractJsonStringValue(final String json, final String key) { + final String search = "\"" + key + "\""; + final int keyIdx = json.indexOf(search); + if (keyIdx < 0) { + return null; + } + final int colonIdx = json.indexOf(':', keyIdx + search.length()); + if (colonIdx < 0) { + return null; + } + final int startQuote = json.indexOf('"', colonIdx + 1); + if (startQuote < 0) { + return null; + } + final int endQuote = json.indexOf('"', startQuote + 1); + if (endQuote < 0) { + return null; + } + return json.substring(startQuote + 1, endQuote); + } +} diff --git a/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java b/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java new file mode 100644 index 00000000000..0ce8dd4641e --- /dev/null +++ b/exist-services/src/main/java/org/exist/repo/RepoPackageLoader.java @@ -0,0 +1,88 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.repo; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.exist.SystemProperties; +import org.exist.util.io.TemporaryFileManager; +import org.expath.pkg.repo.XarFileSource; +import org.expath.pkg.repo.XarSource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +/** + * Loads packages from a remote public repository (e.g. https://exist-db.org/exist/apps/public-repo). + * Used by {@link Deployment} to resolve package dependencies during installation, + * and by {@link PackageService} for direct package installation from a registry. + */ +public record RepoPackageLoader(String repoURL) implements PackageLoader { + + private static final Logger LOG = LogManager.getLogger(RepoPackageLoader.class); + + private static final int CONNECT_TIMEOUT = 15_000; + private static final int READ_TIMEOUT = 15_000; + + @Override + public XarSource load(final String name, final Version version) throws IOException { + String pkgURL = repoURL + "?name=" + URLEncoder.encode(name, StandardCharsets.UTF_8) + + "&processor=" + SystemProperties.getInstance().getSystemProperty("product-version", "2.2.0"); + if (version != null) { + if (version.getMin() != null) { + pkgURL += "&semver-min=" + version.getMin(); + } + if (version.getMax() != null) { + pkgURL += "&semver-max=" + version.getMax(); + } + if (version.getSemVer() != null) { + pkgURL += "&semver=" + version.getSemVer(); + } + if (version.getVersion() != null) { + pkgURL += "&version=" + URLEncoder.encode(version.getVersion(), StandardCharsets.UTF_8); + } + } + LOG.info("Retrieving package from {}", pkgURL); + final HttpURLConnection connection = (HttpURLConnection) URI.create(pkgURL).toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setReadTimeout(READ_TIMEOUT); + connection.setRequestMethod("GET"); + connection.setRequestProperty("User-Agent", "eXist-db Package Manager"); + connection.connect(); + + try (final InputStream is = connection.getInputStream()) { + final TemporaryFileManager temporaryFileManager = TemporaryFileManager.getInstance(); + final Path outFile = temporaryFileManager.getTemporaryFile(); + Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING); + return new XarFileSource(outFile); + } catch (final IOException e) { + throw new IOException("Failed to download package from " + pkgURL + ": " + e.getMessage(), e); + } + } +} diff --git a/pom.xml b/pom.xml index f82e32d6b2b..09b6c322159 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,7 @@ exist-jetty-config exist-samples exist-service + exist-services exist-start extensions exist-xqts