diff --git a/.gitignore b/.gitignore index 1d9ec6a..243f776 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ test/composer.lock test/vendor .unfinished +.vscode + +# using the docker-compose example will auto-extract some embedded images. ignore. +test/fixtures/id3v2_artist_album_title_cover.jpg +test/fixtures/tagged_with_cover.jpg diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 48ab8ba..e0d3353 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,14 @@ Changelog ========= +1.37 2022-10-27 * Errors now return an HTTP status code 500 by default. + * If the error is due to no content, or a bad URL passed to + ?dir=, then it will be a 404 and no information about + the server paths will be returned in the output. Thanks + to @EdwarDDay for this security suggestion. (#64) + * fix nasty bug where paths were sometimes invalid due to + mishandling of trailing slashes (#55) + 1.36 2022-08-25 * Fix bug where podcasts with autosaved cover art would end up with duplicated iTunes metadata tags. Thanks once again to @EdwarDDay for the bug report. diff --git a/README.md b/README.md index 8d3cda5..5439d24 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![Testing dir2cast](https://github.com/ben-xo/dir2cast/actions/workflows/testing.yml/badge.svg)](https://github.com/ben-xo/dir2cast/actions/workflows/testing.yml) -dir2cast by Ben XO v1.36 (2022-08-25) +dir2cast by Ben XO v1.37 (2022-10-27) ================================================================================ https://github.com/ben-xo/dir2cast/ diff --git a/dir2cast.php b/dir2cast.php index 45107a6..e8debf0 100644 --- a/dir2cast.php +++ b/dir2cast.php @@ -56,7 +56,7 @@ /* DEFAULTS *********************************************/ // error handler needs these, so let's set them now. -define('VERSION', '1.36'); +define('VERSION', '1.37'); define('DIR2CAST_HOMEPAGE', 'https://github.com/ben-xo/dir2cast/'); define('GENERATOR', 'dir2cast ' . VERSION . ' by Ben XO (' . DIR2CAST_HOMEPAGE . ')'); @@ -92,6 +92,8 @@ function __autoloader($class_name) /* CLASSES **********************************************/ +class ExitException extends Exception {} + abstract class GetterSetter { protected $parameters = array(); @@ -615,7 +617,7 @@ protected function stripBasePath($filename) { if(strlen(RSS_File_Item::$FILES_DIR) && strpos($filename, RSS_File_Item::$FILES_DIR) === 0) { - return ltrim(substr($filename, strlen(RSS_File_Item::$FILES_DIR)), '/'); + $filename = ltrim(substr($filename, strlen(RSS_File_Item::$FILES_DIR)), '/'); } return $filename; } @@ -1193,7 +1195,10 @@ protected function scan() self::$DEBUG && print("$item_count items added.\n"); if(self::$EMPTY_PODCAST_IS_ERROR && 0 == $item_count) - throw new Exception("No Items found in {$this->source_dir}"); + { + http_response_code(404); + throw new Exception("No content yet."); + } $this->calculateItemHash(); @@ -1573,17 +1578,25 @@ public static function get_primed_error($type) return 'dir2cast requires getID3. You should download this from ' . DIR2CAST_HOMEPAGE .' and install it with dir2cast.'; } } - + public static function display($message, $errfile, $errline) { if(self::$errors) { - if(!defined('CLI_ONLY') && !ini_get('html_errors')) + if(!defined('CLI_ONLY')) + { + if(!http_response_code()) + { + http_response_code(500); + } + } + + if((!defined('CLI_ONLY')) && !ini_get('html_errors')) { header("Content-type: text/plain"); // reset the content-type } - if(!defined('CLI_ONLY') && ini_get('html_errors')) + if((!defined('CLI_ONLY')) && ini_get('html_errors')) { header("Content-type: text/html"); // reset the content-type @@ -1603,14 +1616,16 @@ public static function display($message, $errfile, $errline)

An error occurred generating your podcast.

-

+

+ +

+
+ This error occurred on line of . +
-
- This error occurred on line of . -

