From fed7233c1b511c4f116dd2d8cb31502354a67ee3 Mon Sep 17 00:00:00 2001 From: RedNesto Date: Tue, 26 Nov 2024 23:48:45 +0100 Subject: [PATCH] Fix #2391 Add authorization capability to project creator Supports in-IDE credentials, and sourcing from Maven's settings.xml and Gradle's gradle.properties --- src/main/kotlin/MinecraftConfigurable.kt | 17 +- src/main/kotlin/MinecraftSettings.kt | 22 ++ .../creator/custom/CreatorCredentials.kt | 222 ++++++++++++++++++ .../kotlin/creator/custom/MavenRepoTable.kt | 156 ++++++++++++ .../providers/BuiltinTemplateProvider.kt | 2 +- .../providers/RemoteTemplateProvider.kt | 212 ++++++++++++++++- .../MavenArtifactVersionCreatorProperty.kt | 54 +++-- src/main/kotlin/creator/maven-repo-utils.kt | 9 +- .../messages/MinecraftDevelopment.properties | 24 ++ 9 files changed, 688 insertions(+), 30 deletions(-) create mode 100644 src/main/kotlin/creator/custom/CreatorCredentials.kt create mode 100644 src/main/kotlin/creator/custom/MavenRepoTable.kt diff --git a/src/main/kotlin/MinecraftConfigurable.kt b/src/main/kotlin/MinecraftConfigurable.kt index b75ff0c0c..94838c3ec 100644 --- a/src/main/kotlin/MinecraftConfigurable.kt +++ b/src/main/kotlin/MinecraftConfigurable.kt @@ -22,6 +22,7 @@ package com.demonwav.mcdev import com.demonwav.mcdev.asset.MCDevBundle import com.demonwav.mcdev.asset.PlatformAssets +import com.demonwav.mcdev.creator.custom.mavenRepoTable import com.demonwav.mcdev.creator.custom.templateRepoTable import com.demonwav.mcdev.update.ConfigurePluginUpdatesDialog import com.intellij.ide.projectView.ProjectView @@ -94,16 +95,26 @@ class MinecraftConfigurable : Configurable { } group(MCDevBundle("minecraft.settings.creator")) { - row(MCDevBundle("minecraft.settings.creator.repos")) {} + twoColumnsRow( + { label(MCDevBundle("minecraft.settings.creator.repos")) }, + { label(MCDevBundle("minecraft.settings.creator.maven")) }, + ) - row { + twoColumnsRow({ templateRepoTable( MutableProperty( { settings.creatorTemplateRepos.toMutableList() }, { settings.creatorTemplateRepos = it } ) ) - }.resizableRow() + }, { + mavenRepoTable( + MutableProperty( + { settings.creatorMavenRepos.toMutableList() }, + { settings.creatorMavenRepos = it } + ) + ) + }).resizableRow() } onApply { diff --git a/src/main/kotlin/MinecraftSettings.kt b/src/main/kotlin/MinecraftSettings.kt index 8733f50fb..2fc10fdc8 100644 --- a/src/main/kotlin/MinecraftSettings.kt +++ b/src/main/kotlin/MinecraftSettings.kt @@ -29,6 +29,7 @@ import com.intellij.openapi.editor.markup.EffectType import com.intellij.util.xmlb.annotations.Attribute import com.intellij.util.xmlb.annotations.Tag import com.intellij.util.xmlb.annotations.Text +import com.intellij.util.xmlb.annotations.Transient @State(name = "MinecraftSettings", storages = [Storage("minecraft_dev.xml")]) class MinecraftSettings : PersistentStateComponent { @@ -43,6 +44,7 @@ class MinecraftSettings : PersistentStateComponent { var mixinClassIcon: Boolean = true, var creatorTemplateRepos: List = listOf(TemplateRepo.makeBuiltinRepo()), + var creatorMavenRepos: List = listOf(), ) @Tag("repo") @@ -64,6 +66,20 @@ class MinecraftSettings : PersistentStateComponent { } } + @Tag("maven") + data class MavenRepo( + @get:Attribute("id") + var id: String, + @get:Attribute("url") + var url: String, + @get:Attribute("username") + var username: String, + @get:Transient // Saved in PasswordSafe + var password: String? + ) { + constructor() : this("", "", "", "") + } + private var state = State() override fun getState(): State { @@ -120,6 +136,12 @@ class MinecraftSettings : PersistentStateComponent { state.creatorTemplateRepos = creatorTemplateRepos.map { it.copy() } } + var creatorMavenRepos: List + get() = state.creatorMavenRepos.map { it.copy() } + set(creatorMavenRepos) { + state.creatorMavenRepos = creatorMavenRepos.map { it.copy() } + } + enum class UnderlineType(private val regular: String, val effectType: EffectType) { NORMAL("Normal", EffectType.LINE_UNDERSCORE), diff --git a/src/main/kotlin/creator/custom/CreatorCredentials.kt b/src/main/kotlin/creator/custom/CreatorCredentials.kt new file mode 100644 index 000000000..1ab283bad --- /dev/null +++ b/src/main/kotlin/creator/custom/CreatorCredentials.kt @@ -0,0 +1,222 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.creator.custom + +import com.demonwav.mcdev.MinecraftSettings +import com.demonwav.mcdev.creator.custom.providers.RemoteTemplateProvider.RemoteAuthType +import com.github.kittinunf.fuel.core.Request +import com.github.kittinunf.fuel.core.extensions.authentication +import com.intellij.collaboration.auth.ServerAccount +import com.intellij.collaboration.auth.findAccountOrNull +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe +import git4idea.remote.GitHttpAuthDataProvider +import git4idea.remote.hosting.http.SilentHostedGitHttpAuthDataProvider +import java.nio.file.Path +import java.util.Properties +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.xpath.XPathFactory +import javax.xml.xpath.XPathNodes +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.inputStream + +object CreatorCredentials { + + private val xmlDocumentBuilder = DocumentBuilderFactory.newDefaultInstance().newDocumentBuilder() + private val xPath = XPathFactory.newDefaultInstance().newXPath() + + private fun makeServiceName(url: String): String = generateServiceName("MinecraftDev Creator", url) + + fun persistCredentials( + url: String, + username: String?, + password: String, + ) { + val serviceName = makeServiceName(url) + val credAttrs = CredentialAttributes(serviceName, username) + PasswordSafe.instance.setPassword(credAttrs, password) + } + + fun getCredentials(url: String, username: String?): Credentials? { + val serviceName = makeServiceName(url) + val credAttrs = CredentialAttributes(serviceName, username) + return PasswordSafe.instance[credAttrs] + } + + suspend fun configureAuthorization( + request: Request, + url: String, + authType: RemoteAuthType, + credentials: String + ): Request { + when (authType) { + RemoteAuthType.NONE -> return request + + RemoteAuthType.BASIC -> { + val creds = getCredentials(url, credentials) + val username = creds?.userName + val password = creds?.getPasswordAsString() + if (username != null && password != null) { + return request.authentication().basic(username, password) + } + } + + RemoteAuthType.BEARER -> { + val creds = getCredentials(url, null) + val password = creds?.getPasswordAsString() + if (password != null) { + return request.authentication().bearer(password) + } + } + + RemoteAuthType.GIT_HTTP -> { + val creds = findGitHttpAuthBearerToken(credentials) + if (creds != null) { + return request.authentication().basic(creds.first, creds.second) + } + } + + RemoteAuthType.HEADER -> { + val creds = getCredentials(url, credentials) + val username = creds?.userName + val password = creds?.getPasswordAsString() + if (username != null && password != null) { + return request.header(username, password) + } + } + } + + return request + } + + fun getGitHttpAuthProviders(): List> { + return GitHttpAuthDataProvider.EP_NAME.extensionList + .filterIsInstance>() + } + + fun findGitHttpAuthProvider(providerId: String): SilentHostedGitHttpAuthDataProvider? { + return getGitHttpAuthProviders().find { provider -> provider.providerId == providerId } + } + + fun getGitHttpAuthAccounts(providerId: String): MutableList { + return findGitHttpAuthProvider(providerId)?.accountManager?.accountsState?.value.orEmpty().toMutableList() + } + + fun findGitHttpAuthAccount(credentials: String): ServerAccount? { + val providerId = credentials.substringBefore(':').takeIf(String::isNotBlank) ?: return null + val accountId = credentials.substringAfter(':').takeIf(String::isNotBlank) ?: return null + val accountManager = findGitHttpAuthProvider(providerId)?.accountManager ?: return null + return accountManager.findAccountOrNull { account -> account.id == accountId } + } + + suspend fun findGitHttpAuthBearerToken(credentials: String): Pair? { + val providerId = credentials.substringBefore(':').takeIf(String::isNotBlank) ?: return null + val accountId = credentials.substringAfter(':').takeIf(String::isNotBlank) ?: return null + val accountManager = findGitHttpAuthProvider(providerId)?.accountManager ?: return null + val account = accountManager.findAccountOrNull { account -> account.id == accountId } ?: return null + val token = accountManager.findCredentials(account) ?: return null + return account.name to token + } + + fun findMavenRepoCredentials(url: String): Pair? { + val repoData = MinecraftSettings.instance.creatorMavenRepos.find { url.startsWith(it.url) } + if (repoData == null) { + return null + } + + // First check credentials in IntelliJ IDEA + if (repoData.username.isNotBlank()) { + val credentials = getCredentials(repoData.url, repoData.username) + var username = credentials?.userName + var password = credentials?.getPasswordAsString() + if (username != null && password != null) { + return username to password + } + } + + // If IntelliJ doesn't have them look into the Maven settings, or Gradle properties + val sourcedCredentials = findMavenServerCredentials(repoData.id) ?: findGradleRepoCredentials(repoData.id) + if (sourcedCredentials != null) { + return sourcedCredentials + } + + return null + } + + fun getMavenSettingsPath(): Path { + return Path(System.getProperty("user.home"), ".m2", "settings.xml") + } + + private fun findMavenServerCredentials(serverId: String): Pair? { + val path = getMavenSettingsPath() + if (!path.exists()) { + return null + } + + val document = path.inputStream().use { input -> xmlDocumentBuilder.parse(input) } + val nodes = xPath.evaluateExpression( + "/settings/servers/server/id/text()[.=\"$serverId\"]/ancestor::server/*", + document, + XPathNodes::class.java + ) + + var username: String? = null + var password: String? = null + for (node in nodes) { + when (node.nodeName) { + "username" -> username = node.textContent + "password" -> password = node.textContent + } + } + + if (username != null && password != null) { + return username to password + } + + return null + } + + fun getGradleProperties(): Path { + return System.getenv("GRADLE_USER_HOME")?.let(::Path) + ?: Path(System.getProperty("user.home"), ".gradle", "gradle.properties") + } + + private fun findGradleRepoCredentials(id: String): Pair? { + val path = getGradleProperties() + if (!path.exists()) { + return null + } + + val properties = Properties() + path.inputStream().use(properties::load) + + val username = properties[id + "Username"]?.toString() + val password = properties[id + "Password"]?.toString() + if (username != null && password != null) { + return username to password + } + + return null + } +} diff --git a/src/main/kotlin/creator/custom/MavenRepoTable.kt b/src/main/kotlin/creator/custom/MavenRepoTable.kt new file mode 100644 index 000000000..46f0315cc --- /dev/null +++ b/src/main/kotlin/creator/custom/MavenRepoTable.kt @@ -0,0 +1,156 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2024 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.creator.custom + +import com.demonwav.mcdev.MinecraftSettings +import com.demonwav.mcdev.asset.MCDevBundle +import com.intellij.ide.BrowserUtil +import com.intellij.ide.actions.RevealFileAction +import com.intellij.openapi.util.NlsContexts +import com.intellij.ui.ToolbarDecorator +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.MutableProperty +import com.intellij.ui.dsl.builder.Row +import com.intellij.ui.table.TableView +import com.intellij.util.ui.ColumnInfo +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.ListTableModel +import java.awt.Dimension +import javax.swing.DefaultCellEditor +import javax.swing.JPanel +import javax.swing.table.DefaultTableCellRenderer +import javax.swing.table.TableCellRenderer +import kotlin.reflect.KMutableProperty1 + +private abstract class PropertyColumnInfo( + private val property: KMutableProperty1, + name: @NlsContexts.ColumnName String, + private val tooltip: @NlsContexts.Tooltip String? = null +) : ColumnInfo(name) { + + override fun valueOf(item: I): A = property.get(item) + + override fun setValue(item: I, value: A) = property.set(item, value) + + override fun isCellEditable(item: I): Boolean = true + + override fun getTooltipText(): @NlsContexts.Tooltip String? = tooltip +} + +private object IdColumn : PropertyColumnInfo( + MinecraftSettings.MavenRepo::id, + MCDevBundle("minecraft.settings.creator.maven.column.id"), + MCDevBundle("minecraft.settings.creator.maven.column.id.tooltip"), +) + +private object UrlColumn : PropertyColumnInfo( + MinecraftSettings.MavenRepo::url, + MCDevBundle("minecraft.settings.creator.maven.column.url"), + MCDevBundle("minecraft.settings.creator.maven.column.url.tooltip"), +) + +private object UsernameColumn : PropertyColumnInfo( + MinecraftSettings.MavenRepo::username, + MCDevBundle("minecraft.settings.creator.maven.column.username"), +) + +private object PasswordColumn : PropertyColumnInfo( + MinecraftSettings.MavenRepo::password, + MCDevBundle("minecraft.settings.creator.maven.column.password"), +) { + override fun setValue(item: MinecraftSettings.MavenRepo, value: String?) { + super.setValue(item, value) + getEditor(item).passwordHidden = !value.isNullOrBlank() + } + + override fun getCustomizedRenderer( + o: MinecraftSettings.MavenRepo?, + renderer: TableCellRenderer? + ): TableCellRenderer = PasswordTableCell + + override fun getEditor(item: MinecraftSettings.MavenRepo): PasswordTableCellEditor = PasswordTableCellEditor +} + +@Suppress("JavaIoSerializableObjectMustHaveReadResolve") +private object PasswordTableCellEditor : DefaultCellEditor(JBPasswordField()) { + + var passwordHidden: Boolean + get() = (component as JBPasswordField).emptyText.text.isNotBlank() + set(value) { + (component as JBPasswordField).setPasswordIsStored(value) + } +} + +@Suppress("JavaIoSerializableObjectMustHaveReadResolve") +private object PasswordTableCell : DefaultTableCellRenderer() { + + override fun setValue(value: Any?) { + text = "*".repeat((value as? String)?.length ?: 0) + } +} + +fun Row.mavenRepoTable( + prop: MutableProperty> +): Cell { + val model = + object : ListTableModel(IdColumn, UrlColumn, UsernameColumn, PasswordColumn) { + override fun addRow() { + val defaultName = MCDevBundle("minecraft.settings.creator.maven.default_id") + addRow(MinecraftSettings.MavenRepo(defaultName, "", "", "")) + } + } + val table = TableView(model) + table.setShowGrid(true) + table.tableHeader.reorderingAllowed = false + + val decoratedTable = ToolbarDecorator.createDecorator(table) + .setPreferredSize(Dimension(JBUI.scale(300), JBUI.scale(200))) + .createPanel() + return cell(decoratedTable) + .bind( + { _ -> model.items }, + { _, original -> + val repos = original.toMutableList() + // Need a copy to not affect the original when populating passwords + for (repo in repos) { + if (repo.username.isNotBlank()) { + val credentials = CreatorCredentials.getCredentials(repo.url, repo.username) + repo.password = credentials?.getPasswordAsString() ?: "" + } + } + model.items = repos + }, + prop + ).onApply { + for (repo in model.items) { + if (!repo.password.isNullOrBlank()) { + CreatorCredentials.persistCredentials(repo.url, repo.username, repo.password!!) + } + } + }.comment(MCDevBundle("minecraft.settings.creator.maven.comment")) { event -> + when (event.description) { + "mcdev://maven_settings" -> RevealFileAction.openFile(CreatorCredentials.getMavenSettingsPath()) + "mcdev://gradle_properties" -> RevealFileAction.openFile(CreatorCredentials.getGradleProperties()) + else -> BrowserUtil.browse(event.url) + } + } +} diff --git a/src/main/kotlin/creator/custom/providers/BuiltinTemplateProvider.kt b/src/main/kotlin/creator/custom/providers/BuiltinTemplateProvider.kt index 7a7dc167b..8214a6cc5 100644 --- a/src/main/kotlin/creator/custom/providers/BuiltinTemplateProvider.kt +++ b/src/main/kotlin/creator/custom/providers/BuiltinTemplateProvider.kt @@ -51,7 +51,7 @@ class BuiltinTemplateProvider : RemoteTemplateProvider() { return } - if (doUpdateRepo(indicator, label, builtinRepoUrl)) { + if (doUpdateRepo(indicator, label, builtinRepoUrl, RemoteAuthType.NONE, "")) { repoUpdated = true } } diff --git a/src/main/kotlin/creator/custom/providers/RemoteTemplateProvider.kt b/src/main/kotlin/creator/custom/providers/RemoteTemplateProvider.kt index 6861109b6..9b2af77f1 100644 --- a/src/main/kotlin/creator/custom/providers/RemoteTemplateProvider.kt +++ b/src/main/kotlin/creator/custom/providers/RemoteTemplateProvider.kt @@ -23,10 +23,12 @@ package com.demonwav.mcdev.creator.custom.providers import com.demonwav.mcdev.MinecraftSettings import com.demonwav.mcdev.asset.MCDevBundle import com.demonwav.mcdev.creator.custom.BuiltinValidations +import com.demonwav.mcdev.creator.custom.CreatorCredentials import com.demonwav.mcdev.creator.custom.TemplateDescriptor import com.demonwav.mcdev.creator.modalityState import com.demonwav.mcdev.creator.selectProxy import com.demonwav.mcdev.update.PluginUtil +import com.demonwav.mcdev.util.capitalize import com.demonwav.mcdev.util.refreshSync import com.github.kittinunf.fuel.core.FuelManager import com.github.kittinunf.fuel.coroutines.awaitByteArrayResult @@ -38,20 +40,31 @@ import com.intellij.openapi.application.writeAction import com.intellij.openapi.diagnostic.ControlFlowException import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.observable.properties.PropertyGraph +import com.intellij.openapi.observable.util.equalsTo +import com.intellij.openapi.observable.util.transform import com.intellij.openapi.observable.util.trim import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.util.NlsContexts import com.intellij.openapi.vfs.JarFileSystem +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.EnumComboBoxModel +import com.intellij.ui.MutableCollectionComboBoxModel import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.COLUMNS_LARGE +import com.intellij.ui.dsl.builder.bindItem import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.columns import com.intellij.ui.dsl.builder.panel import com.intellij.ui.dsl.builder.textValidation import com.intellij.util.io.createDirectories +import java.awt.Component import java.nio.file.Path import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JList +import javax.swing.ListCellRenderer import kotlin.io.path.absolutePathString import kotlin.io.path.exists import kotlin.io.path.writeBytes @@ -73,7 +86,7 @@ open class RemoteTemplateProvider : TemplateProvider { continue } - if (doUpdateRepo(indicator, repo.name, remote.url)) { + if (doUpdateRepo(indicator, repo.name, remote.url, remote.authType, remote.authCredentials)) { updatedTemplates.add(remote.url) } } @@ -82,7 +95,9 @@ open class RemoteTemplateProvider : TemplateProvider { protected suspend fun doUpdateRepo( indicator: ProgressIndicator, repoName: String, - originalRepoUrl: String + originalRepoUrl: String, + authType: RemoteAuthType, + credentials: String ): Boolean { indicator.text2 = "Updating remote repository $repoName" @@ -90,11 +105,16 @@ open class RemoteTemplateProvider : TemplateProvider { val manager = FuelManager() manager.proxy = selectProxy(repoUrl) - val result = manager.get(repoUrl) - .header("User-Agent", "github_org/minecraft-dev/${PluginUtil.pluginVersion}") - .header("Accepts", "application/json") + var request = manager.get(repoUrl) + .header("Accept", "application/octet-stream") + .header("User-Agent", PluginUtil.useragent) .timeout(10000) - .awaitByteArrayResult() + + if (credentials.isNotBlank()) { + request = CreatorCredentials.configureAuthorization(request, originalRepoUrl, authType, credentials) + } + + val result = request.awaitByteArrayResult() val data = result.onError { thisLogger().warn("Could not fetch remote templates repository update at $repoUrl", it) @@ -170,6 +190,56 @@ open class RemoteTemplateProvider : TemplateProvider { val autoUpdateProperty = propertyGraph.property(defaultRepo?.autoUpdate != false) val innerPathProperty = propertyGraph.property(defaultRepo?.innerPath ?: "").trim() + val authTypeProperty = propertyGraph.property(defaultRepo?.authType ?: RemoteAuthType.NONE) + + val initialBasicAuthCredentials = + defaultRepo?.authCredentials.takeIf { authTypeProperty.get() == RemoteAuthType.BASIC } + val basicAuthUsernameProperty = propertyGraph.property(initialBasicAuthCredentials ?: "") + val basicAuthPasswordProperty = propertyGraph.property( + CreatorCredentials.getCredentials(urlProperty.get(), basicAuthUsernameProperty.get()) + ?.getPasswordAsString() ?: "" + ) + + val bearerTokenProperty = propertyGraph.property( + CreatorCredentials.getCredentials(urlProperty.get(), null)?.getPasswordAsString() ?: "" + ).trim() + + val initialGitAuthCredentials = + defaultRepo?.authCredentials.takeIf { authTypeProperty.get() == RemoteAuthType.GIT_HTTP } + val gitAuthProviderProperty = propertyGraph.lazyProperty { + initialGitAuthCredentials?.substringBefore(':') + ?: CreatorCredentials.getGitHttpAuthProviders().first().providerId + } + val gitAuthAccountProperty = propertyGraph.lazyProperty { + initialGitAuthCredentials?.let(CreatorCredentials::findGitHttpAuthAccount) + } + val gitAuthAccountsModel = MutableCollectionComboBoxModel( + CreatorCredentials.getGitHttpAuthAccounts(gitAuthProviderProperty.get()) + ) + if (gitAuthAccountProperty.get() == null) { + gitAuthAccountProperty.set(gitAuthAccountsModel.items.firstOrNull()) + } + gitAuthProviderProperty.afterChange { providerId -> + gitAuthAccountsModel.update(CreatorCredentials.getGitHttpAuthAccounts(providerId)) + } + + val initialCustomHeader = + defaultRepo?.authCredentials.takeIf { authTypeProperty.get() == RemoteAuthType.HEADER } + val headerNameProperty = propertyGraph.property(initialCustomHeader?.substringBefore(':') ?: "") + val headerValueProperty = propertyGraph.property( + CreatorCredentials.getCredentials(urlProperty.get(), headerNameProperty.get())?.getPasswordAsString() ?: "" + ) + + val credentialsProperty = authTypeProperty.transform { authType -> + when (authType) { + RemoteAuthType.NONE -> "" + RemoteAuthType.BASIC -> basicAuthUsernameProperty.get() + RemoteAuthType.BEARER -> "" + RemoteAuthType.GIT_HTTP -> gitAuthProviderProperty.get() + ':' + gitAuthAccountProperty.get()?.id + RemoteAuthType.HEADER -> headerNameProperty.get() + } + } + return panel { row(MCDevBundle("creator.ui.custom.remote.url.label")) { textField() @@ -188,21 +258,142 @@ open class RemoteTemplateProvider : TemplateProvider { .bindText(innerPathProperty) } + row(MCDevBundle("creator.ui.custom.remote.auth_type.label")) { + comboBox( + EnumComboBoxModel(RemoteAuthType::class.java), + object : JLabel(), ListCellRenderer { + override fun getListCellRendererComponent( + list: JList?, + value: RemoteAuthType?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component? { + text = value?.displayname?.let(MCDevBundle::invoke) ?: value?.name?.capitalize().toString() + return this + } + }).bindItem(authTypeProperty) + } + + row(MCDevBundle("creator.ui.custom.remote.basic_auth.username.label")) { + textField() + .align(AlignX.FILL) + .columns(COLUMNS_LARGE) + .bindText(basicAuthUsernameProperty) + .addValidationRule("Must not be blank") { field -> + authTypeProperty.get() == RemoteAuthType.BASIC && field.text.isBlank() + } + }.visibleIf(authTypeProperty.equalsTo(RemoteAuthType.BASIC)) + + row(MCDevBundle("creator.ui.custom.remote.basic_auth.password.label")) { + passwordField() + .align(AlignX.FILL) + .columns(COLUMNS_LARGE) + .bindText(basicAuthPasswordProperty) + }.visibleIf(authTypeProperty.equalsTo(RemoteAuthType.BASIC)) + + row(MCDevBundle("creator.ui.custom.remote.bearer_token.label")) { + passwordField() + .align(AlignX.FILL) + .columns(COLUMNS_LARGE) + .bindText(bearerTokenProperty) + .addValidationRule("Must not be blank") { field -> + authTypeProperty.get() == RemoteAuthType.BEARER && field.password.all { it.isWhitespace() } + } + }.visibleIf(authTypeProperty.equalsTo(RemoteAuthType.BEARER)) + + row(MCDevBundle("creator.ui.custom.remote.git_http_provider.label")) { + comboBox(CollectionComboBoxModel(CreatorCredentials.getGitHttpAuthProviders().map { it.providerId })) + .align(AlignX.FILL) + .bindItem(gitAuthProviderProperty) + .validationOnApply { box -> + if (authTypeProperty.get() == RemoteAuthType.GIT_HTTP && box.item == null) { + error("A provider must be selected") + } else null + } + }.visibleIf(authTypeProperty.equalsTo(RemoteAuthType.GIT_HTTP)) + + row(MCDevBundle("creator.ui.custom.remote.git_http_account.label")) { + comboBox(gitAuthAccountsModel) + .align(AlignX.FILL) + .bindItem(gitAuthAccountProperty) + .validationOnApply { box -> + if (authTypeProperty.get() == RemoteAuthType.GIT_HTTP && box.item == null) { + error("An account must be selected") + } else null + } + }.visibleIf(authTypeProperty.equalsTo(RemoteAuthType.GIT_HTTP)) + + row(MCDevBundle("creator.ui.custom.remote.custom_header.name.label")) { + textField() + .align(AlignX.FILL) + .columns(COLUMNS_LARGE) + .bindText(headerNameProperty) + .addValidationRule("Must not be blank") { field -> + authTypeProperty.get() == RemoteAuthType.HEADER && field.text.all { it.isWhitespace() } + } + }.visibleIf(authTypeProperty.equalsTo(RemoteAuthType.HEADER)) + + row(MCDevBundle("creator.ui.custom.remote.custom_header.value.label")) { + passwordField() + .align(AlignX.FILL) + .columns(COLUMNS_LARGE) + .bindText(headerValueProperty) + }.visibleIf(authTypeProperty.equalsTo(RemoteAuthType.HEADER)) + row { checkBox(MCDevBundle("creator.ui.custom.remote.auto_update.label")) .bindSelected(autoUpdateProperty) } onApply { - val repo = RemoteTemplateRepo(urlProperty.get(), autoUpdateProperty.get(), innerPathProperty.get()) + val url = urlProperty.get() + when (authTypeProperty.get()) { + RemoteAuthType.NONE -> Unit + RemoteAuthType.BASIC -> CreatorCredentials.persistCredentials( + url, + basicAuthUsernameProperty.get(), + basicAuthPasswordProperty.get() + ) + + RemoteAuthType.BEARER -> CreatorCredentials.persistCredentials(url, null, bearerTokenProperty.get()) + RemoteAuthType.GIT_HTTP -> Unit + RemoteAuthType.HEADER -> CreatorCredentials.persistCredentials( + url, + headerNameProperty.get(), + headerValueProperty.get() + ) + } + + val repo = RemoteTemplateRepo( + url, + autoUpdateProperty.get(), + innerPathProperty.get(), + authTypeProperty.get(), + credentialsProperty.get() + ) dataSetter(repo.serialize()) } } } - data class RemoteTemplateRepo(val url: String, val autoUpdate: Boolean, val innerPath: String) { + enum class RemoteAuthType(val displayname: @NlsContexts.Label String) { + NONE("minecraft.settings.creator.repos.column.provider.none"), + BASIC("minecraft.settings.creator.repos.column.provider.basic"), + BEARER("minecraft.settings.creator.repos.column.provider.bearer"), + GIT_HTTP("minecraft.settings.creator.repos.column.provider.git_http"), + HEADER("minecraft.settings.creator.repos.column.provider.header") + } + + data class RemoteTemplateRepo( + val url: String, + val autoUpdate: Boolean, + val innerPath: String, + val authType: RemoteAuthType, + val authCredentials: String, + ) { - fun serialize(): String = "$url\n$autoUpdate\n$innerPath" + fun serialize(): String = "$url\n$autoUpdate\n$innerPath\n${authType.name}\n$authCredentials" companion object { @@ -223,6 +414,9 @@ open class RemoteTemplateProvider : TemplateProvider { lines[0], lines.getOrNull(1).toBoolean(), lines.getOrNull(2) ?: "", + runCatching { RemoteAuthType.valueOf(lines.getOrNull(3) ?: "") } + .getOrDefault(RemoteAuthType.NONE), + lines.getOrNull(4) ?: "", ) } } diff --git a/src/main/kotlin/creator/custom/types/MavenArtifactVersionCreatorProperty.kt b/src/main/kotlin/creator/custom/types/MavenArtifactVersionCreatorProperty.kt index 2730b6c53..097094d96 100644 --- a/src/main/kotlin/creator/custom/types/MavenArtifactVersionCreatorProperty.kt +++ b/src/main/kotlin/creator/custom/types/MavenArtifactVersionCreatorProperty.kt @@ -22,18 +22,24 @@ package com.demonwav.mcdev.creator.custom.types import com.demonwav.mcdev.creator.collectMavenVersions import com.demonwav.mcdev.creator.custom.CreatorContext +import com.demonwav.mcdev.creator.custom.CreatorCredentials import com.demonwav.mcdev.creator.custom.TemplateEvaluator import com.demonwav.mcdev.creator.custom.TemplatePropertyDescriptor import com.demonwav.mcdev.creator.custom.TemplateValidationReporter import com.demonwav.mcdev.util.SemanticVersion import com.demonwav.mcdev.util.getOrLogException +import com.github.kittinunf.fuel.core.Request +import com.github.kittinunf.fuel.core.extensions.authentication import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.observable.properties.GraphProperty import com.intellij.ui.ComboboxSpeedSearch +import com.intellij.ui.JBColor import com.intellij.ui.dsl.builder.Panel import com.intellij.ui.dsl.builder.bindItem +import com.intellij.ui.dsl.builder.bindText import com.intellij.util.ui.AsyncProcessIcon import java.util.concurrent.ConcurrentHashMap +import java.util.function.Function import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -50,6 +56,7 @@ class MavenArtifactVersionCreatorProperty( override val graphProperty: GraphProperty = graph.property(SemanticVersion(emptyList())) private val versionsProperty = graph.property>(emptyList()) private val loadingVersionsProperty = graph.property(true) + private val loadingVersionsStatusProperty = graph.property("") override fun buildUi(panel: Panel) { panel.row(descriptor.translatedLabel) { @@ -60,6 +67,9 @@ class MavenArtifactVersionCreatorProperty( cell(AsyncProcessIcon(makeStorageKey("progress"))) .visibleIf(loadingVersionsProperty) + label("").applyToComponent { foreground = JBColor.RED } + .bindText(loadingVersionsStatusProperty) + .visibleIf(loadingVersionsProperty) versionsProperty.afterChange { versions -> combobox.component.removeAllItems() @@ -115,9 +125,13 @@ class MavenArtifactVersionCreatorProperty( rawVersionFilter, versionFilter, descriptor.limit ?: 50 - ) { versions -> - versionsProperty.set(versions) - loadingVersionsProperty.set(false) + ) { result -> + result.onSuccess { versions -> + versionsProperty.set(versions) + loadingVersionsProperty.set(false) + }.onFailure { exception -> + loadingVersionsStatusProperty.set(exception.message ?: exception.javaClass.simpleName) + } } } @@ -132,32 +146,40 @@ class MavenArtifactVersionCreatorProperty( rawVersionFilter: (String) -> Boolean, versionFilter: (SemanticVersion) -> Boolean, limit: Int, - uiCallback: (List) -> Unit + uiCallback: (Result>) -> Unit ) { // Let's not mix up cached versions if different properties // point to the same URL, but have different filters or limits val cacheKey = "$key-$url" val cachedVersions = versionsCache[cacheKey] if (cachedVersions != null) { - uiCallback(cachedVersions) + uiCallback(Result.success(cachedVersions)) return } val scope = context.childScope("MavenArtifactVersionCreatorProperty") scope.launch(Dispatchers.Default) { - val versions = withContext(Dispatchers.IO) { collectMavenVersions(url) } - .asSequence() - .filter(rawVersionFilter) - .mapNotNull(SemanticVersion::tryParse) - .filter(versionFilter) - .sortedDescending() - .take(limit) - .toList() - - versionsCache[cacheKey] = versions + val result = withContext(Dispatchers.IO) { + var requestCustomizer = CreatorCredentials.findMavenRepoCredentials(url)?.let { (user, pass) -> + Function { request -> request.authentication().basic(user, pass) } + } + + runCatching { collectMavenVersions(url, requestCustomizer) } + }.map { result -> + val versions = result.asSequence() + .filter(rawVersionFilter) + .mapNotNull(SemanticVersion::tryParse) + .filter(versionFilter) + .sortedDescending() + .take(limit) + .toList() + + versionsCache[cacheKey] = versions + versions + } withContext(context.uiContext) { - uiCallback(versions) + uiCallback(result) } } } diff --git a/src/main/kotlin/creator/maven-repo-utils.kt b/src/main/kotlin/creator/maven-repo-utils.kt index 901a1a0af..51004a89d 100644 --- a/src/main/kotlin/creator/maven-repo-utils.kt +++ b/src/main/kotlin/creator/maven-repo-utils.kt @@ -22,20 +22,27 @@ package com.demonwav.mcdev.creator import com.demonwav.mcdev.update.PluginUtil import com.github.kittinunf.fuel.core.FuelManager +import com.github.kittinunf.fuel.core.Request import com.github.kittinunf.fuel.core.requests.suspendable import java.io.IOException +import java.util.function.Function import java.util.function.Predicate import javax.xml.stream.XMLInputFactory import javax.xml.stream.events.XMLEvent @Throws(IOException::class) -suspend fun collectMavenVersions(url: String, filter: Predicate = Predicate { true }): List { +suspend fun collectMavenVersions( + url: String, + requestCustomizer: Function? = null, + filter: Predicate = Predicate { true } +): List { val manager = FuelManager() manager.proxy = selectProxy(url) val response = manager.get(url) .header("User-Agent", PluginUtil.useragent) .allowRedirects(true) + .let { requestCustomizer?.apply(it) ?: it } .suspendable() .await() diff --git a/src/main/resources/messages/MinecraftDevelopment.properties b/src/main/resources/messages/MinecraftDevelopment.properties index 6e09278c6..571c0886f 100644 --- a/src/main/resources/messages/MinecraftDevelopment.properties +++ b/src/main/resources/messages/MinecraftDevelopment.properties @@ -49,6 +49,14 @@ creator.ui.custom.remote.url.label=Download URL: creator.ui.custom.remote.url.comment='$version' will be replaced by the template descriptor version currently in use creator.ui.custom.remote.inner_path.label=Inner Path: creator.ui.custom.remote.inner_path.comment='$version' will be replaced by the template descriptor version currently in use +creator.ui.custom.remote.auth_type.label=Authentication: +creator.ui.custom.remote.basic_auth.username.label=Username: +creator.ui.custom.remote.basic_auth.password.label=Password: +creator.ui.custom.remote.bearer_token.label=Bearer Token: +creator.ui.custom.remote.git_http_provider.label=Git HTTP Auth Provider: +creator.ui.custom.remote.git_http_account.label=Git HTTP Account: +creator.ui.custom.remote.custom_header.name.label=Name: +creator.ui.custom.remote.custom_header.value.label=Value: creator.ui.custom.remote.auto_update.label=Auto update creator.ui.warn.no_properties=This template has no properties @@ -267,9 +275,25 @@ minecraft.settings.creator=Creator minecraft.settings.creator.repos=Template Repositories: minecraft.settings.creator.repos.column.name=Name minecraft.settings.creator.repos.column.provider=Provider +minecraft.settings.creator.repos.column.provider.none=None +minecraft.settings.creator.repos.column.provider.basic=Basic +minecraft.settings.creator.repos.column.provider.bearer=Bearer +minecraft.settings.creator.repos.column.provider.git_http=Git HTTP +minecraft.settings.creator.repos.column.provider.header=Custom Header minecraft.settings.creator.repo_config.title={0} Template Repo Configuration minecraft.settings.creator.repo.default_name=My Repo minecraft.settings.creator.repo.builtin_name=Built In +minecraft.settings.creator.maven=Maven Repositories: +minecraft.settings.creator.maven.comment=If Username is blank, credentials are sourced from either:\ +
- ~/.m2/settings.xml servers, see Maven's documentation\ +
- ~/.gradle/gradle.properties, see Gradle's documentation +minecraft.settings.creator.maven.column.id=ID +minecraft.settings.creator.maven.column.id.tooltip=The server ID as it is in the Maven's settings.xml, or Gradle's home gradle.properties. +minecraft.settings.creator.maven.column.url=URL +minecraft.settings.creator.maven.column.url.tooltip=The base URL used to find what credentials to use. +minecraft.settings.creator.maven.column.username=Username +minecraft.settings.creator.maven.column.password=Password +minecraft.settings.creator.maven.default_id=repo-id minecraft.settings.lang_template.display_name=Localization Template minecraft.settings.lang_template.scheme=Scheme: