diff --git a/.classpath b/.classpath deleted file mode 100644 index 212c7fcb..00000000 --- a/.classpath +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.gitignore b/.gitignore index 73900435..edd64534 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ /application.yml /.project *.gz +.project +.classpath +.settings diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index 5fd4d502..00000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index eb919476..00000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1 +0,0 @@ -distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.3.3/apache-maven-3.3.3-bin.zip \ No newline at end of file diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs deleted file mode 100644 index cf6931b9..00000000 --- a/.settings/org.eclipse.core.resources.prefs +++ /dev/null @@ -1,4 +0,0 @@ -eclipse.preferences.version=1 -encoding//src/main/java=UTF-8 -encoding//src/main/resources=UTF-8 -encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs deleted file mode 100644 index 91ca62e2..00000000 --- a/.settings/org.eclipse.jdt.core.prefs +++ /dev/null @@ -1,14 +0,0 @@ -eclipse.preferences.version=1 -org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled -org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate -org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8 -org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve -org.eclipse.jdt.core.compiler.compliance=1.8 -org.eclipse.jdt.core.compiler.debug.lineNumber=generate -org.eclipse.jdt.core.compiler.debug.localVariable=generate -org.eclipse.jdt.core.compiler.debug.sourceFile=generate -org.eclipse.jdt.core.compiler.problem.assertIdentifier=error -org.eclipse.jdt.core.compiler.problem.enumIdentifier=error -org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning -org.eclipse.jdt.core.compiler.release=disabled -org.eclipse.jdt.core.compiler.source=1.8 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs deleted file mode 100644 index f897a7f1..00000000 --- a/.settings/org.eclipse.m2e.core.prefs +++ /dev/null @@ -1,4 +0,0 @@ -activeProfiles= -eclipse.preferences.version=1 -resolveWorkspaceProjects=true -version=1 diff --git a/.settings/org.eclipse.wst.common.project.facet.core.xml b/.settings/org.eclipse.wst.common.project.facet.core.xml deleted file mode 100644 index d8582952..00000000 --- a/.settings/org.eclipse.wst.common.project.facet.core.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/Jenkinsfile b/Jenkinsfile index fd59b2f3..e86eef4f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,6 +3,7 @@ pipeline { agent { kubernetes { yamlFile 'kubernetesPod.yaml' + workspaceVolume dynamicPVC(accessModes: 'ReadWriteOnce', requestsSize: '40Gi') } } @@ -20,7 +21,7 @@ pipeline { configFileProvider([configFile(fileId: 'maven-settings-rsb', variable: 'MAVEN_SETTINGS_RSB')]) { - sh 'mvn -B -s $MAVEN_SETTINGS_RSB -U clean deploy' + sh 'mvn -B -s $MAVEN_SETTINGS_RSB -Dmaven.repo.local=/home/jenkins/agent/m2 -U clean deploy' } } diff --git a/LICENSE_HEADER b/LICENSE_HEADER index 8a89b71b..75db0f4e 100755 --- a/LICENSE_HEADER +++ b/LICENSE_HEADER @@ -1,6 +1,6 @@ ShinyProxy -Copyright (C) 2016-2021 Open Analytics +Copyright (C) 2016-2023 Open Analytics =========================================================================== diff --git a/README.md b/README.md index a7d385ad..8a391f63 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Open Source Enterprise Deployment for Shiny Apps Learn more at https://shinyproxy.io -#### (c) Copyright Open Analytics NV, 2016-2021 - Apache License 2.0 +#### (c) Copyright Open Analytics NV, 2016-2023 - Apache License 2.0 ## Building from source @@ -33,7 +33,7 @@ The build will result in a single `.jar` file that is made available in the `tar ## Running the application ``` -java -jar shinyproxy-2.3.0.jar +java -jar shinyproxy-3.0.0.jar ``` Navigate to http://localhost:8080 to access the application. If the default configuration is used, authentication will be done against the LDAP server at *ldap.forumsys.com*; to log in one can use the user name "tesla" and password "password". @@ -42,4 +42,3 @@ Navigate to http://localhost:8080 to access the application. If the default con ## Further information https://shinyproxy.io - diff --git a/kubernetesPod.yaml b/kubernetesPod.yaml index e69f0c12..0b3edd24 100644 --- a/kubernetesPod.yaml +++ b/kubernetesPod.yaml @@ -1,12 +1,30 @@ apiVersion: v1 kind: Pod metadata: + name: shinyproxy labels: ci: shinyproxy-build spec: + securityContext: + fsGroup: 65534 containers: - - name: containerproxy-build - image: 196229073436.dkr.ecr.eu-west-1.amazonaws.com/openanalytics/containerproxy-build - command: - - cat - tty: true + - name: containerproxy-build + image: 196229073436.dkr.ecr.eu-west-1.amazonaws.com/openanalytics/containerproxy-build + securityContext: + privileged: true + command: [ "sh" ] + args: [ "/usr/src/app/docker-entrypoint.sh" ] + tty: true + volumeMounts: + - name: workspace-volume + subPath: docker + mountPath: /var/lib/docker + resources: + requests: + ephemeral-storage: "20Gi" + memory: "2Gi" + cpu: "1.0" + limits: + memory: "4Gi" + cpu: "1.5" + ephemeral-storage: "20Gi" diff --git a/owasp-suppression.xml b/owasp-suppression.xml index 368acb00..032f955b 100644 --- a/owasp-suppression.xml +++ b/owasp-suppression.xml @@ -2,14 +2,27 @@ - - - - CVE-2018-1258 + CVE-2013-4152 + CVE-2013-7315 + CVE-2014-0054 + CVE-2016-1000027 + CVE-2018-11039 + CVE-2018-11040 + CVE-2018-1257 + CVE-2020-5421 + CVE-2022-22950 + CVE-2022-22965 + CVE-2022-22968 + CVE-2022-22970 + CVE-2022-22976 + CVE-2022-22978 + CVE-2019-3772 + CVE-2022-27772 + CVE-2020-5408 + + CVE-2021-20323 + CVE-2021-3632 + CVE-2021-3637 + CVE-2021-3827 + CVE-2021-3856 + CVE-2021-4133 + CVE-2022-1245 + CVE-2022-1466 + + - 7e39112810f6096061c43504188d18edc7d7eece + CVE-2021-29425 + + CVE-2021-37533 @@ -78,4 +107,25 @@ CVE-2015-5258 + + + + CVE-2016-7046 + CVE-2016-6311 + + + + + CVE-2022-45868 + + + + + CVE-2017-10355 + + + + + CVE-2020-23171 + diff --git a/pom.xml b/pom.xml index 5aa3efae..b38ee3e3 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ eu.openanalytics shinyproxy - 2.6.1 + 3.0.2 jar ShinyProxy @@ -19,14 +19,16 @@ org.springframework.boot spring-boot-starter-parent - 2.5.12 + 2.7.6 UTF-8 1.8 - 0.8.11 + 8 + 8 + 1.0.2 & @@ -51,7 +53,6 @@ oa-nexus-snapshots OpenAnalytics Snapshots Repository - https://nexus.openanalytics.eu/repository/snapshots true false @@ -59,7 +60,6 @@ oa-nexus-releases OpenAnalytics Snapshots Repository - https://nexus.openanalytics.eu/repository/releases false true @@ -72,53 +72,89 @@ containerproxy ${containerproxy.version} - - com.fasterxml.jackson.datatype - jackson-datatype-jsr353 - 2.11.2 - - org.springframework.boot spring-boot-starter-mail + 2.7.6 + + + org.webjars + datatables + 1.12.1 org.webjars - js-cookie - 2.2.1 + datatables-buttons + 2.2.2 + + + org.webjars + datatables-responsive + 2.2.7 org.webjars handlebars - 4.7.6 + 4.7.7 io.undertow undertow-core - 2.2.8.Final + 2.2.21.Final io.undertow undertow-servlet - 2.2.8.Final + 2.2.21.Final io.undertow undertow-websockets-jsr - 2.2.8.Final + 2.2.21.Final org.jboss.xnio xnio-nio - 3.8.4.Final + 3.8.8.Final org.jboss.xnio xnio-api - 3.8.4.Final - + 3.8.8.Final + + + org.projectlombok + lombok + provided + + + + org.yaml + snakeyaml + 1.33 + + + + + org.glassfish.jersey + jersey-bom + 2.26-b03 + pom + import + + + + io.netty + netty-bom + 4.1.86.Final + pom + import + + + + @@ -143,6 +179,7 @@ repackage + exec eu.openanalytics.containerproxy.ContainerProxyApplication @@ -159,7 +196,21 @@ - + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + + org.codehaus.mojo rpm-maven-plugin @@ -408,13 +459,6 @@ false - - maven-deploy-plugin - 3.0.0-M1 - - true - - diff --git a/src/main/java/eu/openanalytics/shinyproxy/AppRequestInfo.java b/src/main/java/eu/openanalytics/shinyproxy/AppRequestInfo.java index 7bab27a9..f6aaad9e 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/AppRequestInfo.java +++ b/src/main/java/eu/openanalytics/shinyproxy/AppRequestInfo.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -20,8 +20,6 @@ */ package eu.openanalytics.shinyproxy; -import eu.openanalytics.containerproxy.util.BadRequestException; - import javax.servlet.http.HttpServletRequest; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -35,19 +33,17 @@ public class AppRequestInfo { private final String appName; private final String appInstance; private final String subPath; + private final String appPath; - public AppRequestInfo(String appName, String appInstance, String subPath) { + public AppRequestInfo(String appName, String appInstance, String appPath, String subPath) { this.appName = appName; this.appInstance = appInstance; + this.appPath = appPath; this.subPath = subPath; } - public static AppRequestInfo fromRequestOrException(HttpServletRequest request) { - AppRequestInfo result = fromURI(request.getRequestURI()); - if (result == null) { - throw new BadRequestException("Error parsing URL."); - } - return result; + public static AppRequestInfo fromRequestOrNull(HttpServletRequest request) { + return fromURI(request.getRequestURI()); } public static AppRequestInfo fromURI(String uri) { @@ -56,47 +52,54 @@ public static AppRequestInfo fromURI(String uri) { if (appInstanceMatcher.matches()) { String appName = appInstanceMatcher.group(2); if (appName == null || appName.trim().equals("")) { - throw new BadRequestException("Error parsing URL: name of app not found in URL."); + return null; } String appInstance = appInstanceMatcher.group(3); if (appInstance == null || appInstance.trim().equals("")) { - throw new BadRequestException("Error parsing URL: name of instance not found in URL."); + return null; } if (appInstance.length() > 64 || !INSTANCE_NAME_PATTERN.matcher(appInstance).matches()) { - throw new BadRequestException("Error parsing URL: name of instance contains invalid characters or is too long."); + return null; } String subPath = appInstanceMatcher.group(4); + String appPath; if (subPath == null || subPath.trim().equals("")) { subPath = null; + appPath = uri; } else { subPath = subPath.trim(); + appPath = uri.substring(0, uri.length() - subPath.length()); } - return new AppRequestInfo(appName, appInstance, subPath); + return new AppRequestInfo(appName, appInstance, appPath, subPath); } else if (appMatcher.matches()) { String appName = appMatcher.group(2); if (appName == null || appName.trim().equals("")) { - throw new BadRequestException("Error parsing URL: name of app not found in URL."); + return null; } String appInstance = "_"; String subPath = appMatcher.group(3); + String appPath; if (subPath == null || subPath.trim().equals("")) { subPath = null; + appPath = uri; } else { subPath = subPath.trim(); + appPath = uri.substring(0, uri.length() - subPath.length()); } - return new AppRequestInfo(appName, appInstance, subPath); + return new AppRequestInfo(appName, appInstance, appPath, subPath); } else { return null; } } + public String getAppInstance() { return appInstance; } @@ -115,4 +118,9 @@ public String getAppName() { public String getSubPath() { return subPath; } + + public String getAppPath() { + return appPath; + } + } diff --git a/src/main/java/eu/openanalytics/shinyproxy/AuthenticationRequiredFilter.java b/src/main/java/eu/openanalytics/shinyproxy/AuthenticationRequiredFilter.java index a68d3f05..443fc936 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/AuthenticationRequiredFilter.java +++ b/src/main/java/eu/openanalytics/shinyproxy/AuthenticationRequiredFilter.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -20,10 +20,11 @@ */ package eu.openanalytics.shinyproxy; -import eu.openanalytics.shinyproxy.controllers.AppController; +import eu.openanalytics.shinyproxy.controllers.dto.ShinyProxyApiResponse; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.ClientAuthorizationRequiredException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.util.ThrowableAnalyzer; @@ -42,26 +43,31 @@ /** * A filter that blocks the default {@link AuthenticationEntryPoint} when requests are made to certain endpoints. + * These endpoints are only accessed from AJAX calls. * These endpoints are: - * - /app_direct_i/* /* /** (without spaces), i.e. any subpath on the app_direct endpoint (thus not the page that loads the app) + * - /app_proxy/** (without spaces), i.e. any subpath on the app_direct endpoint (thus not the page that loads the app) * - /heartbeat/* , i.e. heartbeat requests + * - /api/** + * - /admin/data * * When the filter detects that a user is not authenticated when requesting one of these endpoints, it returns the response: - * {"status":"error", "message":"shinyproxy_authentication_required"} with status code 401. + * {"status":"fail", "data":"shinyproxy_authentication_required"} with status code 401. * This response is specific unique enough such that it can be handled by the frontend. * - * See {@link AppController#appDirect} where a similar approach is used for apps that have been stopped. - * * Note: this cannot be easily implemented as a {@link AuthenticationEntryPoint} since these entrypoints are sometimes, * but not always overridden by the authentication backend. + * See #26403, #28490 */ public class AuthenticationRequiredFilter extends GenericFilterBean { private final ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer(); private static final RequestMatcher REQUEST_MATCHER = new OrRequestMatcher( - new AntPathRequestMatcher("/app_direct_i/*/*/**"), - new AntPathRequestMatcher("/heartbeat/*")); + new AntPathRequestMatcher("/app_proxy/**"), + new AntPathRequestMatcher("/heartbeat/*"), + new AntPathRequestMatcher("/api/**"), + new AntPathRequestMatcher("/admin/data") + ); public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; @@ -77,8 +83,7 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex); } SecurityContextHolder.getContext().setAuthentication(null); - response.setStatus(401); - response.getWriter().write("{\"status\":\"error\", \"message\":\"shinyproxy_authentication_required\"}"); + ShinyProxyApiResponse.authenticationRequired(response); return; } throw ex; @@ -91,12 +96,16 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) */ private boolean isAuthException(Exception ex) { Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex); - RuntimeException ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain); - if (ase != null) { + Throwable type = throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain); + if (type != null) { + return true; + } + type = throwableAnalyzer.getFirstThrowableOfType(ClientAuthorizationRequiredException.class, causeChain); + if (type != null) { return true; } - ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain); - return ase != null; + type = throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain); + return type != null; } /** diff --git a/src/main/java/eu/openanalytics/shinyproxy/OperatorCookieFilter.java b/src/main/java/eu/openanalytics/shinyproxy/OperatorCookieFilter.java deleted file mode 100644 index 17ee3984..00000000 --- a/src/main/java/eu/openanalytics/shinyproxy/OperatorCookieFilter.java +++ /dev/null @@ -1,129 +0,0 @@ -/** - * ShinyProxy - * - * Copyright (C) 2016-2021 Open Analytics - * - * =========================================================================== - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Apache License as published by - * The Apache Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program 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 - * Apache License for more details. - * - * You should have received a copy of the Apache License - * along with this program. If not, see - */ -package eu.openanalytics.shinyproxy; - - -import org.apache.commons.lang3.tuple.ImmutableTriple; -import org.springframework.data.util.Pair; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.web.filter.GenericFilterBean; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; - -public class OperatorCookieFilter extends GenericFilterBean { - - public static final List REQUEST_MATCHERS = Arrays.asList( - // all methods (to support the various auth backends) - new FilterMatcher(new AntPathRequestMatcher("/"), false, ""), - // all methods (to support the various auth backends) - new FilterMatcher(new AntPathRequestMatcher("/login"),false, ""), - new FilterMatcher(new AntPathRequestMatcher("/logout-success", HttpMethod.GET.name()), false, "logout-success"), - // redirect to main page in order to re-try auth on new server - new FilterMatcher(new AntPathRequestMatcher("/auth-error", HttpMethod.GET.name()), true, ""), - // redirect to main page in order to re-try auth on new server - new FilterMatcher(new AntPathRequestMatcher("/app-access-denied", HttpMethod.GET.name()), true, "")); - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletRequest httpRequest = (HttpServletRequest) request; - - FilterMatcher match = match(httpRequest); - - if (match == null) { - chain.doFilter(request, response); - return; - } - - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - - // only continue if the FilterMatcher allows authenticated requests OR if the user is NOT logged in - if (match.alsoIfAuthenticated || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { - String currentInstance = httpRequest.getHeader("X-ShinyProxy-Instance"); - String latestInstance = httpRequest.getHeader("X-ShinyProxy-Latest-Instance"); - - if (currentInstance != null && latestInstance != null) { - if (!currentInstance.equals(latestInstance)) { - String pattern = match.requestMatcher.getPattern(); - HttpServletResponse httpResponse = (HttpServletResponse) response; - httpResponse.sendRedirect( - ServletUriComponentsBuilder - .fromCurrentContextPath() - .path("/server-transfer") - .queryParam("redirectUri", pattern) - .build() - .toUriString() - ); - return; - } - } - } - - chain.doFilter(request, response); - } - - public static FilterMatcher match(HttpServletRequest request) { - for (FilterMatcher matcher : REQUEST_MATCHERS) { - if (matcher.requestMatcher.matches(request)) { - return matcher; - } - } - return null; - } - - public static String getRedirectUriByMatch(String redirectUri) { - if (redirectUri == null) { - return ""; - } - for (FilterMatcher matcher : REQUEST_MATCHERS) { - if (matcher.requestMatcher.getPattern().equals(redirectUri)) { - return matcher.redirectUri; - } - } - return ""; - } - - private static class FilterMatcher { - public final AntPathRequestMatcher requestMatcher; - public final Boolean alsoIfAuthenticated; - public final String redirectUri; - - public FilterMatcher(AntPathRequestMatcher requestMatcher, Boolean alsoIfAuthenticated, String redirectUri) { - this.requestMatcher = requestMatcher; - this.alsoIfAuthenticated = alsoIfAuthenticated; - this.redirectUri = redirectUri; - } - } - -} - diff --git a/src/main/java/eu/openanalytics/shinyproxy/OperatorEnabledCondition.java b/src/main/java/eu/openanalytics/shinyproxy/OperatorEnabledCondition.java deleted file mode 100644 index d2ca4ea1..00000000 --- a/src/main/java/eu/openanalytics/shinyproxy/OperatorEnabledCondition.java +++ /dev/null @@ -1,34 +0,0 @@ -/** - * ShinyProxy - * - * Copyright (C) 2016-2021 Open Analytics - * - * =========================================================================== - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Apache License as published by - * The Apache Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program 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 - * Apache License for more details. - * - * You should have received a copy of the Apache License - * along with this program. If not, see - */ -package eu.openanalytics.shinyproxy; - -import org.springframework.context.annotation.Condition; -import org.springframework.context.annotation.ConditionContext; -import org.springframework.core.type.AnnotatedTypeMetadata; - -public class OperatorEnabledCondition implements Condition { - - @Override - public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { - return context.getEnvironment().getProperty("proxy.realm-id") != null; - } - -} diff --git a/src/main/java/eu/openanalytics/shinyproxy/OperatorService.java b/src/main/java/eu/openanalytics/shinyproxy/OperatorService.java deleted file mode 100644 index 0b3bdba7..00000000 --- a/src/main/java/eu/openanalytics/shinyproxy/OperatorService.java +++ /dev/null @@ -1,85 +0,0 @@ -/** - * ShinyProxy - * - * Copyright (C) 2016-2021 Open Analytics - * - * =========================================================================== - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Apache License as published by - * The Apache Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program 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 - * Apache License for more details. - * - * You should have received a copy of the Apache License - * along with this program. If not, see - */ -package eu.openanalytics.shinyproxy; - -import eu.openanalytics.containerproxy.service.IdentifierService; -import org.springframework.core.env.Environment; -import org.springframework.stereotype.Component; - -import javax.annotation.PostConstruct; -import javax.inject.Inject; - -@Component -public class OperatorService { - - @Inject - private Environment environment; - - @Inject - private IdentifierService identifierService; - - private Boolean isEnabled; - - private Boolean mustForceTransfer; - - private Boolean showTransferMessageOnMainPage; - - private Boolean showTransferMessageOnAppPage; - - @PostConstruct - public void init() { - isEnabled = identifierService.realmId != null; - mustForceTransfer = environment.getProperty("proxy.operator.force-transfer", Boolean.class, false); - showTransferMessageOnAppPage = environment.getProperty("proxy.operator.show-transfer-message-app-page", Boolean.class, true); - showTransferMessageOnMainPage = environment.getProperty("proxy.operator.show-transfer-message-main-page", Boolean.class, true); - } - - /** - * @return whether this ShinyProxy server is running in an environment controlled by the ShinyProxy operator. - */ - public Boolean isEnabled() { - return isEnabled; - } - - /** - * @return whether to force transferring the user to the latest instance if no apps running and if the user - * is authenticated. (this is unrelated to transferring the user before logging in and after logging out) - */ - public Boolean mustForceTransfer() { - return mustForceTransfer; - } - - /** - * @return whether a message/popup should be shown on the app page when the user is using an old server and they - * have at least one app running. - */ - public Boolean showTransferMessageOnAppPage() { - return showTransferMessageOnAppPage; - } - - /** - * @return whether a message/popup should be shown on the main page when the user is using an old server and they - * have at least one app running. - */ - public Boolean showTransferMessageOnMainPage() { - return showTransferMessageOnMainPage; - } -} diff --git a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyConfiguration.java b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyConfiguration.java index 4ce8eeb8..c164bfc6 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyConfiguration.java +++ b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyConfiguration.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -22,9 +22,10 @@ import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValueKeyRegistry; import eu.openanalytics.shinyproxy.runtimevalues.AppInstanceKey; -import eu.openanalytics.shinyproxy.runtimevalues.MaxInstancesKey; import eu.openanalytics.shinyproxy.runtimevalues.PublicPathKey; import eu.openanalytics.shinyproxy.runtimevalues.ShinyForceFullReloadKey; +import eu.openanalytics.shinyproxy.runtimevalues.TrackAppUrl; +import eu.openanalytics.shinyproxy.runtimevalues.UserTimeZoneKey; import eu.openanalytics.shinyproxy.runtimevalues.WebSocketReconnectionModeKey; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; @@ -35,9 +36,10 @@ public class ShinyProxyConfiguration { static { RuntimeValueKeyRegistry.addRuntimeValueKey(AppInstanceKey.inst); - RuntimeValueKeyRegistry.addRuntimeValueKey(MaxInstancesKey.inst); RuntimeValueKeyRegistry.addRuntimeValueKey(PublicPathKey.inst); RuntimeValueKeyRegistry.addRuntimeValueKey(ShinyForceFullReloadKey.inst); RuntimeValueKeyRegistry.addRuntimeValueKey(WebSocketReconnectionModeKey.inst); + RuntimeValueKeyRegistry.addRuntimeValueKey(TrackAppUrl.inst); + RuntimeValueKeyRegistry.addRuntimeValueKey(UserTimeZoneKey.inst); } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyIframeScriptInjector.java b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyIframeScriptInjector.java new file mode 100644 index 00000000..e3739fe6 --- /dev/null +++ b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyIframeScriptInjector.java @@ -0,0 +1,170 @@ +/** + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.shinyproxy; + +import eu.openanalytics.containerproxy.util.ContextPathHelper; +import io.netty.buffer.ByteBuf; +import io.undertow.UndertowMessages; +import io.undertow.server.HttpServerExchange; +import io.undertow.server.protocol.http.ServerFixedLengthStreamSinkConduit; +import io.undertow.util.Headers; +import org.springframework.http.HttpStatus; +import org.xnio.IoUtils; +import org.xnio.channels.StreamSourceChannel; +import org.xnio.conduits.AbstractStreamSinkConduit; +import org.xnio.conduits.ConduitWritableByteChannel; +import org.xnio.conduits.Conduits; +import org.xnio.conduits.StreamSinkConduit; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; + +/** + * The goal of this class is to inject a `"; + outputStream.write(r.getBytes(StandardCharsets.UTF_8)); + } + + ByteBuffer out = ByteBuffer.wrap(outputStream.toByteArray()); + // 3. set Content-Length header + updateContentLength(exchange, out); + // 4. write new response (to the next stream) + do { + next.write(out); + } while (out.hasRemaining()); + + // 5. call parent method + super.terminateWrites(); + } + + private void updateContentLength(HttpServerExchange exchange, ByteBuffer output) { + long length = output.limit(); + + // check works case-insensitive + if (!exchange.getResponseHeaders().contains("Transfer-Encoding")) { + exchange.getResponseHeaders().put(Headers.CONTENT_LENGTH, length); + } + + // also update length of ServerFixedLengthStreamSinkConduit + if (next instanceof ServerFixedLengthStreamSinkConduit) { + Method m; + + try { + m = ServerFixedLengthStreamSinkConduit.class.getDeclaredMethod( + "reset", + long.class, + HttpServerExchange.class); + m.setAccessible(true); + } + catch (NoSuchMethodException | SecurityException ex) { + throw new RuntimeException("could not find ServerFixedLengthStreamSinkConduit.reset method", ex); + } + + try { + m.invoke(next, length, exchange); + } + catch (Throwable ex) { + throw new RuntimeException("could not access BUFFERED_REQUEST_DATA field", ex); + } + } + } + +} diff --git a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecExtension.java b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecExtension.java new file mode 100644 index 00000000..799b2417 --- /dev/null +++ b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecExtension.java @@ -0,0 +1,76 @@ +/** + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.shinyproxy; + +import eu.openanalytics.containerproxy.model.spec.AbstractSpecExtension; +import eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext; +import eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver; +import eu.openanalytics.containerproxy.spec.expression.SpelField; +import eu.openanalytics.shinyproxy.runtimevalues.WebsocketReconnectionMode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.HashMap; +import java.util.Map; + +@EqualsAndHashCode(callSuper = true) +@Data +@Setter +@Getter +@Builder(toBuilder = true) +@AllArgsConstructor +@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE) // Jackson deserialize compatibility +public class ShinyProxySpecExtension extends AbstractSpecExtension { + + WebsocketReconnectionMode websocketReconnectionMode; + + Boolean shinyForceFullReload; + + @Builder.Default + SpelField.Integer maxInstances = new SpelField.Integer(); + + Boolean hideNavbarOnMainPageLink; + + Boolean alwaysShowSwitchInstance; + + Boolean trackAppUrl; + + String templateGroup; + + Map templateProperties = new HashMap<>(); + + @Override + public ShinyProxySpecExtension firstResolve(SpecExpressionResolver resolver, SpecExpressionContext context) { + return this; + } + + @Override + public ShinyProxySpecExtension finalResolve(SpecExpressionResolver resolver, SpecExpressionContext context) { + return this; + } + +} diff --git a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecExtensionProvider.java b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecExtensionProvider.java new file mode 100644 index 00000000..ba4dcca1 --- /dev/null +++ b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecExtensionProvider.java @@ -0,0 +1,61 @@ +/** + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.shinyproxy; + + +import eu.openanalytics.containerproxy.spec.IProxySpecProvider; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.List; + +@Configuration +@ConfigurationProperties(prefix = "proxy") +public class ShinyProxySpecExtensionProvider { + + private List specs; + + @Inject + private IProxySpecProvider proxySpecProvider; + + @PostConstruct + public void postInit() { + if (specs == null) { + this.specs = new ArrayList<>(); + return; + } + specs.forEach(specExtension -> { + proxySpecProvider.getSpec(specExtension.getId()).addSpecExtension(specExtension); + }); + } + + public void setSpecs(List specs) { + this.specs = specs; + } + + public List getSpecs() { + return specs; + } + +} diff --git a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecMergeStrategy.java b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecMergeStrategy.java deleted file mode 100644 index 40b3e6ff..00000000 --- a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecMergeStrategy.java +++ /dev/null @@ -1,48 +0,0 @@ -/** - * ShinyProxy - * - * Copyright (C) 2016-2021 Open Analytics - * - * =========================================================================== - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Apache License as published by - * The Apache Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program 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 - * Apache License for more details. - * - * You should have received a copy of the Apache License - * along with this program. If not, see - */ -package eu.openanalytics.shinyproxy; - -import java.util.Set; - -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Component; - -import eu.openanalytics.containerproxy.model.runtime.RuntimeSetting; -import eu.openanalytics.containerproxy.model.spec.ProxySpec; -import eu.openanalytics.containerproxy.spec.IProxySpecMergeStrategy; -import eu.openanalytics.containerproxy.spec.ProxySpecException; - -@Component -@Primary -public class ShinyProxySpecMergeStrategy implements IProxySpecMergeStrategy { - - @Override - public ProxySpec merge(ProxySpec baseSpec, ProxySpec runtimeSpec, Set runtimeSettings) throws ProxySpecException { - if (baseSpec == null) throw new ProxySpecException("Base proxy spec is required but missing"); - if (runtimeSpec != null) throw new ProxySpecException("Runtime proxy specs are not allowed"); - if (runtimeSettings != null && !runtimeSettings.isEmpty()) throw new ProxySpecException("Runtime proxy settings are not allowed"); - - ProxySpec finalSpec = new ProxySpec(); - baseSpec.copy(finalSpec); - return finalSpec; - } - -} diff --git a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecProvider.java b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecProvider.java index 9d8866a9..1c390c11 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecProvider.java +++ b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxySpecProvider.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -26,36 +26,43 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import javax.annotation.PostConstruct; - -import org.springframework.beans.factory.annotation.Value; -import eu.openanalytics.containerproxy.model.runtime.Proxy; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue; -import eu.openanalytics.shinyproxy.runtimevalues.MaxInstancesKey; +import eu.openanalytics.containerproxy.model.spec.AccessControl; +import eu.openanalytics.containerproxy.model.spec.ContainerSpec; +import eu.openanalytics.containerproxy.model.spec.DockerSwarmSecret; +import eu.openanalytics.containerproxy.model.spec.Parameters; +import eu.openanalytics.containerproxy.model.spec.PortMapping; +import eu.openanalytics.containerproxy.model.spec.ProxySpec; +import eu.openanalytics.containerproxy.service.UserService; +import eu.openanalytics.containerproxy.spec.IProxySpecProvider; +import eu.openanalytics.containerproxy.spec.expression.SpecExpressionContext; +import eu.openanalytics.containerproxy.spec.expression.SpecExpressionResolver; +import eu.openanalytics.containerproxy.spec.expression.SpelField; import eu.openanalytics.shinyproxy.runtimevalues.ShinyForceFullReloadKey; +import eu.openanalytics.shinyproxy.runtimevalues.TrackAppUrl; import eu.openanalytics.shinyproxy.runtimevalues.WebSocketReconnectionModeKey; -import org.springframework.beans.factory.annotation.Autowired; import eu.openanalytics.shinyproxy.runtimevalues.WebsocketReconnectionMode; +import eu.openanalytics.shinyproxy.ShinyProxySpecProvider.ShinyProxySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Primary; import org.springframework.core.env.Environment; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.introspector.PropertyUtils; - -import eu.openanalytics.containerproxy.model.spec.ContainerSpec; -import eu.openanalytics.containerproxy.model.spec.ProxyAccessControl; -import eu.openanalytics.containerproxy.model.spec.ProxySpec; -import eu.openanalytics.containerproxy.spec.IProxySpecProvider; -import eu.openanalytics.shinyproxy.ShinyProxySpecProvider.ShinyProxySpec; +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; class Dummy { @@ -82,9 +89,10 @@ public void setSpecs(List specs) { public class ShinyProxySpecProvider implements IProxySpecProvider { private static final String PROP_DEFAULT_MAX_INSTANCES = "proxy.default-max-instances"; + private static final String PROP_DEFAULT_ALWAYS_SWITCH_INSTANCE = "proxy.default-always-switch-instance"; private List specs = new ArrayList<>(); - private Map shinyProxySpecs = new HashMap<>(); + private List templateGroups = new ArrayList<>(); private long specsFileTs = 0; @@ -148,6 +156,17 @@ public class ShinyProxySpecProvider implements IProxySpecProvider { private static Environment environment; + private String defaultMaxInstances; + + private Boolean defaultAlwaysSwitchInstance; + + @Inject + private SpecExpressionResolver expressionResolver; + + @Inject + @Lazy + private UserService userService; + @Autowired public void setEnvironment(Environment env){ ShinyProxySpecProvider.environment = env; @@ -159,6 +178,9 @@ public void afterPropertiesSet() { this.specs.stream().collect(Collectors.groupingBy(ProxySpec::getId)).forEach((id, duplicateSpecs) -> { if (duplicateSpecs.size() > 1) throw new IllegalArgumentException(String.format("Configuration error: spec with id '%s' is defined multiple times", id)); }); + defaultMaxInstances = environment.getProperty(PROP_DEFAULT_MAX_INSTANCES, String.class, "1"); + defaultAlwaysSwitchInstance = environment.getProperty(PROP_DEFAULT_ALWAYS_SWITCH_INSTANCE, Boolean.class, false); + specs.forEach(ProxySpec::setContainerIndex); } public List getSpecs() { @@ -170,7 +192,7 @@ public List getSpecs() { Dummy obj = yaml.load(new FileInputStream(specsFile)); List specsTmp = obj.getSpecs(); for(ShinyProxySpec specTmp : specsTmp){ - specTmp.setContainerNetwork(containerNetwork); + specTmp.setContainerNetwork(new SpelField.String(containerNetwork)); Map containerEnv = specTmp.getContainerEnv(); containerEnv.put("MIRO_ENGINE_HOST", engineHost); containerEnv.put("MIRO_ENGINE_NAMESPACE", engineNs); @@ -202,22 +224,20 @@ public List getSpecs() { } specTmp.setContainerEnv(containerEnv); - String volumesTmp[] = specTmp.getContainerVolumes(); - volumesTmp[0] = modelDir.concat(volumesTmp[0]); - volumesTmp[1] = dataDir.concat(volumesTmp[1]); + List volumesTmp = specTmp.getContainerVolumes(); + volumesTmp.set(0, modelDir.concat(volumesTmp.get(0))); + volumesTmp.set(1, dataDir.concat(volumesTmp.get(1))); specTmp.setContainerVolumes(volumesTmp); if ( specTmp.getId().equals("admin") ) { - specTmp.setContainerImage(containerAdminImage); + specTmp.setContainerImage(new SpelField.String(containerAdminImage)); } else { - specTmp.setContainerImage(containerImage); + specTmp.setContainerImage(new SpelField.String(containerImage)); } } - specs = specsTmp.stream().map(s -> { - shinyProxySpecs.put(s.getId(), s); - return ShinyProxySpecProvider.convert(s); - }).collect(Collectors.toList()); + specs = specsTmp.stream().map(ShinyProxySpec::getProxySpec).collect(Collectors.toList()); + specs.forEach(ProxySpec::setContainerIndex); } } catch (FileNotFoundException e) { @@ -231,10 +251,6 @@ public ProxySpec getSpec(String id) { return specs.stream().filter(s -> id.equals(s.getId())).findAny().orElse(null); } - public ShinyProxySpec getShinyProxySpec(String specId) { - return shinyProxySpecs.get(specId); - } - public void setSpecs() { this.specs = this.getSpecs(); } @@ -251,456 +267,373 @@ public List getTemplateGroups() { return templateGroups; } - public static ProxySpec convert(ShinyProxySpec from) { - ProxySpec to = new ProxySpec(); - to.setId(from.getId()); - to.setDisplayName(from.getDisplayName()); - to.setDescription(from.getDescription()); - to.setLogoURL(from.getLogoURL()); - to.setMaxLifeTime(from.getMaxLifetime()); - to.setStopOnLogout(from.getStopOnLogout()); - to.setHeartbeatTimeout(from.getHeartbeatTimeout()); - - if (from.getKubernetesPodPatches() != null) { - try { - to.setKubernetesPodPatches(from.getKubernetesPodPatches()); - } catch (Exception e) { - throw new IllegalArgumentException(String.format("Configuration error: spec with id '%s' has invalid kubernetes-pod-patches", from.getId())); - } - } - to.setKubernetesAdditionalManifests(from.getKubernetesAdditionalManifests()); - to.setKubernetesAdditionalPersistentManifests(from.getKubernetesAdditionalPersistentManifests()); - - ProxyAccessControl acl = new ProxyAccessControl(); - to.setAccessControl(acl); - - if (from.getAccessGroups() != null && from.getAccessGroups().length > 0) { - acl.setGroups(from.getAccessGroups()); - } - - if (from.getAccessUsers() != null && from.getAccessUsers().length > 0) { - acl.setUsers(from.getAccessUsers()); - } - - if (from.getAccessExpression() != null && from.getAccessExpression().length() > 0) { - acl.setExpression(from.getAccessExpression()); - } - - ContainerSpec cSpec = new ContainerSpec(); - cSpec.setImage(from.getContainerImage()); - cSpec.setCmd(from.getContainerCmd()); - cSpec.setEnv(from.getContainerEnv()); - cSpec.setEnvFile(from.getContainerEnvFile()); - cSpec.setNetwork(from.getContainerNetwork()); - cSpec.setNetworkConnections(from.getContainerNetworkConnections()); - cSpec.setDns(from.getContainerDns()); - cSpec.setVolumes(from.getContainerVolumes()); - cSpec.setMemoryRequest(from.getContainerMemoryRequest()); - cSpec.setMemoryLimit(from.getContainerMemoryLimit()); - cSpec.setCpuRequest(from.getContainerCpuRequest()); - cSpec.setCpuLimit(from.getContainerCpuLimit()); - cSpec.setPrivileged(from.isContainerPrivileged()); - cSpec.setLabels(from.getLabels()); - cSpec.setTargetPath(from.getTargetPath()); - - Map portMapping = new HashMap<>(); - if (from.getPort() > 0) { - portMapping.put("default", from.getPort()); - } else { - portMapping.put("default", 3838); - } - cSpec.setPortMapping(portMapping); - - to.setContainerSpecs(Collections.singletonList(cSpec)); - - return to; - } - - public List getRuntimeValues(ProxySpec proxy) { List runtimeValues = new ArrayList<>(); - ShinyProxySpec shinyProxySpec = shinyProxySpecs.get(proxy.getId()); - WebsocketReconnectionMode webSocketReconnectionMode = shinyProxySpec.getWebsocketReconnectionMode(); + // WebsocketReconnectionMode webSocketReconnectionMode = proxy.getSpecExtension(ShinyProxySpecExtension.class).getWebsocketReconnectionMode(); + WebsocketReconnectionMode webSocketReconnectionMode = WebsocketReconnectionMode.Auto; if (webSocketReconnectionMode == null) { runtimeValues.add(new RuntimeValue(WebSocketReconnectionModeKey.inst, environment.getProperty("proxy.default-websocket-reconnection-mode", WebsocketReconnectionMode.class, WebsocketReconnectionMode.None))); } else { runtimeValues.add(new RuntimeValue(WebSocketReconnectionModeKey.inst, webSocketReconnectionMode)); } - runtimeValues.add(new RuntimeValue(MaxInstancesKey.inst, getMaxInstancesForSpec(proxy.getId()))); - runtimeValues.add(new RuntimeValue(ShinyForceFullReloadKey.inst, getShinyForceFullReload(proxy.getId()))); + runtimeValues.add(new RuntimeValue(ShinyForceFullReloadKey.inst, getShinyForceFullReload(proxy))); + + // Boolean trackAppUrl = proxy.getSpecExtension(ShinyProxySpecExtension.class).getTrackAppUrl(); + Boolean trackAppUrl = false; + if (trackAppUrl == null) { + trackAppUrl = environment.getProperty("proxy.default-track-app-url", Boolean.class, false); + } + runtimeValues.add(new RuntimeValue(TrackAppUrl.inst, trackAppUrl)); return runtimeValues; } - public Integer getMaxInstancesForSpec(String specId) { - ShinyProxySpec shinyProxySpec = shinyProxySpecs.get(specId); - if (shinyProxySpec == null) { - return null; - } - Integer defaultMaxInstances = environment.getProperty(PROP_DEFAULT_MAX_INSTANCES, Integer.class, 1); - Integer maxInstances = shinyProxySpec.getMaxInstances(); + public Integer getMaxInstancesForSpec(ProxySpec proxySpec) { + Authentication user = userService.getCurrentAuth(); + SpecExpressionContext context = SpecExpressionContext.create( + user, + user.getPrincipal(), + user.getCredentials()); + + // Integer maxInstances = proxySpec.getSpecExtension(ShinyProxySpecExtension.class).getMaxInstances().resolve(expressionResolver, context).getValueOrNull(); + Integer maxInstances = 1; if (maxInstances != null) { - return shinyProxySpec.getMaxInstances(); + return maxInstances; } - return defaultMaxInstances; + return expressionResolver.evaluateToInteger(defaultMaxInstances, context); } - public Boolean getShinyForceFullReload(String specId) { - ShinyProxySpec shinyProxySpec = shinyProxySpecs.get(specId); - if (shinyProxySpec == null) { - return null; - } - if (shinyProxySpec.getShinyForceFullReload() != null) { - return shinyProxySpec.getShinyForceFullReload(); + public Map getMaxInstances() { + Authentication user = userService.getCurrentAuth(); + SpecExpressionContext context = SpecExpressionContext.create( + user, + user.getPrincipal(), + user.getCredentials()); + + Map result = new HashMap<>(); + + Integer resolvedDefault = expressionResolver.evaluateToInteger(defaultMaxInstances, context); + + for (ProxySpec proxySpec: getSpecs()) { + // Integer maxInstances = proxySpec.getSpecExtension(ShinyProxySpecExtension.class).getMaxInstances().resolve(expressionResolver, context).getValueOrNull(); + Integer maxInstances = 1; + if (maxInstances != null) { + result.put(proxySpec.getId(), maxInstances); + } else { + result.put(proxySpec.getId(), resolvedDefault); + } } - return false; + + return result; } - public Boolean getHideNavbarOnMainPageLink(String specId) { - ShinyProxySpec shinyProxySpec = shinyProxySpecs.get(specId); - if (shinyProxySpec == null) { - return null; - } - if (shinyProxySpec.getHideNavbarOnMainPageLink() != null) { - return shinyProxySpec.getHideNavbarOnMainPageLink(); + public Boolean getShinyForceFullReload(ProxySpec proxySpec) { + // Boolean shinyProxyForceFullReload = proxySpec.getSpecExtension(ShinyProxySpecExtension.class).getShinyForceFullReload(); + Boolean shinyProxyForceFullReload = false; + if (shinyProxyForceFullReload != null) { + return shinyProxyForceFullReload; } return false; } - public String getTemplateGroupOfApp(String specId) { - ShinyProxySpec shinyProxySpec = shinyProxySpecs.get(specId); - if (shinyProxySpec == null) { - return null; + + public Boolean getHideNavbarOnMainPageLink(ProxySpec proxySpec) { + // Boolean hideNavbarOnMainPageLink = proxySpec.getSpecExtension(ShinyProxySpecExtension.class).getHideNavbarOnMainPageLink(); + Boolean hideNavbarOnMainPageLink = false; + if (hideNavbarOnMainPageLink != null) { + return hideNavbarOnMainPageLink; } - return shinyProxySpec.getTemplateGroup(); + return false; } - - public void postProcessRecoveredProxy(Proxy proxy) { - proxy.addRuntimeValues(getRuntimeValues(proxy.getSpec())); + + public Boolean getAlwaysShowSwitchInstance(ProxySpec proxySpec) { + // Boolean alwaysShowSwitchInstance = proxySpec.getSpecExtension(ShinyProxySpecExtension.class).getAlwaysShowSwitchInstance(); + Boolean alwaysShowSwitchInstance = null; + if (alwaysShowSwitchInstance != null) { + return alwaysShowSwitchInstance; + } + return defaultAlwaysSwitchInstance; } public static class ShinyProxySpec { - private String id; - private String displayName; - private String description; - private String logoURL; - - private String containerImage; - private String[] containerCmd; - private Map containerEnv; - private String containerEnvFile; - private String containerNetwork; - private String[] containerNetworkConnections; - private String[] containerDns; - private String[] containerVolumes; - private String containerMemoryRequest; - private String containerMemoryLimit; - private String containerCpuRequest; - private String containerCpuLimit; - private boolean containerPrivileged; - private String kubernetesPodPatches; - private List kubernetesAdditionalManifests = new ArrayList<>(); - private List kubernetesAdditionalPersistentManifests = new ArrayList<>(); - - private String targetPath; - private WebsocketReconnectionMode websocketReconnectionMode; - private Boolean shinyForceFullReload; - private Integer maxInstances; - private Boolean hideNavbarOnMainPageLink; - private Long maxLifetime; - private Boolean stopOnLogout; - private Long heartbeatTimeout; - - private Map labels; - - private int port; - private String[] accessGroups; - private String[] accessUsers; - private String accessExpression; - private String templateGroup; - private Map templateProperties = new HashMap<>(); + private final ProxySpec.ProxySpecBuilder proxySpec; + private final ContainerSpec.ContainerSpecBuilder containerSpec; + private final AccessControl accessControl; + private final PortMapping.PortMappingBuilder defaultPortMapping; + private List additionalPortMappings = new ArrayList<>(); + + public ShinyProxySpec() { + proxySpec = ProxySpec.builder(); + containerSpec = ContainerSpec.builder(); + accessControl = new AccessControl(); + defaultPortMapping = PortMapping.builder().name("default").port(3838); + proxySpec.accessControl(accessControl); + } public String getId() { - return id; + return proxySpec.build().getId(); } public void setId(String id) { - this.id = id; + proxySpec.id(id); } public String getDisplayName() { - return displayName; + return proxySpec.build().getDisplayName(); } public void setDisplayName(String displayName) { - this.displayName = displayName; + proxySpec.displayName(displayName); } public String getDescription() { - return description; + return proxySpec.build().getDescription(); } public void setDescription(String description) { - this.description = description; + proxySpec.description(description); } public String getLogoURL() { - return logoURL; + return proxySpec.build().getLogoURL(); } public void setLogoURL(String logoURL) { - this.logoURL = logoURL; + proxySpec.logoURL(logoURL); } - public String getContainerImage() { - return containerImage; + public SpelField.String getContainerImage() { + return containerSpec.build().getImage(); } - public void setContainerImage(String containerImage) { - this.containerImage = containerImage; + public void setContainerImage(SpelField.String containerImage) { + containerSpec.image(containerImage); } - public String[] getContainerCmd() { - return containerCmd; + public SpelField.StringList getContainerCmd() { + return containerSpec.build().getCmd(); } - public void setContainerCmd(String[] containerCmd) { - this.containerCmd = containerCmd; + public void setContainerCmd(List containerCmd) { + containerSpec.cmd(new SpelField.StringList(containerCmd)); } public Map getContainerEnv() { - return containerEnv; + return containerSpec.build().getEnv().getOriginalValue(); } public void setContainerEnv(Map containerEnv) { - this.containerEnv = containerEnv; + containerSpec.env(new SpelField.StringMap(containerEnv)); } - public String getContainerEnvFile() { - return containerEnvFile; + public SpelField.String getContainerEnvFile() { + return containerSpec.build().getEnvFile(); } - public void setContainerEnvFile(String containerEnvFile) { - this.containerEnvFile = containerEnvFile; + public void setContainerEnvFile(SpelField.String containerEnvFile) { + containerSpec.envFile(containerEnvFile); } - public String getContainerNetwork() { - return containerNetwork; + public SpelField.String getContainerNetwork() { + return containerSpec.build().getNetwork(); } - public void setContainerNetwork(String containerNetwork) { - this.containerNetwork = containerNetwork; + public void setContainerNetwork(SpelField.String containerNetwork) { + containerSpec.network(containerNetwork); } - public String[] getContainerNetworkConnections() { - return containerNetworkConnections; + public SpelField.StringList getContainerNetworkConnections() { + return containerSpec.build().getNetworkConnections(); } - public void setContainerNetworkConnections(String[] containerNetworkConnections) { - this.containerNetworkConnections = containerNetworkConnections; + public void setContainerNetworkConnections(List containerNetworkConnections) { + containerSpec.networkConnections(new SpelField.StringList(containerNetworkConnections)); } - public String[] getContainerDns() { - return containerDns; + public SpelField.StringList getContainerDns() { + return containerSpec.build().getDns(); } - public void setContainerDns(String[] containerDns) { - this.containerDns = containerDns; + public void setContainerDns(List containerDns) { + containerSpec.dns(new SpelField.StringList(containerDns)); } - public String[] getContainerVolumes() { - return containerVolumes; + public List getContainerVolumes() { + return containerSpec.build().getVolumes().getOriginalValue(); } - public void setContainerVolumes(String[] containerVolumes) { - this.containerVolumes = containerVolumes; + public void setContainerVolumes(List containerVolumes) { + containerSpec.volumes(new SpelField.StringList(containerVolumes)); } - public String getContainerMemoryRequest() { - return containerMemoryRequest; + public SpelField.String getContainerMemoryRequest() { + return containerSpec.build().getMemoryRequest(); } - public void setContainerMemoryRequest(String containerMemoryRequest) { - this.containerMemoryRequest = containerMemoryRequest; + public void setContainerMemoryRequest(SpelField.String containerMemoryRequest) { + containerSpec.memoryRequest(containerMemoryRequest); } - public String getContainerMemoryLimit() { - return containerMemoryLimit; + public SpelField.String getContainerMemoryLimit() { + return containerSpec.build().getMemoryLimit(); } - public void setContainerMemoryLimit(String containerMemoryLimit) { - this.containerMemoryLimit = containerMemoryLimit; + public void setContainerMemoryLimit(SpelField.String containerMemoryLimit) { + containerSpec.memoryLimit(containerMemoryLimit); } - public String getContainerCpuRequest() { - return containerCpuRequest; + public SpelField.String getContainerCpuRequest() { + return containerSpec.build().getCpuRequest(); } - public void setContainerCpuRequest(String containerCpuRequest) { - this.containerCpuRequest = containerCpuRequest; + public void setContainerCpuRequest(SpelField.String containerCpuRequest) { + containerSpec.cpuRequest(containerCpuRequest); } - public String getContainerCpuLimit() { - return containerCpuLimit; + public SpelField.String getContainerCpuLimit() { + return containerSpec.build().getCpuLimit(); } - public void setContainerCpuLimit(String containerCpuLimit) { - this.containerCpuLimit = containerCpuLimit; + public void setContainerCpuLimit(SpelField.String containerCpuLimit) { + containerSpec.cpuLimit(containerCpuLimit); } public boolean isContainerPrivileged() { - return containerPrivileged; + return containerSpec.build().isPrivileged(); } public void setContainerPrivileged(boolean containerPrivileged) { - this.containerPrivileged = containerPrivileged; + containerSpec.privileged(containerPrivileged); } - public Map getLabels() { - return labels; + public SpelField.StringMap getLabels() { + return containerSpec.build().getLabels(); } public void setLabels(Map labels) { - this.labels = labels; + containerSpec.labels(new SpelField.StringMap(labels)); } public int getPort() { - return port; + return defaultPortMapping.build().getPort(); } public void setPort(int port) { - this.port = port; + defaultPortMapping.port(port); } public String[] getAccessGroups() { - return accessGroups; + return accessControl.getGroups(); } public void setAccessGroups(String[] accessGroups) { - this.accessGroups = accessGroups; + accessControl.setGroups(accessGroups); } - public String getKubernetesPodPatches() { - return kubernetesPodPatches; + public SpelField.String getTargetPath() { + return defaultPortMapping.build().getTargetPath(); } - public void setKubernetesPodPatches(String kubernetesPodPatches) { - this.kubernetesPodPatches = kubernetesPodPatches; + public void setTargetPath(SpelField.String targetPath) { + defaultPortMapping.targetPath(targetPath); } - public void setKubernetesAdditionalManifests(List manifests) { - this.kubernetesAdditionalManifests = manifests; + public String[] getAccessUsers() { + return accessControl.getUsers(); } - public List getKubernetesAdditionalManifests() { - return kubernetesAdditionalManifests; + public void setAccessUsers(String[] accessUsers) { + accessControl.setUsers(accessUsers); } - public void setKubernetesAdditionalPersistentManifests(List manifests) { - this.kubernetesAdditionalPersistentManifests = manifests; + public String getAccessExpression() { + return accessControl.getExpression(); } - public List getKubernetesAdditionalPersistentManifests() { - return kubernetesAdditionalPersistentManifests; - } - - public String getTargetPath() { - return targetPath; + public void setAccessExpression(String accessExpression) { + accessControl.setExpression(accessExpression); } - public void setTargetPath(String targetPath) { - this.targetPath = targetPath; + public List getDockerSwarmSecrets() { + return containerSpec.build().getDockerSwarmSecrets(); } - public WebsocketReconnectionMode getWebsocketReconnectionMode() { - return websocketReconnectionMode; + public void setDockerSwarmSecrets(List dockerSwarmSecrets) { + containerSpec.dockerSwarmSecrets(dockerSwarmSecrets); } - public void setWebsocketReconnectionMode(WebsocketReconnectionMode websocketReconnectionMode) { - this.websocketReconnectionMode = websocketReconnectionMode; + public String getDockerRegistryDomain() { + return containerSpec.build().getDockerRegistryDomain(); } - public Boolean getShinyForceFullReload() { - return shinyForceFullReload; + public void setDockerRegistryDomain(String dockerRegistryDomain) { + containerSpec.dockerRegistryDomain(dockerRegistryDomain); } - public void setShinyForceFullReload(Boolean shinyForceFullReload) { - this.shinyForceFullReload = shinyForceFullReload; + public String getDockerRegistryUsername() { + return containerSpec.build().getDockerRegistryUsername(); } - public Integer getMaxInstances() { - return maxInstances; + public void setDockerRegistryUsername(String dockerRegistryUsername) { + containerSpec.dockerRegistryUsername(dockerRegistryUsername); } - public void setMaxInstances(Integer maxInstances) { - this.maxInstances = maxInstances; + public String getDockerRegistryPassword() { + return containerSpec.build().getDockerRegistryPassword(); } - public Boolean getHideNavbarOnMainPageLink() { - return hideNavbarOnMainPageLink; + public void setDockerRegistryPassword(String dockerRegistryPassword) { + containerSpec.dockerRegistryPassword(dockerRegistryPassword); } - public void setHideNavbarOnMainPageLink(Boolean hideNavbarOnMainPageLink) { - this.hideNavbarOnMainPageLink = hideNavbarOnMainPageLink; - } + public Parameters getParameters() { + return proxySpec.build().getParameters(); + } - public Long getMaxLifetime() { - return maxLifetime; + public void setParameters(Parameters parameters) { + proxySpec.parameters(parameters); + } + + public SpelField.Long getMaxLifetime() { + return proxySpec.build().getMaxLifeTime(); } - public void setMaxLifetime(Long maxLifetime) { - this.maxLifetime = maxLifetime; + public void setMaxLifetime(SpelField.Long maxLifetime) { + proxySpec.maxLifeTime(maxLifetime); } public Boolean getStopOnLogout() { - return stopOnLogout; + return proxySpec.build().getStopOnLogout(); } public void setStopOnLogout(Boolean stopOnLogout) { - this.stopOnLogout = stopOnLogout; - } - - public void setHeartbeatTimeout(Long heartbeatTimeout) { - this.heartbeatTimeout = heartbeatTimeout; + proxySpec.stopOnLogout(stopOnLogout); } - public Long getHeartbeatTimeout() { - return heartbeatTimeout; + public SpelField.Long getHeartbeatTimeout() { + return proxySpec.build().getHeartbeatTimeout(); } - public void setTemplateGroup(String templateGroup) { - this.templateGroup = templateGroup; + public void setHeartbeatTimeout(SpelField.Long heartbeatTimeout) { + proxySpec.heartbeatTimeout(heartbeatTimeout); } - public String getTemplateGroup() { - return templateGroup; + public List getAdditionalPortMappings() { + return additionalPortMappings; } - public void setTemplateProperties(Map templateProperties) { - this.templateProperties = templateProperties; + public void setAdditionalPortMappings(List additionalPortMappings) { + this.additionalPortMappings = additionalPortMappings; } - public Map getTemplateProperties() { - return templateProperties; - } - - public String[] getAccessUsers() { - return accessUsers; - } - - public void setAccessUsers(String[] accessUsers) { - this.accessUsers = accessUsers; - } - - public String getAccessExpression() { - return accessExpression; - } - - public void setAccessExpression(String accessExpression) { - this.accessExpression = accessExpression; + public ProxySpec getProxySpec() { + additionalPortMappings.add(defaultPortMapping.build()); + containerSpec.portMapping(additionalPortMappings); + proxySpec.containerSpecs(Collections.singletonList(containerSpec.build())); + return proxySpec.build(); } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyTestStrategy.java b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyTestStrategy.java index ff5e38a6..2dd7c164 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyTestStrategy.java +++ b/src/main/java/eu/openanalytics/shinyproxy/ShinyProxyTestStrategy.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -20,22 +20,19 @@ */ package eu.openanalytics.shinyproxy; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.util.Arrays; -import java.util.function.IntPredicate; - -import javax.inject.Inject; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; +import eu.openanalytics.containerproxy.backend.strategy.IProxyTestStrategy; +import eu.openanalytics.containerproxy.model.runtime.Proxy; +import eu.openanalytics.containerproxy.service.StructuredLogger; +import eu.openanalytics.containerproxy.util.Retrying; import org.springframework.context.annotation.Primary; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; -import eu.openanalytics.containerproxy.backend.strategy.IProxyTestStrategy; -import eu.openanalytics.containerproxy.model.runtime.Proxy; +import javax.inject.Inject; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.Arrays; /** * This component tests the responsiveness of Shiny containers by making an HTTP GET request to the container's published port (default 3838). @@ -45,7 +42,7 @@ @Primary public class ShinyProxyTestStrategy implements IProxyTestStrategy { - private Logger log = LogManager.getLogger(ShinyProxyTestStrategy.class); + private final StructuredLogger log = StructuredLogger.create(getClass()); @Inject private Environment environment; @@ -54,45 +51,45 @@ public class ShinyProxyTestStrategy implements IProxyTestStrategy { public boolean testProxy(Proxy proxy) { int totalWaitMs = Integer.parseInt(environment.getProperty("proxy.container-wait-time", "20000")); - int waitMs = Math.min(2000, totalWaitMs); - int maxTries = totalWaitMs / waitMs; int timeoutMs = Integer.parseInt(environment.getProperty("proxy.container-wait-timeout", "5000")); - if (proxy.getTargets().isEmpty()) return false; - URI targetURI = proxy.getTargets().values().iterator().next(); + if (proxy.getContainers().get(0).getTargets().isEmpty()) return false; + URI targetURI = proxy.getContainers().get(0).getTargets().get(proxy.getId()); - return retry(i -> { + return Retrying.retry((currentAttempt, maxAttempts) -> { try { - URL testURL = new URL(targetURI.toString()); + if (proxy.getStatus().isUnavailable()) { + // proxy got stopped while loading -> no need to try to connect it since the container will already be deleted + return true; + } + URL testURL = new URL(targetURI.toString() + "/"); HttpURLConnection connection = ((HttpURLConnection) testURL.openConnection()); - connection.setConnectTimeout(timeoutMs); + if (currentAttempt <= 5) { + // When the container has only just started (or when the k8s service has only just been created), + // it could be that our traffic ends in a black hole, and we need to wait the full 5s seconds of + // the timeout. Therefore, we first try a few attempts with a lower timeout. If the container is + // fast, this will result in a faster startup. If the container is slow to startup, not time is waste. + connection.setConnectTimeout(200); + connection.setReadTimeout(200); + } else { + connection.setConnectTimeout(timeoutMs); + connection.setReadTimeout(timeoutMs); + } connection.setInstanceFollowRedirects(false); int responseCode = connection.getResponseCode(); - if (Arrays.asList(200, 301, 302, 303, 307, 308).contains(responseCode)) return true; + if (Arrays.asList(200, 301, 302, 303, 307, 308).contains(responseCode)) { + if (currentAttempt > 10) { + log.info(proxy, "Container responsive"); + } + return true; + } } catch (Exception e) { - if (i > 1 && log != null) log.warn(String.format("Container unresponsive, trying again (%d/%d): %s", i, maxTries, targetURI)); + if (currentAttempt > 10) { + log.warn(proxy, String.format("Container unresponsive, trying again (%d/%d): %s", currentAttempt, maxAttempts, targetURI)); + } } return false; - }, maxTries, waitMs, false); + }, totalWaitMs); } - private static boolean retry(IntPredicate job, int tries, int waitTime, boolean retryOnException) { - boolean retVal = false; - RuntimeException exception = null; - for (int currentTry = 1; currentTry <= tries; currentTry++) { - try { - if (job.test(currentTry)) { - retVal = true; - exception = null; - break; - } - } catch (RuntimeException e) { - if (retryOnException) exception = e; - else throw e; - } - try { Thread.sleep(waitTime); } catch (InterruptedException ignore) {} - } - if (exception == null) return retVal; - else throw exception; - } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/Thymeleaf.java b/src/main/java/eu/openanalytics/shinyproxy/Thymeleaf.java index 9dae1f3b..1f4028c3 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/Thymeleaf.java +++ b/src/main/java/eu/openanalytics/shinyproxy/Thymeleaf.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -36,19 +36,23 @@ public class Thymeleaf { public String getAppUrl(ProxySpec proxySpec) { UriComponentsBuilder builder = ServletUriComponentsBuilder.fromCurrentContextPath().pathSegment("app", proxySpec.getId()); - if (shinyProxySpecProvider.getHideNavbarOnMainPageLink(proxySpec.getId())) { + if (shinyProxySpecProvider.getHideNavbarOnMainPageLink(proxySpec)) { builder.queryParam("sp_hide_navbar", "true"); } return builder.toUriString(); } + public boolean openSwitchInstanceInsteadOfApp(ProxySpec proxySpec) { + return shinyProxySpecProvider.getAlwaysShowSwitchInstance(proxySpec); + } + public String getTemplateProperty(String specId, String property) { - ShinyProxySpecProvider.ShinyProxySpec shinyProxySpec = shinyProxySpecProvider.getShinyProxySpec(specId); - if (shinyProxySpec == null) { + ProxySpec proxySpec = shinyProxySpecProvider.getSpec(specId); + if (proxySpec == null) { return null; } - return shinyProxySpec.getTemplateProperties().get(property); + return proxySpec.getSpecExtension(ShinyProxySpecExtension.class).getTemplateProperties().get(property); } public String getTemplateProperty(String specId, String property, String defaultValue) { diff --git a/src/main/java/eu/openanalytics/shinyproxy/UISecurityConfig.java b/src/main/java/eu/openanalytics/shinyproxy/UISecurityConfig.java index a9c6fe8d..a100dacd 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/UISecurityConfig.java +++ b/src/main/java/eu/openanalytics/shinyproxy/UISecurityConfig.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -23,48 +23,63 @@ import eu.openanalytics.containerproxy.auth.IAuthenticationBackend; import eu.openanalytics.containerproxy.security.ICustomSecurityConfig; import eu.openanalytics.containerproxy.service.UserService; -import eu.openanalytics.shinyproxy.controllers.HeartbeatController; +import org.springframework.context.annotation.Lazy; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.access.ExceptionTranslationFilter; -import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static eu.openanalytics.containerproxy.ui.AuthController.AUTH_SUCCESS_URL_SESSION_ATTR; @Component public class UISecurityConfig implements ICustomSecurityConfig { - @Inject - private IAuthenticationBackend auth; - - @Inject - private UserService userService; + @Inject + private IAuthenticationBackend auth; + + @Inject + private UserService userService; - @Inject - private OperatorService operatorService; + @Inject + @Lazy + private SavedRequestAwareAuthenticationSuccessHandler savedRequestAwareAuthenticationSuccessHandler; - @Override - public void apply(HttpSecurity http) throws Exception { - if (auth.hasAuthorization()) { - - // Limit access to the app pages according to spec permissions - http.authorizeRequests().antMatchers("/app/{specId}/**").access("@accessControlService.canAccess(authentication, #specId)"); - http.authorizeRequests().antMatchers("/app_i/{specId}/**").access("@accessControlService.canAccess(authentication, #specId)"); - http.authorizeRequests().antMatchers("/app_direct/{specId}/**").access("@accessControlService.canAccess(authentication, #specId)"); - http.authorizeRequests().antMatchers("/app_direct_i/{specId}/**").access("@accessControlService.canAccess(authentication, #specId)"); + @Override + public void apply(HttpSecurity http) throws Exception { + if (auth.hasAuthorization()) { - // Limit access to the admin pages - http.authorizeRequests().antMatchers("/admin").hasAnyRole(userService.getAdminGroups()); + // Limit access to the app pages according to spec permissions + http.authorizeRequests().antMatchers("/app/{specId}/**").access("@proxyAccessControlService.canAccessOrHasExistingProxy(authentication, #specId)"); + http.authorizeRequests().antMatchers("/app_i/{specId}/**").access("@proxyAccessControlService.canAccessOrHasExistingProxy(authentication, #specId)"); + http.authorizeRequests().antMatchers("/app_direct/{specId}/**").access("@proxyAccessControlService.canAccessOrHasExistingProxy(authentication, #specId)"); + http.authorizeRequests().antMatchers("/app_direct_i/{specId}/**").access("@proxyAccessControlService.canAccessOrHasExistingProxy(authentication, #specId)"); - http.addFilterAfter(new AuthenticationRequiredFilter(), ExceptionTranslationFilter.class); - } + http.addFilterAfter(new AuthenticationRequiredFilter(), ExceptionTranslationFilter.class); - if (operatorService.isEnabled()) { - // running using operator - http.addFilterAfter(new OperatorCookieFilter(), AnonymousAuthenticationFilter.class); - http.authorizeRequests().antMatchers("/server-transfer").permitAll(); + savedRequestAwareAuthenticationSuccessHandler.setRedirectStrategy(new DefaultRedirectStrategy() { + @Override + public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException { + String redirectUrl = calculateRedirectUrl(request.getContextPath(), url); + AppRequestInfo appRequestInfo = AppRequestInfo.fromURI(redirectUrl); + if (appRequestInfo != null) { + // before auth, the user tried to open the page of an app, redirect back to that app + // (we don't redirect to any other app, see #30648 and #28624) + request.getSession().setAttribute(AUTH_SUCCESS_URL_SESSION_ATTR, url); + } + response.sendRedirect(ServletUriComponentsBuilder.fromCurrentContextPath().path("/auth-success").build().toUriString()); + } + }); } + // Limit access to the admin pages + http.authorizeRequests().antMatchers("/admin").access("@userService.isAdmin()"); + http.authorizeRequests().antMatchers("/admin/data").access("@userService.isAdmin()"); - } + } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/AdminController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/AdminController.java index 82a5e53c..dbea948e 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/controllers/AdminController.java +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/AdminController.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -20,21 +20,35 @@ */ package eu.openanalytics.shinyproxy.controllers; -import java.net.URI; -import java.util.List; -import java.util.stream.Collectors; - -import javax.inject.Inject; -import javax.servlet.http.HttpServletRequest; - +import eu.openanalytics.containerproxy.api.dto.ApiResponse; +import eu.openanalytics.containerproxy.model.runtime.Container; +import eu.openanalytics.containerproxy.model.runtime.ParameterNames; +import eu.openanalytics.containerproxy.model.runtime.Proxy; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.BackendContainerNameKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ContainerImageKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.HeartbeatTimeoutKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.InstanceIdKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.MaxLifetimeKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ParameterNamesKey; import eu.openanalytics.containerproxy.service.hearbeat.ActiveProxiesService; -import eu.openanalytics.containerproxy.service.hearbeat.HeartbeatService; import eu.openanalytics.shinyproxy.runtimevalues.AppInstanceKey; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; -import eu.openanalytics.containerproxy.model.runtime.Proxy; +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.stream.Collectors; @Controller public class AdminController extends BaseController { @@ -45,32 +59,65 @@ public class AdminController extends BaseController { @RequestMapping("/admin") private String admin(ModelMap map, HttpServletRequest request) { prepareMap(map, request); - - List proxies = proxyService.getProxies(null, false); - map.put("proxies", proxies.stream().map(ProxyInfo::new).collect(Collectors.toList())); return "admin"; } + @Operation(summary = "Get active proxies of all users.", tags = "ShinyProxy") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Active proxies are returned.", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProxyInfoResponse.class), + examples = { + @ExampleObject(value = "{\"status\": \"success\", \"data\": [{\"status\": \"Up\", \"proxyId\": \"9cd90bbb-ae9c-4016-9b9c-d2852b3a0bf6\", \"userId\": \"jack\", \"appName\": \"01_hello\", " + + "\"instanceName\": \"Default\", \"endpoint\": \"N/A\", \"uptime\": \"0:00:39\", \"lastHeartBeat\": \"0:00:05\", \"imageName\": \"openanalytics/shinyproxy-demo\", \"imageTag\": \"N/A\", " + + "\"heartbeatTimeout\": null, \"maxLifetime\": \"0:02:00\", \"spInstance\": \"9bec0d32754eab6a036bf1ee032bca82f98df0c5\", \"backendContainerName\": " + + "\"900b4f35b283401946db1d7cb8fe31ad5e6209d921b3cb9fd668ed6b9cbf7aa5\", \"parameters\": null}, {\"status\": \"Up\", \"proxyId\": \"b34d416e-ce6e-4351-a126-8836c88f2200\", \"userId\": " + + "\"jack\", \"appName\": \"06_tabsets\", \"instanceName\": \"Default\", \"endpoint\": \"N/A\", \"uptime\": \"0:00:18\", \"lastHeartBeat\": \"0:00:02\", \"imageName\": " + + "\"openanalytics/shinyproxy-demo\", \"imageTag\": \"N/A\", \"heartbeatTimeout\": null, \"maxLifetime\": \"0:02:00\", \"spInstance\": \"9bec0d32754eab6a036bf1ee032bca82f98df0c5\", " + + "\"backendContainerName\": \"2158b5b49c4138a9d0d6313fc4b62eba074b359473143be1d98102ab06c74bf8\", \"parameters\": null}]}") + } + ) + }), + }) + @RequestMapping(value = "/admin/data", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.GET) + @ResponseBody + private ResponseEntity>> adminData() { + List proxies = proxyService.getProxies(null, false); + List proxyInfos = proxies.stream().map(ProxyInfo::new).collect(Collectors.toList()); + return ApiResponse.success(proxyInfos); + } + public class ProxyInfo { - public final String status; - public final String id; + + @Schema(allowableValues = {"New", "Up", "Stopping", "Pausing", "Paused", "Resuming", "Stopped"}) + public final String status; + + public final String proxyId; public final String userId; public final String appName; - public final String appInstanceName; + public final String instanceName; public final String endpoint; public final String uptime; public final String lastHeartBeat; public final String imageName; public final String imageTag; + public final String heartbeatTimeout; + public final String maxLifetime; + public final String spInstance; + public final String backendContainerName; + public final List parameters; public ProxyInfo(Proxy proxy) { status = proxy.getStatus().toString(); - id = proxy.getId(); + proxyId = proxy.getId(); userId = proxy.getUserId(); - appName = proxy.getSpec().getId(); - appInstanceName = getInstanceName(proxy); - endpoint = proxy.getTargets().values().stream().map(URI::toString).findFirst().orElse("N/A"); // Shiny apps have only one endpoint + appName = proxy.getSpecId(); + instanceName = getInstanceName(proxy); if (proxy.getStartupTimestamp() > 0) { uptime = getTimeDelta(proxy.getStartupTimestamp()); @@ -85,18 +132,58 @@ public ProxyInfo(Proxy proxy) { lastHeartBeat = getTimeDelta(heartBeat); } - String[] parts = proxy.getSpec().getContainerSpecs().get(0).getImage().split(":"); - imageName = parts[0]; - if (parts.length > 1) { - imageTag = parts[1]; + if (!proxy.getContainers().isEmpty()) { + Container container = proxy.getContainers().get(0); + String[] parts = container.getRuntimeValue(ContainerImageKey.inst).split(":"); + imageName = parts[0]; + if (parts.length > 1) { + imageTag = parts[1]; + } else { + imageTag = "N/A"; + } + if (container.getTargets().containsKey(proxy.getId())) { + endpoint = container.getTargets().get(proxy.getId()).toString(); + } else { + endpoint = "N/A"; + } + backendContainerName = container.getRuntimeObjectOrDefault(BackendContainerNameKey.inst, "N/A"); + } else { + imageName = "N/A"; + imageTag = "N/A"; + endpoint = "N/A"; + backendContainerName = "N/A"; + } + + Long heartbeatTimeout = proxy.getRuntimeObjectOrNull(HeartbeatTimeoutKey.inst); + if (heartbeatTimeout != null && heartbeatTimeout != -1) { + this.heartbeatTimeout = formatSeconds(heartbeatTimeout / 1000); + } else { + this.heartbeatTimeout = null; + } + + Long maxLifetime = proxy.getRuntimeObjectOrNull(MaxLifetimeKey.inst); + if (maxLifetime != null && maxLifetime != -1) { + this.maxLifetime = formatSeconds(maxLifetime * 60); } else { - imageTag = "latest"; + this.maxLifetime = null; } + + ParameterNames providedParameters = proxy.getRuntimeObjectOrNull(ParameterNamesKey.inst); + if (providedParameters != null) { + parameters = providedParameters.getParametersNames(); + } else { + parameters = null; + } + spInstance = proxy.getRuntimeObjectOrDefault(InstanceIdKey.inst, "N/A"); } private String getTimeDelta(Long timestamp) { - long uptimeSec = (System.currentTimeMillis() - timestamp)/1000; - return String.format("%d:%02d:%02d", uptimeSec/3600, (uptimeSec%3600)/60, uptimeSec%60); + long seconds = (System.currentTimeMillis() - timestamp)/1000; + return formatSeconds(seconds); + } + + private String formatSeconds(Long seconds) { + return String.format("%d:%02d:%02d", seconds/3600, (seconds%3600)/60, seconds%60); } private String getInstanceName(Proxy proxy) { @@ -109,4 +196,9 @@ private String getInstanceName(Proxy proxy) { } + public static class ProxyInfoResponse { + public String status = "success"; + public List data; + } + } diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/AppController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/AppController.java index 6106a527..c67d6fb4 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/controllers/AppController.java +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/AppController.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -22,194 +22,460 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.databind.ObjectMapper; +import eu.openanalytics.containerproxy.api.dto.ApiResponse; +import eu.openanalytics.containerproxy.api.dto.SwaggerDto; +import eu.openanalytics.containerproxy.auth.impl.OpenIDAuthenticationBackend; +import eu.openanalytics.containerproxy.model.Views; +import eu.openanalytics.containerproxy.model.runtime.AllowedParametersForUser; +import eu.openanalytics.containerproxy.model.runtime.ParameterValues; import eu.openanalytics.containerproxy.model.runtime.Proxy; -import eu.openanalytics.containerproxy.model.runtime.ProxyStatus; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.DisplayNameKey; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.ParameterValuesKey; import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue; import eu.openanalytics.containerproxy.model.spec.ProxySpec; -import eu.openanalytics.containerproxy.util.BadRequestException; +import eu.openanalytics.containerproxy.service.AsyncProxyService; +import eu.openanalytics.containerproxy.service.InvalidParametersException; +import eu.openanalytics.containerproxy.service.ParametersService; +import eu.openanalytics.containerproxy.util.ContextPathHelper; import eu.openanalytics.containerproxy.util.ProxyMappingManager; -import eu.openanalytics.containerproxy.util.Retrying; import eu.openanalytics.shinyproxy.AppRequestInfo; -import eu.openanalytics.shinyproxy.ShinyProxySpecProvider; +import eu.openanalytics.shinyproxy.ShinyProxyIframeScriptInjector; +import eu.openanalytics.shinyproxy.controllers.dto.ShinyProxyApiResponse; import eu.openanalytics.shinyproxy.runtimevalues.AppInstanceKey; import eu.openanalytics.shinyproxy.runtimevalues.PublicPathKey; -import eu.openanalytics.shinyproxy.runtimevalues.WebSocketReconnectionModeKey; +import eu.openanalytics.shinyproxy.runtimevalues.UserTimeZoneKey; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.servlet.view.RedirectView; +import org.springframework.web.util.UriComponentsBuilder; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.ExpressionContext; +import org.thymeleaf.spring5.dialect.SpringStandardDialect; +import org.thymeleaf.templatemode.TemplateMode; +import org.thymeleaf.templateresolver.StringTemplateResolver; import javax.inject.Inject; +import javax.servlet.RequestDispatcher; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.springframework.web.bind.annotation.RequestMethod.GET; @Controller public class AppController extends BaseController { - private Logger log = LogManager.getLogger(AppController.class); + private final Logger log = LogManager.getLogger(AppController.class); @Inject private ProxyMappingManager mappingManager; @Inject - private ShinyProxySpecProvider shinyProxySpecProvider; + private AsyncProxyService asyncProxyService; - @RequestMapping(value={"/app_i/*/*", "/app/*"}, method=RequestMethod.GET) - public String app(ModelMap map, HttpServletRequest request) { - AppRequestInfo appRequestInfo = AppRequestInfo.fromRequestOrException(request); + @Inject + private ParametersService parameterService; - prepareMap(map, request); + private final ObjectMapper objectMapper = new ObjectMapper(); + + public AppController() { + objectMapper.setConfig(objectMapper.getSerializationConfig() + .withView(Views.UserApi.class)); + } + + @RequestMapping(value={"/app_i/*/**", "/app/**"}, method= GET) + public ModelAndView app(ModelMap map, HttpServletRequest request, HttpServletResponse response) { + AppRequestInfo appRequestInfo = AppRequestInfo.fromRequestOrNull(request); + if (appRequestInfo == null) { + request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpStatus.FORBIDDEN.value()); + return new ModelAndView("forward:/error"); + } Proxy proxy = findUserProxy(appRequestInfo); - awaitReady(proxy); - log.info(String.format("Received GET request at endpoint: /app/ for model: %s. Container path: %s", getAppTitle(appRequestInfo), buildContainerPath(request, appRequestInfo))); - map.put("appTitle", getAppTitle(appRequestInfo)); + String containerSubPath = buildContainerSubPath(request, appRequestInfo); + + log.info(String.format("Received GET request at endpoint: /app/ for model: %s. Container path: %s", appRequestInfo.getAppName(), containerSubPath)); + ProxySpec spec = proxyService.getProxySpec(appRequestInfo.getAppName()); + Optional redirect = createRedirectIfRequired(request, appRequestInfo, proxy, spec); + if (redirect.isPresent()) { + return new ModelAndView(redirect.get()); + } + + if (proxy == null && spec == null) { + request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpStatus.FORBIDDEN.value()); + return new ModelAndView("forward:/error"); + } + + prepareMap(map, request); + map.put("heartbeatRate", getHeartbeatRate()); + map.put("page", "app"); map.put("appName", appRequestInfo.getAppName()); map.put("appInstance", appRequestInfo.getAppInstance()); map.put("appInstanceDisplayName", appRequestInfo.getAppInstanceDisplayName()); - map.put("containerPath", (proxy == null) ? "" : buildContainerPath(request, appRequestInfo)); - map.put("proxyId", (proxy == null) ? "" : proxy.getId()); - map.put("webSocketReconnectionMode", (proxy == null) ? "" : proxy.getRuntimeValue(WebSocketReconnectionModeKey.inst)); - map.put("heartbeatRate", getHeartbeatRate()); - map.put("isAppPage", true); - map.put("maxInstances", shinyProxySpecProvider.getMaxInstancesForSpec(appRequestInfo.getAppName())); - map.put("shinyForceFullReload", shinyProxySpecProvider.getShinyForceFullReload(appRequestInfo.getAppName())); + map.put("appPath", appRequestInfo.getAppPath()); + map.put("containerSubPath", containerSubPath); + map.put("refreshOpenidEnabled", authenticationBackend.getName().equals(OpenIDAuthenticationBackend.NAME)); + ParameterValues previousParameters = null; + if (proxy == null || proxy.getRuntimeObjectOrNull(DisplayNameKey.inst) == null) { + if (spec.getDisplayName() == null || spec.getDisplayName().isEmpty()) { + map.put("appTitle", spec.getId()); + } else { + map.put("appTitle", spec.getDisplayName()); + } + map.put("proxy", null); + } else { + map.put("appTitle", proxy.getRuntimeValue(DisplayNameKey.inst)); + previousParameters = proxy.getRuntimeObjectOrNull(ParameterValuesKey.inst); + } + map.put("proxy", secureProxy(proxy)); + if (spec != null && spec.getParameters() != null) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + AllowedParametersForUser allowedParametersForUser = parameterService.calculateAllowedParametersForUser(auth, spec, previousParameters); + map.put("parameterAllowedCombinations", allowedParametersForUser.getAllowedCombinations()); + map.put("parameterValues", allowedParametersForUser.getValues()); + map.put("parameterDefaults", allowedParametersForUser.getDefaultValue()); + map.put("parameterDefinitions", spec.getParameters().getDefinitions()); + map.put("parameterIds", spec.getParameters().getIds()); - // operator specific - map.put("operatorShowTransferMessage", operatorService.showTransferMessageOnAppPage()); + if (spec.getParameters().getTemplate() != null) { + map.put("parameterFragment", renderParameterTemplate(spec.getParameters().getTemplate(), map)); + } else { + map.put("parameterFragment", null); + } + } else { + map.put("parameterValues", null); + map.put("parameterDefaults", null); + map.put("parameterDefinitions", null); + map.put("parameterIds", null); + map.put("parameterFragment", null); + } - return "app"; + return new ModelAndView("app", map); } - - @RequestMapping(value={"/app_i/*/*", "/app/*"}, method=RequestMethod.POST) + + // TODO add example with timezone + @Operation(summary = "Start an app.", tags = "ShinyProxy", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AppBody.class), + examples = { + @ExampleObject(name = "With parameters", value = "{\"parameters\":{\"resources\":\"2 CPU cores - 8G RAM\",\"other_parameter\":\"example\"}}"), + @ExampleObject(name = "With timezone", value = "{\"timezone\":\"Europe/Brussels\"}") + } + ) + ) + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "The proxy has been created.", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = SwaggerDto.ProxyResponse.class), + examples = { + @ExampleObject(value = "{\"status\":\"success\",\"data\":{\"id\":\"cdaa8056-4f96-428e-91e8-bc13518d8987\",\"status\":\"New\",\"startupTimestamp\":0,\"createdTimestamp\":1671707875757," + + "\"userId\":\"jack\",\"specId\":\"01_hello\",\"displayName\":\"Hello Application\",\"containers\":[],\"runtimeValues\":{\"SHINYPROXY_FORCE_FULL_RELOAD\":false," + + "\"SHINYPROXY_WEBSOCKET_RECONNECTION_MODE\":\"None\",\"SHINYPROXY_MAX_INSTANCES\":100,\"SHINYPROXY_PUBLIC_PATH\":\"/app_proxy/cdaa8056-4f96-428e-91e8-bc13518d8987/\"," + + "\"SHINYPROXY_APP_INSTANCE\":\"default\"}}}\n") + } + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Invalid request, app not started.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(name = "Max instances reached", value = "{\"status\":\"fail\",\"data\":\"Cannot start new proxy because the maximum amount of instances of this proxy has been reached\"}"), + @ExampleObject(name = "Instance already exists", value = "{\"status\":\"fail\",\"data\":\"You already have an instance of this app with the given name\"}"), + @ExampleObject(name = "Parameters required", value = "{\"status\":\"fail\",\"data\":\"No parameters provided, but proxy spec expects parameters\"}"), + @ExampleObject(name = "Missing parameter", value = "{\"status\":\"fail\",\"data\":\"Missing value for parameter example\"}"), + @ExampleObject(name = "Invalid parameter value", value = "{\"status\":\"fail\",\"data\":\"Provided parameter values are not allowed\"}") + } + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "Proxy spec not found or no permission to use this proxy spec.", + content = { + @Content( + mediaType = "application/json", + examples = {@ExampleObject(value = "{\"status\": \"fail\", \"data\": \"forbidden\"}")} + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Failed to start proxy.", + content = { + @Content( + mediaType = "application/json", + examples = {@ExampleObject(value = "{\"status\": \"fail\", \"data\": \"Failed to start proxy\"}")} + ) + }), + }) @ResponseBody - public Map startApp(HttpServletRequest request) { - AppRequestInfo appRequestInfo = AppRequestInfo.fromRequestOrException(request); - - Proxy proxy = getOrStart(appRequestInfo); - String containerPath = buildContainerPath(request, appRequestInfo); - - log.info(String.format("Received POST request at endpoint: /app/ for proxy id: %s. Container path: %s", proxy.getId(), containerPath)); - Map response = new HashMap<>(); - response.put("containerPath", containerPath); - response.put("proxyId", proxy.getId()); - response.put("webSocketReconnectionMode", proxy.getRuntimeValue(WebSocketReconnectionModeKey.inst)); - return response; + @JsonView(Views.UserApi.class) + @RequestMapping(value = "/app_i/{specId}/{appInstanceName}", method = RequestMethod.POST) + public ResponseEntity> startApp(@PathVariable String specId, @PathVariable String appInstanceName, @RequestBody(required = false) AppBody appBody) { + log.info(String.format("Received POST request at endpoint: /app/ for spec id: %s. app instance name: %s", specId, appInstanceName)); + ProxySpec spec = proxyService.getProxySpec(specId); + if (!userService.canAccess(spec)) { + return ApiResponse.failForbidden(); + } + Proxy proxy = findUserProxy(specId, appInstanceName); + if (proxy != null) { + return ApiResponse.fail("You already have an instance of this app with the given name"); + } + + if (!validateProxyStart(spec)) { + return ApiResponse.fail("Cannot start new app because the maximum amount of instances of this app has been reached"); + } + + List runtimeValues = shinyProxySpecProvider.getRuntimeValues(spec); + String id = UUID.randomUUID().toString(); + runtimeValues.add(new RuntimeValue(PublicPathKey.inst, getPublicPath(id))); + runtimeValues.add(new RuntimeValue(AppInstanceKey.inst, appInstanceName)); + if (appBody != null && appBody.getTimezone() != null) { + runtimeValues.add(new RuntimeValue(UserTimeZoneKey.inst, appBody.getTimezone())); + } + + try { + return ApiResponse.success(asyncProxyService.startProxy(spec, runtimeValues, id, (appBody != null) ? appBody.getParameters() : null)); + } catch (InvalidParametersException ex) { + return ApiResponse.fail(ex.getMessage()); + } catch (Throwable t ) { + return ApiResponse.error("Failed to start proxy"); + } } - - @RequestMapping(value={"/app_direct_i/**", "/app_direct/**"}) - public void appDirect(HttpServletRequest request, HttpServletResponse response) throws IOException { - AppRequestInfo appRequestInfo = AppRequestInfo.fromRequestOrException(request); - Proxy proxy = findUserProxy(appRequestInfo); + @Operation(summary = "Proxy request to app. This endpoint is used to serve the iframe, hence it makes some assumptions. Do not use it directly or for embedding.", tags = "ShinyProxy") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "User is not authenticated.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(value = "{\"message\":\"shinyproxy_authentication_required\",\"status\":\"fail\"}") + } + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "410", + description = "App has been stopped or the app never existed or the user has no access to the app.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(value = "{\"message\":\"app_stopped_or_non_existent\",\"status\":\"fail\"}") + } + ) + }), + }) + @RequestMapping(value={"/app_proxy/{proxyId}/**"}) + public void appProxy(@PathVariable String proxyId, HttpServletRequest request, HttpServletResponse response) throws IOException { + String requestUrl = request.getRequestURI().substring(getBasePublicPath().length()); - if (proxy == null && appRequestInfo.getSubPath() != null && !appRequestInfo.getSubPath().equals("/")) { - response.setStatus(410); - response.getWriter().write("{\"status\":\"error\", \"message\":\"app_stopped_or_non_existent\"}"); - return; - } else { - proxy = getOrStart(appRequestInfo); - awaitReady(proxy); + Proxy proxy = proxyService.getProxy(proxyId); + if (proxy == null || proxy.getStatus().isUnavailable() || !userService.isOwner(proxy)) { + ShinyProxyApiResponse.appStoppedOrNonExistent(response); + return; + } + try { + mappingManager.dispatchAsync(proxy.getId(), requestUrl, request, response); + } catch (Exception e) { + throw new RuntimeException("Error routing proxy request", e); } - - String mapping = getProxyEndpoint(proxy); - - if (appRequestInfo.getSubPath() == null) { + } + + /** + * Special handler for HTML requests that inject the ShinyProxy iframe javascript. + */ + @RequestMapping(value={"/app_proxy/{proxyId}/**"}, produces= "text/html", method = GET) + public void appProxyHtml(@PathVariable String proxyId, HttpServletRequest request, HttpServletResponse response) throws IOException { + String requestUrl = request.getRequestURI().substring(getBasePublicPath().length()); + + Proxy proxy = proxyService.getProxy(proxyId); + if (proxy == null || proxy.getStatus().isUnavailable() || !userService.isOwner(proxy)) { + ShinyProxyApiResponse.appStoppedOrNonExistent(response); + return; + } + + String secFetchMode = request.getHeader("Sec-Fetch-Mode"); + if (secFetchMode != null && !secFetchMode.equals("navigate")) { + // do not inject script since this isn't a navigate request (it's e.g. an ajax/fetch request) + // note: the header is relatively new and therefore the script is injected if the header is not present + // see: #30809 try { - response.sendRedirect(request.getRequestURI() + "/"); + mappingManager.dispatchAsync(proxy.getId(), requestUrl, request, response); + return; } catch (Exception e) { - throw new RuntimeException("Error redirecting proxy request", e); + throw new RuntimeException("Error routing proxy request", e); } - return; } - + try { - mappingManager.dispatchAsync(mapping + appRequestInfo.getSubPath(), request, response); + mappingManager.dispatchAsync(proxyId, requestUrl, request, response, (exchange) -> { + exchange.getRequestHeaders().remove("Accept-Encoding"); // ensure no encoding is used + exchange.addResponseWrapper((factory, exchange1) -> new ShinyProxyIframeScriptInjector(factory.create(), exchange1)); + }); } catch (Exception e) { throw new RuntimeException("Error routing proxy request", e); } } - private Proxy getOrStart(AppRequestInfo appRequestInfo) { - Proxy proxy = findUserProxy(appRequestInfo); - if (proxy == null) { - ProxySpec spec = proxyService.getProxySpec(appRequestInfo.getAppName()); - - if (spec == null) throw new BadRequestException("Unknown proxy spec: " + appRequestInfo.getAppName()); - ProxySpec resolvedSpec = proxyService.resolveProxySpec(spec, null, null); + private String buildContainerSubPath(HttpServletRequest request, AppRequestInfo appRequestInfo) { + String queryString = ServletUriComponentsBuilder.fromRequest(request) + .replaceQueryParam("sp_hide_navbar") + .replaceQueryParam("sp_instance_override") + .build().getQuery(); - List runtimeValues = shinyProxySpecProvider.getRuntimeValues(spec); - runtimeValues.add(new RuntimeValue(PublicPathKey.inst, getPublicPath(appRequestInfo))); - runtimeValues.add(new RuntimeValue(AppInstanceKey.inst, appRequestInfo.getAppInstance())); - - if (!validateProxyStart(spec)) { - throw new BadRequestException("Cannot start new proxy because the maximum amount of instances of this proxy has been reached"); - } + String res = UriComponentsBuilder + .fromPath(appRequestInfo.getSubPath()) + .query(queryString) + .build(false) // #30932: queryString is not yet encoded + .toUriString(); - proxy = proxyService.startProxy(resolvedSpec, false, runtimeValues); + if (res.startsWith("/")) { + return res.substring(1); } - return proxy; + return res; } + private String getPublicPath(String proxyId) { + return getBasePublicPath() + proxyId + "/"; + } - private boolean awaitReady(Proxy proxy) { - if (proxy == null) return false; - if (proxy.getStatus() == ProxyStatus.Up) return true; - if (proxy.getStatus() == ProxyStatus.Stopping || proxy.getStatus() == ProxyStatus.Stopped) return false; - - int totalWaitMs = Integer.parseInt(environment.getProperty("proxy.container-wait-time", "20000")); - int waitMs = Math.min(500, totalWaitMs); - int maxTries = totalWaitMs / waitMs; - Retrying.retry(i -> proxy.getStatus() != ProxyStatus.Starting, maxTries, waitMs); - - return (proxy.getStatus() == ProxyStatus.Up); + private String getBasePublicPath() { + return ContextPathHelper.withEndingSlash() + "app_proxy/"; } - - private String buildContainerPath(HttpServletRequest request, AppRequestInfo appRequestInfo) { - String queryString = ServletUriComponentsBuilder.fromRequest(request).replaceQueryParam("sp_hide_navbar").build().getQuery(); - queryString = (queryString == null) ? "" : "?" + queryString; - - return getPublicPath(appRequestInfo) + queryString; + /** + * Checks if a redirect is required before we can handle the request. + *

