Last active
May 9, 2018 01:05
-
-
Save hperrin/8833977 to your computer and use it in GitHub Desktop.
PHP Slim archiving class. Slim is a portable file archive format based on JSON. It can be self extracting in multiple languages.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* Slim archiving class. | |
* | |
* Slim is a portable file archive format based on JSON. It can be self | |
* extracting in multiple languages. | |
* | |
* @license https://www.apache.org/licenses/LICENSE-2.0 | |
* @author Hunter Perrin <[email protected]> | |
* @copyright SciActive.com | |
* @link http://sciactive.com/ | |
*/ | |
class Slim { | |
/** | |
* Slim file format version. | |
*/ | |
const SLIM_VERSION = '1.0'; | |
/** | |
* The header data of the archive. | |
* | |
* The header array contains information about the archive: | |
* | |
* - files - An array of the files, directories, and links in the archive. | |
* - comp - Type of compression used on the archive. | |
* - compl - Level of compression used on the archive. | |
* - ichk - Whether integrity checks are used on the file data. | |
* - ext - Extra data. | |
* | |
* @var array | |
*/ | |
private $header = array(); | |
/** | |
* The files to be written to the archive. | |
* | |
* @var array | |
*/ | |
private $files = array(); | |
/** | |
* The entries (virtual files) to be written to the archive. | |
* | |
* @var array | |
*/ | |
private $entries = array(); | |
/** | |
* The offset in bytes of the beginning of the file stream. | |
* | |
* @var int | |
*/ | |
private $streamOffset; | |
/** | |
* The compression filter's resource handle. | |
* | |
* @var resource | |
*/ | |
private $compressionFilter; | |
/** | |
* The stub to place at the beginning of the file. | |
* | |
* The stub may begin with a shebang, such as: | |
* | |
* - #!/bin/sh | |
* - #! /usr/bin/php | |
* | |
* The next line (or the first line, if the shebang is omitted) *must* end | |
* with the string "slim1.0", such as: | |
* | |
* - slim1.0 | |
* - <?php //slim1.0 | |
* - #slim1.0 | |
* | |
* The stub cannot contain a line with only the string "HEADER", because | |
* that line signifies the beginning of the archive header. | |
* | |
* @var string | |
*/ | |
public $stub = 'slim1.0'; | |
/** | |
* Extra data to be included in the archive. | |
* | |
* This can be anything. | |
* | |
* @var array | |
*/ | |
public $ext = array(); | |
/** | |
* The filename of the archive. | |
* | |
* @var string | |
*/ | |
public $filename = ''; | |
/** | |
* Whether to compress the JSON header. | |
* | |
* Header compression always uses deflate. (RFC 1951) | |
* | |
* @var bool | |
*/ | |
public $headerCompression = true; | |
/** | |
* The compression level (1-9) to use during header compression. | |
* | |
* -1 signifies default compression level. | |
* | |
* @var int | |
*/ | |
public $headerCompressionLevel = 9; | |
/** | |
* The type of compression to use when saving the file. | |
* | |
* Currently the only compression types supported by this implementation of | |
* the Slim format are deflate and bzip2. | |
* | |
* @var string | |
*/ | |
public $compression = 'deflate'; | |
/** | |
* The compression level (1-9) to use during compression. | |
* | |
* -1 signifies default compression level. | |
* | |
* Only works for deflate. | |
* | |
* @var int | |
*/ | |
public $compressionLevel = 9; | |
/** | |
* The directory to work within. | |
* | |
* When adding/extracting files, relative paths will be based on this path. | |
* | |
* @var string | |
*/ | |
public $workingDirectory = ''; | |
/** | |
* Try to preserve file user and group. | |
* | |
* @var bool | |
*/ | |
public $preserveOwner = false; | |
/** | |
* Try to preserve file permissions. | |
* | |
* @var bool | |
*/ | |
public $preserveMode = false; | |
/** | |
* Try to preserve file access/modified times. | |
* | |
* @var bool | |
*/ | |
public $preserveTimes = false; | |
/** | |
* Use MD5 sums of files to check their integrity. | |
* | |
* @var bool | |
*/ | |
public $fileIntegrity = false; | |
/** | |
* Don't extract files into parent directories. | |
* | |
* This causes '..' directories to be changed to '__' (two underscores). | |
* | |
* @var bool | |
*/ | |
public $noParents = true; | |
/** | |
* Add a slash to the end of a path, if it's not already there. | |
* | |
* @param string $path The path. | |
* @return string The new path. | |
*/ | |
private function addSlash($path) { | |
if ($path != '' && substr($path, -1) != '/') { | |
return "{$path}/"; | |
} | |
return $path; | |
} | |
/** | |
* Alter a path, with a regard to the current working directory. | |
* | |
* @param string $path The path to alter. | |
* @param bool $addingWorkingDir Whether to add or strip the working directory. | |
* @return string The new path. | |
*/ | |
private function makePath($path, $addingWorkingDir = true) { | |
if ($addingWorkingDir) { | |
if (substr($path, 1) != '/' && $this->workingDirectory != '') { // && substr($path, strlen($this->workingDirectory)) != $this->workingDirectory) | |
return $this->addSlash($this->workingDirectory) . $path; | |
} | |
return $path; | |
} else { | |
if ($this->workingDirectory != '' && substr($path, 0, strlen($this->workingDirectory)) == $this->workingDirectory) { // && substr($path, strlen($this->workingDirectory)) != $this->workingDirectory) | |
return substr($path, strlen($this->workingDirectory)); | |
} | |
return $path; | |
} | |
} | |
/** | |
* Apply the selected filters to a file handle. | |
* | |
* @param resource $handle The handle. | |
* @param string $mode 'r' for read filters, 'w' for write filters. | |
*/ | |
private function applyFilters($handle, $mode) { | |
switch ($this->compression) { | |
case 'deflate': | |
$this->compressionFilter = stream_filter_append($handle, $mode == 'w' ? 'zlib.deflate' : 'zlib.inflate', $mode == 'w' ? STREAM_FILTER_WRITE : STREAM_FILTER_READ, $this->compressionLevel); | |
break; | |
case 'bzip2': | |
$this->compressionFilter = stream_filter_append($handle, $mode == 'w' ? 'bzip2.compress' : 'bzip2.decompress', $mode == 'w' ? STREAM_FILTER_WRITE : STREAM_FILTER_READ); | |
break; | |
} | |
} | |
/** | |
* Check a path according to a regex filter. | |
* | |
* @param string $path The path to check. | |
* @param string|array $filter A regex pattern or an array of regex patterns. | |
* @return bool True if the path does not match any of the filters, false otherwise. | |
*/ | |
private function pathFilter($path, $filter) { | |
if (is_string($filter)) { | |
return !preg_match($filter, $path); | |
} | |
if (!is_array($filter)) { | |
return false; | |
} | |
foreach ($filter as $curFilter) { | |
if (preg_match($curFilter, $path)) { | |
return false; | |
} | |
} | |
return true; | |
} | |
/** | |
* Seek in a file. | |
* | |
* This function uses a workaround for seeking in compressed streams. It | |
* will use fread() instead of fseek(). | |
* | |
* @param resource $handle The handle. | |
* @param int $offset The offset to seek to. | |
* @param int $whence When set to SEEK_CUR, $offset will be based on $this->streamOffset. | |
* @return int 0 on success, -1 on failure. | |
*/ | |
private function fseek($handle, $offset, $whence = null) { | |
// SEEK_CUR always seeks from $this->streamOffset. | |
switch ($this->compression) { | |
case 'deflate': | |
case 'bzip2': | |
if (isset($whence)) { | |
if ($whence == SEEK_CUR){ | |
$distance = ftell($handle) - $this->streamOffset; | |
if ($distance) { | |
$test = $offset - $distance; | |
if ($test < 0) { | |
fseek($handle, 0); | |
stream_filter_remove($this->compressionFilter); | |
fseek($handle, $this->streamOffset); | |
$this->applyFilters($handle, 'r'); | |
} else { | |
$offset = $test; | |
} | |
} | |
if (!$offset) { | |
return 0; | |
} | |
do { | |
fread($handle, ($offset > 8192) ? 8192 : $offset); | |
$offset -= 8192; | |
} while ($offset > 0); | |
return 0; | |
} | |
return fseek($handle, $offset, $whence); | |
} else { | |
return fseek($handle, $offset); | |
} | |
break; | |
default: | |
if ($whence == SEEK_CUR) { | |
return fseek($handle, $this->streamOffset + $offset); | |
} elseif (isset($whence)) { | |
return fseek($handle, $offset, $whence); | |
} else { | |
return fseek($handle, $offset); | |
} | |
break; | |
} | |
} | |
/** | |
* Add a directory to the archive. | |
* | |
* @param string $path The path of the directory. | |
* @param bool $contents Whether to add the contents of the directory. | |
* @param bool $recursive Whether to recurse into subdirectories. | |
* @param mixed $filter A regex pattern or an array of regex patterns, which when matches a path, it will be excluded. | |
* @param bool $excludeVcs Whether to exclude SVN and CVS directories. | |
* @return bool True on success, false on failure. (All files being filtered is not considered a failure.) | |
*/ | |
public function addDirectory($path, $contents = true, $recursive = true, $filter = null, $excludeVcs = true) { | |
$relPath = $this->addSlash($path); | |
$absPath = $this->addSlash($this->makePath($path)); | |
if ($absPath != '' && !is_dir($absPath)) { | |
return false; | |
} | |
if ($absPath != '' && (is_null($filter) || $this->pathFilter($relPath, $filter))) { | |
$this->files[] = $absPath; | |
} | |
if (!$contents) { | |
return true; | |
} | |
$dirContents = scandir($absPath == '' ? '.' : $absPath); | |
if ($dirContents === false) { | |
return false; | |
} | |
foreach ($dirContents as $curPath) { | |
if ($curPath === '.' | |
|| $curPath === '..' | |
|| ( | |
$excludeVcs | |
&& ( | |
$curPath === '.git' | |
|| $curPath === '.hg' | |
|| $curPath === '.hgtags' | |
|| $curPath === '.svn' | |
|| $curPath === '.cvs' | |
) | |
) | |
|| ( | |
isset($filter) | |
&& !$this->pathFilter($relPath.$curPath, $filter) | |
) | |
) { | |
continue; | |
} | |
if (is_file($absPath.$curPath)) { | |
$this->files[] = $absPath.$curPath; | |
} elseif (is_dir($absPath.$curPath)) { | |
if ($recursive) { | |
if (!$this->addDirectory($relPath.$curPath, $contents, $recursive, $filter, $excludeVcs)) { | |
return false; | |
} | |
} else { | |
$this->files[] = $this->addSlash($absPath.$curPath); | |
} | |
} | |
} | |
return true; | |
} | |
/** | |
* Add a file to the archive. | |
* | |
* @param string $path The path of the file. | |
* @param mixed $filter A regex pattern or an array of regex patterns, which when matches a path, it will be excluded. | |
* @return bool True on success, false on failure. (The file being filtered is not considered a failure.) | |
*/ | |
public function addFile($path, $filter = null) { | |
if (!is_file($this->makePath($path))) { | |
return false; | |
} | |
if (isset($filter) && !$this->pathFilter($path, $filter)) { | |
return true; | |
} | |
$this->files[] = $this->makePath($path); | |
return true; | |
} | |
/** | |
* Add an entry directly to the archive. | |
* | |
* You can add files and directories that don't actually exist using this | |
* function. The entry path must be relative to the root of the archive. | |
* (Absolute paths will be cleaned.) | |
* | |
* The entry array requires at least the following entries: | |
* | |
* - "type" - The type of entry. One of "link", "file", or "dir". | |
* - "path" - The path of the entry. | |
* | |
* If "type" is "link", the following entry is required: | |
* | |
* - "target" - The target of the symlink. | |
* | |
* If "type" is "file", the following entry is required: | |
* | |
* - "data" - The contents of the file. | |
* | |
* The following entries are optional: | |
* | |
* - "uid" - The user id of the entry. (preserveOwner) | |
* - "gid" - The group id of the entry. (preserveOwner) | |
* - "mode" - The protection mode of the entry. (preserveMode) | |
* - "atime" - The last access time of the entry. (preserveTimes) | |
* - "mtime" - The last modified time of the entry. (preserveTimes) | |
* | |
* Be sure to add any parent directories first, before adding the entries | |
* they contain. | |
* | |
* @param array $entry The entry array. | |
* @return bool True on success, false on failure. | |
*/ | |
public function addEntry($entry) { | |
if (empty($entry) || !isset($entry['type']) || !isset($entry['path']) || $entry['path'] == '') { | |
return false; | |
} | |
$newEntry = array( | |
'type' => $entry['type'], | |
'path' => preg_replace('/^\/+/', '', $entry['path']) | |
); | |
switch ($entry['type']) { | |
case 'link': | |
if (!isset($entry['target']) || $entry['target'] == '') { | |
return false; | |
} | |
$newEntry['target'] = $entry['target']; | |
break; | |
case 'file': | |
if (!isset($entry['data'])) { | |
return false; | |
} | |
$newEntry['data'] = (string) $entry['data']; | |
break; | |
case 'dir': | |
$newEntry['path'] = $this->addSlash($newEntry['path']); | |
break; | |
default: | |
return false; | |
} | |
if (isset($entry['uid'])) { | |
$newEntry['uid'] = (int) $entry['uid']; | |
} | |
if (isset($entry['gid'])) { | |
$newEntry['gid'] = (int) $entry['gid']; | |
} | |
if (isset($entry['mode'])) { | |
$newEntry['mode'] = (int) $entry['mode']; | |
} | |
if (isset($entry['atime'])) { | |
$newEntry['atime'] = (int) $entry['atime']; | |
} | |
if (isset($entry['mtime'])) { | |
$newEntry['mtime'] = (int) $entry['mtime']; | |
} | |
$this->entries[] = $newEntry; | |
return true; | |
} | |
/** | |
* Write the archive to a file. | |
* | |
* @param string|null $filename The filename to write the archive to. | |
* @return bool True on success, false on failure. | |
*/ | |
public function write($filename = NULL) { | |
if (is_null($filename)) { | |
$filename = $this->filename; | |
} else { | |
$this->filename = $filename; | |
} | |
unset($this->header['comp']); | |
unset($this->header['compl']); | |
if (!empty($this->compression)) { | |
$this->header['comp'] = (string) $this->compression; | |
if ($this->compression == 'deflate') { | |
$this->header['compl'] = (int) $this->compressionLevel; | |
} | |
} | |
$this->header['files'] = array(); | |
$this->header['ichk'] = (bool) $this->fileIntegrity; | |
$this->header['ext'] = (array) $this->ext; | |
$offset = 0.00; | |
// Handle real files. | |
foreach ($this->files as $curFile) { | |
$curPath = $this->makePath($curFile, false); | |
if (is_link($curFile)) { | |
$newArray = array( | |
'type' => 'link', | |
'path' => $curPath, | |
'target' => readlink($curFile) | |
); | |
$fileInfo = lstat($curFile); | |
} elseif (is_file($curFile)) { | |
$curFileSize = (float) sprintf("%u", filesize($curFile)); | |
$newArray = array( | |
'type' => 'file', | |
'path' => $curPath, | |
'offset' => $offset, | |
'size' => $curFileSize | |
); | |
if ($this->fileIntegrity) { | |
$newArray['md5'] = md5_file($curFile); | |
} | |
$offset += $curFileSize; | |
$fileInfo = stat($curFile); | |
} elseif (is_dir($curFile)) { | |
$newArray = array( | |
'type' => 'dir', | |
'path' => $curPath | |
); | |
$fileInfo = stat($curFile); | |
} else { | |
continue; | |
} | |
if ($this->preserveOwner) { | |
$newArray['uid'] = $fileInfo['uid']; | |
$newArray['gid'] = $fileInfo['gid']; | |
} | |
if ($this->preserveMode) { | |
$newArray['mode'] = $fileInfo['mode']; | |
} | |
if ($this->preserveTimes) { | |
$newArray['atime'] = $fileInfo['atime']; | |
$newArray['mtime'] = $fileInfo['mtime']; | |
} | |
$this->header['files'][] = $newArray; | |
} | |
// Handle virtual files. | |
foreach ($this->entries as $curEntry) { | |
$newArray = array( | |
'type' => $curEntry['type'], | |
'path' => $curEntry['path'] | |
); | |
switch ($curEntry['type']) { | |
case 'link': | |
$newArray['target'] = $curEntry['target']; | |
break; | |
case 'file': | |
$newArray['offset'] = $offset; | |
$newArray['size'] = (float) sprintf("%u", strlen($curEntry['data'])); | |
if ($this->fileIntegrity) { | |
$newArray['md5'] = md5($curEntry['data']); | |
} | |
$offset += $newArray['size']; | |
break; | |
} | |
if ($this->preserveOwner) { | |
if (isset($curEntry['uid'])) { | |
$newArray['uid'] = (int) $curEntry['uid']; | |
} | |
if (isset($curEntry['gid'])) { | |
$newArray['gid'] = (int) $curEntry['gid']; | |
} | |
} | |
if ($this->preserveMode && isset($curEntry['mode'])) { | |
$newArray['mode'] = (int) $curEntry['mode']; | |
} | |
if ($this->preserveTimes) { | |
if (isset($curEntry['atime'])) { | |
$newArray['atime'] = (int) $curEntry['atime']; | |
} | |
if (isset($curEntry['mtime'])) { | |
$newArray['mtime'] = (int) $curEntry['mtime']; | |
} | |
} | |
$this->header['files'][] = $newArray; | |
} | |
if (!($fhandle = fopen($filename, 'w'))) { | |
return false; | |
} | |
$header = $this->headerCompression ? 'D'.gzdeflate(json_encode($this->header), $this->headerCompressionLevel) : json_encode($this->header); | |
$beforeStream = "{$this->stub}\nHEADER\n{$header}\nSTREAM\n"; | |
$this->streamOffset = strlen($beforeStream); | |
fwrite($fhandle, $beforeStream); | |
$this->applyFilters($fhandle, 'w'); | |
foreach ($this->files as $curFile) { | |
if (is_link($curFile) || !is_file($curFile)) { | |
continue; | |
} | |
if (!($fread = fopen($curFile, 'r'))) { | |
return false; | |
} | |
@set_time_limit(21600); | |
stream_copy_to_stream($fread, $fhandle); | |
} | |
foreach ($this->entries as $curEntry) { | |
if ($curEntry['type'] != 'file') { | |
continue; | |
} | |
fwrite($fhandle, $curEntry['data']); | |
} | |
return fclose($fhandle); | |
} | |
/** | |
* Open an archive for reading. | |
* | |
* @param string $filename The filename of the archive to open. | |
* @return bool True on success, false on failure. | |
*/ | |
public function read($filename = null) { | |
if (is_null($filename)) { | |
$filename = $this->filename; | |
} else { | |
$this->filename = $filename; | |
} | |
if (!file_exists($filename) || !($fhandle = fopen($filename, 'r'))) { | |
return false; | |
} | |
$this->stub = ''; | |
$check = fgets($fhandle); | |
if (substr($check, 0, 2) == '#!') { | |
$this->stub = $check; | |
$check = fgets($fhandle); | |
} | |
if (substr($check, -8) != "slim1.0\n") { | |
return false; | |
} | |
do { | |
$this->stub .= $check; | |
$check = fgets($fhandle); | |
} while (!feof($fhandle) && $check != "HEADER\n"); | |
if (!($this->stub = substr($this->stub, 0, -1))) { | |
return false; | |
} | |
$header = ''; | |
do { | |
$header .= fgets($fhandle); | |
} while (!feof($fhandle) && substr($header, -7) != "STREAM\n"); | |
if (substr($header, -7) != "STREAM\n" || !($header = substr($header, 0, -7))) { | |
return false; | |
} | |
if (substr($header, 0, 1) == 'D') { | |
$header = gzinflate(substr($header, 1)); | |
} | |
if (!($this->header = json_decode($header, true))) { | |
return false; | |
} | |
$this->compression = (string) $this->header['comp']; | |
$this->compressionLevel = (int) $this->header['compl']; | |
$this->fileIntegrity = (bool) $this->header['ichk']; | |
$this->ext = (array) $this->header['ext']; | |
$this->streamOffset = ftell($fhandle); | |
return fclose($fhandle); | |
} | |
/** | |
* Get an array of information about files in the archive. | |
* | |
* @return array File information. | |
*/ | |
public function getCurrentFiles() { | |
return $this->header['files']; | |
} | |
/** | |
* Return a file's content from the archive. | |
* | |
* @param string $filename The filename of the file to return. | |
* @return string The contents of the file. | |
*/ | |
public function getFile($filename) { | |
foreach ($this->header['files'] as $curEntry) { | |
if ($curEntry['path'] != $filename || $curEntry['type'] != 'file') { | |
continue; | |
} | |
if (!($fhandle = fopen($this->filename, 'r'))) { | |
return false; | |
} | |
$this->fseek($fhandle, $this->streamOffset); | |
$this->applyFilters($fhandle, 'r'); | |
$this->fseek($fhandle, $curEntry['offset'], SEEK_CUR); | |
do { | |
$data = fread($fhandle, $curEntry['size'] - strlen($data)); | |
} while (!feof($fhandle) && strlen($data) < $curEntry['size']); | |
fclose($fhandle); | |
if ($this->fileIntegrity && $curEntry['md5'] != md5($data)) { | |
return false; | |
} | |
return $data; | |
} | |
return false; | |
} | |
/** | |
* Extract from the archive. | |
* | |
* @param string $path The path of the file or directory to extract. If it is an empty string (''), the entire archive will be extracted. | |
* @param bool $recursive Whether to extract the contents of directories. (If false, only the directory will be created.) | |
* @param mixed $filter A regex pattern or an array of regex patterns, which when matches a path, it will be excluded. | |
* @return bool True on success, false on failure. | |
*/ | |
public function extract($path = '', $recursive = true, $filter = null) { | |
$return = true; | |
$pathSlash = $this->addSlash($path); | |
if (!is_array($this->header['files']) || !($fhandle = fopen($this->filename, 'r'))) { | |
return false; | |
} | |
$this->fseek($fhandle, $this->streamOffset); | |
$this->applyFilters($fhandle, 'r'); | |
foreach ($this->header['files'] as $curEntry) { | |
if ($path != '') { | |
if ($recursive) { | |
$curPathSlash = $this->addSlash($curEntry['path']); | |
if ($curEntry['path'] != $path && substr($curPathSlash, 0, strlen($pathSlash)) != $pathSlash) { | |
continue; | |
} | |
} else { | |
if ($curEntry['path'] != $path) { | |
continue; | |
} | |
} | |
} | |
if (isset($filter) && !$this->pathFilter($curEntry['path'], $filter)) { | |
continue; | |
} | |
$curPath = $this->makePath($curEntry['path']); | |
if ($this->noParents) { | |
$curPath = preg_replace('/(^|\/)\.\.(\/|$)/S', '__', $curPath); | |
} | |
switch ($curEntry['type']) { | |
case 'file': | |
$this->fseek($fhandle, $curEntry['offset'], SEEK_CUR); | |
if (!($fwrite = fopen($curPath, 'w'))) { | |
$return = false; | |
continue; | |
} | |
@set_time_limit(21600); | |
$bytes = stream_copy_to_stream($fhandle, $fwrite, $curEntry['size']); | |
$return = $return && ($bytes == $curEntry['size']) && fclose($fwrite); | |
if ($this->fileIntegrity && $curEntry['md5'] != md5_file($curPath)) { | |
$return = false; | |
} | |
break; | |
case 'dir': | |
if (!is_dir($curPath)) { | |
$return = $return && mkdir($curPath); | |
} | |
break; | |
case 'link': | |
// TODO: Symlink owner/perms. | |
// Save cwd. | |
$cwd = getcwd(); | |
// Change to the current path's dir. | |
if (!chdir(dirname($curPath))) { | |
$return = false; | |
} | |
// Make a symlink from that path. | |
if (!is_file($curPath)) { | |
$return = $return && symlink($curEntry['target'], basename($curPath)); | |
} | |
// Change back to the original dir. | |
if (!chdir($cwd)) { | |
$return = false; | |
} | |
break; | |
} | |
if ($this->preserveOwner && isset($curEntry['uid'])) { | |
chown($curPath, $curEntry['uid']); | |
} | |
if ($this->preserveOwner && isset($curEntry['gid'])) { | |
chgrp($curPath, $curEntry['gid']); | |
} | |
if ($this->preserveMode && isset($curEntry['mode'])) { | |
chmod($curPath, $curEntry['mode']); | |
} | |
if ($this->preserveTimes && (isset($curEntry['atime']) || isset($curEntry['mtime']))) { | |
touch($curPath, $curEntry['mtime'], $curEntry['atime']); | |
} | |
} | |
$return = $return && fclose($fhandle); | |
return $return; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment