diff --git a/airtime_mvc/application/Bootstrap.php b/airtime_mvc/application/Bootstrap.php index 63fd52dde..2c8cd839c 100644 --- a/airtime_mvc/application/Bootstrap.php +++ b/airtime_mvc/application/Bootstrap.php @@ -20,6 +20,7 @@ require_once "DateHelper.php"; require_once "LocaleHelper.php"; require_once "FileDataHelper.php"; require_once "HTTPHelper.php"; +require_once "FileIO.php"; require_once "OsPath.php"; require_once "Database.php"; require_once "ProvisioningHelper.php"; @@ -82,13 +83,28 @@ class Bootstrap extends Zend_Application_Bootstrap_Bootstrap } $view->headScript()->appendScript("var userType = '$userType';"); } + + /** + * Create a global namespace to hold a session token for CSRF prevention + */ + protected function _initCsrfNamespace() { + $csrf_namespace = new Zend_Session_Namespace('csrf_namespace'); + // Check if the token exists + if (!$csrf_namespace->authtoken) { + // If we don't have a token, regenerate it and set a 2 hour timeout + // Should we log the user out here if the token is expired? + $csrf_namespace->authtoken = sha1(uniqid(rand(),1)); + $csrf_namespace->setExpirationSeconds(2*60*60); + } + } /** * Ideally, globals should be written to a single js file once * from a php init function. This will save us from having to * reinitialize them every request */ - private function _initTranslationGlobals($view) { + private function _initTranslationGlobals() { + $view = $this->getResource('view'); $view->headScript()->appendScript("var PRODUCT_NAME = '" . PRODUCT_NAME . "';"); $view->headScript()->appendScript("var USER_MANUAL_URL = '" . USER_MANUAL_URL . "';"); $view->headScript()->appendScript("var COMPANY_NAME = '" . COMPANY_NAME . "';"); @@ -174,9 +190,10 @@ class Bootstrap extends Zend_Application_Bootstrap_Bootstrap && strpos($_SERVER['REQUEST_URI'], $baseUrl.'audiopreview/audio-preview') === false && strpos($_SERVER['REQUEST_URI'], $baseUrl.'audiopreview/playlist-preview') === false && strpos($_SERVER['REQUEST_URI'], $baseUrl.'audiopreview/block-preview') === false) { - if (Application_Model_Preference::GetLiveChatEnabled()) { + $plan_level = strval(Application_Model_Preference::GetPlanLevel()); + // Since the Hobbyist plan doesn't come with Live Chat support, don't enable it + if (Application_Model_Preference::GetLiveChatEnabled() && $plan_level !== 'hobbyist') { $client_id = strval(Application_Model_Preference::GetClientId()); - $plan_level = strval(Application_Model_Preference::GetPlanLevel()); $station_url = $_SERVER['SERVER_NAME'] . $_SERVER['REQUEST_URI']; $view->headScript()->appendScript("var livechat_client_id = '$client_id';\n". "var livechat_plan_type = '$plan_level';\n". diff --git a/airtime_mvc/application/cloud_storage/Amazon_S3StorageBackend.php b/airtime_mvc/application/cloud_storage/Amazon_S3StorageBackend.php index 406e81016..e2b6a1ba6 100644 --- a/airtime_mvc/application/cloud_storage/Amazon_S3StorageBackend.php +++ b/airtime_mvc/application/cloud_storage/Amazon_S3StorageBackend.php @@ -60,8 +60,7 @@ class Amazon_S3StorageBackend extends StorageBackend public function getFilePrefix() { - $clientCurrentAirtimeProduct = Billing::getClientCurrentAirtimeProduct(); - $hostingId = $clientCurrentAirtimeProduct["id"]; + $hostingId = Billing::getClientInstanceId(); return substr($hostingId, -2)."/".$hostingId; } } diff --git a/airtime_mvc/application/cloud_storage/ProxyStorageBackend.php b/airtime_mvc/application/cloud_storage/ProxyStorageBackend.php index e8d8cf788..4df1e19f7 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,17 +32,17 @@ 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 deletePhysicalFile($resourceId) { $this->storageBackend->deletePhysicalFile($resourceId); diff --git a/airtime_mvc/application/common/Billing.php b/airtime_mvc/application/common/Billing.php index a4bb72150..fa716f6d1 100644 --- a/airtime_mvc/application/common/Billing.php +++ b/airtime_mvc/application/common/Billing.php @@ -14,8 +14,16 @@ class Billing /** Get the Airtime instance ID of the instance the customer is currently viewing. */ public static function getClientInstanceId() { - $currentProduct = Billing::getClientCurrentAirtimeProduct(); - return $currentProduct["id"]; + //$currentProduct = Billing::getClientCurrentAirtimeProduct(); + //return $currentProduct["id"]; + //XXX: Major hack attack. Since this function gets called often, rather than querying WHMCS + // we're just going to extract it from airtime.conf since it's the same as the rabbitmq username. + $CC_CONFIG = Config::getConfig(); + $instanceId = $CC_CONFIG['rabbitmq']['user']; + if (!is_numeric($instanceId)) { + throw new Exception("Invalid instance id in " . __FUNCTION__ . ": " . $instanceId); + } + return $instanceId; } public static function getProducts() @@ -318,4 +326,4 @@ class Billing $result = Billing::makeRequest($credentials["url"], $query_string); } -} \ No newline at end of file +} diff --git a/airtime_mvc/application/common/FileDataHelper.php b/airtime_mvc/application/common/FileDataHelper.php index 6ebe610e8..5a600e419 100644 --- a/airtime_mvc/application/common/FileDataHelper.php +++ b/airtime_mvc/application/common/FileDataHelper.php @@ -24,5 +24,4 @@ class FileDataHelper { $data["bpm"] = intval($data["bpm"]); } } - } \ No newline at end of file diff --git a/airtime_mvc/application/common/FileIO.php b/airtime_mvc/application/common/FileIO.php new file mode 100644 index 000000000..2c7724757 --- /dev/null +++ b/airtime_mvc/application/common/FileIO.php @@ -0,0 +1,77 @@ + 0) { + 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); + } +} \ No newline at end of file diff --git a/airtime_mvc/application/common/HTTPHelper.php b/airtime_mvc/application/common/HTTPHelper.php index db314bb0b..177892e1f 100644 --- a/airtime_mvc/application/common/HTTPHelper.php +++ b/airtime_mvc/application/common/HTTPHelper.php @@ -17,4 +17,28 @@ class Application_Common_HTTPHelper $request->getParam("timezone", null) ); } + + public static function getStationUrl() + { + $CC_CONFIG = Config::getConfig(); + $baseUrl = $CC_CONFIG['baseUrl']; + $baseDir = $CC_CONFIG['baseDir']; + $basePort = $CC_CONFIG['basePort']; + if (empty($baseDir)) { + $baseDir = "/"; + } + if ($baseDir[0] != "") { + $baseDir = "/" . $baseDir; + } + + $scheme = "http"; + if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') { + $scheme = "https"; + $basePort = "443"; //Airtime Pro compatibility hack + } + + $stationUrl = "$scheme://${baseUrl}:${basePort}${baseDir}"; + + return $stationUrl; + } } diff --git a/airtime_mvc/application/common/ProvisioningHelper.php b/airtime_mvc/application/common/ProvisioningHelper.php index 9804e7d6e..48bbddc8c 100644 --- a/airtime_mvc/application/common/ProvisioningHelper.php +++ b/airtime_mvc/application/common/ProvisioningHelper.php @@ -36,19 +36,33 @@ class ProvisioningHelper $this->parsePostParams(); //For security, the Airtime Pro provisioning system creates the database for the user. - // $this->setNewDatabaseConnection(); + $this->setNewDatabaseConnection(); + //if ($this->checkDatabaseExists()) { // throw new Exception("ERROR: Airtime database already exists"); //} + + if (!$this->checkDatabaseExists()) { + throw new Exception("ERROR: $this->dbname database does not exist."); + } + + //We really want to do this check because all the Propel-generated SQL starts with "DROP TABLE IF EXISTS". + //If we don't check, then a second call to this API endpoint would wipe all the tables! + if ($this->checkTablesExist()) { + throw new Exception("ERROR: airtime tables already exists"); + } + //$this->createDatabase(); //All we need to do is create the database tables. + $this->createDatabaseTables(); $this->initializeMusicDirsTable($this->instanceId); } catch (Exception $e) { http_response_code(400); - Logging::error($e->getMessage()); - echo $e->getMessage(); + Logging::error($e->getMessage() + ); + echo $e->getMessage() . PHP_EOL; return; } @@ -67,6 +81,19 @@ class ProvisioningHelper return isset($result[0]); } + private function checkTablesExist() + { + try { + $result = self::$dbh->query("SELECT 1 FROM cc_files LIMIT 1"); + } catch (Exception $e) { + // We got an exception == table not found + echo($e . PHP_EOL); + return FALSE; + } + + // Result is either boolean FALSE (no table found) or PDOStatement Object (table found) + return $result !== FALSE; + } private function parsePostParams() { $this->dbuser = $_POST['dbuser']; @@ -84,9 +111,11 @@ class ProvisioningHelper private function setNewDatabaseConnection() { self::$dbh = new PDO("pgsql:host=" . $this->dbhost - . ";dbname=postgres" + . ";dbname=" . $this->dbname . ";port=5432" . ";user=" . $this->dbuser . ";password=" . $this->dbpass); + //Turn on PDO exceptions because they're off by default. + //self::$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $err = self::$dbh->errorInfo(); if ($err[1] != null) { throw new PDOException("ERROR: Could not connect to database"); diff --git a/airtime_mvc/application/configs/conf.php b/airtime_mvc/application/configs/conf.php index 6cf5f9d6c..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)) { diff --git a/airtime_mvc/application/controllers/ApiController.php b/airtime_mvc/application/controllers/ApiController.php index 5ebb6c15b..0bc07cdd1 100644 --- a/airtime_mvc/application/controllers/ApiController.php +++ b/airtime_mvc/application/controllers/ApiController.php @@ -95,126 +95,11 @@ 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()); } - - /** - * Read data from StoredFile object and send with XHR response - * - * @param Application_Model_StoredFile $storedFile - StoredFile object holding file information - */ - private function readStoredFileObject($storedFile) { - $filepath = $storedFile->getFilePath(); - $size = $storedFile->getFileSize(); - $mimeType = $storedFile->getPropelOrm()->getDbMime(); - - $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 (!isset($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'); - if ($size > 0) { - 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() @@ -610,9 +495,8 @@ class ApiController extends Zend_Controller_Action $path = $show->getDbImagePath(); $mime_type = mime_content_type($path); - - header("Content-type: " . $mime_type); - $this->smartReadFile($path, $mime_type); + + Application_Common_FileIO::smartReadFile($path, filesize($path), $mime_type); } else { header('HTTP/1.0 401 Unauthorized'); print _('You are not allowed to access this resource. '); diff --git a/airtime_mvc/application/controllers/AudiopreviewController.php b/airtime_mvc/application/controllers/AudiopreviewController.php index a4e94b85c..c5ad22a58 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(); @@ -49,10 +47,15 @@ class AudiopreviewController extends Zend_Controller_Action $media = Application_Model_StoredFile::RecallById($audioFileID); $uri = $baseUrl."api/get-media/file/".$audioFileID; $mime = $media->getPropelOrm()->getDbMime(); + $this->view->audioFileArtist = htmlspecialchars($media->getPropelOrm()->getDbArtistName()); + $this->view->audioFileTitle = htmlspecialchars($media->getPropelOrm()->getDbTrackTitle()); + } elseif ($type == "stream") { $webstream = CcWebstreamQuery::create()->findPk($audioFileID); $uri = $webstream->getDbUrl(); $mime = $webstream->getDbMime(); + $this->view->audioFileTitle = htmlspecialchars($webstream->getDbName()); + } else { throw new Exception("Unknown type for audio preview!.Type=$type"); } @@ -60,10 +63,7 @@ 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->type = $type; $this->_helper->viewRenderer->setRender('audio-preview'); diff --git a/airtime_mvc/application/controllers/LibraryController.php b/airtime_mvc/application/controllers/LibraryController.php index 3e7d33cd7..8c635baa6 100644 --- a/airtime_mvc/application/controllers/LibraryController.php +++ b/airtime_mvc/application/controllers/LibraryController.php @@ -217,7 +217,7 @@ class LibraryController extends Zend_Controller_Action // and not the cloud_file id (if applicable) for track download. // Our application logic (StoredFile.php) will determine if the track // is a cloud_file and handle it appropriately. - $url = $baseUrl."api/get-media/file/".$id.".".$file->getFileExtension().'/download/true'; + $url = $baseUrl."api/get-media/file/$id/download/true"; $menu["download"] = array("name" => _("Download"), "icon" => "download", "url" => $url); } elseif ($type === "playlist" || $type === "block") { if ($type === 'playlist') { diff --git a/airtime_mvc/application/controllers/LoginController.php b/airtime_mvc/application/controllers/LoginController.php index 1de9e106e..9d2ef2f9d 100644 --- a/airtime_mvc/application/controllers/LoginController.php +++ b/airtime_mvc/application/controllers/LoginController.php @@ -119,6 +119,9 @@ class LoginController extends Zend_Controller_Action { $auth = Zend_Auth::getInstance(); $auth->clearIdentity(); + // Unset all session variables relating to CSRF prevention on logout + $csrf_namespace = new Zend_Session_Namespace('csrf_namespace'); + $csrf_namespace->unsetAll(); $this->_redirect('showbuilder/index'); } diff --git a/airtime_mvc/application/controllers/PluploadController.php b/airtime_mvc/application/controllers/PluploadController.php index f1197ec2b..7c808140c 100644 --- a/airtime_mvc/application/controllers/PluploadController.php +++ b/airtime_mvc/application/controllers/PluploadController.php @@ -31,9 +31,10 @@ class PluploadController extends Zend_Controller_Action $this->view->quotaLimitReached = true; } + //Because uploads are done via AJAX (and we're not using Zend form for those), we manually add the CSRF + //token in here. $csrf_namespace = new Zend_Session_Namespace('csrf_namespace'); - $csrf_namespace->setExpirationSeconds(5*60*60); - $csrf_namespace->authtoken = sha1(uniqid(rand(),1)); + //The CSRF token is generated in Bootstrap.php $csrf_element = new Zend_Form_Element_Hidden('csrf'); $csrf_element->setValue($csrf_namespace->authtoken)->setRequired('true')->removeDecorator('HtmlTag')->removeDecorator('Label'); diff --git a/airtime_mvc/application/controllers/PreferenceController.php b/airtime_mvc/application/controllers/PreferenceController.php index 1b97672f8..219e35e01 100644 --- a/airtime_mvc/application/controllers/PreferenceController.php +++ b/airtime_mvc/application/controllers/PreferenceController.php @@ -38,6 +38,7 @@ class PreferenceController extends Zend_Controller_Action if ($form->isValid($values)) { Application_Model_Preference::SetHeadTitle($values["stationName"], $this->view); + Application_Model_Preference::SetStationDescription($values["stationDescription"]); Application_Model_Preference::SetDefaultCrossfadeDuration($values["stationDefaultCrossfadeDuration"]); Application_Model_Preference::SetDefaultFadeIn($values["stationDefaultFadeIn"]); Application_Model_Preference::SetDefaultFadeOut($values["stationDefaultFadeOut"]); @@ -49,7 +50,11 @@ class PreferenceController extends Zend_Controller_Action $logoUploadElement = $form->getSubForm('preferences_general')->getElement('stationLogo'); $logoUploadElement->receive(); $imagePath = $logoUploadElement->getFileName(); - Application_Model_Preference::SetStationLogo($imagePath); + + // Only update the image logo if the new logo is non-empty + if (!empty($imagePath) && $imagePath != "") { + Application_Model_Preference::SetStationLogo($imagePath); + } Application_Model_Preference::SetUploadToSoundcloudOption($values["UploadToSoundcloudOption"]); Application_Model_Preference::SetSoundCloudDownloadbleOption($values["SoundCloudDownloadbleOption"]); diff --git a/airtime_mvc/application/controllers/UpgradeController.php b/airtime_mvc/application/controllers/UpgradeController.php index c9748467b..9af3dc1e9 100644 --- a/airtime_mvc/application/controllers/UpgradeController.php +++ b/airtime_mvc/application/controllers/UpgradeController.php @@ -19,6 +19,7 @@ class UpgradeController extends Zend_Controller_Action array_push($upgraders, new AirtimeUpgrader255()); array_push($upgraders, new AirtimeUpgrader259()); array_push($upgraders, new AirtimeUpgrader2510()); + array_push($upgraders, new AirtimeUpgrader2511()); $didWePerformAnUpgrade = false; try diff --git a/airtime_mvc/application/controllers/plugins/Acl_plugin.php b/airtime_mvc/application/controllers/plugins/Acl_plugin.php index 106dfee11..10910fb73 100644 --- a/airtime_mvc/application/controllers/plugins/Acl_plugin.php +++ b/airtime_mvc/application/controllers/plugins/Acl_plugin.php @@ -152,17 +152,22 @@ class Zend_Controller_Plugin_Acl extends Zend_Controller_Plugin_Abstract } } } else { //We have a session/identity. - // If we have an identity and we're making a RESTful request, // we need to check the CSRF token - if ($request->_action != "get" && $request->getModuleName() == "rest") { - $tokenValid = $this->verifyCSRFToken($request->getParam("csrf_token")); + if ($_SERVER['REQUEST_METHOD'] != "GET" && $request->getModuleName() == "rest") { + $token = $request->getParam("csrf_token"); + $tokenValid = $this->verifyCSRFToken($token); if (!$tokenValid) { + $csrf_namespace = new Zend_Session_Namespace('csrf_namespace'); + $csrf_namespace->authtoken = sha1(openssl_random_pseudo_bytes(128)); + + Logging::warn("Invalid CSRF token: $token"); $this->getResponse() ->setHttpResponseCode(401) - ->appendBody("ERROR: CSRF token mismatch."); - return; + ->appendBody("ERROR: CSRF token mismatch.") + ->sendResponse(); + die(); } } @@ -207,7 +212,7 @@ class Zend_Controller_Plugin_Acl extends Zend_Controller_Plugin_Abstract $current_namespace = new Zend_Session_Namespace('csrf_namespace'); $observed_csrf_token = $token; $expected_csrf_token = $current_namespace->authtoken; - + return ($observed_csrf_token == $expected_csrf_token); } diff --git a/airtime_mvc/application/forms/AddShowStyle.php b/airtime_mvc/application/forms/AddShowStyle.php index d6e97e019..7d79a90e9 100644 --- a/airtime_mvc/application/forms/AddShowStyle.php +++ b/airtime_mvc/application/forms/AddShowStyle.php @@ -79,7 +79,7 @@ class Application_Form_AddShowStyle extends Zend_Form_SubForm ->addValidator('Count', false, 1) ->addValidator('Extension', false, 'jpg,jpeg,png,gif') ->addFilter('ImageSize'); - + $this->addElement($upload); // Add image preview @@ -93,6 +93,14 @@ class Application_Form_AddShowStyle extends Zend_Form_SubForm 'class' => 'big' )))); $preview->setAttrib('disabled','disabled'); + + $csrf_namespace = new Zend_Session_Namespace('csrf_namespace'); + $csrf_element = new Zend_Form_Element_Hidden('csrf'); + $csrf_element->setValue($csrf_namespace->authtoken) + ->setRequired('true') + ->removeDecorator('HtmlTag') + ->removeDecorator('Label'); + $this->addElement($csrf_element); } public function disable() diff --git a/airtime_mvc/application/forms/GeneralPreferences.php b/airtime_mvc/application/forms/GeneralPreferences.php index 420c2af71..b8a066e3e 100644 --- a/airtime_mvc/application/forms/GeneralPreferences.php +++ b/airtime_mvc/application/forms/GeneralPreferences.php @@ -49,6 +49,13 @@ class Application_Form_GeneralPreferences extends Zend_Form_SubForm $stationLogoUpload->setAttrib('accept', 'image/*'); $this->addElement($stationLogoUpload); + $stationLogoRemove = new Zend_Form_Element_Button('stationLogoRemove'); + $stationLogoRemove->setLabel(_('Remove')); + $stationLogoRemove->setAttrib('class', 'btn'); + $stationLogoRemove->setAttrib('id', 'logo-remove-btn'); + $stationLogoRemove->setAttrib('onclick', 'removeLogo();'); + $this->addElement($stationLogoRemove); + //Default station crossfade duration $this->addElement('text', 'stationDefaultCrossfadeDuration', array( 'class' => 'input_text', diff --git a/airtime_mvc/application/models/RabbitMq.php b/airtime_mvc/application/models/RabbitMq.php index 435036a6e..682ff99b0 100644 --- a/airtime_mvc/application/models/RabbitMq.php +++ b/airtime_mvc/application/models/RabbitMq.php @@ -80,7 +80,7 @@ class Application_Model_RabbitMq } public static function SendMessageToAnalyzer($tmpFilePath, $importedStorageDirectory, $originalFilename, - $callbackUrl, $apiKey, $currentStorageBackend, $filePrefix) + $callbackUrl, $apiKey, $storageBackend, $filePrefix) { //Hack for Airtime Pro. The RabbitMQ settings for communicating with airtime_analyzer are global //and shared between all instances on Airtime Pro. @@ -107,7 +107,7 @@ class Application_Model_RabbitMq $queue = 'airtime-uploads'; $autoDeleteExchange = false; $data['tmp_file_path'] = $tmpFilePath; - $data['current_storage_backend'] = $currentStorageBackend; + $data['storage_backend'] = $storageBackend; $data['import_directory'] = $importedStorageDirectory; $data['original_filename'] = $originalFilename; $data['callback_url'] = $callbackUrl; 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/StoredFile.php b/airtime_mvc/application/models/StoredFile.php index 26cbea647..5e6e53161 100644 --- a/airtime_mvc/application/models/StoredFile.php +++ b/airtime_mvc/application/models/StoredFile.php @@ -392,13 +392,15 @@ SQL; Logging::info("User ".$user->getLogin()." is deleting file: ".$this->_file->getDbTrackTitle()." - file id: ".$file_id); $filesize = $this->_file->getFileSize(); - if ($filesize <= 0) { + if ($filesize < 0) { throw new Exception("Cannot delete file with filesize ".$filesize); } //Delete the physical file from either the local stor directory //or from the cloud - $this->_file->deletePhysicalFile(); + if ($this->_file->getDbImportStatus() == CcFiles::IMPORT_STATUS_SUCCESS) { + $this->_file->deletePhysicalFile(); + } //Update the user's disk usage Application_Model_Preference::updateDiskUsage(-1 * $filesize); @@ -944,19 +946,17 @@ SQL; * The file is actually copied to "stor/organize", which is a staging directory where files go * before they're processed by airtime_analyzer, which then moves them to "stor/imported" in the final * step. - * - * TODO: Implement better error handling here... * * @param string $tempFilePath * @param string $originalFilename + * @param bool $copyFile Copy the file instead of moving it. * @throws Exception * @return Ambigous */ - public static function copyFileToStor($tempFilePath, $originalFilename) + public static function moveFileToStor($tempFilePath, $originalFilename, $copyFile=false) { $audio_file = $tempFilePath; - Logging::info('copyFileToStor: moving file '.$audio_file); - + $storDir = Application_Model_MusicDir::getStorDir(); $stor = $storDir->getDirectory(); // check if "organize" dir exists and if not create one @@ -970,57 +970,34 @@ SQL; Logging::info("Warning: couldn't change permissions of $audio_file to 0644"); } - // Check if liquidsoap can play this file - // TODO: Move this to airtime_analyzer - /* - if (!self::liquidsoapFilePlayabilityTest($audio_file)) { - return array( - "code" => 110, - "message" => _("This file appears to be corrupted and will not " - ."be added to media library.")); - }*/ - - // Did all the checks for real, now trying to copy $audio_stor = Application_Common_OsPath::join($stor, "organize", $originalFilename); - Logging::info($originalFilename); - Logging::info($audio_stor); - $user = Application_Model_User::getCurrentUser(); - if (is_null($user)) { - $uid = Application_Model_User::getFirstAdminId(); - } else { - $uid = $user->getId(); - } - /* - $id_file = "$audio_stor.identifier"; - if (file_put_contents($id_file, $uid) === false) { - Logging::info("Could not write file to identify user: '$uid'"); - Logging::info("Id file path: '$id_file'"); - Logging::info("Defaulting to admin (no identification file was - written)"); - } else { - Logging::info("Successfully written identification file for - uploaded '$audio_stor'"); - }*/ - //if the uploaded file is not UTF-8 encoded, let's encode it. Assuming source //encoding is ISO-8859-1 $audio_stor = mb_detect_encoding($audio_stor, "UTF-8") == "UTF-8" ? $audio_stor : utf8_encode($audio_stor); - Logging::info("copyFileToStor: moving file $audio_file to $audio_stor"); - // Martin K.: changed to rename: Much less load + quicker since this is - // an atomic operation - if (@rename($audio_file, $audio_stor) === false) { - //something went wrong likely there wasn't enough space in . - //the audio_stor to move the file too warn the user that . - //the file wasn't uploaded and they should check if there . - //is enough disk space . - unlink($audio_file); //remove the file after failed rename - //unlink($id_file); // Also remove the identifier file - - throw new Exception("The file was not uploaded, this error can occur if the computer " - ."hard drive does not have enough disk space or the stor " - ."directory does not have correct write permissions."); + if ($copyFile) { + Logging::info("Copying file $audio_file to $audio_stor"); + if (@copy($audio_file, $audio_stor) === false) { + throw new Exception("Failed to copy $audio_file to $audio_stor"); + } + } else { + Logging::info("Moving file $audio_file to $audio_stor"); + + // Martin K.: changed to rename: Much less load + quicker since this is + // an atomic operation + if (@rename($audio_file, $audio_stor) === false) { + //something went wrong likely there wasn't enough space in . + //the audio_stor to move the file too warn the user that . + //the file wasn't uploaded and they should check if there . + //is enough disk space . + unlink($audio_file); //remove the file after failed rename + //unlink($id_file); // Also remove the identifier file + + throw new Exception("The file was not uploaded, this error can occur if the computer " + . "hard drive does not have enough disk space or the stor " + . "directory does not have correct write permissions."); + } } return $audio_stor; } diff --git a/airtime_mvc/application/models/airtime/CcFiles.php b/airtime_mvc/application/models/airtime/CcFiles.php index 2ca2fac46..029d21a15 100644 --- a/airtime_mvc/application/models/airtime/CcFiles.php +++ b/airtime_mvc/application/models/airtime/CcFiles.php @@ -11,17 +11,313 @@ * * @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; + + 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' + ); + //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(); + } + + /* 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. + */ + + //Extract the original filename, which we set as the temporary title for the track + //until it's finished being processed by the analyzer. + $originalFilename = $_FILES["file"]["name"]; + $tempFilePath = $_FILES['file']['tmp_name']; + + try { + self::createAndImport($fileArray, $tempFilePath, $originalFilename); + } catch (Exception $e) + { + @unlink($tempFilePath); + throw $e; + } + } + + /** Import a music file to the library from a local file on disk (something pre-existing). + * This function allows you to copy a file rather than move it, which is useful for importing + * static music files (like sample tracks). + * @param string $filePath The full path to the audio file to import. + * @param bool $copyFile True if you want to just copy the false, false if you want to move it (default false) + * @throws Exception + */ + public static function createFromLocalFile($fileArray, $filePath, $copyFile=false) + { + $info = pathinfo($filePath); + $fileName = basename($filePath).'.'.$info['extension']; + self::createAndImport($fileArray, $filePath, $fileName, $copyFile); + } + + /** Create a new CcFiles object/row and import a file for it. + * You shouldn't call this directly. Either use createFromUpload() or createFromLocalFile(). + * @param array $fileArray Any metadata to pre-fill for the audio file + * @param string $filePath The full path to the audio file to import + * @param string $originalFilename + * @param bool $copyFile + * @return mixed + * @throws Exception + * @throws PropelException + */ + private static function createAndImport($fileArray, $filePath, $originalFilename, $copyFile=false) + { + $file = new CcFiles(); + + try + { + $fileArray = self::removeBlacklistedFields($fileArray); + + self::validateFileArray($fileArray); + + $file->fromArray($fileArray); + $file->setDbOwnerId(self::getOwnerId()); + $now = new DateTime("now", new DateTimeZone("UTC")); + $file->setDbTrackTitle($originalFilename); + $file->setDbUtime($now); + $file->setDbHidden(true); + $file->save(); + + //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"))) { + throw new Exception("Bad file extension."); + } + + $callbackUrl = Application_Common_HTTPHelper::getStationUrl() . "/rest/media/" . $file->getPrimaryKey(); + + Application_Service_MediaService::importFileToLibrary($callbackUrl, $filePath, + $originalFilename, self::getOwnerId(), $copyFile); + + return CcFiles::sanitizeResponse($file); + + } catch (Exception $e) { + $file->setDbImportStatus(self::IMPORT_STATUS_FAILED); + $file->setDbHidden(true); + $file->save(); + throw $e; + } + } + + /** Update a file with metadata specified in an array. + * @param $fileId string The ID of the file to update in the DB. + * @param $fileArray array 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); + + try { + + 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) { + throw new FileNotFoundException("Invalid filesize for $fileId"); + } + + $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) { + throw new FileNotFoundException("Invalid filesize for $fileId"); + } + 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(); + + } else { + throw new FileNotFoundException(); + } + } + catch (FileNotFoundException $e) + { + $file->setDbImportStatus(self::IMPORT_STATUS_FAILED); + $file->setDbHidden(true); + $file->save(); + throw $e; + } + + 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 = Application_Model_StoredFile::RecallById($id, $con); + $storedFile->delete(); + } 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(); @@ -57,7 +353,7 @@ class CcFiles extends BaseCcFiles { /** * * Strips out the private fields we do not want to send back in API responses - * @param $file a CcFiles object + * @param $file string a CcFiles object */ //TODO: rename this function? public static function sanitizeResponse($file) @@ -70,7 +366,7 @@ class CcFiles extends BaseCcFiles { return $response; } - + /** * Returns the file size in bytes. */ @@ -78,13 +374,14 @@ class CcFiles extends BaseCcFiles { { return $this->getDbFilesize(); } - + public function getFilename() { $info = pathinfo($this->getAbsoluteFilePath()); - return $info['filename']; + //filename doesn't contain the extension because PHP is awful + return $info['filename'].".".$info['extension']; } - + /** * Returns the file's absolute file path stored on disk. */ @@ -92,7 +389,7 @@ class CcFiles extends BaseCcFiles { { return $this->getAbsoluteFilePath(); } - + /** * Returns the file's absolute file path stored on disk. */ @@ -100,14 +397,93 @@ class CcFiles extends BaseCcFiles { { $music_dir = Application_Model_MusicDir::getDirByPK($this->getDbDirectory()); if (!$music_dir) { - throw new Exception("Invalid music_dir for file in database."); + throw new Exception("Invalid music_dir for file " . $this->getDbId() . " in database."); } $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/models/airtime/CloudFile.php b/airtime_mvc/application/models/airtime/CloudFile.php index fdc609ffc..50b805ab1 100644 --- a/airtime_mvc/application/models/airtime/CloudFile.php +++ b/airtime_mvc/application/models/airtime/CloudFile.php @@ -5,6 +5,9 @@ require_once 'ProxyStorageBackend.php'; /** * Skeleton subclass for representing a row from the 'cloud_file' table. * + * This class uses Propel's delegation feature to virtually inherit from CcFile! + * You can call any CcFile method on this function and it will work! -- Albert + * * Each cloud_file has a corresponding cc_file referenced as a foreign key. * The file's metadata is stored in the cc_file table. This, cloud_file, * table represents files that are stored in the cloud. diff --git a/airtime_mvc/application/modules/rest/controllers/MediaController.php b/airtime_mvc/application/modules/rest/controllers/MediaController.php index cbe53ac30..6a2cccd63 100644 --- a/airtime_mvc/application/modules/rest/controllers/MediaController.php +++ b/airtime_mvc/application/modules/rest/controllers/MediaController.php @@ -4,27 +4,6 @@ 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() { $this->view->layout()->disableLayout(); @@ -35,15 +14,33 @@ class Rest_MediaController extends Zend_Rest_Controller public function indexAction() { + $totalFileCount = CcFilesQuery::create()->count(); + + // Check if offset and limit were sent with request. + // Default limit to zero and offset to $totalFileCount + $offset = $this->_getParam('offset', 0); + $limit = $this->_getParam('limit', $totalFileCount); + + $query = CcFilesQuery::create() + ->filterByDbHidden(false) + ->filterByDbFileExists(true) + ->filterByDbImportStatus(0) + ->setLimit($limit) + ->setOffset($offset) + ->orderByDbId(); + $queryCount = $query->count(); + $queryResult = $query->find(); + $files_array = array(); - foreach (CcFilesQuery::create()->find() as $file) + foreach ($queryResult as $file) { array_push($files_array, CcFiles::sanitizeResponse($file)); } - + $this->getResponse() - ->setHttpResponseCode(200) - ->appendBody(json_encode($files_array)); + ->setHttpResponseCode(200) + ->setHeader('X-TOTAL-COUNT', $queryCount) + ->appendBody(json_encode($files_array)); /** TODO: Use this simpler code instead after we upgrade to Propel 1.7 (Airtime 2.6.x branch): $this->getResponse() @@ -59,18 +56,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 +80,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 +106,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 +133,26 @@ 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); + try { + $requestData = json_decode($this->getRequest()->getRawBody(), true); + $sanitizedFile = CcFiles::updateFromArray($id, $requestData); - $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(); - $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 +162,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 +202,11 @@ 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..ee4238e8d --- /dev/null +++ b/airtime_mvc/application/services/MediaService.php @@ -0,0 +1,89 @@ +getDirectory() . "/imported/" . $ownerId; + } + + //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::moveFileToStor($filePath, $originalFilename, $copyFile); + + //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()); + + return $newTempFilePath; + } + + + /** + * @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(); + + 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 . '"'); + } + + $filepath = $media->getFilePath(); + $size= $media->getFileSize(); + $mimeType = $media->getPropelOrm()->getDbMime(); + Application_Common_FileIO::smartReadFile($filepath, $size, $mimeType); + exit; + } else { + throw new FileNotFoundException(); + } + } + + + + + +} + diff --git a/airtime_mvc/application/upgrade/Upgrades.php b/airtime_mvc/application/upgrade/Upgrades.php index 74b9a6d4d..31c602437 100644 --- a/airtime_mvc/application/upgrade/Upgrades.php +++ b/airtime_mvc/application/upgrade/Upgrades.php @@ -341,3 +341,44 @@ class AirtimeUpgrader2510 extends AirtimeUpgrader } } } + +class AirtimeUpgrader2511 extends AirtimeUpgrader +{ + protected function getSupportedVersions() { + return array ( + '2.5.10' + ); + } + + public function getNewVersion() { + return '2.5.11'; + } + + public function upgrade($dir = __DIR__) { + Cache::clear(); + assert($this->checkIfUpgradeSupported()); + + $newVersion = $this->getNewVersion(); + + try { + $this->toggleMaintenanceScreen(true); + Cache::clear(); + + // Begin upgrade + $queryResult = CcFilesQuery::create() + ->select(array('disk_usage')) + ->withColumn('SUM(CcFiles.filesize)', 'disk_usage') + ->find(); + $disk_usage = $queryResult[0]; + Application_Model_Preference::setDiskUsage($disk_usage); + + Application_Model_Preference::SetAirtimeVersion($newVersion); + Cache::clear(); + + $this->toggleMaintenanceScreen(false); + } catch(Exception $e) { + $this->toggleMaintenanceScreen(false); + throw $e; + } + } +} diff --git a/airtime_mvc/application/views/scripts/form/preferences_general.phtml b/airtime_mvc/application/views/scripts/form/preferences_general.phtml index 65be0a13b..b93cb0a86 100644 --- a/airtime_mvc/application/views/scripts/form/preferences_general.phtml +++ b/airtime_mvc/application/views/scripts/form/preferences_general.phtml @@ -7,10 +7,12 @@ element->getElement('stationLogo')->render() ?> - + element->getElement('stationLogoRemove')->render() ?> + +
- +
element->getElement('locale')->render() ?> diff --git a/airtime_mvc/application/views/scripts/partialviews/trialBox.phtml b/airtime_mvc/application/views/scripts/partialviews/trialBox.phtml index b38973dc2..70f8ba322 100644 --- a/airtime_mvc/application/views/scripts/partialviews/trialBox.phtml +++ b/airtime_mvc/application/views/scripts/partialviews/trialBox.phtml @@ -1,4 +1,4 @@ -trial_remain != '' && $this->trial_remain != "Trial expired."){?> +is_trial && $this->trial_remain != '' && $this->trial_remain != "Trial expired."){?>

diff --git a/airtime_mvc/public/css/styles.css b/airtime_mvc/public/css/styles.css index e99ac2f02..31a3f8383 100644 --- a/airtime_mvc/public/css/styles.css +++ b/airtime_mvc/public/css/styles.css @@ -2197,12 +2197,17 @@ dd.radio-inline-list, .preferences dd.radio-inline-list, .stream-config dd.radio height: 120px; } +.preferences #stationLogoRemove-label { + display: none; +} + .preferences #logo-remove-btn { float: right; + margin-bottom: 4px; } .preferences #Logo-img-container { - float: left; + margin-top: 30px; } #show_time_info { 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("