@@ -1629,16 +1644,40 @@ public static function display($message, $errfile, $errline) echo strip_tags(self::get_primed_error(ErrorHandler::$primer)) . "\n"; } - exit(-1); + exit(-1); // can't throw - this is the exception handler } } - + + public static function display404($message) + { + if(defined('CLI_ONLY')) + { + http_response_code(404); + header("Content-type: text/plain"); + } + throw new ExitException("Not Found: $message", -2); + } } class SettingsHandler { private static $settings_cache = array(); + /** + * getopt() uses argv directly and is a pain to mock. It's nicer to pass argv in, + * but mocking it a pain. + */ + public static function getopt($argv_in, $short_options, $long_options) + { + if(isset($GLOBALS['argv']) && $argv_in != $GLOBALS['argv']) + { + return fake_getopt($argv_in, $short_options, $long_options); + } + return getopt($short_options, $long_options); + } + + + /** * This method sets up all app-wide settings that are required at initialization time. * @@ -1653,7 +1692,8 @@ public static function bootstrap(array $SERVER, array $GET, array $argv) define('CLI_ONLY', true); } - if(defined('CLI_ONLY') && CLI_ONLY) { + // do not use DIR2CAST_BASE directly. use DIR2CAST_BASE() + if(defined('CLI_ONLY')) { define('DIR2CAST_BASE', realpath(dirname($argv[0]))); } else { define('DIR2CAST_BASE', dirname(__FILE__)); @@ -1662,15 +1702,15 @@ public static function bootstrap(array $SERVER, array $GET, array $argv) // If an installation-wide config file exists, load it now. // Installation-wide config can contain TMP_DIR, MP3_DIR and MIN_CACHE_TIME. // Anything else it contains will be used as a fall-back if no dir-specific dir2cast.ini exists - if(file_exists( DIR2CAST_BASE . '/dir2cast.ini' )) + if(file_exists( DIR2CAST_BASE() . 'dir2cast.ini' )) { - $ini_file_name = DIR2CAST_BASE . '/dir2cast.ini'; + $ini_file_name = DIR2CAST_BASE() . 'dir2cast.ini'; self::load_from_ini( $ini_file_name ); self::finalize(array('TMP_DIR', 'MP3_BASE', 'MP3_DIR', 'MIN_CACHE_TIME', 'FORCE_PASSWORD')); define('INI_FILE', $ini_file_name); } - $cli_options = getopt('', array('help', 'media-dir::', 'media-url::', 'output::', 'dont-uncache', 'min-file-age::', 'debug', 'ignore-dir2cast-mtime', 'clock-offset::')); + $cli_options = self::getopt($argv, '', array('help', 'media-dir::', 'media-url::', 'output::', 'dont-uncache', 'min-file-age::', 'debug', 'ignore-dir2cast-mtime', 'clock-offset::')); if($cli_options) { if(isset($cli_options['help'])) { print "Usage: php dir2cast.php [--help] [--media-dir=MP3_DIR] [--media-url=MP3_URL] [--output=OUTPUT_FILE]\n"; @@ -1686,7 +1726,13 @@ public static function bootstrap(array $SERVER, array $GET, array $argv) } if(!defined('MP3_DIR') && !empty($cli_options['media-dir'])) { - define('MP3_DIR', realpath($cli_options['media-dir'])); + + if(!is_dir($cli_options['media-dir']) or !is_readable($cli_options['media-dir'])) + { + ErrorHandler::display404($cli_options['media-dir']); + } + // do not use MP3_DIR directly. use MP3_DIR() + define('MP3_DIR', slashdir(realpath($cli_options['media-dir']))); } if(!defined('MP3_URL') && !empty($cli_options['media-url'])) { @@ -1725,25 +1771,32 @@ public static function bootstrap(array $SERVER, array $GET, array $argv) define('FORCE_PASSWORD', ''); if(!defined('TMP_DIR')) { - define('TMP_DIR', DIR2CAST_BASE . '/temp'); + define('TMP_DIR', DIR2CAST_BASE() . 'temp'); } + // do not use MP3_BASE directly. use MP3_BASE() if(!defined('MP3_BASE')) { if(!empty($SERVER['HTTP_HOST'])) define('MP3_BASE', dirname($SERVER['SCRIPT_FILENAME'])); else - define('MP3_BASE', DIR2CAST_BASE); + define('MP3_BASE', DIR2CAST_BASE()); } - + + // do not use MP3_DIR directly. use MP3_DIR() if(!defined('MP3_DIR')) { if(!empty($GET['dir'])) - define('MP3_DIR', MP3_BASE . '/' . safe_path(magic_stripslashes($GET['dir']))); + { + define('MP3_DIR', MP3_BASE() . safe_path(magic_stripslashes($GET['dir']))); + if(!is_dir(MP3_DIR()) or !is_readable(MP3_DIR())) + { + ErrorHandler::display404($GET['dir']); + } + } else - define('MP3_DIR', MP3_BASE); + define('MP3_DIR', MP3_BASE()); } - } /** @@ -1753,15 +1806,14 @@ public static function defaults(array $SERVER) { // if an MP3_DIR specific config file exists, load it now, as long as it's not the same file as the global one! if( - file_exists( MP3_DIR . '/dir2cast.ini' ) and - realpath(DIR2CAST_BASE . '/dir2cast.ini') != realpath( MP3_DIR . '/dir2cast.ini' ) + file_exists( MP3_DIR() . 'dir2cast.ini' ) and + realpath(DIR2CAST_BASE() . 'dir2cast.ini') != realpath( MP3_DIR() . 'dir2cast.ini' ) ) { - self::load_from_ini( MP3_DIR . '/dir2cast.ini' ); + self::load_from_ini( MP3_DIR() . 'dir2cast.ini' ); } self::finalize(); - if(!defined('MP3_URL')) { # This works on the principle that MP3_DIR must be under DOCUMENT_ROOT (otherwise how will you serve the MP3s?) @@ -1770,18 +1822,18 @@ public static function defaults(array $SERVER) if(!empty($SERVER['HTTP_HOST'])) { - $path_part = substr(MP3_DIR, strlen($SERVER['DOCUMENT_ROOT'])); + $path_part = substr(MP3_DIR(), strlen(slashdir($SERVER['DOCUMENT_ROOT']))); define('MP3_URL', - 'http' . (!empty($SERVER['HTTPS']) ? 's' : '') . '://' . $SERVER['HTTP_HOST'] . '/' . ltrim( rtrim( $path_part, '/' ) . '/', '/' )); + 'http' . (!empty($SERVER['HTTPS']) ? 's' : '') . '://' . $SERVER['HTTP_HOST'] . '/' . ltrim( slashdir( $path_part ), '/' )); } else - define('MP3_URL', 'file://' . MP3_DIR ); + define('MP3_URL', 'file://' . MP3_DIR() ); } if(!defined('TITLE')) { - if(basename(MP3_DIR)) - define('TITLE', basename(MP3_DIR)); + if(basename(MP3_DIR())) + define('TITLE', basename(MP3_DIR())); else define('TITLE', 'My First dir2cast Podcast'); } @@ -1804,10 +1856,10 @@ public static function defaults(array $SERVER) if(!defined('DESCRIPTION')) { - if(file_exists(MP3_DIR . '/description.txt')) - define('DESCRIPTION', file_get_contents(MP3_DIR . '/description.txt')); - elseif(file_exists(DIR2CAST_BASE . '/description.txt')) - define('DESCRIPTION', file_get_contents(DIR2CAST_BASE . '/description.txt')); + if(file_exists(MP3_DIR() . 'description.txt')) + define('DESCRIPTION', file_get_contents(MP3_DIR() . 'description.txt')); + elseif(file_exists(DIR2CAST_BASE() . 'description.txt')) + define('DESCRIPTION', file_get_contents(DIR2CAST_BASE() . 'description.txt')); else define('DESCRIPTION', 'Podcast'); } @@ -1829,33 +1881,33 @@ public static function defaults(array $SERVER) if(!defined('ITUNES_SUBTITLE')) { - if(file_exists(MP3_DIR . '/itunes_subtitle.txt')) - define('ITUNES_SUBTITLE', file_get_contents(MP3_DIR . '/itunes_subtitle.txt')); - elseif(file_exists(DIR2CAST_BASE . '/itunes_subtitle.txt')) - define('ITUNES_SUBTITLE', file_get_contents(DIR2CAST_BASE . '/itunes_subtitle.txt')); + if(file_exists(MP3_DIR() . 'itunes_subtitle.txt')) + define('ITUNES_SUBTITLE', file_get_contents(MP3_DIR() . 'itunes_subtitle.txt')); + elseif(file_exists(DIR2CAST_BASE() . 'itunes_subtitle.txt')) + define('ITUNES_SUBTITLE', file_get_contents(DIR2CAST_BASE() . 'itunes_subtitle.txt')); else define('ITUNES_SUBTITLE', DESCRIPTION); } if(!defined('ITUNES_SUMMARY')) { - if(file_exists(MP3_DIR . '/itunes_summary.txt')) - define('ITUNES_SUMMARY', file_get_contents(MP3_DIR . '/itunes_summary.txt')); - elseif(file_exists(DIR2CAST_BASE . '/itunes_summary.txt')) - define('ITUNES_SUMMARY', file_get_contents(DIR2CAST_BASE . '/itunes_summary.txt')); + if(file_exists(MP3_DIR() . 'itunes_summary.txt')) + define('ITUNES_SUMMARY', file_get_contents(MP3_DIR() . 'itunes_summary.txt')); + elseif(file_exists(DIR2CAST_BASE() . 'itunes_summary.txt')) + define('ITUNES_SUMMARY', file_get_contents(DIR2CAST_BASE() . 'itunes_summary.txt')); else define('ITUNES_SUMMARY', DESCRIPTION); } if(!defined('IMAGE')) { - if(file_exists(rtrim(MP3_DIR, '/') . '/image.jpg')) + if(file_exists(MP3_DIR() . 'image.jpg')) define('IMAGE', rtrim(MP3_URL, '/') . '/image.jpg'); - elseif(file_exists(rtrim(MP3_DIR, '/') . '/image.png')) + elseif(file_exists(MP3_DIR() . 'image.png')) define('IMAGE', rtrim(MP3_URL, '/') . '/image.png'); - elseif(file_exists(DIR2CAST_BASE . '/image.jpg')) + elseif(file_exists(DIR2CAST_BASE() . 'image.jpg')) define('IMAGE', rtrim(MP3_URL, '/') . '/image.jpg'); - elseif(file_exists(DIR2CAST_BASE . '/image.png')) + elseif(file_exists(DIR2CAST_BASE() . 'image.png')) define('IMAGE', rtrim(MP3_URL, '/') . '/image.png'); else define('IMAGE', ''); @@ -1863,13 +1915,13 @@ public static function defaults(array $SERVER) if(!defined('ITUNES_IMAGE')) { - if(file_exists(rtrim(MP3_DIR, '/') . '/itunes_image.jpg')) + if(file_exists(MP3_DIR() . 'itunes_image.jpg')) define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.jpg'); - elseif(file_exists(rtrim(MP3_DIR, '/') . '/itunes_image.png')) + elseif(file_exists(MP3_DIR() . 'itunes_image.png')) define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.png'); - elseif(file_exists(DIR2CAST_BASE . '/itunes_image.jpg')) + elseif(file_exists(DIR2CAST_BASE() . 'itunes_image.jpg')) define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.jpg'); - elseif(file_exists(DIR2CAST_BASE . '/itunes_image.png')) + elseif(file_exists(DIR2CAST_BASE() . 'itunes_image.png')) define('ITUNES_IMAGE', rtrim(MP3_URL, '/') . '/itunes_image.png'); else define('ITUNES_IMAGE', ''); @@ -1926,7 +1978,7 @@ public static function defaults(array $SERVER) define('CLOCK_OFFSET', 0); // Set up factory settings for Podcast subclasses - Dir_Podcast::$EMPTY_PODCAST_IS_ERROR = !defined('CLI_ONLY') || !CLI_ONLY; + Dir_Podcast::$EMPTY_PODCAST_IS_ERROR = !defined('CLI_ONLY'); Dir_Podcast::$RECURSIVE_DIRECTORY_ITERATOR = RECURSIVE_DIRECTORY_ITERATOR; Dir_Podcast::$ITEM_COUNT = ITEM_COUNT; Dir_Podcast::$MIN_FILE_AGE = MIN_FILE_AGE; @@ -1937,7 +1989,7 @@ public static function defaults(array $SERVER) // Set up up factory settings for RSS Items RSS_File_Item::$FILES_URL = MP3_URL; // TODO: rename this to MEDIA_URL - RSS_File_Item::$FILES_DIR = MP3_DIR; // TODO: rename this to MEDIA_DIR + RSS_File_Item::$FILES_DIR = MP3_DIR(); // TODO: rename this to MEDIA_DIR Media_RSS_Item::$LONG_TITLES = LONG_TITLES; Media_RSS_Item::$DESCRIPTION_SOURCE = DESCRIPTION_SOURCE; } @@ -2019,10 +2071,10 @@ public function update_mtime_if_metadata_files_modified() ); foreach($metadata_files as $file) { - $filepath = rtrim(MP3_DIR, '/') . '/' . $file; + $filepath = MP3_DIR() . $file; if(!file_exists($filepath)) { - $filepath = DIR2CAST_BASE . '/' . $file; + $filepath = DIR2CAST_BASE() . $file; } if(!file_exists($filepath)) { @@ -2077,7 +2129,7 @@ public function output() if(!defined('OUTPUT_FILE')) { $output = $podcast->generate(); - if(!defined('CLI_ONLY') || !CLI_ONLY) + if(!defined('CLI_ONLY')) { $podcast->http_headers(strlen($output)); } @@ -2147,6 +2199,23 @@ function utf8_for_xml($s) return preg_replace('/[^\x{0009}\x{000a}\x{000d}\x{0020}-\x{D7FF}\x{E000}-\x{FFFD}]+/u', '', $s); } +function slashdir($dir) +{ + return rtrim($dir, '/') . '/'; +} + +function DIR2CAST_BASE() { + return slashdir(DIR2CAST_BASE); +} + +function MP3_BASE() { + return slashdir(MP3_BASE); +} + +function MP3_DIR() { + return slashdir(MP3_DIR); +} + /* DISPATCH *********************************************/ function main($args) @@ -2161,7 +2230,7 @@ function main($args) empty($_SERVER) ? array() : $_SERVER ); - $podcast = new Locking_Cached_Dir_Podcast(MP3_DIR, TMP_DIR); + $podcast = new Locking_Cached_Dir_Podcast(MP3_DIR(), TMP_DIR); $podcast->setClockOffset(CLOCK_OFFSET); $dispatcher = new Dispatcher($podcast); @@ -2181,7 +2250,15 @@ function main($args) if(isset($GLOBALS['argv'])) { $args = $argv; } - exit(main($args)); + try + { + exit(main($args)); + } + catch(ExitException $e) + { + print($e->getMessage()."\n"); + exit($e->getCode()); + } } /* THE END *********************************************/ diff --git a/docker-compose/nginx/default.conf b/docker-compose/nginx/default.conf index a918f54..f2ab099 100644 --- a/docker-compose/nginx/default.conf +++ b/docker-compose/nginx/default.conf @@ -8,7 +8,7 @@ server { # Don't allow downloading of dir2cast.ini, as it may contain sensitive # info such as the refresh password. Also, don't allow downloading of # dir2cast.php, for security and privacy reasons. - location ~ /dir2cast\.(ini|php)$ { + location ~ \.(ini|php)$ { return 404; } diff --git a/test/FakeGetoptTest.php b/test/FakeGetoptTest.php new file mode 100644 index 0000000..c3769b2 --- /dev/null +++ b/test/FakeGetoptTest.php @@ -0,0 +1,74 @@ +assertEquals( + fake_getopt(array('php', '--halp'), '', array()), + array() + ); + $this->assertEquals( + fake_getopt(array('php'), '', array()), + array() + ); + } + public function test_fake_getopt_no_match() + { + $this->assertEquals( + fake_getopt(array('php', '--halp'), '', array('help')), + array() + ); + $this->assertEquals( + fake_getopt(array('php'), '', array('help')), + array() + ); + } + + public function test_fake_getopt_bool_arg() + { + $this->assertEquals( + fake_getopt(array('php', '--help'), '', array('help')), + array('help' => false) + ); + } + public function test_fake_getopt_string_arg() + { + $this->assertEquals( + fake_getopt(array('php', '--media-dir'), '', array('media-dir::')), + array('media-dir' => '') + ); + $this->assertEquals( + fake_getopt(array('php', '--media-dir='), '', array('media-dir::')), + array() // XXX: seems to be a bug in getopt + ); + $this->assertEquals( + fake_getopt(array('php', '--media-dir=test'), '', array('media-dir::')), + array('media-dir' => 'test') + ); + } + public function test_fake_getopt_escaping() + { + $this->assertEquals( + fake_getopt(array('php', "--media-dir= "), '', array('media-dir::')), + array('media-dir' => ' ') + ); + $this->assertEquals( + fake_getopt(array('php', '--media-dir=""'), '', array('media-dir::')), + array('media-dir' => '""') + ); + $this->assertEquals( + fake_getopt(array('php', "--media-dir=''"), '', array('media-dir::')), + array('media-dir' => "''") + ); + } + public function test_fake_getopt_both_arg_types() + { + $this->assertEquals( + fake_getopt(array('php', '--help', '--media-dir'), '', array('help', 'media-dir::')), + array('help' => false, 'media-dir' => '') + ); + } +} diff --git a/test/FourOhFourTest.php b/test/FourOhFourTest.php new file mode 100644 index 0000000..3f2214d --- /dev/null +++ b/test/FourOhFourTest.php @@ -0,0 +1,23 @@ +assertEquals("Not Found: dir2cast.ini", implode("\n", $output)); + $this->assertEquals(254, $returncode); // 254 is -2 + } + + public static function tearDownAfterClass(): void + { + chdir('..'); + } +} diff --git a/test/SettingsHandlerTest.php b/test/SettingsHandlerTest.php index dd7f052..9a8699e 100644 --- a/test/SettingsHandlerTest.php +++ b/test/SettingsHandlerTest.php @@ -42,7 +42,64 @@ class SettingsHandlerTest extends TestCase 'DONT_UNCACHE_IF_OUTPUT_FILE', 'MIN_FILE_AGE', ); + + public $temp_file = false; + public $starting_dir = false; + + public function setUp(): void + { + $this->temp_file = false; + $this->starting_dir = false; + } + public function test_getopt_hook() + { + $argv_copy = $GLOBALS['argv']; + $argc_copy = $GLOBALS['argc']; + + $short_options = ''; + $long_options = array('help', 'media-dir::', 'bootstrap'); + + $cli_options = SettingsHandler::getopt( + array(), + $short_options, $long_options + ); + $this->assertEquals($cli_options, array()); + + $cli_options = SettingsHandler::getopt( + array('dir2cast.php'), + $short_options, $long_options + ); + $this->assertEquals($cli_options, array()); + + $cli_options = SettingsHandler::getopt( + array('dir2cast.php', '--help'), + $short_options, $long_options + ); + $this->assertEquals($cli_options, array('help' => false)); + + $cli_options = SettingsHandler::getopt( + array('dir2cast.php', '--media-dir=test1'), + $short_options, $long_options + ); + $this->assertEquals($cli_options, array('media-dir' => 'test1')); + + $cli_options = SettingsHandler::getopt( + array('dir2cast.php', '--media-dir=test2', '--bootstrap'), + $short_options, $long_options + ); + $this->assertEquals($cli_options, array('media-dir' => 'test2', 'bootstrap' => false)); + + $cli_options = SettingsHandler::getopt( + array('dir2cast.php', '--bootstrap', '--media-dir=test3'), + $short_options, $long_options + ); + $this->assertEquals($cli_options, array('media-dir' => 'test3', 'bootstrap' => false)); + + $this->assertEquals($argv_copy, $GLOBALS['argv']); + $this->assertEquals($argc_copy, $GLOBALS['argc']); + } + /** * @runInSeparateProcess * @preserveGlobalState disabled @@ -80,7 +137,34 @@ public function test_default_defines_set() // should not be defined as $argv was empty $this->assertFalse(defined('CLI_ONLY')); - $this->assertEquals(DIR2CAST_BASE, realpath('..')); // from bootstrap.php + $this->assertEquals(DIR2CAST_BASE(), slashdir(realpath('..'))); // from bootstrap.php + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_pre_defined_slashed() + { + define('DIR2CAST_BASE', '/tmp/'); + $this->assertEquals(DIR2CAST_BASE(), '/tmp/'); + define('MP3_BASE', '/tmp/'); + $this->assertEquals(DIR2CAST_BASE(), '/tmp/'); + define('MP3_PATH', '/tmp/'); + $this->assertEquals(DIR2CAST_BASE(), '/tmp/'); + } + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_pre_defined_slashless() + { + define('DIR2CAST_BASE', '/tmp'); + $this->assertEquals(DIR2CAST_BASE(), '/tmp/'); + define('MP3_BASE', '/tmp'); + $this->assertEquals(DIR2CAST_BASE(), '/tmp/'); + define('MP3_PATH', '/tmp'); + $this->assertEquals(DIR2CAST_BASE(), '/tmp/'); } /** @@ -115,23 +199,24 @@ public function test_defines_CLI_ONLY_if_argv0() $this->assertFalse(defined('CLI_ONLY')); SettingsHandler::bootstrap(array(), array(), array('dir2cast.php')); $this->assertTrue(defined('CLI_ONLY')); - $this->assertEquals(DIR2CAST_BASE, getcwd()); // from fake $argv + $this->assertEquals(DIR2CAST_BASE(), slashdir(getcwd())); // from fake $argv } /** * @runInSeparateProcess * @preserveGlobalState disabled - * @testWith [null] - * ["dir2cast.php"] + * @testWith [null, null] + * ["dir2cast.php", null] + * ["dir2cast.php", "--media-dir="] */ - public function test_bootstrap_sets_sensible_global_defaults_for_entire_installation($argv0) + public function test_bootstrap_sets_sensible_global_defaults_for_entire_installation($argv0, $argv1) { - SettingsHandler::bootstrap(array(), array(), array($argv0)); + SettingsHandler::bootstrap(array(), array(), array($argv0, $argv1)); $this->assertEquals(MIN_CACHE_TIME, 5); $this->assertEquals(FORCE_PASSWORD, ''); - $this->assertEquals(TMP_DIR, DIR2CAST_BASE . '/temp'); - $this->assertEquals(MP3_BASE, DIR2CAST_BASE); - $this->assertEquals(MP3_DIR, DIR2CAST_BASE); + $this->assertEquals(TMP_DIR, DIR2CAST_BASE() . 'temp'); + $this->assertEquals(MP3_BASE(), DIR2CAST_BASE()); + $this->assertEquals(MP3_DIR(), DIR2CAST_BASE()); } /** @@ -149,10 +234,255 @@ public function test_when_SERVER_HTTP_HOST_then_MP3_BASE_defaults_to_same_dir() /* $GET */ array(), /* $argv */ array() ); - $this->assertEquals(MP3_BASE, '/var/www'); - $this->assertEquals(MP3_DIR, '/var/www'); + $this->assertEquals(MP3_BASE(), '/var/www/'); + $this->assertEquals(MP3_DIR(), '/var/www/'); } + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_cli_media_404() + { + $this->temp_file = basename(tempnam('./', 'test_cli_media_404')); + $this->assertFalse(strpos($this->temp_file, '/')); + unlink($this->temp_file); + + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: {$this->temp_file}"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array(), array("dir2cast.php", "--media-dir={$this->temp_file}")); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_404() + { + $this->temp_file = basename(tempnam('../', 'test_GET_media_404')); + $this->assertFalse(strpos($this->temp_file, '/')); + unlink('../' . $this->temp_file); + + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: {$this->temp_file}"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array("dir" => $this->temp_file), array()); + $this->assertEquals(http_response_code(), 404); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_cli_media_not_dir_404() + { + $this->temp_file = basename(tempnam('./', 'test_cli_media_not_dir_404')); + + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: {$this->temp_file}"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array(), array("dir2cast.php", "--media-dir={$this->temp_file}")); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_not_dir_404() + { + $this->temp_file = basename(tempnam('../', 'test_GET_media_not_dir_404')); + + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: {$this->temp_file}"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array("dir" => $this->temp_file), array()); + $this->assertEquals(http_response_code(), 404); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_cli_media_dir_but_no_permissions_404() + { + $this->temp_file = basename(tempnam('./', 'test_cli_media_dir_but_no_permissions_404')); + unlink($this->temp_file); + mkdir($this->temp_file); + chmod($this->temp_file, 0); + + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: {$this->temp_file}"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array(), array("dir2cast.php", "--media-dir={$this->temp_file}")); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_but_no_permissions_404() + { + $this->temp_file = basename(tempnam('../', 'test_GET_media_dir_but_no_permissions_404')); + unlink('../' . $this->temp_file); + mkdir('../' . $this->temp_file); + chmod('../' . $this->temp_file, 0); + + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: {$this->temp_file}"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array("dir" => $this->temp_file), array()); + $this->assertEquals(http_response_code(), 404); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_cli_media_dir_a_ok() + { + $this->temp_file = basename(tempnam('./', 'test_cli_media_dir_a_ok')); + unlink($this->temp_file); + mkdir($this->temp_file); + + SettingsHandler::bootstrap(array(), array(), array("dir2cast.php", "--media-dir={$this->temp_file}")); + $this->assertEquals(MP3_BASE(), slashdir(realpath('.'))); + $this->assertEquals(MP3_DIR(), slashdir(realpath($this->temp_file))); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_a_ok() + { + $this->temp_file = basename(tempnam('../', 'test_GET_media_dir_a_ok')); + unlink('../' . $this->temp_file); + mkdir('../' . $this->temp_file); + + SettingsHandler::bootstrap(array(), array("dir" => $this->temp_file), array()); + $this->assertEquals(MP3_BASE(), slashdir(realpath('..'))); // due to bootstrap.php chdir + $this->assertEquals(MP3_DIR(), slashdir(realpath('../' . $this->temp_file))); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_safe_dot_dot_1() + { + $this->starting_dir = getcwd(); + mkdir('deep'); + mkdir('deep/root'); + chdir('deep/root'); + SettingsHandler::bootstrap(array(), array("dir" => ".."), array()); + + $this->assertEquals(MP3_BASE(), slashdir(realpath("{$this->starting_dir}/.."))); // due to bootstrap.php chdir + $this->assertEquals(MP3_DIR(), MP3_BASE()); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_safe_dot_dot_2() + { + $this->starting_dir = getcwd(); + mkdir('deep'); + mkdir('deep/root'); + chdir('deep/root'); + SettingsHandler::bootstrap(array(), array("dir" => "../../.."), array()); + + $this->assertEquals(MP3_BASE(), slashdir(realpath("{$this->starting_dir}/.."))); // due to bootstrap.php chdir + $this->assertEquals(MP3_DIR(), MP3_BASE()); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_safe_slash_dir() + { + $this->starting_dir = getcwd(); + mkdir('deep'); + mkdir('deep/root'); + chdir('deep/root'); + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: /etc"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array("dir" => "/etc"), array()); + $this->assertEquals(http_response_code(), 404); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_safe_slash_dir_2() + { + $this->starting_dir = getcwd(); + mkdir('deep'); + mkdir('deep/root'); + chdir('deep/root'); + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: ////etc"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array("dir" => "////etc"), array()); + $this->assertEquals(http_response_code(), 404); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_safe_dir_with_good_base() + { + $this->starting_dir = getcwd(); + mkdir('deep'); + mkdir('deep/root'); + chdir('deep/root'); + define('MP3_BASE', realpath('..')); + SettingsHandler::bootstrap(array(), array("dir" => "root"), array()); + + $this->assertEquals(MP3_BASE(), realpath("..") . '/'); + $this->assertEquals(MP3_DIR(), realpath('.') . '/'); + $this->assertFalse(http_response_code()); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_GET_media_dir_unsafe_slash_dir_with_good_base() + { + $this->starting_dir = getcwd(); + mkdir('deep'); + mkdir('deep/root'); + chdir('deep/root'); + define('MP3_BASE', realpath('..')); + $this->expectException("ExitException"); + $this->expectExceptionMessage("Not Found: ../deep/root"); + $this->expectExceptionCode(-2); + SettingsHandler::bootstrap(array(), array("dir" => "../deep/root"), array()); + $this->assertEquals(http_response_code(), 404); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + // public function test_cli_arg_parsing() + // { + + // } + // TODO: test HTTP_HOST + GET dir /** @@ -194,7 +524,7 @@ public function test_sensible_defaults($argv0) $this->assertSame(getID3_Podcast_Helper::$AUTO_SAVE_COVER_ART, AUTO_SAVE_COVER_ART); $this->assertSame(iTunes_Podcast_Helper::$ITUNES_SUBTITLE_SUFFIX, ITUNES_SUBTITLE_SUFFIX); $this->assertSame(RSS_File_Item::$FILES_URL, MP3_URL); - $this->assertSame(RSS_File_Item::$FILES_DIR, MP3_DIR); + $this->assertSame(RSS_File_Item::$FILES_DIR, MP3_DIR()); $this->assertSame(Media_RSS_Item::$LONG_TITLES, LONG_TITLES); $this->assertSame(Media_RSS_Item::$DESCRIPTION_SOURCE, DESCRIPTION_SOURCE); } @@ -225,7 +555,7 @@ public function test_CLI_ONLY_sensible_defaults() SettingsHandler::bootstrap(array(), array(), array('dir2cast.php')); SettingsHandler::defaults(array()); - $this->assertEquals(MP3_URL, 'file://' . getcwd()); + $this->assertEquals(MP3_URL, 'file://' . getcwd() . '/'); $this->assertEquals(LINK, 'http://www.example.com/'); $this->assertEquals(RSS_LINK, 'http://www.example.com/rss'); $this->assertEquals(TITLE, 'test'); // name of this folder @@ -326,11 +656,28 @@ public function test_HTTPS_URLs_exist() public function tearDown(): void { + if($this->starting_dir) { + chdir($this->starting_dir); + rmrf('deep'); + } file_exists('description.txt') && unlink('description.txt'); file_exists('itunes_subtitle.txt') && unlink('itunes_subtitle.txt'); file_exists('itunes_summary.txt') && unlink('itunes_summary.txt'); file_exists('image.jpg') && unlink('image.jpg'); file_exists('itunes_image.jpg') && unlink('itunes_image.jpg'); + if($this->temp_file) + { + if(file_exists($this->temp_file)) { + chmod($this->temp_file, 755); + if(is_dir($this->temp_file)) rmdir($this->temp_file); + else unlink($this->temp_file); + } + elseif(file_exists('../'.$this->temp_file)) { + chmod('../'.$this->temp_file, 755); + if(is_dir('../'.$this->temp_file)) rmdir('../'.$this->temp_file); + else unlink('../'.$this->temp_file); + } + } } } diff --git a/test/bootstrap.php b/test/bootstrap.php index ff103b6..9fe6f3e 100644 --- a/test/bootstrap.php +++ b/test/bootstrap.php @@ -85,6 +85,44 @@ function temp_xml_glob() return '.' . DIRECTORY_SEPARATOR . 'temp' . DIRECTORY_SEPARATOR . '*.xml'; } +function escape_single_quoted_string($string) +{ + $string = str_replace('\\', '\\\\', $string); + $string = str_replace('\'', '\\\'', $string); + return $string; +} + +function fake_getopt_command($argv_in, $short_options, $long_options) +{ + $argv_string = "'" . implode("', '", array_map('escape_single_quoted_string', $argv_in) ). "'"; + $argv_count = count($argv_in); + $short_options_string = escape_single_quoted_string($short_options); + $long_options_string = "'" . implode("', '", array_map('escape_single_quoted_string', $long_options) ). "'"; + + $command_parts = array( + 'php', '-d', 'register_argc_argv=false', '-r', escapeshellarg(<< 0) + return unserialize($output[0]); + return array(); +} define('NO_DISPATCHER', true); diff --git a/test/run.sh b/test/run.sh index 11f01d9..031418f 100755 --- a/test/run.sh +++ b/test/run.sh @@ -38,6 +38,7 @@ if [[ "$PATH_COVERAGE" != '' ]]; then fi vendor/bin/phpunit \ + --colors=always \ --bootstrap "$SCRIPT_DIR/bootstrap.php" \ --coverage-php /tmp/cov-main \ --coverage-filter ../dir2cast.php \