Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to transparently stream a file to the browser?

CONTROLLED ENVIRONMENT: IE8, IIS 7, ColdFusion

When issuing a GET request pointing to a media file such as .mp3, .mpeg, etc. from IE, the browser will launch the associated application (Window Media Player) and I guess that the way IIS serves the file allows the application to stream it.

We would like to be able to have a full control over the streaming process of a file so that we can allow it at certain times and to certain users only. For that reason we cannot simply let IIS directly serve the file and we wanted to use ColdFusion to serve the file instead.

We have tried a few different approaches, but in every case the browser is downloading the entire file content before launching the external application. That's what we want to avoid.

Please note that we do not want an NTFS-permission based solution.

The solution that looked the most promising was Using ColdFusion To Stream Files To The Client Without Loading The Entire File Into Memory but the only advantage that seemed to offer was that the file wouldn't be entirely loaded in memory when served to the browser, but the browser still waits until the end of the transfer and then opens up the file in Winwodw Media Player.

Is there a way to use ColdFusion or Java to stream a file to the browser and having the browser delegate the handling to the associated application just like when we let IIS directly serve the file?

like image 563
plalx Avatar asked Oct 15 '13 15:10

plalx


1 Answers

WORKING SOLUTION:

I finally managed to find a solution that supports seeking and that doesn't involve too much work. I basically created an HttpForwardRequest component that delegates the request handling to the web server by issuing a new HTTP request to the specified media URL while preserving other initial servlet request details, such as HTTP headers. The web server's response will then be piped into the servlet's response output stream.

In our case, since the web server (ISS 7.0) already know how to do HTTP streaming, that's the only thing we have to do.

Note: I have tried with getRequestDispatcher('some_media_url').forward(...) but it seems that it cannot serve media files with the correct headers.

HttpForwardRequest code:

<cfcomponent output="no">

    <cffunction name="init" access="public" returntype="HttpForwardRequest" output="no">
        <cfargument name="url" type="string" required="yes" hint="The URL to which the request should be forwarded to.">
        <cfargument name="requestHeaders" type="struct" required="yes" hint="The HTTP request headers.">
        <cfargument name="response" type="any" required="yes" hint=" The servlet's response object.">
        <cfargument name="responseHeaders" type="struct" required="no" default="#{}#" hint="Custom response headers to override the initial request response headers.">

        <cfset variables.instance = {
            url = arguments.url,
            requestHeaders = arguments.requestHeaders,
            response = arguments.response,
            responseHeaders = arguments.responseHeaders
        }>

        <cfreturn this>
    </cffunction>

    <cffunction name="send" access="public" returntype="void" output="no">
        <cfset var response = variables.instance.response>
        <cfset var outputStream = response.getOutputStream()>
        <cfset var buffer = createBuffer()>

        <cftry>

            <cfset var connection = createObject('java', 'java.net.URL')
                    .init(variables.instance.url)
                    .openConnection()>

            <cfset setRequestHeaders(connection)>

            <cfset setResponseHeaders(connection)>

            <cfset var inputStream = connection.getInputStream()>

            <cfset response.setStatus(connection.getResponseCode(), connection.getResponseMessage())>

            <cfloop condition="true">
                <cfset var bytesRead = inputStream.read(buffer, javaCast('int', 0), javaCast('int', arrayLen(buffer)))>

                <cfif bytesRead eq -1>
                    <cfbreak>
                </cfif>

                <cftry>
                    <cfset outputStream.write(buffer, javaCast('int', 0), bytesRead)>

                    <cfset outputStream.flush()>

                    <!--- 
                    Connection reset by peer: socket write error

                    The above error occurs when users are seeking a video.
                    That is probably normal since I assume the client (e.g. Window Media Player) 
                    closes the connection when seeking.
                    --->
                    <cfcatch type="java.net.SocketException">
                        <cfbreak>
                    </cfcatch>
                </cftry>
            </cfloop>

            <cffinally>

                <cfif not isNull(inputStream)>
                    <cfset inputStream.close()>
                </cfif>

                <cfif not isNull(connection)>
                    <cfset connection.disconnect()>
                </cfif>

            </cffinally>
        </cftry>

    </cffunction>

    <cffunction name="setRequestHeaders" access="private" returntype="void" output="no">

        <cfargument name="connection" type="any" required="yes">

        <cfset var requestHeaders = variables.instance.requestHeaders>

        <cfloop collection="#requestHeaders#" item="local.key">
            <cfset arguments.connection.setRequestProperty(key, requestHeaders[key])>
        </cfloop>

    </cffunction>

    <cffunction name="setResponseHeaders" access="private" returntype="void" output="no">
        <cfargument name="connection" type="any" required="yes">

        <cfset var response = variables.instance.response>
        <cfset var responseHeaders = variables.instance.responseHeaders>
        <cfset var i = -1>

        <!--- Copy connection headers --->
        <cfloop condition="true">

            <cfset i = javaCast('int', i + 1)>

            <cfset var key = arguments.connection.getHeaderFieldKey(i)>

            <cfset var value = arguments.connection.getHeaderField(i)>

            <cfif isNull(key)>
                <cfif isNull(value)>
                    <!--- Both, key and value are null, break --->
                    <cfbreak>
                </cfif>

                <!--- Sometimes the key is null but the value is not, just ignore and keep iterating --->
                <cfcontinue>
            </cfif>

            <cfset setResponseHeader(key, value)>
        </cfloop>

        <!--- Apply custom headers --->
        <cfloop collection="#responseHeaders#" item="key">
            <cfset setResponseHeader(key, responseHeaders[key])>
        </cfloop>

    </cffunction>

    <cffunction name="setResponseHeader" access="private" returntype="void" output="no">
        <cfargument name="key" type="string" required="yes">
        <cfargument name="value" type="string" required="yes">

        <cfset var response = variables.instance.response>

        <cfif arguments.key eq 'Content-Type'>
            <cfset response.setContentType(arguments.value)>
        <cfelse>
            <cfset response.setHeader(arguments.key, arguments.value)>
        </cfif>
    </cffunction>

    <cffunction name="createBuffer" access="private" returntype="any" output="no">
        <cfreturn repeatString("12345", 1024).getBytes()>
    </cffunction>

