From 0e63e463c48405a463305830c8f35af0303f2075 Mon Sep 17 00:00:00 2001 From: "CMX\\tiburon" Date: Wed, 24 Jul 2019 11:42:07 +0300 Subject: [PATCH] Initial --- .gitignore | 5 + LICENSE | 26 + README.md | 280 ++++++++- pom.xml | 533 ++++++++++++++++++ .../atlassian/jira/JiraApplicationHelper.java | 160 ++++++ .../atlassian/jira/JiraI18nHelper.java | 59 ++ .../atlassian/jira/JiraUserHelper.java | 75 +++ .../jira/JiraWebAuthenticationHelper.java | 48 ++ .../atlassian/jira/bean/LicenseBean.java | 75 +++ .../atlassian/jira/bean/LicensesBean.java | 44 ++ .../atlassian/jira/bean/SettingsBean.java | 62 ++ .../jira/rest/DirectoriesResource.java | 89 +++ .../atlassian/jira/rest/LicensesResource.java | 76 +++ .../atlassian/jira/rest/SettingsResource.java | 91 +++ .../META-INF/spring/spring-scanner.xml | 10 + src/main/resources/atlassian-plugin.xml | 21 + .../resources/images/logo_octo_375_293.png | Bin 0 -> 48446 bytes 17 files changed, 1653 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 pom.xml create mode 100644 src/main/java/su/tiburon/atlassian/jira/JiraApplicationHelper.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/JiraI18nHelper.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/JiraUserHelper.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/JiraWebAuthenticationHelper.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/bean/LicenseBean.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/bean/LicensesBean.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/bean/SettingsBean.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/rest/DirectoriesResource.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/rest/LicensesResource.java create mode 100644 src/main/java/su/tiburon/atlassian/jira/rest/SettingsResource.java create mode 100644 src/main/resources/META-INF/spring/spring-scanner.xml create mode 100644 src/main/resources/atlassian-plugin.xml create mode 100644 src/main/resources/images/logo_octo_375_293.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d22ad0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.settings +/.vscode +/target +/.classpath +/.project \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a716fd8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2018, ASERVO Software GmbH +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of ASERVO Software GmbH nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md index 195f0dc..36ea031 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,280 @@ -# jira-rest-extender +[![OCTOPUS CodeWare](https://www.tiburon.su/files/logo_octo_text_600_153.png)](https://www.tiburon.su) +REST API Extender for JIRA +================ + +REST API for automated JIRA configuration with URMS + +Related Documentation +--------------------- + +* [Atlassian REST API design guidelines version 1](https://developer.atlassian.com/server/framework/atlassian-sdk/atlassian-rest-api-design-guidelines-version-1/) + +Resources +--------- + +All resources produce JSON (media type: `application/json`) results. + +### Settings + +Access important JIRA settings like the title, the base url, the mode +etc. + +* #### `GET /rest/extender/1/settings` + + Get JIRA application settings. + + __Responses__ + + ![Status 200][status-200] + + ```javascript + { + "baseurl": "http://localhost:2990/jira", + "mode": "private", + "title": "Your Company JIRA" + } + ``` + + ![Status 401][status-401] + + Returned if the current user is not authenticated. + + ![Status 403][status-403] + + Returned if the current user is not an administrator. + +* #### `PUT /rest/extender/1/settings` + + Set JIRA application settings. + + __Request Body__ + + Media type: `application/json` + + Content: Settings, for example: + + ```javascript + { + "baseurl": "http://localhost:2990/jira", + "mode": "private", + "title": "Your Company JIRA" + } + ``` + + __Request Parameters__ + + None. + + __Responses__ + + ![Status 200][status-200] + + Returned if request could be executed without major exceptions. + + The response will contain a list of errors that occurred while setting + some specific values such as a string that was too long, for example: + + ``` + { + "errorMessages": [ + "The length of the application title must not exceed 255 characters" + ], + "errors": {} + } + ``` + + ![Status 401][status-401] + + Returned if the current user is not authenticated. + + ![Status 403][status-403] + + Returned if the current user is not an administrator. + +### Licenses + +The JIRA license API is a bit weird and needs to be well understood. +Just like in the web interface, different license keys can theoretically +be set for each application (JIRA Core, JIRA Software, etc.). However, +the entered license key is always set for all applications for which it +is valid. + +For example: + +1. JIRA Core and JIRA Software each have their own license key. If a +license key is now entered (in the web interface or via the REST API) +with which both applications can be licensed, this license key is stored +for both applications. + +2. JIRA Core and JIRA Software use a common license key. If a license +key is now entered (in the web interface or via the REST API) with which +only JIRA Core can be licensed, the license key is also only stored for +JIRA Core. + +So again, an entered license key is always stored for all applications +for which it is valid. The web interface might suggest that you can +select the desired application, but this is not true. + +* #### `GET /rest/extender/1/licenses` + + Get the license keys together with the application keys of the + applications using this license key. + + __Responses__ + + ![Status 200][status-200] + + ```javascript + { + "licenses": [ + { + "key": "AAA...", + "applicationKeys": [ + "jira-software" + ] + }, + { + "key": "AAA...", + "applicationKeys": [ + "jira-core", + "jira-servicedesk" + ] + } + ] + } + ``` + + ![Status 401][status-401] + + Returned if the current user is not authenticated. + + ![Status 403][status-403] + + Returned if the current user is not an administrator. + +* #### `PUT /rest/extender/1/licenses` + + Set a license by its license key. + + __Request Body__ + + Media type: `text/plain` + + Content: License key, for example: + + ``` + AAA... + ``` + + __Request Parameters__ + + | parameter | type | description | + | ----------- | --------- | ------------------------------------------------------------------------------ | + | `clear` | _boolean_ | Clear all licenses before setting the new license, optional, defaults to false | + + __Responses__ + + ![Status 200][status-200] + + ```javascript + { + "key": "AAA...", + "applicationKeys": [ + "jira-core", + "jira-servicedesk", + "jira-software" + ] + } + ``` + + ![Status 401][status-401] + + Returned if the current user is not authenticated. + + ![Status 403][status-403] + + Returned if the current user is not an administrator. + +### Syncronise Crowd User Directory + +* #### `GET /rest/extender/1/directory` + + Get info about User Directories. + + __Responses__ + + ![Status 200][status-200] + + ```javascript + [ + { + "id": 1, + "name": "Jira Internal Directory", + "active": true, + "createdDate": 1362038271308, + "updatedDate": 1362038271308, + "lowerName": "jira internal directory", + "description": "Jira default internal directory", + "type": "INTERNAL", + "implementationClass": "com.atlassian.crowd.directory.InternalDirectory", + "lowerImplementationClass": "com.atlassian.crowd.directory.internaldirectory", + "allowedOperations":["CREATE_GROUP", "CREATE_ROLE", "CREATE_USER", "DELETE_GROUP", "DELETE_ROLE", "DELETE_USER", "UPDATE_GROUP",…], + "attributes":{ + "user_encryption_method": "atlassian-security" + }, + "empty": false, + "keys":["user_encryption_method"], + "encryptionType": "atlassian-security" + } + ] + ``` + + ![Status 401][status-401] + + Returned if the current user is not authenticated. + + ![Status 403][status-403] + + Returned if the current user is not an administrator. + +* #### `PUT /rest/extender/1/directory` + + Start sycronyse User Directory. + + __Request Parameters__ + + | parameter | type | description | + | --------- | ------ | ----------------------------------------------------- | + | `id` | _long_ | id of the User Directory, which should be syncronised | + + __Responses__ + + ![Status 200][status-200] + + Returned if request could be executed without major exceptions. + + The response will contain a list of errors that occurred while setting + some specific values such as a string that was too long, for example: + + ``` + { + "message": "parameter should not be null!", + "status-code": 500, + "stack-trace": full_stack_trace_of_the_application + } + ``` + ![Status 401][status-401] + + Returned if the current user is not authenticated. + + ![Status 403][status-403] + + Returned if the current user is not an administrator. + + +[status-200]: https://img.shields.io/badge/status-200-brightgreen.svg +[status-400]: https://img.shields.io/badge/status-400-red.svg +[status-401]: https://img.shields.io/badge/status-401-red.svg +[status-403]: https://img.shields.io/badge/status-403-red.svg +[status-404]: https://img.shields.io/badge/status-404-red.svg diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..fadeea6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,533 @@ + + + + 4.0.0 + + su.tiburon.atlassian.jira + rest-extender + 1.1.0 + atlassian-plugin + + REST API extender for JIRA + REST API extender for JIRA. + + + + BSD 3-Clause License + https://opensource.org/licenses/BSD-3-Clause + + + + + Github + https://github.com/tiburon-777/rest-extender/issues + + + + scm:git:git://github.com/tiburon-777/rest-extender.git + scm:git:git@github.com:tiburon-777/rest-extender.git + https://github.com/tiburon-777/rest-extender + + + + OCTOPUS CodeWare + http://www.tiburon.su/ + + + + + Andrey Ivanov + ya@tiburon.su + OCTOPUS CodeWare + https://www.tiburon.su + + + + + + 1.8 + 1.8 + UTF-8 + UTF-8 + + + 8.0.1 + + 7.5.0 + 8.0.2 + 8.0.2 + 6.2.0 + 3.1.0 + 5.0.1 + 2.0.0 + 2.1.5 + 2.10.2-rc04 + 2.15.3 + + + 1.7.9 + 2.4 + 1.1.1 + 2.1 + + + 4.12 + 1.10.19 + 0.8.1 + 4.3.0 + + + + + + + com.atlassian.jira + jira-api + ${jira.version} + provided + + + jta + jta + + + + + + com.atlassian.jira + jira-core + ${jira.version} + provided + + + jndi + jndi + + + jta + jta + + + + + + com.atlassian.jira.plugins + project-templates-api + ${atlassian.project.templates.version} + provided + + + + com.atlassian.jira + jira-rest-api + ${jira.version} + provided + + + + com.atlassian.plugins.rest + atlassian-rest-common + ${atlassian.plugins.rest.version} + provided + + + + com.atlassian.plugins.rest + atlassian-rest-doclet + ${atlassian.plugins.rest.version} + provided + + + + com.atlassian.plugin + atlassian-spring-scanner-annotation + ${atlassian.spring.scanner.version} + provided + + + + com.atlassian.templaterenderer + atlassian-template-renderer-api + 3.0.0 + provided + + + + com.atlassian.sal + sal-api + ${atlassian.sal.version} + provided + + + + com.atlassian.crowd + crowd-api + ${atlassian.crowdapi.version} + provided + + + + com.atlassian.upm + plugin-license-storage-lib + ${upm.license.compatibility.version} + + + + com.atlassian.upm + plugin-license-storage-plugin + ${upm.license.compatibility.version} + provided + + + + com.atlassian.upm + licensing-api + ${upm.license.compatibility.version} + provided + + + + com.atlassian.upm + upm-api + ${upm.license.compatibility.version} + provided + + + + + + org.slf4j + slf4j-api + ${slf4j.version} + provided + + + + javax.ws.rs + jsr311-api + ${javax.ws.version} + provided + + + + javax.xml.bind + jaxb-api + ${javax.xml.version} + provided + + + + + + junit + junit + ${junit.version} + test + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + com.atlassian.jira + jira-tests + ${jira.version} + test + + + + + + + com.atlassian.maven.plugins + jira-maven-plugin + ${atlassian.amps.version} + true + + ${jira.version} + false + false + false + + true + true + false + + false + + + + jira-software + ${jira.version} + + + + + com.atlassian.labs.plugins + quickreload + ${atlassian.quick.reload.version} + + + + ${project.groupId}.${project.artifactId} + * + + org.springframework.*;resolution:="optional", + org.eclipse.gemini.*;resolution:="optional", + sun.misc;resolution:=optional, + * + + + + + + crowd + crowd + 3.1.3 + + + confluence + confluence + 6.8.0 + + + + + + + com.atlassian.plugin + atlassian-spring-scanner-maven-plugin + ${atlassian.spring.scanner.version} + + + + atlassian-spring-scanner + + process-classes + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + + + + org.eluder.coveralls + coveralls-maven-plugin + ${coveralls.version} + + + + + + + sonatype-nexus-snapshots + Sonatype Nexus snapshot repository + https://oss.sonatype.org/content/repositories/snapshots + + + + sonatype-nexus-staging + Sonatype Nexus release staging repository + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + + release + + true + + + + + org.apache.maven.plugins + maven-gpg-plugin + 1.6 + + + sign-artifacts + deploy + + sign + + + + + ${gpg.keyname} + ${gpg.passphrase} + ${gpg.executable} + + + + + org.apache.maven.plugins + maven-deploy-plugin + 2.8.2 + + + true + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.8 + true + + + default-deploy + deploy + + deploy + + + + + sonatype-nexus-staging + https://oss.sonatype.org/ + true + + + + + + + + sources + + true + + + + + org.apache.maven.plugins + maven-source-plugin + 3.0.1 + + + attach-sources + install + + jar-no-fork + + + + + + + + + + javadoc + + true + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.0.1 + + + attach-javadocs + install + + jar + + + + + + + + + + + + atlassian-public + https://packages.atlassian.com/maven/repository/public + + true + never + warn + + + true + warn + + + + + sonatype-oss + https://oss.sonatype.org/content/groups/public + + true + never + warn + + + true + warn + + + + + + + atlassian-public + https://packages.atlassian.com/maven/repository/public + + true + never + warn + + + true + warn + + + + + sonatype-oss + https://oss.sonatype.org/content/groups/public + + true + never + warn + + + true + warn + + + + + diff --git a/src/main/java/su/tiburon/atlassian/jira/JiraApplicationHelper.java b/src/main/java/su/tiburon/atlassian/jira/JiraApplicationHelper.java new file mode 100644 index 0000000..8993c67 --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/JiraApplicationHelper.java @@ -0,0 +1,160 @@ +package su.tiburon.atlassian.jira; + +import com.atlassian.jira.bc.license.JiraLicenseService; +import com.atlassian.jira.bc.license.JiraLicenseService.ValidationResult; +import com.atlassian.jira.config.properties.APKeys; +import com.atlassian.jira.config.properties.ApplicationProperties; +import com.atlassian.jira.license.JiraLicenseManager; +import com.atlassian.jira.license.LicenseDetails; +import com.atlassian.jira.util.UrlValidator; +import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; +import com.google.common.collect.Lists; +import com.opensymphony.util.TextUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import com.atlassian.crowd.embedded.api.CrowdDirectoryService; +import com.atlassian.crowd.embedded.api.Directory; + +import javax.annotation.Nonnull; +import javax.inject.Inject; +import java.util.Collection; +import java.util.List; + +@Component +public class JiraApplicationHelper { + + @ComponentImport + private final ApplicationProperties applicationProperties; + + private final JiraI18nHelper i18nHelper; + + @ComponentImport + private final JiraLicenseManager licenseManager; + + @ComponentImport + private final JiraLicenseService licenseService; + + @ComponentImport + private final CrowdDirectoryService crowdDirectoryService; + + /** + * Constructor. + * + * @param applicationProperties injected {@link ApplicationProperties} + * @param i18nHelper injected {@link JiraI18nHelper} + * @param licenseManager injected {@link JiraLicenseManager} + * @param licenseService injected {@link JiraLicenseService} + * @param crowdDirectoryService injected {@link CrowdDirectoryService} + */ + @Inject + public JiraApplicationHelper( + final ApplicationProperties applicationProperties, + final JiraI18nHelper i18nHelper, + final JiraLicenseManager licenseManager, + final JiraLicenseService licenseService, + final CrowdDirectoryService crowdDirectoryService) { + + this.applicationProperties = applicationProperties; + this.i18nHelper = i18nHelper; + this.licenseManager = licenseManager; + this.licenseService = licenseService; + this.crowdDirectoryService = crowdDirectoryService; + } + + + public void syncDir(@Nonnull final Long dirId ) { + crowdDirectoryService.synchroniseDirectory(dirId); + } + + public List getDir() { + return crowdDirectoryService.findAllDirectories(); + } + + public String getBaseUrl() { + return applicationProperties.getString(APKeys.JIRA_BASEURL); + } + + public void setBaseUrl( + @Nonnull final String baseUrl) { + + if (!UrlValidator.isValid(baseUrl)) { + throw new IllegalArgumentException(i18nHelper.getText("admin.errors.you.must.set.a.valid.base.url")); + } + + applicationProperties.setString(APKeys.JIRA_BASEURL, baseUrl); + } + + public String getMode() { + return applicationProperties.getString(APKeys.JIRA_MODE); + } + + public void setMode( + @Nonnull final String mode) { + + if (!mode.equalsIgnoreCase("public") && !mode.equalsIgnoreCase("private")) { + throw new IllegalArgumentException("Invalid mode"); + } + + if (mode.equalsIgnoreCase("public") && hasExternalUserManagement()) { + throw new IllegalArgumentException(i18nHelper.getText("admin.errors.invalid.mode.externalUM.combination")); + } + + applicationProperties.setString(APKeys.JIRA_MODE, mode); + } + + public String getTitle() { + return applicationProperties.getString(APKeys.JIRA_TITLE); + } + + public void setTitle( + @Nonnull final String title) { + + if (!TextUtils.stringSet(title)) { + throw new IllegalArgumentException(i18nHelper.getText("admin.errors.you.must.set.an.application.title")); + } + + if (StringUtils.length(title) > 255) { + throw new IllegalArgumentException(i18nHelper.getText("admin.errors.invalid.length.of.an.application.title")); + } + + applicationProperties.setString(APKeys.JIRA_TITLE, title); + } + + private boolean hasExternalUserManagement() { + return applicationProperties.getOption(APKeys.JIRA_OPTION_USER_EXTERNALMGT); + } + + /** + * Get all licenses. + * + * @return licenses details + */ + public Collection getLicenses() { + return Lists.newArrayList(licenseManager.getLicenses()); + } + + /** + * Set a new license key and clear all licenses before if wanted. + * + * @param key the license key + * @param clear whether to remove all licenses before setting the new license + * @return license details + */ + public LicenseDetails setLicense( + final String key, + boolean clear) { + + final ValidationResult validationResult = licenseService.validate(i18nHelper.getI18nHelper(), key); + + if (validationResult.getErrorCollection().hasAnyErrors()) { + throw new IllegalArgumentException("Specified license was invalid."); + } + + final String licenseString = validationResult.getLicenseString(); + + return clear + ? licenseManager.clearAndSetLicenseNoEvent(licenseString) + : licenseManager.setLicenseNoEvent(licenseString); + } + +} diff --git a/src/main/java/su/tiburon/atlassian/jira/JiraI18nHelper.java b/src/main/java/su/tiburon/atlassian/jira/JiraI18nHelper.java new file mode 100644 index 0000000..fa76961 --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/JiraI18nHelper.java @@ -0,0 +1,59 @@ +package su.tiburon.atlassian.jira; + +import com.atlassian.jira.user.preferences.PreferenceKeys; +import com.atlassian.jira.util.I18nHelper; +import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; +import com.opensymphony.module.propertyset.PropertySet; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import java.util.Locale; + +@Component +public class JiraI18nHelper { + + @ComponentImport + private final I18nHelper.BeanFactory i18nBeanFactory; + + private final JiraUserHelper userHelper; + + /** + * Constructor. + * + * @param i18nBeanFactory injected {@link I18nHelper.BeanFactory} + * @param userHelper injected {@link JiraUserHelper} + */ + @Inject + public JiraI18nHelper( + final I18nHelper.BeanFactory i18nBeanFactory, + final JiraUserHelper userHelper) { + + this.i18nBeanFactory = i18nBeanFactory; + this.userHelper = userHelper; + } + + public String getText( + final String key) { + + return getI18nHelper().getText(key); + } + + public I18nHelper getI18nHelper() { + return i18nBeanFactory.getInstance(getLocale()); + } + + Locale getLocale() { + final PropertySet userProperties = userHelper.getUserProperties(); + + if (userProperties != null) { + final String locale = userProperties.getString(PreferenceKeys.USER_LOCALE); + + if (locale != null) { + return new Locale(locale); + } + } + + return Locale.getDefault(); + } + +} diff --git a/src/main/java/su/tiburon/atlassian/jira/JiraUserHelper.java b/src/main/java/su/tiburon/atlassian/jira/JiraUserHelper.java new file mode 100644 index 0000000..ce01c97 --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/JiraUserHelper.java @@ -0,0 +1,75 @@ +package su.tiburon.atlassian.jira; + +import com.atlassian.jira.security.JiraAuthenticationContext; +import com.atlassian.jira.user.ApplicationUser; +import com.atlassian.jira.user.UserPropertyManager; +import com.atlassian.jira.user.preferences.UserPreferencesManager; +import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; +import com.opensymphony.module.propertyset.PropertySet; +import org.springframework.stereotype.Component; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +@Component +public class JiraUserHelper { + + @ComponentImport + private final JiraAuthenticationContext authenticationContext; + + @ComponentImport + private final UserPropertyManager userPropertyManager; + + /** + * Constructor. + * + * @param authenticationContext injected {@link JiraAuthenticationContext} + * @param userPropertyManager injected {@link UserPreferencesManager} + */ + @Inject + public JiraUserHelper( + final JiraAuthenticationContext authenticationContext, + final UserPropertyManager userPropertyManager) { + + this.authenticationContext = authenticationContext; + this.userPropertyManager = userPropertyManager; + } + + /** + * Get logged in user. + * + * @return user + */ + @Nullable + public ApplicationUser getLoggedInUser() { + return authenticationContext.getLoggedInUser(); + } + + /** + * Get the property set of the logged in user. + * + * @return property set + */ + @Nullable + public PropertySet getUserProperties() { + return getUserProperties(getLoggedInUser()); + } + + /** + * Get the property set of the given user. + * + * @param user the application user + * @return property set + */ + @Nullable + public PropertySet getUserProperties( + @Nullable final ApplicationUser user) { + + if (user != null) { + return userPropertyManager.getPropertySet(user); + } + + return null; + } + +} diff --git a/src/main/java/su/tiburon/atlassian/jira/JiraWebAuthenticationHelper.java b/src/main/java/su/tiburon/atlassian/jira/JiraWebAuthenticationHelper.java new file mode 100644 index 0000000..d146235 --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/JiraWebAuthenticationHelper.java @@ -0,0 +1,48 @@ +package su.tiburon.atlassian.jira; + +import com.atlassian.jira.security.GlobalPermissionManager; +import com.atlassian.jira.user.ApplicationUser; +import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; + +import static com.atlassian.jira.permission.GlobalPermissionKey.SYSTEM_ADMIN; + +@Component +public class JiraWebAuthenticationHelper { + + @ComponentImport + private final GlobalPermissionManager globalPermissionManager; + + private final JiraUserHelper userHelper; + + /** + * Constructor. + * + * @param userHelper the injected {@link JiraUserHelper} + * @param globalPermissionManager the injected {@link GlobalPermissionManager} + */ + @Inject + public JiraWebAuthenticationHelper( + final GlobalPermissionManager globalPermissionManager, + final JiraUserHelper userHelper) { + + this.globalPermissionManager = globalPermissionManager; + this.userHelper = userHelper; + } + + public void mustBeSysAdmin() { + final ApplicationUser user = userHelper.getLoggedInUser(); + if (user == null) { + throw new WebApplicationException(Response.Status.UNAUTHORIZED); + } + + final boolean isSysAdmin = globalPermissionManager.hasPermission(SYSTEM_ADMIN, user); + if (!isSysAdmin) { + throw new WebApplicationException(Response.Status.FORBIDDEN); + } + } +} diff --git a/src/main/java/su/tiburon/atlassian/jira/bean/LicenseBean.java b/src/main/java/su/tiburon/atlassian/jira/bean/LicenseBean.java new file mode 100644 index 0000000..1de2f62 --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/bean/LicenseBean.java @@ -0,0 +1,75 @@ +package su.tiburon.atlassian.jira.bean; + +import com.atlassian.application.api.ApplicationKey; +import com.atlassian.jira.license.LicenseDetails; + +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.Collection; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Bean for {@link com.atlassian.jira.license.LicenseDetails} results in REST responses. + */ +@XmlRootElement(name = "license") +public class LicenseBean { + + @XmlElement + private final String key; + + @XmlElement + private final Collection applicationKeys; + + private LicenseBean( + final String key, + final Collection applicationKeys) { + + this.key = key; + this.applicationKeys = applicationKeys; + } + + public String getKey() { + return key; + } + + public Collection getApplicationKeys() { + return applicationKeys.stream() + .map(ApplicationKey::valueOf) + .collect(Collectors.toSet()); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof LicenseBean) { + LicenseBean other = (LicenseBean) obj; + + return Objects.equals(key, other.key) + && Objects.equals(applicationKeys, other.applicationKeys); + } + + return false; + } + + @Override + public int hashCode() { + return Objects.hash(key, applicationKeys); + } + + /** + * Factory method for creating a bean from {@link LicenseDetails}. + * + * @param licenseDetail the license details + */ + public static LicenseBean from( + final LicenseDetails licenseDetail) { + + final String key = licenseDetail.getLicenseString(); + final Collection applicationKeys = licenseDetail.getLicensedApplications().getKeys().stream() + .map(ApplicationKey::value) + .collect(Collectors.toSet()); + + return new LicenseBean(key, applicationKeys); + } + +} \ No newline at end of file diff --git a/src/main/java/su/tiburon/atlassian/jira/bean/LicensesBean.java b/src/main/java/su/tiburon/atlassian/jira/bean/LicensesBean.java new file mode 100644 index 0000000..439face --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/bean/LicensesBean.java @@ -0,0 +1,44 @@ +package su.tiburon.atlassian.jira.bean; + +import com.atlassian.jira.license.LicenseDetails; + +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * Bean for collection of {@link LicenseDetails} results in REST responses. + */ +@XmlRootElement(name = "licenses") +public class LicensesBean { + + @XmlElement + private final Collection licenses; + + private LicensesBean( + final Collection licenses) { + + this.licenses = licenses; + } + + public Collection getLicenses() { + return licenses; + } + + /** + * Factory method for creating a bean from a collection of {@link LicenseDetails}. + * + * @param licenseDetails the license details + */ + public static LicensesBean from( + final Collection licenseDetails) { + + final Collection licenses = licenseDetails.stream() + .map(LicenseBean::from) + .collect(Collectors.toList()); + + return new LicensesBean(licenses); + } + +} \ No newline at end of file diff --git a/src/main/java/su/tiburon/atlassian/jira/bean/SettingsBean.java b/src/main/java/su/tiburon/atlassian/jira/bean/SettingsBean.java new file mode 100644 index 0000000..1f072cd --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/bean/SettingsBean.java @@ -0,0 +1,62 @@ +package su.tiburon.atlassian.jira.bean; + +import su.tiburon.atlassian.jira.JiraApplicationHelper; + +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; + +/** + * Bean for setting results in REST responses. + */ +@XmlRootElement(name = "settings") +public class SettingsBean { + + @XmlElement + private final String baseurl; + + @XmlElement + private final String mode; + + @XmlElement + private final String title; + + /** + * The default constructor is needed for JSON request deserialization. + */ + public SettingsBean() { + this.baseurl = null; + this.mode = null; + this.title = null; + } + + public SettingsBean( + final String baseUrl, + final String mode, + final String title) { + + this.baseurl = baseUrl; + this.mode = mode; + this.title = title; + } + + public SettingsBean( + final JiraApplicationHelper applicationHelper) { + + this.baseurl = applicationHelper.getBaseUrl(); + this.mode = applicationHelper.getMode(); + this.title = applicationHelper.getTitle(); + } + + public String getBaseUrl() { + return baseurl; + } + + public String getTitle() { + return title; + } + + public String getMode() { + return mode; + } + +} diff --git a/src/main/java/su/tiburon/atlassian/jira/rest/DirectoriesResource.java b/src/main/java/su/tiburon/atlassian/jira/rest/DirectoriesResource.java new file mode 100644 index 0000000..2d5a073 --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/rest/DirectoriesResource.java @@ -0,0 +1,89 @@ +package su.tiburon.atlassian.jira.rest; + +import com.atlassian.jira.rest.api.util.ErrorCollection; +import com.atlassian.plugins.rest.common.security.AnonymousAllowed; +import su.tiburon.atlassian.jira.JiraApplicationHelper; +import su.tiburon.atlassian.jira.JiraWebAuthenticationHelper; +import org.springframework.stereotype.Component; +import com.atlassian.crowd.embedded.api.Directory; +import org.codehaus.jackson.map.ObjectMapper; + +import java.util.List; +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; + +/** + * Settings resource to get the licenses. + */ +@Path("/directory") +@AnonymousAllowed +@Produces(MediaType.APPLICATION_JSON) +@Component +public class DirectoriesResource { + + private final JiraApplicationHelper applicationHelper; + + private final JiraWebAuthenticationHelper webAuthenticationHelper; + + /** + * Constructor. + * + * @param applicationHelper the injected {@link JiraApplicationHelper} + * @param webAuthenticationHelper the injected {@link JiraWebAuthenticationHelper} + */ + @Inject + public DirectoriesResource( + final JiraApplicationHelper applicationHelper, + final JiraWebAuthenticationHelper webAuthenticationHelper) { + + this.applicationHelper = applicationHelper; + this.webAuthenticationHelper = webAuthenticationHelper; + } + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + public Response syncDir(@QueryParam("id") Long dirId) throws WebApplicationException { + + webAuthenticationHelper.mustBeSysAdmin(); + + final ErrorCollection errorCollection = ErrorCollection.of(); + + if (dirId != null) { + try { + applicationHelper.syncDir(dirId); + } catch (Exception e) { + errorCollection.addErrorMessage(e.getMessage()); + } + } else { + throw new WebApplicationException(Response.Status.BAD_REQUEST); + } + if (errorCollection.hasAnyErrors()) { + return Response.ok(errorCollection).build(); + } else { + return Response.ok(null).build(); + } + } + + @GET + public Response getDir() { + webAuthenticationHelper.mustBeSysAdmin(); + + ObjectMapper mapper = new ObjectMapper(); + final List directories = applicationHelper.getDir(); + String jsn = ""; + try { + jsn = mapper.writeValueAsString(directories); + } catch (Exception e) { + e.printStackTrace(); + } + return Response.ok(jsn).build(); + } +} \ No newline at end of file diff --git a/src/main/java/su/tiburon/atlassian/jira/rest/LicensesResource.java b/src/main/java/su/tiburon/atlassian/jira/rest/LicensesResource.java new file mode 100644 index 0000000..8f2c90d --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/rest/LicensesResource.java @@ -0,0 +1,76 @@ +package su.tiburon.atlassian.jira.rest; + +import com.atlassian.jira.license.LicenseDetails; +import com.atlassian.plugins.rest.common.security.AnonymousAllowed; +import su.tiburon.atlassian.jira.JiraApplicationHelper; +import su.tiburon.atlassian.jira.JiraWebAuthenticationHelper; +import su.tiburon.atlassian.jira.bean.LicenseBean; +import su.tiburon.atlassian.jira.bean.LicensesBean; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import java.util.Collection; + +/** + * Licenses resource to get the licenses. + */ +@Path("/licenses") +@AnonymousAllowed +@Produces(MediaType.APPLICATION_JSON) +@Component +public class LicensesResource { + + private final JiraApplicationHelper applicationHelper; + + private final JiraWebAuthenticationHelper webAuthenticationHelper; + + /** + * Constructor. + * + * @param applicationHelper the injected {@link JiraApplicationHelper} + * @param webAuthenticationHelper the injected {@link JiraWebAuthenticationHelper} + */ + @Inject + public LicensesResource( + final JiraApplicationHelper applicationHelper, + final JiraWebAuthenticationHelper webAuthenticationHelper) { + + this.applicationHelper = applicationHelper; + this.webAuthenticationHelper = webAuthenticationHelper; + } + + @GET + public Response getLicenses() { + webAuthenticationHelper.mustBeSysAdmin(); + + final Collection licenseDetails = applicationHelper.getLicenses(); + return Response.ok(LicensesBean.from(licenseDetails)).build(); + } + + @PUT + @Consumes(MediaType.TEXT_PLAIN) + public Response setLicense( + @QueryParam("clear") @DefaultValue("false") boolean clear, + final String licenseKey) throws WebApplicationException { + + webAuthenticationHelper.mustBeSysAdmin(); + + if (licenseKey == null) { + throw new WebApplicationException(Response.Status.BAD_REQUEST); + } + + final LicenseDetails licenseDetail = applicationHelper.setLicense(licenseKey, clear); + return Response.ok(LicenseBean.from(licenseDetail)).build(); + } + +} diff --git a/src/main/java/su/tiburon/atlassian/jira/rest/SettingsResource.java b/src/main/java/su/tiburon/atlassian/jira/rest/SettingsResource.java new file mode 100644 index 0000000..801cc95 --- /dev/null +++ b/src/main/java/su/tiburon/atlassian/jira/rest/SettingsResource.java @@ -0,0 +1,91 @@ +package su.tiburon.atlassian.jira.rest; + +import com.atlassian.jira.rest.api.util.ErrorCollection; +import com.atlassian.plugins.rest.common.security.AnonymousAllowed; +import su.tiburon.atlassian.jira.JiraApplicationHelper; +import su.tiburon.atlassian.jira.JiraWebAuthenticationHelper; +import su.tiburon.atlassian.jira.bean.SettingsBean; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +/** + * Settings resource to get the licenses. + */ +@Path("/settings") +@AnonymousAllowed +@Produces(MediaType.APPLICATION_JSON) +@Component +public class SettingsResource { + + private final JiraApplicationHelper applicationHelper; + + private final JiraWebAuthenticationHelper webAuthenticationHelper; + + /** + * Constructor. + * + * @param applicationHelper the injected {@link JiraApplicationHelper} + * @param webAuthenticationHelper the injected {@link JiraWebAuthenticationHelper} + */ + @Inject + public SettingsResource( + final JiraApplicationHelper applicationHelper, + final JiraWebAuthenticationHelper webAuthenticationHelper) { + + this.applicationHelper = applicationHelper; + this.webAuthenticationHelper = webAuthenticationHelper; + } + + @GET + public Response getSettings() { + webAuthenticationHelper.mustBeSysAdmin(); + + final SettingsBean settingsBean = new SettingsBean(applicationHelper); + return Response.ok(settingsBean).build(); + } + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + public Response setSettings( + final SettingsBean settingsBean) { + + webAuthenticationHelper.mustBeSysAdmin(); + + final ErrorCollection errorCollection = ErrorCollection.of(); + + if (settingsBean.getBaseUrl() != null) { + try { + applicationHelper.setBaseUrl(settingsBean.getBaseUrl()); + } catch (Exception e) { + errorCollection.addErrorMessage(e.getMessage()); + } + } + + if (settingsBean.getMode() != null) { + try { + applicationHelper.setMode(settingsBean.getMode()); + } catch (Exception e) { + errorCollection.addErrorMessage(e.getMessage()); + } + } + + if (settingsBean.getTitle() != null) { + try { + applicationHelper.setTitle(settingsBean.getTitle()); + } catch (Exception e) { + errorCollection.addErrorMessage(e.getMessage()); + } + } + + return Response.ok(errorCollection).build(); + } + +} diff --git a/src/main/resources/META-INF/spring/spring-scanner.xml b/src/main/resources/META-INF/spring/spring-scanner.xml new file mode 100644 index 0000000..d91305f --- /dev/null +++ b/src/main/resources/META-INF/spring/spring-scanner.xml @@ -0,0 +1,10 @@ + + + + diff --git a/src/main/resources/atlassian-plugin.xml b/src/main/resources/atlassian-plugin.xml new file mode 100644 index 0000000..6f2f630 --- /dev/null +++ b/src/main/resources/atlassian-plugin.xml @@ -0,0 +1,21 @@ + + + ${project.description} + ${project.version} + + images/logo_octo_375_293.png + images/logo_octo_375_293.png + + + + + + com.atlassian.auiplugin:ajs + com.atlassian.auiplugin:aui-experimental-expander + + atl.general + + + + + diff --git a/src/main/resources/images/logo_octo_375_293.png b/src/main/resources/images/logo_octo_375_293.png new file mode 100644 index 0000000000000000000000000000000000000000..543fe87bc1280c6a92e40a73af8e6f41fe4221f3 GIT binary patch literal 48446 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 literal 0 HcmV?d00001