diff --git a/CHANGELOG.md b/CHANGELOG.md index f23524c666..73be698524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for pr merge with prior rebase ([#1332](https://github.com/scm-manager/scm-manager/pull/1332)) - Tags overview for repository [#1331](https://github.com/scm-manager/scm-manager/pull/1331) - Permissions can be specified for namespaces ([#1335](https://github.com/scm-manager/scm-manager/pull/1335)) +- Show update info on admin information page ([#1342](https://github.com/scm-manager/scm-manager/pull/1342)) ### Fixed - Missing synchronization during repository creation ([#1328](https://github.com/scm-manager/scm-manager/pull/1328)) diff --git a/docs/de/user/admin/assets/administration-information.png b/docs/de/user/admin/assets/administration-information.png index 1050025588..71630d7ddd 100644 Binary files a/docs/de/user/admin/assets/administration-information.png and b/docs/de/user/admin/assets/administration-information.png differ diff --git a/docs/de/user/admin/index.md b/docs/de/user/admin/index.md index 2eeb57996b..3f88a4c402 100644 --- a/docs/de/user/admin/index.md +++ b/docs/de/user/admin/index.md @@ -11,6 +11,6 @@ Im Bereich Administration kann die SCM-Manager Instanz administriert werden. Von ### Information -Auf der Informationsseite in der Administration findet man die aktuelle Version der SCM-Manager Instanz und hilfreiche Links zur Kontaktaufnahme mit dem SCM-Manager Support-Team. +Auf der Informationsseite in der Administration findet man die aktuelle Version der SCM-Manager Instanz und hilfreiche Links zur Kontaktaufnahme mit dem SCM-Manager Support-Team. Falls eine neuere Version des SCM-Managers verfügbar ist, wird der Link zum Download-Bereich der offiziellen Webseite angezeigt. ![Administration-Information](assets/administration-information.png) diff --git a/docs/en/user/admin/assets/administration-information.png b/docs/en/user/admin/assets/administration-information.png index 64ffd1d7a8..ee60458cff 100644 Binary files a/docs/en/user/admin/assets/administration-information.png and b/docs/en/user/admin/assets/administration-information.png differ diff --git a/docs/en/user/admin/index.md b/docs/en/user/admin/index.md index 7c5f30c2cb..d2e77554d1 100644 --- a/docs/en/user/admin/index.md +++ b/docs/en/user/admin/index.md @@ -9,6 +9,6 @@ The SCM-Manager instance can be administered in the Administration area. From he * [Settings](settings/) ### Information -On the information page in the administration area you can find the version of your SCM-Manager instance and helpful links to get in touch with the SCM-Manager support team. +On the information page in the administration area you can find the version of your SCM-Manager instance and helpful links to get in touch with the SCM-Manager support team. If there is a newer version for SCM-Manager, it will be shown with the link to the download section on the official SCM-Manager homepage. ![Administration-Information](assets/administration-information.png) diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index 3a69917df1..7bb64a9454 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -69,6 +69,12 @@ public class ScmConfiguration implements Configuration { public static final String DEFAULT_PLUGINURL = "https://plugin-center-api.scm-manager.org/api/v1/plugins/{version}?os={os}&arch={arch}"; + /** + * SCM Manager release feed url + */ + public static final String DEFAULT_RELEASE_FEED_URL = + "https://scm-manager.org/download/rss.xml"; + /** * Default url for login information (plugin and feature tips on the login page). */ @@ -140,6 +146,9 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "plugin-url") private String pluginUrl = DEFAULT_PLUGINURL; + @XmlElement(name = "release-feed-url") + private String releaseFeedUrl = DEFAULT_RELEASE_FEED_URL; + /** * Login attempt timeout. * @@ -217,6 +226,7 @@ public class ScmConfiguration implements Configuration { this.enabledXsrfProtection = other.enabledXsrfProtection; this.namespaceStrategy = other.namespaceStrategy; this.loginInfoUrl = other.loginInfoUrl; + this.releaseFeedUrl = other.releaseFeedUrl; } /** @@ -272,6 +282,15 @@ public class ScmConfiguration implements Configuration { return pluginUrl; } + /** + * Returns the url of the rss release feed. + * + * @return the rss release feed url. + */ + public String getReleaseFeedUrl() { + return releaseFeedUrl; + } + /** * Returns a set of glob patterns for urls which should excluded from * proxy settings. @@ -324,6 +343,7 @@ public class ScmConfiguration implements Configuration { /** * Returns {@code true} if anonymous mode is enabled. + * * @return {@code true} if anonymous mode is enabled * @deprecated since 2.4.0 use {@link ScmConfiguration#getAnonymousMode} instead */ @@ -379,6 +399,7 @@ public class ScmConfiguration implements Configuration { /** * Enables the anonymous access at protocol level. + * * @param anonymousAccessEnabled enable or disables the anonymous access * @deprecated since 2.4.0 use {@link ScmConfiguration#setAnonymousMode(AnonymousMode)} instead */ @@ -393,8 +414,8 @@ public class ScmConfiguration implements Configuration { /** * Configures the anonymous mode. - * @param mode type of anonymous mode * + * @param mode type of anonymous mode * @since 2.4.0 */ public void setAnonymousMode(AnonymousMode mode) { @@ -446,6 +467,10 @@ public class ScmConfiguration implements Configuration { this.pluginUrl = pluginUrl; } + public void setReleaseFeedUrl(String releaseFeedUrl) { + this.releaseFeedUrl = releaseFeedUrl; + } + /** * Set glob patterns for urls which are should be excluded from proxy * settings. diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 4b79e3621c..6112d233f4 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -79,6 +79,7 @@ public class VndMediaType { public static final String ME = PREFIX + "me" + SUFFIX; public static final String SOURCE = PREFIX + "source" + SUFFIX; public static final String ANNOTATE = PREFIX + "annotate" + SUFFIX; + public static final String ADMIN_INFO = PREFIX + "adminInfo" + SUFFIX; public static final String ERROR_TYPE = PREFIX + "error" + SUFFIX; public static final String REPOSITORY_ROLE = PREFIX + "repositoryRole" + SUFFIX; diff --git a/scm-core/src/main/java/sonia/scm/xml/XmlUTCDateAdapter.java b/scm-core/src/main/java/sonia/scm/xml/XmlUTCDateAdapter.java new file mode 100644 index 0000000000..188b703620 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/xml/XmlUTCDateAdapter.java @@ -0,0 +1,43 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.xml; + +import javax.xml.bind.annotation.adapters.XmlAdapter; +import java.text.SimpleDateFormat; +import java.util.Date; + +public class XmlUTCDateAdapter extends XmlAdapter { + @Override + public Date unmarshal(String date) throws Exception { + SimpleDateFormat formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + return formatter.parse(date); + } + + @Override + public String marshal(Date date) { + SimpleDateFormat formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + return formatter.format(date); + } +} diff --git a/scm-ui/ui-types/src/Config.ts b/scm-ui/ui-types/src/Config.ts index 34969fa7ee..667aa7b199 100644 --- a/scm-ui/ui-types/src/Config.ts +++ b/scm-ui/ui-types/src/Config.ts @@ -47,5 +47,6 @@ export type Config = { enabledXsrfProtection: boolean; namespaceStrategy: string; loginInfoUrl: string; + releaseFeedUrl: string; _links: Links; }; diff --git a/scm-ui/ui-webapp/public/locales/de/admin.json b/scm-ui/ui-webapp/public/locales/de/admin.json index 620228df0b..6f8de6c7df 100644 --- a/scm-ui/ui-webapp/public/locales/de/admin.json +++ b/scm-ui/ui-webapp/public/locales/de/admin.json @@ -9,6 +9,12 @@ "info": { "title": "Administration", "currentAppVersion": "Aktuelle Software-Versionsnummer", + "newAppVersion": "Neue SCM-Manager Version verfügbar", + "newRelease": { + "title": "Neue SCM-Manager Version verfügbar", + "description": "Die neueste SCM-Manager Version {{version}} steht auf der offiziellen Webseite zum Download bereit.", + "downloadButton": "Neueste Version herunterladen" + }, "communityTitle": "Community Support", "communityIconAlt": "Community Support Icon", "communityInfo": "Das SCM-Manager Support-Team steht für allgemeine Fragen, die Meldung von Fehlern sowie Anfragen für Features gerne für Sie über die offiziellen Kanäle bereit.", diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index 2016568fdd..ad749c12c8 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -46,6 +46,7 @@ }, "skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen", "plugin-url": "Plugin Center URL", + "release-feed-url": "Release Feed URL", "enabled-xsrf-protection": "XSRF Protection aktivieren", "namespace-strategy": "Namespace Strategie", "login-info-url": "Login Info URL" @@ -60,6 +61,7 @@ "realmDescriptionHelpText": "Beschreibung des Authentication Realm.", "dateFormatHelpText": "Moments Datumsformat. Zulässige Formate sind in der MomentJS Dokumentation beschrieben.", "pluginUrlHelpText": "Die URL der Plugin Center API. Beschreibung der Platzhalter: version = SCM-Manager Version; os = Betriebssystem; arch = Architektur", + "releaseFeedUrlHelpText": "Die URL des RSS Release Feed des SCM-Manager. Darüber wird über die neue SCM-Manager Version informiert. Um diese Funktion zu deaktivieren lassen Sie dieses Feld leer.", "enableForwardingHelpText": "mod_proxy Port Weiterleitung aktivieren.", "disableGroupingGridHelpText": "Repository Gruppen deaktivieren. Nach einer Änderung an dieser Einstellung muss die Seite komplett neu geladen werden.", "allowAnonymousAccessHelpText": "Anonyme Benutzer haben Zugriff auf freigegebene Repositories.", diff --git a/scm-ui/ui-webapp/public/locales/en/admin.json b/scm-ui/ui-webapp/public/locales/en/admin.json index eae79e3a9c..ae404117fe 100644 --- a/scm-ui/ui-webapp/public/locales/en/admin.json +++ b/scm-ui/ui-webapp/public/locales/en/admin.json @@ -9,6 +9,12 @@ "info": { "title": "Administration", "currentAppVersion": "Current Application Version", + "newAppVersion": "New SCM-Manager version available", + "newRelease": { + "title": "New SCM-Manager version available", + "description": "Download the latest SCM-Manager version {{version}} from the official homepage.", + "downloadButton": "Get latest version" + }, "communityTitle": "Community Support", "communityIconAlt": "Community Support Icon", "communityInfo": "Contact the SCM-Manager support team for questions about SCM-Manager, to report bugs or to request features through the official channels.", diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index 6f789e747c..1150dd4e3d 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -46,6 +46,7 @@ }, "skip-failed-authenticators": "Skip Failed Authenticators", "plugin-url": "Plugin Center URL", + "release-feed-url": "Release Feed URL", "enabled-xsrf-protection": "Enabled XSRF Protection", "namespace-strategy": "Namespace Strategy", "login-info-url": "Login Info URL" @@ -60,6 +61,7 @@ "realmDescriptionHelpText": "Enter authentication realm description.", "dateFormatHelpText": "Moments date format. Please have a look at the MomentJS documentation.", "pluginUrlHelpText": "The url of the Plugin Center API. Explanation of the placeholders: version = SCM-Manager Version; os = Operation System; arch = Architecture", + "releaseFeedUrlHelpText": "The url of the RSS Release Feed for SCM-Manager. This provides up-to-date version information. To disable this feature just leave the url blank.", "enableForwardingHelpText": "Enable mod_proxy port forwarding.", "disableGroupingGridHelpText": "Disable repository Groups. A complete page reload is required after a change of this value.", "allowAnonymousAccessHelpText": "Anonymous users have access on granted repositories.", diff --git a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx index 973945b2f8..f412acf8e8 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx @@ -143,6 +143,7 @@ class ConfigForm extends React.Component { anonymousMode={config.anonymousMode} skipFailedAuthenticators={config.skipFailedAuthenticators} pluginUrl={config.pluginUrl} + releaseFeedUrl={config.releaseFeedUrl} enabledXsrfProtection={config.enabledXsrfProtection} namespaceStrategy={config.namespaceStrategy} onChange={(isValid, changedValue, name) => this.onChange(isValid, changedValue, name)} diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx index 931e14cd4d..580a3546da 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx @@ -35,6 +35,7 @@ type Props = WithTranslation & { anonymousMode: AnonymousMode; skipFailedAuthenticators: boolean; pluginUrl: string; + releaseFeedUrl: string; enabledXsrfProtection: boolean; namespaceStrategy: string; namespaceStrategies?: NamespaceStrategies; @@ -49,6 +50,7 @@ class GeneralSettings extends React.Component { realmDescription, loginInfoUrl, pluginUrl, + releaseFeedUrl, enabledXsrfProtection, anonymousMode, namespaceStrategy, @@ -126,6 +128,17 @@ class GeneralSettings extends React.Component { /> +
+
+ +
+
); } @@ -148,6 +161,9 @@ class GeneralSettings extends React.Component { handlePluginCenterUrlChange = (value: string) => { this.props.onChange(true, value, "pluginUrl"); }; + handleReleaseFeedUrlChange = (value: string) => { + this.props.onChange(true, value, "releaseFeedUrl"); + }; } export default withTranslation("config")(GeneralSettings); diff --git a/scm-ui/ui-webapp/src/admin/containers/Admin.tsx b/scm-ui/ui-webapp/src/admin/containers/Admin.tsx index 98a308e4e3..e4a23dd7e6 100644 --- a/scm-ui/ui-webapp/src/admin/containers/Admin.tsx +++ b/scm-ui/ui-webapp/src/admin/containers/Admin.tsx @@ -36,7 +36,8 @@ import { SecondaryNavigation, SecondaryNavigationColumn, StateMenuContextProvider, - SubNavigation + SubNavigation, + urls } from "@scm-manager/ui-components"; import { getAvailablePluginsLink, getInstalledPluginsLink, getLinks } from "../../modules/indexResource"; import AdminDetails from "./AdminDetails"; @@ -45,7 +46,6 @@ import GlobalConfig from "./GlobalConfig"; import RepositoryRoles from "../roles/containers/RepositoryRoles"; import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole"; import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole"; -import { urls } from "@scm-manager/ui-components"; type Props = RouteComponentProps & WithTranslation & { @@ -55,7 +55,6 @@ type Props = RouteComponentProps & }; class Admin extends React.Component { - matchesRoles = (route: any) => { const url = urls.matchedUrl(this.props); const regex = new RegExp(`${url}/role/`); diff --git a/scm-ui/ui-webapp/src/admin/containers/AdminDetails.tsx b/scm-ui/ui-webapp/src/admin/containers/AdminDetails.tsx index 86eed31c49..17eae2191e 100644 --- a/scm-ui/ui-webapp/src/admin/containers/AdminDetails.tsx +++ b/scm-ui/ui-webapp/src/admin/containers/AdminDetails.tsx @@ -25,18 +25,23 @@ import React from "react"; import { connect } from "react-redux"; import { WithTranslation, withTranslation } from "react-i18next"; import styled from "styled-components"; -import { Image, Loading, Subtitle, Title } from "@scm-manager/ui-components"; -import { getAppVersion } from "../../modules/indexResource"; +import { apiClient, Image, Loading, Subtitle, Title } from "@scm-manager/ui-components"; +import { getAppVersion, getUpdateInfoLink } from "../../modules/indexResource"; type Props = WithTranslation & { - loading: boolean; - error: Error; version: string; + updateInfoLink?: string; }; -const NoBottomMarginSubtitle = styled(Subtitle)` - margin-bottom: 0.25rem !important; -`; +type State = { + loading: boolean; + updateInfo?: UpdateInfo; +}; + +type UpdateInfo = { + latestVersion: string; + link: string; +}; const BottomMarginDiv = styled.div` margin-bottom: 1.5rem; @@ -46,23 +51,85 @@ const BoxShadowBox = styled.div` box-shadow: 0 2px 3px rgba(40, 177, 232, 0.1), 0 0 0 2px rgba(40, 177, 232, 0.2); `; +const NoBottomMarginSubtitle = styled(Subtitle)` + margin-bottom: 0.25rem !important; +`; + const ImageWrapper = styled.div` padding: 0.2rem 0.4rem; `; -class AdminDetails extends React.Component { - render() { - const { loading, t } = this.props; +class AdminDetails extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { + loading: false + }; + } + + componentDidMount() { + const { updateInfoLink } = this.props; + + if (updateInfoLink) { + this.setState({ loading: true }, () => + apiClient + .get(updateInfoLink) + .then(r => r.json()) + .then(updateInfo => this.setState({ updateInfo })) + .then(() => this.setState({ loading: false })) + // ignore errors for this action + .catch(() => this.setState({ loading: false })) + ); + } + } + + renderUpdateInfo() { + const { loading, updateInfo } = this.state; + const { t } = this.props; if (loading) { return ; } + return ( + updateInfo && ( + <> + + + +
+ + ) + ); + } + + render() { + const { version, t } = this.props; + return ( <> <NoBottomMarginSubtitle subtitle={t("admin.info.currentAppVersion")} /> - <BottomMarginDiv>{this.props.version}</BottomMarginDiv> + <BottomMarginDiv>{version}</BottomMarginDiv> + {this.renderUpdateInfo()} <BoxShadowBox className="box"> <article className="media"> <ImageWrapper className="media-left"> @@ -110,8 +177,10 @@ class AdminDetails extends React.Component<Props> { const mapStateToProps = (state: any) => { const version = getAppVersion(state); + const updateInfoLink = getUpdateInfoLink(state); return { - version + version, + updateInfoLink }; }; diff --git a/scm-ui/ui-webapp/src/modules/indexResource.ts b/scm-ui/ui-webapp/src/modules/indexResource.ts index b204e5600f..8244f81f4d 100644 --- a/scm-ui/ui-webapp/src/modules/indexResource.ts +++ b/scm-ui/ui-webapp/src/modules/indexResource.ts @@ -158,6 +158,10 @@ export function getAppVersion(state: object) { return state.indexResources.version; } +export function getUpdateInfoLink(state: object) { + return getLink(state, "updateInfo"); +} + export function getUiPluginsLink(state: object) { return getLink(state, "uiPlugins"); } diff --git a/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedDto.java b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedDto.java new file mode 100644 index 0000000000..2e9139069d --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedDto.java @@ -0,0 +1,80 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.admin; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import sonia.scm.xml.XmlUTCDateAdapter; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import java.util.Date; +import java.util.List; + +@XmlRootElement(name = "rss") +@XmlAccessorType(XmlAccessType.FIELD) +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public final class ReleaseFeedDto { + + @XmlElement(name = "channel") + private Channel channel; + + public Channel getChannel() { + return channel; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @AllArgsConstructor + @NoArgsConstructor + @Getter + @Setter + public static class Channel { + + @XmlElement(name = "item") + private List<Release> releases; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @NoArgsConstructor + @AllArgsConstructor + @Getter + @Setter + public static class Release { + private String title; + private String description; + private String link; + private String guid; + @XmlJavaTypeAdapter(XmlUTCDateAdapter.class) + private Date pubDate; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java new file mode 100644 index 0000000000..26c4c63b94 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseFeedParser.java @@ -0,0 +1,119 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.admin; + +import com.google.common.base.Strings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.net.ahc.AdvancedHttpClient; +import sonia.scm.version.Version; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.io.IOException; +import java.util.Comparator; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +@Singleton +public class ReleaseFeedParser { + + public static final int DEFAULT_TIMEOUT_IN_MILLIS = 1000; + + private static final Logger LOG = LoggerFactory.getLogger(ReleaseFeedParser.class); + + private final AdvancedHttpClient client; + private final ExecutorService executorService; + private final long timeoutInMillis; + private Future<Optional<UpdateInfo>> updateInfoFuture; + + @Inject + public ReleaseFeedParser(AdvancedHttpClient client) { + this(client, DEFAULT_TIMEOUT_IN_MILLIS); + } + + public ReleaseFeedParser(AdvancedHttpClient client, long timeoutInMillis) { + this.client = client; + this.timeoutInMillis = timeoutInMillis; + this.executorService = Executors.newSingleThreadExecutor(); + } + + Optional<UpdateInfo> findLatestRelease(String url) { + Future<Optional<UpdateInfo>> currentUpdateInfoFuture; + boolean updateInfoFutureCreated = false; + try { + synchronized (this) { + currentUpdateInfoFuture = this.updateInfoFuture; + if (currentUpdateInfoFuture == null) { + currentUpdateInfoFuture = submitRequest(url); + this.updateInfoFuture = currentUpdateInfoFuture; + updateInfoFutureCreated = true; + } + } + try { + return currentUpdateInfoFuture.get(timeoutInMillis, TimeUnit.MILLISECONDS); + } catch (Exception e) { + LOG.error("Could not read release feed", e); + return Optional.empty(); + } + } finally { + if (updateInfoFutureCreated) { + synchronized (this) { + this.updateInfoFuture = null; + } + } + } + } + + private Future<Optional<UpdateInfo>> submitRequest(String url) { + return executorService.submit(() -> { + LOG.info("Search for newer versions of SCM-Manager"); + Optional<ReleaseFeedDto.Release> latestRelease = parseLatestReleaseFromRssFeed(url); + return latestRelease.map(release -> new UpdateInfo(release.getTitle(), release.getLink())); + }); + } + + private Optional<ReleaseFeedDto.Release> parseLatestReleaseFromRssFeed(String url) { + try { + if (Strings.isNullOrEmpty(url)) { + return Optional.empty(); + } + ReleaseFeedDto releaseFeed = client.get(url).request().contentFromXml(ReleaseFeedDto.class); + return filterForLatestRelease(releaseFeed); + } catch (Exception e) { + LOG.error("Could not parse release feed from {}", url, e); + return Optional.empty(); + } + } + + private Optional<ReleaseFeedDto.Release> filterForLatestRelease(ReleaseFeedDto releaseFeed) { + return releaseFeed.getChannel().getReleases() + .stream() + .min(Comparator.comparing(release -> Version.parse(release.getTitle().trim()))); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/admin/ReleaseVersionChecker.java b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseVersionChecker.java new file mode 100644 index 0000000000..3999850fa9 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/admin/ReleaseVersionChecker.java @@ -0,0 +1,86 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.admin; + +import com.google.common.annotations.VisibleForTesting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.SCMContextProvider; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.version.Version; + +import javax.inject.Inject; +import java.util.Optional; + +public class ReleaseVersionChecker { + + private static final Logger LOG = LoggerFactory.getLogger(ReleaseVersionChecker.class); + private static final String CACHE_NAME = "sonia.cache.updateInfo"; + + private final ReleaseFeedParser releaseFeedParser; + private final ScmConfiguration scmConfiguration; + private final SCMContextProvider scmContextProvider; + private Cache<String, Optional<UpdateInfo>> cache; + + @Inject + public ReleaseVersionChecker(ReleaseFeedParser releaseFeedParser, ScmConfiguration scmConfiguration, SCMContextProvider scmContextProvider, CacheManager cacheManager) { + this.releaseFeedParser = releaseFeedParser; + this.scmConfiguration = scmConfiguration; + this.scmContextProvider = scmContextProvider; + this.cache = cacheManager.getCache(CACHE_NAME); + } + + @VisibleForTesting + void setCache(Cache<String, Optional<UpdateInfo>> cache) { + this.cache = cache; + } + + public Optional<UpdateInfo> checkForNewerVersion() { + if (cache.contains(scmConfiguration.getReleaseFeedUrl())) { + return cache.get(scmConfiguration.getReleaseFeedUrl()); + } + return findLatestRelease(); + } + + private Optional<UpdateInfo> findLatestRelease() { + String releaseFeedUrl = scmConfiguration.getReleaseFeedUrl(); + Optional<UpdateInfo> latestRelease = releaseFeedParser.findLatestRelease(releaseFeedUrl); + if (latestRelease.isPresent() && isNewerVersion(latestRelease.get())) { + cache.put(scmConfiguration.getReleaseFeedUrl(), latestRelease); + return latestRelease; + } + // we cache that no new version was available to prevent request every time + LOG.debug("No newer version found for SCM-Manager"); + cache.put(scmConfiguration.getReleaseFeedUrl(), Optional.empty()); + return Optional.empty(); + } + + private boolean isNewerVersion(UpdateInfo updateInfo) { + Version versionFromReleaseFeed = Version.parse(updateInfo.getLatestVersion()); + return versionFromReleaseFeed.isNewer(scmContextProvider.getVersion()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/admin/UpdateInfo.java b/scm-webapp/src/main/java/sonia/scm/admin/UpdateInfo.java new file mode 100644 index 0000000000..aeab3a17e1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/admin/UpdateInfo.java @@ -0,0 +1,35 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.admin; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class UpdateInfo { + private final String latestVersion; + private final String link; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminInfoResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminInfoResource.java new file mode 100644 index 0000000000..3802e0788b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminInfoResource.java @@ -0,0 +1,79 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import sonia.scm.admin.UpdateInfo; +import sonia.scm.admin.ReleaseVersionChecker; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import java.util.Optional; + +@OpenAPIDefinition(tags = { + @Tag(name = "AdminInfo", description = "Admin information endpoints") +}) +@Path("v2") +public class AdminInfoResource { + + private final ReleaseVersionChecker checker; + private final UpdateInfoMapper mapper; + + @Inject + public AdminInfoResource(ReleaseVersionChecker checker, UpdateInfoMapper mapper) { + this.checker = checker; + this.mapper = mapper; + } + + /** + * Checks for a newer core version of SCM-Manager. + */ + @GET + @Path("updateInfo") + @Produces(VndMediaType.ADMIN_INFO) + @Operation(summary = "Returns release info.", description = "Returns information about the latest release if a newer version of SCM-Manager is available.", tags = "AdminInfo") + @ApiResponse(responseCode = "200", description = "success") + @ApiResponse(responseCode = "204", description = "no newer version was found") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public UpdateInfoDto getUpdateInfo() { + Optional<UpdateInfo> updateInfo = checker.checkForNewerVersion(); + return updateInfo.map(mapper::map).orElse(null); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index c219d61ad8..bec72a552b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -58,6 +58,7 @@ public class ConfigDto extends HalRepresentation { private boolean enabledXsrfProtection; private String namespaceStrategy; private String loginInfoUrl; + private String releaseFeedUrl; @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index d8c9acc7f6..599af7c6ba 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -102,6 +102,9 @@ public class IndexDtoGenerator extends HalAppenderMapper { } if (ConfigurationPermissions.list().isPermitted()) { builder.single(link("config", resourceLinks.config().self())); + if (!Strings.isNullOrEmpty(configuration.getReleaseFeedUrl())) { + builder.single(link("updateInfo", resourceLinks.adminInfo().updateInfo())); + } } builder.single(link("repositories", resourceLinks.repositoryCollection().self())); builder.single(link("namespaces", resourceLinks.namespaceCollection().self())); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index b6c8e9d8ea..eb54d90684 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -76,6 +76,7 @@ public class MapperModule extends AbstractModule { bind(RepositoryToHalMapper.class).to(Mappers.getMapperClass(RepositoryToRepositoryDtoMapper.class)); bind(BlameResultToBlameDtoMapper.class).to(Mappers.getMapperClass(BlameResultToBlameDtoMapper.class)); + bind(UpdateInfoMapper.class).to(Mappers.getMapperClass(UpdateInfoMapper.class)); // no mapstruct required bind(MeDtoFactory.class); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 99a2058249..546b6c8b52 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -264,6 +264,22 @@ class ResourceLinks { } } + AdminInfoLinks adminInfo() { + return new AdminInfoLinks(scmPathInfoStore.get()); + } + + static class AdminInfoLinks { + private final LinkBuilder adminInfoLinkBuilder; + + AdminInfoLinks(ScmPathInfo pathInfo) { + adminInfoLinkBuilder = new LinkBuilder(pathInfo, AdminInfoResource.class); + } + + String updateInfo() { + return adminInfoLinkBuilder.method("getUpdateInfo").parameters().href(); + } + } + public RepositoryLinks repository() { return new RepositoryLinks(scmPathInfoStore.get()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateInfoDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateInfoDto.java new file mode 100644 index 0000000000..4d8a40197e --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateInfoDto.java @@ -0,0 +1,48 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@AllArgsConstructor +@NoArgsConstructor +@Setter +@Getter +@SuppressWarnings("squid:S2160") // we do not need equals for dto +public class UpdateInfoDto extends HalRepresentation { + private String latestVersion; + private String link; + + UpdateInfoDto(Links links, Embedded embedded) { + super(links, embedded); + } +} + diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateInfoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateInfoMapper.java new file mode 100644 index 0000000000..c7843c122b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateInfoMapper.java @@ -0,0 +1,54 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.Links; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ObjectFactory; +import sonia.scm.admin.UpdateInfo; + +import javax.inject.Inject; + +import static de.otto.edison.hal.Links.linkingTo; + +@Mapper +public abstract class UpdateInfoMapper extends HalAppenderMapper { + + @Inject + private ResourceLinks resourceLinks; + + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes + public abstract UpdateInfoDto map(UpdateInfo updateInfo); + + @ObjectFactory + UpdateInfoDto createDto() { + Links.Builder linksBuilder = linkingTo() + .self(resourceLinks.adminInfo().updateInfo()); + + return new UpdateInfoDto(linksBuilder.build(), Embedded.emptyEmbedded()); + } +} diff --git a/scm-webapp/src/main/resources/config/gcache.xml b/scm-webapp/src/main/resources/config/gcache.xml index 68bc7a2e9d..5ee5e651ab 100644 --- a/scm-webapp/src/main/resources/config/gcache.xml +++ b/scm-webapp/src/main/resources/config/gcache.xml @@ -66,6 +66,16 @@ expireAfterWrite="3600" /> + <!-- + UpdateInfo cache + average: 30K +--> + <cache + name="sonia.cache.updateInfo" + maximumSize="1" + expireAfterWrite="3600" + /> + <!-- Search cache for users average: 0.5K diff --git a/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java new file mode 100644 index 0000000000..590cd905ac --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseFeedParserTest.java @@ -0,0 +1,123 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.admin; + +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.net.ahc.AdvancedHttpClient; + +import java.io.IOException; +import java.util.Date; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReleaseFeedParserTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + AdvancedHttpClient client; + + ReleaseFeedParser releaseFeedParser; + + @BeforeEach + void createSut() { + releaseFeedParser = new ReleaseFeedParser(client, 100); + } + + @Test + void shouldFindLatestRelease() throws IOException { + String url = "https://www.scm-manager.org/download/rss.xml"; + + when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenReturn(createReleaseFeedDto()); + + Optional<UpdateInfo> update = releaseFeedParser.findLatestRelease(url); + + assertThat(update).isPresent(); + assertThat(update.get().getLatestVersion()).isEqualTo("3"); + assertThat(update.get().getLink()).isEqualTo("download-3"); + } + + @Test + void shouldHandleTimeout() throws IOException { + String url = "https://www.scm-manager.org/download/rss.xml"; + + Semaphore waitWithResultUntilTimeout = new Semaphore(0); + + when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { + waitWithResultUntilTimeout.acquire(); + return createReleaseFeedDto(); + }); + + Optional<UpdateInfo> update = releaseFeedParser.findLatestRelease(url); + + waitWithResultUntilTimeout.release(); + + assertThat(update).isEmpty(); + } + + @Test + void shouldNotQueryInParallel() throws IOException, ExecutionException, InterruptedException { + String url = "https://www.scm-manager.org/download/rss.xml"; + + Semaphore waitWithResultUntilBothTriggered = new Semaphore(0); + + when(client.get(url).request().contentFromXml(ReleaseFeedDto.class)).thenAnswer(invocation -> { + waitWithResultUntilBothTriggered.acquire(); + return createReleaseFeedDto(); + }); + + final ExecutorService executorService = Executors.newFixedThreadPool(2); + Future<Optional<UpdateInfo>> update1 = executorService.submit(() -> releaseFeedParser.findLatestRelease(url)); + Future<Optional<UpdateInfo>> update2 = executorService.submit(() -> releaseFeedParser.findLatestRelease(url)); + + waitWithResultUntilBothTriggered.release(2); + + assertThat(update1.get()).containsSame(update2.get().get()); + } + + private ReleaseFeedDto createReleaseFeedDto() { + ReleaseFeedDto.Release release1 = createRelease("1", "download-1", new Date(1000000000L)); + ReleaseFeedDto.Release release2 = createRelease("2", "download-2", new Date(2000000000L)); + ReleaseFeedDto.Release release3 = createRelease("3", "download-3", new Date(3000000000L)); + ReleaseFeedDto.Channel channel = new ReleaseFeedDto.Channel(ImmutableList.of(release1, release2, release3)); + return new ReleaseFeedDto(channel); + } + + private ReleaseFeedDto.Release createRelease(String version, String link, Date date) { + return new ReleaseFeedDto.Release(version, version, link, version, date); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/admin/ReleaseVersionCheckerTest.java b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseVersionCheckerTest.java new file mode 100644 index 0000000000..d50e5de38c --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/admin/ReleaseVersionCheckerTest.java @@ -0,0 +1,102 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.admin; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.SCMContextProvider; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.cache.MapCacheManager; +import sonia.scm.config.ScmConfiguration; + +import java.io.IOException; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReleaseVersionCheckerTest { + + private ReleaseFeedParser feedReader; + private ScmConfiguration scmConfiguration; + private SCMContextProvider contextProvider; + private ReleaseVersionChecker checker; + + @BeforeEach + void setup() { + feedReader = mock(ReleaseFeedParser.class); + scmConfiguration = mock(ScmConfiguration.class); + contextProvider = mock(SCMContextProvider.class); + CacheManager cacheManager = new MapCacheManager(); + + checker = new ReleaseVersionChecker(feedReader, scmConfiguration, contextProvider, cacheManager); + } + + @Test + void shouldReturnEmptyOptional() { + when(scmConfiguration.getReleaseFeedUrl()).thenReturn("releaseFeed"); + when(feedReader.findLatestRelease("releaseFeed")).thenReturn(Optional.empty()); + + Optional<UpdateInfo> updateInfo = checker.checkForNewerVersion(); + + assertThat(updateInfo).isNotPresent(); + } + + @Test + void shouldReturnUpdateInfoFromCache() { + String url = "releaseFeed"; + when(scmConfiguration.getReleaseFeedUrl()).thenReturn(url); + + UpdateInfo cachedUpdateInfo = new UpdateInfo("1.42.9", "download-link"); + Cache<String, Optional<UpdateInfo>> cache = new MapCacheManager().getCache("sonia.cache.updateInfo"); + cache.put(url, Optional.of(cachedUpdateInfo)); + checker.setCache(cache); + + Optional<UpdateInfo> updateInfo = checker.checkForNewerVersion(); + + assertThat(updateInfo).isPresent(); + assertThat(updateInfo.get().getLatestVersion()).isEqualTo("1.42.9"); + assertThat(updateInfo.get().getLink()).isEqualTo("download-link"); + } + + @Test + void shouldReturnUpdateInfo() { + UpdateInfo updateInfo = new UpdateInfo("2.5.0", "download-link"); + when(scmConfiguration.getReleaseFeedUrl()).thenReturn("releaseFeed"); + when(feedReader.findLatestRelease("releaseFeed")).thenReturn(Optional.of(updateInfo)); + when(contextProvider.getVersion()).thenReturn("1.9.0"); + + Optional<UpdateInfo> latestRelease = checker.checkForNewerVersion(); + + assertThat(latestRelease).isPresent(); + assertThat(latestRelease.get().getLatestVersion()).isEqualTo("2.5.0"); + assertThat(latestRelease.get().getLink()).isEqualTo("download-link"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java index f2d6e85710..e7c21f5556 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksMock.java @@ -79,6 +79,7 @@ public class ResourceLinksMock { lenient().when(resourceLinks.namespace()).thenReturn(new ResourceLinks.NamespaceLinks(pathInfo)); lenient().when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(pathInfo)); lenient().when(resourceLinks.namespacePermission()).thenReturn(new ResourceLinks.NamespacePermissionLinks(pathInfo)); + lenient().when(resourceLinks.adminInfo()).thenReturn(new ResourceLinks.AdminInfoLinks(pathInfo)); return resourceLinks; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java index 40b6bb837e..cef98c4062 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java @@ -83,7 +83,6 @@ public class ScmConfigurationToConfigDtoMapperTest { public void shouldMapFields() { ScmConfiguration config = createConfiguration(); - when(subject.isPermitted("configuration:write:global")).thenReturn(true); ConfigDto dto = mapper.map(config); @@ -106,6 +105,7 @@ public class ScmConfigurationToConfigDtoMapperTest { assertTrue(dto.isEnabledXsrfProtection()); assertEquals("username", dto.getNamespaceStrategy()); assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl()); + assertEquals("https://www.scm-manager.org/download/rss.xml", dto.getReleaseFeedUrl()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref()); @@ -159,6 +159,7 @@ public class ScmConfigurationToConfigDtoMapperTest { config.setEnabledXsrfProtection(true); config.setNamespaceStrategy("username"); config.setLoginInfoUrl("https://scm-manager.org/login-info"); + config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml"); return config; } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UpdateInfoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UpdateInfoMapperTest.java new file mode 100644 index 0000000000..7dfa8bda3b --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/UpdateInfoMapperTest.java @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.api.v2.resources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.admin.UpdateInfo; +import sonia.scm.api.v2.resources.UpdateInfoDto; +import sonia.scm.api.v2.resources.UpdateInfoMapper; +import sonia.scm.api.v2.resources.UpdateInfoMapperImpl; +import sonia.scm.api.v2.resources.ResourceLinks; +import sonia.scm.api.v2.resources.ResourceLinksMock; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class UpdateInfoMapperTest { + + private final URI baseUri = URI.create("https://hitchhiker.com/scm/"); + + @SuppressWarnings("unused") // Is injected + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private UpdateInfoMapperImpl mapper; + + @Test + void shouldMapToDto() { + UpdateInfo updateInfo = new UpdateInfo("1.2.3", "download-link"); + UpdateInfoDto dto = mapper.map(updateInfo); + + assertThat(dto.getLink()).isEqualTo(updateInfo.getLink()); + assertThat(dto.getLatestVersion()).isEqualTo(updateInfo.getLatestVersion()); + assertThat(dto.getLinks().getLinkBy("self").get().getHref()).isEqualTo("https://hitchhiker.com/scm/v2/updateInfo"); + } +}