Class for upload/download management
Class for upload/download management
Here's a class for managing the upload and download of files via PHP. It supports HTTP
Range (for partial downloads and continuing), cache control, ETags, if-modified-since,
and the usual suspects like forcing the dialog "Save file as" for mime types that would
be directly shown in the browser (like images or text files). ResourceFile
can be used
directly or extended to make own constant settings. An example how the downloading works
is described in the example implementation here:
Anbei eine Klasse, mit der Uploads und Downloads von Dateien mit PHP einfach gestaltet werden kann. Unterstützt ist HTTP-Range, Cache control, ETag, if-modified-since, und die üblichen Funktionen wie das Erzwingen des "Datei speichern unter"-Dialogs oder konfigurierbare Mime-Types. Die Klasse kann sowohl direkt verwendet, alsauch durch Vererbung konfiguriert werden. Wie es geht zeigt dieser Beispiel-Quelltext:
Sample source code
Anwendungsbeispiel
<?php
require_once('swlib/swlib.class.php');
use sw\Tracer;
use sw\FileSystem;
use sw\ResourceFile;
// We create a root path for the ResourceFile class, that makes is safer
$mediaLibraryPath = FileSystem::getTempDirectory() . '/example_medialibrary';
$file = 'file.txt';
if(!FileSystem::isDirectory($mediaLibraryPath)) {
FileSystem::mkdir($mediaLibraryPath, 0777);
}
// Make a file we want to download ...
FileSystem::writeFile("$mediaLibraryPath/$file", "That's a text...");
// Two examples how to use the class:
if($_GET['inherited']) {
// Here is an example for setting fixed and unchangable settings by
// deriving form the original ResourceFile class. Of cause, this is
// not required ...
class MyResourceFile extends ResourceFile {
// Example: Constant root resource directory
public function getFileRootDirectory() {
return FileSystem::getTempDirectory() . '/example_medialibrary';
}
// Example: Constant expiry time
public function getExpireTime() {
return 3600;
}
}
$rc = new MyResourceFile($file);
} else {
$rc = new ResourceFile(
"/$file", // sub path (with respect to the root path below)
$mediaLibraryPath, // resources root path
null, // Expiry time in seconds, we leave it at default
'private', // can be 'private', 'public' or 'nocache'
false // force that the file is downloaded as file
);
}
// Some examples what you can get for use in your source code:
$resourceName = $rc->getName();
$filesize = $rc->getSize();
$lastModified = $rc->getLastModified();
$withRespectToRoot = $rc->getPath();
$wholePath = $rc->getFilePath();
$extension = $rc->getExtension();
$parentDirectory = $rc->getDirectory();
$fileExists = $rc->exists();
$mime = $rc->getMimeType();
$etag = $rc->getETag();
$md5 = $rc->getMD5();
$cacheControl = $rc->getCacheControl();
$expires = $rc->getExpireTime();
$mustDownload = $rc->getForceSaveFile();
// Ok. let's download it
$rc->download();
Output
These are the request/response headers:
Ausgabe
So sehen dann die Request/Response-Headers aus:
Request Headers
Accept:application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,...
Cache-Control:max-age=0
Referer:http://htx.localhost/tests/
User-Agent:Mozilla/5.0 (Macintosh; U; Intel ...
Response Headers
Accept-Ranges:bytes
Cache-Control:private, must-revalidate, max-age=1278280363
Connection:close
Content-Disposition:inline; filename="file.txt"
Content-Length:27
Content-Range:bytes 0-16/16
Content-Type:text/plain
Date:Sun, 04 Jul 2010 20:52:43 GMT
Etag:"8dc4b39f096b4b6823d6722fe0fd7bad"
Expires:Sun, 04 Jul 2010 21:52:43 GMT
Last-Modified:Sun, 04 Jul 2010 20:52:43 GMT
Server:Apache/2.2.14 (Unix) DAV/2 mod_ssl/2.2.14 OpenSSL ...
X-Powered-By:PHP/5.3.1
Class source code
Well, and this is the class source code. Some annotations
Make sure that you don't send any text before, this would mess up the download. Use an output buffer (e.g. the class OutputBuffer in this category).
I extended the class, so that the checksum, date etc are saved in the database. This makes sure that you can see if the file was modified in-between (e.g. by another process or script).
Klassen-Quelltext
So, und hier der der Klassen-Quelltext. Noch ein paar Anmerkungen:
Stelle sicher, dass nichts vor dem Download gesendet wird, denn dann ist die Datei falsch und die HTTP-Header bereits gesendet. Ausgabepufferung hilft dies zu vermeiden (die Klasse
Outputuffer
ist in dieser Kategorie)Für mich habe ich die Klasse erweitert, so dass die Checksumme, Datum usw. aus der Datenbank geladen werden. Das sorgt nicht nur für höhere Ausführungsgeschwindigkeit, sondern stellt auch sicher, dass es bemerkt wird wenn die Datei zwischendurch geändert wurde.
<?php
/**
* Resource file class, implemented to manage downloads and uploads of files.
* Provides HTTP-Range (partial download), ETAG, If-Modified-Since, auto
* detection of the mime type, and cache control.
* Base for downloadable or requestable
* resources saved in a database or as
* files. Provides mime type handling
* and output with http header information.
*
* @gpackage de.atwillys.sw.php.swLib
* @author Stefan Wilhelm
* @copyright Stefan Wilhelm, 2007-2010
* @license GPL
* @version 1.0
* @uses FileSystem
* @uses Tracer
*/
namespace sw;
class ResourceFile {
/**
* Stores the possible mime types
* Use getMimeType() function to
* access the mime types, the array
* ist filled with data on first
* access to getMimeType().
* @staticvar array
*/
protected static $mimeTypes = array();
/**
* The full path with directory, file name
* and extension, but not including the
* root directory.
* @var string
*/
protected $path = '';
/**
* Defines if the "Save on Disk" Dialog
* has to be shown, even if the Browser
* can show the file.
* @var bool
*/
private $forceSaveFile = false;
/**
* Defines the cache control, acceptet is "private", "public", "nocache",
* "omit", Wrong values are interpreted as omit.
* @var string
*/
private $cacheControl = 'omit';
/**
* Defines if HTTP Range for partial downloading is enabled
* @var bool
*/
private $enableHttpRange = true;
/**
* The expiry time in seconds
* @var int
*/
private $expireTime = 3600;
/**
* Specifies the root path which $path is relative to.
* This can prevent storing and reading files from a wrong
* path. The setter of the variable is set-once.
* @var string
*/
private $rootDirectory = '';
/**
* Specifies the chunk size to read from disk and pass through.
* @var unsigned int
*/
private $readChunkSizeKB = 1024;
/**
* Assigns a new resource object by specifying
* the identifying part or the path (e.g.
* /my_folder/my_file.ext, the real local path
* might be $_SERVER['DOCUMENT_ROOT']/files/my_folder/my_file.ext,
* or any prefix in a database, but this is
* the path that identifies the resource.
* @param string $subpath
*/
public function __construct($subpath='', $rootPath=null, $expireTime=null, $cacheControl=null, $forceSaveFile=null) {
if (strlen($subpath) > 0)
$this->path = $subpath;
if (!empty($rootPath))
$this->rootDirectory = $rootPath;
if (!is_null($expireTime))
$this->setExpireTime($expireTime);
if (!empty($cacheControl))
$this->setCacheControl($cacheControl);
if (!is_null($forceSaveFile))
$this->setForceSaveFile($forceSaveFile);
}
/**
* Returns the root directory of the filesystem saved
* files (if database files it is the cache, if 'normal'
* files it is the local directory where the resource
* files are saved in.
* E.g. $_SERVER['DOCUMENT_ROOT'] . '/files'
* @return string
*/
public function getFileRootDirectory() {
return $this->rootDirectory;
}
/**
* Returns content expire time in seconds.
* @return int
*/
public function getExpireTime() {
return $this->expireTime;
}
/**
* Sets the new expiry time interval in seconds.
* This is not an absolute timestamp (which will be
* calculated during the download).
* @param int $seconds
*/
public function setExpireTime($seconds) {
if (!is_numeric($seconds)) {
throw new LException("Wrong expiry time specified: :sec", array(':sec' => $seconds));
} else {
$this->expireTime = intval($seconds);
}
}
/**
* Returns the path with resource name and extension
* in the local root path.
* @return string
*/
public final function getPath() {
return $this->path;
}
/**
* Returns the path to the server loacal saved
* file. This is an absolute system file patb.
* @return string
*/
public final function getFilePath() {
return $this->getFileRootDirectory() . '/' . trim($this->getPath(), ' /');
}
/**
* Returns the resource name.
* @return string
*/
public final function getName() {
return basename($this->path);
}
/**
* Returns the resource extension.
* @return string
*/
public final function getExtension() {
$p = strrpos($this->getName(), '.');
if ($p === false || $p == strlen($this->getName())) {
return '';
} else {
return substr($this->getName(), $p + 1);
}
}
/**
* Returns the resource parent directory.
* @return string
*/
public final function getDirectory() {
return dirname($this->path);
}
/**
* Returns the mime type by parsing the
* extension.
* @return string
*/
public function getMimeType() {
if (empty(self::$mimeTypes)) {
self::$mimeTypes = array(
'txt' => 'text/plain',
'htm' => 'text/html',
'html' => 'text/html',
'php' => 'text/html',
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'xml' => 'application/xml',
'swf' => 'application/x-shockwave-flash',
'flv' => 'video/x-flv',
'png' => 'image/png',
'jpe' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'jpg' => 'image/jpeg',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'ico' => 'image/vnd.microsoft.icon',
'tiff' => 'image/tiff',
'tif' => 'image/tiff',
'svg' => 'image/svg+xml',
'svgz' => 'image/svg+xml',
'zip' => 'application/zip',
'rar' => 'application/x-rar-compressed',
'exe' => 'application/x-msdownload',
'msi' => 'application/x-msdownload',
'cab' => 'application/vnd.ms-cab-compressed',
'mp3' => 'audio/mpeg',
'mp4' => 'video/mp4',
'm4a' => 'audio/x-m4a',
'm4v' => 'video/x-m4v',
'epub' => 'document/x-epub',
'qt' => 'video/quicktime',
'mov' => 'video/quicktime',
'pdf' => 'application/pdf',
'psd' => 'image/vnd.adobe.photoshop',
'ai' => 'application/postscript',
'eps' => 'application/postscript',
'ps' => 'application/postscript',
'doc' => 'application/msword',
'rtf' => 'application/rtf',
'xls' => 'application/vnd.ms-excel',
'ppt' => 'application/vnd.ms-powerpoint',
'odt' => 'application/vnd.oasis.opendocument.text',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet'
);
}
$ext = $this->getExtension();
if (isset(self::$mimeTypes[$ext])) {
return self::$mimeTypes[$ext];
} else {
return 'application/octet-stream';
}
}
/**
* Returns the file size in bytes
* @return int
*/
public function getSize() {
$s = filesize($this->getFilePath());
if ($s === false) {
throw new LException('Failed to get file size');
} else {
return $s;
}
}
/**
* Returns the unix timestamp when the resource
* was last modified. Overload this function for
* database usage.
* @return int
*/
public function getLastModified() {
$t = @filemtime($this->getFilePath());
if ($t === false) {
throw new LException('Getting file last modified time failed');
} else {
return $t;
}
}
/**
* Returns the md5 checksum of the resource.
* Overload this function for database usage.
* @return int
*/
public function getMD5() {
$t = md5_file($this->getFilePath());
if ($t === false) {
throw new LException('Getting file md5 failed');
} else {
return $t;
}
}
/**
* Returns if the resource exists
* @return bool
*/
public function exists() {
return is_file($this->getFilePath()) ? true : false;
}
/**
* Returns an ETAG for http header information.
* @return string
*/
public function getETag() {
return md5($this->getFilePath() . $this->getLastModified() . $this->getSize());
}
/**
* Returns if the "Save file dialog" is to bs
* shown, even if the browser is able to interprete
* the file.
* @return bool
*/
public function getForceSaveFile() {
return $this->forceSaveFile;
}
/**
* Sets if the "Save file dialog" is to bs
* shown, even if the browser is able to interprete
* the file.
* @param bool $enable
*/
public function setForceSaveFile($enable) {
$this->forceSaveFile = $enable ? true : false;
}
/**
* Returns the cache control setting. Values are "public", "private"
* or "nocache"
* @return string
*/
public function getCacheControl() {
return $this->cacheControl;
}
/**
* Sets the cache control setting. Values are "public", "private"
* or "nocache"
* @return string
*/
public function setCacheControl($ctrl) {
$this->cacheControl = $this->cacheControl = trim(strtolower($ctrl));
switch ($this->cacheControl) {
case 'public':
case 'private':
case 'nocache':
case 'omit':
break;
default:
$this->cacheControl = 'omit';
Tracer::trace("Warning: wrong cache control set: $ctrl");
}
}
/**
* Moves uploaded file to a subfolder of
* getFileRootDirectory(). $postfile is a
* string name of a $_FILES['$postfile'] element.
* $targetFile is subpath and file name. The extension
* will be automatically added.
* @param string $postfile
* @param string $targetFile=''
* @param bool $overwrite=false
*/
public function upload($postfile, $targetFile='', $overwrite=false) {
// Initialize path to "invalid"
$this->path = '';
$postfile = trim(strtolower($postfile));
$files = array_change_key_case($_FILES);
if (!isset($files[$postfile])) {
throw new LException('No file with this (input form) name posted');
} else if (!is_array($files[$postfile])) {
throw new LException('The the $_POST[] array is empty.');
} else {
$postfile = array_change_key_case($files[$postfile], CASE_LOWER);
unset($files);
if ($postfile['name'] == '') {
throw new LException('No file to upload selected');
} else if (!isset($postfile['error']) || $postfile['error'] != 0) {
throw new LException('The uploaded file was not uploaded completly');
} else if (false && $postfile['size'] == 0) { // TODO: Hmm, this could also be a normal use case ... must be checked
throw new LException('Upload file is empty');
} else {
$fname = trim($postfile['name']);
$ftype = trim($postfile['type']);
$ftemp = trim($postfile['tmp_name']);
$fsize = $postfile['size'];
$fext = FileSystem::getExtension($fname);
if ($fsize != filesize($ftemp)) {
throw new LException('The file size of the uploaded file does not match the size specified by the client');
} else {
if (empty($targetFile)) {
// Generate target file name
$targetFile = $this->getFileRootDirectory() . '/' . $fname;
} else {
$targetFile = $this->getFileRootDirectory() . '/' . trim($targetFile, ' /');
}
if (!$overwrite && FileSystem::exists($targetFile)) {
throw new LException('The file to upload already exists in the file system');
} else {
// Move uploaded file
if (!move_uploaded_file($ftemp, $targetFile) === null) {
if (!is_writable(dirname($targetFile))) {
throw new LException('Target file parent directory is not writable for you.');
} else {
throw new LException('Failed to move uploaded file');
}
} else {
$this->path = trim(str_replace($this->getFileRootDirectory(), '', $targetFile), ' /');
}
}
}
}
}
}
/**
* Binary passthrough to the http client, if no
* file name is specified the resource file name
* is used. $forSaveOnLocalComputer defines if
* always the "save file to" is displayed in
* the browser (attachment disposition).
* @param string $filename
* @param bool $forSaveOnLocalComputer
*/
public function download($fileName='') {
if ($fileName == '') {
$fileName = $this->getName();
}
// Clear all actual output buffers and drop'em, cause the session
// to write the session file (to prevent lock errors for large
// file downloads)
while (ob_get_level() > 0) {
@ob_end_clean();
}
@session_write_close();
@set_time_limit(5);
if ((headers_sent($hs_file, $hs_line))) {
Tracer::trace("Headers already sent in ':ffl'", array(':ffl' => "$hs_file:$hs_line"));
throw new LException('Headers already sent');
} else if (connection_status() != 0) {
Tracer::trace('Connection was closed');
throw new LException('Connection closed');
} else if ($fileName == '') {
Tracer::trace('File name is empty' . $this->getFilePath());
throw new LException('File name is empty');
} else if (!$this->exists()) {
Tracer::trace('File not found:' . $this->getFilePath());
throw new LException('File not found');
} else if (substr($this->getName(), 0, 1) == '.') {
Tracer::trace('File is hidden (.anything)' . $this->getFilePath());
throw new LException('File hidden');
} else {
Tracer::trace('file exists and not hidden');
$request_headers = array_change_key_case(apache_request_headers(), CASE_LOWER);
$fileMdyf = $this->getLastModified();
$fileetag = $this->getETag();
if (isset($request_headers['if-modified-since']) && @strftime("D, d M Y H:i:s", $request_headers['if-modified-since']) == $fileMdyf) {
Tracer::trace('if-modified-since matched, send not modified 304');
header('Content-Length: 0', true, 304);
} else if (isset($request_headers['if-none-match']) && stripos($fileetag, $request_headers['if-none-match']) !== false) {
Tracer::trace('if-none-match matched, send not modified 304');
header('Content-Length: 0', true, 304);
} else {
$fileSize = $this->getSize();
$fileMime = $this->getMimeType();
$fileExpr = time() + $this->expireTime;
$filePath = $this->getFilePath();
header_remove('Content-Language');
header_remove('X-Generator');
header_remove('X-Powered-By');
header_remove('Set-Cookie');
header('HTTP/1.1 200 OK', true, 200);
header("Last-Modified: " . @gmdate("D, d M Y H:i:s", $fileMdyf) . " GMT", true);
header('ETag: "' . $fileetag . '"', true);
switch ($this->cacheControl) {
case 'private':
Tracer::trace('Cache control: private');
header('Cache-Control: private, must-revalidate, max-age=' . $fileExpr, true);
header('Expires: ' . @gmdate('D, d M Y H:i:s', $fileExpr) . ' GMT', true);
header_remove('Pragma');
break;
case 'public':
Tracer::trace('Cache control: public');
header('Cache-Control: public, must-revalidate, max-age=' . $fileExpr, true);
header('Expires: ' . @gmdate('D, d M Y H:i:s', $fileExpr) . ' GMT', true);
header_remove('Pragma');
break;
case 'nocache':
Tracer::trace('Cache control: nocache');
header('Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0');
header('Expires: ' . @gmdate('D, d M Y H:i:s', $fileExpr) . ' GMT', true);
header('Pragma: no-cache', true);
case 'omit':
default:
header_remove('Cache-Control');
header_remove('Expires');
header_remove('Pragma');
}
if ($this->forceSaveFile) {
Tracer::trace('adding the "for local saving headers"');
header('Content-Disposition: attachment; filename="' . $fileName . '"', true);
header('Content-Transfer-Encoding: binary', true);
}
// header("Content-Length: " . $fileSize, true);
header('Content-Type: ' . $fileMime, true);
header('Accept-Ranges: bytes', true);
// Prepare send
$ranges = array();
if(isset($_SERVER['HTTP_RANGE'])) {
$range = explode('=', str_replace(' ', '', $_SERVER['HTTP_RANGE']), 2);
if(count($range) != 2 && strtyolower(trim(reset($range))) != 'bytes') {
header("HTTP/1.1 505 Internal server error", true, 505);
throw new LException('Wrong range definition (only "bytes=" accepted)');
}
$range = explode(',', end($range));
if(count($range) > 1) {
header("HTTP/1.1 505 Internal server error", true, 505);
throw new LException('Multipart responses for multiple ranges not supported yet.');
}
foreach($range as $r) {
$r = explode('-', $r);
foreach($r as $k => $v) $r[$k] = trim($v);
if(count($r) > 2) {
header("HTTP/1.1 505 Internal server error", true, 505);
throw new LException('Unsupported range format: ' . $_SERVER['HTTP_RANGE']);
}
$fs = is_numeric($r[0]) ? intval($r[0]) : null;
$fe = is_numeric($r[1]) ? intval($r[1]) : null;
if($fs !== null && $fe !== null) {
$ranges[] = array('s' => $fs, 'e' => $fe);
} else if($fs !== null && $fe === null) {
$ranges[] = array('s' => $fs, 'e' => $fileSize-1);
} else if($fs === null && $fe !== null) {
$ranges[] = array('s' => $fileSize-$fe, 'e' => $fileSize-1);
} else {
header("HTTP/1.1 505 Internal server error", true, 505);
throw new LException('Unsupported range format: ' . $_SERVER['HTTP_RANGE']);
}
}
}
if(empty($ranges)) {
$ranges[] = array('s' => 0, 'e' => $fileSize-1);
}
// Send the file
if (!($pfile = @fopen($filePath, 'rb'))) {
header("HTTP/1.1 505 Internal server error", true, 505);
throw new LException('Failed to open file for range download: :path', array(':path' => $filePath));
} else {
$fileChunkSize = intval($this->readChunkSizeKB) * 1024;
if($fileChunkSize < 4096) $fileChunkSize = 4096;
foreach($ranges as $i => $range) {
Tracer::trace('passing range[' . $i . '] ...');
$fs = $range['s'];
$fe = $range['e'];
//// This will be problematic for multipart content ...
header('Content-Length:' . ($fe-$fs+1), true);
if(isset($_SERVER['HTTP_RANGE'])) header("Content-Range: bytes $fs-$fe/$fileSize", true);
@fseek($pfile, 0, 0);
$fp = $fs;
@fseek($pfile, $fs, 0);
while (!feof($pfile) && $fp <= $fe && (connection_status() == NORMAL)) {
print @fread($pfile, min($fileChunkSize, $fe-$fp+1));
$fp += $fileChunkSize;
@set_time_limit(3);
}
}
@fclose($pfile);
}
}
}
}
}