</cfcomponent>

cf_streamurl code:

<cfparam name="attributes.url" type="url">

<cfif thisTag.executionMode neq 'start'>
    <cfexit>
</cfif>

<cfset pageContext = getPageContext()>

<cfset requestHeaders = {
    'Authorization' = 'Anonymous'
}>

<cfset structAppend(requestHeaders, getHTTPRequestData().headers, false)>

<cfset pageContext.setFlushOutput(false)>

<!--- Forward the request to IIS --->
<cfset new references.cfc.servlet.HttpForwardRequest(
    attributes.url,
    requestHeaders,
    pageContext.getResponse().getResponse()
).send()>

You can then use the cf_streamurl custom tag like:

<cf_streamurl url="http://sh34lprald94/media_stream/unprotected/trusts.mp4"/>

IMPORTANT: It only supports Anonymous authentication for now.


First half-working attempt (historical purpose only):

We found a solution (which was actually quite simple) that suits our needs by inspecting the HTTP headers of the response packet and looking at the mime type returned by IIS when letting it server the media file.

The issue was that when trying to serve the file content to the browser using ColdFusion, we had to use one of the Window Media Services mime types to force the browser to delegate the handling to Window Media Player directly (which is then able to stream the file).

File extension MIME type 
.asf video/x-ms-asf 
.asx video/x-ms-asf 
.wma audio/x-ms-wma 
.wax audio/x-ms-wax 
.wmv audio/x-ms-wmv 
.wvx video/x-ms-wvx 
.wm video/x-ms-wm 
.wmx video/x-ms-wmx 
.wmz application/x-ms-wmz 
.wmd application/x-ms-wmd

The first step for solving the issue was to write a function that would resolve the mime type correctly based on the file's extension. IIS has that knowledge already, however I haven't found a way of querying it's MIME registry yet.

Note: wmsMimeTypes is a struct used as a map to lookup WMS mime types.

<cffunction name="getMimeType" access="public" returntype="string">
    <cfargument name="fileName" type="string" required="yes">

    <cfset var mimeType = 'application/x-unknown'>
    <cfset var ext = this.getFileExtension(arguments.fileName)>

    <cfif structKeyExists(this.wmsMimeTypes, ext)>
        <cfreturn this.wmsMimeTypes[ext]>
    </cfif>

    <!--- TODO: Is there a way to read the IIS MIME registry? --->
    <cfregistry action="get" branch="HKEY_CLASSES_ROOT\.#ext#" entry="Content Type" variable="mimeType">

    <cfreturn mimeType>

</cffunction>

Then we implemented a stream method like below that encapsulates the streaming process based on the implementation found in Using ColdFusion To Stream Files To The Client Without Loading The Entire File Into Memory

Note: It also works with cfcontent, but I read that it was quite inefficient because it's consuming too much resources, especially because it loads the entire file in memory before flushing to the browser.

<cffunction name="stream" access="public" returntype="void">
    <cfargument name="file" type="string" required="yes">
    <cfargument name="mimeType" type="string" required="no">

    <cfscript>
        var fileName = getFileFromPath(arguments.file);
        var resolvedMimeType = structKeyExists(arguments, 'mimeType')? arguments.mimeType : this.getMimeType(fileName);
        var javaInt0 = javaCast('int', 0);
        var response = getPageContext().getResponse().getResponse();
        var binaryOutputStream = response.getOutputStream();
        var bytesBuffer = repeatString('11111', 1024).getBytes();
        var fileInputStream = createObject('java', 'java.io.FileInputStream').init(javaCast('string', getRootPath() & arguments.file));

        getPageContext().setFlushOutput(javaCast('boolean', false));

        response.resetBuffer();
        response.setContentType(javaCast('string', resolvedMimeType));

        try {
            while (true) {
                bytesRead = fileInputStream.read(bytesBuffer, javaInt0, javaCast('int', arrayLen(bytesBuffer)));

                if (bytesRead eq -1) break;

                binaryOutputStream.write(bytesBuffer, javaInt0, javaCast('int', bytesRead));
                binaryOutputStream.flush();
            }               
            response.reset();
         } finally {
             if (not isNull(fileInputStream)) fileInputStream.close();
             if (not isNull(binaryOutputStream)) binaryOutputStream.close();
         }
    </cfscript>
</cffunction>

You must NOT set the Content-Disposition header or the browser will download the file instead of delegating the control to WMP.

Note: Letting the web server to stream the file to the client (or the CF solution we used) will never be as efficient as using a media server, like stated in the article that @Miguel-F suggested.

MAJOR DOWNSIDE: The previous implementation will not support seeking which actually might make the solution almost unusable.

like image 141
plalx Avatar answered Nov 03 '22 12:11

plalx