[FileRepo] [FileBackend] Added support for custom file headers.
authorAaron Schulz <aschulz@wikimedia.org>
Tue, 20 Nov 2012 01:38:17 +0000 (17:38 -0800)
committerAaron Schulz <aschulz@wikimedia.org>
Tue, 20 Nov 2012 22:07:02 +0000 (14:07 -0800)
* For backends that support it, custom HTTP headers can be set on files.
* Added a getStreamHeaders() function to MediaHandler to let subclasses
  recommend header name/value pairs to be used for responses to GET/HEAD
  requests. For example, an OGG handler could set "X-Content-Duration".
* Made LocalFile use this function to set HTTP headers of new uploads.

Change-Id: I1b017e1342513f0097fe6d142aae18e819403293

includes/filebackend/FSFile.php
includes/filebackend/FileBackend.php
includes/filebackend/FileBackendStore.php
includes/filebackend/FileOp.php
includes/filebackend/SwiftFileBackend.php
includes/filerepo/FileRepo.php
includes/filerepo/file/File.php
includes/filerepo/file/LocalFile.php
includes/media/MediaHandler.php

index fda356e..5141ec5 100644 (file)
@@ -131,7 +131,7 @@ class FSFile {
                        # Height, width and metadata
                        $handler = MediaHandler::getHandler( $info['mime'] );
                        if ( $handler ) {
-                               $tempImage = (object)array();
+                               $tempImage = (object)array(); // XXX (hack for File object)
                                $info['metadata'] = $handler->getMetadata( $tempImage, $this->path );
                                $gis = $handler->getImageSize( $tempImage, $this->path, $info['metadata'] );
                                if ( is_array( $gis ) ) {
index b5e2315..25126a3 100644 (file)
@@ -182,7 +182,8 @@ abstract class FileBackend {
         *         'content'             => <string of new file contents>,
         *         'overwrite'           => <boolean>,
         *         'overwriteSame'       => <boolean>,
-        *         'disposition'         => <Content-Disposition header value>
+        *         'disposition'         => <Content-Disposition header value>,
+        *         'headers'             => <HTTP header name/value map> # since 1.21
         *     );
         * @endcode
         *
@@ -194,7 +195,8 @@ abstract class FileBackend {
         *         'dst'                 => <storage path>,
         *         'overwrite'           => <boolean>,
         *         'overwriteSame'       => <boolean>,
-        *         'disposition'         => <Content-Disposition header value>
+        *         'disposition'         => <Content-Disposition header value>,
+        *         'headers'             => <HTTP header name/value map> # since 1.21
         *     )
         * @endcode
         *
@@ -247,10 +249,14 @@ abstract class FileBackend {
         *   - overwriteSame       : An error will not be given if a file already
         *                           exists at the destination that has the same
         *                           contents as the new contents to be written there.
-        *   - disposition         : When supplied, the backend will add a Content-Disposition
+        *   - disposition         : If supplied, the backend will return a Content-Disposition
         *                           header when GETs/HEADs of the destination file are made.
-        *                           Backends that don't support file metadata will ignore this.
-        *                           See http://tools.ietf.org/html/rfc6266 (since 1.20).
+        *                           Backends that don't support metadata ignore this.
+        *                           See http://tools.ietf.org/html/rfc6266. (since 1.20)
+        *   - headers             : If supplied, the backend will return these headers when
+        *                           GETs/HEADs of the destination file are made. Header values
+        *                           should be smaller than 256 bytes, often options or numbers.
+        *                           Backends that don't support metadata ignore this. (since 1.21)
         *
         * $opts is an associative of boolean flags, including:
         *   - force               : Operation precondition errors no longer trigger an abort.
@@ -265,10 +271,10 @@ abstract class FileBackend {
         *   - nonJournaled        : Don't log this operation batch in the file journal.
         *                           This limits the ability of recovery scripts.
         *   - parallelize         : Try to do operations in parallel when possible.
-        *   - bypassReadOnly      : Allow writes in read-only mode (since 1.20).
+        *   - bypassReadOnly      : Allow writes in read-only mode. (since 1.20)
         *   - preserveCache       : Don't clear the process cache before checking files.
         *                           This should only be used if all entries in the process
-        *                           cache were added after the files were already locked (since 1.20).
+        *                           cache were added after the files were already locked. (since 1.20)
         *
         * @remarks Remarks on locking:
         * File system paths given to operations should refer to files that are
@@ -411,7 +417,8 @@ abstract class FileBackend {
         *         'op'                  => 'create',
         *         'dst'                 => <storage path>,
         *         'content'             => <string of new file contents>,
-        *         'disposition'         => <Content-Disposition header value>
+        *         'disposition'         => <Content-Disposition header value>,
+        *         'headers'             => <HTTP header name/value map> # since 1.21
         *     )
         * @endcode
         * b) Copy a file system file into storage
@@ -420,7 +427,8 @@ abstract class FileBackend {
         *         'op'                  => 'store',
         *         'src'                 => <file system path>,
         *         'dst'                 => <storage path>,
-        *         'disposition'         => <Content-Disposition header value>
+        *         'disposition'         => <Content-Disposition header value>,
+        *         'headers'             => <HTTP header name/value map> # since 1.21
         *     )
         * @endcode
         * c) Copy a file within storage
@@ -465,6 +473,10 @@ abstract class FileBackend {
         *                           header when GETs/HEADs of the destination file are made.
         *                           Backends that don't support file metadata will ignore this.
         *                           See http://tools.ietf.org/html/rfc6266 (since 1.20).
+        *   - headers             : If supplied, the backend will return these headers when
+        *                           GETs/HEADs of the destination file are made. Header values
+        *                           should be smaller than 256 bytes, often options or numbers.
+        *                           Backends that don't support metadata ignore this. (since 1.21)
         *
         * $opts is an associative of boolean flags, including:
         *   - bypassReadOnly      : Allow writes in read-only mode (since 1.20)
index 0f435a3..f6d0b24 100644 (file)
@@ -93,6 +93,7 @@ abstract class FileBackendStore extends FileBackend {
         *   - content       : the raw file contents
         *   - dst           : destination storage path
         *   - disposition   : Content-Disposition header value for the destination
+        *   - headers       : HTTP header name/value map
         *   - async         : Status will be returned immediately if supported.
         *                     If the status is OK, then its value field will be
         *                     set to a FileBackendStoreOpHandle object.
@@ -130,6 +131,7 @@ abstract class FileBackendStore extends FileBackend {
         *   - src           : source path on disk
         *   - dst           : destination storage path
         *   - disposition   : Content-Disposition header value for the destination
+        *   - headers       : HTTP header name/value map
         *   - async         : Status will be returned immediately if supported.
         *                     If the status is OK, then its value field will be
         *                     set to a FileBackendStoreOpHandle object.
@@ -1066,6 +1068,9 @@ abstract class FileBackendStore extends FileBackend {
                wfProfileIn( __METHOD__ . '-' . $this->name );
                $status = Status::newGood();
 
+               // Fix up custom header name/value pairs...
+               $ops = array_map( array( $this, 'stripInvalidHeadersFromOp' ), $ops );
+
                // Build up a list of FileOps...
                $performOps = $this->getOperationsInternal( $ops );
 
@@ -1115,6 +1120,9 @@ abstract class FileBackendStore extends FileBackend {
                wfProfileIn( __METHOD__ . '-' . $this->name );
                $status = Status::newGood();
 
+               // Fix up custom header name/value pairs...
+               $ops = array_map( array( $this, 'stripInvalidHeadersFromOp' ), $ops );
+
                $supportedOps = array( 'create', 'store', 'copy', 'move', 'delete', 'null' );
                $async = ( $this->parallelize === 'implicit' );
                $maxConcurrency = $this->concurrency; // throttle
@@ -1206,6 +1214,24 @@ abstract class FileBackendStore extends FileBackend {
                return array();
        }
 
+       /**
+        * Strip long HTTP headers from a file operation
+        *
+        * @param $op array Same format as doOperation()
+        * @return Array
+        */
+       protected function stripInvalidHeadersFromOp( array $op ) {
+               if ( isset( $op['headers'] ) ) {
+                       foreach ( $op['headers'] as $name => $value ) {
+                               if ( strlen( $name ) > 255 || strlen( $value ) > 255 ) {
+                                       trigger_error( "Header '$name: $value' is too long." );
+                                       unset( $op['headers'][$name] );
+                               }
+                       }
+               }
+               return $op;
+       }
+
        /**
         * @see FileBackend::preloadCache()
         */
index 49111d9..60be5ee 100644 (file)
@@ -466,7 +466,7 @@ abstract class FileOp {
 class CreateFileOp extends FileOp {
        protected function allowedParams() {
                return array( array( 'content', 'dst' ),
-                       array( 'overwrite', 'overwriteSame', 'disposition' ) );
+                       array( 'overwrite', 'overwriteSame', 'disposition', 'headers' ) );
        }
 
        protected function doPrecheck( array &$predicates ) {
@@ -529,7 +529,7 @@ class StoreFileOp extends FileOp {
         */
        protected function allowedParams() {
                return array( array( 'src', 'dst' ),
-                       array( 'overwrite', 'overwriteSame', 'disposition' ) );
+                       array( 'overwrite', 'overwriteSame', 'disposition', 'headers' ) );
        }
 
        /**
index 48db9d3..624374b 100644 (file)
@@ -236,6 +236,10 @@ class SwiftFileBackend extends FileBackendStore {
                        if ( isset( $params['disposition'] ) ) {
                                $obj->headers['Content-Disposition'] = $this->truncDisp( $params['disposition'] );
                        }
+                       // Set any other custom headers if requested
+                       if ( isset( $params['headers'] ) ) {
+                               $obj->headers += $params['headers'];
+                       }
                        if ( !empty( $params['async'] ) ) { // deferred
                                $op = $obj->write_async( $params['content'] );
                                $status->value = new SwiftFileOpHandle( $this, $params, 'Create', $op );
@@ -315,6 +319,10 @@ class SwiftFileBackend extends FileBackendStore {
                        if ( isset( $params['disposition'] ) ) {
                                $obj->headers['Content-Disposition'] = $this->truncDisp( $params['disposition'] );
                        }
+                       // Set any other custom headers if requested
+                       if ( isset( $params['headers'] ) ) {
+                               $obj->headers += $params['headers'];
+                       }
                        if ( !empty( $params['async'] ) ) { // deferred
                                wfSuppressWarnings();
                                $fp = fopen( $params['src'], 'rb' );
index 651ee27..91fbaab 100644 (file)
@@ -1027,18 +1027,25 @@ class FileRepo {
         * Returns a FileRepoStatus object. On success, the value contains "new" or
         * "archived", to indicate whether the file was new with that name.
         *
+        * Options to $options include:
+        *   - headers : name/value map of HTTP headers to use in response to GET/HEAD requests
+        *
         * @param $srcPath String: the source file system path, storage path, or URL
         * @param $dstRel String: the destination relative path
         * @param $archiveRel String: the relative path where the existing file is to
         *        be archived, if there is one. Relative to the public zone root.
         * @param $flags Integer: bitfield, may be FileRepo::DELETE_SOURCE to indicate
         *        that the source file should be deleted if possible
+        * @param $options Array Optional additional parameters
         * @return FileRepoStatus
         */
-       public function publish( $srcPath, $dstRel, $archiveRel, $flags = 0 ) {
+       public function publish(
+               $srcPath, $dstRel, $archiveRel, $flags = 0, array $options = array()
+       ) {
                $this->assertWritableRepo(); // fail out if read-only
 
-               $status = $this->publishBatch( array( array( $srcPath, $dstRel, $archiveRel ) ), $flags );
+               $status = $this->publishBatch(
+                       array( array( $srcPath, $dstRel, $archiveRel, $options ) ), $flags );
                if ( $status->successCount == 0 ) {
                        $status->ok = false;
                }
@@ -1054,7 +1061,8 @@ class FileRepo {
        /**
         * Publish a batch of files
         *
-        * @param $triplets Array: (source, dest, archive) triplets as per publish()
+        * @param $triplets Array: (source, dest, archive) triplets or
+        *        (source, dest, archive, options) quartets as per publish().
         * @param $flags Integer: bitfield, may be FileRepo::DELETE_SOURCE to indicate
         *        that the source files should be deleted if possible
         * @throws MWException
@@ -1077,6 +1085,7 @@ class FileRepo {
                // Validate each triplet and get the store operation...
                foreach ( $triplets as $i => $triplet ) {
                        list( $srcPath, $dstRel, $archiveRel ) = $triplet;
+                       $options = isset( $triplet[3] ) ? $triplet[3] : array();
                        // Resolve source to a storage path if virtual
                        $srcPath = $this->resolveToStoragePath( $srcPath );
                        if ( !$this->validateFilename( $dstRel ) ) {
@@ -1100,6 +1109,9 @@ class FileRepo {
                                return $this->newFatal( 'directorycreateerror', $archiveDir );
                        }
 
+                       // Set any desired headers to be use in GET/HEAD responses
+                       $headers = isset( $options['headers'] ) ? $options['headers'] : array();
+
                        // Archive destination file if it exists.
                        // This will check if the archive file also exists and fail if does.
                        // This is a sanity check to avoid data loss. On Windows and Linux,
@@ -1117,25 +1129,28 @@ class FileRepo {
                        if ( FileBackend::isStoragePath( $srcPath ) ) {
                                if ( $flags & self::DELETE_SOURCE ) {
                                        $operations[] = array(
-                                               'op'           => 'move',
-                                               'src'          => $srcPath,
-                                               'dst'          => $dstPath,
-                                               'overwrite'    => true // replace current
+                                               'op'        => 'move',
+                                               'src'       => $srcPath,
+                                               'dst'       => $dstPath,
+                                               'overwrite' => true, // replace current
+                                               'headers'   => $headers
                                        );
                                } else {
                                        $operations[] = array(
-                                               'op'           => 'copy',
-                                               'src'          => $srcPath,
-                                               'dst'          => $dstPath,
-                                               'overwrite'    => true // replace current
+                                               'op'        => 'copy',
+                                               'src'       => $srcPath,
+                                               'dst'       => $dstPath,
+                                               'overwrite' => true, // replace current
+                                               'headers'   => $headers
                                        );
                                }
                        } else { // FS source path
                                $operations[] = array(
-                                       'op'           => 'store',
-                                       'src'          => $srcPath,
-                                       'dst'          => $dstPath,
-                                       'overwrite'    => true // replace current
+                                       'op'        => 'store',
+                                       'src'       => $srcPath,
+                                       'dst'       => $dstPath,
+                                       'overwrite' => true, // replace current
+                                       'headers'   => $headers
                                );
                                if ( $flags & self::DELETE_SOURCE ) {
                                        $sourceFSFilesToDelete[] = $srcPath;
index 9a080ae..50bda52 100644 (file)
@@ -1390,17 +1390,21 @@ abstract class File {
         * The archive name should be passed through to recordUpload for database
         * registration.
         *
+        * Options to $options include:
+        *   - headers : name/value map of HTTP headers to use in response to GET/HEAD requests
+        *
         * @param $srcPath String: local filesystem path to the source image
         * @param $flags Integer: a bitwise combination of:
         *     File::DELETE_SOURCE    Delete the source file, i.e. move
         *         rather than copy
+        * @param $options Array Optional additional parameters
         * @return FileRepoStatus object. On success, the value member contains the
         *     archive name, or an empty string if it was a new file.
         *
         * STUB
         * Overridden by LocalFile
         */
-       function publish( $srcPath, $flags = 0 ) {
+       function publish( $srcPath, $flags = 0, array $options = array() ) {
                $this->readOnlyError();
        }
 
index f0a3c15..c2c17f5 100644 (file)
@@ -951,7 +951,7 @@ class LocalFile extends File {
 
        /**
         * Upload a file and record it in the DB
-        * @param $srcPath String: source storage path or virtual URL
+        * @param $srcPath String: source storage path, virtual URL, or filesystem path
         * @param $comment String: upload description
         * @param $pageText String: text to use for the new description page,
         *                  if a new description page is created
@@ -972,11 +972,27 @@ class LocalFile extends File {
                        return $this->readOnlyFatalStatus();
                }
 
+               if ( !$props ) {
+                       wfProfileIn( __METHOD__ . '-getProps' );
+                       $props = FileBackend::isStoragePath( $srcPath )
+                               ? $this->repo->getFileProps( $srcPath )
+                               : FSFile::getPropsFromPath( $srcPath );
+                       wfProfileOut( __METHOD__ . '-getProps' );
+               }
+
+               $options = array();
+               $handler = MediaHandler::getHandler( $props['mime'] );
+               if ( $handler ) {
+                       $options['headers'] = $handler->getStreamHeaders( $props['metadata'] );
+               } else {
+                       $options['headers'] = array();
+               }
+
                // truncate nicely or the DB will do it for us
                // non-nicely (dangling multi-byte chars, non-truncated version in cache).
                $comment = $wgContLang->truncate( $comment, 255 );
                $this->lock(); // begin
-               $status = $this->publish( $srcPath, $flags );
+               $status = $this->publish( $srcPath, $flags, $options );
 
                if ( $status->successCount > 0 ) {
                        # Essentially we are displacing any existing current file and saving
@@ -1252,11 +1268,12 @@ class LocalFile extends File {
         * @param $srcPath String: local filesystem path to the source image
         * @param $flags Integer: a bitwise combination of:
         *     File::DELETE_SOURCE      Delete the source file, i.e. move rather than copy
+        * @param $options Array Optional additional parameters
         * @return FileRepoStatus object. On success, the value member contains the
         *     archive name, or an empty string if it was a new file.
         */
-       function publish( $srcPath, $flags = 0 ) {
-               return $this->publishTo( $srcPath, $this->getRel(), $flags );
+       function publish( $srcPath, $flags = 0, array $options = array() ) {
+               return $this->publishTo( $srcPath, $this->getRel(), $flags, $options );
        }
 
        /**
@@ -1270,10 +1287,11 @@ class LocalFile extends File {
         * @param $dstRel String: target relative path
         * @param $flags Integer: a bitwise combination of:
         *     File::DELETE_SOURCE      Delete the source file, i.e. move rather than copy
+        * @param $options Array Optional additional parameters
         * @return FileRepoStatus object. On success, the value member contains the
         *     archive name, or an empty string if it was a new file.
         */
-       function publishTo( $srcPath, $dstRel, $flags = 0 ) {
+       function publishTo( $srcPath, $dstRel, $flags = 0, array $options = array() ) {
                if ( $this->getRepo()->getReadOnlyReason() !== false ) {
                        return $this->readOnlyFatalStatus();
                }
@@ -1283,7 +1301,7 @@ class LocalFile extends File {
                $archiveName = wfTimestamp( TS_MW ) . '!'. $this->getName();
                $archiveRel = 'archive/' . $this->getHashPath() . $archiveName;
                $flags = $flags & File::DELETE_SOURCE ? LocalRepo::DELETE_SOURCE : 0;
-               $status = $this->repo->publish( $srcPath, $dstRel, $archiveRel, $flags );
+               $status = $this->repo->publish( $srcPath, $dstRel, $archiveRel, $flags, $options );
 
                if ( $status->value == 'new' ) {
                        $status->value = '';
index 965099f..558a9b6 100644 (file)
@@ -243,6 +243,15 @@ abstract class MediaHandler {
                return array( $ext, $mime );
        }
 
+       /**
+        * Get useful response headers for GET/HEAD requests for a file with the given metadata
+        * @param $metadata mixed Result this handlers getMetadata() for a file
+        * @return Array
+        */
+       public function getStreamHeaders( $metadata ) {
+               return array();
+       }
+
        /**
         * True if the handled types can be transformed
         * @return bool