File upload layer extracted & evolved from numerous projects

master
Vitaliy Filippov 2018-09-09 15:16:31 +03:00
commit 2812bab85d
9 changed files with 1150 additions and 0 deletions

124
DBObjectFile.php Normal file
View File

@ -0,0 +1,124 @@
<?php
/**
* Простой слой загрузки файлов на сервер - привязка к DBObject.php
* Версия 2018-01-14
* (c) Виталий Филиппов 2018
*/
class File extends DBObject
{
const ONLY_BINARY = 1;
const ONLY_IMAGES = 2;
const ONLY_SWF = 4;
const ONLY_VIDEO = 8;
const IMAGES_VIDEO = 10;
const ANY_MEDIA = 14;
const ANYTHING = 15;
const CROP_XY = 1;
const CROP_Y = 2;
const CROP_X = 3;
public static $table = 'files';
public static $clean = [], $dirty = [];
public static $fields = [
'id' => false,
'user_id' => false,
'sha1' => false,
'format' => false,
'mimetype' => false,
'size' => true,
'width' => true,
'height' => true,
'added' => true,
'props' => true,
];
public static $joins = [
'user' => 'User',
];
protected function get_disk_path()
{
return FileHandler::getPath(false, $this->data);
}
protected function get_raw_url()
{
return FileHandler::getPath(true, $this->data);
}
protected function get_fsize_ru()
{
return FileUtils::sizeString($this->data['size'], 'ru');
}
protected function get_fsize_en()
{
return FileUtils::sizeString($this->data['size'], 'en');
}
protected function get_url()
{
return App::url('api', [ 'action' => 'Files.thumb', 'sha1' => $this->data['sha1'] ]);
}
protected function get_gps()
{
return FileHandler::getGPS($this->data);
}
public function getThumb($width, $height, $force = false, $crop = false, $alignY = 0.5)
{
return FileHandler::getThumb($this->data, $width, $height, $force, $crop, $alignY);
}
public function cropThumb($width, $height, $alignY = 0.5)
{
return FileHandler::getThumb($this->data, $width, $height, false, self::CROP_XY, $alignY);
}
public function cropYThumb($width, $max_height, $alignY = 0.5)
{
return FileHandler::getThumb($this->data, $width, $max_height, false, self::CROP_Y, $alignY);
}
public function cropXThumb($max_width, $height)
{
return FileHandler::getThumb($this->data, $max_width, $height, false, self::CROP_X);
}
public static function upload(LocalFile $localFile, $allowedFormats = File::ANYTHING)
{
$file = new File();
$file->data = FileHandler::upload($localFile, $allowedFormats);
return $file->data ? $file : NULL;
}
public static function uploadUrl($url, $allowedFormats = File::ONLY_IMAGES, $curl_options = [])
{
$file = new File();
$file->data = FileHandler::uploadUrl($url, $allowedFormats, $curl_options);
return $file->data ? $file : NULL;
}
public function delete()
{
return FileHandler::deleteFiles([ 'id' => $this->data['id'] ]);
}
public static function newFromRow($row)
{
$obj = parent::newFromRow($row);
if ($obj)
{
$obj->data['props'] = json_decode($obj->data['props'], true);
}
return $obj;
}
public function saveMe()
{
throw new Exception('File objects are immutable');
}
}

84
File.php Normal file
View File