+ * ShinyProxy supports proxying to multiple targets. When proxying to a target (without a sub-path for that specific target), the URL must end with a slash. + * However, when the sub-path does not point to a specific target, it's not required that the URL ends with a slash. + *

+ *

+ * Assume an app called `myapp` has a additional-port-mapping named `abc`: + * - /app/myapp -> no redirect required (getPublicPath() always add a slash) + * - /app/myapp/test123 -> no redirect required + * - /app/myapp/abc -> redirect to /app/myapp/abc/ + * - /app/myapp/abc/ -> no redirect required + * - /app/myapp/abc/test -> no redirect required + *

+ * @param request the current request + * @param appRequestInfo the appRequstInfo for this request + * @param proxy the current proxy + * @param spec the spec of the current app + * @return a RedirectView if a redirect is needed + */ + private Optional createRedirectIfRequired(HttpServletRequest request, AppRequestInfo appRequestInfo, Proxy proxy, ProxySpec spec) { + // if sub-path is empty or it's a slash -> no redirect required + if (appRequestInfo.getSubPath() == null || appRequestInfo.getSubPath().equals("/")) { + return Optional.empty(); + } + + // sub-path always starts with a slash -> get part without the slash + // this contains the mapping and any additional paths + String subPath = appRequestInfo.getSubPath().substring(1); + + // if the subPath contains a slash -> no redirect required + // e.g. /app/myapp/mapping/ + // e.g. /app/myapp/mapping/some_path + // ^^^^^^^^^^^^^^^^^^ -> this is the subpath (without initial slash) + if (subPath.contains("/")) { + return Optional.empty(); + } + + // the provided subpath does not contain a slash (i.e. it's a single "directory" name) + // -> we have to check whether the provided subpath is a configured mapping (and thus point to a specific port on the app) + // or whether it's just a subpath + boolean isMappingWithoutSlash = spec.getContainerSpecs().get(0) + .getPortMapping() + .stream() + .anyMatch(it -> it.getName().equals(subPath)); + if (isMappingWithoutSlash) { + // the provided subpath is a configured mapping -> redirect so it ends with a slash + String uri = ServletUriComponentsBuilder.fromRequest(request) + .path("/") + .build() + .toUriString(); + return Optional.of(new RedirectView(uri)); + } + + return Optional.empty(); } - private String getPublicPath(AppRequestInfo appRequestInfo) { - return getContextPath() + "app_direct_i/" + appRequestInfo.getAppName() + "/" + appRequestInfo.getAppInstance() + '/'; + private String renderParameterTemplate(String template, ModelMap map) { + TemplateEngine templateEngine = new TemplateEngine(); + StringTemplateResolver stringTemplateResolver = new StringTemplateResolver(); + stringTemplateResolver.setTemplateMode(TemplateMode.HTML); + stringTemplateResolver.setCacheable(false); + + templateEngine.setTemplateResolver(stringTemplateResolver); + templateEngine.setDialect(new SpringStandardDialect()); + + ExpressionContext context = new ExpressionContext(templateEngine.getConfiguration(), null, map); + return templateEngine.process(template, context); } /** - * Validates whether a proxy should be allowed to start. + * Converts a proxy into an Object using {@link Views.UserApi} view, in order to hide security sensitive values. + * @return the secured proxy */ - private boolean validateProxyStart(ProxySpec spec) { - Integer maxInstances = shinyProxySpecProvider.getMaxInstancesForSpec(spec.getId()); + private Object secureProxy(Proxy proxy) { + return objectMapper.convertValue(proxy, Object.class); + } - if (maxInstances == -1) { - return true; + private static class AppBody { + private Map parameters; + private String timezone; + + @Schema(description = "Map of parameters for the app.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + public Map getParameters() { + return parameters; } - // note: there is a very small change that the user is able to start more instances than allowed, if the user - // starts many proxies at once. E.g. in the following scenario: - // - max proxies = 2 - // - user starts a proxy - // - user sends a start proxy request -> this function is called and returns true - // - just before this new proxy is added to the list of active proxies, the user sends a new start proxy request - // - again this new proxy is allowed, because there is still only one proxy in the list of active proxies - // -> the user has three proxies running. - // Because of chance that this happens is small and that the consequences are low, we accept this risk. - int currentAmountOfInstances = proxyService.getProxies( - p -> p.getSpec().getId().equals(spec.getId()) - && userService.isOwner(p), - false).size(); + public void setParameters(Map parameters) { + this.parameters = parameters; + } + @Schema(description = "The timezone of the user in TZ format.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + public String getTimezone() { + return timezone; + } - return currentAmountOfInstances < maxInstances; + public void setTimezone(String timezone) { + this.timezone = timezone; + } } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/AppDirectController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/AppDirectController.java new file mode 100644 index 00000000..668d53ea --- /dev/null +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/AppDirectController.java @@ -0,0 +1,144 @@ +/** + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.shinyproxy.controllers; + +import eu.openanalytics.containerproxy.ContainerProxyException; +import eu.openanalytics.containerproxy.model.runtime.Proxy; +import eu.openanalytics.containerproxy.model.runtime.ProxyStatus; +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValue; +import eu.openanalytics.containerproxy.model.spec.ProxySpec; +import eu.openanalytics.containerproxy.service.InvalidParametersException; +import eu.openanalytics.containerproxy.util.ContextPathHelper; +import eu.openanalytics.containerproxy.util.ProxyMappingManager; +import eu.openanalytics.shinyproxy.AppRequestInfo; +import eu.openanalytics.shinyproxy.runtimevalues.AppInstanceKey; +import eu.openanalytics.shinyproxy.runtimevalues.PublicPathKey; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.ModelAndView; + +import javax.inject.Inject; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@Controller +public class AppDirectController extends BaseController { + + @Inject + private ProxyMappingManager mappingManager; + + @Operation(summary = "Proxy request to app. Starts the app if it does not yet exists. Can be used directly or for embedding.", tags = "ShinyProxy") + @RequestMapping(value = {"/app_direct_i/**", "/app_direct/**"}) + public void appDirect(HttpServletRequest request, HttpServletResponse response) throws InvalidParametersException, ServletException, IOException { + // note: app_direct does not support parameters and resume + AppRequestInfo appRequestInfo = AppRequestInfo.fromRequestOrNull(request); + if (appRequestInfo == null) { + request.setAttribute(RequestDispatcher.ERROR_STATUS_CODE, HttpStatus.FORBIDDEN.value()); + request.getRequestDispatcher("/error").forward(request, response); + return; + } + + if (appRequestInfo.getSubPath() == null) { + try { + response.sendRedirect(request.getRequestURI() + "/"); + } catch (Exception e) { + throw new RuntimeException("Error redirecting proxy request", e); + } + return; + } + + Proxy proxy = getOrStart(appRequestInfo, request, response); + if (proxy == null) { + return; + } + String mapping = getProxyEndpoint(proxy); + + try { + mappingManager.dispatchAsync(proxy.getId(), mapping + appRequestInfo.getSubPath(), request, response); + } catch (Exception e) { + throw new RuntimeException("Error routing proxy request", e); + } + } + + private Proxy getOrStart(AppRequestInfo appRequestInfo, HttpServletRequest request, HttpServletResponse response) throws InvalidParametersException, ServletException, IOException { + Proxy proxy = findUserProxy(appRequestInfo); + if (proxy == null) { + ProxySpec spec = proxyService.getProxySpec(appRequestInfo.getAppName()); + + if (spec == null) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + request.getRequestDispatcher("/error").forward(request, response); + return null; + } + + if (!validateProxyStart(spec)) { + throw new ContainerProxyException("Cannot start new proxy because the maximum amount of instances of this proxy has been reached"); + } + + List runtimeValues = shinyProxySpecProvider.getRuntimeValues(spec); + String id = UUID.randomUUID().toString(); + runtimeValues.add(new RuntimeValue(PublicPathKey.inst, getPublicPath(appRequestInfo))); + runtimeValues.add(new RuntimeValue(AppInstanceKey.inst, appRequestInfo.getAppInstance())); + + try { + proxyService.startProxy(userService.getCurrentAuth(), spec, runtimeValues, id, null).run(); + } catch (Throwable t) { + throw new ContainerProxyException("Failed to start app " + appRequestInfo.getAppName(), t); + } + proxy = proxyService.getProxy(id); + } + if (proxy.getStatus() == ProxyStatus.Up) { + return proxy; + } else if (proxy.getStatus() == ProxyStatus.New) { + // maximum wait 10 minutes for the app to startup + for (int i = 0; i < 600; i++ ) { + try { + Thread.sleep(1000); + } catch (InterruptedException ex) { + throw new ContainerProxyException("Failed to start app " + appRequestInfo.getAppName()); + } + Proxy result = proxyService.getProxy(proxy.getId()); + if (result == null) { + throw new ContainerProxyException("Failed to start app " + appRequestInfo.getAppName()); + } + if (result.getStatus().equals(ProxyStatus.Up)) { + return result; + } + if (!result.getStatus().equals(ProxyStatus.New)) { + throw new ContainerProxyException("Failed to start app " + appRequestInfo.getAppName()); + } + } + } + throw new ContainerProxyException("Failed to start app " + appRequestInfo.getAppName()); + } + + private String getPublicPath(AppRequestInfo appRequestInfo) { + return ContextPathHelper.withEndingSlash() + "app_direct_i/" + appRequestInfo.getAppName() + "/" + appRequestInfo.getAppInstance(); + } + +} diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/BaseController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/BaseController.java index 6df0342c..18a06ef8 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/controllers/BaseController.java +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/BaseController.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -20,23 +20,17 @@ */ package eu.openanalytics.shinyproxy.controllers; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.net.URLConnection; -import java.security.Principal; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; - -import javax.inject.Inject; -import javax.servlet.http.HttpServletRequest; - import eu.openanalytics.containerproxy.auth.IAuthenticationBackend; +import eu.openanalytics.containerproxy.backend.IContainerBackend; +import eu.openanalytics.containerproxy.model.runtime.Proxy; +import eu.openanalytics.containerproxy.model.spec.ProxySpec; +import eu.openanalytics.containerproxy.service.IdentifierService; +import eu.openanalytics.containerproxy.service.ProxyService; +import eu.openanalytics.containerproxy.service.UserService; import eu.openanalytics.containerproxy.service.hearbeat.HeartbeatService; +import eu.openanalytics.containerproxy.util.ContextPathHelper; import eu.openanalytics.shinyproxy.AppRequestInfo; -import eu.openanalytics.shinyproxy.OperatorService; +import eu.openanalytics.shinyproxy.ShinyProxySpecProvider; import eu.openanalytics.shinyproxy.runtimevalues.AppInstanceKey; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -47,11 +41,16 @@ import org.springframework.ui.ModelMap; import org.springframework.util.StreamUtils; -import eu.openanalytics.containerproxy.model.runtime.Proxy; -import eu.openanalytics.containerproxy.model.spec.ProxySpec; -import eu.openanalytics.containerproxy.service.ProxyService; -import eu.openanalytics.containerproxy.service.UserService; -import eu.openanalytics.containerproxy.util.SessionHelper; +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; public abstract class BaseController { @@ -71,44 +70,38 @@ public abstract class BaseController { HeartbeatService heartbeatService; @Inject - OperatorService operatorService; + IdentifierService identifierService; + + @Inject + protected ShinyProxySpecProvider shinyProxySpecProvider; + + @Inject + private IContainerBackend backend; private static final Logger logger = LogManager.getLogger(BaseController.class); private static final Map imageCache = new HashMap<>(); - protected String getUserName(HttpServletRequest request) { - Principal principal = request.getUserPrincipal(); - return (principal == null) ? request.getSession().getId() : principal.getName(); - } - - protected String getAppTitle(AppRequestInfo appRequestInfo) { - String appName = appRequestInfo.getAppName(); - ProxySpec spec = proxyService.getProxySpec(appName); - if (spec == null || spec.getDisplayName() == null || spec.getDisplayName().isEmpty()) return appName; - else return spec.getDisplayName(); - } - - protected String getContextPath() { - return SessionHelper.getContextPath(environment, true); - } - protected long getHeartbeatRate() { return heartbeatService.getHeartbeatRate(); } protected Proxy findUserProxy(AppRequestInfo appRequestInfo) { + return findUserProxy(appRequestInfo.getAppName(), appRequestInfo.getAppInstance()); + } + + protected Proxy findUserProxy(String appname, String appInstance) { return proxyService.findProxy(p -> - p.getSpec().getId().equals(appRequestInfo.getAppName()) - && p.getRuntimeValue(AppInstanceKey.inst).equals(appRequestInfo.getAppInstance()) - && userService.isOwner(p), + p.getSpecId().equals(appname) + && p.getRuntimeValue(AppInstanceKey.inst).equals(appInstance) + && userService.isOwner(p), false); } - + protected String getProxyEndpoint(Proxy proxy) { - if (proxy == null || proxy.getTargets().isEmpty()) return null; - return proxy.getTargets().keySet().iterator().next(); + if (proxy == null || proxy.getContainers().get(0).getTargets().isEmpty()) return null; + return proxy.getContainers().get(0).getTargets().keySet().iterator().next(); } - + protected void prepareMap(ModelMap map, HttpServletRequest request) { map.put("application_name", environment.getProperty("spring.application.name")); // name of ShinyProxy, ContainerProxy etc map.put("title", environment.getProperty("proxy.title", "GAMS MIRO")); @@ -117,9 +110,8 @@ protected void prepareMap(ModelMap map, HttpServletRequest request) { map.put("themeCss", "/assets/css/themes/" + environment.getProperty("proxy.theme", "default") + ".css"); map.put("bootstrapCss", "/webjars/bootstrap/3.4.1/css/bootstrap.min.css"); map.put("bootstrapJs", "/webjars/bootstrap/3.4.1/js/bootstrap.min.js"); - map.put("jqueryJs", "/webjars/jquery/3.5.1/jquery.min.js"); - map.put("cookieJs", "/webjars/js-cookie/2.2.1/js.cookie.min.js"); - map.put("handlebars", "/webjars/handlebars/4.7.6/handlebars.runtime.min.js"); + map.put("jqueryJs", "/webjars/jquery/3.6.1/jquery.min.js"); + map.put("handlebars", "/webjars/handlebars/4.7.7/handlebars.runtime.min.js"); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); boolean isLoggedIn = authentication != null && !(authentication instanceof AnonymousAuthenticationToken) && authentication.isAuthenticated(); @@ -127,13 +119,13 @@ protected void prepareMap(ModelMap map, HttpServletRequest request) { map.put("isAdmin", userService.isAdmin(authentication)); map.put("isSupportEnabled", isLoggedIn && getSupportAddress() != null); map.put("logoutUrl", authenticationBackend.getLogoutURL()); - map.put("isAppPage", false); // defaults, used in navbar + map.put("page", ""); // defaults, used in navbar map.put("maxInstances", 0); // defaults, used in navbar - map.put("contextPath", getContextPath()); - - // operator specific - map.put("operatorEnabled", operatorService.isEnabled()); - map.put("operatorForceTransfer", operatorService.mustForceTransfer()); + map.put("contextPath", ContextPathHelper.withEndingSlash()); + map.put("resourcePrefix", "/" + identifierService.instanceId); + map.put("appMaxInstances", shinyProxySpecProvider.getMaxInstances()); + map.put("pauseSupported", backend.supportsPause()); + map.put("spInstance", identifierService.instanceId); } protected String getSupportAddress() { @@ -163,4 +155,31 @@ protected String resolveImageURI(String resourceURI) { return resolvedValue; } + /** + * Validates whether a proxy should be allowed to start. + */ + protected boolean validateProxyStart(ProxySpec spec) { + Integer maxInstances = shinyProxySpecProvider.getMaxInstancesForSpec(spec); + + if (maxInstances == -1) { + return true; + } + + // note: there is a very small change that the user is able to start more instances than allowed, if the user + // starts many proxies at once. E.g. in the following scenario: + // - max proxies = 2 + // - user starts a proxy + // - user sends a start proxy request -> this function is called and returns true + // - just before this new proxy is added to the list of active proxies, the user sends a new start proxy request + // - again this new proxy is allowed, because there is still only one proxy in the list of active proxies + // -> the user has three proxies running. + // Because of chance that this happens is small and that the consequences are low, we accept this risk. + int currentAmountOfInstances = proxyService.getProxies( + p -> p.getSpecId().equals(spec.getId()) + && userService.isOwner(p), + false).size(); + + return currentAmountOfInstances < maxInstances; + } + } diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/HeartbeatController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/HeartbeatController.java index 5754887c..bb3f8e85 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/controllers/HeartbeatController.java +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/HeartbeatController.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -21,12 +21,19 @@ package eu.openanalytics.shinyproxy.controllers; import eu.openanalytics.containerproxy.model.runtime.Proxy; +import eu.openanalytics.containerproxy.api.dto.ApiResponse; import eu.openanalytics.containerproxy.service.ProxyService; import eu.openanalytics.containerproxy.service.UserService; +import eu.openanalytics.containerproxy.service.hearbeat.ActiveProxiesService; import eu.openanalytics.containerproxy.service.hearbeat.HeartbeatService; +import eu.openanalytics.shinyproxy.controllers.dto.ShinyProxyApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -34,7 +41,6 @@ import org.springframework.web.bind.annotation.ResponseBody; import javax.inject.Inject; -import java.util.HashMap; @Controller public class HeartbeatController { @@ -48,32 +54,138 @@ public class HeartbeatController { @Inject private UserService userService; + @Inject + private ActiveProxiesService activeProxiesService; + /** * Endpoint used to force a heartbeat. This is used when an app cannot piggy-back heartbeats on other requests * or on a WebSocket connection. - * @return */ + @Operation(summary = "Force an heartbeat for an app.", tags = "ShinyProxy") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Heartbeat sent.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(value = "{\"status\":\"success\", \"data\": null}") + } + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "User is not authenticated.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(value = "{\"message\":\"shinyproxy_authentication_required\",\"status\":\"fail\"}") + } + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "410", + description = "App has been stopped or the app never existed or the user has no access to the app.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(value = "{\"message\":\"app_stopped_or_non_existent\",\"status\":\"fail\"}") + } + ) + }), + }) @RequestMapping(value = "/heartbeat/{proxyId}", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE) @ResponseBody - public ResponseEntity> heartbeat(@PathVariable("proxyId") String proxyId) { + public ResponseEntity> heartbeat(@PathVariable("proxyId") String proxyId) { + Proxy proxy = proxyService.getProxy(proxyId); + + if (proxy == null || proxy.getStatus().isUnavailable() || !userService.isOwner(proxy)) { + return ShinyProxyApiResponse.appStoppedOrNonExistent(); + } + + heartbeatService.heartbeatReceived(HeartbeatService.HeartbeatSource.FALLBACK, proxy.getId(), null); + + return ApiResponse.success(); + } + + + /** + * Provides info to about the heartbeat, max lifetime etc. of this app. + */ + @Operation(summary = "Get heartbeat information for an app.", tags = "ShinyProxy") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Heartbeat info returned.", + content = { + @Content( + mediaType = "application/json", + schema = @Schema(implementation = HeartBeatInfoDto.class), + examples = { + @ExampleObject(value = "{\"status\":\"success\"}") + } + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "User is not authenticated.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(value = "{\"message\":\"shinyproxy_authentication_required\",\"status\":\"fail\"}") + } + ) + }), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "410", + description = "App has been stopped or the app never existed or the user has no access to the app.", + content = { + @Content( + mediaType = "application/json", + examples = { + @ExampleObject(value = "{\"message\":\"app_stopped_or_non_existent\",\"status\":\"fail\"}") + } + ) + }), + }) + @RequestMapping(value = "/heartbeat/{proxyId}", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public ResponseEntity> getHeartbeatInfo(@PathVariable("proxyId") String proxyId) { Proxy proxy = proxyService.getProxy(proxyId); - if (proxy == null) { - return ResponseEntity.status(410).body(new HashMap() {{ - put("status", "error"); - put("message", "app_stopped_or_non_existent"); - }}); + if (proxy == null || proxy.getStatus().isUnavailable() || !userService.isOwner(proxy)) { + return ShinyProxyApiResponse.appStoppedOrNonExistent(); } - if (!userService.isOwner(proxy)) { - throw new AccessDeniedException(String.format("Cannot register heartbeat for proxy %s: access denied", proxyId)); + Long lastHeartbeat = activeProxiesService.getLastHeartBeat(proxy.getId()); + + HeartBeatInfoDto resp = new HeartBeatInfoDto(lastHeartbeat, heartbeatService.getHeartbeatRate()); + + return ApiResponse.success(resp); + } + + + private static class HeartBeatInfoDto { + + private final Long lastHeartbeat; + private final Long heartbeatRate; + + private HeartBeatInfoDto(Long lastHeartbeat, Long heartbeatRate) { + this.lastHeartbeat = lastHeartbeat; + this.heartbeatRate = heartbeatRate; } - heartbeatService.heartbeatReceived(HeartbeatService.HeartbeatSource.FALLBACK, proxy.getId(), null); + public Long getHeartbeatRate() { + return heartbeatRate; + } - return ResponseEntity.ok(new HashMap() {{ - put("status", "success"); - }}); + public Long getLastHeartbeat() { + return lastHeartbeat; + } } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/IndexController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/IndexController.java index ad838573..53ca3d30 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/controllers/IndexController.java +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/IndexController.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -21,12 +21,15 @@ package eu.openanalytics.shinyproxy.controllers; import eu.openanalytics.containerproxy.model.spec.ProxySpec; +import eu.openanalytics.shinyproxy.ShinyProxySpecExtension; import eu.openanalytics.shinyproxy.ShinyProxySpecProvider; +import org.springframework.core.env.Environment; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.servlet.view.RedirectView; +import javax.annotation.PostConstruct; import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import java.util.ArrayList; @@ -39,7 +42,17 @@ public class IndexController extends BaseController { @Inject - ShinyProxySpecProvider shinyProxySpecProvider; + private ShinyProxySpecProvider shinyProxySpecProvider; + + @Inject + private Environment environment; + + private MyAppsMode myAppsMode; + + @PostConstruct + public void init() { + myAppsMode = environment.getProperty("proxy.my-apps-mode", MyAppsMode.class, MyAppsMode.None); + } @RequestMapping("/") private Object index(ModelMap map, HttpServletRequest request) { @@ -68,7 +81,8 @@ private Object index(ModelMap map, HttpServletRequest request) { List ungroupedApps = new ArrayList<>(); for (ProxySpec app: apps) { - String groupId = shinyProxySpecProvider.getTemplateGroupOfApp(app.getId()); + // String groupId = app.getSpecExtension(ShinyProxySpecExtension.class).getTemplateGroup(); + String groupId = null; if (groupId != null) { groupedApps.putIfAbsent(groupId, new ArrayList<>()); groupedApps.get(groupId).add(app); @@ -77,16 +91,23 @@ private Object index(ModelMap map, HttpServletRequest request) { } } - List templateGroups = shinyProxySpecProvider.getTemplateGroups().stream().filter((g) -> groupedApps.containsKey(g.getId())).collect(Collectors.toList());; + List templateGroups = shinyProxySpecProvider.getTemplateGroups().stream().filter((g) -> groupedApps.containsKey(g.getId())).collect(Collectors.toList()); map.put("templateGroups", templateGroups); map.put("groupedApps", groupedApps); map.put("ungroupedApps", ungroupedApps); + // navbar + map.put("page", "index"); - // operator specific - map.put("operatorShowTransferMessage", operatorService.showTransferMessageOnMainPage()); + map.put("myAppsMode", myAppsMode.toString()); return "index"; } + public enum MyAppsMode { + Inline, + Modal, + None + } + } diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/IssueController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/IssueController.java index f47207e2..e63596ab 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/controllers/IssueController.java +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/IssueController.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -28,6 +28,7 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import eu.openanalytics.containerproxy.log.LogPaths; import eu.openanalytics.shinyproxy.AppRequestInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -52,7 +53,7 @@ public class IssueController extends BaseController { @RequestMapping(value="/issue", method=RequestMethod.POST) public ResponseEntity> postIssue(HttpServletRequest request, HttpServletResponse response) { IssueForm form = new IssueForm(); - form.setUserName(getUserName(request)); + form.setUserName(userService.getCurrentUserId()); form.setCurrentLocation(request.getParameter("currentLocation")); AppRequestInfo appRequestInfo = AppRequestInfo.fromURI(form.getCurrentLocation()); if (appRequestInfo != null) { @@ -62,7 +63,7 @@ public ResponseEntity> postIssue(HttpServletRequest requ Proxy activeProxy = null; for (Proxy proxy: proxyService.getProxies(null, false)) { - if (proxy.getUserId().equals(form.getUserName()) && proxy.getSpec().getId().equals(form.getAppName())) { + if (proxy.getUserId().equals(form.getUserName()) && proxy.getSpecId().equals(form.getAppName())) { activeProxy = proxy; break; } @@ -99,17 +100,20 @@ public void sendSupportMail(IssueForm form, Proxy proxy) { // Attachments (only if container-logging is enabled) if (proxy != null) { - String[] filePaths = logService.getLogs(proxy); + LogPaths filePaths = logService.getLogs(proxy); - if (filePaths != null && filePaths.length > 1) { - if (new File(filePaths[0]).exists()) { - for (String p: filePaths) { - File f = new File(p); - helper.addAttachment(f.getName(), f); + if (filePaths != null) { + File stdout = filePaths.getStdout().toFile(); + if (stdout.exists()) { + helper.addAttachment(stdout.getName(), stdout); + // if stderr exists add it as well (stdout may exists without stderr) + File stderr = filePaths.getStderr().toFile(); + if (stderr.exists()) { + helper.addAttachment(stderr.getName(), stderr); } } else { - body.append(String.format("Log (stdout): %s%s", filePaths[0], lineSep)); - body.append(String.format("Log (stderr): %s%s", filePaths[1], lineSep)); + body.append(String.format("Log (stdout): %s%s", filePaths.getStdout().toString(), lineSep)); + body.append(String.format("Log (stderr): %s%s", filePaths.getStderr().toString(), lineSep)); } } } @@ -153,4 +157,4 @@ public void setCustomMessage(String customMessage) { this.customMessage = customMessage; } } -} \ No newline at end of file +} diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/OperatorController.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/OperatorController.java deleted file mode 100644 index 86502633..00000000 --- a/src/main/java/eu/openanalytics/shinyproxy/controllers/OperatorController.java +++ /dev/null @@ -1,48 +0,0 @@ -/** - * ShinyProxy - * - * Copyright (C) 2016-2021 Open Analytics - * - * =========================================================================== - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Apache License as published by - * The Apache Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program 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 - * Apache License for more details. - * - * You should have received a copy of the Apache License - * along with this program. If not, see - */ -package eu.openanalytics.shinyproxy.controllers; - -import eu.openanalytics.shinyproxy.OperatorCookieFilter; -import eu.openanalytics.shinyproxy.OperatorEnabledCondition; -import org.springframework.context.annotation.Conditional; -import org.springframework.stereotype.Controller; -import org.springframework.ui.ModelMap; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; - -import javax.servlet.http.HttpServletRequest; - -@Controller -@Conditional(OperatorEnabledCondition.class) -public class OperatorController extends BaseController { - - @RequestMapping(value = "/server-transfer", method = RequestMethod.GET) - public String getServerTransferPage(ModelMap map, HttpServletRequest request) { - String redirectUri = request.getParameter("redirectUri"); - String allowedRedirectUri = OperatorCookieFilter.getRedirectUriByMatch(redirectUri); - - map.put("redirectUri", allowedRedirectUri); - - prepareMap(map, request); - return "server-transfer"; - } - -} diff --git a/src/main/java/eu/openanalytics/shinyproxy/controllers/dto/ShinyProxyApiResponse.java b/src/main/java/eu/openanalytics/shinyproxy/controllers/dto/ShinyProxyApiResponse.java new file mode 100644 index 00000000..67f89ae2 --- /dev/null +++ b/src/main/java/eu/openanalytics/shinyproxy/controllers/dto/ShinyProxyApiResponse.java @@ -0,0 +1,44 @@ +/** + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.shinyproxy.controllers.dto; + +import eu.openanalytics.containerproxy.api.dto.ApiResponse; +import eu.openanalytics.containerproxy.util.ImmediateJsonResponse; +import org.springframework.http.ResponseEntity; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class ShinyProxyApiResponse { + + public static ResponseEntity> appStoppedOrNonExistent() { + return ResponseEntity.status(410).body(new ApiResponse<>("fail", "app_stopped_or_non_existent")); + } + + public static void appStoppedOrNonExistent(HttpServletResponse response) throws IOException { + ImmediateJsonResponse.write(response, 410, "{\"status\":\"fail\", \"data\":\"app_stopped_or_non_existent\"}"); + } + + public static void authenticationRequired(HttpServletResponse response) throws IOException { + ImmediateJsonResponse.write(response, 410, "{\"status\":\"fail\", \"data\":\"shinyproxy_authentication_required\"}"); + } + +} diff --git a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/AppInstanceKey.java b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/AppInstanceKey.java index 027b2677..cffd6c17 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/AppInstanceKey.java +++ b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/AppInstanceKey.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -30,9 +30,21 @@ public AppInstanceKey() { false, true, // include as annotation so that the value can be recovered false, - true, String.class); + true, + true, + false, + String.class); } public static AppInstanceKey inst = new AppInstanceKey(); + @Override + public String deserializeFromString(String value) { + return value; + } + + @Override + public String serializeToString(String value) { + return value; + } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/PublicPathKey.java b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/PublicPathKey.java index b21303d6..fd74577c 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/PublicPathKey.java +++ b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/PublicPathKey.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -30,9 +30,22 @@ public PublicPathKey() { false, true, // include as annotation so that the value can be recovered true, - true, String.class); + true, + true, + false, + String.class); } public static PublicPathKey inst = new PublicPathKey(); + @Override + public String deserializeFromString(String value) { + return value; + } + + @Override + public String serializeToString(String value) { + return value; + } + } diff --git a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/ShinyForceFullReloadKey.java b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/ShinyForceFullReloadKey.java index 6ab8cf78..af290d5e 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/ShinyForceFullReloadKey.java +++ b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/ShinyForceFullReloadKey.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -29,11 +29,23 @@ public ShinyForceFullReloadKey() { super("openanalytics.eu/sp-shiny-force-full-reload", "SHINYPROXY_FORCE_FULL_RELOAD", false, + true, false, + true, + true, false, - false, Boolean.class); + Boolean.class); } public static ShinyForceFullReloadKey inst = new ShinyForceFullReloadKey(); + @Override + public Boolean deserializeFromString(String value) { + return Boolean.valueOf(value); + } + + @Override + public String serializeToString(Boolean value) { + return value.toString(); + } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/MaxInstancesKey.java b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/TrackAppUrl.java similarity index 61% rename from src/main/java/eu/openanalytics/shinyproxy/runtimevalues/MaxInstancesKey.java rename to src/main/java/eu/openanalytics/shinyproxy/runtimevalues/TrackAppUrl.java index f8543e01..31142a84 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/MaxInstancesKey.java +++ b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/TrackAppUrl.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -20,20 +20,32 @@ */ package eu.openanalytics.shinyproxy.runtimevalues; + import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValueKey; -public class MaxInstancesKey extends RuntimeValueKey { +public class TrackAppUrl extends RuntimeValueKey { - public MaxInstancesKey() { - super("openanalytics.eu/sp-max-instances", - "SHINYPROXY_MAX_INSTANCES", + public TrackAppUrl() { + super("openanalytics.eu/sp-track-app-url", + "SHINYPROXY_TRACK_APP_URL", false, + true, false, + true, + true, false, - false, Integer.class); + Boolean.class); } - public static MaxInstancesKey inst = new MaxInstancesKey(); + public static TrackAppUrl inst = new TrackAppUrl(); + @Override + public Boolean deserializeFromString(String value) { + return Boolean.valueOf(value); + } + @Override + public String serializeToString(Boolean value) { + return value.toString(); + } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/UserTimeZoneKey.java b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/UserTimeZoneKey.java new file mode 100644 index 00000000..9b072df7 --- /dev/null +++ b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/UserTimeZoneKey.java @@ -0,0 +1,50 @@ +/** + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +package eu.openanalytics.shinyproxy.runtimevalues; + +import eu.openanalytics.containerproxy.model.runtime.runtimevalues.RuntimeValueKey; + +public class UserTimeZoneKey extends RuntimeValueKey { + + public UserTimeZoneKey() { + super("openanalytics.eu/sp-user-timezone", + "SHINYPROXY_USER_TIMEZONE", + false, + true, // include as annotation so that the value can be recovered + false, + true, + true, + false, + String.class); + } + + public static UserTimeZoneKey inst = new UserTimeZoneKey(); + + @Override + public String deserializeFromString(String value) { + return value; + } + + @Override + public String serializeToString(String value) { + return value; + } +} diff --git a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebSocketReconnectionModeKey.java b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebSocketReconnectionModeKey.java index 0cff2a34..f087d8dd 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebSocketReconnectionModeKey.java +++ b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebSocketReconnectionModeKey.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -29,11 +29,23 @@ public WebSocketReconnectionModeKey() { super("openanalytics.eu/sp-websocket-reconnection-mode", "SHINYPROXY_WEBSOCKET_RECONNECTION_MODE", false, + true, false, + true, false, - false, WebsocketReconnectionMode.class); + false, + WebsocketReconnectionMode.class); } public static WebSocketReconnectionModeKey inst = new WebSocketReconnectionModeKey(); + @Override + public WebsocketReconnectionMode deserializeFromString(String value) { + return WebsocketReconnectionMode.valueOf(value); + } + + @Override + public String serializeToString(WebsocketReconnectionMode value) { + return value.name(); + } } diff --git a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebsocketReconnectionMode.java b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebsocketReconnectionMode.java index a58f9d20..6226a360 100644 --- a/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebsocketReconnectionMode.java +++ b/src/main/java/eu/openanalytics/shinyproxy/runtimevalues/WebsocketReconnectionMode.java @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * diff --git a/src/main/resources/static/css/default.css b/src/main/resources/static/css/default.css index fc306cc7..14919ce9 100644 --- a/src/main/resources/static/css/default.css +++ b/src/main/resources/static/css/default.css @@ -1,7 +1,7 @@ /** * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -18,72 +18,56 @@ * You should have received a copy of the Apache License * along with this program. If not, see */ -body > div { padding-top: 10px; } -body > div#navbar { padding-top: 0px; } - -#navbar + div { padding-top: 50px; } -#navbar + iframe { padding-top: 50px; } - #applist { - margin-top: 10px; - padding-top: 50px; + margin-top: 16px; } #applist h2 { padding-left: 20px; + border-bottom: 1px solid rgba(0,0,0,.2); + margin-bottom: 30px; + margin-top: 0px; + padding-bottom: 10px; } -#new-version-banner { - margin: 70px 20px 20px 20px; - padding-top: 15px !important; -} - -#new-version-banner[style*='display: block'] + #applist { - margin-top: 10px; - padding-top: 0px; -} - - #shinyframe { - border: none; - display: block; + border: none; + display: block; + bottom: 0; + position: absolute; } #admin { margin-left: 10px; + margin-right: 10px; } #admin th, td { padding: 5px; } +#admin #allApps { + width: 100%; +} + #error { padding-left: 15px; } .loading { display: none; - position: fixed; - top: 150px; width: 100%; z-index: 999; } .loading-img { background: url(data:image/gif;base64,R0lGODlhQABAAPcAAIDF3b/i7pXP4+r1+arY6NTs9FWy0mu710SqzWi617zg7f3+/kiszvj8/VCw0Ov2+o/M4ez2+vL5/Pb7/eLy91y109ft9Pz+/ozK4PT6/Mrn8UKpzXTA2s7p8oLG3t7w9lay0ozL4K7a6f7+/6LV5vv9/qTV51+21JbP40+v0Eytz0OpzeDx99Pr8+b0+EWqzUGozHG+2afX53nC21q007vg7ebz+Pn8/XrC2+/3+3O/2qPV5pPO4sjm8HC+2dbs9Nru9e73+u/4+57T5WK31WG31ff7/fL5+8vn8YvK4NTr81mz07rg7Wy82HjC21Kw0bHc6lGw0f7///P6/On1+bnf7PX6/F621KHU5pLO4sXk7+f0+Z3S5cbl8Nzv9uPy+OTz+Nnu9bLc6vr9/me61sDi7sXl8Njt9bDb6lm007jf7MLj79Xs9H/F3a3a6fH5++Xz+JnR5NDq8nK/2ZjQ4/D4++33+pjQ5F211KfX6Oj0+YbI35vS5N/w9pfQ42+92c7o8vX7/LPd69Hq81y11Mnm8a/a6o3L4d3v9oPH3o7L4Vez0qjX6KvZ6bTd68/p8nG/2bbe7Fiz0r3h7bPc6+Py94nJ38Pk78zo8YnJ4IrK4K3Z6Wu82Mfl8MHj7nXA2lu007be60CozP///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh/wtYTVAgRGF0YVhNUDw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoTWFjaW50b3NoKSIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDowMDA3NTlEOTk4REYxMUUzQTY5NkRERDBGNjQ2NTNFMiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDowMDA3NTlEQTk4REYxMUUzQTY5NkRERDBGNjQ2NTNFMiI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjA4MTUwMjlFOThERDExRTNBNjk2REREMEY2NDY1M0UyIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjAwMDc1OUQ4OThERjExRTNBNjk2REREMEY2NDY1M0UyIi8+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+Af/+/fz7+vn49/b19PPy8fDv7u3s6+rp6Ofm5eTj4uHg397d3Nva2djX1tXU09LR0M/OzczLysnIx8bFxMPCwcC/vr28u7q5uLe2tbSzsrGwr66trKuqqainpqWko6KhoJ+enZybmpmYl5aVlJOSkZCPjo2Mi4qJiIeGhYSDgoGAf359fHt6eXh3dnV0c3JxcG9ubWxramloZ2ZlZGNiYWBfXl1cW1pZWFdWVVRTUlFQT05NTEtKSUhHRkVEQ0JBQD8+PTw7Ojk4NzY1NDMyMTAvLi0sKyopKCcmJSQjIiEgHx4dHBsaGRgXFhUUExIREA8ODQwLCgkIBwYFBAMCAQAAIfkEBAMAAAAsAAAAAEAAQAAACMkARwkcSLCgwYMIEypcyLChw4cQI0qcSLGixYsYM2rcyLGjx48gQ4ocSbKkyZMoU6pcybKly5cwY8qcSbOmzZs4c+okqSABAgQJFBDs+TPoUJ9AhZKEIKqpUwgCmTptCnWU1KlVQyqYOlXBVq5NvYINKzLBWFEJzI5NezaBSARnf8aFOxbB27l465ZtqxYs27Uiv4IVO5bw4JFXn0YFWzUx1ZJEkx4tqlRgZKM7M2vezLmz58+gQ4seTbq06dOoU6tezbq169c2AwIAIfkEBAMAAAAsEQAbAB8ACgAACJIAR42SgAKEKBUeKAgcWPBgwoUsklwxcCDPhIURaIjauJFBh1EZOXb8iGSJgZMndUwRqEMkxxQZWroUlQIOHpQ4FY1iMZPjkJ4bAeAcGoQJUFExjk4cihKQUaBJgS5lagAQT6A/gQqlaiDIKJkuYYIVWfMmU50gNYr0mNYl25JDVS4kaBChQoF0Hd7dKZGiRYEBAQAh+QQEAwAAACwRABsAIAAKAAAIkwBHCbQwgwGMCiQaCBxF0CBChQK98PDByQOTEgtHKVghqmPHBBM0cvQoCuQoLU0OqFQJAeMoFwhIesQAU2ZHmilXrnQj8I7Njgh4/BSFgIROnZAwchhakumMozrBjFo6NIFTqCul+vwZdGhRrAeSvoxpkyZZmTix8hS4UabJtiRNojzaMmPDgwkX3n24UCJFiy4DAgAh+QQEAwAAACwSABsAIAAKAAAIlABHjVoggsiGFz40CBxY8GDChaOsqDkEwJKbIAsX4BDFsaOMgRs7cvw4SggEAChR7qEg0IRIkS1cvuTYYhSXlDhDjBnxZCbHNj19AoCDs6icCD45SkoqChSSojgjIU26NKlTqCkj8UwKNOlQrCjljJI5M2bSmjeh6gQ586PGtgJNFl2ZsSFChQIJGrwLUSJFixhHBQQAIfkEBAMAAAAsEgAbACIACgAACKEARwl8EMKBKAchHggcRdAgQoULR+WowkcAFi0NIn4wKKrjwQ+jNnrs6ACkQDBxBKhUucOKwAsnRno8cSOmTFEnLoy6wWWlTygCe9z0iGVoxx6jlPhcGmSUCaOiOEDdMSrAUp9hRu2AqmNq1asrswo1WtQoUqVgBTSFOZSmTZk5d/a8ClSgyJElQ3L0mPdkSp8tIzY8mHDh4IcRJVK0iFFgQAAh+QQEAwAAACwTABsAIwAKAAAIoQBHCdQQ48WGIiIWCBxF0CBChQsFTkAChYAYTBMiyhDFseOMC6M2duT4MWKdigRSWnwjsMXIkTtcvuS4Y+EINSpzVhkxCsBMjlF8/ozCc5SLnEi3jKrwk+OSpqIGCPyBNKeFpVCfNpU6imrVlFeFzgzalKjAo18JKJU5M2bTmgJvft0pUOTIknY9glx4EqkYlgsbHkwYuOBgiBEnVryYcVRAACH5BAQDAAAALBQAGwAkAAoAAAigAEeNakCiAgwGTn4IHFjwYMKFEEdJ+aKhjCc5QRZOSCCqY8cNNUZt9PgxZMQRgwKoXFlJIAaSHhHYeAlTlMyILFbqDPAmA4KaHbP8BEoHohQtO1f+UAK0I5mmouZANJJ0ZSGmTZ82lbqQatUAhXw2Fdq06MKjXxXShClzLcmbEHNWfSOSI0mQdWHiPZlyZ0uBBA0iVAi44eCIZylaxCgwIAAh+QQEAwAAACwUABsAJwAKAAAIpABHCaTgQYUoEAIkCBxF0CBChQsjCrzxxUKBMFsuROzAQJRHj2kijOL4EaRIiQKtWCzAsgCQEgIzpCj5UYdMmh51oBy14EzLnywEosH5cQhRj0ElBvnJtMEoDEdFxYjKBKUNpj+FPI069WhViVexstQ69KjRo0kjLhVbwOlNnDZnwt3ZU2xakiVDjuyY9yRKlUxfRmx4MOFCwg93LqRoEaPGUQEBACH5BAQDAAAALAMAGwA6AAoAAAjOAEeNunAkwoAIU0YIXMhwoYIECBAkUMDwYcSJDTMKbJDjwYMcDRaOeTCgZEk7CzQuhCCqpUsIAlm6bAlTpUAJJk1OGSXFYM6SQmwqmDmzxlCiLWvYbPDTZAMjTU2WUJkAacsEVa0msJkj6oAcOL0aUYnAqqgVZa2ukKLS64AHYaOO1ZgWKVqza9t6fQDV61SNWZFiNbtVZdeoOXpGDaryKFKjZpWqZBo15MifKG2OkjmzJueXmkfF1bmQoEGECkNblEjRIUTWoTd2/BhyVEAAIfkEBAMAAAAsAwAbADoACgAACK8ARwkUmAMRGzYfjgxcOFACChCiVHig0PBhxIkMM2rM+KWAx49UNkagIapkSQYdRo00eTLlxpcL7XycWUCCRg4sTabIgDOnqJ0wgwKh+ZFFRhY+TQ5JWhJN0JcLiH48k7EGU1ExrmJ4ujGq1AJUGVplmpXpVq4ah0o1yhAp06VMnaLNKFOqzYw9c+7MyxLoXI5EQ2pcyRKlSpKFXf7NWPBgQpgOIUqkKDDyRcqLRwUEACH5BAQDAAAALAUAGwAnAAoAAAipAEcJFBjox6UAXbyUGMhwlAUnDGBUINFg4MOIEys2FLImgEePPTQyrLFClEmTCSaMInkSpcqBF8x8nPmooQ0ELU9iuJnTJAaGNmYKzcCQTk+TCLIcFYWAqEAgQmduYThnqSgyVpUMhBrV49SBVZdiXapVYNCuAZwKNHo06dKmMGVGrQkUZ8+ddnP+ZMhRaMiGK0u2TBk4J+GGBQ8mXAjYIUSJFC0+zigwIAAh+QQEAwAAACwIABsAJAAKAAAInQBHCRxVQkkoAobM1Bk4cIGIIhtexNDQ8GHEiQxH3ahCoGPHTTYYLsAhqqRJGaNGmjzJsJPHlyIaDDSxcmULmjVLthA44aVPCwJHRMlZEsBQogAEuvD5sovAB0RLLokqqoJSph6djoIadWpUq6N6YiUAdJTQqEbRDnTJNObMqDfhDtzoE6RIkjVRqsybseDBhAszOoQokaLAwRcNBwQAIfkEBAMAAAAsCgAbACMACgAACKUARwkcVQKJCQFxHFEZKPBBCAeiHIR4MNAhRIkUGY5pJKBjRxRABn6AKKpkxA+jRpos6QDlwAAeY/o5MurCiZUmT9y4iVPUiQsCb6CIGbPLqB49TWJJWrKHQBdEY0IZtYOpKA5WTTyN6nFqVaZYmWodJZSrAKNImS5l6lQgzKgza/LEqXPuyp8DNxIFKZKkyZYp/bJ0ObDgwYQLGVqMOLHiQ8YZAwIAIfkEBAMAAAAsDAAbACIACgAACJcARwkcdUSMJgBJBEkYOEqDjxcbiIhYMNAhRIkUGerJBKBjxyQPBMoQRbIkDoojS5I8OXABD48wBYxooVKlCZo1SZoYGAamTy8AcpJ80kaoqCcjBJbxCXMNDaOiFkGNoJSpR6dQpRqlOqqnVQBAjRIVm3SUS6sycea8aXTnwI0+QYrMyTKlSpYMCx5MuLDiw4gT/V4MPCogACH5BAQDAAAALA4AGwAgAAoAAAiVAEcJXDApUZM/WYAIHNWARAUYDGZYWNjwYcSJC0vwOMCxY4BRExKIGjlyhQKQIkmKMrmQUseXTShgUEkSgYuZNEXZHHWBw8uXXBDkHMlD6NA7o6j8fPlp6MiUQzkkXdqxqVOoOaX2pHogqNOiTpGOcrk0Jk6aNs+q3DlK49KPIWmyjKuS5UKCBhEqFFgRokSKDv1iDAgAIfkEBAMAAAAsDwAbACAACgAACJQARwk0wuiAATxJ+ggcRcGDClEgUEhY2PBhxIkLM3AwwJHjEg2jOjAQRZIkjQghR5YUdXIhhI4wCYFJsbIkhww0a4riIFAICJgwAegsOWQoSRYhgcK8YlRUjKZMkirlyNToU6NRfU41INRoUaNIR71UKjNnzZtmV/IUqBHox5Q1W4qMi3IhQYMIFQqsCFEiRYd9MQYEADs=) center no-repeat #fff; + height: 50px; } .loading-txt { text-align: center; font-size: 24px; - margin-top: -50px; -} - -#new-version-btn { - margin-left: 20px; -} - -#reconnecting { - height: 175px; + margin-top: 30px; } #reloadFailed { @@ -120,14 +104,53 @@ body > div#navbar { padding-top: 0px; } width: 100px; } -#switchInstancesModal .btn-group { - float: right; - margin-top: 9px; +.myApps .btn-group { + float:right; + margin-left: 15px; + display: block; + height: 20px +} + +.app-instance-title { + color: #337ab7; + text-decoration: none; + float: left; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: calc(100% - 150px); +} + +.active-app-instance-title { + float: left; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: calc(100% - 205px); } -#switchInstancesModal li { - height: 40px; - line-height: 40px; +.app-instance:hover .app-instance-title { + color: #23527c; + text-decoration: underline; +} + +.app-list-title { + color: #337ab7; + text-decoration: none; +} + +.list-group-item:hover .app-list-title { + color: #23527c; + text-decoration: underline; +} + +@media (min-width: 1200px) { + .myApps-inline { + max-width: 760px; + float: right; + } } .admin-proxy-id { @@ -150,24 +173,88 @@ body > div#navbar { padding-top: 0px; } width: 8em; } -#server-transfer-message { - margin-top: 10px; - padding-top: 50px; +#stopping-all-apps-btn { + display: none; } +#parameterForm { + display: none; + width: 100%; +} -#appPage #new-version-banner { - margin: 70px 20px 20px 30px; - position: absolute; - right: 200px; - top: 0px; +#parameterForm .form-horizontal { + margin-top: 25px; +} + +#parameterForm .help-block { + margin-bottom: 0; + padding-left: 10px; +} + +#selectAllWarning { + display: none; +} + +#selectAllWarning div { + margin-bottom: 0; +} + +#switchInstancesModal, #myAppsModal, #appDetailsModal { + display: none; +} + +#appDetailsModal table { + width: 100%; + table-layout: fixed; +} + +#appDetailsModal td { + width: 50%; + word-wrap: anywhere; +} + +#appDetailsModal tr:first-child td { + border-top: 0; +} + +#appDetailsModal .help-block { + margin-bottom: 0; +} + + +@media (max-width: 992px) { + .myApps-title, .myApps-inline #myApps, .myApps-footer { + margin-left: 0 !important; + margin-right: 0 !important; + } } -#appPage #new-version-banner button.close { - line-height: 1.5; +.myApps-title { + border-bottom: 1px solid rgba(0,0,0,.2); + margin-right: 30px; + margin-top: 30px; + margin-left: 30px; + height: 30px; +} + +.myApps-inline #myApps { + margin-right: 30px; + margin-left: 30px; + margin-top: 30px; +} + +.myApps-footer { + margin-top: 30px; + margin-left: 30px; + height: 60px; +} + +#stop-all-apps-btn { + display: none; } -#appPage #new-version-btn { - margin-left: 20px; - margin-right: 20px; +.status-label { + display: inline-block; + width: 70px; + line-height: inherit; } diff --git a/src/main/resources/static/handlebars/app_details.handlebars b/src/main/resources/static/handlebars/app_details.handlebars new file mode 100644 index 00000000..179e9dbf --- /dev/null +++ b/src/main/resources/static/handlebars/app_details.handlebars @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + {{#if userId }} + + + + + {{/if}} + + + + + {{#if uptime }} + + + + + {{/if}} + {{#if isInUse }} + + + + + {{/if}} + {{#if lastHeartBeat }} + + + + + {{/if}} + {{#if heartbeatTimeout }} + + + + + {{/if}} + {{#if imageName }} + + + + + {{/if}} + {{#if imageTag }} + + + + + {{/if}} + {{#if maxLifetime}} + + + + + {{/if}} + {{#if backendContainerName }} + + + + + {{/if}} + {{#if parameters}} + {{#each parameters}} + + + + + {{/each}} + {{/if}} +
+ App name + + {{ appName }} +
+ Instance + + {{ instanceName }} +
+ ID + + {{ proxyId }} +
+ Username + + {{ userId }} +
+ Status + + {{ status }} +
+ Uptime + + {{ uptime }} +
+ App is in use + + {{ isInUse }} +
+ Last heartbeat + + {{ lastHeartBeat }} +
+ Heartbeat timeout + + The app is automatically stopped when it has not been used for this amount of time. + + + {{ heartbeatTimeout }} + {{#if heartbeatTimeoutRemaining}} +
+ Remaining: {{ heartbeatTimeoutRemaining }} + {{/if}} +
+ Image + + {{ imageName }} +
+ Image tag + + {{ imageTag }} +
+ Max lifetime + + The app is automatically stopped when it has been running for this amount of time (even if it is in use). + + + {{ maxLifetime }} + {{#if maxLifetimeRemaining}} +
+ Remaining: {{ maxLifetimeRemaining }} + {{/if}} +
+ Name of container in backend + + {{ backendContainerName }} +
+ {{ displayName }} + {{#if description}} + + {{{ description }}} + + {{/if}} + + {{ value }} +
diff --git a/src/main/resources/static/handlebars/generate.sh b/src/main/resources/static/handlebars/generate.sh index 26ae3dfa..8e7337f4 100755 --- a/src/main/resources/static/handlebars/generate.sh +++ b/src/main/resources/static/handlebars/generate.sh @@ -2,7 +2,7 @@ # # ShinyProxy # -# Copyright (C) 2016-2021 Open Analytics +# Copyright (C) 2016-2023 Open Analytics # # =========================================================================== # @@ -27,7 +27,7 @@ set -u set -o pipefail if [ ! -f "./node_modules/.bin/handlebars" ]; then - npm install handlebars@4.7.6 --save false + npm install handlebars@4.7.7 --save false fi rm precompiled.js diff --git a/src/main/resources/static/handlebars/my_apps.handlebars b/src/main/resources/static/handlebars/my_apps.handlebars new file mode 100644 index 00000000..99893730 --- /dev/null +++ b/src/main/resources/static/handlebars/my_apps.handlebars @@ -0,0 +1,30 @@ +{{#each apps}} +
{{ displayName }}
+ +{{/each}} +{{#unless apps}} + You don't have any running apps.
+ Start a new app by clicking on its name or logo. +{{/unless}} diff --git a/src/main/resources/static/handlebars/precompiled.js b/src/main/resources/static/handlebars/precompiled.js index d256cda1..c393b0a2 100644 --- a/src/main/resources/static/handlebars/precompiled.js +++ b/src/main/resources/static/handlebars/precompiled.js @@ -1,7 +1,7 @@ /* * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -18,4 +18,4 @@ * You should have received a copy of the Apache License * along with this program. If not, see */ -!function(){var n=Handlebars.template;(Handlebars.templates=Handlebars.templates||{}).switch_instances=n({1:function(n,l,t,e,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return null!=(a=o(t,"if").call(null!=l?l:n.nullContext||{},null!=l?o(l,"active"):l,{name:"if",hash:{},fn:n.program(2,a,0),inverse:n.program(4,a,0),data:a,loc:{start:{line:3,column:8},end:{line:18,column:15}}}))?a:""},2:function(n,l,t,e,a){var o=null!=l?l:n.nullContext||{},r=n.hooks.helperMissing,u="function",c=n.escapeExpression,s=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return"
  • \n "+c(typeof(n=null!=(n=s(t,"name")||(null!=l?s(l,"name"):l))?n:r)==u?n.call(o,{name:"name",hash:{},data:a,loc:{start:{line:5,column:19},end:{line:5,column:29}}}):n)+'\n
    \n \n \n
    \n
  • \n'},4:function(n,l,t,e,a){var o=null!=l?l:n.nullContext||{},r=n.hooks.helperMissing,u="function",c=n.escapeExpression,s=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return'
  • \n '+c(typeof(n=null!=(n=s(t,"name")||(null!=l?s(l,"name"):l))?n:r)==u?n.call(o,{name:"name",hash:{},data:a,loc:{start:{line:13,column:52},end:{line:13,column:62}}}):n)+'\n
    \n \n
    \n
  • \n"},compiler:[8,">= 4.3.0"],main:function(n,l,t,e,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return"
      \n"+(null!=(a=o(t,"each").call(null!=l?l:n.nullContext||{},null!=l?o(l,"instances"):l,{name:"each",hash:{},fn:n.program(1,a,0),inverse:n.noop,data:a,loc:{start:{line:2,column:4},end:{line:19,column:13}}}))?a:"")+"
    \n"},useData:!0})}(); \ No newline at end of file +!function(){var n=Handlebars.template,l=Handlebars.templates=Handlebars.templates||{};l.app_details=n({1:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n Username\n \n \n "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"userId")||(null!=l?o(l,"userId"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"userId",hash:{},data:a,loc:{start:{line:32,column:16},end:{line:32,column:28}}}):e)+"\n \n \n"},3:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n Uptime\n \n \n "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"uptime")||(null!=l?o(l,"uptime"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"uptime",hash:{},data:a,loc:{start:{line:50,column:12},end:{line:50,column:24}}}):e)+"\n \n \n"},5:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n App is in use\n \n \n "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"isInUse")||(null!=l?o(l,"isInUse"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"isInUse",hash:{},data:a,loc:{start:{line:60,column:16},end:{line:60,column:29}}}):e)+"\n \n \n"},7:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n Last heartbeat\n \n \n "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"lastHeartBeat")||(null!=l?o(l,"lastHeartBeat"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"lastHeartBeat",hash:{},data:a,loc:{start:{line:70,column:16},end:{line:70,column:35}}}):e)+"\n \n \n"},9:function(n,l,e,t,a){var o,r=null!=l?l:n.nullContext||{},u=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return' \n \n Heartbeat timeout\n \n The app is automatically stopped when it has not been used for this amount of time.\n \n \n \n '+n.escapeExpression("function"==typeof(o=null!=(o=u(e,"heartbeatTimeout")||(null!=l?u(l,"heartbeatTimeout"):l))?o:n.hooks.helperMissing)?o.call(r,{name:"heartbeatTimeout",hash:{},data:a,loc:{start:{line:83,column:16},end:{line:83,column:38}}}):o)+"\n"+(null!=(o=u(e,"if").call(r,null!=l?u(l,"heartbeatTimeoutRemaining"):l,{name:"if",hash:{},fn:n.program(10,a,0),inverse:n.noop,data:a,loc:{start:{line:84,column:16},end:{line:87,column:23}}}))?o:"")+" \n \n"},10:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return"
    \n Remaining: "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"heartbeatTimeoutRemaining")||(null!=l?o(l,"heartbeatTimeoutRemaining"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"heartbeatTimeoutRemaining",hash:{},data:a,loc:{start:{line:86,column:31},end:{line:86,column:62}}}):e)+"\n"},12:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n Image\n \n \n "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"imageName")||(null!=l?o(l,"imageName"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"imageName",hash:{},data:a,loc:{start:{line:97,column:16},end:{line:97,column:31}}}):e)+"\n \n \n"},14:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n Image tag\n \n \n "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"imageTag")||(null!=l?o(l,"imageTag"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"imageTag",hash:{},data:a,loc:{start:{line:107,column:16},end:{line:107,column:30}}}):e)+"\n \n \n"},16:function(n,l,e,t,a){var o,r=null!=l?l:n.nullContext||{},u=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return' \n \n Max lifetime\n \n The app is automatically stopped when it has been running for this amount of time (even if it is in use).\n \n \n \n '+n.escapeExpression("function"==typeof(o=null!=(o=u(e,"maxLifetime")||(null!=l?u(l,"maxLifetime"):l))?o:n.hooks.helperMissing)?o.call(r,{name:"maxLifetime",hash:{},data:a,loc:{start:{line:120,column:16},end:{line:120,column:33}}}):o)+"\n"+(null!=(o=u(e,"if").call(r,null!=l?u(l,"maxLifetimeRemaining"):l,{name:"if",hash:{},fn:n.program(17,a,0),inverse:n.noop,data:a,loc:{start:{line:121,column:16},end:{line:124,column:23}}}))?o:"")+" \n \n"},17:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return"
    \n Remaining: "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"maxLifetimeRemaining")||(null!=l?o(l,"maxLifetimeRemaining"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"maxLifetimeRemaining",hash:{},data:a,loc:{start:{line:123,column:31},end:{line:123,column:57}}}):e)+"\n"},19:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n Name of container in backend\n \n \n "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"backendContainerName")||(null!=l?o(l,"backendContainerName"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"backendContainerName",hash:{},data:a,loc:{start:{line:134,column:16},end:{line:134,column:42}}}):e)+"\n \n \n"},21:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return null!=(e=o(e,"each").call(null!=l?l:n.nullContext||{},null!=l?o(l,"parameters"):l,{name:"each",hash:{},fn:n.program(22,a,0),inverse:n.noop,data:a,loc:{start:{line:139,column:8},end:{line:153,column:17}}}))?e:""},22:function(n,l,e,t,a){var o,r=null!=l?l:n.nullContext||{},u=n.hooks.helperMissing,i="function",s=n.escapeExpression,c=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return" \n \n "+s(typeof(o=null!=(o=c(e,"displayName")||(null!=l?c(l,"displayName"):l))?o:u)==i?o.call(r,{name:"displayName",hash:{},data:a,loc:{start:{line:142,column:23},end:{line:142,column:40}}}):o)+"\n"+(null!=(n=c(e,"if").call(r,null!=l?c(l,"description"):l,{name:"if",hash:{},fn:n.program(23,a,0),inverse:n.noop,data:a,loc:{start:{line:143,column:20},end:{line:147,column:27}}}))?n:"")+" \n \n "+s(typeof(o=null!=(o=c(e,"value")||(null!=l?c(l,"value"):l))?o:u)==i?o.call(r,{name:"value",hash:{},data:a,loc:{start:{line:150,column:20},end:{line:150,column:31}}}):o)+"\n \n \n"},23:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return' \n '+(null!=(o="function"==typeof(e=null!=(e=o(e,"description")||(null!=l?o(l,"description"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"description",hash:{},data:a,loc:{start:{line:145,column:28},end:{line:145,column:47}}}):e)?o:"")+"\n \n"},compiler:[8,">= 4.3.0"],main:function(n,l,e,t,a){var o,r,u=null!=l?l:n.nullContext||{},i=n.hooks.helperMissing,s="function",c=n.escapeExpression,p=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return'\n \n \n \n \n \n \n \n \n \n \n \n \n"+(null!=(o=p(e,"if").call(u,null!=l?p(l,"userId"):l,{name:"if",hash:{},fn:n.program(1,a,0),inverse:n.noop,data:a,loc:{start:{line:26,column:4},end:{line:35,column:11}}}))?o:"")+" \n \n \n \n"+(null!=(o=p(e,"if").call(u,null!=l?p(l,"uptime"):l,{name:"if",hash:{},fn:n.program(3,a,0),inverse:n.noop,data:a,loc:{start:{line:44,column:4},end:{line:53,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"isInUse"):l,{name:"if",hash:{},fn:n.program(5,a,0),inverse:n.noop,data:a,loc:{start:{line:54,column:4},end:{line:63,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"lastHeartBeat"):l,{name:"if",hash:{},fn:n.program(7,a,0),inverse:n.noop,data:a,loc:{start:{line:64,column:4},end:{line:73,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"heartbeatTimeout"):l,{name:"if",hash:{},fn:n.program(9,a,0),inverse:n.noop,data:a,loc:{start:{line:74,column:4},end:{line:90,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"imageName"):l,{name:"if",hash:{},fn:n.program(12,a,0),inverse:n.noop,data:a,loc:{start:{line:91,column:4},end:{line:100,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"imageTag"):l,{name:"if",hash:{},fn:n.program(14,a,0),inverse:n.noop,data:a,loc:{start:{line:101,column:4},end:{line:110,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"maxLifetime"):l,{name:"if",hash:{},fn:n.program(16,a,0),inverse:n.noop,data:a,loc:{start:{line:111,column:4},end:{line:127,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"backendContainerName"):l,{name:"if",hash:{},fn:n.program(19,a,0),inverse:n.noop,data:a,loc:{start:{line:128,column:4},end:{line:137,column:11}}}))?o:"")+(null!=(o=p(e,"if").call(u,null!=l?p(l,"parameters"):l,{name:"if",hash:{},fn:n.program(21,a,0),inverse:n.noop,data:a,loc:{start:{line:138,column:4},end:{line:154,column:11}}}))?o:"")+"
    \n App name\n \n '+c(typeof(r=null!=(r=p(e,"appName")||(null!=l?p(l,"appName"):l))?r:i)==s?r.call(u,{name:"appName",hash:{},data:a,loc:{start:{line:7,column:12},end:{line:7,column:25}}}):r)+"\n
    \n Instance\n \n "+c(typeof(r=null!=(r=p(e,"instanceName")||(null!=l?p(l,"instanceName"):l))?r:i)==s?r.call(u,{name:"instanceName",hash:{},data:a,loc:{start:{line:15,column:12},end:{line:15,column:30}}}):r)+"\n
    \n ID\n \n "+c(typeof(r=null!=(r=p(e,"proxyId")||(null!=l?p(l,"proxyId"):l))?r:i)==s?r.call(u,{name:"proxyId",hash:{},data:a,loc:{start:{line:23,column:12},end:{line:23,column:25}}}):r)+"\n
    \n Status\n \n "+c(typeof(r=null!=(r=p(e,"status")||(null!=l?p(l,"status"):l))?r:i)==s?r.call(u,{name:"status",hash:{},data:a,loc:{start:{line:41,column:12},end:{line:41,column:24}}}):r)+"\n
    \n"},useData:!0}),l.my_apps=n({1:function(n,l,e,t,a,o,r){var u,i=null!=l?l:n.nullContext||{},s=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return"
    "+n.escapeExpression("function"==typeof(u=null!=(u=s(e,"displayName")||(null!=l?s(l,"displayName"):l))?u:n.hooks.helperMissing)?u.call(i,{name:"displayName",hash:{},data:a,loc:{start:{line:2,column:8},end:{line:2,column:25}}}):u)+'
    \n
    \n'+(null!=(u=s(e,"each").call(i,null!=l?s(l,"instances"):l,{name:"each",hash:{},fn:n.program(2,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:4,column:8},end:{line:24,column:17}}}))?u:"")+"
    \n"},2:function(n,l,e,t,a,o,r){var u,i=null!=l?l:n.nullContext||{},s=n.hooks.helperMissing,c="function",p=n.escapeExpression,m=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return' \n '+p(typeof(u=null!=(u=m(e,"instanceName")||(null!=l?m(l,"instanceName"):l))?u:s)==c?u.call(i,{name:"instanceName",hash:{},data:a,loc:{start:{line:6,column:49},end:{line:6,column:67}}}):u)+'\n
    \n \n \n"+(null!=(p=m(e,"if").call(i,null!=r[2]?m(r[2],"pauseSupported"):r[2],{name:"if",hash:{},fn:n.program(3,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:16,column:20},end:{line:20,column:27}}}))?p:"")+"
    \n "+(null!=(p=(m(e,"formatStatus")||l&&m(l,"formatStatus")||s).call(i,null!=l?m(l,"status"):l,{name:"formatStatus",hash:{},data:a,loc:{start:{line:22,column:22},end:{line:22,column:49}}}))?p:"")+" "+(null!=(p=m(e,"if").call(i,null!=l?m(l,"uptime"):l,{name:"if",hash:{},fn:n.program(6,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:22,column:50},end:{line:22,column:92}}}))?p:"")+"\n
    \n"},3:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return null!=(e=o(e,"if").call(null!=l?l:n.nullContext||{},null!=l?o(l,"uptime"):l,{name:"if",hash:{},fn:n.program(4,a,0),inverse:n.noop,data:a,loc:{start:{line:17,column:24},end:{line:19,column:31}}}))?e:""},4:function(n,l,e,t,a){var o,r=null!=l?l:n.nullContext||{},u=n.hooks.helperMissing,i="function",s=n.escapeExpression,n=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return' \n"},6:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return"Uptime: "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"uptime")||(null!=l?o(l,"uptime"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"uptime",hash:{},data:a,loc:{start:{line:22,column:72},end:{line:22,column:84}}}):e)+" "},8:function(n,l,e,t,a){return" You don't have any running apps.
    \n Start a new app by clicking on its name or logo.\n"},compiler:[8,">= 4.3.0"],main:function(n,l,e,t,a,o,r){var u,i=null!=l?l:n.nullContext||{},s=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return(null!=(u=s(e,"each").call(i,null!=l?s(l,"apps"):l,{name:"each",hash:{},fn:n.program(1,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:1,column:0},end:{line:26,column:9}}}))?u:"")+(null!=(u=s(e,"unless").call(i,null!=l?s(l,"apps"):l,{name:"unless",hash:{},fn:n.program(8,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:27,column:0},end:{line:30,column:11}}}))?u:"")},useData:!0,useDepths:!0}),l.switch_instances=n({1:function(n,l,e,t,a,o,r){var u=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return null!=(e=u(e,"if").call(null!=l?l:n.nullContext||{},null!=l?u(l,"active"):l,{name:"if",hash:{},fn:n.program(2,a,0,o,r),inverse:n.program(8,a,0,o,r),data:a,loc:{start:{line:3,column:8},end:{line:32,column:15}}}))?e:""},2:function(n,l,e,t,a,o,r){var u,i=null!=l?l:n.nullContext||{},s=n.hooks.helperMissing,c="function",p=n.escapeExpression,m=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return'
    \n '+p(typeof(u=null!=(u=m(e,"instanceName")||(null!=l?m(l,"instanceName"):l))?u:s)==c?u.call(i,{name:"instanceName",hash:{},data:a,loc:{start:{line:5,column:53},end:{line:5,column:71}}}):u)+'\n
    \n \n \n \n'+(null!=(p=m(e,"if").call(i,null!=r[1]?m(r[1],"pauseSupported"):r[1],{name:"if",hash:{},fn:n.program(3,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:10,column:20},end:{line:14,column:27}}}))?p:"")+"
    \n "+(null!=(p=(m(e,"formatStatus")||l&&m(l,"formatStatus")||s).call(i,null!=l?m(l,"status"):l,{name:"formatStatus",hash:{},data:a,loc:{start:{line:16,column:22},end:{line:16,column:48}}}))?p:"")+" "+(null!=(p=m(e,"if").call(i,null!=l?m(l,"uptime"):l,{name:"if",hash:{},fn:n.program(6,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:16,column:49},end:{line:16,column:91}}}))?p:"")+"\n
    \n"},3:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return null!=(e=o(e,"if").call(null!=l?l:n.nullContext||{},null!=l?o(l,"uptime"):l,{name:"if",hash:{},fn:n.program(4,a,0),inverse:n.noop,data:a,loc:{start:{line:11,column:24},end:{line:13,column:31}}}))?e:""},4:function(n,l,e,t,a){var o,r=null!=l?l:n.nullContext||{},u=n.hooks.helperMissing,i="function",s=n.escapeExpression,n=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return' \n"},6:function(n,l,e,t,a){var o=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return"Uptime: "+n.escapeExpression("function"==typeof(e=null!=(e=o(e,"uptime")||(null!=l?o(l,"uptime"):l))?e:n.hooks.helperMissing)?e.call(null!=l?l:n.nullContext||{},{name:"uptime",hash:{},data:a,loc:{start:{line:16,column:71},end:{line:16,column:83}}}):e)+" "},8:function(n,l,e,t,a,o,r){var u,i=n.escapeExpression,s=null!=l?l:n.nullContext||{},c=n.hooks.helperMissing,p="function",m=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return' \n '+i(typeof(u=null!=(u=m(e,"instanceName")||(null!=l?m(l,"instanceName"):l))?u:c)==p?u.call(s,{name:"instanceName",hash:{},data:a,loc:{start:{line:20,column:49},end:{line:20,column:67}}}):u)+'\n
    \n \n \n"+(null!=(i=m(e,"if").call(s,null!=r[1]?m(r[1],"pauseSupported"):r[1],{name:"if",hash:{},fn:n.program(3,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:24,column:20},end:{line:28,column:27}}}))?i:"")+"
    \n "+(null!=(i=(m(e,"formatStatus")||l&&m(l,"formatStatus")||c).call(s,null!=l?m(l,"status"):l,{name:"formatStatus",hash:{},data:a,loc:{start:{line:30,column:22},end:{line:30,column:48}}}))?i:"")+" "+(null!=(i=m(e,"if").call(s,null!=l?m(l,"uptime"):l,{name:"if",hash:{},fn:n.program(6,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:30,column:49},end:{line:30,column:91}}}))?i:"")+"\n
    \n"},10:function(n,l,e,t,a){return" You don't have any instances of this app.
    \n"},compiler:[8,">= 4.3.0"],main:function(n,l,e,t,a,o,r){var u,i=null!=l?l:n.nullContext||{},s=n.lookupProperty||function(n,l){if(Object.prototype.hasOwnProperty.call(n,l))return n[l]};return'
    \n'+(null!=(u=s(e,"each").call(i,null!=l?s(l,"instances"):l,{name:"each",hash:{},fn:n.program(1,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:2,column:4},end:{line:33,column:13}}}))?u:"")+"
    \n"+(null!=(u=s(e,"unless").call(i,null!=l?s(l,"instances"):l,{name:"unless",hash:{},fn:n.program(10,a,0,o,r),inverse:n.noop,data:a,loc:{start:{line:35,column:0},end:{line:37,column:11}}}))?u:"")},useData:!0,useDepths:!0})}(); \ No newline at end of file diff --git a/src/main/resources/static/handlebars/switch_instances.handlebars b/src/main/resources/static/handlebars/switch_instances.handlebars index 6e8ded45..9dfa015a 100644 --- a/src/main/resources/static/handlebars/switch_instances.handlebars +++ b/src/main/resources/static/handlebars/switch_instances.handlebars @@ -1,20 +1,37 @@ - + +{{#unless instances}} + You don't have any instances of this app.
    +{{/unless}} diff --git a/src/main/resources/static/js/new_version_check.js b/src/main/resources/static/js/new_version_check.js deleted file mode 100644 index 36b0c9bf..00000000 --- a/src/main/resources/static/js/new_version_check.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - * ShinyProxy - * - * Copyright (C) 2016-2021 Open Analytics - * - * =========================================================================== - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Apache License as published by - * The Apache Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program 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 - * Apache License for more details. - * - * You should have received a copy of the Apache License - * along with this program. If not, see - */ -document.addEventListener('DOMContentLoaded', function() { - document.getElementById('new-version-btn').addEventListener("click", function() { - $.get("api/proxy", function(data, status){ - if (data.length > 0) { - if (confirm("Warning: you have " + data.length + " apps running, your existing session(s) will be closed once you switch to the new version.")) { - update(); - } - } else { - update(); - } - }); - - }); - - var spInstanceCookie = Cookies.get('sp-instance'); - var spLatestInstanceCookie = Cookies.get('sp-latest-instance'); - - if (typeof spInstanceCookie !== 'undefined' && typeof spLatestInstanceCookie !== 'undefined') { - if (spInstanceCookie !== spLatestInstanceCookie) { - document.getElementById('new-version-banner').style.display = "block"; - } - } - - function update() { - var path = location.pathname; - - Cookies.set('sp-instance', Cookies.get('sp-latest-instance'), {path: path}); - location.reload(); - } - -}, false); \ No newline at end of file diff --git a/src/main/resources/static/js/shiny.admin.js b/src/main/resources/static/js/shiny.admin.js new file mode 100644 index 00000000..18fabf87 --- /dev/null +++ b/src/main/resources/static/js/shiny.admin.js @@ -0,0 +1,157 @@ +/* + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +Shiny = window.Shiny || {}; +Shiny.admin = { + + _adminData: null, + _detailsRefreshIntervalId: null, + _table: null, + + async init() { + Shiny.admin._adminData = await Shiny.api.getAdminData(); + Shiny.admin._table = $('.table').DataTable({ + data: Shiny.admin._adminData, + aaSorting: [], // apply no sort by default + paging: false, + lengthChange: false, + buttons: [{extend: 'csv'}], + responsive: { + details: false + }, + columns: [ + { + data: 'server', + className: 'admin-monospace', + }, + { + data: 'proxyId', + className: 'admin-monospace', + }, + { + data: null, + render: function (data, type) { + if (type === 'display') { + return Shiny.ui.formatStatus(data.status); + } + return data; + } + }, + { + data: 'userId', + className: 'admin-monospace', + }, + { + data: 'appName', + className: 'admin-monospace', + }, + { + data: 'instanceName', + className: 'admin-monospace', + }, + { + data: 'endpoint', + className: 'admin-monospace', + }, + { + data: 'uptime', + className: 'admin-monospace', + }, + { + data: 'lastHeartBeat', + className: 'admin-monospace', + }, + { + data: 'imageName', + className: 'admin-monospace', + }, + { + data: 'imageTag', + className: 'admin-monospace', + }, + { + data: null, + render: function (data, type) { + if (type === 'display') { + return ` +
    + + +
    + `; + } + return data; + }, + }, + ] + }); + Shiny.admin._table.buttons().container().prependTo('#allApps'); + + window.addEventListener("resize", function () { + Shiny.admin._table.columns.adjust(); + Shiny.admin._table.responsive.rebuild(); + Shiny.admin._table.responsive.recalc(); + }); + + Shiny.admin._refreshIntervalId = setInterval(async function () { + if (!document.hidden) { + await Shiny.admin._refreshTable(); + } + }, 2500); + }, + + async _refreshTable() { + Shiny.admin._adminData = await Shiny.api.getAdminData(); + Shiny.admin._table.clear().rows.add(Shiny.admin._adminData).draw(); + }, + + showAppDetails(appName, appInstanceName, proxyId) { + function refresh() { + let appDetails = null; + for (const app of Shiny.admin._adminData) { + if (app.proxyId === proxyId) { + appDetails = app; + break; + } + } + if (appDetails === null) { + console.log("Did not found details for app", proxyId); + return; + } + + document.getElementById('appDetails').innerHTML = Handlebars.templates.app_details(appDetails); + Shiny.ui.showAppDetailsModal(); + } + + refresh(); + Shiny.admin._detailsRefreshIntervalId = setInterval(function () { + if (!document.hidden) { + refresh(); + } + }, 2500); + }, + +} diff --git a/src/main/resources/static/js/shiny.api.js b/src/main/resources/static/js/shiny.api.js index a18799e6..b5ca1968 100644 --- a/src/main/resources/static/js/shiny.api.js +++ b/src/main/resources/static/js/shiny.api.js @@ -1,7 +1,7 @@ /* * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -20,46 +20,178 @@ */ Shiny = window.Shiny || {}; Shiny.api = { - getProxies: function (cb, cb_fail) { - $.get(Shiny.common.staticState.contextPath + "api/proxy?only_owned_proxies=true", function (proxies) { - cb(proxies); - }).fail(function (response) { - cb_fail(response); + _proxiesCache: null, + getProxies: async function () { + const resp = await fetch(Shiny.api.buildURL("api/proxy")); + const json = await Shiny.api._getResponseJson(resp); + if (json === null) { + return []; + } + return json.data; + }, + async changeProxyStatus(proxyId, desiredState, parameters) { + if (parameters === null) { + parameters = {}; + } + const resp = await fetch(Shiny.api.buildURL("api/" + proxyId + '/status'), { + method: 'PUT', + body: JSON.stringify({"desiredState": desiredState, "parameters": parameters}), + headers: { + 'Content-Type': 'application/json' + }, }); + const json = await Shiny.api._getResponseJson(resp); + return json !== null; }, - deleteProxyById: function (id, cb, cb_fail) { - $.ajax({ - url: Shiny.common.staticState.contextPath + "api/proxy/" + id, - type: 'DELETE', - success: cb, - error: function (result) { - cb_fail(result); + async waitForStatusChange(proxyId) { + let networkErrors = 0; + while (true) { + const url = Shiny.api.buildURL('api/' + proxyId + "/status?watch=true&timeout=10"); + try { + const resp = await fetch(url); + const json = await Shiny.api._getResponseJson(resp); + if (json === null) { + return null; + } + if (json.data.status === "Up" || json.data.status === "Stopped" || json.data.status === "Paused" ) { + return json.data; + } + } catch (e) { + // retry the status request up to 10 times in case of network issues. + console.log(e); + networkErrors++; + if (networkErrors >= 10) { + console.log("Reached more than 10 NetworkErrors, stopping attempt"); + return null; + } } - }); + } + }, + getProxyById: async function (proxyId) { + const resp = await fetch(Shiny.api.buildURL("api/proxy/" + proxyId)); + const json = await Shiny.api._getResponseJson(resp); + return json.data; + }, + getProxyByIdFromCache: async function (proxyId) { + if (Shiny.api._proxiesCache === null) { + return await Shiny.api.getProxyById(proxyId); + } + for (const instance of Shiny.api._proxiesCache) { + if (instance.id === proxyId) { + return instance; + } + } + return null; }, - getProxyId: function (appName, instanceName, cb, cb_fail) { - Shiny.api.getProxies(function (proxies) { - for (var i = 0; i < proxies.length; i++) { - var proxy = proxies[i]; - if (proxy.hasOwnProperty('spec') && proxy.spec.hasOwnProperty('id') && - proxy.hasOwnProperty('runtimeValues') && proxy.runtimeValues.hasOwnProperty('SHINYPROXY_APP_INSTANCE') - && proxy.spec.id === appName && proxy.runtimeValues.SHINYPROXY_APP_INSTANCE === instanceName) { - cb(proxies[i].id); - return; + getProxiesAsTemplateData: async function () { + const proxies = await Shiny.api.getProxies(); + Shiny.api._proxiesCache = proxies; + let templateData = {'apps': {}}; + + for (const instance of proxies) { + let displayName = null; + if (instance.hasOwnProperty('specId') && + instance.hasOwnProperty('runtimeValues') && + instance.runtimeValues.hasOwnProperty('SHINYPROXY_APP_INSTANCE')) { + + let appInstance = instance.runtimeValues.SHINYPROXY_APP_INSTANCE; + + displayName = instance.displayName; + let instanceName = Shiny.instances._toAppDisplayName(appInstance); + + let uptime = null; + if (instance.status === "Up" && instance.hasOwnProperty("startupTimestamp") && instance.startupTimestamp > 0) { + uptime = Shiny.ui.formatSeconds((Date.now() - instance.startupTimestamp) / 1000); + } + + let spInstance = null; + if (instance.runtimeValues.hasOwnProperty('SHINYPROXY_INSTANCE')) { + spInstance = instance.runtimeValues.SHINYPROXY_INSTANCE; } + + const url = Shiny.api._buildURLForApp(instance); + if (!templateData.apps.hasOwnProperty(instance.specId)) { + templateData.apps[instance.specId] = []; + } + templateData.apps[instance.specId].push({ + appName: instance.specId, + instanceName: instanceName, + displayName: displayName, + url: url, + spInstance: spInstance, + proxyId: instance.id, + uptime: uptime, + status: instance.status + }); + } else { + console.log("Received invalid instance object from server.", instance); } - cb(null); - }, cb_fail); + } + + for (const appName of Object.keys(templateData.apps)) { + templateData.apps[appName] = { + instances: templateData.apps[appName].sort(function (a, b) { + return a.instanceName.toLowerCase() > b.instanceName.toLowerCase() ? 1 : -1 + }), + displayName: appName + }; + } + + return templateData; }, - getProxyById: function(proxyId, cb, cb_fail) { - $.get(Shiny.common.staticState.contextPath + "api/proxy/" + proxyId, function (proxy) { - cb(true, proxy); - }).fail(function (response) { - if (response.status === 404) { - cb(false, null); - return; + async getAdminData() { + const resp = await fetch(Shiny.api.buildURL("admin/data")) + const json = await Shiny.api._getResponseJson(resp); + if (json === null) { + return; + } + const apps = []; + json.data.forEach(app => { + if (app.spInstance === Shiny.common.staticState.spInstance) { + app['server'] = "This server"; + apps.unshift(app); // ensure "This server" is front of the list + } else { + app['server'] = app.spInstance; + apps.push(app); } - cb_fail(response); }); + return apps; + }, + getHeartBeatInfo: async function (proxyId) { + const resp = await fetch(Shiny.api.buildURL("heartbeat/" + proxyId)) + const json = await Shiny.api._getResponseJson(resp); + if (json === null) { + return null; + } + return json.data; + }, + buildURL(location) { + const baseURL = new URL(Shiny.common.staticState.contextPath, window.location.origin); + return new URL(location, baseURL); + }, + _buildURLForApp: function (app) { + const appName = app.specId; + const appInstance = app.runtimeValues.SHINYPROXY_APP_INSTANCE; + return Shiny.common.staticState.contextPath + "app_i/" + appName + "/" + appInstance + "/"; + }, + _getResponseJson: async function(response) { + if (response.status !== 200) { + console.log("Received invalid response (not 200 OK) ", response); + return null; + } + let json = await response.json(); + if (!json.hasOwnProperty("status")) { + console.log("Received invalid response (missing status) ", json); + return null; + } + if (json.status !== "success") { + console.log("Received invalid response (status is not success) ", json); + return null; + } + if (!json.hasOwnProperty("data")) { + console.log("Received invalid response (missing data) ", json); + return null; + } + return json; } -}; \ No newline at end of file +}; diff --git a/src/main/resources/static/js/shiny.app.js b/src/main/resources/static/js/shiny.app.js index 1fe41eee..faf74dbd 100644 --- a/src/main/resources/static/js/shiny.app.js +++ b/src/main/resources/static/js/shiny.app.js @@ -1,3 +1,23 @@ +/* + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ // noinspection ES6ConvertVarToLetConst /* @@ -28,21 +48,29 @@ Shiny = window.Shiny || {}; Shiny.app = { staticState: { - proxyId: null, appName: null, appInstanceName: null, - containerPath: null, - webSocketReconnectionMode: null, maxReloadAttempts: 3, heartBeatRate: null, + openIdRefreshRate: 30000, maxInstances: null, - shinyForceFullReload: null, + parameters: { + allowedCombinations: null, + names: null, + ids: null + }, + appPath: null, + containerSubPath: null, }, runtimeState: { + /** + * @type {?{id: string, status: string, runtimeValues: {SHINYPROXY_PUBLIC_PATH: string, SHINYPROXY_WEBSOCKET_RECONNECTION_MODE: string, SHINYPROXY_FORCE_FULL_RELOAD: boolean, SHINYPROXY_TRACK_APP_URL: boolean}}} + */ + proxy: null, + containerPath: null, navigatingAway: false, reloaded: false, - injectorIntervalId: null, tryingToReconnect: false, reloadAttempts: 0, reloadDismissed: false, @@ -50,62 +78,158 @@ Shiny.app = { suspendHeartbeat: false, lastHeartbeatTime: null, appStopped: false, + parentFrameUrl: null, // the current url of the shinyproxy page, i.e. the location of the browser (e.g. http://localhost:8080/app/01_hello); guaranteed to end with / + baseFrameUrl: null, // the base url of the app iframe (i.e. without any subpath, query parameters, hash location etc.); guaranteed to end with / }, /** * Start the Shiny Application. - * @param containerPath - * @param webSocketReconnectionMode - * @param proxyId + * @param proxy * @param heartBeatRate * @param appName * @param appInstanceName - * @param maxInstances - * @param shinyForceFullReload + * @param parameterAllowedCombinations + * @param parameterDefinitions + * @param parametersIds + * @param appPath + * @param containerSubPath */ - start: function (containerPath, webSocketReconnectionMode, proxyId, heartBeatRate, appName, appInstanceName, maxInstances, shinyForceFullReload) { + start: async function (proxy, heartBeatRate, appName, appInstanceName, parameterAllowedCombinations, parameterDefinitions, parametersIds, appPath, containerSubPath) { Shiny.app.staticState.heartBeatRate = heartBeatRate; Shiny.app.staticState.appName = appName; Shiny.app.staticState.appInstanceName = appInstanceName; - Shiny.app.staticState.maxInstances = parseInt(maxInstances, 10); - Shiny.app.staticState.shinyForceFullReload = shinyForceFullReload; - - function internalStart() { - if (containerPath === "") { - Shiny.ui.showLoading(); - $.post(window.location.pathname + window.location.search, function (response) { - Shiny.app.staticState.containerPath = response.containerPath; - Shiny.app.staticState.webSocketReconnectionMode = response.webSocketReconnectionMode; - Shiny.app.staticState.proxyId = response.proxyId; - Shiny.ui.setupIframe(); - Shiny.ui.showFrame(); - Shiny.connections.startHeartBeats(); - }).fail(function (request) { - if (!Shiny.app.runtimeState.navigatingAway) { - console.log(request.responseText); - $("#loadingAnimation").hide(); - $("#loadAppError").show(); - } - }); + Shiny.app.staticState.appPath = appPath; + Shiny.app.staticState.containerSubPath = containerSubPath; + Shiny.app.staticState.parameters.allowedCombinations = parameterAllowedCombinations; + Shiny.app.staticState.parameters.names = parameterDefinitions; + Shiny.app.staticState.parameters.ids = parametersIds; + Shiny.app.runtimeState.proxy = proxy; + Shiny.app.loadApp(); + }, + async loadApp() { + if (Shiny.app.runtimeState.proxy === null) { + if (Shiny.app.staticState.parameters.names !== null) { + Shiny.ui.showParameterForm(); } else { - Shiny.app.staticState.containerPath = containerPath; - Shiny.app.staticState.webSocketReconnectionMode = webSocketReconnectionMode; - Shiny.app.staticState.proxyId = proxyId; - Shiny.ui.setupIframe(); - Shiny.ui.showFrame(); - Shiny.connections.startHeartBeats(); + Shiny.app.startAppWithParameters(null); } - } + } else if (Shiny.app.runtimeState.proxy.status === "New" + || Shiny.app.runtimeState.proxy.status === "Resuming") { + Shiny.ui.showLoading(); + await Shiny.app.waitForAppStart(); + } else if (Shiny.app.runtimeState.proxy.status === "Paused") { + if (Shiny.app.staticState.parameters.names !== null) { + Shiny.ui.showParameterForm(); + } else { + Shiny.app.resumeApp(null); + } + } else if (Shiny.app.runtimeState.proxy.status === "Up") { + Shiny.app.runtimeState.containerPath = Shiny.app.runtimeState.proxy.runtimeValues.SHINYPROXY_PUBLIC_PATH + Shiny.app.staticState.containerSubPath + window.location.hash; + if (!(await Shiny.app.checkAppHealth())) { + return; + } + Shiny.ui.setupIframe(); + Shiny.ui.showFrame(); + Shiny.connections.startHeartBeats(); - if (Shiny.operator !== undefined) { - Shiny.operator.start(function() { - internalStart(); - }); + const baseURL = new URL(Shiny.common.staticState.contextPath, window.location.origin); + let parentUrl = new URL(Shiny.app.staticState.appPath , baseURL).toString(); + if (!parentUrl.endsWith("/")) { + parentUrl = parentUrl + "/"; + } + Shiny.app.runtimeState.parentFrameUrl = parentUrl; + let baseFrameUrl = new URL(Shiny.app.runtimeState.proxy.runtimeValues.SHINYPROXY_PUBLIC_PATH , baseURL).toString(); + if (!baseFrameUrl.endsWith("/")) { + baseFrameUrl = parentUrl + "/"; + } + Shiny.app.runtimeState.baseFrameUrl = baseFrameUrl; + } else if (Shiny.app.runtimeState.proxy.status === "Stopping") { + Shiny.ui.showStoppingPage(); + // re-send stop request in case previous stop is stuck + await Shiny.api.changeProxyStatus(Shiny.app.runtimeState.proxy.id, 'Stopping') + Shiny.app.runtimeState.proxy = await Shiny.api.waitForStatusChange(Shiny.app.runtimeState.proxy.id); + if (Shiny.app.runtimeState.proxy !== null && !Shiny.app.runtimeState.navigatingAway) { + Shiny.ui.showStoppedPage(); + } + } else if (Shiny.app.runtimeState.proxy.status === "Pausing") { + Shiny.ui.showPausingPage(); + Shiny.app.runtimeState.proxy = await Shiny.api.waitForStatusChange(Shiny.app.runtimeState.proxy.id); + if (Shiny.app.runtimeState.proxy !== null && !Shiny.app.runtimeState.navigatingAway) { + Shiny.ui.showPausedAppPage(); + } } else { - internalStart(); + Shiny.app.startupFailed(); } }, - + async waitForAppStart() { + const proxy = await Shiny.api.waitForStatusChange(Shiny.app.runtimeState.proxy.id); + Shiny.app.runtimeState.proxy = proxy; + if (proxy === null || proxy.status === "Stopped") { + Shiny.app.startupFailed(); + } else { + Shiny.app.loadApp(); + } + }, + submitParameters(parameters) { + if (Shiny.app.runtimeState.proxy === null) { + Shiny.app.startAppWithParameters(parameters); + } else if (Shiny.app.runtimeState.proxy.status === "Paused") { + Shiny.app.resumeApp(parameters); + } + }, + async resumeApp(parameters) { + Shiny.ui.showResumingPage(); + await Shiny.api.changeProxyStatus(Shiny.app.runtimeState.proxy.id, 'Resuming', parameters) + await Shiny.app.waitForAppStart(); + }, + async startAppWithParameters(parameters) { + Shiny.ui.showLoading(); + if (parameters === null) { + parameters = {} + } + const body = {parameters, timezone: Shiny.ui.getTimeZone()}; + let url = Shiny.api.buildURL('app_i/' + Shiny.app.staticState.appName + '/' + Shiny.app.staticState.appInstanceName); + let response = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json' + }, + }); + if (response.status !== 200) { + Shiny.app.startupFailed(); + return; + } + response = await response.json(); + if (response.status !== "success") { + Shiny.app.startupFailed(); + return; + } + Shiny.app.runtimeState.proxy = response.data; + await Shiny.app.waitForAppStart(); + }, + startupFailed() { + if (!Shiny.app.runtimeState.appStopped && !Shiny.app.runtimeState.navigatingAway) { + Shiny.ui.showStartFailedPage(); + } + }, + async checkAppHealth() { + // check that the app endpoint is still accessible + const response = await fetch(Shiny.app.runtimeState.containerPath); + if (response.status !== 503) { + return true; + } + const json = await response.json(); + if (json.status === "error" && json.message === "app_stopped_or_non_existent") { + Shiny.ui.showStoppedPage(); + return false; + } + if (json.status === "error" && json.message === "app_crashed") { + Shiny.ui.showCrashedPage(); + return false; + } + return true; + } } diff --git a/src/main/resources/static/js/shiny.common.js b/src/main/resources/static/js/shiny.common.js index 5761b02e..47d70074 100644 --- a/src/main/resources/static/js/shiny.common.js +++ b/src/main/resources/static/js/shiny.common.js @@ -1,7 +1,7 @@ /* * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -24,11 +24,187 @@ Shiny.common = { staticState: { contextPath: null, applicationName: null, + spInstance: null, + appMaxInstances: null, // max instances per app + myAppsMode: null, + pauseSupported: null, }, + runtimeState: { + switchInstanceApp: null, + }, + _refreshIntervalId: null, + _detailsRefreshIntervalId: null, - init: function(contextPath, applicationName) { + init: function (contextPath, applicationName, spInstance, appMaxInstances, myAppsMode, pauseSupported) { Shiny.common.staticState.contextPath = contextPath; Shiny.common.staticState.applicationName = applicationName; - } + Shiny.common.staticState.spInstance = spInstance; + Shiny.common.staticState.appMaxInstances = appMaxInstances; + Shiny.common.staticState.myAppsMode = myAppsMode; + Shiny.common.staticState.pauseSupported = pauseSupported; + }, + + sleep: function (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + }, + + onShowMyApps: function () { + Shiny.common._refreshModal(); + clearInterval(Shiny.common._refreshIntervalId); + Shiny.common._refreshIntervalId = setInterval(async function () { + if (!document.hidden) { + await Shiny.common._refreshModal(); + } + }, 2500); + }, + + onCloseMyApps: function () { + if (Shiny.common.staticState.myAppsMode === 'Modal') { + clearInterval(Shiny.common._refreshIntervalId); + } + }, + + showAppDetails: function (event, appName, appInstanceName, proxyId) { + event.preventDefault(); + if (Shiny.common.staticState.myAppsMode === 'Modal') { + Shiny.ui.showAppDetailsModal($('#myAppsModal')); + } else { + Shiny.ui.showAppDetailsModal(); + } + Shiny.common.loadAppDetails(appName, appInstanceName, proxyId); + }, + + closeAppDetails: function() { + clearInterval(Shiny.common._detailsRefreshIntervalId); + if (Shiny.admin !== undefined) { + clearInterval(Shiny.admin._detailsRefreshIntervalId); + } + }, + + loadAppDetails(appName, appInstanceName, proxyId) { + async function refresh() { + const proxy = await Shiny.api.getProxyByIdFromCache(proxyId); + const heartbeatInfo = await Shiny.api.getHeartBeatInfo(proxyId); + if (proxy === null || proxy.status === "Stopped" || proxy.status === "Stopping") { + const templateData = { + appName: appName, + proxyId: proxyId, + status: "Stopped", + instanceName: appInstanceName, + } + document.getElementById('appDetails').innerHTML = Handlebars.templates.app_details(templateData); + Shiny.common.closeAppDetails(); + return; + } + + let uptime = "N/A"; + let heartbeatTimeout = null; + let heartbeatTimeoutRemaining = null; + let isInUse = "N/A"; + let maxLifetime = null; + let maxLifetimeRemaining = null; -} \ No newline at end of file + if (proxy.status === "Up" && proxy.startupTimestamp > 0 && heartbeatInfo !== null) { + const uptimeSec = (Date.now() - proxy.startupTimestamp) / 1000; + uptime = Shiny.ui.formatSeconds(uptimeSec); + + const timeoutMs = parseInt(proxy.runtimeValues.SHINYPROXY_HEARTBEAT_TIMEOUT, 10); + if (timeoutMs !== -1) { + heartbeatTimeout = Shiny.ui.formatSeconds(timeoutMs / 1000); + } + + const timeSinceLastHeartbeat = (Date.now() - heartbeatInfo.lastHeartbeat) + if (timeSinceLastHeartbeat <= (heartbeatInfo.heartbeatRate * 2)) { + isInUse = "Yes"; + } else { + isInUse = "No"; + const remaining = Math.max(0, (timeoutMs - timeSinceLastHeartbeat) / 1000); + heartbeatTimeoutRemaining = Shiny.ui.formatSeconds(remaining); + } + + const maxLifetimeSec = parseInt(proxy.runtimeValues.SHINYPROXY_MAX_LIFETIME, 10) * 60; + if (maxLifetimeSec > 0) { + maxLifetime = Shiny.ui.formatSeconds(maxLifetimeSec); + const remaining = Math.max(0, maxLifetimeSec - uptimeSec); + maxLifetimeRemaining = Shiny.ui.formatSeconds(remaining); + } + } + + let parameters = null; + if (proxy.runtimeValues.hasOwnProperty("SHINYPROXY_PARAMETER_NAMES")) { + parameters = proxy.runtimeValues.SHINYPROXY_PARAMETER_NAMES; + } + + const templateData = { + appName: proxy.specId, + proxyId: proxy.id, + status: proxy.status, + instanceName: appInstanceName, + uptime: uptime, + heartbeatTimeout: heartbeatTimeout, + maxLifetime: maxLifetime, + parameters: parameters, + isInUse: isInUse, + heartbeatTimeoutRemaining: heartbeatTimeoutRemaining, + maxLifetimeRemaining: maxLifetimeRemaining + } + document.getElementById('appDetails').innerHTML = Handlebars.templates.app_details(templateData); + } + refresh(); + Shiny.common._detailsRefreshIntervalId = setInterval(function() { + if (!document.hidden) { + refresh(); + } + }, 2500); + }, + + async onStopAllApps() { + if (confirm("Are you sure you want to stop all your apps?")) { + $('#stop-all-apps-btn').hide(); + $('#stopping-all-apps-btn').show(); + const proxies = await Shiny.api.getProxies() + const proxyIds = []; + for (const proxy of proxies) { + Shiny.api.changeProxyStatus(proxy.id, 'Stopping'); + proxyIds.push(proxy.id); + } + // wait for all proxies to be stopped + while (!await Shiny.common._areAllProxiesDeleted(proxyIds)) { + await Shiny.common.sleep(500); + } + await Shiny.common._refreshModal(); + $('#stopping-all-apps-btn').hide(); + } + }, + + async _areAllProxiesDeleted(proxyIds) { + const proxies = await Shiny.api.getProxies() + for (const proxy of proxies) { + if (proxyIds.includes(proxy.id)) { + return false; + } + } + return true; + }, + + _refreshModal: async function () { + const templateData = await Shiny.api.getProxiesAsTemplateData(); + templateData.apps = Object.values(templateData.apps); + templateData.apps.sort(function (a, b) { + return a.displayName.toLowerCase() > b.displayName.toLowerCase() ? 1 : -1 + }); + templateData['pauseSupported'] = Shiny.common.staticState.pauseSupported; + document.getElementById('myApps').innerHTML = Handlebars.templates.my_apps(templateData); + if (templateData.apps.length === 0 ) { + $('#stop-all-apps-btn').hide(); + } else if ($("#stopping-all-apps-btn").is(":hidden")) { + // only show it if we are not stopping all apps + $('#stop-all-apps-btn').show(); + } + }, + async startIndex() { + if (Shiny.common.staticState.myAppsMode === 'Inline') { + Shiny.common.onShowMyApps(); + } + }, +} diff --git a/src/main/resources/static/js/shiny.connections.js b/src/main/resources/static/js/shiny.connections.js index 3e2f7aac..7db3b2ee 100644 --- a/src/main/resources/static/js/shiny.connections.js +++ b/src/main/resources/static/js/shiny.connections.js @@ -1,7 +1,7 @@ /* * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -31,13 +31,13 @@ Shiny.connections = { * in the last `Shiny.heartBeatRate` milliseconds. */ startHeartBeats: function () { + Shiny.connections.sendHeartBeat(); // send heartbeat right after loading app to validate the app is working setInterval(function () { if (Shiny.app.runtimeState.appStopped || Shiny.app.runtimeState.suspendHeartbeat) { return; } var lastHeartbeat = Date.now() - Shiny.app.runtimeState.lastHeartbeatTime; - if (lastHeartbeat > Shiny.app.staticState.heartBeatRate && Shiny.app.staticState.proxyId !== null) { - + if (lastHeartbeat > Shiny.app.staticState.heartBeatRate && Shiny.app.runtimeState.proxy !== null) { const _shinyFrame = document.getElementById('shinyframe'); if (typeof _shinyFrame.contentWindow.Shiny !== 'undefined' && typeof _shinyFrame.contentWindow.Shiny.shinyapp !== 'undefined' && @@ -57,6 +57,47 @@ Shiny.connections = { }, Shiny.app.staticState.heartBeatRate); }, + /** + * Send heartbeat and process the result. + */ + sendHeartBeat: function() { + // contextPath is guaranteed to end with a slash + $.post(Shiny.api.buildURL("heartbeat/" + Shiny.app.runtimeState.proxy.id), function() {}) + .fail(function (response) { + if (Shiny.app.runtimeState.appStopped) { + // if stopped in meantime -> ignore + return; + } + if (response.status === 401) { + Shiny.ui.showLoggedOutPage(); + return; + } + try { + var res = JSON.parse(response.responseText); + if (res !== null && res.status === "fail") { + if (res.data === "app_stopped_or_non_existent") { + Shiny.ui.showStoppedPage(); + } else if (res.data === "shinyproxy_authentication_required") { + Shiny.ui.showLoggedOutPage(); + } + } + } catch (error) { + // server or connection crashed, let app reconnect + // ignore JSON parsing error + } + }); + }, + + startOpenidRefresh: function() { + setInterval(function() { + if (Shiny.app.runtimeState.proxy && Shiny.app.runtimeState.proxy.status === "Stopped") { + console.log("no openid refresh"); + return; + } + $.post(Shiny.api.buildURL("refresh-openid")); + }, Shiny.app.staticState.openIdRefreshRate); + }, + /** * Handles a WebSocket error (i.e. close). */ @@ -79,13 +120,10 @@ Shiny.connections = { } // Check if the app has been stopped by another tab - Shiny.connections._checkAppHasBeenStopped(function (isStopped) { - //if (isStopped) { - // app was stopped, show stopped screen - Shiny.ui.showStoppedPage(); - return; - //} - //Shiny.connections._reloadPage(); + Shiny.connections._checkAppHasBeenStopped(function () { + // app was stopped, show stopped screen + Shiny.ui.showStoppedPage(); + return; }); }, @@ -99,7 +137,7 @@ Shiny.connections = { typeof _shinyFrame.contentWindow.Shiny.shinyapp !== 'undefined' && typeof _shinyFrame.contentWindow.Shiny.shinyapp.reconnect === 'function') { - if (Shiny.app.staticState.shinyForceFullReload) { + if (Shiny.app.runtimeState.proxy.runtimeValues.SHINYPROXY_FORCE_FULL_RELOAD) { // this is a Shiny app, but the forceFullReload option is set -> handle it as a non-Shiny app. return false; } @@ -238,7 +276,7 @@ Shiny.connections = { _checkAppHasBeenStopped: function (cb) { $.ajax({ method: 'POST', - url: Shiny.common.staticState.contextPath + "heartbeat/" + Shiny.app.staticState.proxyId, + url: Shiny.api.buildURL("heartbeat/" + Shiny.app.runtimeState.proxy.id), timeout: 3000, success: function () { cb(false); @@ -246,11 +284,11 @@ Shiny.connections = { error: function (response) { try { var res = JSON.parse(response.responseText); - if (res !== null && res.status === "error") { - if (res.message === "app_stopped_or_non_existent") { + if (res !== null && res.status === "fail") { + if (res.data === "app_stopped_or_non_existent") { cb(true); return; - } else if (res.message === "shinyproxy_authentication_required") { + } else if (res.data === "shinyproxy_authentication_required") { Shiny.ui.showLoggedOutPage(); // never call call-back, but just redirect to login page return; @@ -264,6 +302,25 @@ Shiny.connections = { } }); - } + }, + + _updateIframeUrl: function(url) { + if (!Shiny.app.runtimeState.proxy.runtimeValues.SHINYPROXY_TRACK_APP_URL) { + return; + } + if (Shiny.app.runtimeState.navigatingAway || Shiny.app.runtimeState.appStopped) { + return; + } + if (url === undefined || url === null) { + return; + } + if (url.startsWith(Shiny.app.runtimeState.baseFrameUrl)) { + const newUrl = url.replace(Shiny.app.runtimeState.baseFrameUrl, Shiny.app.runtimeState.parentFrameUrl); + window.history.replaceState(null, null, newUrl); + } else if (url.startsWith(Shiny.app.runtimeState.proxy.runtimeValues.SHINYPROXY_PUBLIC_PATH)) { + const newUrl = url.replace(Shiny.app.runtimeState.proxy.runtimeValues.SHINYPROXY_PUBLIC_PATH, Shiny.app.runtimeState.parentFrameUrl); + window.history.replaceState(null, null, newUrl); + } + }, -}; \ No newline at end of file +}; diff --git a/src/main/resources/static/js/shiny.iframe.js b/src/main/resources/static/js/shiny.iframe.js new file mode 100644 index 00000000..2ce602dd --- /dev/null +++ b/src/main/resources/static/js/shiny.iframe.js @@ -0,0 +1,163 @@ +/* + * ShinyProxy + * + * Copyright (C) 2016-2023 Open Analytics + * + * =========================================================================== + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Apache License as published by + * The Apache Software Foundation, either version 2 of the License, or + * (at your option) any later version. + * + * This program 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 + * Apache License for more details. + * + * You should have received a copy of the Apache License + * along with this program. If not, see + */ +var shinyProxy = null; +if (window.parent.Shiny !== undefined + && window.parent.Shiny.connections !== undefined) { + shinyProxy = window.parent.Shiny; + + var oldWebsocket = window.WebSocket; + + function ErrorHandlingWebSocket(url, protocols) { + console.log("Called ErrorHandlingWebSocket"); + var res = new oldWebsocket(url, protocols); + + function handler() { + console.log("Handling error of websocket connection.") + setTimeout(shinyProxy.connections.handleWebSocketError, 1); // execute async to not block other error handling code + } + + res.addEventListener("error", function () { + handler(); + }); + + res.addEventListener("close", function (event) { + if (!event.wasClean) { + handler(); + } + }); + + shinyProxy.app.runtimeState.websocketConnections.push(res); + + return res; + } + + ErrorHandlingWebSocket.prototype = oldWebsocket.prototype; + ErrorHandlingWebSocket.CONNECTING = oldWebsocket.CONNECTING; + ErrorHandlingWebSocket.OPEN = oldWebsocket.OPEN; + ErrorHandlingWebSocket.CLOSING = oldWebsocket.CLOSING; + ErrorHandlingWebSocket.CLOSED = oldWebsocket.CLOSED; + + window.WebSocket = ErrorHandlingWebSocket; + + /** + * Replaces the `fetch` function on the `parent` object by a wrapper function that keeps tracks of the + * Shiny.lastHeartbeatTime. + * This can be called on window.fetch to update the Shiny.lastHeartbeatTime everytime a fetch + * request is sent. + * Note: the only side effect when a Shiny app would circumvent this function is that more heartbeats than + * strictly needed are sent. + * @param parent + */ + var _replaceFetch = function (parent) { + var originalFetch = parent.fetch; + + parent.fetch = function () { + shinyProxy.app.runtimeState.lastHeartbeatTime = Date.now(); + + return new Promise((resolve, reject) => { + originalFetch.apply(this, arguments) + .then((response) => { + if (response.status === 410 || response.status === 401 || response.status === 503) { + response.clone().json().then(function (clonedResponse) { + if (clonedResponse.status === "fail" && clonedResponse.data === "app_stopped_or_non_existent") { + shinyProxy.ui.showStoppedPage(); + } else if (clonedResponse.status === "fail" && clonedResponse.data === "shinyproxy_authentication_required") { + shinyProxy.ui.showLoggedOutPage(); + } else if (clonedResponse.status === "fail" && clonedResponse.data === "app_crashed") { + shinyProxy.ui.showCrashedPage(); + } + }); + } + resolve(response); + }) + .catch((error) => { + reject(error); + }) + }); + } + }; + + + /** + * Replaces the `open` function on the `parent` object by a wrapper function that keeps tracks of the + * Shiny.lastHeartbeatTime. + * This can be called on window.XMLHttpRequest.prototype to update the Shiny.lastHeartbeatTime everytime an AJAX + * request is sent. + * Note: the only side effect when a Shiny app would circumvent this function is that more heartbeats than + * strictly needed are sent. + * @param parent + */ + var _replaceOpen = function (parent) { + var originalOpen = parent.open; + + parent.open = function () { + this.addEventListener('load', function () { + if (this.status === 410 || this.status === 401 || this.status === 503) { + var res = JSON.parse(this.responseText); + if (res !== null && res.status === "fail" && res.data === "app_stopped_or_non_existent") { + // app stopped + shinyProxy.ui.showStoppedPage(); + } else if (res !== null && res.status === "fail" && res.data === "shinyproxy_authentication_required") { + // app stopped + shinyProxy.ui.showLoggedOutPage(); + } else if (res !== null && res.status === "fail" && res.data === "app_crashed") { + shinyProxy.ui.showCrashedPage(); + } + } + }); + shinyProxy.app.runtimeState.lastHeartbeatTime = Date.now(); + + return originalOpen.apply(this, arguments); + } + }; + + _replaceFetch(window); + _replaceOpen(window.XMLHttpRequest.prototype); + + // update the url when the page changes, e.g. plain HTTP apps + window.addEventListener('load', function() { + shinyProxy.connections._updateIframeUrl(window.location.toString()); + }); + + // update the url for SPA apps + var originalReplaceState = window.history.replaceState; + window.history.replaceState = function (data, title, url) { + originalReplaceState.call(window.history, data, title, url); + shinyProxy.connections._updateIframeUrl(url); + }; + + // update the url for SPA apps + var originalPushState = window.history.pushState; + window.history.pushState = function (data, title, url) { + originalPushState.call(window.history, data, title, url); + shinyProxy.connections._updateIframeUrl(url); + }; + + // required for some type of applications (e.g. Angular 1: apache zeppelin) + // note: this event doesn't get triggered for calls to history.replaceState and + // history.pushState. + window.addEventListener('popstate', (event) => { + setTimeout(() => { + shinyProxy.connections._updateIframeUrl(window.location.toString()); + }); + }); + +} diff --git a/src/main/resources/static/js/shiny.instances.js b/src/main/resources/static/js/shiny.instances.js index 6b1f5b0b..7dd74ef5 100644 --- a/src/main/resources/static/js/shiny.instances.js +++ b/src/main/resources/static/js/shiny.instances.js @@ -1,7 +1,7 @@ /* * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -26,202 +26,223 @@ Shiny.instances = { _refreshIntervalId: null, eventHandlers: { - onShow: function () { + onShow: function (appName) { + if (appName === null) { + Shiny.common.runtimeState.switchInstanceApp = { + appName: Shiny.app.staticState.appName, + maxInstances: Shiny.common.staticState.appMaxInstances[Shiny.app.staticState.appName], + newTab: true, + } + } else { + Shiny.common.runtimeState.switchInstanceApp = { + appName: appName, + maxInstances: Shiny.common.staticState.appMaxInstances[appName], + newTab: false, + } + } + Shiny.instances._refreshModal(); clearInterval(Shiny.instances._refreshIntervalId); - Shiny.instances._refreshIntervalId = setInterval(function() { + Shiny.instances._refreshIntervalId = setInterval(async function () { if (!document.hidden) { - Shiny.instances._refreshModal(); + await Shiny.instances._refreshModal(); } }, 2500); }, - onClose: function() { + onClose: function () { clearInterval(Shiny.instances._refreshIntervalId); + clearInterval(Shiny.instances._detailsRefreshIntervalId); // just to be sure }, - onDeleteInstance: function (instanceName) { - // this function can be called with one of: - // - no argument (i.e. undefined) -> the current instance - // - `_` or `Default` -> both represent the default instance - // - any other name of an instance - - if (instanceName === undefined) { - instanceName = Shiny.app.staticState.appInstanceName; + showAppDetails: function(event, appName, appInstanceName, proxyId) { + if (event) { + event.preventDefault(); } - - // show `_` as `Default` in the confirmation message - var displayName = instanceName; - if (displayName === "_") { - displayName = "Default"; + if (appInstanceName === undefined) { + // when no arguments provided -> show the current app + appName = Shiny.app.staticState.appName; + appInstanceName = Shiny.instances._toAppDisplayName(Shiny.app.staticState.appInstanceName); + proxyId = Shiny.app.runtimeState.proxy.id; + Shiny.ui.showAppDetailsModal(); + } else { + Shiny.ui.showAppDetailsModal($('#switchInstancesModal')); + } + Shiny.common.loadAppDetails(appName, appInstanceName, proxyId); + }, + // TODO rename to onStopApp ? + onDeleteInstance: async function (event, appInstanceName, proxyId) { + if (event) { + event.preventDefault(); + } + if (appInstanceName === undefined) { + // when no arguments provided -> stop the current app + appInstanceName = Shiny.instances._toAppDisplayName(Shiny.app.staticState.appInstanceName); + proxyId = Shiny.app.runtimeState.proxy.id; } - if (confirm("Are you sure you want to delete instance \"" + displayName + "\"?")) { - Shiny.instances._deleteInstance(instanceName, function () { - if (instanceName === "Default") { - instanceName = "_"; - } - if (instanceName === Shiny.app.staticState.appInstanceName) { - Shiny.ui.showStoppedPage(); - } - }); + if (confirm("Are you sure you want to stop instance \"" + appInstanceName + "\"?")) { + if (!await Shiny.api.changeProxyStatus(proxyId, 'Stopping')) { + alert("Cannot stop this app now, please try again later"); + return; + } + if (Shiny.instances._isOpenedApp(proxyId)) { + Shiny.app.runtimeState.appStopped = true; + Shiny.ui.removeFrame(); + Shiny.ui.showStoppingPage(); + Shiny.app.runtimeState.proxy = await Shiny.api.waitForStatusChange(Shiny.app.runtimeState.proxy.id); + Shiny.ui.showStoppedPage(); + } } }, - onRestartInstance: function () { - if (confirm("Are you sure you want to restart the current instance?")) { - Shiny.ui.hideInstanceModal(); - Shiny.ui.showLoading(); + async onPauseApp(event, appInstanceName, proxyId) { + if (event) { + event.preventDefault(); + } + if (appInstanceName === undefined) { + // when no arguments provided -> pause the current app + appInstanceName = Shiny.instances._toAppDisplayName(Shiny.app.staticState.appInstanceName); + proxyId = Shiny.app.runtimeState.proxy.id; + } - if (Shiny.app.runtimeState.appStopped) { - window.location.reload(false); + if (confirm("Are you sure you want to pause instance \"" + appInstanceName + "\"?")) { + if (!await Shiny.api.changeProxyStatus(proxyId, 'Pausing')) { + alert("Cannot pause this app now, please try again later"); return; } + if (Shiny.instances._isOpenedApp(proxyId)) { + Shiny.app.runtimeState.appStopped = true; + Shiny.ui.removeFrame(); + Shiny.ui.showPausingPage(); + Shiny.app.runtimeState.proxy = await Shiny.api.waitForStatusChange(Shiny.app.runtimeState.proxy.id); + Shiny.ui.showPausedAppPage(); + } + } + }, + // TODO rename to onRestartApp? + onRestartInstance: async function (event) { + if (event) { + event.preventDefault(); + } + const overrideUrl = new URL(window.location); + overrideUrl.searchParams.delete("sp_instance_override"); - Shiny.instances._deleteInstance(Shiny.app.staticState.appInstanceName, function (proxyId) { - Shiny.instances._waitUntilInstanceDeleted(proxyId, function () { - window.location.reload(false); - }); - }); + if (Shiny.app.runtimeState.appStopped + || Shiny.app.runtimeState.proxy.status === "Stopped" + || Shiny.app.runtimeState.proxy.status === "Paused") { + window.location = overrideUrl; + return; + } else if (confirm("Are you sure you want to restart the current instance?")) { + Shiny.app.runtimeState.appStopped = true; + Shiny.ui.removeFrame(); + Shiny.ui.showStoppingPage(); + + await Shiny.api.changeProxyStatus(Shiny.app.runtimeState.proxy.id, 'Stopping'); + Shiny.app.runtimeState.proxy = await Shiny.api.waitForStatusChange(Shiny.app.runtimeState.proxy.id); + + window.location = overrideUrl; } }, - onNewInstance: function () { - var inputField = $("#instanceNameField"); - var instance = inputField.val().trim(); + onNewInstance: async function () { + const appName = Shiny.common.runtimeState.switchInstanceApp.appName; + const inputField = $("#instanceNameField"); + let instance = inputField.val().trim(); if (instance === "") { return; } - if (instance.length > 64) { - alert("The provide name is too long (maximum 64 characters)"); - return; + if (instance.toLowerCase() === "default") { + instance = "_"; } - if (!Shiny.instances._nameRegex.test(instance)) { - alert("The provide name contains invalid characters (ony alphanumeric characters, '_', '-' and '.' are allowed.)"); + if (instance.length > 64) { + alert("The provided name is too long (maximum 64 characters)"); return; } - if (instance === Shiny.app.staticState.appInstanceName) { - alert("This instance is already opened in the current tab"); + if (!Shiny.instances._nameRegex.test(instance)) { + alert("The provided name contains invalid characters (only alphanumeric characters, '_', '-' and '.' are allowed.)"); return; } - if (Shiny.app.staticState.maxInstances !== -1) { - // this must be a synchronous call (i.e. without any callbacks) so that the window.open function is not - // blocked by the browser. - var currentAmountOfInstances = Shiny.instances._getCurrentAmountOfInstances(); - if (currentAmountOfInstances >= Shiny.app.staticState.maxInstances) { + const existingInstances = await Shiny.api.getProxies(); + if (existingInstances.hasOwnProperty(appName)) { + const currentAmountOfInstances = existingInstances[appName].length; + const maxInstances = Shiny.common.runtimeState.switchInstanceApp.maxInstances; + if (maxInstances !== -1 && currentAmountOfInstances >= maxInstances) { alert("You cannot start a new instance because you are using the maximum amount of instances of this app!"); return; } + for (const existingInstance of existingInstances[appName]) { + if (existingInstance.runtimeValues.SHINYPROXY_APP_INSTANCE === instance) { + alert("You are already using an instance with this name!"); + return; + } + } } - window.open(Shiny.instances._createUrlForInstance(instance), "_blank"); + if (Shiny.common.runtimeState.switchInstanceApp.newTab) { + window.open(Shiny.instances._createUrlForInstance(instance), "_blank"); + } else { + window.location = Shiny.instances._createUrlForInstance(instance); + } inputField.val(''); - Shiny.ui.hideInstanceModal(); + Shiny.ui.hideModal(); }, }, - _createUrlForInstance: function (instance) { - return Shiny.common.staticState.contextPath + "app_i/" + Shiny.app.staticState.appName + "/" + instance + "/"; + return Shiny.common.staticState.contextPath + "app_i/" + Shiny.common.runtimeState.switchInstanceApp.appName + "/" + instance + "/"; }, - - _deleteInstance: function (instanceName, cb) { - if (instanceName === "Default") { - instanceName = "_"; - } - if (instanceName === Shiny.app.staticState.appInstanceName) { - Shiny.app.runtimeState.appStopped = true; - Shiny.ui.removeFrame(); - } - Shiny.api.getProxyId(Shiny.app.staticState.appName, instanceName, function (proxyId) { - if (proxyId !== null) { - Shiny.api.deleteProxyById(proxyId, function () { - cb(proxyId); - }, function() { - alert("Error deleting proxy, please try again.") + _refreshModal: async function () { + let templateData = await Shiny.api.getProxiesAsTemplateData(); + let appName = Shiny.common.runtimeState.switchInstanceApp.appName; + if (templateData.apps.hasOwnProperty(appName)) { + templateData = templateData.apps[appName]; + + if (Shiny.app.runtimeState.proxy !== null) { + templateData.instances.forEach(instance => { + instance.active = instance.proxyId === Shiny.app.runtimeState.proxy.id }); } - }, function() { - alert("Error deleting proxy, please try again.") - }); - }, - _waitUntilInstanceDeleted: function (proxyId, cb) { - Shiny.api.getProxyById(proxyId, function (found) { - if (!found) { - cb(); - return; + // put active item in front of the list + const index = templateData.instances.findIndex(instance => instance.active); + if (index > 0) { // list may not contain any active instance + const active = templateData.instances[index]; + templateData.instances.splice(index, 1); + templateData.instances.unshift(active); } - setTimeout(function () { - Shiny.instances._waitUntilInstanceDeleted(proxyId, cb); - }, 500); - }, function() {}); - }, - _refreshModal: function() { - Shiny.api.getProxies(function (proxies) { - var templateData = {'instances': []}; - - for (var idx = 0; idx < proxies.length; idx++) { - var proxy = proxies[idx]; - - if (proxy.hasOwnProperty('spec') && proxy.spec.hasOwnProperty('id') && - proxy.hasOwnProperty('runtimeValues') && proxy.runtimeValues.hasOwnProperty('SHINYPROXY_APP_INSTANCE')) { - - var appInstance = proxy.runtimeValues.SHINYPROXY_APP_INSTANCE; - if (proxy.spec.id !== Shiny.app.staticState.appName) { - continue; - } - - if (proxy.status !== "Up" && proxy.status !== "Starting" && proxy.status !== "New") { - continue; - } + } else { + templateData = {"instances": []}; + } - var proxyName = "" - if (appInstance === "_") { - proxyName = "Default"; - } else { - proxyName = appInstance; - } + if (Shiny.common.runtimeState.switchInstanceApp.maxInstances === -1) { + $('#maxInstances').text("unlimited"); + } else { + $('#maxInstances').text(Shiny.common.runtimeState.switchInstanceApp.maxInstances); + } - var active = Shiny.app.staticState.appInstanceName === appInstance; - var url = Shiny.instances._createUrlForInstance(appInstance); + $('#usedInstances').text(templateData['instances'].length); - templateData['instances'].push({name: proxyName, active: active, url: url}); - } else { - console.log("Received invalid proxy object from server."); - } - } + if (Shiny.common.runtimeState.switchInstanceApp.newTab) { + templateData['target'] = '_blank'; + } else { + templateData['target'] = ''; + } - templateData['instances'].sort(function (a, b) { - return a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1 - }); + templateData['pauseSupported'] = Shiny.common.staticState.pauseSupported; - if (Shiny.app.staticState.maxInstances === -1) { - $('#maxInstances').text("unlimited"); - } else { - $('#maxInstances').text(Shiny.app.staticState.maxInstances); - } - $('#usedInstances').text(templateData['instances'].length); - document.getElementById('appInstances').innerHTML = Shiny.instances._template(templateData); - }); + document.getElementById('appInstances').innerHTML = Handlebars.templates.switch_instances(templateData); }, - _getCurrentAmountOfInstances: function() { - var currentAmountOfInstances = 0; - - $.ajax({ - url: Shiny.common.staticState.contextPath + "api/proxy", - success: function(result) { - for (var idx = 0; idx < result.length; idx++) { - var proxy = result[idx]; - if (proxy.hasOwnProperty('spec') && proxy.spec.hasOwnProperty('id') && proxy.spec.id === Shiny.app.staticState.appName) { - currentAmountOfInstances++; - } - } - }, - async: false - }); - - return currentAmountOfInstances; + _toAppDisplayName(appInstanceName) { + if (appInstanceName === "_") { + return "Default"; + } + return appInstanceName; + }, + _isOpenedApp(proxyId) { + return Shiny.app !== undefined + && Shiny.app.runtimeState.proxy != null + && Shiny.app.runtimeState.proxy.id === proxyId; } -}; \ No newline at end of file +}; diff --git a/src/main/resources/static/js/shiny.operator.js b/src/main/resources/static/js/shiny.operator.js deleted file mode 100644 index 439cdf9e..00000000 --- a/src/main/resources/static/js/shiny.operator.js +++ /dev/null @@ -1,115 +0,0 @@ -// noinspection ES6ConvertVarToLetConst - -/* - * ShinyProxy - * - * Copyright (C) 2016-2021 Open Analytics - * - * =========================================================================== - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Apache License as published by - * The Apache Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * This program 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 - * Apache License for more details. - * - * You should have received a copy of the Apache License - * along with this program. If not, see - */ - - -Shiny = window.Shiny || {}; -Shiny.operator = { - - staticState: { - forceTransfer: null, - showTransferMessage: null, - }, - - /** - * Start the Shiny Application. - * @param forceTransfer whether to force transferring the user to the latest instance if no apps running - * @param showTransferMessage whether a message/popup should be shown when the user is using an old server and they - * have at least one app running. - */ - init: function (forceTransfer, showTransferMessage) { - Shiny.operator.staticState.forceTransfer = forceTransfer; - Shiny.operator.staticState.showTransferMessage = showTransferMessage; - }, - - start: function(cb=null) { - document.getElementById('new-version-btn').addEventListener("click", function() { - Shiny.api.getProxies(function (proxies) { - Shiny.operator.hideMessage(); - if (proxies.length > 0) { - if (confirm("Warning: you have " + proxies.length + " apps running, your existing session(s) will be closed once you switch to the new version.")) { - Shiny.operator.transferToNewInstance(); - } - } else { - Shiny.operator.transferToNewInstance(); - } - }); - }); - if (Shiny.operator.newInstanceAvailable()) { - // check amount of apps running - if (Shiny.operator.staticState.forceTransfer) { - Shiny.api.getProxies(function (proxies) { - if (proxies.length === 0) { - // force transfer - Shiny.operator.transferToNewInstance(); - } else { - // display message - Shiny.operator.displayMessage(); - if (cb !== null) cb(); - } - }, function() { - // failure -> display message - Shiny.operator.displayMessage(); - if (cb !== null) cb(); - }); - } else { - // display message - Shiny.operator.displayMessage(); - if (cb !== null) cb(); - } - } else { - if (cb !== null) cb(); - } - }, - - newInstanceAvailable: function() { - var spInstanceCookie = Cookies.get('sp-instance'); - var spLatestInstanceCookie = Cookies.get('sp-latest-instance'); - - return typeof spInstanceCookie !== 'undefined' && typeof spLatestInstanceCookie !== 'undefined' && spInstanceCookie !== spLatestInstanceCookie; - }, - - displayMessage: function() { - // only show the message if the option is enabled - if (Shiny.operator.staticState.showTransferMessage) { - document.getElementById('new-version-banner').style.display = "block"; - } - }, - - hideMessage: function() { - document.getElementById('new-version-banner').style.display = "none"; - }, - - transferToNewInstance: function() { - $('#loading,#reconnecting,#reloadFailed,#appStopped,#shinyframe').remove(); - - $('#applist,#iframeinsert').replaceWith( - "
    " + - "

    Transferring you to the latest version of " + Shiny.common.staticState.applicationName + " ...

    " + - "
    "); - - Cookies.set('sp-instance', Cookies.get('sp-latest-instance'), {path: Shiny.common.staticState.contextPath}); - location.reload(); - } - -} - diff --git a/src/main/resources/static/js/shiny.ui.js b/src/main/resources/static/js/shiny.ui.js index 592ee71b..45166418 100644 --- a/src/main/resources/static/js/shiny.ui.js +++ b/src/main/resources/static/js/shiny.ui.js @@ -1,7 +1,7 @@ /* * ShinyProxy * - * Copyright (C) 2016-2021 Open Analytics + * Copyright (C) 2016-2023 Open Analytics * * =========================================================================== * @@ -28,10 +28,7 @@ Shiny.ui = { */ setupIframe: function () { var $iframe = $('') - // IMPORTANT: start the injector before setting the `src` property of the iframe - // This is required to ensure that the polling catches all events and therefore the injector works properly. - // Shiny.connections.startInjector(); - $iframe.attr("src", Shiny.app.staticState.containerPath); + $iframe.attr("src", Shiny.app.runtimeState.containerPath); $('#iframeinsert').before($iframe); // insert the iframe into the HTML. }, @@ -47,7 +44,7 @@ Shiny.ui = { /** * Shows the reconnecting page. */ - showReconnecting: function() { + showReconnecting: function () { $('#appStopped').hide(); $('#shinyframe').hide(); $("#loading").show(); @@ -64,28 +61,85 @@ Shiny.ui = { }); }, - showFailedToReloadPage: function () { + showResumingPage: function () { + $('#shinyframe').hide(); + $("#loading").show(); + }, + + showStoppingPage: function () { $('#shinyframe').hide(); + $("#loading").show(); + }, + + showPausingPage: function () { + $('#shinyframe').hide(); + $("#loading").show(); + }, + + showPausedAppPage: function () { + $('#shinyframe').remove(); + $("#loading").hide(); + $('#appPaused').show(); + $("#navbarWrapper").show(); + }, + + showFailedToReloadPage: function () { + $('#shinyframe').remove(); $("#loading").hide(); $("#reloadFailed").show(); + $("#navbarWrapper").show(); + }, + + showStartFailedPage: function () { + $('#shinyframe').hide(); + $("#loading").hide(); + $("#startFailed").show(); + $("#navbarWrapper").show(); }, - showStoppedPage: function() { + showStoppedPage: function () { + Shiny.app.runtimeState.appStopped = true; $('#shinyframe').remove(); $("#loading").hide(); - $('#appStopped').show(); + $("#navbarWrapper").show(); + if (!$('#appCrashed').is(":visible")) { + $('#appStopped').show(); + } }, - showLoggedOutPage: function() { + showCrashedPage: function () { + Shiny.app.runtimeState.appStopped = true; + $('#shinyframe').remove(); + $("#loading").hide(); + $('#appCrashed').show(); + $("#navbarWrapper").show(); + }, + + showLoggedOutPage: function () { + Shiny.app.runtimeState.appStopped = true; if (!Shiny.app.runtimeState.navigatingAway) { // only show it when not navigating away, e.g. when logging out in the current tab $('#shinyframe').remove(); $("#loading").hide(); + $("#navbar").hide(); $('#userLoggedOut').show(); + $("#navbarWrapper").show(); } }, removeFrame() { $('#shinyframe').remove(); + }, + + getTimeZone() { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return null; + } } -} \ No newline at end of file +} + +Handlebars.registerHelper('formatStatus', function (status) { + return Shiny.ui.formatStatus(status); +}); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 84729197..4cb8ba35 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -2,7 +2,7 @@ ShinyProxy - Copyright (C) 2016-2021 Open Analytics + Copyright (C) 2016-2023 Open Analytics =========================================================================== @@ -26,44 +26,69 @@ - - - - + + + + + + + + + + + + + + + + -
    +

    Active Proxies

    - - - - - - - - - - - - - - - - - - - - - - - - - -
    IDStatusUserAppnameInstanceEndpointUptimeLast heartbeatImageImage tag
    +
    + + + + + + + + + + + + + + + + + + + +
    ServerIDStatusUserAppnameInstanceEndpointUptimeLast heartbeatImageImage tagActions
    +
    + +
    + + + + + + + + + + - \ No newline at end of file + diff --git a/src/main/resources/templates/app.html b/src/main/resources/templates/app.html index 9cee48c2..764907ef 100644 --- a/src/main/resources/templates/app.html +++ b/src/main/resources/templates/app.html @@ -2,7 +2,7 @@ ShinyProxy - Copyright (C) 2016-2021 Open Analytics + Copyright (C) 2016-2023 Open Analytics =========================================================================== @@ -25,34 +25,61 @@ xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity4"> - - - - - - - - - - - - - - - + + + + + + + + + + + + + + -
    +
    -