Webserver: Redirect / to the first static web path.

DemoBench: Misc usability improvements:

- Pre-fill details for some fictional banks when Add Node is pushed.
- Make services a checkbox list rather than one where you have to know how to use the keyboard to do multi-select.
- Make web server launch button spin until server is launched to show activity.
- Suppress an exception that spams the log due to inability to load all the states. It'll get fixed as part of the vault API and serialisation work.
This commit is contained in:
Mike Hearn 2017-04-24 19:03:16 +02:00
parent 35f6de9c50
commit 238d4e29e2
8 changed files with 128 additions and 56 deletions

View File

@ -9,12 +9,17 @@ import javax.security.auth.x500.X500Principal
/** /**
* The validation function will validate the input string using the following rules: * The validation function will validate the input string using the following rules:
*
* - No blacklisted words like "node", "server". * - No blacklisted words like "node", "server".
* - Restrict names to Latin scripts for now to avoid right-to-left issues, debugging issues when we can't pronounce names over the phone, and character confusability attacks. * - Restrict names to Latin scripts for now to avoid right-to-left issues, debugging issues when we can't pronounce
* - Should start with a capital letter. * names over the phone, and character confusability attacks.
* - Must consist of at least three letters and should start with a capital letter.
* - No commas or equals signs. * - No commas or equals signs.
* - No dollars or quote marks, we might need to relax the quote mark constraint in future to handle Irish company names. * - No dollars or quote marks, we might need to relax the quote mark constraint in future to handle Irish company names.
*
* @throws IllegalArgumentException if the name does not meet the required rules. The message indicates why not.
*/ */
@Throws(IllegalArgumentException::class)
fun validateLegalName(normalizedLegalName: String) { fun validateLegalName(normalizedLegalName: String) {
rules.forEach { it.validate(normalizedLegalName) } rules.forEach { it.validate(normalizedLegalName) }
} }
@ -36,7 +41,8 @@ private val rules: List<Rule<String>> = listOf(
// TODO: Implement confusable character detection if we add more scripts. // TODO: Implement confusable character detection if we add more scripts.
UnicodeRangeRule(LATIN, COMMON, INHERITED), UnicodeRangeRule(LATIN, COMMON, INHERITED),
CapitalLetterRule(), CapitalLetterRule(),
X500NameRule() X500NameRule(),
MustHaveAtLeastTwoLettersRule()
) )
private class UnicodeNormalizationRule : Rule<String> { private class UnicodeNormalizationRule : Rule<String> {
@ -52,9 +58,9 @@ private class UnicodeRangeRule(vararg supportScripts: Character.UnicodeScript) :
require(pattern.matcher(legalName).matches()) { require(pattern.matcher(legalName).matches()) {
val illegalChars = legalName.replace(pattern.toRegex(), "").toSet() val illegalChars = legalName.replace(pattern.toRegex(), "").toSet()
if (illegalChars.size > 1) { if (illegalChars.size > 1) {
"Illegal characters $illegalChars in \"$legalName\"." "Forbidden characters $illegalChars in \"$legalName\"."
} else { } else {
"Illegal character $illegalChars in \"$legalName\"." "Forbidden character $illegalChars in \"$legalName\"."
} }
} }
} }
@ -63,7 +69,7 @@ private class UnicodeRangeRule(vararg supportScripts: Character.UnicodeScript) :
private class CharacterRule(vararg val bannedChars: Char) : Rule<String> { private class CharacterRule(vararg val bannedChars: Char) : Rule<String> {
override fun validate(legalName: String) { override fun validate(legalName: String) {
bannedChars.forEach { bannedChars.forEach {
require(!legalName.contains(it, true)) { "Illegal character: $it" } require(!legalName.contains(it, true)) { "Character not allowed in legal names: $it" }
} }
} }
} }
@ -71,7 +77,7 @@ private class CharacterRule(vararg val bannedChars: Char) : Rule<String> {
private class WordRule(vararg val bannedWords: String) : Rule<String> { private class WordRule(vararg val bannedWords: String) : Rule<String> {
override fun validate(legalName: String) { override fun validate(legalName: String) {
bannedWords.forEach { bannedWords.forEach {
require(!legalName.contains(it, ignoreCase = true)) { "Illegal word: $it" } require(!legalName.contains(it, ignoreCase = true)) { "Word not allowed in legal names: $it" }
} }
} }
} }
@ -84,7 +90,7 @@ private class LengthRule(val maxLength: Int) : Rule<String> {
private class CapitalLetterRule : Rule<String> { private class CapitalLetterRule : Rule<String> {
override fun validate(legalName: String) { override fun validate(legalName: String) {
val capitalizedLegalName = legalName.split(" ").map(String::capitalize).joinToString(" ") val capitalizedLegalName = legalName.capitalize()
require(legalName == capitalizedLegalName) { "Legal name should be capitalized. i.e. '$capitalizedLegalName'" } require(legalName == capitalizedLegalName) { "Legal name should be capitalized. i.e. '$capitalizedLegalName'" }
} }
} }
@ -96,6 +102,13 @@ private class X500NameRule : Rule<String> {
} }
} }
private class MustHaveAtLeastTwoLettersRule : Rule<String> {
override fun validate(legalName: String) {
// Try to exclude names like "/", "£", "X" etc.
require(legalName.count { it.isLetter() } >= 3) { "Must have at least two letters" }
}
}
private interface Rule<in T> { private interface Rule<in T> {
fun validate(legalName: T) fun validate(legalName: T)
} }

