mirror of
https://github.com/corda/corda.git
synced 2025-06-13 04:38:19 +00:00
Moved webserver to root project.
This commit is contained in:
@ -0,0 +1,34 @@
|
||||
package net.corda.webserver
|
||||
|
||||
import com.google.common.net.HostAndPort
|
||||
import net.corda.core.getOrThrow
|
||||
import net.corda.node.driver.addressMustBeBound
|
||||
import net.corda.node.driver.addressMustNotBeBound
|
||||
import net.corda.node.driver.driver
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class DriverTests {
|
||||
companion object {
|
||||
val executorService = Executors.newScheduledThreadPool(2)
|
||||
|
||||
fun webserverMustBeUp(webserverAddr: HostAndPort) {
|
||||
addressMustBeBound(executorService, webserverAddr)
|
||||
}
|
||||
|
||||
fun webserverMustBeDown(webserverAddr: HostAndPort) {
|
||||
addressMustNotBeBound(executorService, webserverAddr)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starting a node and independent web server works`() {
|
||||
val addr = driver {
|
||||
val node = startNode("test").getOrThrow()
|
||||
val webserverAddr = startWebserver(node).getOrThrow()
|
||||
webserverMustBeUp(webserverAddr)
|
||||
webserverAddr
|
||||
}
|
||||
webserverMustBeDown(addr)
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
package net.corda.webserver
|
||||
|
||||
import com.typesafe.config.Config
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import com.typesafe.config.ConfigParseOptions
|
||||
import com.typesafe.config.ConfigRenderOptions
|
||||
import joptsimple.OptionParser
|
||||
import joptsimple.util.EnumConverter
|
||||
import net.corda.core.div
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import org.slf4j.event.Level
|
||||
import java.io.PrintStream
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
|
||||
// NOTE: Do not use any logger in this class as args parsing is done before the logger is setup.
|
||||
class ArgsParser {
|
||||
private val optionParser = OptionParser()
|
||||
// The intent of allowing a command line configurable directory and config path is to allow deployment flexibility.
|
||||
// Other general configuration should live inside the config file unless we regularly need temporary overrides on the command line
|
||||
private val baseDirectoryArg = optionParser
|
||||
.accepts("base-directory", "The node working directory where all the files are kept")
|
||||
.withRequiredArg()
|
||||
.defaultsTo(".")
|
||||
private val configFileArg = optionParser
|
||||
.accepts("config-file", "The path to the config file")
|
||||
.withRequiredArg()
|
||||
.defaultsTo("node.conf")
|
||||
private val loggerLevel = optionParser
|
||||
.accepts("logging-level", "Enable logging at this level and higher")
|
||||
.withRequiredArg()
|
||||
.withValuesConvertedBy(object : EnumConverter<Level>(Level::class.java) {})
|
||||
.defaultsTo(Level.INFO)
|
||||
private val logToConsoleArg = optionParser.accepts("log-to-console", "If set, prints logging to the console as well as to a file.")
|
||||
private val helpArg = optionParser.accepts("help").forHelp()
|
||||
|
||||
fun parse(vararg args: String): CmdLineOptions {
|
||||
val optionSet = optionParser.parse(*args)
|
||||
require(!optionSet.has(baseDirectoryArg) || !optionSet.has(configFileArg)) {
|
||||
"${baseDirectoryArg.options()[0]} and ${configFileArg.options()[0]} cannot be specified together"
|
||||
}
|
||||
val baseDirectory = Paths.get(optionSet.valueOf(baseDirectoryArg)).normalize().toAbsolutePath()
|
||||
val configFile = baseDirectory / optionSet.valueOf(configFileArg)
|
||||
val help = optionSet.has(helpArg)
|
||||
val loggingLevel = optionSet.valueOf(loggerLevel)
|
||||
val logToConsole = optionSet.has(logToConsoleArg)
|
||||
return CmdLineOptions(baseDirectory, configFile, help, loggingLevel, logToConsole)
|
||||
}
|
||||
|
||||
fun printHelp(sink: PrintStream) = optionParser.printHelpOn(sink)
|
||||
}
|
||||
|
||||
data class CmdLineOptions(val baseDirectory: Path,
|
||||
val configFile: Path,
|
||||
val help: Boolean,
|
||||
val loggingLevel: Level,
|
||||
val logToConsole: Boolean) {
|
||||
|
||||
private val log = loggerFor<CmdLineOptions>()
|
||||
|
||||
fun loadConfig(allowMissingConfig: Boolean = false, configOverrides: Map<String, Any?> = emptyMap()): Config {
|
||||
return loadConfig(baseDirectory, configFile, allowMissingConfig, configOverrides)
|
||||
}
|
||||
|
||||
private fun loadConfig(baseDirectory: Path,
|
||||
configFile: Path = baseDirectory / "node.conf",
|
||||
allowMissingConfig: Boolean = false,
|
||||
configOverrides: Map<String, Any?> = emptyMap()): Config {
|
||||
val parseOptions = ConfigParseOptions.defaults()
|
||||
val defaultConfig = ConfigFactory.parseResources("web-reference.conf", parseOptions.setAllowMissing(false))
|
||||
val appConfig = ConfigFactory.parseFile(configFile.toFile(), parseOptions.setAllowMissing(allowMissingConfig))
|
||||
val overrideConfig = ConfigFactory.parseMap(configOverrides + mapOf(
|
||||
// Add substitution values here
|
||||
"basedir" to baseDirectory.toString())
|
||||
)
|
||||
val finalConfig = overrideConfig
|
||||
.withFallback(appConfig)
|
||||
.withFallback(defaultConfig)
|
||||
.resolve()
|
||||
log.info("Config:\n${finalConfig.root().render(ConfigRenderOptions.defaults())}")
|
||||
return finalConfig
|
||||
}
|
||||
}
|
69
webserver/src/main/kotlin/net/corda/webserver/WebServer.kt
Normal file
69
webserver/src/main/kotlin/net/corda/webserver/WebServer.kt
Normal file
@ -0,0 +1,69 @@
|
||||
@file:JvmName("WebServer")
|
||||
|
||||
package net.corda.webserver
|
||||
|
||||
import com.typesafe.config.ConfigException
|
||||
import net.corda.core.div
|
||||
import net.corda.core.rootCause
|
||||
import net.corda.webserver.internal.NodeWebServer
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.lang.management.ManagementFactory
|
||||
import java.net.InetAddress
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
val argsParser = ArgsParser()
|
||||
|
||||
val cmdlineOptions = try {
|
||||
argsParser.parse(*args)
|
||||
} catch (ex: Exception) {
|
||||
println("Unknown command line arguments: ${ex.message}")
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
// Maybe render command line help.
|
||||
if (cmdlineOptions.help) {
|
||||
argsParser.printHelp(System.out)
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
// Set up logging.
|
||||
if (cmdlineOptions.logToConsole) {
|
||||
// This property is referenced from the XML config file.
|
||||
System.setProperty("consoleLogLevel", "info")
|
||||
}
|
||||
|
||||
System.setProperty("log-path", (cmdlineOptions.baseDirectory / "web/logs").toString())
|
||||
val log = LoggerFactory.getLogger("Main")
|
||||
println("Logs can be found in ${System.getProperty("log-path")}")
|
||||
|
||||
val conf = try {
|
||||
WebServerConfig(cmdlineOptions.baseDirectory, cmdlineOptions.loadConfig())
|
||||
} catch (e: ConfigException) {
|
||||
println("Unable to load the configuration file: ${e.rootCause.message}")
|
||||
exitProcess(2)
|
||||
}
|
||||
|
||||
log.info("Main class: ${WebServerConfig::class.java.protectionDomain.codeSource.location.toURI().path}")
|
||||
val info = ManagementFactory.getRuntimeMXBean()
|
||||
log.info("CommandLine Args: ${info.inputArguments.joinToString(" ")}")
|
||||
log.info("Application Args: ${args.joinToString(" ")}")
|
||||
log.info("bootclasspath: ${info.bootClassPath}")
|
||||
log.info("classpath: ${info.classPath}")
|
||||
log.info("VM ${info.vmName} ${info.vmVendor} ${info.vmVersion}")
|
||||
log.info("Machine: ${InetAddress.getLocalHost().hostName}")
|
||||
log.info("Working Directory: ${cmdlineOptions.baseDirectory}")
|
||||
log.info("Starting as webserver on ${conf.webAddress}")
|
||||
|
||||
try {
|
||||
val server = NodeWebServer(conf)
|
||||
server.start()
|
||||
val elapsed = (System.currentTimeMillis() - startTime) / 10 / 100.0
|
||||
println("Webserver started up in $elapsed sec")
|
||||
server.run()
|
||||
} catch (e: Exception) {
|
||||
log.error("Exception during node startup", e)
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package net.corda.webserver
|
||||
|
||||
import com.google.common.net.HostAndPort
|
||||
import com.typesafe.config.Config
|
||||
import net.corda.core.div
|
||||
import net.corda.nodeapi.User
|
||||
import net.corda.nodeapi.config.SSLConfiguration
|
||||
import net.corda.nodeapi.config.getListOrElse
|
||||
import net.corda.nodeapi.config.getValue
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* [baseDirectory] is not retrieved from the config file but rather from a command line argument.
|
||||
*/
|
||||
class WebServerConfig(val baseDirectory: Path, val config: Config) : SSLConfiguration {
|
||||
override val certificatesDirectory: Path get() = baseDirectory / "certificates"
|
||||
override val keyStorePassword: String by config
|
||||
override val trustStorePassword: String by config
|
||||
val exportJMXto: String get() = "http"
|
||||
val useHTTPS: Boolean by config
|
||||
val p2pAddress: HostAndPort by config // TODO: Use RPC port instead of P2P port (RPC requires authentication, P2P does not)
|
||||
val webAddress: HostAndPort by config
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
package net.corda.webserver.api
|
||||
|
||||
import net.corda.core.node.NodeInfo
|
||||
import java.time.LocalDateTime
|
||||
import javax.ws.rs.GET
|
||||
import javax.ws.rs.Path
|
||||
import javax.ws.rs.Produces
|
||||
import javax.ws.rs.core.MediaType
|
||||
import javax.ws.rs.core.Response
|
||||
|
||||
/**
|
||||
* Top level interface to external interaction with the distributed ledger.
|
||||
*
|
||||
* Wherever a list is returned by a fetchXXX method that corresponds with an input list, that output list will have optional elements
|
||||
* where a null indicates "missing" and the elements returned will be in the order corresponding with the input list.
|
||||
*
|
||||
*/
|
||||
@Path("")
|
||||
interface APIServer {
|
||||
/**
|
||||
* Report current UTC time as understood by the platform.
|
||||
*/
|
||||
@GET
|
||||
@Path("servertime")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
fun serverTime(): LocalDateTime
|
||||
|
||||
/**
|
||||
* Report whether this node is started up or not.
|
||||
*/
|
||||
@GET
|
||||
@Path("status")
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
fun status(): Response
|
||||
|
||||
/**
|
||||
* Report this node's configuration and identities.
|
||||
*/
|
||||
@GET
|
||||
@Path("info")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
fun info(): NodeInfo
|
||||
}
|
21
webserver/src/main/kotlin/net/corda/webserver/api/Query.kt
Normal file
21
webserver/src/main/kotlin/net/corda/webserver/api/Query.kt
Normal file
@ -0,0 +1,21 @@
|
||||
package net.corda.webserver.api
|
||||
|
||||
/**
|
||||
* Extremely rudimentary query language which should most likely be replaced with a product.
|
||||
*/
|
||||
interface StatesQuery {
|
||||
companion object {
|
||||
fun select(criteria: Criteria): Selection {
|
||||
return Selection(criteria)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO make constructors private
|
||||
data class Selection(val criteria: Criteria) : StatesQuery
|
||||
|
||||
interface Criteria {
|
||||
object AllDeals : Criteria
|
||||
|
||||
data class Deal(val ref: String) : Criteria
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package net.corda.webserver.internal
|
||||
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.webserver.api.*
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import javax.ws.rs.core.Response
|
||||
|
||||
class APIServerImpl(val rpcOps: CordaRPCOps) : APIServer {
|
||||
|
||||
override fun serverTime(): LocalDateTime {
|
||||
return LocalDateTime.ofInstant(rpcOps.currentNodeTime(), ZoneId.of("UTC"))
|
||||
}
|
||||
|
||||
/**
|
||||
* This endpoint is for polling if the webserver is serving. It will always return 200.
|
||||
*/
|
||||
override fun status(): Response {
|
||||
return Response.ok("started").build()
|
||||
}
|
||||
|
||||
override fun info() = rpcOps.nodeIdentity()
|
||||
}
|
@ -0,0 +1,186 @@
|
||||
package net.corda.webserver.internal
|
||||
|
||||
import net.corda.client.rpc.CordaRPCClient
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.node.CordaPluginRegistry
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import net.corda.nodeapi.ArtemisMessagingComponent
|
||||
import net.corda.webserver.WebServerConfig
|
||||
import net.corda.webserver.servlets.AttachmentDownloadServlet
|
||||
import net.corda.webserver.servlets.DataUploadServlet
|
||||
import net.corda.webserver.servlets.ObjectMapperConfig
|
||||
import net.corda.webserver.servlets.ResponseFilter
|
||||
import org.apache.activemq.artemis.api.core.ActiveMQNotConnectedException
|
||||
import org.eclipse.jetty.server.*
|
||||
import org.eclipse.jetty.server.handler.HandlerCollection
|
||||
import org.eclipse.jetty.servlet.DefaultServlet
|
||||
import org.eclipse.jetty.servlet.ServletContextHandler
|
||||
import org.eclipse.jetty.servlet.ServletHolder
|
||||
import org.eclipse.jetty.util.ssl.SslContextFactory
|
||||
import org.eclipse.jetty.webapp.WebAppContext
|
||||
import org.glassfish.jersey.server.ResourceConfig
|
||||
import org.glassfish.jersey.server.ServerProperties
|
||||
import org.glassfish.jersey.servlet.ServletContainer
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.*
|
||||
|
||||
class NodeWebServer(val config: WebServerConfig) {
|
||||
private companion object {
|
||||
val log = loggerFor<NodeWebServer>()
|
||||
val retryDelay = 1000L // Milliseconds
|
||||
}
|
||||
|
||||
val address = config.webAddress
|
||||
private var renderBasicInfoToConsole = true
|
||||
private lateinit var server: Server
|
||||
|
||||
fun start() {
|
||||
logAndMaybePrint("Starting as webserver: ${config.webAddress}")
|
||||
server = initWebServer(retryConnectLocalRpc())
|
||||
}
|
||||
|
||||
fun run() {
|
||||
while (server.isRunning) {
|
||||
Thread.sleep(100) // TODO: Redesign
|
||||
}
|
||||
}
|
||||
|
||||
private fun initWebServer(localRpc: CordaRPCOps): Server {
|
||||
// Note that the web server handlers will all run concurrently, and not on the node thread.
|
||||
val handlerCollection = HandlerCollection()
|
||||
|
||||
// Export JMX monitoring statistics and data over REST/JSON.
|
||||
if (config.exportJMXto.split(',').contains("http")) {
|
||||
val classpath = System.getProperty("java.class.path").split(System.getProperty("path.separator"))
|
||||
val warpath = classpath.firstOrNull { it.contains("jolokia-agent-war-2") && it.endsWith(".war") }
|
||||
if (warpath != null) {
|
||||
handlerCollection.addHandler(WebAppContext().apply {
|
||||
// Find the jolokia WAR file on the classpath.
|
||||
contextPath = "/monitoring/json"
|
||||
setInitParameter("mimeType", "application/json")
|
||||
war = warpath
|
||||
})
|
||||
} else {
|
||||
log.warn("Unable to locate Jolokia WAR on classpath")
|
||||
}
|
||||
}
|
||||
|
||||
// API, data upload and download to services (attachments, rates oracles etc)
|
||||
handlerCollection.addHandler(buildServletContextHandler(localRpc))
|
||||
|
||||
val server = Server()
|
||||
|
||||
val connector = if (config.useHTTPS) {
|
||||
val httpsConfiguration = HttpConfiguration()
|
||||
httpsConfiguration.outputBufferSize = 32768
|
||||
httpsConfiguration.addCustomizer(SecureRequestCustomizer())
|
||||
val sslContextFactory = SslContextFactory()
|
||||
sslContextFactory.keyStorePath = config.keyStoreFile.toString()
|
||||
sslContextFactory.setKeyStorePassword(config.keyStorePassword)
|
||||
sslContextFactory.setKeyManagerPassword(config.keyStorePassword)
|
||||
sslContextFactory.setTrustStorePath(config.trustStoreFile.toString())
|
||||
sslContextFactory.setTrustStorePassword(config.trustStorePassword)
|
||||
sslContextFactory.setExcludeProtocols("SSL.*", "TLSv1", "TLSv1.1")
|
||||
sslContextFactory.setIncludeProtocols("TLSv1.2")
|
||||
sslContextFactory.setExcludeCipherSuites(".*NULL.*", ".*RC4.*", ".*MD5.*", ".*DES.*", ".*DSS.*")
|
||||
sslContextFactory.setIncludeCipherSuites(".*AES.*GCM.*")
|
||||
val sslConnector = ServerConnector(server, SslConnectionFactory(sslContextFactory, "http/1.1"), HttpConnectionFactory(httpsConfiguration))
|
||||
sslConnector.port = address.port
|
||||
sslConnector
|
||||
} else {
|
||||
val httpConfiguration = HttpConfiguration()
|
||||
httpConfiguration.outputBufferSize = 32768
|
||||
val httpConnector = ServerConnector(server, HttpConnectionFactory(httpConfiguration))
|
||||
httpConnector.port = address.port
|
||||
httpConnector
|
||||
}
|
||||
server.connectors = arrayOf<Connector>(connector)
|
||||
|
||||
server.handler = handlerCollection
|
||||
server.start()
|
||||
log.info("Starting webserver on address $address")
|
||||
return server
|
||||
}
|
||||
|
||||
private fun buildServletContextHandler(localRpc: CordaRPCOps): ServletContextHandler {
|
||||
return ServletContextHandler().apply {
|
||||
contextPath = "/"
|
||||
setAttribute("rpc", localRpc)
|
||||
addServlet(DataUploadServlet::class.java, "/upload/*")
|
||||
addServlet(AttachmentDownloadServlet::class.java, "/attachments/*")
|
||||
|
||||
val resourceConfig = ResourceConfig()
|
||||
resourceConfig.register(ObjectMapperConfig(localRpc))
|
||||
resourceConfig.register(ResponseFilter())
|
||||
resourceConfig.register(APIServerImpl(localRpc))
|
||||
|
||||
val webAPIsOnClasspath = pluginRegistries.flatMap { x -> x.webApis }
|
||||
for (webapi in webAPIsOnClasspath) {
|
||||
log.info("Add plugin web API from attachment $webapi")
|
||||
val customAPI = try {
|
||||
webapi.apply(localRpc)
|
||||
} catch (ex: InvocationTargetException) {
|
||||
log.error("Constructor $webapi threw an error: ", ex.targetException)
|
||||
continue
|
||||
}
|
||||
resourceConfig.register(customAPI)
|
||||
}
|
||||
|
||||
val staticDirMaps = pluginRegistries.map { x -> x.staticServeDirs }
|
||||
val staticDirs = staticDirMaps.flatMap { it.keys }.zip(staticDirMaps.flatMap { it.values })
|
||||
staticDirs.forEach {
|
||||
val staticDir = ServletHolder(DefaultServlet::class.java)
|
||||
staticDir.setInitParameter("resourceBase", it.second)
|
||||
staticDir.setInitParameter("dirAllowed", "true")
|
||||
staticDir.setInitParameter("pathInfoOnly", "true")
|
||||
addServlet(staticDir, "/web/${it.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"))
|
||||
|
||||
val container = ServletContainer(resourceConfig)
|
||||
val jerseyServlet = ServletHolder(container)
|
||||
addServlet(jerseyServlet, "/api/*")
|
||||
jerseyServlet.initOrder = 0 // Initialise at server start
|
||||
}
|
||||
}
|
||||
|
||||
private fun retryConnectLocalRpc(): CordaRPCOps {
|
||||
while (true) {
|
||||
try {
|
||||
return connectLocalRpcAsNodeUser()
|
||||
} catch (e: ActiveMQNotConnectedException) {
|
||||
log.debug("Could not connect to ${config.p2pAddress} due to exception: ", e)
|
||||
Thread.sleep(retryDelay)
|
||||
// This error will happen if the server has yet to create the keystore
|
||||
// Keep the fully qualified package name due to collisions with the Kotlin stdlib
|
||||
// exception of the same name
|
||||
} catch (e: java.nio.file.NoSuchFileException) {
|
||||
log.debug("Tried to open a file that doesn't yet exist, retrying", e)
|
||||
Thread.sleep(retryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectLocalRpcAsNodeUser(): CordaRPCOps {
|
||||
log.info("Connecting to node at ${config.p2pAddress} as node user")
|
||||
val client = CordaRPCClient(config.p2pAddress, config)
|
||||
client.start(ArtemisMessagingComponent.NODE_USER, ArtemisMessagingComponent.NODE_USER)
|
||||
return client.proxy()
|
||||
}
|
||||
|
||||
/** Fetch CordaPluginRegistry classes registered in META-INF/services/net.corda.core.node.CordaPluginRegistry files that exist in the classpath */
|
||||
val pluginRegistries: List<CordaPluginRegistry> by lazy {
|
||||
ServiceLoader.load(CordaPluginRegistry::class.java).toList()
|
||||
}
|
||||
|
||||
/** Used for useful info that we always want to show, even when not logging to the console */
|
||||
fun logAndMaybePrint(description: String, info: String? = null) {
|
||||
val msg = if (info == null) description else "${description.padEnd(40)}: $info"
|
||||
val loggerName = if (renderBasicInfoToConsole) "BasicInfo" else "Main"
|
||||
LoggerFactory.getLogger(loggerName).info(msg)
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package net.corda.webserver.servlets
|
||||
|
||||
import net.corda.core.crypto.SecureHash
|
||||
import net.corda.core.node.services.StorageService
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import java.io.FileNotFoundException
|
||||
import javax.servlet.http.HttpServlet
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
/**
|
||||
* Allows the node administrator to either download full attachment zips, or individual files within those zips.
|
||||
*
|
||||
* GET /attachments/123abcdef12121 -> download the zip identified by this hash
|
||||
* GET /attachments/123abcdef12121/foo.txt -> download that file specifically
|
||||
*
|
||||
* Files are always forced to be downloads, they may not be embedded into web pages for security reasons.
|
||||
*
|
||||
* TODO: See if there's a way to prevent access by JavaScript.
|
||||
* TODO: Provide an endpoint that exposes attachment file listings, to make attachments browseable.
|
||||
*/
|
||||
class AttachmentDownloadServlet : HttpServlet() {
|
||||
private val log = loggerFor<AttachmentDownloadServlet>()
|
||||
|
||||
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
|
||||
val reqPath = req.pathInfo?.substring(1)
|
||||
if (reqPath == null) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val hash = SecureHash.parse(reqPath.substringBefore('/'))
|
||||
val storage = servletContext.getAttribute("storage") as StorageService
|
||||
val attachment = storage.attachments.openAttachment(hash) ?: throw FileNotFoundException()
|
||||
|
||||
// Don't allow case sensitive matches inside the jar, it'd just be confusing.
|
||||
val subPath = reqPath.substringAfter('/', missingDelimiterValue = "").toLowerCase()
|
||||
|
||||
resp.contentType = "application/octet-stream"
|
||||
if (subPath == "") {
|
||||
resp.addHeader("Content-Disposition", "attachment; filename=\"$hash.zip\"")
|
||||
attachment.open().use { it.copyTo(resp.outputStream) }
|
||||
} else {
|
||||
val filename = subPath.split('/').last()
|
||||
resp.addHeader("Content-Disposition", "attachment; filename=\"$filename\"")
|
||||
attachment.extractFile(subPath, resp.outputStream)
|
||||
}
|
||||
resp.outputStream.close()
|
||||
} catch(e: FileNotFoundException) {
|
||||
log.warn("404 Not Found whilst trying to handle attachment download request for ${servletContext.contextPath}/$reqPath")
|
||||
resp.sendError(HttpServletResponse.SC_NOT_FOUND)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package net.corda.webserver.servlets
|
||||
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.core.utilities.loggerFor
|
||||
import org.apache.commons.fileupload.servlet.ServletFileUpload
|
||||
import java.util.*
|
||||
import javax.servlet.http.HttpServlet
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import javax.servlet.http.HttpServletResponse
|
||||
|
||||
/**
|
||||
* Uploads to the node via the [CordaRPCOps] uploadFile interface.
|
||||
*/
|
||||
class DataUploadServlet: HttpServlet() {
|
||||
private val log = loggerFor<DataUploadServlet>()
|
||||
|
||||
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
|
||||
@Suppress("DEPRECATION") // Bogus warning due to superclass static method being deprecated.
|
||||
val isMultipart = ServletFileUpload.isMultipartContent(req)
|
||||
val rpc = servletContext.getAttribute("rpc") as CordaRPCOps
|
||||
|
||||
if (!isMultipart) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "This end point is for data uploads only.")
|
||||
return
|
||||
}
|
||||
|
||||
val upload = ServletFileUpload()
|
||||
val iterator = upload.getItemIterator(req)
|
||||
val messages = ArrayList<String>()
|
||||
|
||||
if (!iterator.hasNext()) {
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Got an upload request with no files")
|
||||
return
|
||||
}
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
val item = iterator.next()
|
||||
log.info("Receiving ${item.name}")
|
||||
|
||||
try {
|
||||
val dataType = req.pathInfo.substring(1).substringBefore('/')
|
||||
@Suppress("DEPRECATION") // TODO: Replace the use of uploadFile
|
||||
messages += rpc.uploadFile(dataType, item.name, item.openStream())
|
||||
log.info("${item.name} successfully accepted: ${messages.last()}")
|
||||
} catch(e: RuntimeException) {
|
||||
println(e)
|
||||
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Got a file upload request for an unknown data type")
|
||||
}
|
||||
}
|
||||
|
||||
// Send back the hashes as a convenience for the user.
|
||||
val writer = resp.writer
|
||||
messages.forEach { writer.println(it) }
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package net.corda.webserver.servlets
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import net.corda.core.messaging.CordaRPCOps
|
||||
import net.corda.jackson.JacksonSupport
|
||||
import javax.ws.rs.ext.ContextResolver
|
||||
import javax.ws.rs.ext.Provider
|
||||
|
||||
/**
|
||||
* Primary purpose is to install Kotlin extensions for Jackson ObjectMapper so data classes work
|
||||
* and to organise serializers / deserializers for java.time.* classes as necessary.
|
||||
*/
|
||||
@Provider
|
||||
class ObjectMapperConfig(rpc: CordaRPCOps) : ContextResolver<ObjectMapper> {
|
||||
val defaultObjectMapper = JacksonSupport.createDefaultMapper(rpc)
|
||||
override fun getContext(type: Class<*>) = defaultObjectMapper
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
package net.corda.webserver.servlets
|
||||
|
||||
import javax.ws.rs.container.ContainerRequestContext
|
||||
import javax.ws.rs.container.ContainerResponseContext
|
||||
import javax.ws.rs.container.ContainerResponseFilter
|
||||
import javax.ws.rs.ext.Provider
|
||||
|
||||
/**
|
||||
* This adds headers needed for cross site scripting on API clients.
|
||||
*/
|
||||
@Provider
|
||||
class ResponseFilter : ContainerResponseFilter {
|
||||
override fun filter(requestContext: ContainerRequestContext, responseContext: ContainerResponseContext) {
|
||||
val headers = responseContext.headers
|
||||
|
||||
/**
|
||||
* TODO we need to revisit this for security reasons
|
||||
*
|
||||
* We don't want this scriptable from any web page anywhere, but for demo reasons
|
||||
* we're making this really easy to access pending a proper security approach including
|
||||
* access control and authentication at a network and software level.
|
||||
*
|
||||
*/
|
||||
headers.add("Access-Control-Allow-Origin", "*")
|
||||
|
||||
if (requestContext.method == "OPTIONS") {
|
||||
headers.add("Access-Control-Allow-Headers", "Content-Type,Accept,Origin")
|
||||
headers.add("Access-Control-Allow-Methods", "POST,PUT,GET,OPTIONS")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
1
webserver/src/main/resources/web-reference.conf
Normal file
1
webserver/src/main/resources/web-reference.conf
Normal file
@ -0,0 +1 @@
|
||||
useHTTPS = false
|
Reference in New Issue
Block a user