From 780f93e625db7d1d056301e5de86ba48f46ec32a Mon Sep 17 00:00:00 2001 From: Chris Rankin Date: Wed, 3 May 2017 13:21:26 +0100 Subject: [PATCH] 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. --- .../net/corda/core/contracts/Structures.kt | 30 +++++++++-------- samples/attachment-demo/build.gradle | 1 + .../corda/attachmentdemo/AttachmentDemo.kt | 32 +++++++++++++++++-- .../servlets/AttachmentDownloadServlet.kt | 28 ++++++++++------ .../webserver/servlets/CorDappInfoServlet.kt | 2 ++ .../webserver/servlets/DataUploadServlet.kt | 2 ++ .../webserver/servlets/ResponseFilter.kt | 2 ++ 7 files changed, 71 insertions(+), 26 deletions(-) diff --git a/core/src/main/kotlin/net/corda/core/contracts/Structures.kt b/core/src/main/kotlin/net/corda/core/contracts/Structures.kt index 85cec09013..f2f4d1091a 100644 --- a/core/src/main/kotlin/net/corda/core/contracts/Structures.kt +++ b/core/src/main/kotlin/net/corda/core/contracts/Structures.kt @@ -10,6 +10,7 @@ import net.corda.core.node.services.ServiceType import net.corda.core.serialization.* import net.corda.core.transactions.TransactionBuilder import java.io.FileNotFoundException +import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.security.PublicKey @@ -496,20 +497,7 @@ interface Attachment : NamedByHash { * * @throws FileNotFoundException if the given path doesn't exist in the attachment. */ - fun extractFile(path: String, outputTo: OutputStream) { - 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) - } + fun extractFile(path: String, outputTo: OutputStream) = openAsJAR().use { it.extractFile(path, outputTo) } } abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment { @@ -529,3 +517,17 @@ abstract class AbstractAttachment(dataLoader: () -> ByteArray) : Attachment { override fun hashCode() = id.hashCode() 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) +} diff --git a/samples/attachment-demo/build.gradle b/samples/attachment-demo/build.gradle index c6e4341c53..9d840e993b 100644 --- a/samples/attachment-demo/build.gradle +++ b/samples/attachment-demo/build.gradle @@ -70,6 +70,7 @@ task deployNodes(type: net.corda.plugins.Cordform, dependsOn: ['jar']) { advertisedServices = [] p2pPort 10008 rpcPort 10009 + webPort 10010 cordapps = [] rpcUsers = ext.rpcUsers } diff --git a/samples/attachment-demo/src/main/kotlin/net/corda/attachmentdemo/AttachmentDemo.kt b/samples/attachment-demo/src/main/kotlin/net/corda/attachmentdemo/AttachmentDemo.kt index a4279be5ab..09e6f2fa2f 100644 --- a/samples/attachment-demo/src/main/kotlin/net/corda/attachmentdemo/AttachmentDemo.kt +++ b/samples/attachment-demo/src/main/kotlin/net/corda/attachmentdemo/AttachmentDemo.kt @@ -19,9 +19,14 @@ import net.corda.core.utilities.DUMMY_NOTARY_KEY import net.corda.core.utilities.Emoji import net.corda.flows.FinalityFlow import java.io.InputStream +import java.net.HttpURLConnection +import java.net.URL 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.test.assertEquals internal enum class Role { SENDER, @@ -73,7 +78,7 @@ fun sender(rpc: CordaRPCOps, inputStream: InputStream, hash: SecureHash.SHA256) if (!rpc.attachmentExists(hash)) { inputStream.use { 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()) { val state = wtx.outputs.map { it.data }.filterIsInstance().single() 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)}") } else { println("Error: no output state found in ${wtx.id}") diff --git a/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt b/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt index fb113859d6..9171e02e60 100644 --- a/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt +++ b/webserver/src/main/kotlin/net/corda/webserver/servlets/AttachmentDownloadServlet.kt @@ -1,12 +1,17 @@ package net.corda.webserver.servlets +import net.corda.core.contracts.extractFile 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 java.io.FileNotFoundException +import java.io.IOException +import java.util.jar.JarInputStream import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest 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. @@ -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. * * 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() { private val log = loggerFor() + @Throws(IOException::class) override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { val reqPath = req.pathInfo?.substring(1) if (reqPath == null) { @@ -31,21 +37,23 @@ class AttachmentDownloadServlet : HttpServlet() { try { val hash = SecureHash.parse(reqPath.substringBefore('/')) - val storage = servletContext.getAttribute("storage") as StorageService - val attachment = storage.attachments.openAttachment(hash) ?: throw FileNotFoundException() + val rpc = servletContext.getAttribute("rpc") as CordaRPCOps + val attachment = rpc.openAttachment(hash) // 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) } + resp.contentType = MediaType.APPLICATION_OCTET_STREAM + if (subPath.isEmpty()) { + resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$hash.zip\"") + attachment.use { it.copyTo(resp.outputStream) } } else { val filename = subPath.split('/').last() - resp.addHeader("Content-Disposition", "attachment; filename=\"$filename\"") - attachment.extractFile(subPath, resp.outputStream) + resp.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") + 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() } catch(e: FileNotFoundException) { log.warn("404 Not Found whilst trying to handle attachment download request for ${servletContext.contextPath}/$reqPath") diff --git a/webserver/src/main/kotlin/net/corda/webserver/servlets/CorDappInfoServlet.kt b/webserver/src/main/kotlin/net/corda/webserver/servlets/CorDappInfoServlet.kt index 48c7836d04..38bcd8a427 100644 --- a/webserver/src/main/kotlin/net/corda/webserver/servlets/CorDappInfoServlet.kt +++ b/webserver/src/main/kotlin/net/corda/webserver/servlets/CorDappInfoServlet.kt @@ -6,6 +6,7 @@ import net.corda.core.messaging.CordaRPCOps import net.corda.core.node.CordaPluginRegistry import org.glassfish.jersey.server.model.Resource import org.glassfish.jersey.server.model.ResourceMethod +import java.io.IOException import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse @@ -16,6 +17,7 @@ import javax.servlet.http.HttpServletResponse */ class CorDappInfoServlet(val plugins: List, val rpc: CordaRPCOps): HttpServlet() { + @Throws(IOException::class) override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { resp.writer.appendHTML().html { head { diff --git a/webserver/src/main/kotlin/net/corda/webserver/servlets/DataUploadServlet.kt b/webserver/src/main/kotlin/net/corda/webserver/servlets/DataUploadServlet.kt index 9bf30893d7..a81adb9a1b 100644 --- a/webserver/src/main/kotlin/net/corda/webserver/servlets/DataUploadServlet.kt +++ b/webserver/src/main/kotlin/net/corda/webserver/servlets/DataUploadServlet.kt @@ -3,6 +3,7 @@ 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.io.IOException import java.util.* import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest @@ -14,6 +15,7 @@ import javax.servlet.http.HttpServletResponse class DataUploadServlet : HttpServlet() { private val log = loggerFor() + @Throws(IOException::class) override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { @Suppress("DEPRECATION") // Bogus warning due to superclass static method being deprecated. val isMultipart = ServletFileUpload.isMultipartContent(req) diff --git a/webserver/src/main/kotlin/net/corda/webserver/servlets/ResponseFilter.kt b/webserver/src/main/kotlin/net/corda/webserver/servlets/ResponseFilter.kt index ec49edd182..5b29019585 100644 --- a/webserver/src/main/kotlin/net/corda/webserver/servlets/ResponseFilter.kt +++ b/webserver/src/main/kotlin/net/corda/webserver/servlets/ResponseFilter.kt @@ -1,5 +1,6 @@ package net.corda.webserver.servlets +import java.io.IOException import javax.ws.rs.container.ContainerRequestContext import javax.ws.rs.container.ContainerResponseContext import javax.ws.rs.container.ContainerResponseFilter @@ -10,6 +11,7 @@ import javax.ws.rs.ext.Provider */ @Provider class ResponseFilter : ContainerResponseFilter { + @Throws(IOException::class) override fun filter(requestContext: ContainerRequestContext, responseContext: ContainerResponseContext) { val headers = responseContext.headers