View File

@ -81,19 +81,6 @@ class NodeController(check: atRuntime = ::checkExists) : Controller() {
val nextPort: Int get() = port.andIncrement val nextPort: Int get() = port.andIncrement
fun isPortAvailable(port: Int): Boolean {
if (isPortValid(port)) {
try {
ServerSocket(port).close()
return true
} catch (e: IOException) {
return false
}
} else {
return false
}
}
fun isPortValid(port: Int) = (port >= minPort) && (port <= maxPort) fun isPortValid(port: Int) = (port >= minPort) && (port <= maxPort)
fun keyExists(key: String) = nodes.keys.contains(key) fun keyExists(key: String) = nodes.keys.contains(key)

View File

@ -7,8 +7,27 @@ import javafx.beans.property.SimpleStringProperty
import net.corda.core.node.CityDatabase import net.corda.core.node.CityDatabase
import tornadofx.* import tornadofx.*
class NodeData { object SuggestedDetails {
val banks = listOf(
// Mike: Rome? Why Rome?
// Roger: Notaries public (also called "notaries", "notarial officers", or "public notaries") hold an office
// which can trace its origins back to the ancient Roman Republic, when they were called scribae ("scribes"),
// tabelliones forenses, or personae publicae.[4]
// Mike: Can't argue with that. It's even got a citation.
"Notary" to "Rome",
"Bank of Breakfast Tea" to "Liverpool",
"Bank of Big Apples" to "New York",
"Bank of Baguettes" to "Paris",
"Bank of Fondue" to "Geneve",
"Bank of Maple Syrup" to "Toronto"
)
private var cursor = 0
val nextBank: Pair<String, String> get() = banks[cursor++ % banks.size]
}
class NodeData {
val legalName = SimpleStringProperty("") val legalName = SimpleStringProperty("")
val nearestCity = SimpleObjectProperty(CityDatabase["London"]!!) val nearestCity = SimpleObjectProperty(CityDatabase["London"]!!)
val p2pPort = SimpleIntegerProperty() val p2pPort = SimpleIntegerProperty()
@ -16,5 +35,13 @@ class NodeData {
val webPort = SimpleIntegerProperty() val webPort = SimpleIntegerProperty()
val h2Port = SimpleIntegerProperty() val h2Port = SimpleIntegerProperty()
val extraServices = SimpleListProperty(mutableListOf<String>().observable()) val extraServices = SimpleListProperty(mutableListOf<String>().observable())
}
class NodeDataModel : ItemViewModel<NodeData>(NodeData()) {
val legalName = bind { item?.legalName }
val nearestCity = bind { item?.nearestCity }
val p2pPort = bind { item?.p2pPort }
val rpcPort = bind { item?.rpcPort }
val webPort = bind { item?.webPort }
val h2Port = bind { item?.h2Port }
} }

View File

@ -1,14 +0,0 @@
package net.corda.demobench.model
import tornadofx.*
class NodeDataModel : ItemViewModel<NodeData>(NodeData()) {
val legalName = bind { item?.legalName }
val nearestCity = bind { item?.nearestCity }
val p2pPort = bind { item?.p2pPort }
val rpcPort = bind { item?.rpcPort }
val webPort = bind { item?.webPort }
val h2Port = bind { item?.h2Port }
}

View File

@ -4,9 +4,9 @@ import de.jensd.fx.glyphs.fontawesome.FontAwesomeIcon
import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView import de.jensd.fx.glyphs.fontawesome.FontAwesomeIconView
import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory import de.jensd.fx.glyphs.fontawesome.utils.FontAwesomeIconFactory
import javafx.application.Platform import javafx.application.Platform
import javafx.beans.InvalidationListener
import javafx.geometry.Pos import javafx.geometry.Pos
import javafx.scene.control.ComboBox import javafx.scene.control.ComboBox
import javafx.scene.control.SelectionMode.MULTIPLE
import javafx.scene.image.Image import javafx.scene.image.Image
import javafx.scene.image.ImageView import javafx.scene.image.ImageView
import javafx.scene.input.KeyCode import javafx.scene.input.KeyCode
@ -14,13 +14,20 @@ import javafx.scene.layout.Pane
import javafx.scene.layout.Priority import javafx.scene.layout.Priority
import javafx.stage.FileChooser import javafx.stage.FileChooser
import javafx.util.StringConverter import javafx.util.StringConverter
import net.corda.core.div
import net.corda.core.exists
import net.corda.core.node.CityDatabase import net.corda.core.node.CityDatabase
import net.corda.core.node.PhysicalLocation import net.corda.core.node.PhysicalLocation
import net.corda.core.utilities.DUMMY_NOTARY import net.corda.core.readAllLines
import net.corda.core.utilities.normaliseLegalName
import net.corda.core.utilities.validateLegalName
import net.corda.core.writeLines
import net.corda.demobench.model.* import net.corda.demobench.model.*
import net.corda.demobench.ui.CloseableTab import net.corda.demobench.ui.CloseableTab
import org.controlsfx.control.CheckListView
import tornadofx.* import tornadofx.*
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.Paths
import java.util.* import java.util.*
class NodeTabView : Fragment() { class NodeTabView : Fragment() {
@ -31,9 +38,27 @@ class NodeTabView : Fragment() {
private companion object : Component() { private companion object : Component() {
const val textWidth = 465.0 const val textWidth = 465.0
const val maxNameLength = 15
val jvm by inject<JVMConfig>() val jvm by inject<JVMConfig>()
val cordappPathsFile = jvm.dataHome / "cordapp-paths.txt"
fun loadDefaultCordappPaths(): MutableList<Path> {
if (cordappPathsFile.exists())
return cordappPathsFile.readAllLines().map { Paths.get(it) }.filter { it.exists() }.toMutableList()
else
return ArrayList()
}
// This is shared between tabs.
private val cordapps = loadDefaultCordappPaths().observable()
init {
// Save when the list is changed.
cordapps.addListener(InvalidationListener {
log.info("Writing cordapp paths to $cordappPathsFile")
cordappPathsFile.writeLines(cordapps.map { it.toAbsolutePath().toString() })
})
}
} }
private val nodeController by inject<NodeController>() private val nodeController by inject<NodeController>()
@ -41,7 +66,6 @@ class NodeTabView : Fragment() {
private val chooser = FileChooser() private val chooser = FileChooser()
private val model = NodeDataModel() private val model = NodeDataModel()
private val cordapps = LinkedList<Path>().observable()
private val availableServices: List<String> = if (nodeController.hasNetworkMap()) serviceController.services else serviceController.notaries private val availableServices: List<String> = if (nodeController.hasNetworkMap()) serviceController.services else serviceController.notaries
private val nodeTerminalView = find<NodeTerminalView>() private val nodeTerminalView = find<NodeTerminalView>()
@ -73,17 +97,34 @@ class NodeTabView : Fragment() {
} }
key.consume() key.consume()
} }
cellCache { item ->
hbox {
label(item.fileName.toString())
pane {
hboxConstraints { hgrow = Priority.ALWAYS }
}
val delete = FontAwesomeIconView(FontAwesomeIcon.MINUS_CIRCLE)
delete.setOnMouseClicked {
cordapps.remove(selectionModel.selectedItem)
}
delete.style += "; -fx-cursor: hand"
addChildIfPossible(delete)
}
}
} }
} }
fieldset("Services") { fieldset("Services") {
styleClass.addAll("services-panel") styleClass.addAll("services-panel")
listview(availableServices.observable()) { val servicesList = CheckListView(availableServices.observable()).apply {
vboxConstraints { vGrow = Priority.ALWAYS } vboxConstraints { vGrow = Priority.ALWAYS }
selectionModel.selectionMode = MULTIPLE model.item.extraServices.set(checkModel.checkedItems)
model.item.extraServices.set(selectionModel.selectedItems) if (!nodeController.hasNetworkMap()) {
checkModel.check(0)
}
} }
add(servicesList)
} }
} }
@ -131,13 +172,15 @@ class NodeTabView : Fragment() {
root.add(nodeConfigView) root.add(nodeConfigView)
root.add(nodeTerminalView) root.add(nodeTerminalView)
model.legalName.value = if (nodeController.hasNetworkMap()) "" else DUMMY_NOTARY.name
model.nearestCity.value = if (nodeController.hasNetworkMap()) null else CityDatabase["London"]!!
model.p2pPort.value = nodeController.nextPort model.p2pPort.value = nodeController.nextPort
model.rpcPort.value = nodeController.nextPort model.rpcPort.value = nodeController.nextPort
model.webPort.value = nodeController.nextPort model.webPort.value = nodeController.nextPort
model.h2Port.value = nodeController.nextPort model.h2Port.value = nodeController.nextPort
val defaults = SuggestedDetails.nextBank
model.legalName.value = defaults.first
model.nearestCity.value = CityDatabase[defaults.second]
chooser.title = "CorDapps" chooser.title = "CorDapps"
chooser.initialDirectory = jvm.dataHome.toFile() chooser.initialDirectory = jvm.dataHome.toFile()
chooser.extensionFilters.add(FileChooser.ExtensionFilter("CorDapps (*.jar)", "*.jar", "*.JAR")) chooser.extensionFilters.add(FileChooser.ExtensionFilter("CorDapps (*.jar)", "*.jar", "*.JAR"))
@ -151,15 +194,11 @@ class NodeTabView : Fragment() {
if (it == null) { if (it == null) {
error("Node name is required") error("Node name is required")
} else { } else {
val name = it.trim() try {
if (name.isEmpty()) { validateLegalName(normaliseLegalName(it))
error("Node name is required")
} else if (nodeController.nameExists(name)) {
error("Node with this name already exists")
} else if (name.length > maxNameLength) {
error("Name is too long")
} else {
null null
} catch (e: IllegalArgumentException) {
error(e.message)
} }
} }
} }