@ -0,0 +1,84 @@
<?php
/**
* Простой слой загрузки файлов на сервер - "голая" версия
* Версия 2018-01-14
* (c) Виталий Филиппов 2018
*/
class File
{
const ONLY_BINARY = 1;
const ONLY_IMAGES = 2;
const ONLY_SWF = 4;
const ONLY_VIDEO = 8;
const IMAGES_VIDEO = 10;
const ANY_MEDIA = 14;
const ANYTHING = 15;
const CROP_XY = 1;
const CROP_Y = 2;
const CROP_X = 3;
public static $table = 'files';
public static function getDiskPath($file)
{
return FileHandler::getPath(false, $file);
}
public static function getUrl($file)
{
return FileHandler::getPath(true, $file);
}
public static function getThumbPath($file, $type)
{
return FileHandler::getThumbPath(false, $file, $type);
}
public static function getGPS($file)
{
return FileHandler::getGPS($file);
}
public static function getSizeString($file, $lang = 'ru')
{
return FileUtils::sizeString($file['size'], $lang);
}
public static function getThumb($file, $width, $height, $force = false, $crop = false, $alignY = 0.5)
{
return FileHandler::getThumb($file, $width, $height, $force, $crop, $alignY);
}
public static function cropThumb($file, $width, $height, $alignY = 0.5)
{
return FileHandler::getThumb($file, $width, $height, false, self::CROP_XY, $alignY);
}
public static function cropYThumb($file, $width, $max_height, $alignY = 0.5)
{
return FileHandler::getThumb($file, $width, $max_height, false, self::CROP_Y, $alignY);
}
public static function cropXThumb($file, $max_width, $height)
{
return FileHandler::getThumb($file, $max_width, $height, false, self::CROP_X);
}
public static function upload(LocalFile $localFile, $allowedFormats = File::ANYTHING)
{
return FileHandler::upload($localFile, $allowedFormats);
}
public static function uploadUrl($url, $allowedFormats = File::ONLY_IMAGES, $curl_options = [])
{
return FileHandler::uploadUrl($url, $allowedFormats, $curl_options);
}
public static function deleteFiles($where)
{
return FileHandler::deleteFiles($where);
}
}

204
FileHandler.php Normal file
View File

@ -0,0 +1,204 @@
<?php
/**
* Простой слой загрузки файлов на сервер
* Версия 2018-01-14
* (c) Виталий Филиппов 2018
*/
class FileHandler
{
public static $thumbPath = 'thumb/';
public static function base($web_or_fs = true)
{
static $p, $l;
if (!$p)
{
$p = App::$config['files_path'];
if ($p{0} != '/')
{
$p = '/'.$p;
}
$l = App::$config['local_path'];
if ($l{strlen($l)-1} == '/')
{
$l = substr($l, 0, -1);
}
if ($p{strlen($p)-1} != '/')
{
$p .= '/';
}
}
return $web_or_fs ? App::domain().$p : $l.$p;
}
public static function getPath($web_or_fs, $file)
{
$s = $file['sha1'];
return FileHandler::base($web_or_fs) . '/' . substr($s, 0, 1) . '/' . substr($s, 0, 2) . '/' . $s . '.' . $file['format'];
}
public static function getThumbPath($web_or_fs, $file, $type)
{
$s = $file['sha1'];
$ext = $file['format'] == 'jpg' || $file['format'] == 'png' || $file['format'] == 'gif'
? $file['format'] : 'jpg';
return FileHandler::base($web_or_fs) . self::$thumbPath . '/' . $type . '/' .substr($s, 0, 1) . '/' . substr($s, 0, 2) . '/' . $s . '.' . $ext;
}
public static function getGPS($file)
{
$props = $file['props'];
if (empty($props['GPSLatitude']) && empty($props['GPSLongitude']))
{
return NULL;
}
$latitude = FileUtils::exifGPS($props['GPSLatitude'], $props['GPSLatitudeRef']);
$longitude = FileUtils::exifGPS($props['GPSLongitude'], $props['GPSLongitudeRef']);
return [ $latitude, $longitude ];
}
public static function upload(LocalFile $localFile, $allowedFormats = File::ANYTHING)
{
$tmp_name = $localFile->getLocalPath();
$props = FileUtils::getProps($allowedFormats, $tmp_name, $localFile->getFileName());
$row = [
'id' => NULL,
'added' => time(),
'user_id' => App::$user['id'] ?: NULL,
] + $props + [ 'props' => [] ];
$exist = App::$db->select(File::$table, '*', [ 'sha1' => $row['sha1'] ], NULL, MS_ROW);
if ($exist)
{
$exist['props'] = json_decode($exist['props'], true);
return $exist;
}
$row['id'] = App::$db->insert_row(File::$table, [
'props' => json_encode($row['props'], JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),
] + $row);
$fn = FileHandler::getPath(false, $row);
FileUtils::mkpath(dirname($fn), true);
$m = $localFile->shouldMove ? 'rename' : 'copy';
if (!@$m($tmp_name, $fn))
{
$error = error_get_last();
throw new Exception($error['message']);
}
chmod($fn, 0666 & ~umask());
return $row;
}
static $uploadCurl;
public static function uploadUrl($url, $flags = File::ONLY_IMAGES, $curl_options = [])
{
if (!$url)
{
return NULL;
}
$file = NULL;
if (substr($url, 0, 2) == '//')
{
$url = "http:$url";
}
if (!self::$uploadCurl)
{
// Reuse handle to use keepalive when possible
self::$uploadCurl = curl_init();
}
curl_setopt_array(self::$uploadCurl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
] + $curl_options);
$s = curl_exec(self::$uploadCurl);
if ($s)
{
$tmp = tempnam(sys_get_temp_dir(), 'upl');
file_put_contents($tmp, $s);
unset($s);
$file = File::upload(new LocalFile($tmp, true), $flags);
@unlink($tmp);
}
else
{
// Log it as E_USER_NOTICE
trigger_error(curl_error(self::$uploadCurl));
}
return $file;
}
public static function deleteFiles($where)
{
$files = App::$db->select(File::$table, '*', $where);
foreach ($existing as $e)
{
$disk_name = FileHandler::getPath(false, $e);
if (file_exists($disk_name))
{
// Remove old file
// FIXME unlink thumbnails
unlink($disk_name);
}
}
}
/**
* Get or generate a thumbnail and return its URL
*/
public static function getThumb($file, $width, $height, $force = false, $crop = false, $alignY = 0.5)
{
$size = FileUtils::getThumbSize($file, $width, $height, $crop);
if (!$size)
{
return FileHandler::getPath(true, $file);
}
list($width, $height) = $size;
if (!$crop)
{
$type = intval($width);
}
else
{
$p = $crop == File::CROP_Y ? 'cy' : ($crop == File::CROP_X ? 'cx' : 'c');
$type = intval($width).'x'.intval($height).'_'.$alignY;
}
$fn = FileHandler::getThumbPath(false, $file, $type);
if (!file_exists($fn) || $force)
{
if (substr($file['mimetype'], 0, 6) === 'video/')
{
$sourcefn = FileHandler::getThumbPath(false, $file, 'src');
}
else
{
$sourcefn = FileHandler::getPath(false, $file);
}
try
{
$im = FileUtils::magick();
$im->readImage($sourcefn);
$props = $file['props'];
if (!empty($props['Orientation']) && $props['Orientation'] > 5)
{
/* swap width & height */
$t = $width;
$width = $height;
$height = $t;
if ($crop == File::CROP_X || $crop == File::CROP_Y)
$crop = 5-$crop;
}
FileUtils::makeThumb($im, $width, $height, $crop, isset($props['Orientation']) ? $props['Orientation'] : NULL, $alignY);
$im->setCompressionQuality(FileUtils::$quality);
FileUtils::mkpath(dirname($fn));
$im->writeImage($fn);
}
catch (Exception $e)
{
trigger_error("$e");
return false;
}
}
return FileHandler::getThumbPath(true, $file, $type);
}
}

427
FileUtils.php Normal file
View File

