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:
*
* - 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.
* - Should start with a capital letter.
* - 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.
* - Must consist of at least three letters and should start with a capital letter.
* - 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.
*
* @throws IllegalArgumentException if the name does not meet the required rules. The message indicates why not.
*/
@Throws(IllegalArgumentException::class)
fun validateLegalName(normalizedLegalName: String) {
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.
UnicodeRangeRule(LATIN, COMMON, INHERITED),
CapitalLetterRule(),
X500NameRule()
X500NameRule(),
MustHaveAtLeastTwoLettersRule()
)
private class UnicodeNormalizationRule : Rule<String> {
@ -52,9 +58,9 @@ private class UnicodeRangeRule(vararg supportScripts: Character.UnicodeScript) :
require(pattern.matcher(legalName).matches()) {
val illegalChars = legalName.replace(pattern.toRegex(), "").toSet()
if (illegalChars.size > 1) {
"Illegal characters $illegalChars in \"$legalName\"."
"Forbidden characters $illegalChars in \"$legalName\"."
} 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> {
override fun validate(legalName: String) {
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> {
override fun validate(legalName: String) {
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> {
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'" }
}
}
@ -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> {
fun validate(legalName: T)
}

View File

@ -81,19 +81,6 @@ class NodeController(check: atRuntime = ::checkExists) : Controller() {
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 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 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 nearestCity = SimpleObjectProperty(CityDatabase["London"]!!)
val p2pPort = SimpleIntegerProperty()
@ -16,5 +35,13 @@ class NodeData {
val webPort = SimpleIntegerProperty()
val h2Port = SimpleIntegerProperty()
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.utils.FontAwesomeIconFactory
import javafx.application.Platform
import javafx.beans.InvalidationListener
import javafx.geometry.Pos
import javafx.scene.control.ComboBox
import javafx.scene.control.SelectionMode.MULTIPLE
import javafx.scene.image.Image
import javafx.scene.image.ImageView
import javafx.scene.input.KeyCode
@ -14,13 +14,20 @@ import javafx.scene.layout.Pane
import javafx.scene.layout.Priority
import javafx.stage.FileChooser
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.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.ui.CloseableTab
import org.controlsfx.control.CheckListView
import tornadofx.*
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
class NodeTabView : Fragment() {
@ -31,9 +38,27 @@ class NodeTabView : Fragment() {
private companion object : Component() {
const val textWidth = 465.0
const val maxNameLength = 15
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>()
@ -41,7 +66,6 @@ class NodeTabView : Fragment() {
private val chooser = FileChooser()
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 nodeTerminalView = find<NodeTerminalView>()
@ -73,17 +97,34 @@ class NodeTabView : Fragment() {
}
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") {
styleClass.addAll("services-panel")
listview(availableServices.observable()) {
val servicesList = CheckListView(availableServices.observable()).apply {
vboxConstraints { vGrow = Priority.ALWAYS }
selectionModel.selectionMode = MULTIPLE
model.item.extraServices.set(selectionModel.selectedItems)
model.item.extraServices.set(checkModel.checkedItems)
if (!nodeController.hasNetworkMap()) {
checkModel.check(0)
}
}
add(servicesList)
}
}
@ -131,13 +172,15 @@ class NodeTabView : Fragment() {
root.add(nodeConfigView)
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.rpcPort.value = nodeController.nextPort
model.webPort.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.initialDirectory = jvm.dataHome.toFile()
chooser.extensionFilters.add(FileChooser.ExtensionFilter("CorDapps (*.jar)", "*.jar", "*.JAR"))
@ -151,15 +194,11 @@ class NodeTabView : Fragment() {
if (it == null) {
error("Node name is required")
} else {
val name = it.trim()
if (name.isEmpty()) {
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 {
try {
validateLegalName(normaliseLegalName(it))
null
} catch (e: IllegalArgumentException) {
error(e.message)
}
}
}

View File

@ -11,6 +11,7 @@ import javafx.application.Platform
import javafx.embed.swing.SwingNode
import javafx.scene.control.Button
import javafx.scene.control.Label
import javafx.scene.control.ProgressIndicator
import javafx.scene.image.ImageView
import javafx.scene.layout.StackPane
import javafx.scene.layout.HBox
@ -142,10 +143,17 @@ class NodeTerminalView : Fragment() {
return@setOnAction
}
launchWebButton.isDisable = true
val oldLabel = launchWebButton.text
launchWebButton.text = ""
launchWebButton.graphic = ProgressIndicator()
log.info("Starting web server for ${config.legalName}")
webServer.open(config) then {
Platform.runLater { launchWebButton.isDisable = false }
Platform.runLater {
launchWebButton.isDisable = false
launchWebButton.text = oldLabel
launchWebButton.graphic = null
}
} success {
log.info("Web server for ${config.legalName} started on $it")
Platform.runLater {
@ -204,6 +212,8 @@ class NodeTerminalView : Fragment() {
Platform.runLater {
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) {
log.log(Level.WARNING, "Cash balance RPC failed: ${e.message}", e)
}

View File

@ -26,7 +26,7 @@
<Insets left="20" top="15.0"/>
</StackPane.margin>
</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>
<Insets right="25.0" top="10.0" />
</StackPane.margin>

View File

@ -28,7 +28,9 @@ import java.io.IOException
import java.io.Writer
import java.lang.reflect.InvocationTargetException
import java.util.ServiceLoader
import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.ws.rs.core.MediaType
class NodeWebServer(val config: WebServerConfig) {
@ -157,6 +159,8 @@ class NodeWebServer(val config: WebServerConfig) {
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
resourceConfig.addProperties(mapOf(ServerProperties.APPLICATION_NAME to "node.api",
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 {
while (true) {
try {