View File

@ -11,6 +11,7 @@ import javafx.application.Platform
import javafx.embed.swing.SwingNode import javafx.embed.swing.SwingNode
import javafx.scene.control.Button import javafx.scene.control.Button
import javafx.scene.control.Label import javafx.scene.control.Label
import javafx.scene.control.ProgressIndicator
import javafx.scene.image.ImageView import javafx.scene.image.ImageView
import javafx.scene.layout.StackPane import javafx.scene.layout.StackPane
import javafx.scene.layout.HBox import javafx.scene.layout.HBox
@ -142,10 +143,17 @@ class NodeTerminalView : Fragment() {
return@setOnAction return@setOnAction
} }
launchWebButton.isDisable = true launchWebButton.isDisable = true
val oldLabel = launchWebButton.text
launchWebButton.text = ""
launchWebButton.graphic = ProgressIndicator()
log.info("Starting web server for ${config.legalName}") log.info("Starting web server for ${config.legalName}")
webServer.open(config) then { webServer.open(config) then {
Platform.runLater { launchWebButton.isDisable = false } Platform.runLater {
launchWebButton.isDisable = false
launchWebButton.text = oldLabel
launchWebButton.graphic = null
}
} success { } success {
log.info("Web server for ${config.legalName} started on $it") log.info("Web server for ${config.legalName} started on $it")
Platform.runLater { Platform.runLater {
@ -204,6 +212,8 @@ class NodeTerminalView : Fragment() {
Platform.runLater { Platform.runLater {
balance.value = if (cashBalances.isNullOrEmpty()) "0" else cashBalances balance.value = if (cashBalances.isNullOrEmpty()) "0" else cashBalances
} }
} catch (e: ClassNotFoundException) {
// TODO: Remove this special case once Rick's serialisation work means we can deserialise states that weren't on our own classpath.
} catch (e: Exception) { } catch (e: Exception) {
log.log(Level.WARNING, "Cash balance RPC failed: ${e.message}", e) log.log(Level.WARNING, "Cash balance RPC failed: ${e.message}", e)
} }

View File

@ -26,7 +26,7 @@
<Insets left="20" top="15.0"/> <Insets left="20" top="15.0"/>
</StackPane.margin> </StackPane.margin>
</ImageView> </ImageView>
<Button fx:id="addNodeButton" mnemonicParsing="false" styleClass="info-button" text="Add Node" StackPane.alignment="TOP_RIGHT"> <Button fx:id="addNodeButton" styleClass="info-button" text="_Add Node" StackPane.alignment="TOP_RIGHT">
<StackPane.margin> <StackPane.margin>
<Insets right="25.0" top="10.0" /> <Insets right="25.0" top="10.0" />
</StackPane.margin> </StackPane.margin>

View File

@ -28,7 +28,9 @@ import java.io.IOException
import java.io.Writer import java.io.Writer
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.util.ServiceLoader import java.util.ServiceLoader
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.ws.rs.core.MediaType import javax.ws.rs.core.MediaType
class NodeWebServer(val config: WebServerConfig) { class NodeWebServer(val config: WebServerConfig) {
@ -157,6 +159,8 @@ class NodeWebServer(val config: WebServerConfig) {
addServlet(staticDir, "/web/${it.first}/*") addServlet(staticDir, "/web/${it.first}/*")
} }
addServlet(ServletHolder(HelpServlet("/web/${staticDirs.first().first}")), "/")
// Give the app a slightly better name in JMX rather than a randomly generated one and enable JMX // Give the app a slightly better name in JMX rather than a randomly generated one and enable JMX
resourceConfig.addProperties(mapOf(ServerProperties.APPLICATION_NAME to "node.api", resourceConfig.addProperties(mapOf(ServerProperties.APPLICATION_NAME to "node.api",
ServerProperties.MONITORING_STATISTICS_MBEANS_ENABLED to "true")) ServerProperties.MONITORING_STATISTICS_MBEANS_ENABLED to "true"))
@ -168,6 +172,12 @@ class NodeWebServer(val config: WebServerConfig) {
} }
} }
private inner class HelpServlet(private val redirectTo: String) : HttpServlet() {
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
resp.sendRedirect(resp.encodeRedirectURL(redirectTo))
}
}
private fun retryConnectLocalRpc(): CordaRPCOps { private fun retryConnectLocalRpc(): CordaRPCOps {
while (true) { while (true) {
try { try {