diff --git a/airtime_mvc/application/cloud_storage/ProxyStorageBackend.php b/airtime_mvc/application/cloud_storage/ProxyStorageBackend.php index 29f8a9caa..fa45da5f8 100644 --- a/airtime_mvc/application/cloud_storage/ProxyStorageBackend.php +++ b/airtime_mvc/application/cloud_storage/ProxyStorageBackend.php @@ -5,16 +5,16 @@ require_once 'FileStorageBackend.php'; require_once 'Amazon_S3StorageBackend.php'; /** - * + * * Controls access to the storage backend class where a file is stored. * */ class ProxyStorageBackend extends StorageBackend { private $storageBackend; - + /** - * Receives the file's storage backend and instantiates the approriate + * Receives the file's storage backend and instantiates the appropriate * object. */ public function ProxyStorageBackend($storageBackend) @@ -32,22 +32,22 @@ class ProxyStorageBackend extends StorageBackend $this->storageBackend = new $storageBackend($CC_CONFIG[$storageBackend]); } } - + public function getAbsoluteFilePath($resourceId) { return $this->storageBackend->getAbsoluteFilePath($resourceId); } - + public function getSignedURL($resourceId) { return $this->storageBackend->getSignedURL($resourceId); } - + public function getFileSize($resourceId) { return $this->storageBackend->getFileSize($resourceId); } - + public function deletePhysicalFile($resourceId) { $this->storageBackend->deletePhysicalFile($resourceId); diff --git a/airtime_mvc/application/common/HTTPHelper.php b/airtime_mvc/application/common/HTTPHelper.php index db314bb0b..505befd7e 100644 --- a/airtime_mvc/application/common/HTTPHelper.php +++ b/airtime_mvc/application/common/HTTPHelper.php @@ -17,4 +17,22 @@ class Application_Common_HTTPHelper $request->getParam("timezone", null) ); } + + public static function getStationUrl() + { + $scheme = "http"; + if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { + $scheme = "https"; + } + $CC_CONFIG = Config::getConfig(); + $baseUrl = $CC_CONFIG['baseUrl']; + $baseDir = $CC_CONFIG['baseDir']; + $basePort = $CC_CONFIG['basePort']; + if (empty($baseDir)) { + $baseDir = "/"; + } + $stationUrl = "$scheme://${baseUrl}:${basePort}${baseDir}"; + + return $stationUrl; + } } diff --git a/airtime_mvc/application/configs/conf.php b/airtime_mvc/application/configs/conf.php index d38a03797..eae99d778 100644 --- a/airtime_mvc/application/configs/conf.php +++ b/airtime_mvc/application/configs/conf.php @@ -43,7 +43,7 @@ class Config { } else { $CC_CONFIG['dev_env'] = 'production'; } - + // Parse separate conf file for cloud storage values $cloudStorageConfig = "/etc/airtime-saas/".$CC_CONFIG['dev_env']."/cloud_storage.conf"; if (!file_exists($cloudStorageConfig)) { @@ -92,7 +92,7 @@ class Config { public static function setAirtimeVersion() { $airtime_version = Application_Model_Preference::GetAirtimeVersion(); $uniqueid = Application_Model_Preference::GetUniqueId(); - $buildVersion = file_get_contents(self::$rootDir."/../VERSION"); + $buildVersion = @file_get_contents(self::$rootDir."/../VERSION"); self::$CC_CONFIG['airtime_version'] = md5($airtime_version.$buildVersion); } diff --git a/airtime_mvc/application/controllers/ApiController.php b/airtime_mvc/application/controllers/ApiController.php index 124b4b4d0..f40fa0e5e 100644 --- a/airtime_mvc/application/controllers/ApiController.php +++ b/airtime_mvc/application/controllers/ApiController.php @@ -95,32 +95,8 @@ class ApiController extends Zend_Controller_Action $fileId = $this->_getParam("file"); - $media = Application_Model_StoredFile::RecallById($fileId); - if ($media != null) { - // Make sure we don't have some wrong result beecause of caching - clearstatcache(); - - if ($media->getPropelOrm()->isValidPhysicalFile()) { - $filename = $media->getPropelOrm()->getFilename(); - - //Download user left clicks a track and selects Download. - if ("true" == $this->_getParam('download')) { - //path_info breaks up a file path into seperate pieces of informaiton. - //We just want the basename which is the file name with the path - //information stripped away. We are using Content-Disposition to specify - //to the browser what name the file should be saved as. - header('Content-Disposition: attachment; filename="'.$filename.'"'); - } else { - //user clicks play button for track preview - header('Content-Disposition: inline; filename="'.$filename.'"'); - } - - $this->readStoredFileObject($media); - exit; - } else { - header ("HTTP/1.1 404 Not Found"); - } - } + $inline = !($this->_getParam('download',false) == true); + Application_Service_MediaService::streamFileDownload($fileId, $inline); $this->_helper->json->sendJson(array()); } @@ -137,82 +113,7 @@ class ApiController extends Zend_Controller_Action $this->smartReadFile($filepath, $mimeType, $size); } - - /** - * Reads the requested portion of a file and sends its contents to the client with the appropriate headers. - * - * This HTTP_RANGE compatible read file function is necessary for allowing streaming media to be skipped around in. - * - * @param string $location - the full filepath pointing to the location of the file - * @param string $mimeType - the file's mime type. Defaults to 'audio/mp3' - * @param integer $size - the file size, in bytes - * @return void - * - * @link https://groups.google.com/d/msg/jplayer/nSM2UmnSKKA/Hu76jDZS4xcJ - * @link http://php.net/manual/en/function.readfile.php#86244 - */ - private function smartReadFile($location, $mimeType = 'audio/mp3', $size = null) - { - if (!$location || $location == "") { - throw new FileDoesNotExistException("Requested file does not exist!"); - } - - // If we're passing in a Stored File object, it's faster - // to use getFileSize() and pass in the result - if (!$size || $size <= 0) { - $size= filesize($location); - } - - if ($size <= 0) { - throw new Exception("Invalid file size returned for file at $location"); - } - - $fm = @fopen($location, 'rb'); - if (!$fm) { - header ("HTTP/1.1 505 Internal server error"); - return; - } - - $begin = 0; - $end = $size - 1; - - if (isset($_SERVER['HTTP_RANGE'])) { - if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $_SERVER['HTTP_RANGE'], $matches)) { - $begin = intval($matches[1]); - if (!empty($matches[2])) { - $end = intval($matches[2]); - } - } - } - - if (isset($_SERVER['HTTP_RANGE'])) { - header('HTTP/1.1 206 Partial Content'); - } else { - header('HTTP/1.1 200 OK'); - } - header("Content-Type: $mimeType"); - header('Cache-Control: public, must-revalidate, max-age=0'); - header('Pragma: no-cache'); - header('Accept-Ranges: bytes'); - header('Content-Length:' . (($end - $begin) + 1)); - if (isset($_SERVER['HTTP_RANGE'])) { - header("Content-Range: bytes $begin-$end/$size"); - } - header("Content-Transfer-Encoding: binary"); - - //We can have multiple levels of output buffering. Need to - //keep looping until all have been disabled!!! - //http://www.php.net/manual/en/function.ob-end-flush.php - while (@ob_end_flush()); - - // NOTE: We can't use fseek here because it does not work with streams - // (a.k.a. Files stored in the cloud) - while(!feof($fm) && (connection_status() == 0)) { - echo fread($fm, 1024 * 8); - } - fclose($fm); - } //Used by the SaaS monitoring public function onAirLightAction() diff --git a/airtime_mvc/application/controllers/AudiopreviewController.php b/airtime_mvc/application/controllers/AudiopreviewController.php index a4e94b85c..d98610b6e 100644 --- a/airtime_mvc/application/controllers/AudiopreviewController.php +++ b/airtime_mvc/application/controllers/AudiopreviewController.php @@ -22,8 +22,6 @@ class AudiopreviewController extends Zend_Controller_Action $CC_CONFIG = Config::getConfig(); $audioFileID = $this->_getParam('audioFileID'); - $audioFileArtist = $this->_getParam('audioFileArtist'); - $audioFileTitle = $this->_getParam('audioFileTitle'); $type = $this->_getParam('type'); $baseUrl = Application_Common_OsPath::getBaseDir(); @@ -60,10 +58,9 @@ class AudiopreviewController extends Zend_Controller_Action $this->view->uri = $uri; $this->view->mime = $mime; $this->view->audioFileID = $audioFileID; - // We need to decode artist and title because it gets - // encoded twice in js - $this->view->audioFileArtist = htmlspecialchars(urldecode($audioFileArtist)); - $this->view->audioFileTitle = htmlspecialchars(urldecode($audioFileTitle)); + + $this->view->audioFileArtist = htmlspecialchars($media->getPropelOrm()->getDbArtistName()); + $this->view->audioFileTitle = htmlspecialchars($media->getPropelOrm()->getDbTrackTitle()); $this->view->type = $type; $this->_helper->viewRenderer->setRender('audio-preview'); diff --git a/airtime_mvc/application/models/Schedule.php b/airtime_mvc/application/models/Schedule.php index a893d9dbe..13d53df55 100644 --- a/airtime_mvc/application/models/Schedule.php +++ b/airtime_mvc/application/models/Schedule.php @@ -954,7 +954,6 @@ SQL; $baseUrl = Application_Common_OsPath::getBaseDir(); $filesize = $file->getFileSize(); - self::createFileScheduleEvent($data, $item, $media_id, $uri, $filesize); } diff --git a/airtime_mvc/application/models/airtime/CcFiles.php b/airtime_mvc/application/models/airtime/CcFiles.php index 3bd62a7a5..1e3a72dce 100644 --- a/airtime_mvc/application/models/airtime/CcFiles.php +++ b/airtime_mvc/application/models/airtime/CcFiles.php @@ -11,17 +11,290 @@ * * @package propel.generator.campcaster */ + +class InvalidMetadataException extends Exception +{ +} + +class FileNotFoundException extends Exception +{ +} + +class OverDiskQuotaException extends Exception +{ + +} + class CcFiles extends BaseCcFiles { - + + const MUSIC_DIRS_STOR_PK = 1; + + + //fields that are not modifiable via our RESTful API + private static $blackList = array( + 'id', + 'directory', + 'filepath', + 'file_exists', + 'mtime', + 'utime', + 'lptime', + 'silan_check', + 'soundcloud_id', + 'is_scheduled', + 'is_playlist' + ); + //fields we should never expose through our RESTful API private static $privateFields = array( - 'file_exists', - 'silan_check', - 'is_scheduled', - 'is_playlist' + 'file_exists', + 'silan_check', + 'is_scheduled', + 'is_playlist' ); - - public function getCueLength() + + /** + * Retrieve a sanitized version of the file metadata, suitable for public access. + * @param $fileId + */ + public static function getSanitizedFileById($fileId) + { + $file = CcFilesQuery::create()->findPk($fileId); + if ($file) { + return CcFiles::sanitizeResponse($file); + } else { + throw new FileNotFoundException(); + } + } + + /** Used to create a CcFiles object from an array containing metadata and a file uploaded by POST. + * This is used by our Media REST API! + * @param $fileArray An array containing metadata for a CcFiles object. + * @throws Exception + */ + public static function createFromUpload($fileArray) + { + if (Application_Model_Systemstatus::isDiskOverQuota()) { + throw new OverDiskQuotaException(); + } + + $file = new CcFiles(); + + try{ + $fileArray = self::removeBlacklistedFields($fileArray); + + /*if (!self::validateFileArray($fileArray)) + { + $file->setDbTrackTitle($_FILES["file"]["name"]); + $file->setDbUtime(new DateTime("now", new DateTimeZone("UTC"))); + $file->save(); + return CcFiles::sanitizeResponse($file);*/ + self::validateFileArray($fileArray); + + /* If full_path is set, the post request came from ftp. + * Users are allowed to upload folders via ftp. If this is the case + * we need to include the folder name with the file name, otherwise + * files won't get removed from the organize folder. + */ + if (isset($fileArray["full_path"])) { + $fullPath = $fileArray["full_path"]; + $basePath = isset($_SERVER['AIRTIME_BASE']) ? $_SERVER['AIRTIME_BASE']."/srv/airtime/stor/organize/" : "/srv/airtime/stor/organize/"; + //$relativePath is the folder name(if one) + track name, that was uploaded via ftp + $relativePath = substr($fullPath, strlen($basePath)-1); + } else { + $relativePath = $_FILES["file"]["name"]; + } + + + $file->fromArray($fileArray); + $file->setDbOwnerId(self::getOwnerId()); + $now = new DateTime("now", new DateTimeZone("UTC")); + $file->setDbTrackTitle($_FILES["file"]["name"]); + $file->setDbUtime($now); + $file->setDbHidden(true); + $file->save(); + + $callbackUrl = Application_Common_HTTPHelper::getStationUrl() . "rest/media/" . $file->getPrimaryKey(); + + Application_Service_MediaService::processUploadedFile($callbackUrl, $relativePath, self::getOwnerId()); + return CcFiles::sanitizeResponse($file); + + } catch (Exception $e) { + $file->setDbImportStatus(2); + $file->setDbHidden(true); + throw $e; + } + } + + /** Update a file with metadata specified in an array. + * @param $fileId The ID of the file to update in the DB. + * @param $fileArray An associative array containing metadata. Replaces those fields if they exist. + * @return array A sanitized version of the file metadata array. + * @throws Exception + * @throws FileNotFoundException + * @throws PropelException + */ + public static function updateFromArray($fileId, $fileArray) + { + $file = CcFilesQuery::create()->findPk($fileId); + + $fileArray = self::removeBlacklistedFields($fileArray); + $fileArray = self::stripTimeStampFromYearTag($fileArray); + + self::validateFileArray($fileArray); + if ($file && isset($fileArray["resource_id"])) { + + $file->fromArray($fileArray, BasePeer::TYPE_FIELDNAME); + + //store the original filename + $file->setDbFilepath($fileArray["filename"]); + + $fileSizeBytes = $fileArray["filesize"]; + if (!isset($fileSizeBytes) || $fileSizeBytes === false) + { + $file->setDbImportStatus(self::IMPORT_STATUS_FAILED)->save(); + throw new FileNotFoundException(); + } + + $cloudFile = new CloudFile(); + $cloudFile->setStorageBackend($fileArray["storage_backend"]); + $cloudFile->setResourceId($fileArray["resource_id"]); + $cloudFile->setCcFiles($file); + $cloudFile->save(); + + Application_Model_Preference::updateDiskUsage($fileSizeBytes); + + $now = new DateTime("now", new DateTimeZone("UTC")); + $file->setDbMtime($now); + $file->save(); + + } else if ($file) { + + // Since we check for this value when deleting files, set it first + $file->setDbDirectory(self::MUSIC_DIRS_STOR_PK); + + $file->fromArray($fileArray, BasePeer::TYPE_FIELDNAME); + + //Our RESTful API takes "full_path" as a field, which we then split and translate to match + //our internal schema. Internally, file path is stored relative to a directory, with the directory + //as a foreign key to cc_music_dirs. + if (isset($fileArray["full_path"])) { + $fileSizeBytes = filesize($fileArray["full_path"]); + if (!isset($fileSizeBytes) || $fileSizeBytes === false) + { + $file->setDbImportStatus(self::IMPORT_STATUS_FAILED)->save(); + throw new FileNotFoundException(); + } + Application_Model_Preference::updateDiskUsage($fileSizeBytes); + + $fullPath = $fileArray["full_path"]; + $storDir = Application_Model_MusicDir::getStorDir()->getDirectory(); + $pos = strpos($fullPath, $storDir); + + if ($pos !== FALSE) + { + assert($pos == 0); //Path must start with the stor directory path + + $filePathRelativeToStor = substr($fullPath, strlen($storDir)); + $file->setDbFilepath($filePathRelativeToStor); + } + } + + $now = new DateTime("now", new DateTimeZone("UTC")); + $file->setDbMtime($now); + $file->save(); + + /* $this->removeEmptySubFolders( + isset($_SERVER['AIRTIME_BASE']) ? $_SERVER['AIRTIME_BASE']."/srv/airtime/stor/organize/" : "/srv/airtime/stor/organize/"); */ + } else { + $file->setDbImportStatus(self::IMPORT_STATUS_FAILED)->save(); + throw new FileNotFoundException(); + } + + return CcFiles::sanitizeResponse($file); + } + + /** Delete a file from the database and disk (or cloud). + * @param $id The file ID + * @throws DeleteScheduledFileException + * @throws Exception + * @throws FileNoPermissionException + * @throws FileNotFoundException + * @throws PropelException + */ + public static function deleteById($id) + { + $file = CcFilesQuery::create()->findPk($id); + if ($file) { + $con = Propel::getConnection(); + $storedFile = new Application_Model_StoredFile($file, $con); + if ($storedFile->existsOnDisk()) { + $storedFile->delete(); //TODO: This checks your session permissions... Make it work without a session? + } + $file->delete(); + } else { + throw new FileNotFoundException(); + } + + } + + public static function getDownloadUrl($id) + { + $file = CcFilesQuery::create()->findPk($id); + if ($file) { + $con = Propel::getConnection(); + $storedFile = new Application_Model_StoredFile($file, $con); + $baseDir = Application_Common_OsPath::getBaseDir(); + + return $storedFile->getRelativeFileUrl($baseDir) . '/download/true'; + } + else { + throw new FileNotFoundException(); + } + } + + + private static function validateFileArray(&$fileArray) + { + // Sanitize any wildly incorrect metadata before it goes to be validated + FileDataHelper::sanitizeData($fileArray); + + // EditAudioMD form is used here for validation + $fileForm = new Application_Form_EditAudioMD(); + $fileForm->startForm(0); //The file ID doesn't matter here + $fileForm->populate($fileArray); + + /* + * Here we are truncating metadata of any characters greater than the + * max string length set in the database. In the rare case a track's + * genre is more than 64 chars, for example, we don't want to reject + * tracks for that reason + */ + foreach($fileArray as $tag => &$value) { + if ($fileForm->getElement($tag)) { + $stringLengthValidator = $fileForm->getElement($tag)->getValidator('StringLength'); + //$stringLengthValidator will be false if the StringLength validator doesn't exist on the current element + //in which case we don't have to truncate the extra characters + if ($stringLengthValidator) { + $value = substr($value, 0, $stringLengthValidator->getMax()); + } + + $value = self::stripInvalidUtf8Characters($value); + } + } + + if (!$fileForm->isValidPartial($fileArray)) { + $errors = $fileForm->getErrors(); + $messages = $fileForm->getMessages(); + Logging::error($messages); + throw new Exception("Data validation failed: $errors - $messages"); + } + + return true; + } + + + public function getCueLength() { $cuein = $this->getDbCuein(); $cueout = $this->getDbCueout(); @@ -70,7 +343,7 @@ class CcFiles extends BaseCcFiles { return $response; } - + /** * Returns the file size in bytes. */ @@ -78,13 +351,13 @@ class CcFiles extends BaseCcFiles { { return filesize($this->getAbsoluteFilePath()); } - + public function getFilename() { $info = pathinfo($this->getAbsoluteFilePath()); return $info['filename']; } - + /** * Returns the file's absolute file path stored on disk. */ @@ -92,7 +365,7 @@ class CcFiles extends BaseCcFiles { { return $this->getAbsoluteFilePath(); } - + /** * Returns the file's absolute file path stored on disk. */ @@ -104,10 +377,89 @@ class CcFiles extends BaseCcFiles { } $directory = $music_dir->getDirectory(); $filepath = $this->getDbFilepath(); - return Application_Common_OsPath::join($directory, $filepath); } - + + /** + * + * Strips out fields from incoming request data that should never be modified + * from outside of Airtime + * @param array $data + */ + private static function removeBlacklistedFields($data) + { + foreach (self::$blackList as $key) { + unset($data[$key]); + } + + return $data; + } + + + private static function getOwnerId() + { + try { + if (Zend_Auth::getInstance()->hasIdentity()) { + $service_user = new Application_Service_UserService(); + return $service_user->getCurrentUser()->getDbId(); + } else { + $defaultOwner = CcSubjsQuery::create() + ->filterByDbType('A') + ->orderByDbId() + ->findOne(); + if (!$defaultOwner) { + // what to do if there is no admin user? + // should we handle this case? + return null; + } + return $defaultOwner->getDbId(); + } + } catch(Exception $e) { + Logging::info($e->getMessage()); + } + } + /* + * It's possible that the year tag will be a timestamp but Airtime doesn't support this. + * The year field in cc_files can only be 16 chars max. + * + * This functions strips the year field of it's timestamp, if one, and leaves just the year + */ + private static function stripTimeStampFromYearTag($metadata) + { + if (isset($metadata["year"])) { + if (preg_match("/^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}):(\d{2}))?$/", $metadata["year"])) { + $metadata["year"] = substr($metadata["year"], 0, 4); + } + } + return $metadata; + } + + private static function stripInvalidUtf8Characters($string) + { + //Remove invalid UTF-8 characters + //reject overly long 2 byte sequences, as well as characters above U+10000 and replace with ? + $string = preg_replace('/[\x00-\x08\x10\x0B\x0C\x0E-\x19\x7F]'. + '|[\x00-\x7F][\x80-\xBF]+'. + '|([\xC0\xC1]|[\xF0-\xFF])[\x80-\xBF]*'. + '|[\xC2-\xDF]((?![\x80-\xBF])|[\x80-\xBF]{2,})'. + '|[\xE0-\xEF](([\x80-\xBF](?![\x80-\xBF]))|(?![\x80-\xBF]{2})|[\x80-\xBF]{3,})/S', + '?', $string ); + + //reject overly long 3 byte sequences and UTF-16 surrogates and replace with ? + $string = preg_replace('/\xE0[\x80-\x9F][\x80-\xBF]'. + '|\xED[\xA0-\xBF][\x80-\xBF]/S','?', $string ); + + //Do a final encoding conversion to + $string = mb_convert_encoding($string, 'UTF-8', 'UTF-8'); + return $string; + } + + private function removeEmptySubFolders($path) + { + exec("find $path -empty -type d -delete"); + } + + /** * Checks if the file is a regular file that can be previewed and downloaded. */ diff --git a/airtime_mvc/application/modules/rest/controllers/MediaController.php b/airtime_mvc/application/modules/rest/controllers/MediaController.php index cbe53ac30..02e7fc503 100644 --- a/airtime_mvc/application/modules/rest/controllers/MediaController.php +++ b/airtime_mvc/application/modules/rest/controllers/MediaController.php @@ -4,26 +4,10 @@ require_once 'ProxyStorageBackend.php'; class Rest_MediaController extends Zend_Rest_Controller { - const MUSIC_DIRS_STOR_PK = 1; - const IMPORT_STATUS_SUCCESS = 0; const IMPORT_STATUS_PENDING = 1; const IMPORT_STATUS_FAILED = 2; - //fields that are not modifiable via our RESTful API - private static $blackList = array( - 'id', - 'directory', - 'filepath', - 'file_exists', - 'mtime', - 'utime', - 'lptime', - 'silan_check', - 'soundcloud_id', - 'is_scheduled', - 'is_playlist' - ); public function init() { @@ -59,18 +43,20 @@ class Rest_MediaController extends Zend_Rest_Controller return; } - $file = CcFilesQuery::create()->findPk($id); - if ($file) { - $con = Propel::getConnection(); - $storedFile = new Application_Model_StoredFile($file, $con); - $baseUrl = Application_Common_OsPath::getBaseDir(); - - $CC_CONFIG = Config::getConfig(); + try + { $this->getResponse() - ->setHttpResponseCode(200) - ->appendBody($this->_redirect($storedFile->getRelativeFileUrl($baseUrl).'/download/true/api_key/'.$CC_CONFIG["apiKey"][0])); - } else { + ->setHttpResponseCode(200); + $inline = false; + Application_Service_MediaService::streamFileDownload($id, $inline); + } + catch (FileNotFoundException $e) { $this->fileNotFoundResponse(); + Logging::error($e->getMessage()); + } + catch (Exception $e) { + $this->unknownErrorResponse(); + Logging::error($e->getMessage()); } } @@ -81,14 +67,18 @@ class Rest_MediaController extends Zend_Rest_Controller return; } - $file = CcFilesQuery::create()->findPk($id); - if ($file) { - + try { $this->getResponse() ->setHttpResponseCode(200) - ->appendBody(json_encode(CcFiles::sanitizeResponse($file))); - } else { + ->appendBody(json_encode(CcFiles::getSanitizedFileById($id))); + } + catch (FileNotFoundException $e) { $this->fileNotFoundResponse(); + Logging::error($e->getMessage()); + } + catch (Exception $e) { + $this->unknownErrorResponse(); + Logging::error($e->getMessage()); } } @@ -103,52 +93,24 @@ class Rest_MediaController extends Zend_Rest_Controller return; } - if (Application_Model_Systemstatus::isDiskOverQuota()) { + try { + $sanitizedFile = CcFiles::createFromUpload($this->getRequest()->getPost()); + $this->getResponse() + ->setHttpResponseCode(201) + ->appendBody(json_encode($sanitizedFile)); + } + catch (InvalidMetadataException $e) { + $this->invalidDataResponse(); + Logging::error($e->getMessage()); + } + catch (OverDiskQuotaException $e) { $this->getResponse() ->setHttpResponseCode(400) ->appendBody("ERROR: Disk Quota reached."); - return; } - - $file = new CcFiles(); - $whiteList = $this->removeBlacklistedFieldsFromRequestData($this->getRequest()->getPost()); - - if (!$this->validateRequestData($file, $whiteList)) { - $file->setDbTrackTitle($_FILES["file"]["name"]); - $file->setDbUtime(new DateTime("now", new DateTimeZone("UTC"))); - $file->save(); - return; - } else { - /* If full_path is set, the post request came from ftp. - * Users are allowed to upload folders via ftp. If this is the case - * we need to include the folder name with the file name, otherwise - * files won't get removed from the organize folder. - */ - if (isset($whiteList["full_path"])) { - $fullPath = $whiteList["full_path"]; - $basePath = isset($_SERVER['AIRTIME_BASE']) ? $_SERVER['AIRTIME_BASE']."/srv/airtime/stor/organize/" : "/srv/airtime/stor/organize/"; - //$relativePath is the folder name(if one) + track name, that was uploaded via ftp - $relativePath = substr($fullPath, strlen($basePath)-1); - } else { - $relativePath = $_FILES["file"]["name"]; - } - - - $file->fromArray($whiteList); - $file->setDbOwnerId($this->getOwnerId()); - $now = new DateTime("now", new DateTimeZone("UTC")); - $file->setDbTrackTitle($_FILES["file"]["name"]); - $file->setDbUtime($now); - $file->setDbHidden(true); - $file->save(); - - $callbackUrl = $this->getRequest()->getScheme() . '://' . $this->getRequest()->getHttpHost() . $this->getRequest()->getRequestUri() . "/" . $file->getPrimaryKey(); - - $this->processUploadedFile($callbackUrl, $relativePath, $this->getOwnerId()); - - $this->getResponse() - ->setHttpResponseCode(201) - ->appendBody(json_encode(CcFiles::sanitizeResponse($file))); + catch (Exception $e) { + $this->unknownErrorResponse(); + Logging::error($e->getMessage()); } } @@ -158,89 +120,25 @@ class Rest_MediaController extends Zend_Rest_Controller if (!$id) { return; } - - $file = CcFilesQuery::create()->findPk($id); - // Since we check for this value when deleting files, set it first - //$file->setDbDirectory(self::MUSIC_DIRS_STOR_PK); - - $requestData = json_decode($this->getRequest()->getRawBody(), true); - $whiteList = $this->removeBlacklistedFieldsFromRequestData($requestData); - $whiteList = $this->stripTimeStampFromYearTag($whiteList); - - if (!$this->validateRequestData($file, $whiteList)) { - $file->save(); - return; - } else if ($file && isset($requestData["resource_id"])) { - - $file->fromArray($whiteList, BasePeer::TYPE_FIELDNAME); - - //store the original filename - $file->setDbFilepath($requestData["filename"]); - - $fileSizeBytes = $requestData["filesize"]; - if (!isset($fileSizeBytes) || $fileSizeBytes === false) - { - $file->setDbImportStatus(2)->save(); - $this->fileNotFoundResponse(); - return; - } - $cloudFile = new CloudFile(); - $cloudFile->setStorageBackend($requestData["storage_backend"]); - $cloudFile->setResourceId($requestData["resource_id"]); - $cloudFile->setCcFiles($file); - $cloudFile->save(); - - Application_Model_Preference::updateDiskUsage($fileSizeBytes); - - $now = new DateTime("now", new DateTimeZone("UTC")); - $file->setDbMtime($now); - $file->save(); - + try { + $requestData = json_decode($this->getRequest()->getRawBody(), true); + $sanitizedFile = CcFiles::updateFromArray($id, $requestData); $this->getResponse() - ->setHttpResponseCode(200) - ->appendBody(json_encode(CcFiles::sanitizeResponse($file))); - } else if ($file) { - - //local file storage - $file->setDbDirectory(self::MUSIC_DIRS_STOR_PK); - - $file->fromArray($whiteList, BasePeer::TYPE_FIELDNAME); - //Our RESTful API takes "full_path" as a field, which we then split and translate to match - //our internal schema. Internally, file path is stored relative to a directory, with the directory - //as a foreign key to cc_music_dirs. - if (isset($requestData["full_path"])) { - $fileSizeBytes = filesize($requestData["full_path"]); - if (!isset($fileSizeBytes) || $fileSizeBytes === false) - { - $file->setDbImportStatus(self::IMPORT_STATUS_FAILED)->save(); - $this->fileNotFoundResponse(); - return; - } - Application_Model_Preference::updateDiskUsage($fileSizeBytes); - $fullPath = $requestData["full_path"]; - $storDir = Application_Model_MusicDir::getStorDir()->getDirectory(); - $pos = strpos($fullPath, $storDir); - - if ($pos !== FALSE) - { - assert($pos == 0); //Path must start with the stor directory path - - $filePathRelativeToStor = substr($fullPath, strlen($storDir)); - $file->setDbFilepath($filePathRelativeToStor); - } - } - - $now = new DateTime("now", new DateTimeZone("UTC")); - $file->setDbMtime($now); - $file->save(); - - $this->getResponse() - ->setHttpResponseCode(200) - ->appendBody(json_encode(CcFiles::sanitizeResponse($file))); - } else { - $file->setDbImportStatus(self::IMPORT_STATUS_FAILED)->save(); + ->setHttpResponseCode(201) + ->appendBody(json_encode($sanitizedFile)); + } + catch (InvalidMetadataException $e) { + $this->invalidDataResponse(); + Logging::error($e->getMessage()); + } + catch (FileNotFoundException $e) { $this->fileNotFoundResponse(); + Logging::error($e->getMessage()); + } + catch (Exception $e) { + $this->unknownErrorResponse(); + Logging::error($e->getMessage()); } } @@ -250,16 +148,18 @@ class Rest_MediaController extends Zend_Rest_Controller if (!$id) { return; } - $file = CcFilesQuery::create()->findPk($id); - if ($file) { - $con = Propel::getConnection(); - $storedFile = Application_Model_StoredFile::RecallById($id, $con); - $storedFile->delete(); //TODO: This checks your session permissions... Make it work without a session? - + try { + CcFiles::deleteById($id); $this->getResponse() ->setHttpResponseCode(204); - } else { + } + catch (FileNotFoundException $e) { $this->fileNotFoundResponse(); + Logging::error($e->getMessage()); + } + catch (Exception $e) { + $this->unknownErrorResponse(); + Logging::error($e->getMessage()); } } @@ -288,177 +188,12 @@ class Rest_MediaController extends Zend_Rest_Controller $resp->appendBody("ERROR: Import Failed."); } - private function invalidDataResponse() + private function unknownErrorResponse() { $resp = $this->getResponse(); - $resp->setHttpResponseCode(422); - $resp->appendBody("ERROR: Invalid data"); - } - private function validateRequestData($file, &$whiteList) - { - // Sanitize any wildly incorrect metadata before it goes to be validated - FileDataHelper::sanitizeData($whiteList); - - try { - // EditAudioMD form is used here for validation - $fileForm = new Application_Form_EditAudioMD(); - $fileForm->startForm($file->getDbId()); - $fileForm->populate($whiteList); - - /* - * Here we are truncating metadata of any characters greater than the - * max string length set in the database. In the rare case a track's - * genre is more than 64 chars, for example, we don't want to reject - * tracks for that reason - */ - foreach($whiteList as $tag => &$value) { - if ($fileForm->getElement($tag)) { - $stringLengthValidator = $fileForm->getElement($tag)->getValidator('StringLength'); - //$stringLengthValidator will be false if the StringLength validator doesn't exist on the current element - //in which case we don't have to truncate the extra characters - if ($stringLengthValidator) { - $value = substr($value, 0, $stringLengthValidator->getMax()); - } - - $value = $this->stripInvalidUtf8Characters($value); - } - } - - if (!$fileForm->isValidPartial($whiteList)) { - throw new Exception("Data validation failed"); - } - } catch (Exception $e) { - $errors = $fileForm->getErrors(); - $messages = $fileForm->getMessages(); - Logging::error($messages); - $file->setDbImportStatus(2); - $file->setDbHidden(true); - $this->invalidDataResponse(); - return false; - } - return true; - } - - private function processUploadedFile($callbackUrl, $originalFilename, $ownerId) - { - $CC_CONFIG = Config::getConfig(); - $apiKey = $CC_CONFIG["apiKey"][0]; - - $tempFilePath = $_FILES['file']['tmp_name']; - $tempFileName = basename($tempFilePath); - - //Only accept files with a file extension that we support. - $fileExtension = pathinfo($originalFilename, PATHINFO_EXTENSION); - if (!in_array(strtolower($fileExtension), explode(",", "ogg,mp3,oga,flac,wav,m4a,mp4,opus"))) - { - @unlink($tempFilePath); - throw new Exception("Bad file extension."); - } - - //TODO: Remove uploadFileAction from ApiController.php **IMPORTANT** - It's used by the recorder daemon... - - $importedStorageDirectory = ""; - if ($CC_CONFIG["current_backend"] == "file") { - $storDir = Application_Model_MusicDir::getStorDir(); - $importedStorageDirectory = $storDir->getDirectory() . "/imported/" . $ownerId; - } - - try { - //Copy the temporary file over to the "organize" folder so that it's off our webserver - //and accessible by airtime_analyzer which could be running on a different machine. - $newTempFilePath = Application_Model_StoredFile::copyFileToStor($tempFilePath, $originalFilename); - } catch (Exception $e) { - @unlink($tempFilePath); - Logging::error($e->getMessage()); - return; - } - - //Dispatch a message to airtime_analyzer through RabbitMQ, - //notifying it that there's a new upload to process! - $storageBackend = new ProxyStorageBackend($CC_CONFIG["current_backend"]); - Application_Model_RabbitMq::SendMessageToAnalyzer($newTempFilePath, - $importedStorageDirectory, basename($originalFilename), - $callbackUrl, $apiKey, $CC_CONFIG["current_backend"], - $storageBackend->getFilePrefix()); - } - - private function getOwnerId() - { - try { - if (Zend_Auth::getInstance()->hasIdentity()) { - $service_user = new Application_Service_UserService(); - return $service_user->getCurrentUser()->getDbId(); - } else { - $defaultOwner = CcSubjsQuery::create() - ->filterByDbType(array('A', 'S'), Criteria::IN) - ->orderByDbId() - ->findOne(); - if (!$defaultOwner) { - // what to do if there is no admin user? - // should we handle this case? - return null; - } - return $defaultOwner->getDbId(); - } - } catch(Exception $e) { - Logging::info($e->getMessage()); - } - } - - /** - * - * Strips out fields from incoming request data that should never be modified - * from outside of Airtime - * - * @param array $data - */ - private static function removeBlacklistedFieldsFromRequestData($data) { - foreach (self::$blackList as $key) { - unset($data[$key]); - } - - return $data; - } - - private function removeEmptySubFolders($path) { - exec("find $path -empty -type d -delete"); - } - - /* - * It's possible that the year tag will be a timestamp but Airtime doesn't support this. - * The year field in cc_files can only be 16 chars max. - * - * This functions strips the year field of it's timestamp, if one, and leaves just the year - */ - private function stripTimeStampFromYearTag($metadata) - { - if (isset($metadata["year"])) { - if (preg_match("/^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}):(\d{2}))?$/", $metadata["year"])) { - $metadata["year"] = substr($metadata["year"], 0, 4); - } - } - return $metadata; - } - - private function stripInvalidUtf8Characters($string) - { - //Remove invalid UTF-8 characters - //reject overly long 2 byte sequences, as well as characters above U+10000 and replace with ? - $string = preg_replace('/[\x00-\x08\x10\x0B\x0C\x0E-\x19\x7F]'. - '|[\x00-\x7F][\x80-\xBF]+'. - '|([\xC0\xC1]|[\xF0-\xFF])[\x80-\xBF]*'. - '|[\xC2-\xDF]((?![\x80-\xBF])|[\x80-\xBF]{2,})'. - '|[\xE0-\xEF](([\x80-\xBF](?![\x80-\xBF]))|(?![\x80-\xBF]{2})|[\x80-\xBF]{3,})/S', - '?', $string ); - - //reject overly long 3 byte sequences and UTF-16 surrogates and replace with ? - $string = preg_replace('/\xE0[\x80-\x9F][\x80-\xBF]'. - '|\xED[\xA0-\xBF][\x80-\xBF]/S','?', $string ); - - //Do a final encoding conversion to - $string = mb_convert_encoding($string, 'UTF-8', 'UTF-8'); - return $string; + $resp->setHttpResponseCode(400); + $resp->appendBody("An unknown error occurred."); } } diff --git a/airtime_mvc/application/services/MediaService.php b/airtime_mvc/application/services/MediaService.php new file mode 100644 index 000000000..81f199ae7 --- /dev/null +++ b/airtime_mvc/application/services/MediaService.php @@ -0,0 +1,159 @@ +getDirectory() . "/imported/" . $ownerId; + } + + try { + //Copy the temporary file over to the "organize" folder so that it's off our webserver + //and accessible by airtime_analyzer which could be running on a different machine. + $newTempFilePath = Application_Model_StoredFile::copyFileToStor($tempFilePath, $originalFilename); + } catch (Exception $e) { + @unlink($tempFilePath); + Logging::error($e->getMessage()); + return; + } + + //Dispatch a message to airtime_analyzer through RabbitMQ, + //notifying it that there's a new upload to process! + $storageBackend = new ProxyStorageBackend($CC_CONFIG["current_backend"]); + Application_Model_RabbitMq::SendMessageToAnalyzer($newTempFilePath, + $importedStorageDirectory, basename($originalFilename), + $callbackUrl, $apiKey, + $CC_CONFIG["current_backend"], + $storageBackend->getFilePrefix()); + } + + + /** + * @param $fileId + * @param bool $inline Set the Content-Disposition header to inline to prevent a download dialog from popping up (or attachment if false) + * @throws Exception + * @throws FileNotFoundException + */ + public static function streamFileDownload($fileId, $inline=false) + { + $media = Application_Model_StoredFile::RecallById($fileId); + if ($media == null) { + throw new FileNotFoundException(); + } + $filepath = $media->getFilePath(); + // Make sure we don't have some wrong result beecause of caching + clearstatcache(); + $media = Application_Model_StoredFile::RecallById($fileId); + if ($media == null) { + throw new FileNotFoundException(); + } + + // Make sure we don't have some wrong result beecause of caching + clearstatcache(); + + if ($media->getPropelOrm()->isValidPhysicalFile()) { + $filename = $media->getPropelOrm()->getFilename(); + + //Download user left clicks a track and selects Download. + if (!$inline) { + //We are using Content-Disposition to specify + //to the browser what name the file should be saved as. + header('Content-Disposition: attachment; filename="' . $filename . '"'); + } else { + //user clicks play button for track and downloads it. + header('Content-Disposition: inline; filename="' . $filename . '"'); + } + + self::smartReadFile($media); + exit; + } else { + throw new FileNotFoundException(); + } + } + + + /** + * Reads the requested portion of a file and sends its contents to the client with the appropriate headers. + * + * This HTTP_RANGE compatible read file function is necessary for allowing streaming media to be skipped around in. + * + * @param CcFile $media + * @return void + * + * @link https://groups.google.com/d/msg/jplayer/nSM2UmnSKKA/Hu76jDZS4xcJ + * @link http://php.net/manual/en/function.readfile.php#86244 + */ + private static function smartReadFile($media) + { + $filepath = $media->getFilePath(); + $size= $media->getFileSize(); + $mimeType = $media->getPropelOrm()->getDbMime(); + + $fm = @fopen($filepath, 'rb'); + if (!$fm) { + header ("HTTP/1.1 505 Internal server error"); + + return; + } + + $begin = 0; + $end = $size - 1; + + if (isset($_SERVER['HTTP_RANGE'])) { + if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $_SERVER['HTTP_RANGE'], $matches)) { + $begin = intval($matches[1]); + if (!empty($matches[2])) { + $end = intval($matches[2]); + } + } + } + + if (isset($_SERVER['HTTP_RANGE'])) { + header('HTTP/1.1 206 Partial Content'); + } else { + header('HTTP/1.1 200 OK'); + } + header("Content-Type: $mimeType"); + header('Cache-Control: public, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + header('Accept-Ranges: bytes'); + header('Content-Length:' . (($end - $begin) + 1)); + if (isset($_SERVER['HTTP_RANGE'])) { + header("Content-Range: bytes $begin-$end/$size"); + } + header("Content-Transfer-Encoding: binary"); + + //We can have multiple levels of output buffering. Need to + //keep looping until all have been disabled!!! + //http://www.php.net/manual/en/function.ob-end-flush.php + while (@ob_end_flush()); + + // NOTE: We can't use fseek here because it does not work with streams + // (a.k.a. Files stored in the cloud) + while(!feof($fm) && (connection_status() == 0)) { + echo fread($fm, 1024 * 8); + } + fclose($fm); + } +} + diff --git a/airtime_mvc/public/index.php b/airtime_mvc/public/index.php index f9c6e4fe4..67c659008 100644 --- a/airtime_mvc/public/index.php +++ b/airtime_mvc/public/index.php @@ -54,6 +54,9 @@ set_include_path(APPLICATION_PATH . '/controllers' . PATH_SEPARATOR . get_includ //Controller plugins. set_include_path(APPLICATION_PATH . '/controllers/plugins' . PATH_SEPARATOR . get_include_path()); +//Services. +set_include_path(APPLICATION_PATH . '/services/' . PATH_SEPARATOR . get_include_path()); + //Zend framework if (file_exists('/usr/share/php/libzend-framework-php')) { set_include_path('/usr/share/php/libzend-framework-php' . PATH_SEPARATOR . get_include_path()); diff --git a/airtime_mvc/public/js/airtime/common/common.js b/airtime_mvc/public/js/airtime/common/common.js index 7a043c286..476b5e77d 100644 --- a/airtime_mvc/public/js/airtime/common/common.js +++ b/airtime_mvc/public/js/airtime/common/common.js @@ -91,16 +91,11 @@ function openAudioPreview(p_event) { } } -function open_audio_preview(type, id, audioFileTitle, audioFileArtist) { - // we need to remove soundcloud icon from audioFileTitle - var index = audioFileTitle.indexOf("