@ -0,0 +1,427 @@
<?php
class FileUtils
{
public static $quality = 90;
public static $exifProps = [
'Model',
'Orientation',
'DateTime',
'DateTimeOriginal',
'ExposureTime',
'FNumber',
'ISOSpeedRatings',
'FocalLength',
'FocalLengthIn35mmFilm',
'GPSLongitude',
'GPSLongitudeRef',
'GPSLatitude',
'GPSLatitudeRef',
];
public static function magick()
{
static $class;
if (!$class)
{
// Use GraphicsMagick if possible
$class = class_exists('Gmagick') ? 'Gmagick' : 'Imagick';
}
return new $class();
}
public static function exifGPS($coordinate, $hemisphere)
{
for ($i = 0; $i < 3; $i++)
{
$part = explode('/', $coordinate[$i]);
if (count($part) == 1)
{
$coordinate[$i] = $part[0];
}
elseif (count($part) == 2)
{
$coordinate[$i] = floatval($part[0])/floatval($part[1]);
}
else
{
$coordinate[$i] = 0;
}
}
list($degrees, $minutes, $seconds) = $coordinate;
$sign = ($hemisphere == 'W' || $hemisphere == 'S') ? -1 : 1;
return $sign * ($degrees + $minutes/60 + $seconds/3600);
}
/**
* Get image or file properties (content SHA1, width, height, selected EXIF properties)
*/
public static function getProps($allowed_types, $fs_path, $orig_name)
{
if (!file_exists($fs_path))
{
throw new UserException('no-file-uploaded');
}
$mime = self::checkMime($fs_path);
if (!$mime)
{
throw new UserException('file-mime-type-unknown');
}
elseif (preg_match(App::$config['mime_blacklist'], $mime))
{
// Prevent uploads with dangerous MIME types
throw new UserException('file-mime-type-blacklisted');
}
$stdProps = [
'mimetype' => $mime,
'sha1' => sha1_file($fs_path),
'size' => filesize($fs_path),
'format' => '',
'width' => 0,
'height' => 0,
];
$flag = File::ONLY_BINARY;
$props = NULL;
if ($mime == 'application/x-shockwave-flash')
{
$props = self::getSWFProps($fs_path);
if ($props)
$flag = File::ONLY_SWF;
}
elseif (substr($mime, 0, 6) == 'image/')
{
$props = self::getImageProps($fs_path);
if ($props)
$flag = File::ONLY_IMAGES;
}
elseif (substr($mime, 0, 6) == 'video/')
{
$props = VideoUtils::getVideoProps($fs_path);
if ($props)
$flag = File::ONLY_VIDEO;
}
if (!($allowed_types & $flag))
{
throw new UserException('file-type-denied', [ 'allowed' => $allowed_types, 'type' => $flag ]);
}
$props = ($props ?: []) + $stdProps;
if (!$props['format'])
{
if (!empty($props['filename']))
$orig_name = preg_replace('#^.*/#is', '', $props['filename']);
$p = strrpos($orig_name, '.');
if ($p)
$props['format'] = strtolower(substr($orig_name, $p+1));
}
unset($props['filename']);
return $props;
}
/**
* Get image properties + compress uploaded BMP images to JPEG
*/
protected static function getImageProps($fn)
{
$format = NULL;
$props = [];
try
{
$exif = @exif_read_data($fn);
if ($exif)
{
foreach (self::$exifProps as $p)
{
if (isset($exif[$p]))
{
$props[$p] = $exif[$p];
}
}
}
// FIXME Сначала проверять ширину/высоту, не читая саму картинку в память
$im = static::magick();
$im->readImage($fn);
$format = strtolower($im->getImageFormat());
if ($format == 'jpeg')
{
$format = 'jpg';
}
if ($format != 'png' &&
$format != 'gif' &&
$format != 'jpeg' &&
$format != 'jpg' &&
$format != 'pdf' &&
$format != 'djvu' &&
$format != 'djv' &&
($format != 'ico' || $im->getImageWidth() > 48 || $im->getImageHeight() > 48)) // bmp, tiff, psd...
{
$im->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
if (!empty($props['Orientation']))
{
self::fixOrientation($im, $props['Orientation']);
unset($props['Orientation']);
}
$im->setCompressionQuality(static::$quality);
$im->setImageFormat('jpeg');
$format = 'jpg';
$fn = tempnam(sys_get_temp_dir(), 'imguniq');
$im->writeImage($fn);
}
$width = $im->getImageWidth();
$height = $im->getImageHeight();
if (!empty($props['Orientation']) && $props['Orientation'] >= 5)
{
$t = $width;
$width = $height;
$height = $t;
}
}
catch (Exception $e)
{
// Not an image
return NULL;
}
return [
'filename' => $fn,
'width' => $width,
'height' => $height,
'format' => $format,
'props' => $props,
];
}
/**
* Get MIME type of a file
*/
protected static function checkMime($filename)
{
$mime = false;
if (class_exists('finfo'))
{
static $finfo;
if (!$finfo)
{
$finfo = new finfo(FILEINFO_MIME_TYPE);
}
if ($finfo)
{
$mime = $finfo->file($filename);
}
}
elseif (function_exists('mime_content_type'))
{
$mime = mime_content_type($filename);
}
else
{
$mime = shell_exec("file ".escapeshellarg($filename));
}
return $mime;
}
protected static function getSWFProps($fn)
{
$size = shell_exec("swfdump -X -Y ".escapeshellarg($fn));
if (preg_match('/-X (\d+) -Y (\d+)/', $size, $m))
{
return [
'format' => 'swf',
'width' => $m[1],
'height' => $m[2],
];
}
return NULL;
}
/**
* Fix ImageMagick image $im orientation based on EXIF orientation $orient
*/
public static function fixOrientation($im, $orient)
{
$orient = intval($orient);
if ($orient > 1 && $orient <= 8)
{
if ($orient == 2)
{
$im->flopImage();
}
elseif ($orient == 3)
{
$im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 180);
}
elseif ($orient == 4)
{
$im->flipImage();
}
elseif ($orient == 5)
{
if (method_exists($im, 'transposeImage'))
{
$im->transposeImage();
}
else
{
// GraphicsMagick doesn't have Transpose
$im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 90);
$im->flopImage();
}
}
elseif ($orient == 6)
{
$im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 90);
}
elseif ($orient == 7)
{
if (method_exists($im, 'transverseImage'))
{
$im->transverseImage();
}
else
{
// GraphicsMagick doesn't have Transverse
$im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 270);
$im->flopImage();
}
}
elseif ($orient == 8)
{
$im->rotateImage($im instanceof Imagick ? new ImagickPixel() : new GmagickPixel(), 270);
}
}
}
/**
* Calculate thumbnail image width and height
*/
public static function thumbnailSize($cw, $ch, $maxw, $maxh)
{
if ($maxw <= 0 && $maxh <= 0 || $cw <= 0 || $ch <= 0)
{
return [ $cw, $ch ];
}
if ($maxw > 0 && ($maxh <= 0 || $maxh/$ch >= $maxw/$cw))
{
$nw = $maxw;
$nh = $ch * $maxw / $cw;
}
elseif ($maxh > 0 && ($maxw <= 0 || $maxh/$ch < $maxw/$cw))
{
$nw = $cw * $maxh / $ch;
$nh = $maxh;
}
return [ intval($nw), intval($nh) ];
}
/**
* Return file size description
*/
static $sizeSuffix = [
'Кб' => [
'bytes' => 'bytes',
'Kb' => 'Кб',
'Mb' => 'Мб',
'Gb' => 'Гб',
],
];
public static function sizeString($bytes, $lang = 'ru')
{
$r = $bytes;
if (is_numeric($r))
{
if ($r >= 0 && $r < 1024)
$r = $r . ' ' . (isset(self::$sizeString[$lang]['bytes']) ? self::$sizeString[$lang]['bytes'] : 'bytes');
elseif ($r >= 1024 && $r < 1024*1024)
$r = sprintf('%.2f ', $r/1024) . (isset(self::$sizeString[$lang]['Kb']) ? self::$sizeString[$lang]['Kb'] : 'Kb');
elseif ($r >= 1024*1024 && $r < 1024*1024*1024)
$r = sprintf('%.2f ', $r/1024/1024) . (isset(self::$sizeString[$lang]['Mb']) ? self::$sizeString[$lang]['Mb'] : 'Mb');
elseif ($r >= 1024*1024*1024)
$r = sprintf('%.2f ', $r/1024/1024/1024) . (isset(self::$sizeString[$lang]['Gb']) ? self::$sizeString[$lang]['Gb'] : 'Gb');
elseif ($r < 0)
$r = sprintf('%.2f ', 2-($r/1024/1024/1024)) . (isset(self::$sizeString[$lang]['Gb']) ? self::$sizeString[$lang]['Gb'] : 'Gb');
}
return $r;
}
/**
* Recursively create a directory
*/
public static function mkpath($path, $throw_error = true)
{
if (is_dir($path) || @mkdir($path, 0777, true))
{
return true;
}
if ($throw_error)
{
$error = error_get_last();
throw new Exception("Failed to create path $path: ".$error['message']);
}
return false;
}
/**
* Get thumbnail size for $file
*/
public static function getThumbSize($file, $width, $height, $crop = false)
{
if (empty($file['id']))
return NULL;
if ($width < 0)
$width = 0;
if ($height < 0)
$height = 0;
if (!$width || !$height)
$crop = false;
if (!$file['width'] || !$width && !$height ||
(!$width || $file['width'] <= $width) &&
(!$height || $file['height'] < $height) && !$crop ||
($file['format'] == 'swf' || $file['format'] == 'video'))
return NULL;
if (!$crop)
return self::thumbnailSize($file['width'], $file['height'], $width, $height);
return [ $width, $height ];
}
/**
* Convert ImageMagick image $im to a thumbnail
*/
public static function makeThumb($im, $width, $height, $crop, $orientation, $alignY)
{
$cl = get_class($im);
if ($crop)
{
$iw = $im->getImageWidth();
$ih = $im->getImageHeight();
if ($crop == File::CROP_Y && $ih*$width/$iw < $height)
{
$height = $ih*$width/$iw;
}
elseif ($crop == File::CROP_X && $iw*$height/$ih < $width)
{
$width = $iw*$height/$ih;
}
if ($width/$height < $iw/$ih)
{
$cw = intval($width*$ih/$height);
$im->cropImage($cw, $ih, intval(($iw-$cw)/2), 0);
}
elseif ($width/$height > $iw/$ih)
{
$ch = intval($height*$iw/$width);
$im->cropImage($iw, $ch, 0, intval(($ih-$ch)*$alignY));
}
$im->resizeImage($width, $height, $cl::FILTER_LANCZOS, 1);
$im->setImagePage($width, $height, 0, 0); /* for gif cropping */
$iw = $im->getImageWidth();
$ih = $im->getImageHeight();
}
else
{
$im->resizeImage($width, $height, $cl::FILTER_LANCZOS, 1);
}
$im->stripImage();
if ($orientation !== NULL)
{
self::fixOrientation($im, $orientation);
}
}
}

23
LocalFile.php Normal file
View File

@ -0,0 +1,23 @@
<?php
class LocalFile
{
var $shouldMove = false;
var $name;
function __construct($name, $shouldMove = false)
{
$this->name = $name;
$this->shouldMove = $shouldMove;
}
function getLocalPath()
{
return $this->name;
}
function getFileName()
{
return basename($this->name);
}
}

50
PostedFile.php Normal file
View File

@ -0,0 +1,50 @@
<?php
class PostedFile extends LocalFile
{
var $shouldMove = true;
function __construct($name)
{
$this->name = $name;
if (!is_uploaded_file(@$_FILES[$this->name]['tmp_name']))
{
throw new Exception("No POSTed file with name $name");
}
}
static function newFromName($name)
{
if (is_uploaded_file(@$_FILES[$name]['tmp_name']))
{
return new self($name);
}
return false;
}
function __destruct()
{
$tmp_name = $_FILES[$this->name]['tmp_name'];
if (@is_uploaded_file($tmp_name))
{
// Unlink temporary upload
@unlink($tmp_name);
}
}
function getLocalPath()
{
return $_FILES[$this->name]['tmp_name'];
}
function getFileName()
{
if (isset($_FILES[$this->name]['name']))
{
$name = trim($_FILES[$this->name]['name']);
$name = preg_replace('#^.*[/\\\\]#is', '', $name);
return $name;
}
return '';
}
}

180
VideoUtils.php Normal file
View File

@ -0,0 +1,180 @@
<?php
class VideoUtils
{
public static function getVideoProps($fn)
{
if (empty(App::$config['video_converter']))
{
throw new Exception('Video converter is not configured on the server');
}
$probe = App::$config['video_converter'];
$cmd = $probe['probe'];
$cmd = str_replace('$input', escapeshellarg($fn), $cmd);
$out = shell_exec($cmd);
$data = [];
foreach ([ 'format', 'size', 'duration', 'video_format', 'audio_format' ] as $k)
{
if (preg_match_all($probe['probe_'.$k], $out, $m, PREG_PATTERN_ORDER))
{
unset($m[0]);
$data[$k] = $m;
}
else
{
$data[$k] = NULL;
}
}
if ($data['format'] && $data['size'] && $data['duration'])
{
$dur = $data['duration'][1][0];
$colon = explode(':', $dur);
if (count($colon) > 1)
{
$dur = 0;
for ($mul = 1, $i = 0; $i < count($colon); $i++, $mul *= 60)
$dur += $colon[count($colon)-1-$i] * $mul;
}
// Пустой формат видео означает, что файл ещё нужно обработать (минимум qt-faststart и вытащить тамбик)
return [
'format' => 'video',
'width' => $data['size'][1][0],
'height' => $data['size'][2][0],
'props' => [
'duration' => $dur,
'video_format' => @$data['video_format'][1],
'audio_format' => @$data['audio_format'][1],
],
];
}
return NULL;
}
protected static function extractVideoPreview($file, $redirect_output = '')
{
$conv = App::$config['video_converter'];
$ss = $file['props']['duration'] * $conv['preview_moment'];
$fn = FileHandler::getPath(false, $file);
$out = FileHandler::getThumbPath(false, $file, 'src');
FileUtils::mkpath(dirname($out), true);
$cmd = $conv['extract_frame'];
$cmd = str_replace([ '$input', '$output', '$position' ], [ escapeshellarg($fn), escapeshellarg($out), ceil($ss) ], $cmd);
system($cmd . ($redirect_output ? ' &> '.$redirect_output : ''));
if (!file_exists($out) || !filesize($out))
{
throw new Exception('Failed to extract video frame for preview');
}
}
public static function runConvertJobDaemon($redirect_output = '')
{
while (1)
{
$file = App::$db->selectRow(File::$table, '*', [ 'format' => 'video' ], [ 'LIMIT' => 1 ]);
if ($file)
{
self::convertVideoAndUpdate($file, $redirect_output);
}
App::$db->commitAll();
sleep(5);
}
}
public static function convertVideoAndUpdate($file, $redirect_output = '')
{
$props = self::convertVideo($file, $redirect_output);
if (!$props)
{
return;
}
self::extractVideoPreview($props + $file, $redirect_output);
$update = $props;
if (isset($update['props']))
{
$update['props'] = json_encode($update['props']);
}
App::$db->update(File::$table, $update, [ 'id' => $file['id'] ]);
if (isset($props['sha1']) && $props['sha1'] != $file['sha1'])
{
@unlink(FileHandler::getPath(false, $file));
}
elseif (isset($props['format']) && $props['format'] != $file['format'])
{
@rename(FileHandler::getPath(false, $file), FileHandler::getPath(false, $props + $file));
}
return $props + $file;
}
protected static function convertVideo($file, $redirect_output = '')
{
// Краткая сводка:
// Flash понимает форматы файлов MP4/FLV с видео+аудио FLV+MP3 или H.264+AAC
// HTML5 кроссбраузерно понимает форматы MP4 с видео H.264 и аудио MP3/AAC, либо WebM с видео VP8 и аудио Vorbis
// Для MP4 надо делать qt-faststart, если MOOV ATOM не находится в начале видеофайла
//
// Итак, если формат WebM+VP8+Vorbis или FLV+FLV+MP3 или FLV+H.264+AAC, видео можно вообще не конвертировать
// Если формат MP4+H.264+MP3/AAC, надо проверить/сделать qt-faststart
// Любой другой формат надо перегнать в MP4+H.264+AAC
if ($file['format'] != 'video')
{
return NULL;
}
if (empty(App::$config['video_converter']))
{
throw new Exception('Video converter is not configured on the server');
}
$conv = App::$config['video_converter'];
$vf = $file['props']['video_format'][0];
$af = $file['props']['audio_format'][0];
if ($file['mimetype'] == 'video/x-flv' && ($vf == 'flv1' && $af == 'mp3' || $vf == 'h264' && $af == 'aac'))
{
return [
'format' => 'flv',
];
}
elseif ($file['mimetype'] == 'video/webm' && $vf == 'vp8' && $af == 'vorbis')
{
return [
'format' => 'webm',
];
}
$fn = FileHandler::getPath(false, $file);
$out = tempnam(sys_get_temp_dir(), 'ffc');
if ($file['mimetype'] == 'video/mp4' && $vf == 'h264' && ($af == 'mp3' || $af == 'aac'))
{
$cmd = $conv['qt_faststart'].' '.escapeshellarg($fn).' '.escapeshellarg($out);
system($cmd . ($redirect_output ? ' &> '.$redirect_output : ''));
if (!file_exists($out))
{
// Уже было faststart, всё ок
return [
'format' => 'mp4',
];
}
}
else
{
$cmd = $conv['convert'];
$cmd = str_replace([ '$input', '$output' ], [ escapeshellarg($fn), escapeshellarg($out) ], $cmd);
system($cmd . ($redirect_output ? ' &> '.$redirect_output : ''));
if (!file_exists($out))
{
// Не сконвертировалось, плохо
throw new Exception('Failed to convert video to MP4');
}
}
// Сконвертированный файл
$update = FileUtils::getProps(File::ONLY_VIDEO, $out, $out);
$update['format'] = 'mp4';
$newfn = FileHandler::getPath(false, $update + $file);
FileUtils::mkpath(dirname($newfn), true);
if (!@rename($out, $newfn))
{
$error = error_get_last();
throw new Exception($error['message']);
}
// FIXME Теоретически может получиться так, что полученный файл будет дубликатом, и запрос свалится
// (Может ли ffmpeg дважды выдать одинаковый файл? Тогда будет проблемка)
return $update;
}
}

29
config.php Normal file
View File

@ -0,0 +1,29 @@
<?php
$config += [
'local_path' => dirname(dirname(__DIR__)),
'files_path' => '/files/',
'mime_blacklist' => '#'.
// HTML may contain cookie-stealing JavaScript and web bugs
'^text/(html|(x-)?javascript)$|^application/x-shellscript$'.
// PHP/Perl/Bash/etc scripts may execute arbitrary code on the server
'|php|perl|python|bash|x-c?sh(e|$)'.
// Client-side hazards on Internet Explorer
'|^text/scriptlet$|^application/x-msdownload$'.
// Windows metafile, client-side vulnerability on some systems
'|^application/x-msmetafile$'.
'#is',
'video_converter' => [
'extract_frame' => '/usr/bin/ffmpeg -ss \'$position\' -i $input -vframes 1 -f image2 -y $output 2>&1',
'preview_moment' => '0.05',
'probe' => '/usr/bin/ffmpeg -i $input 2>&1',
'probe_format' => '/Input #\d+, (\S+), from/is',
'format_aliases' => [ 'mov,mp4,m4a,3gp,3g2,mj2' => 'mp4', 'matroska' => 'mkv', 'matroska,webm' => 'mkv' ],
'probe_size' => '/Stream.*Video.*,\s+(\d+)x(\d+)/is', // [1] == width, [2] == height
'probe_duration' => '/Duration: ([\d:]+)/is',
'probe_video_format' => '/Stream.*Video:\s*(\S+)/is',
'probe_audio_format' => '/Stream.*Audio:\s*(\S+)/is',
'qt_faststart' => '/usr/bin/qt-faststart',
'convert' => '/usr/bin/ffmpeg -i $input -vcodec h264 -qmax 28 -acodec aac -movflags faststart -y $output',
],
];

29
files.sql Normal file
View File

@ -0,0 +1,29 @@
-- Файлы
create table if not exists files (
id serial not null primary key,
added bigint not null,
user_id int,
sha1 varchar(40) not null,
format varchar(40) not null,
mimetype varchar(1024) not null,
size bigint not null,
width int not null default 0,
height int not null default 0,
props jsonb not null default '{}'::jsonb,
foreign key (user_id) references users (id) on delete set null on update cascade
);
create unique index on files (sha1);
create index on files (format);
create index on files (user_id);
comment on table files is 'Файлы';
comment on column files.added is 'UNIX время загрузки';
comment on column files.user_id is 'ID пользователя-владельца';
comment on column files.sha1 is 'SHA1 хеш';
comment on column files.format is 'Формат (расширение)';
comment on column files.mimetype is 'MIME-тип (реальный формат)';
comment on column files.size is 'Размер файла';
comment on column files.width is 'Ширина';
comment on column files.height is 'Высота';
comment on column files.propdata is 'Дополнительные свойства (EXIF, параметры видео)';