From 2812bab85d06de8675d528ba50217c43e96d6dbc Mon Sep 17 00:00:00 2001 From: Vitaliy Filippov Date: Sun, 9 Sep 2018 15:16:31 +0300 Subject: [PATCH] File upload layer extracted & evolved from numerous projects --- DBObjectFile.php | 124 ++++++++++++++ File.php | 84 ++++++++++ FileHandler.php | 204 ++++++++++++++++++++++ FileUtils.php | 427 +++++++++++++++++++++++++++++++++++++++++++++++ LocalFile.php | 23 +++ PostedFile.php | 50 ++++++ VideoUtils.php | 180 ++++++++++++++++++++ config.php | 29 ++++ files.sql | 29 ++++ 9 files changed, 1150 insertions(+) create mode 100644 DBObjectFile.php create mode 100644 File.php create mode 100644 FileHandler.php create mode 100644 FileUtils.php create mode 100644 LocalFile.php create mode 100644 PostedFile.php create mode 100644 VideoUtils.php create mode 100644 config.php create mode 100644 files.sql diff --git a/DBObjectFile.php b/DBObjectFile.php new file mode 100644 index 0000000..a447a9f --- /dev/null +++ b/DBObjectFile.php @@ -0,0 +1,124 @@ + 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'); + } +} diff --git a/File.php b/File.php new file mode 100644 index 0000000..df95a3c --- /dev/null +++ b/File.php @@ -0,0 +1,84 @@ +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); + } +} diff --git a/FileUtils.php b/FileUtils.php new file mode 100644 index 0000000..5fd10bd --- /dev/null +++ b/FileUtils.php @@ -0,0 +1,427 @@ + $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); + } + } +} diff --git a/LocalFile.php b/LocalFile.php new file mode 100644 index 0000000..378ec6e --- /dev/null +++ b/LocalFile.php @@ -0,0 +1,23 @@ +name = $name; + $this->shouldMove = $shouldMove; + } + + function getLocalPath() + { + return $this->name; + } + + function getFileName() + { + return basename($this->name); + } +} diff --git a/PostedFile.php b/PostedFile.php new file mode 100644 index 0000000..6d9afbf --- /dev/null +++ b/PostedFile.php @@ -0,0 +1,50 @@ +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 ''; + } +} diff --git a/VideoUtils.php b/VideoUtils.php new file mode 100644 index 0000000..07b6e35 --- /dev/null +++ b/VideoUtils.php @@ -0,0 +1,180 @@ + 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; + } +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..9bc6977 --- /dev/null +++ b/config.php @@ -0,0 +1,29 @@ + 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', + ], +]; diff --git a/files.sql b/files.sql new file mode 100644 index 0000000..fef7d1c --- /dev/null +++ b/files.sql @@ -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, параметры видео)';