mirror of
https://github.com/corda/corda.git
synced 2025-02-23 10:30:24 +00:00
Fix downloading attachments from WebServer. (#615)
* Fix /attachments endpoint on WebServer, and update demo to use it. * Add @Throws statements to servlet methods. * Ensure target entry is not a directory. * Simplify, because JarInputStream verifies signatures by default. * Move JarInputStream.extractFile() function into core. * Don't close the output stream automatically as it commits our response.
This commit is contained in:
parent
ce8e40df50
commit
780f93e625
@ -10,6 +10,7 @@ import net.corda.core.node.services.ServiceType
|
|||||||
import net.corda.core.serialization.*
|
import net.corda.core.serialization.*
|
||||||
import net.corda.core.transactions.TransactionBuilder
|
import net.corda.core.transactions.TransactionBuilder
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.security.PublicKey
|
import java.security.PublicKey
|
||||||
@ -496,20 +497,7 @@ interface Attachment : NamedByHash {
|
|||||||
*
|
*
|
||||||
* @throws FileNotFoundException if the given path doesn't exist in the attachment.
|
* @throws FileNotFoundException if the given path doesn't exist in the attachment.
|
||||||
*/
|
*/
|
||||||
fun extractFile(path: String, outputTo: OutputStream) {
|
fun extractFile(path: String, outputTo: OutputStream) = openAsJAR().use { it.extractFile(path, outputTo) }
|
||||||
val p = path.toLowerCase().split('\\', '/')
|
|
||||||
openAsJAR().use { jar ->
|
|
||||||
while (true) {
|
|
||||||
val e = jar.nextJarEntry ?: break
|
|
||||||
if (e.name.toLowerCase().split('\\', '/') == p) {
|
|
||||||
jar.copyTo(outputTo)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jar.closeEntry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw FileNotFoundException(path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment {
|
abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment {
|
||||||
@ -529,3 +517,17 @@ abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment {
|
|||||||
override fun hashCode() = id.hashCode()
|
override fun hashCode() = id.hashCode()
|
||||||
override fun toString() = "${javaClass.simpleName}(id=$id)"
|
override fun toString() = "${javaClass.simpleName}(id=$id)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun JarInputStream.extractFile(path: String, outputTo: OutputStream) {
|
||||||
|
val p = path.toLowerCase().split('\\', '/')
|
||||||
|
while (true) {
|
||||||
|
val e = nextJarEntry ?: break
|
||||||
|
if (!e.isDirectory && e.name.toLowerCase().split('\\', '/') == p) {
|
||||||
|
copyTo(outputTo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeEntry()
|
||||||
|
}
|
||||||
|
throw FileNotFoundException(path)
|
||||||
|
}
|
||||||
|
@ -70,6 +70,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) {
|
|||||||
advertisedServices = []
|
advertisedServices = []
|
||||||
p2pPort 10008
|
p2pPort 10008
|
||||||
rpcPort 10009
|
rpcPort 10009
|
||||||
|
webPort 10010
|
||||||
cordapps = []
|
cordapps = []
|
||||||
rpcUsers = ext.rpcUsers
|
rpcUsers = ext.rpcUsers
|
||||||
}
|
}
|
||||||
|
@ -19,9 +19,14 @@ import net.corda.core.utilities.DUMMY_NOTARY_KEY
|
|||||||
import net.corda.core.utilities.Emoji
|
import net.corda.core.utilities.Emoji
|
||||||
import net.corda.flows.FinalityFlow
|
import net.corda.flows.FinalityFlow
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
import java.security.PublicKey
|
import java.security.PublicKey
|
||||||
|
import java.util.jar.JarInputStream
|
||||||
|
import javax.servlet.http.HttpServletResponse.SC_OK
|
||||||
|
import javax.ws.rs.core.HttpHeaders.CONTENT_DISPOSITION
|
||||||
|
import javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
internal enum class Role {
|
internal enum class Role {
|
||||||
SENDER,
|
SENDER,
|
||||||
@ -73,7 +78,7 @@ fun sender(rpc: CordaRPCOps, inputStream: InputStream, hash: SecureHash.SHA256)
|
|||||||
if (!rpc.attachmentExists(hash)) {
|
if (!rpc.attachmentExists(hash)) {
|
||||||
inputStream.use {
|
inputStream.use {
|
||||||
val id = rpc.uploadAttachment(it)
|
val id = rpc.uploadAttachment(it)
|
||||||
assertEquals(hash, id)
|
require(hash == id) { "Id was '$id' instead of '$hash'" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +107,29 @@ fun recipient(rpc: CordaRPCOps) {
|
|||||||
if (wtx.outputs.isNotEmpty()) {
|
if (wtx.outputs.isNotEmpty()) {
|
||||||
val state = wtx.outputs.map { it.data }.filterIsInstance<AttachmentContract.State>().single()
|
val state = wtx.outputs.map { it.data }.filterIsInstance<AttachmentContract.State>().single()
|
||||||
require(rpc.attachmentExists(state.hash))
|
require(rpc.attachmentExists(state.hash))
|
||||||
|
|
||||||
|
// Download the attachment via the Web endpoint.
|
||||||
|
val connection = URL("http://localhost:10010/attachments/${state.hash}").openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
require(connection.responseCode == SC_OK) { "HTTP status code was ${connection.responseCode}" }
|
||||||
|
require(connection.contentType == APPLICATION_OCTET_STREAM) { "Content-Type header was ${connection.contentType}" }
|
||||||
|
require(connection.contentLength > 1024) { "Attachment contains only ${connection.contentLength} bytes" }
|
||||||
|
require(connection.getHeaderField(CONTENT_DISPOSITION) == "attachment; filename=\"${state.hash}.zip\"") {
|
||||||
|
"Content-Disposition header was ${connection.getHeaderField(CONTENT_DISPOSITION)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write out the entries inside this jar.
|
||||||
|
println("Attachment JAR contains these entries:")
|
||||||
|
JarInputStream(connection.inputStream).use { it ->
|
||||||
|
while (true) {
|
||||||
|
val e = it.nextJarEntry ?: break
|
||||||
|
println("Entry> ${e.name}")
|
||||||
|
it.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
println("File received - we're happy!\n\nFinal transaction is:\n\n${Emoji.renderIfSupported(wtx)}")
|
println("File received - we're happy!\n\nFinal transaction is:\n\n${Emoji.renderIfSupported(wtx)}")
|
||||||
} else {
|
} else {
|
||||||
println("Error: no output state found in ${wtx.id}")
|
println("Error: no output state found in ${wtx.id}")
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
package net.corda.webserver.servlets
|
package net.corda.webserver.servlets
|
||||||
|
|
||||||
|
import net.corda.core.contracts.extractFile
|
||||||
import net.corda.core.crypto.SecureHash
|
import net.corda.core.crypto.SecureHash
|
||||||
import net.corda.core.node.services.StorageService
|
import net.corda.core.messaging.CordaRPCOps
|
||||||
import net.corda.core.utilities.loggerFor
|
import net.corda.core.utilities.loggerFor
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.jar.JarInputStream
|
||||||
import javax.servlet.http.HttpServlet
|
import javax.servlet.http.HttpServlet
|
||||||
import javax.servlet.http.HttpServletRequest
|
import javax.servlet.http.HttpServletRequest
|
||||||
import javax.servlet.http.HttpServletResponse
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
import javax.ws.rs.core.HttpHeaders
|
||||||
|
import javax.ws.rs.core.MediaType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows the node administrator to either download full attachment zips, or individual files within those zips.
|
* Allows the node administrator to either download full attachment zips, or individual files within those zips.
|
||||||
@ -17,11 +22,12 @@ import javax.servlet.http.HttpServletResponse
|
|||||||
* Files are always forced to be downloads, they may not be embedded into web pages for security reasons.
|
* 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: See if there's a way to prevent access by JavaScript.
|
||||||
* TODO: Provide an endpoint that exposes attachment file listings, to make attachments browseable.
|
* TODO: Provide an endpoint that exposes attachment file listings, to make attachments browsable.
|
||||||
*/
|
*/
|
||||||
class AttachmentDownloadServlet : HttpServlet() {
|
class AttachmentDownloadServlet : HttpServlet() {
|
||||||
private val log = loggerFor<AttachmentDownloadServlet>()
|
private val log = loggerFor<AttachmentDownloadServlet>()
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
|
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
|
||||||
val reqPath = req.pathInfo?.substring(1)
|
val reqPath = req.pathInfo?.substring(1)
|
||||||
if (reqPath == null) {
|
if (reqPath == null) {
|
||||||
@ -31,21 +37,23 @@ class AttachmentDownloadServlet : HttpServlet() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val hash = SecureHash.parse(reqPath.substringBefore('/'))
|
val hash = SecureHash.parse(reqPath.substringBefore('/'))
|
||||||
val storage = servletContext.getAttribute("storage") as StorageService
|
val rpc = servletContext.getAttribute("rpc") as CordaRPCOps
|
||||||
val attachment = storage.attachments.openAttachment(hash) ?: throw FileNotFoundException()
|
val attachment = rpc.openAttachment(hash)
|
||||||
|
|
||||||
// Don't allow case sensitive matches inside the jar, it'd just be confusing.
|
// Don't allow case sensitive matches inside the jar, it'd just be confusing.
|
||||||
val subPath = reqPath.substringAfter('/', missingDelimiterValue = "").toLowerCase()
|
val subPath = reqPath.substringAfter('/', missingDelimiterValue = "").toLowerCase()
|
||||||
|
|
||||||
resp.contentType = "application/octet-stream"
|
resp.contentType = MediaType.APPLICATION_OCTET_STREAM
|
||||||
if (subPath == "") {
|
if (subPath.isEmpty()) {
|
||||||
resp.addHeader("Content-Disposition", "attachment; filename=\"$hash.zip\"")
|
resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$hash.zip\"")
|
||||||
attachment.open().use { it.copyTo(resp.outputStream) }
|
attachment.use { it.copyTo(resp.outputStream) }
|
||||||
} else {
|
} else {
|
||||||
val filename = subPath.split('/').last()
|
val filename = subPath.split('/').last()
|
||||||
resp.addHeader("Content-Disposition", "attachment; filename=\"$filename\"")
|
resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"")
|
||||||
attachment.extractFile(subPath, resp.outputStream)
|
JarInputStream(attachment).use { it.extractFile(subPath, resp.outputStream) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Closing the output stream commits our response. We cannot change the status code after this.
|
||||||
resp.outputStream.close()
|
resp.outputStream.close()
|
||||||
} catch(e: FileNotFoundException) {
|
} catch(e: FileNotFoundException) {
|
||||||
log.warn("404 Not Found whilst trying to handle attachment download request for ${servletContext.contextPath}/$reqPath")
|
log.warn("404 Not Found whilst trying to handle attachment download request for ${servletContext.contextPath}/$reqPath")
|
||||||
|
@ -6,6 +6,7 @@ import net.corda.core.messaging.CordaRPCOps
|
|||||||
import net.corda.core.node.CordaPluginRegistry
|
import net.corda.core.node.CordaPluginRegistry
|
||||||
import org.glassfish.jersey.server.model.Resource
|
import org.glassfish.jersey.server.model.Resource
|
||||||
import org.glassfish.jersey.server.model.ResourceMethod
|
import org.glassfish.jersey.server.model.ResourceMethod
|
||||||
|
import java.io.IOException
|
||||||
import javax.servlet.http.HttpServlet
|
import javax.servlet.http.HttpServlet
|
||||||
import javax.servlet.http.HttpServletRequest
|
import javax.servlet.http.HttpServletRequest
|
||||||
import javax.servlet.http.HttpServletResponse
|
import javax.servlet.http.HttpServletResponse
|
||||||
@ -16,6 +17,7 @@ import javax.servlet.http.HttpServletResponse
|
|||||||
*/
|
*/
|
||||||
class CorDappInfoServlet(val plugins: List<CordaPluginRegistry>, val rpc: CordaRPCOps): HttpServlet() {
|
class CorDappInfoServlet(val plugins: List<CordaPluginRegistry>, val rpc: CordaRPCOps): HttpServlet() {
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
|
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
|
||||||
resp.writer.appendHTML().html {
|
resp.writer.appendHTML().html {
|
||||||
head {
|
head {
|
||||||
|
@ -3,6 +3,7 @@ package net.corda.webserver.servlets
|
|||||||
import net.corda.core.messaging.CordaRPCOps
|
import net.corda.core.messaging.CordaRPCOps
|
||||||
import net.corda.core.utilities.loggerFor
|
import net.corda.core.utilities.loggerFor
|
||||||
import org.apache.commons.fileupload.servlet.ServletFileUpload
|
import org.apache.commons.fileupload.servlet.ServletFileUpload
|
||||||
|
import java.io.IOException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.servlet.http.HttpServlet
|
import javax.servlet.http.HttpServlet
|
||||||
import javax.servlet.http.HttpServletRequest
|
import javax.servlet.http.HttpServletRequest
|
||||||
@ -14,6 +15,7 @@ import javax.servlet.http.HttpServletResponse
|
|||||||
class DataUploadServlet : HttpServlet() {
|
class DataUploadServlet : HttpServlet() {
|
||||||
private val log = loggerFor<DataUploadServlet>()
|
private val log = loggerFor<DataUploadServlet>()
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
|
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
|
||||||
@Suppress("DEPRECATION") // Bogus warning due to superclass static method being deprecated.
|
@Suppress("DEPRECATION") // Bogus warning due to superclass static method being deprecated.
|
||||||
val isMultipart = ServletFileUpload.isMultipartContent(req)
|
val isMultipart = ServletFileUpload.isMultipartContent(req)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package net.corda.webserver.servlets
|
package net.corda.webserver.servlets
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
import javax.ws.rs.container.ContainerRequestContext
|
import javax.ws.rs.container.ContainerRequestContext
|
||||||
import javax.ws.rs.container.ContainerResponseContext
|
import javax.ws.rs.container.ContainerResponseContext
|
||||||
import javax.ws.rs.container.ContainerResponseFilter
|
import javax.ws.rs.container.ContainerResponseFilter
|
||||||
@ -10,6 +11,7 @@ import javax.ws.rs.ext.Provider
|
|||||||
*/
|
*/
|
||||||
@Provider
|
@Provider
|
||||||
class ResponseFilter : ContainerResponseFilter {
|
class ResponseFilter : ContainerResponseFilter {
|
||||||
|
@Throws(IOException::class)
|
||||||
override fun filter(requestContext: ContainerRequestContext, responseContext: ContainerResponseContext) {
|
override fun filter(requestContext: ContainerRequestContext, responseContext: ContainerResponseContext) {
|
||||||
val headers = responseContext.headers
|
val headers = responseContext.headers
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user