Add composer flojs and initial structure for video upload

parent 4653922b
......@@ -5,3 +5,38 @@
content: "\f2c2";
}
#shvtags{
float:left;
border:1px solid #ccc;
padding:5px;
font-family:Arial;
}
#shvtags > span{
cursor:pointer;
display:block;
float:left;
color:#fff;
background:#789;
padding:5px;
padding-right:25px;
margin:4px;
}
#shvtags > span:hover{
opacity:0.7;
}
#shvtags > span:after{
position:absolute;
content:"×";
border:1px solid;
padding:2px 5px;
margin-left:3px;
font-size:11px;
}
#shvtags > input{
background:#eee;
border:0;
margin:4px;
padding:7px;
width:auto;
}
......@@ -82,6 +82,16 @@ if(!class_exists('SH_Admin')) {
register_setting('sexhackme-gallery-settings', 'sexhack_video_page');
register_setting('sexhackme-gallery-settings', 'sexhack_gallery_page');
register_setting('sexhackme-gallery-settings', 'sexhack_video404_page');
register_setting('sexhackme-gallery-settings', 'sexhack_shmdown');
register_setting('sexhackme-gallery-settings', 'sexhack_shmdown_uri');
register_setting('sexhackme-gallery-settings', 'sexhack_video_tmp_path');
register_setting('sexhackme-gallery-settings', 'sexhack_video_flat_path');
register_setting('sexhackme-gallery-settings', 'sexhack_video_vr_path');
register_setting('sexhackme-gallery-settings', 'sexhack_video_hls_storage');
register_setting('sexhackme-gallery-settings', 'sexhack_video_video_storage');
register_setting('sexhackme-gallery-settings', 'sexhack_video_photo_storage');
register_setting('sexhackme-gallery-settings', 'sexhack_video_gif_storage');
register_setting('sexhackme-gallery-settings', 'sexhack_video_vr_storage');
add_action('update_option', '\wp_SexHackMe\SH_Admin::update_gallery_slug', 10, 3);
//register_setting('sexhackme-gallery-settings', 'sexhack_gallery_slug');
}
......@@ -115,7 +125,6 @@ if(!class_exists('SH_Admin')) {
update_option('need_rewrite_flush', 1);
break;
default:
break;
}
......
......@@ -117,7 +117,7 @@ if(!class_exists('SH_MetaBox')) {
public static function save_meta_box_data($post_id)
{
return save_sexhack_video_meta_box_data($post_id);
return save_sexhack_video_forms($post_id);
}
}
......
......@@ -205,10 +205,30 @@ if(!class_exists('SH_Query')) {
$slug = $wpdb->_real_escape($slug);
$sql = "SELECT * FROM {$wpdb->prefix}".SH_PREFIX."videos WHERE slug='".$slug."'";
$dbres = $wpdb->get_results( $sql );
if(is_array($dbres) && count($dbres) > 0)
if(is_array($dbres) && count($dbres) > 0) return new SH_Video((array)$dbres[0]);
return false;
}
public static function get_VideosFromHLS($vpath, $level="public")
{
return new SH_Video((array)$dbres[0]);
global $wpdb;
$vpath = $wpdb->_real_escape($vpath);
switch($level)
{
case "members":
$level="hls_members";
break;
case "premium":
$level="hls_premium";
break;
default:
$level="hls_public";
}
$sql = "SELECT * FROM {$wpdb->prefix}".SH_PREFIX."videos WHERE ".$level."='".$vpath."'";
$dbres = $wpdb->get_results( $sql );
if(is_array($dbres) && count($dbres) > 0) return new SH_Video((array)$dbres[0]);
return false;
}
......
......@@ -24,25 +24,67 @@ namespace wp_SexHackMe;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) exit;
if(!class_exists('SH_VideoUpload')) {
require_once(SH_PLUGIN_DIR_PATH."vendor/autoload.php");
class SH_VideoUpload
{
public function __construct()
{
add_action('wp_ajax_file_upload', array($this, 'file_upload_callback'));
add_action('wp_ajax_nopriv_file_upload', array($this, 'file_upload_callback'));
add_action('wp_ajax_sh_editvideo', array($this, 'edit_video_callback'));
add_action('wp_ajax_nopriv_sh_editvideo', array($this, 'edit_video_callback'));
}
public function file_upload_callback()
{
check_ajax_referer('sh_video_upload', 'security');
$arr_img_ext = array('image/png', 'image/jpeg', 'image/jpg', 'image/gif');
if (in_array($_FILES['file']['type'], $arr_img_ext)) {
//$arr_ext = array('image/png', 'image/jpeg', 'image/jpg', 'image/gif');
//$arr_ext = array('video/mp4', 'video/webm','video/mov','video/m4v','video/mpg','video/flv');
$config = new \Flow\Config();
$config->setTempDir("/tmp");
$request = new \Flow\Request();
if(isset($_POST['uniqid'])) $uniqid = $_POST['uniqid'];
else $uniqid = uniqid();
$uploadFolder = get_option('sexhack_video_tmp_path', '/tmp');
$uploadFileName = $uniqid . "_" . $request->getFileName();
$uploadPath = $uploadFolder."/".$uploadFileName;
if (\Flow\Basic::save($uploadPath, $config, $request)) {
sexhack_log("Hurray, file was saved in " . $uploadPath);
} else {
sexhack_log("UPLOADING...");
}
/*
if (in_array($_FILES['file']['type'], $arr_ext)) {
$upload = wp_upload_bits($_FILES["file"]["name"], null, file_get_contents($_FILES["file"]["tmp_name"]));
//$upload['url'] will gives you uploaded file path
}
wp_die();
*/
//wp_die();
}
public function edit_video_callback()
{
sexhack_log("PORCODIOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO");
sexhack_log($_POST);
sexhack_log($_GET);
// XXX Sanitize $_POST['title']
if(!isset($_POST['title'])) return;
$title = $_POST['title'];
$post_id = wp_insert_post(array (
'post_type' => 'sexhack_video',
'post_title' => $title,
'post_status' => 'queue',
));
}
}
......
......@@ -92,8 +92,21 @@ if(!class_exists('SH_Video')) {
return $this->attributes['title'];
}
public function has_downloads()
public function has_downloads($level=false)
{
switch($level) {
case 'premim':
return $this->download_premium;
break;
case 'members':
return $this->download_members;
break;
case 'public':
return $this->download_public;
break;
default:
return false;
}
if($this->download_public OR $this->download_members OR $this->download_premium) return true;
return false;
}
......
<?php
/**
* Copyright: 2022 (c)Franco (nextime) Lanza <franco@nexlab.it>
* License: GNU/GPL version 3.0
*
* This file is part of SexHackMe Wordpress Plugin.
*
* SexHackMe Wordpress Plugin is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* SexHackMe Wordpress Plugin is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with SexHackMe Wordpress Plugin. If not, see <https://www.gnu.org/licenses/>.
*/
namespace wp_SexHackMe;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) exit;
if(!class_exists('SH_VideoStream')) {
require_once("functions-utils.php");
class SH_VideoStream
{
private $path = "";
private $stream = "";
private $buffer = 102400;
private $start = -1;
private $end = -1;
private $size = 0;
function __construct($filePath, $download=false)
{
$this->path = $filePath;
$this->filename = basename($filePath);
$this->myme_type = sh_mime_type($filePath);
$this->download_type = $download;
}
/**
* Open stream
*/
private function open()
{
if (!($this->stream = fopen($this->path, 'rb'))) {
die('Could not open stream for reading');
}
}
/**
* Set proper header to serve the video content
*/
private function setHeader()
{
ob_get_clean();
header("Content-Type: ".$this->mime_type);
header("Cache-Control: max-age=2592000, public");
header("Expires: ".gmdate('D, d M Y H:i:s', time()+2592000) . ' GMT');
header("Last-Modified: ".gmdate('D, d M Y H:i:s', @filemtime($this->path)) . ' GMT' );
if($this->download_type) {
header('Content-Transfer-Encoding: binary');
header("Content-Disposition: attachment filename=$this->filename;");
}
$this->start = 0;
$this->size = filesize($this->path);
$this->end = $this->size - 1;
header("Accept-Ranges: 0-".$this->end);
if (isset($_SERVER['HTTP_RANGE'])) {
$c_start = $this->start;
$c_end = $this->end;
list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
if (strpos($range, ',') !== false) {
header('HTTP/1.1 416 Requested Range Not Satisfiable');
header("Content-Range: bytes $this->start-$this->end/$this->size");
exit;
}
if ($range == '-') {
$c_start = $this->size - substr($range, 1);
}else{
$range = explode('-', $range);
$c_start = $range[0];
$c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $c_end;
}
$c_end = ($c_end > $this->end) ? $this->end : $c_end;
if ($c_start > $c_end || $c_start > $this->size - 1 || $c_end >= $this->size) {
header('HTTP/1.1 416 Requested Range Not Satisfiable');
header("Content-Range: bytes $this->start-$this->end/$this->size");
exit;
}
$this->start = $c_start;
$this->end = $c_end;
$length = $this->end - $this->start + 1;
fseek($this->stream, $this->start);
header('HTTP/1.1 206 Partial Content');
header("Content-Length: ".$length);
header("Content-Range: bytes $this->start-$this->end/".$this->size);
}
else
{
header("Content-Length: ".$this->size);
}
}
/**
* close curretly opened stream
*/
private function end()
{
fclose($this->stream);
exit;
}
/**
* perform the streaming of calculated range
*/
private function stream()
{
$i = $this->start;
set_time_limit(0);
while(!feof($this->stream) && $i <= $this->end) {
$bytesToRead = $this->buffer;
if(($i+$bytesToRead) > $this->end) {
$bytesToRead = $this->end - $i + 1;
}
$data = fread($this->stream, $bytesToRead);
echo $data;
flush();
$i += $bytesToRead;
}
}
/**
* Start streaming video content
*/
function start()
{
$this->open();
$this->setHeader();
$this->stream();
$this->end();
}
}
function shVideoStream($file, $download=false)
{
return new SH_VideoStream($file, $download);
}
}
?>
......@@ -32,8 +32,11 @@ if(!class_exists("SH_VideoProducts")) {
public function __construct()
{
//add_action('sh_save_video_after_query', array($this, 'sync_product_from_video'), 1, 10);
// fired when saving SH videos to sync the product concurrently.
add_filter('video_before_save', array($this, 'sync_product_from_video'));
// fired when deleting a video
add_action('sh_delete_video', array($this, 'delete_video_product'), 9, 1);
}
......@@ -70,8 +73,9 @@ if(!class_exists("SH_VideoProducts")) {
if(is_numeric($video->thumbnail))
$prod->set_image_id( intval($video->thumbnail ));
// Product status.
if($video->status == 'published')
// Product status. Note we publish the product only
// if is there a downloadable video.
if(($video->status == 'published') && ($video->has_downloads()))
$prod->set_status('publish');
else
$prod->set_status('draft');
......
......@@ -24,22 +24,28 @@ namespace wp_SexHackMe;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) exit;
function save_sexhack_video_meta_box_data( $post_id )
function save_sexhack_video_forms( $post_id )
{
// Verify that the nonce is set and valid.
if (!isset( $_POST['sh_video_description_nonce'])
|| !wp_verify_nonce( $_POST['sh_video_description_nonce'], 'video_description_nonce' ) ) {
if ((!isset( $_POST['sh_video_description_nonce']) || !wp_verify_nonce( $_POST['sh_video_description_nonce'], 'video_description_nonce' ))
&& (!isset( $_POST['sh_editvideo_nonce']) || !wp_verify_nonce( $_POST['sh_editvideo_nonce'], 'sh_editvideo')))
{
return;
}
$admin=false;
if(isset( $_POST['sh_video_description_nonce']) && wp_verify_nonce( $_POST['sh_video_description_nonce'], 'video_description_nonce' )) $admin=true;
// We need to be executed only when post_type is set...
if(!isset($_POST['post_type'])) return;
// ... ant it's set to sexhack_video
if($_POST['post_type']!='sexhack_video') return;
// Make sure we don't get caught in any loop
unset($_POST['sh_video_description_nonce']);
if($admin) unset($_POST['sh_video_description_nonce']);
if(!$admin) unset($_POST['sh_editvideo_nonce']);
// If this is an autosave, our form has not been submitted, so we don't want to do anything.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
......@@ -54,7 +60,7 @@ function save_sexhack_video_meta_box_data( $post_id )
}
}
else {
else { // XXX Add more specific permission for our pages?
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
......@@ -90,13 +96,18 @@ function save_sexhack_video_meta_box_data( $post_id )
// sexhack_log($_POST);
// Model
if($admin) {
if(array_key_exists('video_model', $_POST) && is_numeric($_POST['video_model']) && intval($_POST['video_model']) > 0)
$video->user_id = intval($_POST['video_model']);
} else {
$video->user_id = get_current_user_id();
}
// Video description
$video->description = sanitize_text_field( $_POST['video_description'] );
// Video thumbnail
if($admin) {
if(array_key_exists('video_thumbnail', $_POST) && sanitize_text_field($_POST['video_thumbnail']))
$video->thumbnail = sanitize_text_field( $_POST['video_thumbnail'] );
else if(array_key_exists('_thumbnail_id', $_POST)
......@@ -107,6 +118,11 @@ function save_sexhack_video_meta_box_data( $post_id )
}
else
$video->thumbnail = false;
} else {
// Shoudn't we move it somewhere?
if(isset($_POST['filename_thumb'])) $video->thumbnail = sanitize_text_field($_POST['filename_thumb']);
else $video->thumbnail = false;
}
// Video status
$validstatuses = array('creating','uploading','queue','processing','ready','published','error');
......@@ -134,6 +150,8 @@ function save_sexhack_video_meta_box_data( $post_id )
if(array_key_exists('video_vr_projection', $_POST) && in_array($_POST['video_vr_projection'], array('VR180_LR','VR360_LR')))
$video->vr_projection = $_POST['video_vr_projection'];
// XXX Arrivato qui
// Preview video
if(array_key_exists('video_preview', $_POST) && check_url_or_path(sanitize_text_field($_POST['video_preview'])))
$video->preview = sanitize_text_field($_POST['video_preview']);
......
......@@ -38,8 +38,7 @@ function pms_get_redirect_url($url, $location=false)
{
if( !isset( $_POST['pay_gate'] ) || $_POST['pay_gate'] != 'manual' )
return $url;
// XXX BUG apply_filter ont found??
return apply_filter('sh_get_redirect_url', $url, $location);
return apply_filters('sh_get_redirect_url', $url, $location);
}
?>
......@@ -317,4 +317,81 @@ function checkbox($res)
}
function sh_mime_type($filename) {
$mime_types = 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',
// images
'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',
// archives
'zip' => 'application/zip',
'rar' => 'application/x-rar-compressed',
'exe' => 'application/x-msdownload',
'msi' => 'application/x-msdownload',
'cab' => 'application/vnd.ms-cab-compressed',
// audio/video
'mp3' => 'audio/mpeg',
'qt' => 'video/quicktime',
'mov' => 'video/quicktime',
'm3u8' => 'application/vnd.apple.mpegurl',
// adobe
'pdf' => 'application/pdf',
'psd' => 'image/vnd.adobe.photoshop',
'ai' => 'application/postscript',
'eps' => 'application/postscript',
'ps' => 'application/postscript',
// ms office
'doc' => 'application/msword',
'rtf' => 'application/rtf',
'xls' => 'application/vnd.ms-excel',
'ppt' => 'application/vnd.ms-powerpoint',
// open office
'odt' => 'application/vnd.oasis.opendocument.text',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
);
$tmp = explode('.',$filename);
$ext = strtolower(end($tmp));
$tmp = explode('.',$filename);
$ext = strtolower(end($tmp));
if (array_key_exists($ext, $mime_types)) {
return $mime_types[$ext];
}else if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME);
$mimetype = finfo_file($finfo, $filename);
finfo_close($finfo);
return $mimetype;
} else {
return 'application/octet-stream';
}
}
?>
......@@ -94,6 +94,11 @@ function sh_get_video_from_slug($slug)
return SH_Query::get_VideoFromSlug($slug);
}
function sh_get_video_from_hls($vpath, $level="public")
{
return SH_Query::get_VideoFromHLS($vpath, $level);
}
function sh_get_categories($id=false)
{
return SH_Query::get_Categories($id);
......
......@@ -72,6 +72,66 @@ if ( ! defined( 'ABSPATH' ) ) exit;
<p class="description">Select Video not found page</p>
</td>
</tr>
<tr>
<td>
<label> Use filter script for HLS?</label>
<input type="checkbox" name="sexhack_shmdown" value='1' <?php if(get_option('sexhack_shmdown', false)) echo "checked"; ?>>
</td>
<td>
<label>HLS Filter script URI</label>
<input type='text' name='sexhack_shmdown_uri' value='<?php echo get_option('sexhack_shmdown_uri', ''); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Upload TMP path</label>
<input type='text' name='sexhack_video_tmp_path' value='<?php echo get_option('sexhack_video_tmp_path', '/tmp'); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Upload FLAT path</label>
<input type='text' name='sexhack_video_flat_path' value='<?php echo get_option('sexhack_video_flat_path', '/tmp'); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Upload VR path</label>
<input type='text' name='sexhack_video_vr_path' value='<?php echo get_option('sexhack_video_vr_path', '/tmp'); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Storage HLS</label>
<input type='text' name='sexhack_video_hls_storage' value='<?php echo get_option('sexhack_video_hls_storage', ABSPATH.'HLS'); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Storage Video</label>
<input type='text' name='sexhack_video_video_storage' value='<?php echo get_option('sexhack_video_video_storage', ABSPATH.'Videos'); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Storage Photo</label>
<input type='text' name='sexhack_video_photo_storage' value='<?php echo get_option('sexhack_video_photo_storage', ABSPATH.'Photos'); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Storage GIF</label>
<input type='text' name='sexhack_video_gif_storage' value='<?php echo get_option('sexhack_video_gif_storage', ABSPATH.'GIF'); ?>'>
</td>
</tr>
<tr>
<td>
<label>Video Storage VR</label>
<input type='text' name='sexhack_video_vr_storage' value='<?php echo get_option('sexhack_video_vr_storage', ABSPATH.'VR'); ?>'>
</td>
</tr>
</table>
<?php submit_button(); ?>
......
......@@ -34,6 +34,6 @@ $post = $video->get_post();
<form class="fileUpload" enctype="multipart/form-data">
<div class="form-group">
<label>Choose File:</label>
<input type="file" id="file" accept="image/*" />
<input type="file" id="file" accept="video/*" />
</div>
</form>
This diff is collapsed.
......@@ -25,6 +25,7 @@ namespace wp_SexHackMe;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) exit;
sexhack_log("PORCALAPUPAZZA");
sexhack_log(get_query_var('sh_video', 'NONEEEEEEEEEEEEEE!!!'));
......@@ -142,7 +143,8 @@ get_header(); ?>
</header><!-- .entry-header -->
<div class="sexhack-video-container">
<?php
$filterurl=false;
if(get_option('sexhack_shmdown', false)) $filterurl=get_option('sexhack_shmdown_uri', false);
if(in_array($tab, $avail))
{
switch($tab)
......@@ -181,8 +183,14 @@ get_header(); ?>
break;
default: // public too!
if($hls_public && $video->video_type=='VR') echo do_shortcode( "[sexvideo url=\"".$hls_public."\" posters=\"".$thumb."\"]" );
else if($hls_public) echo do_shortcode( "[sexhls url=\"".$hls_public."\" posters=\"".$thumb."\"]" );
if($filterurl && $hls_public && $video->video_type=='VR')
echo do_shortcode( "[sexvideo url=\"".wp_nonce_url($filterurl.$sh_video."/public/".basename($hls_public), 'shm_public_video-'.$video->id)."\" posters=\"".$thumb."\"]" );
else if($hls_public && $video->video_type=='VR')
echo do_shortcode( "[sexvideo url=\"".$hls_public."\" posters=\"".$thumb."\"]" );
else if($filterurl && $hls_public)
echo do_shortcode( "[sexhls url=\"".wp_nonce_url($filterurl.$sh_video."/public/".basename($hls_public), 'shm_public_video-'.$video->id)."\" posters=\"".$thumb."\"]" );
else if($hls_public)
echo do_shortcode( "[sexhls url=\"".$hls_public."\" posters=\"".$thumb."\"]" );
else if($video_preview) {
//echo do_shortcode( "[sexvideo url=\"".$video_preview."\" posters=\"".$thumb."\"]" );
// XXX BUG: sexvideo doesn't like google.drive.com/uc? videos for cross-site problems?
......@@ -229,9 +237,9 @@ get_header(); ?>
<?php
echo $htmltags;
?>
<?php if($video->has_downloads()) { ?>
<h3><a href="<?php echo get_permalink($video->product_id); ?>">Download the full lenght hi-res version of this video</a><h3>
<?php } ?>
<hr>
<?php
echo do_shortcode("[sexadv adv=".get_option('sexadv_video_bot')."]");
......
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
trigger_error(
$err,
E_USER_ERROR
);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit33072a4244de4b08826ca1e6cda560eb::getLoader();
This diff is collapsed.
This diff is collapsed.
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
);
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Flow' => array($vendorDir . '/flowjs/flow-php-server/src'),
);
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
);
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInit33072a4244de4b08826ca1e6cda560eb
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit33072a4244de4b08826ca1e6cda560eb', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit33072a4244de4b08826ca1e6cda560eb', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInit33072a4244de4b08826ca1e6cda560eb::getInitializer($loader));
$loader->register(true);
return $loader;
}
}
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInit33072a4244de4b08826ca1e6cda560eb
{
public static $prefixesPsr0 = array (
'F' =>
array (
'Flow' =>
array (
0 => __DIR__ . '/..' . '/flowjs/flow-php-server/src',
),
),
);
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixesPsr0 = ComposerStaticInit33072a4244de4b08826ca1e6cda560eb::$prefixesPsr0;
$loader->classMap = ComposerStaticInit33072a4244de4b08826ca1e6cda560eb::$classMap;
}, null, ClassLoader::class);
}
}
{
"packages": [
{
"name": "flowjs/flow-php-server",
"version": "v1.2.0",
"version_normalized": "1.2.0.0",
"source": {
"type": "git",
"url": "https://github.com/flowjs/flow-php-server.git",
"reference": "fe8890c25e835d0b4f58d8bd91331326ab1808ba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/flowjs/flow-php-server/zipball/fe8890c25e835d0b4f58d8bd91331326ab1808ba",
"reference": "fe8890c25e835d0b4f58d8bd91331326ab1808ba",
"shasum": ""
},
"require": {
"php": ">=5.4"
},
"require-dev": {
"ext-mongodb": "*",
"fabpot/php-cs-fixer": "~2.2",
"league/phpunit-coverage-listener": "~1.1",
"mikey179/vfsstream": "v1.2.0",
"mongodb/mongodb": "^1.4.0",
"phpunit/phpunit": "4.*"
},
"suggest": {
"mongodb/mongodb": "Required to use this package with Mongo DB"
},
"time": "2022-08-26T15:50:37+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-0": {
"Flow": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Aidas Klimas",
"email": "aidaskk@gmail.com"
}
],
"description": "PHP library for handling chunk uploads. Works with flow.js html5 file uploads.",
"keywords": [
"chunks",
"file upload",
"flow",
"flow.js",
"html5 file upload",
"resumable",
"resumable.js",
"upload"
],
"support": {
"issues": "https://github.com/flowjs/flow-php-server/issues",
"source": "https://github.com/flowjs/flow-php-server/tree/v1.2.0"
},
"install-path": "../flowjs/flow-php-server"
}
],
"dev": true,
"dev-package-names": []
}
<?php return array(
'root' => array(
'name' => '__root__',
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => NULL,
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev' => true,
),
'versions' => array(
'__root__' => array(
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => NULL,
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
'flowjs/flow-php-server' => array(
'pretty_version' => 'v1.2.0',
'version' => '1.2.0.0',
'reference' => 'fe8890c25e835d0b4f58d8bd91331326ab1808ba',
'type' => 'library',
'install_path' => __DIR__ . '/../flowjs/flow-php-server',
'aliases' => array(),
'dev_requirement' => false,
),
),
);
<?php
// platform_check.php @generated by Composer
$issues = array();
if (!(PHP_VERSION_ID >= 50400)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 5.4.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
} elseif (!headers_sent()) {
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
}
}
trigger_error(
'Composer detected issues in your platform: ' . implode(' ', $issues),
E_USER_ERROR
);
}
# Ide
.idea
# Composer
composer.lock
/vendor
/build
\ No newline at end of file
language: php
dist: precise
php:
- 5.4
- 5.5
before_script:
- composer install --dev --no-interaction --prefer-source
script:
- mkdir -p build
- phpunit --configuration travis.phpunit.xml
# 1.0.0
## Breaking Changes
* Flow\Exception was replaced by Flow\FileOpenException and Flow\FileLockException
* php requirement was changed to >=5.4
* if chunk was not found, 204 status is returned instead of 404
\ No newline at end of file
The MIT License (MIT)
Copyright (c) 2013 Aidas Klimas
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
flow.js php server [![Build Status](https://travis-ci.org/flowjs/flow-php-server.png?branch=master)](https://travis-ci.org/flowjs/flow-php-server) [![Coverage Status](https://coveralls.io/repos/flowjs/flow-php-server/badge.png?branch=master)](https://coveralls.io/r/flowjs/flow-php-server?branch=master)
=======================
PHP library for handling chunk uploads. Library contains helper methods for:
* Testing if uploaded file chunk exists.
* Validating file chunk
* Creating separate chunks folder
* Validating uploaded chunks
* Merging all chunks to a single file
This library is compatible with HTML5 file upload library: https://github.com/flowjs/flow.js
How to get started?
--------------
Setup Composer: https://getcomposer.org/doc/00-intro.md
Run this command in your project:
```
composer require flowjs/flow-php-server
```
This will create a vendor directory for you, which contains an autoload.php file.
Create a new php file named `upload.php`:
```php
//Path to autoload.php from current location
require_once './vendor/autoload.php';
$config = new \Flow\Config();
$config->setTempDir('./chunks_temp_folder');
$request = new \Flow\Request();
$uploadFolder = './final_file_destination/'; // Folder where the file will be stored
$uploadFileName = uniqid()."_".$request->getFileName(); // The name the file will have on the server
$uploadPath = $uploadFolder.$uploadFileName;
if (\Flow\Basic::save($uploadPath, $config, $request)) {
// file saved successfully and can be accessed at $uploadPath
} else {
// This is not a final chunk or request is invalid, continue to upload.
}
```
Make sure that `./chunks_temp_folder` path exists and is writable. All chunks will be saved in this folder.
If you are stuck with this example, please read this issue: [How to use the flow-php-server](https://github.com/flowjs/flow-php-server/issues/3#issuecomment-46979467)
Advanced Usage
--------------
```php
$config = new \Flow\Config();
$config->setTempDir('./chunks_temp_folder');
$file = new \Flow\File($config);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if ($file->checkChunk()) {
header("HTTP/1.1 200 Ok");
} else {
header("HTTP/1.1 204 No Content");
return ;
}
} else {
if ($file->validateChunk()) {
$file->saveChunk();
} else {
// error, invalid chunk upload request, retry
header("HTTP/1.1 400 Bad Request");
return ;
}
}
if ($file->validateFile() && $file->save('./final_file_name')) {
// File upload was completed
} else {
// This is not a final chunk, continue to upload
}
```
Delete unfinished files
-----------------------
For this you should setup cron, which would check each chunk upload time.
If chunk is uploaded long time ago, then chunk should be deleted.
Helper method for checking this:
```php
\Flow\Uploader::pruneChunks('./chunks_folder');
```
Cron task can be avoided by using random function execution.
```php
if (1 == mt_rand(1, 100)) {
\Flow\Uploader::pruneChunks('./chunks_folder');
}
```
Contribution
------------
Your participation in development is very welcome!
To ensure consistency throughout the source code, keep these rules in mind as you are working:
* All features or bug fixes must be tested by one or more specs.
* Your code should follow [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) coding style guide
{
"name": "flowjs/flow-php-server",
"description": "PHP library for handling chunk uploads. Works with flow.js html5 file uploads.",
"license": "MIT",
"authors": [
{
"name": "Aidas Klimas",
"email": "aidaskk@gmail.com"
}
],
"keywords": [
"flow.js",
"flow",
"resumable.js",
"resumable",
"upload",
"file upload",
"html5 file upload",
"chunks"
],
"require": {
"php": ">=5.4"
},
"require-dev": {
"mikey179/vfsstream": "v1.2.0",
"league/phpunit-coverage-listener": "~1.1",
"fabpot/php-cs-fixer": "~2.2",
"phpunit/phpunit": "4.*",
"mongodb/mongodb": "^1.4.0",
"ext-mongodb": "*"
},
"suggest": {
"mongodb/mongodb":"Required to use this package with Mongo DB"
},
"autoload": {
"psr-0": {
"Flow": "src"
}
}
}
#!/bin/bash
php ./vendor/phpunit/phpunit/phpunit "$@"
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false"
bootstrap="test/bootstrap.php"
>
<testsuites>
<testsuite name="Flow Test Suite">
<directory>./test/Unit/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">./src/Flow/</directory>
<exclude>
<file>./src/Flow/Basic.php</file>
</exclude>
</whitelist>
</filter>
</phpunit>
<?php
namespace Flow;
class Autoloader
{
/**
* Directory path
*
* @var string
*/
private $dir;
/**
* Constructor
*
* @param string|null $dir
*/
public function __construct($dir = null)
{
if (is_null($dir)) {
$dir = __DIR__.'/..';
}
$this->dir = $dir;
}
/**
* Return directory path
*
* @return string
*/
public function getDir()
{
return $this->dir;
}
/**
* Register
*
* @codeCoverageIgnore
* @param string|null $dir
*/
public static function register($dir = null)
{
ini_set('unserialize_callback_func', 'spl_autoload_call');
spl_autoload_register(array(new self($dir), 'autoload'));
}
/**
* Handles autoloading of classes
*
* @param string $class A class name
*
* @return boolean Returns true if the class has been loaded
*/
public function autoload($class)
{
if (0 !== strpos($class, 'Flow')) {
return;
}
if (file_exists($file = $this->dir.'/'.str_replace('\\', '/', $class).'.php')) {
require $file;
}
}
}
<?php
namespace Flow;
/**
* Class Basic
*
* Example for handling basic uploads
*
* @package Flow
*/
class Basic
{
/**
* @param string $destination where to save file
* @param string|ConfigInterface $config
* @param RequestInterface $request optional
* @return bool
*/
public static function save($destination, $config, RequestInterface $request = null)
{
if (!$config instanceof ConfigInterface) {
$config = new Config(array(
'tempDir' => $config,
));
}
$file = new File($config, $request);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if ($file->checkChunk()) {
header("HTTP/1.1 200 Ok");
} else {
// The 204 response MUST NOT include a message-body, and thus is always terminated by the first empty line after the header fields.
header("HTTP/1.1 204 No Content");
return false;
}
} else {
if ($file->validateChunk()) {
$file->saveChunk();
} else {
// error, invalid chunk upload request, retry
header("HTTP/1.1 400 Bad Request");
return false;
}
}
if ($file->validateFile() && $file->save($destination)) {
return true;
}
return false;
}
}
<?php
namespace Flow;
class Config implements ConfigInterface
{
/**
* Config
*
* @var array
*/
private $config;
/**
* Controller
*
* @param array $config
*/
public function __construct($config = array())
{
$this->config = $config;
}
/**
* Set path to temporary directory for chunks storage
*
* @param $path
*/
public function setTempDir($path)
{
$this->config['tempDir'] = $path;
}
/**
* Get path to temporary directory for chunks storage
*
* @return string
*/
public function getTempDir()
{
return isset($this->config['tempDir']) ? $this->config['tempDir'] : '';
}
/**
* Set chunk identifier
*
* @param callable $callback
*/
public function setHashNameCallback($callback)
{
$this->config['hashNameCallback'] = $callback;
}
/**
* Generate chunk identifier
*
* @return callable
*/
public function getHashNameCallback()
{
return isset($this->config['hashNameCallback']) ? $this->config['hashNameCallback'] : '\Flow\Config::hashNameCallback';
}
/**
* Callback to pre-process chunk
*
* @param callable $callback
*/
public function setPreprocessCallback($callback)
{
$this->config['preprocessCallback'] = $callback;
}
/**
* Callback to pre-process chunk
*
* @return callable|null
*/
public function getPreprocessCallback()
{
return isset($this->config['preprocessCallback']) ? $this->config['preprocessCallback'] : null;
}
/**
* Delete chunks on save
*
* @param bool $delete
*/
public function setDeleteChunksOnSave($delete)
{
$this->config['deleteChunksOnSave'] = $delete;
}
/**
* Delete chunks on save
*
* @return bool
*/
public function getDeleteChunksOnSave()
{
return isset($this->config['deleteChunksOnSave']) ? $this->config['deleteChunksOnSave'] : true;
}
/**
* Generate chunk identifier
*
* @param RequestInterface $request
*
* @return string
*/
public static function hashNameCallback(RequestInterface $request)
{
return sha1($request->getIdentifier());
}
}
<?php
namespace Flow;
interface ConfigInterface
{
/**
* Get path to temporary directory for chunks storage
*
* @return string
*/
public function getTempDir();
/**
* Generate chunk identifier
*
* @return callable
*/
public function getHashNameCallback();
/**
* Callback to pre-process chunk
*
* @param callable $callback
*/
public function setPreprocessCallback($callback);
/**
* Callback to preprocess chunk
*
* @return callable|null
*/
public function getPreprocessCallback();
/**
* Delete chunks on save
*
* @param bool $delete
*/
public function setDeleteChunksOnSave($delete);
/**
* Delete chunks on save
*
* @return bool
*/
public function getDeleteChunksOnSave();
}
<?php
namespace Flow;
class File
{
/**
* @var RequestInterface
*/
protected $request;
/**
* @var ConfigInterface
*/
private $config;
/**
* File hashed unique identifier
*
* @var string
*/
private $identifier;
/**
* Constructor
*
* @param ConfigInterface $config
* @param RequestInterface $request
*/
public function __construct(ConfigInterface $config, RequestInterface $request = null)
{
$this->config = $config;
if ($request === null) {
$request = new Request();
}
$this->request = $request;
$this->identifier = call_user_func($this->config->getHashNameCallback(), $request);
}
/**
* Get file identifier
*
* @return string
*/
public function getIdentifier()
{
return $this->identifier;
}
/**
* Return chunk path
*
* @param int $index
*
* @return string
*/
public function getChunkPath($index)
{
return $this->config->getTempDir().DIRECTORY_SEPARATOR.basename($this->identifier).'_'. (int) $index;
}
/**
* Check if chunk exist
*
* @return bool
*/
public function checkChunk()
{
return file_exists($this->getChunkPath($this->request->getCurrentChunkNumber()));
}
/**
* Validate file request
*
* @return bool
*/
public function validateChunk()
{
$file = $this->request->getFile();
if (!$file) {
return false;
}
if (!isset($file['tmp_name']) || !isset($file['size']) || !isset($file['error'])) {
return false;
}
if ($this->request->getCurrentChunkSize() != $file['size']) {
return false;
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return false;
}
return true;
}
/**
* Save chunk
*
* @return bool
*/
public function saveChunk()
{
$file = $this->request->getFile();
return $this->_move_uploaded_file($file['tmp_name'], $this->getChunkPath($this->request->getCurrentChunkNumber()));
}
/**
* Check if file upload is complete
*
* @return bool
*/
public function validateFile()
{
$totalChunks = $this->request->getTotalChunks();
$totalChunksSize = 0;
for ($i = $totalChunks; $i >= 1; $i--) {
$file = $this->getChunkPath($i);
if (!file_exists($file)) {
return false;
}
$totalChunksSize += filesize($file);
}
return $this->request->getTotalSize() == $totalChunksSize;
}
/**
* Merge all chunks to single file
*
* @param string $destination final file location
*
*
* @throws FileLockException
* @throws FileOpenException
* @throws \Exception
*
* @return bool indicates if file was saved
*/
public function save($destination)
{
$fh = fopen($destination, 'wb');
if (!$fh) {
throw new FileOpenException('failed to open destination file: '.$destination);
}
if (!flock($fh, LOCK_EX | LOCK_NB, $blocked)) {
// @codeCoverageIgnoreStart
if ($blocked) {
// Concurrent request has requested a lock.
// File is being processed at the moment.
// Warning: lock is not checked in windows.
return false;
}
// @codeCoverageIgnoreEnd
throw new FileLockException('failed to lock file: '.$destination);
}
$totalChunks = $this->request->getTotalChunks();
try {
$preProcessChunk = $this->config->getPreprocessCallback();
for ($i = 1; $i <= $totalChunks; $i++) {
$file = $this->getChunkPath($i);
$chunk = fopen($file, "rb");
if (!$chunk) {
throw new FileOpenException('failed to open chunk: '.$file);
}
if ($preProcessChunk !== null) {
call_user_func($preProcessChunk, $chunk);
}
stream_copy_to_stream($chunk, $fh);
fclose($chunk);
}
} catch (\Exception $e) {
flock($fh, LOCK_UN);
fclose($fh);
throw $e;
}
if ($this->config->getDeleteChunksOnSave()) {
$this->deleteChunks();
}
flock($fh, LOCK_UN);
fclose($fh);
return true;
}
/**
* Delete chunks dir
*/
public function deleteChunks()
{
$totalChunks = $this->request->getTotalChunks();
for ($i = 1; $i <= $totalChunks; $i++) {
$path = $this->getChunkPath($i);
if (file_exists($path)) {
unlink($path);
}
}
}
/**
* This method is used only for testing
*
* @private
* @codeCoverageIgnore
*
* @param string $filePath
* @param string $destinationPath
*
* @return bool
*/
public function _move_uploaded_file($filePath, $destinationPath)
{
return move_uploaded_file($filePath, $destinationPath);
}
}
<?php
namespace Flow;
class FileLockException extends \Exception
{
}
<?php
namespace Flow;
class FileOpenException extends \Exception
{
}
<?php
namespace Flow;
/**
* Class FustyRequest
*
* Imitates single file request as a single chunk file upload
*
* @package Flow
*/
class FustyRequest extends Request
{
private $isFusty = false;
public function __construct($params = null, $file = null)
{
parent::__construct($params, $file);
$this->isFusty = $this->getTotalSize() === null && $this->getFileName() && $this->getFile();
if ($this->isFusty) {
$this->params['flowTotalSize'] = isset($this->file['size']) ? $this->file['size'] : 0;
$this->params['flowTotalChunks'] = 1;
$this->params['flowChunkNumber'] = 1;
$this->params['flowChunkSize'] = $this->params['flowTotalSize'];
$this->params['flowCurrentChunkSize'] = $this->params['flowTotalSize'];
}
}
/**
* Checks if request is formed by fusty flow
* @return bool
*/
public function isFustyFlowRequest()
{
return $this->isFusty;
}
}
<?php
namespace Flow\Mongo;
use Flow\Config;
use MongoDB\GridFS\Bucket;
/**
* @codeCoverageIgnore
*/
class MongoConfig extends Config implements MongoConfigInterface
{
private $gridFs;
/**
* @param Bucket $gridFS storage of the upload (and chunks)
*/
function __construct(Bucket $gridFS)
{
parent::__construct();
$this->gridFs = $gridFS;
}
/**
* @return Bucket
*/
public function getGridFs()
{
return $this->gridFs;
}
}
\ No newline at end of file
<?php
namespace Flow\Mongo;
use Flow\ConfigInterface;
use MongoDB\GridFS\Bucket;
/**
* @codeCoverageIgnore
*/
interface MongoConfigInterface extends ConfigInterface
{
/**
* @return Bucket
*/
public function getGridFs();
}
\ No newline at end of file
<?php
namespace Flow\Mongo;
use Exception;
use Flow\File;
use Flow\Request;
use Flow\RequestInterface;
use MongoDB\BSON\Binary;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Operation\FindOneAndReplace;
/**
* Notes:
*
* - One should ensure indices on the gridfs collection on the property 'flowIdentifier'.
* - Chunk preprocessor not supported (must not modify chunks size)!
* - Must use 'forceChunkSize=true' on client side.
*
* @codeCoverageIgnore
*/
class MongoFile extends File
{
private $uploadGridFsFile;
/**
* @var MongoConfigInterface
*/
private $config;
function __construct(MongoConfigInterface $config, RequestInterface $request = null)
{
if ($request === null) {
$request = new Request();
}
parent::__construct($config, $request);
$this->config = $config;
}
/**
* return array
*/
protected function getGridFsFile()
{
if (!$this->uploadGridFsFile) {
$gridFsFileQuery = $this->getGridFsFileQuery();
$changed = $gridFsFileQuery;
$changed['flowUpdated'] = new UTCDateTime();
$this->uploadGridFsFile = $this->config->getGridFs()->getFilesCollection()->findOneAndReplace($gridFsFileQuery, $changed,
['upsert' => true, 'returnDocument' => FindOneAndReplace::RETURN_DOCUMENT_AFTER]);
}
return $this->uploadGridFsFile;
}
/**
* @param $index int|string 1-based
* @return bool
*/
public function chunkExists($index)
{
return $this->config->getGridFs()->getChunksCollection()->findOne([
'files_id' => $this->getGridFsFile()['_id'],
'n' => (intval($index) - 1)
]) !== null;
}
public function checkChunk()
{
return $this->chunkExists($this->request->getCurrentChunkNumber());
}
/**
* Save chunk
* @param $additionalUpdateOptions array additional options for the mongo update/upsert operation.
* @return bool
* @throws Exception if upload size is invalid or some other unexpected error occurred.
*/
public function saveChunk($additionalUpdateOptions = [])
{
try {
$file = $this->request->getFile();
$chunkQuery = [
'files_id' => $this->getGridFsFile()['_id'],
'n' => intval($this->request->getCurrentChunkNumber()) - 1,
];
$chunk = $chunkQuery;
$data = file_get_contents($file['tmp_name']);
$actualChunkSize = strlen($data);
if ($actualChunkSize > $this->request->getDefaultChunkSize() ||
($actualChunkSize < $this->request->getDefaultChunkSize() &&
$this->request->getCurrentChunkNumber() != $this->request->getTotalChunks())
) {
throw new Exception("Invalid upload! (size: $actualChunkSize)");
}
$chunk['data'] = new Binary($data, Binary::TYPE_GENERIC);
$this->config->getGridFs()->getChunksCollection()->replaceOne($chunkQuery, $chunk, array_merge(['upsert' => true], $additionalUpdateOptions));
unlink($file['tmp_name']);
$this->ensureIndices();
return true;
} catch (Exception $e) {
// try to remove a possibly (partly) stored chunk:
if (isset($chunkQuery)) {
$this->config->getGridFs()->getChunksCollection()->deleteMany($chunkQuery);
}
throw $e;
}
}
/**
* @return bool
*/
public function validateFile()
{
$totalChunks = intval($this->request->getTotalChunks());
$storedChunks = $this->config->getGridFs()->getChunksCollection()
->countDocuments(['files_id' => $this->getGridFsFile()['_id']]);
return $totalChunks === $storedChunks;
}
/**
* Merge all chunks to single file
* @param $metadata array additional metadata for final file
* @return ObjectId|bool of saved file or false if file was already saved
* @throws Exception
*/
public function saveToGridFs($metadata = null)
{
$file = $this->getGridFsFile();
$file['flowStatus'] = 'finished';
$file['metadata'] = $metadata;
$result = $this->config->getGridFs()->getFilesCollection()->findOneAndReplace($this->getGridFsFileQuery(), $file);
// on second invocation no more file can be found, as the flowStatus changed:
if (is_null($result)) {
return false;
} else {
return $file['_id'];
}
}
public function save($destination)
{
throw new Exception("Must not use 'save' on MongoFile - use 'saveToGridFs'!");
}
public function deleteChunks()
{
// nothing to do, as chunks are directly part of the final file
}
public function ensureIndices()
{
$chunksCollection = $this->config->getGridFs()->getChunksCollection();
$indexKeys = ['files_id' => 1, 'n' => 1];
$indexOptions = ['unique' => true, 'background' => true];
$chunksCollection->createIndex($indexKeys, $indexOptions);
}
/**
* @return array
*/
protected function getGridFsFileQuery()
{
return [
'flowIdentifier' => $this->request->getIdentifier(),
'flowStatus' => 'uploading',
'filename' => $this->request->getFileName(),
'chunkSize' => intval($this->request->getDefaultChunkSize()),
'length' => intval($this->request->getTotalSize())
];
}
}
<?php
namespace Flow\Mongo;
use MongoDB\GridFS\Bucket;
/**
* @codeCoverageIgnore
*/
class MongoUploader
{
/**
* Delete chunks older than expiration time.
*
* @param Bucket $gridFs
* @param int $expirationTime seconds
*/
public static function pruneChunks($gridFs, $expirationTime = 172800)
{
$result = $gridFs->find([
'flowUpdated' => ['$lt' => new \MongoDB\BSON\UTCDateTime(time() - $expirationTime)],
'flowStatus' => 'uploading'
]);
foreach ($result as $file) {
$gridFs->delete($file['_id']);
}
}
}
Usage
--------------
* Must use 'forceChunkSize=true' on client side.
* Chunk preprocessor not supported.
* One should ensure indices on the gridfs files collection on the property 'flowIdentifier'.
Besides the points above, the usage is analogous to the 'normal' flow-php:
```php
$config = new \Flow\Mongo\MongoConfig($yourGridFs);
$file = new \Flow\Mongo\MongoFile($config);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if ($file->checkChunk()) {
header("HTTP/1.1 200 Ok");
} else {
header("HTTP/1.1 204 No Content");
return ;
}
} else {
if ($file->validateChunk()) {
$file->saveChunk();
} else {
// error, invalid chunk upload request, retry
header("HTTP/1.1 400 Bad Request");
return ;
}
}
if ($file->validateFile()) {
// File upload was completed
$id = $file->saveToGridFs(['your metadata'=>'value']);
if($id) {
//do custom post processing here, $id is the MongoId of the gridfs file
}
} else {
// This is not a final chunk, continue to upload
}
```
Delete unfinished files
-----------------------
For this you should set up cron, which would check each chunk upload time.
If chunk is uploaded long time ago, then chunk should be deleted.
Helper method for checking this:
```php
\Flow\Mongo\MongoUploader::pruneChunks($yourGridFs);
```
<?php
namespace Flow;
class Request implements RequestInterface
{
/**
* Request parameters
*
* @var array
*/
protected $params;
/**
* File
*
* @var array
*/
protected $file;
/**
* Constructor
*
* @param array|null $params
* @param array|null $file
*/
public function __construct($params = null, $file = null)
{
if ($params === null) {
$params = $_REQUEST;
}
if ($file === null && isset($_FILES['file'])) {
$file = $_FILES['file'];
}
$this->params = $params;
$this->file = $file;
}
/**
* Get parameter value
*
* @param string $name
*
* @return string|int|null
*/
public function getParam($name)
{
return isset($this->params[$name]) ? $this->params[$name] : null;
}
/**
* Get uploaded file name
*
* @return string|null
*/
public function getFileName()
{
return $this->getParam('flowFilename');
}
/**
* Get total file size in bytes
*
* @return int|null
*/
public function getTotalSize()
{
return $this->getParam('flowTotalSize');
}
/**
* Get file unique identifier
*
* @return string|null
*/
public function getIdentifier()
{
return $this->getParam('flowIdentifier');
}
/**
* Get file relative path
*
* @return string|null
*/
public function getRelativePath()
{
return $this->getParam('flowRelativePath');
}
/**
* Get total chunks number
*
* @return int|null
*/
public function getTotalChunks()
{
return $this->getParam('flowTotalChunks');
}
/**
* Get default chunk size
*
* @return int|null
*/
public function getDefaultChunkSize()
{
return $this->getParam('flowChunkSize');
}
/**
* Get current uploaded chunk number, starts with 1
*
* @return int|null
*/
public function getCurrentChunkNumber()
{
return $this->getParam('flowChunkNumber');
}
/**
* Get current uploaded chunk size
*
* @return int|null
*/
public function getCurrentChunkSize()
{
return $this->getParam('flowCurrentChunkSize');
}
/**
* Return $_FILES request
*
* @return array|null
*/
public function getFile()
{
return $this->file;
}
/**
* Checks if request is formed by fusty flow
*
* @return bool
*/
public function isFustyFlowRequest()
{
return false;
}
}
<?php
namespace Flow;
interface RequestInterface
{
/**
* Get uploaded file name
*
* @return string
*/
public function getFileName();
/**
* Get total file size in bytes
*
* @return int
*/
public function getTotalSize();
/**
* Get file unique identifier
*
* @return string
*/
public function getIdentifier();
/**
* Get file relative path
*
* @return string
*/
public function getRelativePath();
/**
* Get total chunks number
*
* @return int
*/
public function getTotalChunks();
/**
* Get default chunk size
*
* @return int
*/
public function getDefaultChunkSize();
/**
* Get current uploaded chunk number, starts with 1
*
* @return int
*/
public function getCurrentChunkNumber();
/**
* Get current uploaded chunk size
*
* @return int
*/
public function getCurrentChunkSize();
/**
* Return $_FILES request
*
* @return array|null
*/
public function getFile();
/**
* Checks if request is formed by fusty flow
*
* @return bool
*/
public function isFustyFlowRequest();
}
<?php
namespace Flow;
class Uploader
{
/**
* Delete chunks older than expiration time.
*
* @param string $chunksFolder
* @param int $expirationTime seconds
*
* @throws FileOpenException
*/
public static function pruneChunks($chunksFolder, $expirationTime = 172800)
{
$handle = opendir($chunksFolder);
if (!$handle) {
throw new FileOpenException('failed to open folder: '.$chunksFolder);
}
while (false !== ($entry = readdir($handle))) {
if ($entry == "." || $entry == ".." || $entry == ".gitignore") {
continue;
}
$path = $chunksFolder.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path)) {
continue;
}
if (time() - filemtime($path) > $expirationTime) {
unlink($path);
}
}
closedir($handle);
}
}
<?php
namespace Unit;
/**
* Autoload unit tests
*
* @coversDefaultClass \Flow\Autoloader
*
* @package Unit
*/
class AutoloadTest extends FlowUnitCase
{
/**
* @covers ::__construct
* @covers ::getDir
*/
public function testAutoloader_construct_default()
{
$expDir = realpath(__DIR__ . '/../../src/Flow') . '/..';
$autoloader = new \Flow\Autoloader();
$this->assertSame($expDir, $autoloader->getDir());
}
/**
* @covers ::__construct
* @covers ::getDir
*/
public function testAutoloader_construct_custom()
{
$expDir = __DIR__;
$autoloader = new \Flow\Autoloader($expDir);
$this->assertSame($expDir, $autoloader->getDir());
}
/**
* @covers ::autoload
*/
public function testClassesExist()
{
$autoloader = new \Flow\Autoloader();
$autoloader->autoload('noclass');
$this->assertFalse(class_exists('noclass', false));
$autoloader->autoload('Flow\NoClass');
$this->assertFalse(class_exists('Flow\NoClass', false));
$autoloader->autoload('Flow\File');
$this->assertTrue(class_exists('Flow\File'));
}
}
<?php
namespace Unit;
use Flow\Config;
use Flow\Request;
/**
* Config unit tests
*
* @coversDefaultClass \Flow\Config
*
* @package Unit
*/
class ConfigTest extends FlowUnitCase
{
/**
* @covers ::getTempDir
* @covers ::getDeleteChunksOnSave
* @covers ::getHashNameCallback
* @covers ::getPreprocessCallback
* @covers ::__construct
*/
public function testConfig_construct_config()
{
$exampleConfig = array(
'tempDir' => '/some/dir',
'deleteChunksOnSave' => TRUE,
'hashNameCallback' => '\SomeNs\SomeClass::someMethod',
'preprocessCallback' => '\SomeNs\SomeClass::preProcess'
);
$config = new Config($exampleConfig);
$this->assertSame($exampleConfig['tempDir'], $config->getTempDir());
$this->assertSame($exampleConfig['deleteChunksOnSave'], $config->getDeleteChunksOnSave());
$this->assertSame($exampleConfig['hashNameCallback'], $config->getHashNameCallback());
$this->assertSame($exampleConfig['preprocessCallback'], $config->getPreprocessCallback());
}
/**
* @covers ::getTempDir
* @covers ::getDeleteChunksOnSave
* @covers ::getHashNameCallback
* @covers ::getPreprocessCallback
* @covers ::__construct
*/
public function testConfig_construct_default()
{
$config = new Config();
$this->assertSame('', $config->getTempDir());
$this->assertSame(true, $config->getDeleteChunksOnSave());
$this->assertSame('\Flow\Config::hashNameCallback', $config->getHashNameCallback());
$this->assertSame(null, $config->getPreprocessCallback());
}
/**
* @covers ::setTempDir
* @covers ::getTempDir
*/
public function testConfig_setTempDir()
{
$dir = '/some/dir';
$config = new Config();
$config->setTempDir($dir);
$this->assertSame($dir, $config->getTempDir());
}
/**
* @covers ::setHashNameCallback
* @covers ::getHashNameCallback
*/
public function testConfig_setHashNameCallback()
{
$callback = '\SomeNs\SomeClass::someMethod';
$config = new Config();
$config->setHashNameCallback($callback);
$this->assertSame($callback, $config->getHashNameCallback());
}
/**
* @covers ::setPreprocessCallback
* @covers ::getPreprocessCallback
*/
public function testConfig_setPreprocessCallback()
{
$callback = '\SomeNs\SomeClass::someOtherMethod';
$config = new Config();
$config->setPreprocessCallback($callback);
$this->assertSame($callback, $config->getPreprocessCallback());
}
/**
* @covers ::setDeleteChunksOnSave
* @covers ::getDeleteChunksOnSave
*/
public function testConfig_setDeleteChunksOnSave()
{
$config = new Config();
$config->setDeleteChunksOnSave(false);
$this->assertFalse($config->getDeleteChunksOnSave());
}
public function testConfig_hashNameCallback()
{
$request = new Request($this->requestArr);
$expHash = sha1($request->getIdentifier());
$this->assertSame($expHash, Config::hashNameCallback($request));
}
}
This diff is collapsed.
<?php
namespace Unit;
use ArrayObject;
class FlowUnitCase extends \PHPUnit_Framework_TestCase
{
/**
* Test request
*
* @var array
*/
protected $requestArr;
/**
* $_FILES
*
* @var array
*/
protected $filesArr;
protected function setUp()
{
$this->requestArr = new ArrayObject(array(
'flowChunkNumber' => 1,
'flowChunkSize' => 1048576,
'flowCurrentChunkSize' => 10,
'flowTotalSize' => 100,
'flowIdentifier' => '13632-prettifyjs',
'flowFilename' => 'prettify.js',
'flowRelativePath' => 'home/prettify.js',
'flowTotalChunks' => 42
));
$this->filesArr = array(
'file' => array(
'name' => 'someFile.gif',
'type' => 'image/gif',
'size' => '10',
'tmp_name' => '/tmp/abc1234',
'error' => UPLOAD_ERR_OK
)
);
}
protected function tearDown()
{
$_REQUEST = array();
$_FILES = array();
}
}
<?php
namespace Unit;
use Flow\File;
use Flow\FustyRequest;
use Flow\Config;
use org\bovigo\vfs\vfsStreamWrapper;
use org\bovigo\vfs\vfsStreamDirectory;
use org\bovigo\vfs\vfsStream;
/**
* FustyRequest unit tests
*
* @coversDefaultClass \Flow\FustyRequest
*
* @package Unit
*/
class FustyRequestTest extends FlowUnitCase
{
/**
* Virtual file system
*
* @var vfsStreamDirectory
*/
protected $vfs;
protected function setUp()
{
parent::setUp();
vfsStreamWrapper::register();
$this->vfs = new vfsStreamDirectory('chunks');
vfsStreamWrapper::setRoot($this->vfs);
}
/**
* @covers ::__construct
* @covers ::isFustyFlowRequest
*/
public function testFustyRequest_construct()
{
$firstChunk = vfsStream::newFile('temp_file');
$firstChunk->setContent('1234567890');
$this->vfs->addChild($firstChunk);
$fileInfo = new \ArrayObject(array(
'size' => 10,
'error' => UPLOAD_ERR_OK,
'tmp_name' => $firstChunk->url()
));
$request = new \ArrayObject(array(
'flowIdentifier' => '13632-prettifyjs',
'flowFilename' => 'prettify.js',
'flowRelativePath' => 'home/prettify.js'
));
$fustyRequest = new FustyRequest($request, $fileInfo);
$this->assertSame('prettify.js', $fustyRequest->getFileName());
$this->assertSame('13632-prettifyjs', $fustyRequest->getIdentifier());
$this->assertSame('home/prettify.js', $fustyRequest->getRelativePath());
$this->assertSame(1, $fustyRequest->getCurrentChunkNumber());
$this->assertTrue($fustyRequest->isFustyFlowRequest());
$this->assertSame(10, $fustyRequest->getTotalSize());
$this->assertSame(10, $fustyRequest->getDefaultChunkSize());
$this->assertSame(10, $fustyRequest->getCurrentChunkSize());
$this->assertSame(1, $fustyRequest->getTotalChunks());
}
/**
*/
public function testFustyRequest_ValidateUpload()
{
//// Setup test
$firstChunk = vfsStream::newFile('temp_file');
$firstChunk->setContent('1234567890');
$this->vfs->addChild($firstChunk);
$fileInfo = new \ArrayObject(array(
'size' => 10,
'error' => UPLOAD_ERR_OK,
'tmp_name' => $firstChunk->url()
));
$request = new \ArrayObject(array(
'flowIdentifier' => '13632-prettifyjs',
'flowFilename' => 'prettify.js',
'flowRelativePath' => 'home/prettify.js'
));
$fustyRequest = new FustyRequest($request, $fileInfo);
$config = new Config();
$config->setTempDir($this->vfs->url());
/** @var File $file */
$file = $this->getMock('Flow\File', array('_move_uploaded_file'), array($config, $fustyRequest));
/** @noinspection PhpUndefinedMethodInspection */
$file->expects($this->once())
->method('_move_uploaded_file')
->will($this->returnCallback(function ($filename, $destination) {
return rename($filename, $destination);
}));
//// Actual test
$this->assertTrue($file->validateChunk());
$this->assertFalse($file->validateFile());
$this->assertTrue($file->saveChunk());
$this->assertTrue($file->validateFile());
$path = $this->vfs->url() . DIRECTORY_SEPARATOR . 'new';
$this->assertTrue($file->save($path));
$this->assertEquals(10, filesize($path));
}
}
<?php
namespace Unit;
use Flow\Request;
/**
* Request unit tests
*
* @coversDefaultClass \Flow\Request
*
* @package Unit
*/
class RequestTest extends FlowUnitCase
{
/**
* @covers ::__construct
*/
public function testRequest_construct_withREQUEST()
{
$_REQUEST = $this->requestArr;
$request = new Request();
$this->assertSame('prettify.js', $request->getFileName());
$this->assertSame(100, $request->getTotalSize());
$this->assertSame('13632-prettifyjs', $request->getIdentifier());
$this->assertSame('home/prettify.js', $request->getRelativePath());
$this->assertSame(42, $request->getTotalChunks());
$this->assertSame(1048576, $request->getDefaultChunkSize());
$this->assertSame(1, $request->getCurrentChunkNumber());
$this->assertSame(10, $request->getCurrentChunkSize());
$this->assertSame(null, $request->getFile());
$this->assertFalse($request->isFustyFlowRequest());
}
/**
* @covers ::__construct
* @covers ::getParam
* @covers ::getFileName
* @covers ::getTotalSize
* @covers ::getIdentifier
* @covers ::getRelativePath
* @covers ::getTotalChunks
* @covers ::getDefaultChunkSize
* @covers ::getCurrentChunkNumber
* @covers ::getCurrentChunkSize
* @covers ::getFile
* @covers ::isFustyFlowRequest
*/
public function testRequest_construct_withCustomRequest()
{
$request = new Request($this->requestArr);
$this->assertSame('prettify.js', $request->getFileName());
$this->assertSame(100, $request->getTotalSize());
$this->assertSame('13632-prettifyjs', $request->getIdentifier());
$this->assertSame('home/prettify.js', $request->getRelativePath());
$this->assertSame(42, $request->getTotalChunks());
$this->assertSame(1048576, $request->getDefaultChunkSize());
$this->assertSame(1, $request->getCurrentChunkNumber());
$this->assertSame(10, $request->getCurrentChunkSize());
$this->assertSame(null, $request->getFile());
$this->assertFalse($request->isFustyFlowRequest());
}
/**
* @covers ::__construct
*/
public function testRequest_construct_withFILES()
{
$_FILES = $this->filesArr;
$request = new Request();
$this->assertSame($this->filesArr['file'], $request->getFile());
}
/**
* @covers ::__construct
*/
public function testRequest_construct_withCustFiles()
{
$request = new Request(null, $this->filesArr['file']);
$this->assertSame($this->filesArr['file'], $request->getFile());
}
}
<?php
namespace Unit;
use Flow\FileOpenException;
use org\bovigo\vfs\vfsStreamWrapper;
use org\bovigo\vfs\vfsStreamDirectory;
use org\bovigo\vfs\vfsStream;
use Flow\Uploader;
/**
* Uploader unit tests
*
* @coversDefaultClass \Flow\Uploader
*
* @package Unit
*/
class UploaderTest extends FlowUnitCase
{
/**
* Virtual file system
*
* @var vfsStreamDirectory
*/
protected $vfs;
protected function setUp()
{
vfsStreamWrapper::register();
$this->vfs = new vfsStreamDirectory('chunks');
vfsStreamWrapper::setRoot($this->vfs);
}
/**
* @covers ::pruneChunks
*/
public function testUploader_pruneChunks()
{
//// Setup test
$newDir = vfsStream::newDirectory('1');
$newDir->lastModified(time()-31);
$newDir->lastModified(time());
$fileFirst = vfsStream::newFile('file31');
$fileFirst->lastModified(time()-31);
$fileSecond = vfsStream::newFile('random_file');
$fileSecond->lastModified(time()-30);
$upDir = vfsStream::newFile('..');
$this->vfs->addChild($newDir);
$this->vfs->addChild($fileFirst);
$this->vfs->addChild($fileSecond);
$this->vfs->addChild($upDir);
//// Actual test
Uploader::pruneChunks($this->vfs->url(), 30);
$this->assertTrue(file_exists($newDir->url()));
$this->assertFalse(file_exists($fileFirst->url()));
$this->assertTrue(file_exists($fileSecond->url()));
}
/**
* @covers ::pruneChunks
*/
public function testUploader_exception()
{
try {
@Uploader::pruneChunks('not/existing/dir', 30);
$this->fail();
} catch (FileOpenException $e) {
$this->assertSame('failed to open folder: not/existing/dir', $e->getMessage());
}
}
}
<?php
require_once(__DIR__ . '/../vendor/autoload.php');
require_once(__DIR__ . '/../src/Flow/Autoloader.php');
require_once(__DIR__ . '/Unit/FlowUnitCase.php');
Flow\Autoloader::register();
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false"
bootstrap="test/bootstrap.php"
>
<testsuites>
<testsuite name="Flow Test Suite">
<directory>./test/Unit/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">./src/Flow/</directory>
<exclude>
<file>./src/Flow/Basic.php</file>
</exclude>
</whitelist>
</filter>
<logging>
<log type="coverage-clover" target="build/coverage.xml"/>
<log type="coverage-text" target="php://stdout" showUncoveredFiles="false"/>
</logging>
<listeners>
<listener class="League\PHPUnitCoverageListener\Listener">
<arguments>
<array>
<element key="printer">
<object class="League\PHPUnitCoverageListener\Printer\StdOut"/>
</element>
<element key="hook">
<object class="League\PHPUnitCoverageListener\Hook\Travis"/>
</element>
<element key="namespace">
<string>Flow</string>
</element>
<element key="repo_token">
<string>TtscpSyNYUnuG2LkxtWCQmAtBk8vWAMsI</string>
</element>
<element key="target_url">
<string>https://coveralls.io/api/v1/jobs</string>
</element>
<element key="coverage_dir">
<string>build</string>
</element>
</array>
</arguments>
</listener>
</listeners>
</phpunit>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment