diff options
Diffstat (limited to 'opendc-web/opendc-web-ui-quarkus-deployment/src')
4 files changed, 548 insertions, 0 deletions
diff --git a/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/AuthConfiguration.java b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/AuthConfiguration.java new file mode 100644 index 00000000..2e4d9198 --- /dev/null +++ b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/AuthConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.ui.deployment; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +import java.util.Optional; + +/** + * Auth configuration for the OpenDC UI extension. + */ +@ConfigGroup +public class AuthConfiguration { + /** + * The authentication domain. + */ + @ConfigItem + Optional<String> domain; + + /** + * The client identifier used by the OpenDC web ui. + */ + @ConfigItem + Optional<String> clientId; + + /** + * The audience of the OpenDC API. + */ + @ConfigItem + Optional<String> audience; +} diff --git a/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiConfig.java b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiConfig.java new file mode 100644 index 00000000..50c1fbe3 --- /dev/null +++ b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiConfig.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.ui.deployment; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigRoot; + +import java.util.Optional; + +/** + * Build-time configuration for the OpenDC UI extension. + */ +@ConfigRoot(name = "opendc-ui") +public class OpenDCUiConfig { + /** + * A flag to include the OpenDC UI extension into the build. + */ + @ConfigItem(defaultValue = "true") + boolean include; + + /** + * The path where the OpenDC UI is available. + */ + @ConfigItem(defaultValue = "/") + String path; + + /** + * The base URL of the OpenDC API. + */ + @ConfigItem(defaultValue = "/api") + String apiBaseUrl; + + /** + * Configuration properties for web UI authentication. + */ + AuthConfiguration auth; + + /** + * Sentry DSN. + */ + @ConfigItem + Optional<String> sentryDsn; +} diff --git a/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiProcessor.java b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiProcessor.java new file mode 100644 index 00000000..54782ace --- /dev/null +++ b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiProcessor.java @@ -0,0 +1,314 @@ +/* + * Copyright (c) 2022 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.ui.deployment; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.ShutdownContextBuildItem; +import io.quarkus.deployment.pkg.builditem.CurateOutcomeBuildItem; +import io.quarkus.maven.dependency.GACT; +import io.quarkus.maven.dependency.ResolvedDependency; +import io.quarkus.paths.PathVisit; +import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; +import io.quarkus.vertx.http.deployment.RouteBuildItem; +import io.quarkus.vertx.http.deployment.webjar.WebJarBuildItem; +import io.quarkus.vertx.http.deployment.webjar.WebJarResourcesFilter; +import io.quarkus.vertx.http.deployment.webjar.WebJarResultsBuildItem; +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; +import org.opendc.web.ui.runtime.OpenDCUiRecorder; +import org.opendc.web.ui.runtime.OpenDCUiRuntimeConfig; + +import java.io.*; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Build processor for the OpenDC web UI Quarkus extension. + */ +public class OpenDCUiProcessor { + + private static final String FEATURE = "opendc-ui"; + private static final GACT OPENDC_UI_WEBJAR_ARTIFACT_KEY = new GACT("org.opendc.web", "opendc-web-ui", null, "jar"); + private static final String OPENDC_UI_WEBJAR_STATIC_RESOURCES_PATH = "META-INF/resources/opendc-web-ui"; + private static final Pattern PATH_PARAM_PATTERN = Pattern.compile("\\[(\\w+)]"); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Provide the {@link FeatureBuildItem} for this Quarkus extension. + */ + @BuildStep(onlyIf = IsIncluded.class) + public FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + /** + * Build the WebJar that is used to serve the Next.js resources. + */ + @BuildStep(onlyIf = IsIncluded.class) + public WebJarBuildItem buildWebJar(OpenDCUiConfig config, + HttpRootPathBuildItem httpRootPathBuildItem) { + return WebJarBuildItem.builder() + .artifactKey(OPENDC_UI_WEBJAR_ARTIFACT_KEY) + .root(OPENDC_UI_WEBJAR_STATIC_RESOURCES_PATH) + .onlyCopyNonArtifactFiles(false) + .useDefaultQuarkusBranding(false) + .filter(new InsertVariablesResourcesFilter(config, httpRootPathBuildItem)) + .build(); + } + + + /** + * Build the Next.js routes based on the route manifest generated by it. + */ + @BuildStep(onlyIf = IsIncluded.class) + public OpenDCUiRoutingBuildItem buildRoutes(CurateOutcomeBuildItem curateOutcomeBuildItem) throws IOException { + ResolvedDependency dependency = getAppArtifact(curateOutcomeBuildItem, OPENDC_UI_WEBJAR_ARTIFACT_KEY); + PathVisit visit = dependency.getContentTree().apply(OPENDC_UI_WEBJAR_STATIC_RESOURCES_PATH + "/routes-manifest.json", v -> v); + + if (visit == null) { + throw new FileNotFoundException("Cannot find routes-manifest.json"); + } + + JsonNode routeManifest = objectMapper.readTree(visit.getUrl()); + + var pages = new ArrayList<OpenDCUiRoutingBuildItem.Page>(); + for (Iterator<JsonNode> it = routeManifest.get("staticRoutes").elements(); it.hasNext();) { + JsonNode route = it.next(); + + String page = route.get("page").asText(); + + // Static routes do not have any path parameters + pages.add(new OpenDCUiRoutingBuildItem.Page(page, page)); + } + + for (Iterator<JsonNode> it = routeManifest.get("dynamicRoutes").elements(); it.hasNext();) { + JsonNode route = it.next(); + + String page = route.get("page").asText(); + String path = PATH_PARAM_PATTERN.matcher(page).replaceAll(r -> ":" + r.group(1)); + + pages.add(new OpenDCUiRoutingBuildItem.Page(path, page)); + } + + var redirects = new ArrayList<OpenDCUiRoutingBuildItem.Redirect>(); + for (Iterator<JsonNode> it = routeManifest.get("redirects").elements(); it.hasNext();) { + JsonNode redirect = it.next(); + if (redirect.has("internal")) { + continue; + } + + int statusCode = redirect.get("statusCode").asInt(); + String path = redirect.get("source").asText().replaceAll("/%%NEXT_BASE_PATH%%", ""); + String destination = redirect.get("destination").asText().replaceAll("/%%NEXT_BASE_PATH%%", ""); + + if (path.isEmpty()) { + path = "/"; + } + + redirects.add(new OpenDCUiRoutingBuildItem.Redirect(path, destination, statusCode)); + } + + var custom404 = routeManifest.get("pages404").asBoolean(); + return new OpenDCUiRoutingBuildItem(pages, redirects, custom404); + } + + /** + * Register the HTTP handles for the OpenDC web UI. + */ + @BuildStep(onlyIf = IsIncluded.class) + @Record(ExecutionTime.RUNTIME_INIT) + public void registerOpenDCUiHandler(OpenDCUiRecorder recorder, + BuildProducer<RouteBuildItem> routes, + HttpRootPathBuildItem httpRootPathBuildItem, + WebJarResultsBuildItem webJarResultsBuildItem, + OpenDCUiRoutingBuildItem openDCUiBuildItem, + OpenDCUiRuntimeConfig runtimeConfig, + OpenDCUiConfig buildConfig, + ShutdownContextBuildItem shutdownContext) { + + WebJarResultsBuildItem.WebJarResult result = webJarResultsBuildItem.byArtifactKey(OPENDC_UI_WEBJAR_ARTIFACT_KEY); + if (result == null) { + return; + } + + String basePath = httpRootPathBuildItem.resolvePath(buildConfig.path); + String finalDestination = result.getFinalDestination(); + + /* Construct dynamic routes */ + for (var redirect : openDCUiBuildItem.getRedirects()) { + String destination = basePath.equals("/") ? redirect.getDestination() : basePath + redirect.getDestination(); + + routes.produce(httpRootPathBuildItem.routeBuilder() + .route(basePath + redirect.getPath()) + .handler(recorder.redirectHandler(destination, redirect.getStatusCode(), runtimeConfig)) + .build()); + } + + for (var page : openDCUiBuildItem.getPages()) { + routes.produce(httpRootPathBuildItem.routeBuilder() + .route(basePath + page.getPath()) + .handler(recorder.pageHandler(finalDestination, page.getName(), runtimeConfig)) + .build()); + } + + /* Construct static routes */ + Handler<RoutingContext> staticHandler = recorder.staticHandler( + finalDestination, + basePath, + result.getWebRootConfigurations(), + runtimeConfig, + shutdownContext + ); + + routes.produce(httpRootPathBuildItem.routeBuilder() + .route(buildConfig.path) + .displayOnNotFoundPage("OpenDC UI") + .routeConfigKey("quarkus.opendc-ui.path") + .handler(staticHandler) + .build()); + + routes.produce(httpRootPathBuildItem.routeBuilder() + .route(buildConfig.path + "*") + .handler(staticHandler) + .build()); + } + + /** + * A {@link WebJarResourcesFilter} that instantiates the variables in the web jar resource. + */ + private static class InsertVariablesResourcesFilter implements WebJarResourcesFilter { + + private static final String HTML = ".html"; + private static final String CSS = ".css"; + private static final String JS = ".js"; + + private final OpenDCUiConfig config; + private final HttpRootPathBuildItem httpRootPathBuildItem; + + + public InsertVariablesResourcesFilter(OpenDCUiConfig config, HttpRootPathBuildItem httpRootPathBuildItem) { + this.config = config; + this.httpRootPathBuildItem = httpRootPathBuildItem; + } + + @Override + public FilterResult apply(String fileName, InputStream stream) throws IOException { + if (stream == null) { + return new FilterResult(null, false); + } + + // Allow replacement of variables in HTML, CSS, and JavaScript files + if (fileName.endsWith(HTML) || fileName.endsWith(CSS) || fileName.endsWith(JS)) { + byte[] oldContentBytes = stream.readAllBytes(); + String oldContents = new String(oldContentBytes); + String contents = substituteVariables(oldContents, this::substitute); + + boolean changed = contents.length() != oldContents.length() || !contents.equals(oldContents); + if (changed) { + return new FilterResult(new ByteArrayInputStream(contents.getBytes()), true); + } else { + return new FilterResult(new ByteArrayInputStream(oldContentBytes), false); + } + } + + return new FilterResult(stream, false); + } + + private String substitute(String var) { + switch (var) { + case "NEXT_BASE_PATH": + String basePath = httpRootPathBuildItem.resolvePath(config.path); + return basePath.equals("/") ? "" : basePath; // Base path must not end with trailing slash + case "NEXT_PUBLIC_API_BASE_URL": + return config.apiBaseUrl; + case "NEXT_PUBLIC_SENTRY_DSN": + return config.sentryDsn.orElse(""); + case "NEXT_PUBLIC_AUTH0_DOMAIN": + return config.auth.domain.orElse(""); + case "NEXT_PUBLIC_AUTH0_CLIENT_ID": + return config.auth.clientId.orElse(""); + case "NEXT_PUBLIC_AUTH0_AUDIENCE": + return config.auth.audience.orElse(""); + default: + return null; + } + } + + /** + * Pattern to match variables in the OpenDC web UI sources specified using the following format: "%%VAR_NAME%%". + * <p> + * Be aware that to properly handle Next.js base path, we need to also match a possible forward slash in front + * of the variable. + */ + private static final Pattern PATTERN = Pattern.compile("/?%%(\\w+)%%"); + + /** + * Helper method to substitute variables in the OpenDC web UI. + */ + private static String substituteVariables(String contents, Function<String, String> substitute) { + Matcher m = PATTERN.matcher(contents); + StringBuilder sb = new StringBuilder(); + + while (m.find()) { + String group = m.group(1); + String val = substitute.apply(group); + m.appendReplacement(sb, val != null ? val : group); + } + + m.appendTail(sb); + return sb.toString(); + } + } + + /** + * A {@link BooleanSupplier} to determine if the OpenDC web UI extension should be included. + */ + private static class IsIncluded implements BooleanSupplier { + OpenDCUiConfig config; + + @Override + public boolean getAsBoolean() { + return config.include; + } + } + + private static ResolvedDependency getAppArtifact(CurateOutcomeBuildItem curateOutcomeBuildItem, GACT artifactKey) { + for (ResolvedDependency dep : curateOutcomeBuildItem.getApplicationModel().getDependencies()) { + if (dep.getKey().equals(artifactKey)) { + return dep; + } + } + throw new RuntimeException("Could not find artifact " + artifactKey + " among the application dependencies"); + } +} diff --git a/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiRoutingBuildItem.java b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiRoutingBuildItem.java new file mode 100644 index 00000000..7e0f9408 --- /dev/null +++ b/opendc-web/opendc-web-ui-quarkus-deployment/src/main/java/org/opendc/web/ui/deployment/OpenDCUiRoutingBuildItem.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2022 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.opendc.web.ui.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; + +import java.util.List; + +/** + * Build item containing the routes for the OpenDC web UI. + */ +public final class OpenDCUiRoutingBuildItem extends SimpleBuildItem { + + private final boolean custom404; + private final List<Page> pages; + private final List<Redirect> redirects; + + /** + * Construct a {@link OpenDCUiRoutingBuildItem} instance. + * + * @param routes The routes defined by Next.js. + * @param redirects The redirects that have been defined by Next.js. + * @param custom404 A flag to indicate that custom 404 pages are enabled. + */ + public OpenDCUiRoutingBuildItem(List<Page> routes, List<Redirect> redirects, boolean custom404) { + this.custom404 = custom404; + this.pages = routes; + this.redirects = redirects; + } + + public List<Page> getPages() { + return pages; + } + + public List<Redirect> getRedirects() { + return redirects; + } + + public boolean hasCustom404() { + return this.custom404; + } + + /** + * A redirect defined by the Next.js routes manifest. + */ + public static final class Redirect { + + private final String path; + private final String destination; + private final int statusCode; + + /** + * Construct a {@link Redirect} route. + * + * @param path The path that should result in a redirect. + * @param destination The destination of the redirect. + * @param statusCode The status code of the redirect. + */ + public Redirect(String path, String destination, int statusCode) { + this.statusCode = statusCode; + this.path = path; + this.destination = destination; + } + + public String getPath() { + return path; + } + + public String getDestination() { + return destination; + } + + public int getStatusCode() { + return statusCode; + } + } + + /** + * A page defined by the Next.js routes manifest. + */ + public static final class Page { + + private final String path; + private final String name; + + public Page(String path, String page) { + this.path = path; + this.name = page; + } + + public String getPath() { + return path; + } + + public String getName() { + return name; + } + } +} |
