diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..89585bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +.dockerignore +.env +Dockerfile +README.md diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c2d5fb6 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,27 @@ +name: Publish Docker image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v2 + with: + context: . + file: ./Dockerfile + push: true + tags: ghcr.io/${{ github.repository }}/cgmsharp:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a7d6d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1773eb5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM php:8.3.3-apache + +RUN apt-get update && apt-get install -y \ + exif \ + ffmpeg \ + libzip-dev \ + libpng-dev \ + libjpeg-dev \ + sudo \ + zip \ + libfreetype6-dev \ + libfontconfig1-dev \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j$(nproc) gd \ + && docker-php-ext-install zip \ + && docker-php-ext-install exif + +RUN apt-get clean + +ARG UID=1026 +ARG GID=101 + +# By default apache runs as the www-data user (uid: 33) if this doesn't match the dockerhost user that has permissions on the mapped volume +# the container won't be able to access the host file system. Force the uid for the www-data user to match the user id on docker host that +# can access the mapped volume. (1026 & 101) +RUN usermod --non-unique -u ${UID} www-data +RUN groupmod --non-unique -g ${GID} www-data + +# copy files-gallery into container +COPY src/index.php /var/www/html + +# customise settings +COPY src/_filesconfig.php /var/www/html/_filesconfig.php + +# setup write permissions on /media +RUN mkdir -p /media \ + && mkdir -p /config \ + && chmod -R 755 /media \ + && chmod -R 755 /config \ + && chown -R www-data:www-data /media \ + && chown -R www-data:www-data /config \ No newline at end of file diff --git a/README.md b/README.md index b529194..b252660 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ -# files-gallery -Docker image to run https://www.files.gallery/ +# Files-Gallery + +Docker container for https://www.files.gallery/ - a single-file PHP app that can be dropped into any folder, instantly creating a gallery of files and folders. + +When running this container, your images directory should be mounted into the container's `/media` directory, files.gallery listens on port 80. \ No newline at end of file diff --git a/config/config/config.php b/config/config/config.php new file mode 100644 index 0000000..0910850 --- /dev/null +++ b/config/config/config.php @@ -0,0 +1,80 @@ + '', + //'start_path' => false, + //'username' => '', + //'password' => '', + //'load_images' => true, + //'load_files_proxy_php' => false, + //'load_images_max_filesize' => 1000000, + //'image_resize_enabled' => true, + //'image_resize_cache' => true, + //'image_resize_dimensions' => 320, + //'image_resize_dimensions_retina' => 480, + //'image_resize_dimensions_allowed' => '', + //'image_resize_types' => 'jpeg, png, gif, webp, bmp, avif', + //'image_resize_quality' => 85, + //'image_resize_function' => 'imagecopyresampled', + //'image_resize_sharpen' => true, + //'image_resize_memory_limit' => 128, + //'image_resize_max_pixels' => 30000000, + //'image_resize_min_ratio' => 1.5, + //'image_resize_cache_direct' => false, + //'folder_preview_image' => true, + //'folder_preview_default' => '_filespreview.jpg', + //'menu_enabled' => true, + //'menu_show' => true, + //'menu_max_depth' => 5, + //'menu_sort' => 'name_asc', + //'menu_cache_validate' => true, + //'menu_load_all' => false, + //'menu_recursive_symlinks' => true, + //'layout' => 'rows', + //'sort' => 'name_asc', + //'sort_dirs_first' => true, + //'sort_function' => 'locale', + //'cache' => true, + //'cache_key' => 0, + //'storage_path' => '_files', + //'files_exclude' => '', + //'dirs_exclude' => '', + //'allow_symlinks' => true, + //'title' => '%name% [%count%]', + //'history' => true, + //'transitions' => true, + //'click' => 'popup', + //'click_window' => '', + //'click_window_popup' => true, + //'code_max_load' => 100000, + //'topbar_sticky' => 'scroll', + //'check_updates' => false, + //'allow_tasks' => false, + //'get_mime_type' => false, + //'context_menu' => true, + //'prevent_right_click' => false, + //'license_key' => '', + //'filter_live' => true, + //'filter_props' => 'name, filetype, mime, features, title', + //'download_dir' => 'browser', + //'download_dir_cache' => 'dir', + //'assets' => '', + //'allow_upload' => false, + //'allow_delete' => false, + //'allow_rename' => false, + //'allow_new_folder' => false, + //'allow_new_file' => false, + //'allow_duplicate' => false, + //'allow_text_edit' => false, + //'demo_mode' => false, + //'upload_allowed_file_types' => '', + //'upload_max_filesize' => 0, + //'upload_exists' => 'increment', + //'popup_video' => true, + //'video_thumbs' => true, + //'video_ffmpeg_path' => 'ffmpeg', + //'lang_default' => 'en', + //'lang_auto' => true, +); \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..1bae481 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,14 @@ +version: '3.8' +services: + app: + container_name: files-gallery + hostname: files.gallery.local # not used, but without it apache will complain about unknown fqdn on startup + build: . + ports: + - "8080:80" + volumes: + - ./config:/config + - C:\pictures:/media + +volumes: + config: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..27ccc58 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,16 @@ +version: '3.8' +services: + app: + image: ghcr.io/marcbarry/files-gallery/files-gallery:latest + container_name: files-gallery + restart: 'unless-stopped' + hostname: files.gallery.local # not used, but without it apache will complain about unknown fqdn on startup + ports: + - "8080:80" + volumes: + - ./config:/config + - ./media:/media + +volumes: + config: + media: diff --git a/src/_filesconfig.php b/src/_filesconfig.php new file mode 100644 index 0000000..62bf924 --- /dev/null +++ b/src/_filesconfig.php @@ -0,0 +1,79 @@ + '/media', + //'start_path' => false, + //'username' => '', + //'password' => '', + //'load_images' => true, + //'load_files_proxy_php' => false, + //'load_images_max_filesize' => 1000000, + //'image_resize_enabled' => true, + //'image_resize_cache' => true, + //'image_resize_dimensions' => 320, + //'image_resize_dimensions_retina' => 480, + //'image_resize_dimensions_allowed' => '', + //'image_resize_types' => 'jpeg, png, gif, webp, bmp', + //'image_resize_quality' => 85, + //'image_resize_function' => 'imagecopyresampled', + //'image_resize_sharpen' => true, + //'image_resize_memory_limit' => 128, + //'image_resize_max_pixels' => 30000000, + //'image_resize_min_ratio' => 1.5, + //'image_resize_cache_direct' => false, + //'folder_preview_image' => true, + //'folder_preview_default' => '_filespreview.jpg', + //'menu_enabled' => true, + //'menu_show' => true, + //'menu_max_depth' => 5, + //'menu_sort' => 'name_asc', + //'menu_cache_validate' => true, + //'menu_load_all' => false, + //'menu_recursive_symlinks' => true, + //'layout' => 'rows', + //'sort' => 'name_asc', + //'sort_dirs_first' => true, + //'sort_function' => 'locale', + //'cache' => true, + //'cache_key' => 0, + 'storage_path' => '/config', + //'files_exclude' => '', + //'dirs_exclude' => '', + //'allow_symlinks' => true, + //'title' => '%name% [%count%]', + //'history' => true, + //'transitions' => true, + //'click' => 'popup', + //'click_window' => '', + //'click_window_popup' => true, + //'code_max_load' => 100000, + //'topbar_sticky' => 'scroll', + //'check_updates' => false, + //'allow_tasks' => false, + //'get_mime_type' => false, + //'context_menu' => true, + //'prevent_right_click' => false, + //'license_key' => '', + //'filter_live' => true, + //'filter_props' => 'name, filetype, mime, features, title', + //'download_dir' => 'zip', + //'download_dir_cache' => 'dir', + //'assets' => '', + //'allow_upload' => false, + //'allow_delete' => false, + //'allow_rename' => false, + //'allow_new_folder' => false, + //'allow_new_file' => false, + //'allow_duplicate' => false, + //'allow_text_edit' => false, + //'demo_mode' => false, + //'upload_allowed_file_types' => '', + //'upload_max_filesize' => 0, + //'upload_exists' => 'increment', + //'popup_video' => true, + //'video_thumbs' => true, + //'video_ffmpeg_path' => 'ffmpeg', + //'lang_default' => 'en', + //'lang_auto' => true, +); \ No newline at end of file diff --git a/src/index.0.5.5.php b/src/index.0.5.5.php new file mode 100644 index 0000000..27f6167 --- /dev/null +++ b/src/index.0.5.5.php @@ -0,0 +1,1938 @@ + '', + 'start_path' => false, + + // login + 'username' => '', + 'password' => '', + + // images + 'load_images' => true, + 'load_files_proxy_php' => false, + 'load_images_max_filesize' => 1000000, + 'image_resize_enabled' => true, + 'image_resize_cache' => true, + 'image_resize_dimensions' => 320, + 'image_resize_dimensions_retina' => 480, + 'image_resize_dimensions_allowed' => '', + 'image_resize_types' => 'jpeg, png, gif, webp, bmp', + 'image_resize_quality' => 85, + 'image_resize_function' => 'imagecopyresampled', + 'image_resize_sharpen' => true, + 'image_resize_memory_limit' => 128, + 'image_resize_max_pixels' => 30000000, + 'image_resize_min_ratio' => 1.5, + 'image_resize_cache_direct' => false, + 'folder_preview_image' => true, + 'folder_preview_default' => '_filespreview.jpg', + + // menu + 'menu_enabled' => true, + 'menu_show' => true, + 'menu_max_depth' => 5, + 'menu_sort' => 'name_asc', + 'menu_cache_validate' => true, + 'menu_load_all' => false, + 'menu_recursive_symlinks' => true, + + // files layout + 'layout' => 'rows', + 'sort' => 'name_asc', + 'sort_dirs_first' => true, + 'sort_function' => 'locale', + + // cache + 'cache' => true, + 'cache_key' => 0, + 'storage_path' => '_files', + + // exclude files directories regex + 'files_exclude' => '', + 'dirs_exclude' => '', + 'allow_symlinks' => true, + + // various + 'title' => '%name% [%count%]', + 'history' => true, + 'transitions' => true, + 'click' => 'popup', + 'click_window' => '', + 'click_window_popup' => true, + 'code_max_load' => 100000, + 'topbar_sticky' => 'scroll', + 'check_updates' => false, + 'allow_tasks' => false, + 'get_mime_type' => false, + 'context_menu' => true, + 'prevent_right_click' => false, + 'license_key' => '', + 'filter_live' => true, + 'filter_props' => 'name, filetype, mime, features, title', + 'download_dir' => 'zip', + 'download_dir_cache' => 'dir', + + // filemanager options + 'allow_upload' => false, + 'allow_delete' => false, + 'allow_rename' => false, + 'allow_new_folder' => false, + 'allow_new_file' => false, + 'allow_duplicate' => false, + 'allow_text_edit' => false, + 'demo_mode' => false, + + // uploader options + 'upload_allowed_file_types' => '', + 'upload_max_filesize' => 0, + 'upload_exists' => 'increment', + + // popup options + 'popup_video' => true, + + // video + 'video_thumbs' => true, + 'video_ffmpeg_path' => 'ffmpeg', + 'video_autoplay' => true, + + // language + 'lang_default' => 'en', + 'lang_auto' => true, + ); + + // config (will popuplate) + public static $config = array(); + + // app vars + static $__dir__ = __DIR__; + static $__file__ = __FILE__; + static $assets; + static $prod = true; + static $version = '0.5.5'; + static $root; + static $doc_root; + static $has_login = false; + static $storage_path; + static $storage_is_within_doc_root = false; + static $storage_config_realpath; + static $storage_config; + static $cache_path; + static $image_resize_cache_direct; + static $image_resize_dimensions_retina = false; + static $dirs_hash = false; + static $local_config_file = '_filesconfig.php'; + static $username = false; + static $password = false; + static $x3_path = false; + + // get config + private function get_config($path) { + if(empty($path) || !file_exists($path)) return array(); + $config = include $path; + return empty($config) || !is_array($config) ? array() : array_map(function($v){ + return is_string($v) ? trim($v) : $v; + }, $config); + } + + // files check system and config [diagnostics] + private function files_check($local_config, $storage_path, $storage_config, $user_config, $user_valid){ + + // display all errors to catch anything unusual + ini_set('display_errors', 1); + ini_set('display_startup_errors', 1); + error_reporting(E_ALL); + + // BASIC DIAGNOSTICS + echo 'Files App check system and config.

Files App ' . config::$version . '

' . (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] . '
' : '') . 'PHP ' . phpversion() . '
' . (isset($_SERVER['SERVER_SOFTWARE']) ? $_SERVER['SERVER_SOFTWARE'] : '') . '

* The following tests are only to help diagnose feature-specific issues.

'; + // prop output helper + function prop($name, $success = 'neutral', $val = false){ + return '
'. $name . ($val ? ': ' . $val . '' : '') . '
'; + } + // filesystem exists/writeable + function exists_writeable($path, $name){ // display additional permissions+owner info only if $path is !writeable + echo file_exists($path) ? prop($name . ' is_writeable ' . (!is_writable($path) ? ' ' . substr(sprintf('%o', fileperms($path)), -4) . ' [owner ' . fileowner($path) . ']' : ''), is_writable($path)) : prop($name . ' "' . $path . '" does not exist', false); + } + exists_writeable(config::$config['root']?:'.', 'root'); + exists_writeable(config::$config['storage_path'], 'storage_path'); + if((file_exists(config::$config['root']) && !is_writable(config::$config['root'])) || (file_exists(config::$config['storage_path']) && !is_writable(config::$config['storage_path']))) exists_writeable(__FILE__, _basename(__FILE__)); + // extension_loaded + if(function_exists('extension_loaded')) foreach (['gd', 'exif'] as $name) echo prop($name, extension_loaded($name)); + // zip + echo prop('ZipArchive', class_exists('ZipArchive')); + // function_exsists + foreach (['mime_content_type', 'finfo_file', 'iptcparse', 'exif_imagetype', 'session_start', 'ini_get', 'exec'] as $name) echo prop($name . '()', function_exists($name)); + // check ffmpeg if exec (else don't check, because could be enabled even if exec() is not) + if(function_exists('exec')) echo prop('ffmpeg', !!get_ffmpeg_path()); + // ini_get + if(function_exists('ini_get')) foreach (['memory_limit', 'file_uploads', 'upload_max_filesize', 'post_max_size', 'max_file_uploads'] as $name) echo prop($name, 'neutral', @ini_get($name)); + + // CONFIG OUTPUT + echo '

Config

'; + // invalid and duplicate arrays + $user_invalid = array_diff_key($user_config, self::$default); + $user_duplicate = array_intersect_assoc($user_valid, self::$default); + + // items + $items = array( + ['arr' => $local_config, 'comment' => "// LOCAL CONFIG\n// " . self::$local_config_file], + ['arr' => $storage_config, 'comment' => "// STORAGE CONFIG\n// " . rtrim($storage_path ?: '', '\/') . '/config/config.php'], + ['arr' => $user_invalid, 'comment' => "// INVALID PARAMS\n// The following custom parameters will be ignored as they are not valid:", 'var' => '$invalid', 'hide' => empty($user_invalid)], + ['arr' => $user_duplicate, 'comment' => "// DUPLICATE DEFAULT PARAMS\n// The following custom parameters will have no effect as they are identical to defaults:", 'var' => '$duplicate', 'hide' => empty($user_duplicate)], + ['arr' => $user_valid, 'comment' => "// USER CONFIG\n// User config parameters.", 'var' => '$user', 'hide' => (empty($local_config) || empty($storage_config)) && empty($user_invalid)], + ['arr' => self::$config, 'comment' => "// CONFIG\n// User parameters merged with default parameters.", 'var' => '$config'], + ['arr' => self::$default, 'comment' => "// DEFAULT CONFIG\n// Default config parameters.", 'var' => '$default'], + //['arr' => array_diff_key(get_class_vars('config'), array_flip(['default', 'config'])), 'comment' => "// STATIC VARS\n// Static app vars.", 'var' => '$static'] + ); + + // loop + $output = ' $props) { + $is_empty = empty($props['arr']); + if(isset($props['hide']) && $props['hide']) continue; + foreach (['username', 'password', 'license_key', 'allow_tasks', '__dir__', '__file__'] as $prop) if(isset($props['arr'][$prop]) && !empty($props['arr'][$prop]) && is_string($props['arr'][$prop])) $props['arr'][$prop] = '***'; + $export = $is_empty ? 'array ()' : var_export($props['arr'], true); + $comment = preg_replace('/\n/', " [" . count($props['arr']) . "]\n", $props['comment'], 1); + $var = isset($props['var']) ? $props['var'] . ' = ' : 'return '; + $output .= PHP_EOL . $comment . PHP_EOL . $var . $export . ';' . PHP_EOL; + } + highlight_string($output . PHP_EOL . ';?>'); + echo '
'; + exit; + } + + // save config + public static function save_config($config = array()){ + $save_config = array_intersect_key(array_replace(self::$storage_config, $config), self::$default); + $export = preg_replace("/ '/", " //'", var_export(array_replace(self::$default, $save_config), true)); + foreach ($save_config as $key => $value) if($value !== self::$default[$key]) $export = str_replace("//'" . $key, "'" . $key, $export); + return @file_put_contents(config::$storage_config_realpath, 'storage_path must be a unique dir.'); + self::$storage_config_realpath = $storage_realpath ? $storage_realpath . '/config/config.php' : false; + self::$storage_config = self::get_config(self::$storage_config_realpath); + + // config + $user_config = array_replace(self::$storage_config, $local_config); + $user_valid = array_intersect_key($user_config, self::$default); + self::$config = array_replace(self::$default, $user_valid); + + // CDN assets + self::$assets = self::$prod ? 'https://cdn.jsdelivr.net/npm/files.photo.gallery@' . self::$version . '/' : ''; + + // root + self::$root = real_path(self::$config['root']); + + // files check with ?check=true + if(get('check')) self::files_check($local_config, $storage_path, self::$storage_config, $user_config, $user_valid); + // if(get('phpinfo')) { phpinfo(); exit; } // check system phpinfo with ?phpinfo=true / disabled for security + + // root does not exist + if($is_doc && !self::$root) error('root dir "' . self::$config['root'] . '" does not exist.'); + + // doc root + self::$doc_root = real_path($_SERVER['DOCUMENT_ROOT']); + + // login credentials + self::$username = self::$config['username']; + self::$password = self::$config['password']; + + // has_login + self::$has_login = self::$username || self::$password ? true : false; + + // $image_cache + $image_cache = self::$config['image_resize_enabled'] && self::$config['image_resize_cache'] && self::$config['load_images'] ? true : false; + + // cache enabled + if($image_cache || self::$config['cache']){ + + // create storage_path + if(empty($storage_realpath)){ + $storage_path = is_string($storage_path) ? rtrim($storage_path, '\/') : false; + if(empty($storage_path)) error('Invalid storage_path parameter.'); + mkdir_or_error($storage_path); + $storage_realpath = real_path($storage_path); + if(empty($storage_realpath)) error("storage_path $storage_path does not exist and can't be created."); + self::$storage_config_realpath = $storage_realpath . '/config/config.php'; // update since it wasn't assigned + } + self::$storage_path = $storage_realpath; + + // storage path is within doc root + if(is_within_docroot(self::$storage_path)) self::$storage_is_within_doc_root = true; + + // cache_path real path + self::$cache_path = self::$storage_path . '/cache'; + + // create storage dirs + if($is_doc){ + $create_dirs = [$storage_realpath . '/config']; + if($image_cache) $create_dirs[] = self::$cache_path . '/images'; + if(self::$config['cache']) array_push($create_dirs, self::$cache_path . '/folders', self::$cache_path . '/menu'); + foreach($create_dirs as $create_dir) mkdir_or_error($create_dir); + } + + // create/update config file, with default parameters commented out. + if($is_doc && self::$storage_config_realpath && (!file_exists(self::$storage_config_realpath) || filemtime(self::$storage_config_realpath) < filemtime(__FILE__))) self::save_config(); + + // image resize cache direct + if(self::$config['image_resize_cache_direct'] && !self::$has_login && self::$config['load_images'] && self::$config['image_resize_cache'] && self::$config['image_resize_enabled'] && self::$storage_is_within_doc_root) self::$image_resize_cache_direct = true; + } + + // root is X3 'content' ? images from X3 resize cache / invalidate X3 cache on filemanager action / X3 license + if(_basename(self::$root) === 'content' && @file_exists(dirname(self::$root) . '/app/x3.inc.php')) { + self::$x3_path = dirname(self::$root); // assign x3_path (for file manager invalidate and image resize paths) + if(!self::$has_login) get_include('plugins/files.x3-login.php'); // optional x3 login plugin + } + + // image_resize_dimensions_retina + if(self::$config['image_resize_dimensions_retina'] && self::$config['image_resize_dimensions_retina'] > self::$config['image_resize_dimensions']) self::$image_resize_dimensions_retina = self::$config['image_resize_dimensions_retina']; + + // dirs hash + self::$dirs_hash = substr(md5(self::$doc_root . self::$__dir__ . self::$root . self::$version . self::$config['cache_key'] . self::$image_resize_cache_direct . self::$config['files_exclude'] . self::$config['dirs_exclude']), 0, 6); + + // login + if(self::$has_login) check_login($is_doc); + } +}; + +// login page +function login_page($is_login_attempt, $sidx, $is_logout, $client_hash){ +?> + + + + + + + Login + + + +
+ + += 5.5 && !password_needs_rehash(config::$password, PASSWORD_DEFAULT) ? password_verify($fpassword, config::$password) : ($fpassword == config::$password || md5($fpassword) == config::$password)) && + $_POST['client_hash'] === $client_hash && + $_POST['sidx'] === $sidx + ){ + $_SESSION['login'] = $login_hash; + + // display login page and exit + } else { + login_page($is_login_attempt, $sidx, $is_logout, $client_hash); + } + + // not logged in (images or post API requests), don't show form. + } else if(post('action')){ + json_error('login'); + + } else { + error('You are not logged in.', 401); + } + } +} + +// +function mkdir_or_error($path){ + if(!file_exists($path) && !mkdir($path, 0777, true)) error('Failed to create ' . $path, 500); +} +function _basename($path){ + return basename($path); // because setlocale(LC_ALL,'en_US.UTF-8') + // OPTIONAL: replace basename() which may fail on UTF-8 chars if locale != UTF8 + // $arr = explode('/', str_replace('\\', '/', $path)); + // return end($arr); +} +function real_path($path){ + $real_path = realpath($path); + return $real_path ? str_replace('\\', '/', $real_path) : false; +} +function root_relative($dir){ + return ltrim(substr($dir, strlen(config::$root)), '\/'); +} +function root_absolute($dir){ + return config::$root . ($dir ? '/' . $dir : ''); +} +function is_within_path($path, $root){ + return strpos($path . '/', $root . '/') === 0; +} +function is_within_root($path){ + return is_within_path($path, config::$root); +} +function is_within_docroot($path){ + return is_within_path($path, config::$doc_root); +} +function get_folders_cache_path($name){ + return config::$cache_path . '/folders/' . $name . '.json'; +} +function get_json_cache_url($name){ + $file = get_folders_cache_path($name); + return file_exists($file) ? get_url_path($file) : false; +} +function get_dir_cache_path($dir, $mtime = false){ + if(!config::$config['cache'] || !$dir) return; + return get_folders_cache_path(get_dir_cache_hash($dir, $mtime)); +} +function get_dir_cache_hash($dir, $mtime = false){ + return config::$dirs_hash . '.' . substr(md5($dir), 0, 6) . '.' . ($mtime ?: filemtime($dir)); +} +function header_memory_time(){ + return (isset($_SERVER['REQUEST_TIME_FLOAT']) ? round(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], 3) . 's, ' : '') . round(memory_get_peak_usage() / 1048576, 1) . 'M'; +} + +// read file +// todo: add files-date header +function read_file($path, $mime = false, $msg = false, $props = false, $cache_headers = false, $clone = false){ + if(!$path || !file_exists($path)) return false; + $cloned = $clone && @copy($path, $clone) ? true : false; + //if($mime == 'image/svg') $mime .= '+xml'; + header('content-type: ' . ($mime ?: 'image/jpeg')); + header('content-length: ' . filesize($path)); + header('content-disposition: filename="' . _basename($path) . '"'); + if($msg) header('files-msg: ' . $msg . ($cloned ? ' [cloned to ' . _basename($clone) . ']' : '') . ' [' . ($props ? $props . ', ' : '') . header_memory_time() . ']'); + if($cache_headers) set_cache_headers(); + if(!is_readable($path) || readfile($path) === false) error('Failed to read file ' . $path . '.', 400); + exit; +} + +// get mime +function get_mime($path){ + if(function_exists('mime_content_type')){ + return mime_content_type($path); + } else { + return function_exists('finfo_file') ? finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path) : false; + } +} + +// set cache headers +function set_cache_headers(){ + $seconds = 31536000; // 1 year; + header('expires: ' . gmdate('D, d M Y H:i:s', time() + $seconds) . ' GMT'); + header("cache-control: public, max-age=$seconds, s-maxage=$seconds, immutable"); + header('pragma: cache'); + // header("Last-Modified:" . gmdate('D, d M Y H:i:s', time() - $seconds) . ' GMT'); + // etag? +} + +// get image cache path +function get_image_cache_path($path, $image_resize_dimensions, $filesize, $filemtime){ + return config::$cache_path . '/images/' . substr(md5($path), 0, 6) . '.' . $filesize . '.' . $filemtime . '.' . $image_resize_dimensions . '.jpg'; +} + +// is exclude +function is_exclude($path = false, $is_dir = true, $symlinked = false){ + + // early exit + if(!$path || $path === config::$root) return; + + // exclude all root-relative paths that start with /_files* (reserved for any files and folders to be ignored and hidden from Files app) + if(strpos('/' . root_relative($path), '/_files') !== false) return true; + + // exclude files PHP application + if($path === config::$__file__) return true; + + // symlinks not allowed + if($symlinked && !config::$config['allow_symlinks']) return true; + + // exclude storage path + if(config::$storage_path && is_within_path($path, config::$storage_path)) return true; + + // dirs_exclude: check root relative dir path + if(config::$config['dirs_exclude']) { + $dirname = $is_dir ? $path : dirname($path); + if($dirname !== config::$root && preg_match(config::$config['dirs_exclude'], substr($dirname, strlen(config::$root)))) return true; + } + + // files_exclude: check vs basename + if(!$is_dir){ + $basename = _basename($path); + if($basename === config::$local_config_file) return true; + if(config::$config['files_exclude'] && preg_match(config::$config['files_exclude'], $basename)) return true; + } +} + +// valid root path +function valid_root_path($path, $is_dir = false){ + + // invalid + if($path === false) return; + if(!$is_dir && empty($path)) return; // path cannot be empty if file + if($path && substr($path, -1) == '/') return; // path should never be root absolute or end with / + + // absolute path may differ if path contains symlink + $root_absolute = root_absolute($path); + $real_path = real_path($root_absolute); + + // file does not exist + if(!$real_path) return; + + // security checks if path contains symlink + if($root_absolute !== $real_path) { + if(strpos(($is_dir ? $path : dirname($path)), ':') !== false) return; // dir may not contain ':' + if(strpos($path, '..') !== false) return; // path may not contain '..' + if(is_exclude($root_absolute, $is_dir, true)) return; + } + + // nope + if(!is_readable($real_path)) return; // not readable + if($is_dir && !is_dir($real_path)) return; // dir check + if(!$is_dir && !is_file($real_path)) return; // file check + if(is_exclude($real_path, $is_dir)) return; // exclude path + + // return root_absolute + return $root_absolute; +} + +// image create from +function image_create_from($path, $type){ + if(!$path || !$type) return; + if($type === IMAGETYPE_JPEG){ + return imagecreatefromjpeg($path); + } else if ($type === IMAGETYPE_PNG) { + return imagecreatefrompng($path); + } else if ($type === IMAGETYPE_GIF) { + return imagecreatefromgif($path); + } else if ($type === 18/*IMAGETYPE_WEBP*/) { + if(version_compare(PHP_VERSION, '5.4.0') >= 0) return imagecreatefromwebp($path); + } else if ($type === IMAGETYPE_BMP) { + if(version_compare(PHP_VERSION, '7.2.0') >= 0) return imagecreatefrombmp($path); + } +} + +// get ffmpeg path / check required config items / check exec() / create "quoted" / check exec('ffmpeg -version') +function get_ffmpeg_path(){ + if(!empty(array_filter(['video_thumbs', 'load_images', 'image_resize_cache', 'video_ffmpeg_path'], function($key){ + return empty(config::$config[$key]); + })) || !function_exists('exec')) return false; + //$path = '"' . str_replace('"', '\"', config::$config['video_ffmpeg_path']) . '"'; // <- if path contains Chinese chars + $path = escapeshellarg(config::$config['video_ffmpeg_path']); + return @exec($path . ' -version') ? $path : false; +} + +// get file (proxy or resize image) +function get_file($path, $resize = false){ + + // validate + if(!$path) error('Invalid file request.', 404); + $path = real_path($path); // in case of symlink path + $mime = get_mime($path); // may return false if server does not support mime_content_type() or finfo_file() + + // video thumbnail (FFmpeg) + if($resize == 'video') { + + // requirements with diagnostics / only check $mime if $mime detected + if($mime && strtok($mime, '/') !== 'video') error('' . _basename($path) . ' (' . $mime . ') is not a video.', 415); + + // get cache path + $cache = get_image_cache_path($path, 480, filesize($path), filemtime($path)); + + // check for cached video thumbnail / $path, $mime, $msg, $props, $cache_headers + if($cache) read_file($cache, null, 'Video thumb served from cache', null, true); + + // get FFmpeg path `video_ffmpeg_path` / checks `exec('ffmpeg -version')` + $ffmpeg_path = get_ffmpeg_path(); + if(!$ffmpeg_path) error('FFmpeg disabled. Check your diagnostics.', 400); + + // ffmpeg command + $cmd = $ffmpeg_path . ' -ss 3 -t 1 -hide_banner -i "' . str_replace('"', '\"', $path) . '" -frames:v 1 -an -vf "thumbnail,scale=480:320:force_original_aspect_ratio=increase,crop=480:320" -r 1 -y -f mjpeg "' . $cache . '" 2>&1'; + + // try to execute command + exec($cmd, $output, $result_code); + + // fail if result_code is anything else than 0 + if($result_code) error("Error generating thumbnail for video (\$result_code $result_code)", 400); + + // fix for empty video previews that get created for extremely short videos (or other unknown errors) + if(file_exists($cache) && !filesize($cache) && imagejpeg(imagecreate(1, 1), $cache)) read_file($cache, 'image/jpeg', '1px placeholder image created and cached', null, true); + + // output created video thumbnail + read_file($cache, null, 'Video thumb created', null, true); + + // resize image + } else if($resize){ + if($mime && strtok($mime, '/') !== 'image') error('' . _basename($path) . ' (' . $mime . ') is not an image.', 415); + foreach (['load_images', 'image_resize_enabled'] as $key) if(!config::$config[$key]) error('[' .$key . '] disabled.', 400); + $resize_dimensions = intval($resize); + if(!$resize_dimensions) error("Invalid resize parameter $resize.", 400); + $allowed = config::$config['image_resize_dimensions_allowed'] ?: []; + if(!in_array($resize_dimensions, array_merge([config::$config['image_resize_dimensions'], config::$config['image_resize_dimensions_retina']], array_map('intval', is_array($allowed) ? $allowed : explode(',', $allowed))))) error("Resize parameter $resize_dimensions is not allowed.", 400); + resize_image($path, $resize_dimensions); + + // proxy file + } else { + + // disable if !proxy and path is within document root (file should never be proxied) + if(!config::$config['load_files_proxy_php'] && is_within_docroot($path)) error('File cannot be proxied.', 400); + + // read file / $mime or 'application/octet-stream' + read_file($path, ($mime ?: 'application/octet-stream'), $msg = 'File ' . _basename($path) . ' proxied.', false, true); + } +} + +// sharpen resized image +function sharpen_image($image){ + $matrix = array( + array(-1, -1, -1), + array(-1, 20, -1), + array(-1, -1, -1), + ); + $divisor = array_sum(array_map('array_sum', $matrix)); + $offset = 0; + imageconvolution($image, $matrix, $divisor, $offset); +} + +// exif orientation +// https://github.com/gumlet/php-image-resize/blob/master/lib/ImageResize.php +function exif_orientation($orientation, &$image){ + if(empty($orientation) || !is_numeric($orientation) || $orientation < 3 || $orientation > 8) return; + $image = imagerotate($image, array(6 => 270, 5 => 270, 3 => 180, 4 => 180, 8 => 90, 7 => 90)[$orientation], 0); + if(in_array($orientation, array(5, 4, 7)) && function_exists('imageflip')) imageflip($image, IMG_FLIP_HORIZONTAL); + return true; +} + +// resize image +function resize_image($path, $resize_dimensions, $clone = false){ + + // file size + $file_size = filesize($path); + + // header props + $header_props = 'w:' . $resize_dimensions . ', q:' . config::$config['image_resize_quality'] . ', ' . config::$config['image_resize_function'] . ', cache:' . (config::$config['image_resize_cache'] ? '1' : '0'); + + // cache + $cache = config::$config['image_resize_cache'] ? get_image_cache_path($path, $resize_dimensions, $file_size, filemtime($path)) : NULL; + if($cache) read_file($cache, null, 'Resized image served from cache', $header_props, true, $clone); + + // imagesize + $info = getimagesize($path); + if(empty($info) || !is_array($info)) error('Invalid image / failed getimagesize().', 500); + $resize_ratio = max($info[0], $info[1]) / $resize_dimensions; + + // image_resize_max_pixels early exit + if(config::$config['image_resize_max_pixels'] && $info[0] * $info[1] > config::$config['image_resize_max_pixels']) error('Image resolution ' . $info[0] . ' x ' . $info[1] . ' (' . ($info[0] * $info[1]) . ' px) exceeds image_resize_max_pixels (' . config::$config['image_resize_max_pixels'] . ' px).', 400); + + // header props + $header_props .= ', ' . $info['mime'] . ', ' . $info[0] . 'x' . $info[1] . ', ratio:' . round($resize_ratio, 2); + + // check if image type is in image_resize_types / jpeg, png, gif, webp, bmp + $is_resize_type = in_array(image_type_to_extension($info[2], false), array_map(function($key){ + $type = trim(strtolower($key)); + return $type === 'jpg' ? 'jpeg' : $type; + }, explode(',', config::$config['image_resize_types']))); + + // serve original if !$is_resize_type || resize ratio < image_resize_min_ratio (only if $file_size <= load_images_max_filesize) + //if((!$is_resize_type || $resize_ratio < max(config::$config['image_resize_min_ratio'], 1)) && !read_file($path, $info['mime'], 'Original image served', $header_props, true, $clone)) error('File does not exist.', 404); + if((!$is_resize_type || ($resize_ratio < max(config::$config['image_resize_min_ratio'], 1) && $file_size <= config::$config['load_images_max_filesize'])) && !read_file($path, $info['mime'], 'Original image served', $header_props, true, $clone)) error('File does not exist.', 404); + + // Calculate new image dimensions. + $resize_width = round($info[0] / $resize_ratio); + $resize_height = round($info[1] / $resize_ratio); + + // memory + $memory_limit = config::$config['image_resize_memory_limit'] && function_exists('ini_get') ? (int) @ini_get('memory_limit') : false; + if($memory_limit && $memory_limit > -1){ + // $memory_required = ceil(($info[0] * $info[1] * 4 + $resize_width * $resize_height * 4) / 1048576); + $memory_required = round(($info[0] * $info[1] * (isset($info['bits']) ? $info['bits'] / 8 : 1) * (isset($info['channels']) ? $info['channels'] : 3) * 1.33 + $resize_width * $resize_height * 4) / 1048576, 1); + $new_memory_limit = function_exists('ini_set') ? max($memory_limit, config::$config['image_resize_memory_limit']) : $memory_limit; + if($memory_required > $new_memory_limit) error('Resizing this image requires at least ' . $memory_required . 'M. Your current PHP memory_limit is ' . $new_memory_limit .'M.', 400); + if($memory_limit < $new_memory_limit && @ini_set('memory_limit', $new_memory_limit . 'M')) $header_props .= ', ' . $memory_limit . 'M => ' . $new_memory_limit . 'M (min ' . $memory_required . 'M)'; + } + + // new dimensions headers + $header_props .= ', ' . $resize_width . 'x' . $resize_height; + + // create new $image + $image = image_create_from($path, $info[2]); + if(!$image) error('Failed to create image resource.', 500); + + // Create final image with new dimensions. + $new_image = imagecreatetruecolor($resize_width, $resize_height); + if(!call_user_func(config::$config['image_resize_function'], $new_image, $image, 0, 0, 0, 0, $resize_width, $resize_height, $info[0], $info[1])) error('Failed to resize image.', 500); + + // destroy original $image resource + imagedestroy($image); + + // exif orientation + $exif = function_exists('exif_read_data') ? @exif_read_data($path) : false; + if(!empty($exif) && is_array($exif) && isset($exif['Orientation']) && exif_orientation($exif['Orientation'], $new_image)) $header_props .= ', orientated from EXIF:' . $exif['Orientation']; + + // sharpen resized image + if(config::$config['image_resize_sharpen']) sharpen_image($new_image); + + // save to cache + if($cache){ + if(!imagejpeg($new_image, $cache, config::$config['image_resize_quality'])) error('imagejpeg() failed to create and cache resized image.', 500); + + // clone cache (used for folder previews) + if($clone) @copy($cache, $clone); + + // cache disabled / direct output + } else { + set_cache_headers(); + header('content-type: image/jpeg'); + header('files-msg: Resized image served [' . $header_props . ', ' . header_memory_time() . ']'); + if(!imagejpeg($new_image, null, config::$config['image_resize_quality'])) error('imagejpeg() failed to create and output resized image.', 500); + } + + // destroy image + imagedestroy($new_image); + + // cache readfile + if($cache && !read_file($cache, null, 'Resized image cached and served', $header_props, true, $clone)) error('Cache file does not exist.', 404); + + // + exit; +} + +function get_url_path($dir){ + if(!is_within_docroot($dir)) return false; + + // if in __dir__ path, __dir__ relative + if(is_within_path($dir, config::$__dir__)) return $dir === config::$__dir__ ? '.' : substr($dir, strlen(config::$__dir__) + 1); + + // doc root, doc root relative + return $dir === config::$doc_root ? '/' : substr($dir, strlen(config::$doc_root)); +} + +// get dir +function get_dir($path, $files = false, $json_url = false){ + + // realpath + $realpath = $path ? real_path($path) : false; + if(!$realpath) return; // no real path for any reason + $symlinked = $realpath !== $path; // path is symlinked at some point + + // exclude + if(is_exclude($path, true, $symlinked)) return; // exclude + if($symlinked && is_exclude($realpath, true, $symlinked)) return; // exclude check again symlink realpath + + // vars + $filemtime = filemtime($realpath); + $url_path = get_url_path($realpath) ?: ($symlinked ? get_url_path($path) : false); + $is_readable = is_readable($realpath); + + // array + $arr = array( + 'basename' => _basename($realpath) ?: _basename($path) ?: '', + 'fileperms' => substr(sprintf('%o', fileperms($realpath)), -4), + 'filetype' => 'dir', + 'is_readable' => $is_readable, + 'is_writeable' => is_writeable($realpath), + 'is_link' => $symlinked ? is_link($path) : false, + 'is_dir' => true, + 'mime' => 'directory', + 'mtime' => $filemtime, + 'path' => root_relative($path) + ); + + // url path + if($url_path) $arr['url_path'] = $url_path; + + // get_files() || config::menu_load_all + if($files && $is_readable) { + + // files array + $arr['files'] = get_files_data($path, $url_path, $arr['dirsize'], $arr['files_count'], $arr['images_count'], $arr['preview']); + + // download_dir cache direct access to zip / better caching and no need to access PHP / only works when download_dir_cache === 'dir' + /*if($url_path && config::$config['download_dir'] === 'zip' && config::$config['download_dir_cache'] === 'dir') { + $zip = $realpath . '/_files.zip'; + if(file_exists($zip) && filemtime($zip) >= $filemtime) $arr['zip'] = get_url_path($zip); + }*/ + } + + // json cache path + if($json_url && config::$storage_is_within_doc_root && !config::$has_login && config::$config['cache']){ + $json_cache = get_json_cache_url(get_dir_cache_hash($realpath, $filemtime)); + if($json_cache) $arr['json_cache'] = $json_cache; + } + + // + return $arr; +} + +// get menu sort +function get_menu_sort($dirs){ + if(strpos(config::$config['menu_sort'], 'date') === 0){ + usort($dirs, function($a, $b) { + return filemtime($a) - filemtime($b); + }); + } else { + natcasesort($dirs); + } + return substr(config::$config['menu_sort'], -4) === 'desc' ? array_reverse($dirs) : $dirs; +} + +// recursive directory scan +function get_dirs($path = false, &$arr = array(), $depth = 0) { + + // get this dir (ignore root, unless load all ... root already loaded into page) + if($depth || config::$config['menu_load_all']) { + $data = get_dir($path, config::$config['menu_load_all'], !config::$config['menu_load_all']); + if(!$data) return $arr; + + // + $arr[] = $data; + + // max depth + if(config::$config['menu_max_depth'] && $depth >= config::$config['menu_max_depth']) return $arr; + + // don't recursive if symlink + if($data['is_link'] && !config::$config['menu_recursive_symlinks']) return $arr; + } + + // get dirs from files array if $data['files'] or glob subdirs + $subdirs = isset($data['files']) ? array_filter(array_map(function($file){ + return $file['filetype'] === 'dir' ? root_absolute($file['path']) : false; + }, $data['files'])) : glob($path . '/*', GLOB_NOSORT|GLOB_ONLYDIR); + + // sort and loop subdirs + if(!empty($subdirs)) foreach(get_menu_sort($subdirs) as $subdir) get_dirs($subdir, $arr, $depth + 1); + + // return + return $arr; +} + +// encode to UTF-8 when required +function safe_iptc_tag($val){ + $val = @substr($val, 0, 1000); + return @mb_detect_encoding($val, 'UTF-8', true) ? $val : @utf8_encode($val); +} + +// get IPTC +function get_iptc($image_info){ + if(!$image_info || !isset($image_info['APP13']) || !function_exists('iptcparse')) return; + $app13 = @iptcparse($image_info['APP13']); + if(empty($app13)) return; + $iptc = array(); + + // loop title, headline, description, creator, credit, copyright, keywords, city, sub-location and province-state + foreach (['title'=>'005', 'headline'=>'105', 'description'=>'120', 'creator'=>'080', 'credit'=>'110', 'copyright'=>'116', 'keywords'=>'025', 'city'=>'090', 'sub-location'=>'092', 'province-state'=>'095'] as $name => $code) { + if(isset($app13['2#' . $code][0]) && !empty($app13['2#' . $code][0])) $iptc[$name] = $name === 'keywords' ? $app13['2#' . $code] : safe_iptc_tag($app13['2#' . $code][0]); + } + + // return IPTC + return $iptc; +} + +// EXIF timestamps always relative to UTC/GMT / Prevent app failure if date strings are malformed +function exif_timestamp($str){ + try { + return (new DateTime($str, new DateTimeZone('UTC')))->getTimestamp(); + } catch (Exception $e) { + return false; + } +} + +// get exif +function get_exif($path){ + if(!function_exists('exif_read_data')) return; + $exif_data = @exif_read_data($path, 'ANY_TAG', 0); + if(empty($exif_data) || !is_array($exif_data)) return; + $exif = array(); + foreach (array('DateTime', 'DateTimeOriginal', 'ExposureTime', 'FNumber', 'FocalLength', 'Make', 'Model', 'Orientation', 'ISOSpeedRatings', 'Software') as $name) { + $val = isset($exif_data[$name]) ? $exif_data[$name] : false; + if($val) $exif[$name] = strpos($name, 'DateTime') === 0 ? exif_timestamp($val) : (is_string($val) ? trim($val) : $val); + } + + // computed ApertureFNumber (f_stop) + if(isset($exif_data['COMPUTED']['ApertureFNumber'])) $exif['ApertureFNumber'] = $exif_data['COMPUTED']['ApertureFNumber']; + + // flash + //if(isset($exif_data['Flash'])) $exif['Flash'] = ($exif_data['Flash'] & 1) != 0; + + // GPS + $exif['gps'] = get_image_location($exif_data); + + // return + return array_filter($exif); +} + +// exif GPS / get_image_location +function get_image_location($exif) { + $arr = array(); + foreach (array('GPSLatitude', 'GPSLongitude') as $key) { + if(!isset($exif[$key]) || !isset($exif[$key.'Ref'])) return false; + $coordinate = $exif[$key]; + if(is_string($coordinate)) $coordinate = array_map('trim', explode(',', $coordinate)); + for ($i = 0; $i < 3; $i++) { + $part = explode('/', $coordinate[$i]); + if (count($part) == 1) { + $coordinate[$i] = $part[0]; + } else if (count($part) == 2) { + if($part[1] == 0) return false; // can't be 0 / invalid GPS + $coordinate[$i] = floatval($part[0])/floatval($part[1]); + } else { + $coordinate[$i] = 0; + } + } + list($degrees, $minutes, $seconds) = $coordinate; + $sign = ($exif[$key.'Ref'] == 'W' || $exif[$key.'Ref'] == 'S') ? -1 : 1; + $arr[] = $sign * ($degrees + $minutes/60 + $seconds/3600); + } + return empty($arr) ? false : $arr; +} + +// +function get_files_data($dir, $url_path = false, &$dirsize = 0, &$files_count = 0, &$images_count = 0, &$preview = false){ + + // scandir + $filenames = scandir($dir, SCANDIR_SORT_NONE); + if(empty($filenames)) return array(); + $items = array(); + + // look for folder_preview_default (might be excluded in loop) + if(config::$config['folder_preview_default'] && in_array(config::$config['folder_preview_default'], $filenames)) $preview = config::$config['folder_preview_default']; + + // loop filenames + foreach($filenames as $filename) { + + // + if($filename === '.' || $filename === '..') continue; + $path = $dir . '/' . $filename; + + // paths + $realpath = real_path($path); // differs from $path only if is symlinked + if(!$realpath) continue; // no real path for any reason, for example symlink dead + $symlinked = $realpath !== $path; // path is symlinked at some point + + // filetype + $filetype = filetype($realpath); + $is_dir = $filetype === 'dir' ? true : false; + + // exclude + if(is_exclude($path, $is_dir, $symlinked)) continue; // exclude + if($symlinked && is_exclude($realpath, $is_dir, $symlinked)) continue; // exclude check again symlink realpath + + // vars + if(!$is_dir) $files_count ++; // files count + $is_link = $symlinked ? is_link($path) : false; // symlink + $basename = $is_link ? (_basename($realpath) ?: $filename) : $filename; + $filemtime = filemtime($realpath); + $is_readable = is_readable($realpath); + $filesize = $is_dir ? false : filesize($realpath); + if($filesize) $dirsize += $filesize; + + // url_path / symlink + $item_url_path = $symlinked ? get_url_path($realpath) : false; // url_path from realpath if symlinked + if(!$item_url_path && $url_path) $item_url_path = $url_path . ($url_path === '/' ? '' : '/') . ($is_link ? _basename($path) : $basename); + + // root path // path relative to config::$root + if(!$symlinked || is_within_root($realpath)){ + $root_path = root_relative($realpath); + + // path is symlinked and !is_within_root(), get path-relative + } else { + + // root path to symlink + $root_path = root_relative($path); + + // check for symlink loop + if($is_link && $is_dir && $path && $root_path) { + $basename_path = _basename($root_path); + if($basename_path && preg_match('/(\/|^)' . $basename_path. '\//', $root_path)){ + $loop_path = ''; + $segments = explode('/', $root_path); + array_pop($segments); + foreach ($segments as $segment) { + $loop_path .= ($loop_path ? '/' : '') . $segment; + if($segment !== $basename_path) continue; + $loop_abs_path = root_absolute($loop_path); + if(!is_link($loop_abs_path) || $realpath !== real_path($loop_abs_path)) continue; + $root_path = $loop_path; + $item_url_path = get_url_path($loop_abs_path) ?: $item_url_path; // new symlink is within doc_root + break; + } + } + } + } + + // add properties + $item = array( + 'basename' => $basename, + 'fileperms' => substr(sprintf('%o', fileperms($realpath)), -4), + 'filetype' => $filetype, + 'filesize' => $filesize, + 'is_readable' => $is_readable, + 'is_writeable' => is_writeable($realpath), + 'is_link' => $is_link, + 'is_dir' => $is_dir, + 'mtime' => $filemtime, + 'path' => $root_path + ); + + // optional props + $ext = !$is_dir ? substr(strrchr($realpath, '.'), 1) : false; + if($ext) { + $ext = strtolower($ext); + $item['ext'] = $ext; + } + $mime = $is_dir ? 'directory' : ($is_readable && (!$ext || $ext === 'ts' || config::$config['get_mime_type']) ? get_mime($realpath) : false); + if($mime) $item['mime'] = $mime; + if($item_url_path) $item['url_path'] = $item_url_path; + + // image / check from mime, fallback to extension + $is_image = $is_dir ? false : ($mime ? (strtok($mime, '/') === 'image' && !strpos($mime, 'svg')) : in_array($ext, array('gif','jpg','jpeg','jpc','jp2','jpx','jb2','png','swf','psd','bmp','tiff','tif','wbmp','xbm','ico','webp'))); + if($is_image){ + + // imagesize + $imagesize = $is_readable ? @getimagesize($realpath, $info) : false; + + // image count and icon + $images_count ++; + $item['icon'] = 'image'; + + // is imagesize + if(!empty($imagesize) && is_array($imagesize)){ + + // set folder_preview + if(!$preview && in_array($ext, array('gif','jpg','jpeg','png'))) $preview = $basename; + + // start image array + $image = array(); + foreach (array(0 => 'width', 1 => 'height', 2 => 'type', 'bits' => 'bits', 'channels' => 'channels', 'mime' => 'mime') as $key => $name) if(isset($imagesize[$key])) $image[$name] = $imagesize[$key]; + + // mime from image + if(!$mime && isset($image['mime'])) $item['mime'] = $image['mime']; + + // IPTC + $iptc = $info ? get_iptc($info) : false; + if(!empty($iptc)) $image['iptc'] = $iptc; + + // EXIF + $exif = get_exif($realpath); + if(!empty($exif)) { + $image['exif'] = $exif; + if(isset($exif['DateTimeOriginal'])) $item['DateTimeOriginal'] = $exif['DateTimeOriginal']; + // invert width/height if exif orientation + if(isset($exif['Orientation']) && $exif['Orientation'] > 4 && $exif['Orientation'] < 9){ + $image['width'] = $imagesize[1]; + $image['height'] = $imagesize[0]; + } + } + + // panorama equirectangular if w/h === 2 / find resized panoramas '_files_{size}_{filename.jpg}' + if($item_url_path && $imagesize[0] && $imagesize[0] > 2048 && $imagesize[0]/$imagesize[1] === 2){ + $panorama_resized = []; + // check for resizes if resize >= original + foreach ([2048, 4096, 8192] as $resize) { + if($resize >= $imagesize[0]) break; + if(file_exists($dir . '/_files_' . $resize . '_' . $filename)) $panorama_resized[] = $resize; + } + if(!empty($panorama_resized)) $item['panorama_resized'] = array_reverse($panorama_resized); + } + + // image resize cache direct + if(config::$image_resize_cache_direct){ + $resize1 = get_image_cache_path($realpath, config::$config['image_resize_dimensions'], $filesize, $filemtime); + if(file_exists($resize1)) $image['resize' . config::$config['image_resize_dimensions']] = get_url_path($resize1); + $retina = config::$image_resize_dimensions_retina; + if($retina){ + $resize2 = get_image_cache_path($realpath, $retina, $filesize, $filemtime); + if(file_exists($resize2)) $image['resize' . $retina] = get_url_path($resize2); + } + } + + // add image to item + $item['image'] = $image; + + // get real mime if getimagesize fails. Could be non-image disguised as image extension + } else if($is_readable && !$mime){ + $mime = get_mime($realpath); + if($mime) { + $item['mime'] = $mime; + if(strtok($mime, '/') !== 'image'){ // unset images_count and icon because is not image after all + $images_count --; + unset($item['icon']); + } + } + } + } + + // add to items with basename as key + $items[$basename] = $item; + } + + // Sort dirs on top and natural case sort / sorts in JS anyway, but improves performance if stored in json cache + uasort($items, function($a, $b){ + if(!config::$config['sort_dirs_first'] || $a['is_dir'] === $b['is_dir']) return strnatcasecmp($a['basename'], $b['basename']); + return $b['is_dir'] ? 1 : -1; + }); + + // + //var_dump($items); exit; + return $items; +} + +// get files +function get_files($dir){ + + // invalid $dir + if(!$dir) json_error('Invalid directory'); + + // cache + $cache = get_dir_cache_path(real_path($dir)); + + // read cache or get dir and cache + if(!read_file($cache, 'application/json', 'files json served from cache')) { + json_cache(get_dir($dir, true), 'files json created' . ($cache ? ' and cached' : ''), $cache); + } +} + +/* start here */ +function post($param){ + return isset($_POST[$param]) && !empty($_POST[$param]) ? $_POST[$param] : false; +} +function get($param){ + return isset($_GET[$param]) && !empty($_GET[$param]) ? $_GET[$param] : false; +} +function json_cache($arr = array(), $msg = false, $cache = true){ + $json = empty($arr) ? '{}' : json_encode($arr, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PARTIAL_OUTPUT_ON_ERROR); + if(empty($json)) json_error(json_last_error() ? json_last_error_msg() : 'json_encode() error'); + if($cache) @file_put_contents($cache, $json); + if($msg) header('files-msg: ' . $msg . ' [' . header_memory_time() . ']'); + header('content-type: application/json'); + echo $json; +} +function json_error($error = 'Error'){ + json_exit(array('error' => $error)); +} +function json_success($success = 'Success'){ + json_exit(array('success' => $success)); +} +function json_toggle($success, $error){ + json_exit(array_filter(array('success' => $success, 'error' => empty($success) ? $error : 0))); +} +function json_exit($arr = array()){ + header('content-type: application/json'); + exit(json_encode($arr)); +} +function error($msg, $code = false){ + // 400 Bad Request, 403 Forbidden, 401 Unauthorized, 404 Not Found, 500 Internal Server Error + if($code) http_response_code($code); + header('content-type: text/html'); + header('Expires: ' . gmdate('D, d M Y H:i:s') . ' GMT'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, s-maxage=0'); + header('Cache-Control: post-check=0, pre-check=0', false); + header('Pragma: no-cache'); + exit('

Error

' . $msg); +} + +// get valid menu cache +function get_valid_menu_cache($cache){ + if(!$cache || !file_exists($cache)) return; + $json = @file_get_contents($cache); + if(empty($json)) return; + if(!config::$config['menu_cache_validate']) return $json; + $arr = @json_decode($json, true); + if(empty($arr)) return; + foreach ($arr as $key => $val) { + $path = $val['path']; + if(strpos($path, '/') !== false && $val['mtime'] !== @filemtime(root_absolute($path))) return; // skip shallow 1st level dirs, and compare filemtime + } + return $json; +} + +// get root dirs +function get_root_dirs(){ + $root_dirs = glob(config::$root . '/*', GLOB_ONLYDIR|GLOB_NOSORT); + if(empty($root_dirs)) return array(); + return array_filter($root_dirs, function($dir){ + return !is_exclude($dir, true, is_link($dir)); + }); +} + +// get menu cache hash +function get_menu_cache_hash($root_dirs){ + $mtime_count = filemtime(config::$root); + foreach ($root_dirs as $root_dir) $mtime_count += filemtime($root_dir); + return substr(md5(config::$doc_root . config::$__dir__ . config::$root), 0, 6) . '.' . substr(md5(config::$version . config::$config['cache_key'] . config::$config['menu_max_depth'] . config::$config['menu_load_all'] . (config::$config['menu_load_all'] ? config::$config['files_exclude'] . config::$image_resize_cache_direct : '') . config::$has_login . config::$config['dirs_exclude'] . config::$config['menu_sort']), 0, 6) . '.' . $mtime_count; +} + +// get dirs +function dirs(){ + + // get menu_cache_hash + if(config::$config['cache']){ + $menu_cache_hash = post('menu_cache_hash'); // get menu cache hash + $menu_cache_arr = $menu_cache_hash ? explode('.', $menu_cache_hash) : false; + if(!$menu_cache_arr || + count($menu_cache_arr) !== 3 || + strlen($menu_cache_arr[0]) !== 6 || + strlen($menu_cache_arr[1]) !== 6 || + !is_numeric($menu_cache_arr[2]) + ) json_error('Invalid menu cache hash'); // early exit + } + $cache = config::$config['cache'] ? config::$cache_path . '/menu/' . $menu_cache_hash . '.json' : false; // get cache path + $json = $cache ? get_valid_menu_cache($cache) : false; // get valid json menu cache + + // $json is valid from menu cache file + if($json){ + header('content-type: application/json'); + header('files-msg: valid menu cache hash [' . $menu_cache_hash . ']' . (!config::$config['menu_cache_validate'] ? '[deep validation disabled]' : '') . '[' . header_memory_time() . ']'); + echo (post('localstorage') ? '{"localstorage":"1"}' : $json); + + // reload dirs + } else { + json_cache(get_dirs(config::$root), 'dirs reloaded' . ($cache ? ' and cached.' : ' [cache disabled]'), $cache); + } +} + +// include file html, php, css, js +function get_include($file){ + if(!config::$storage_path) return; + $path = config::$storage_path . '/' . $file; + if(!file_exists($path)) return; + $ext = pathinfo($path, PATHINFO_EXTENSION); + if(in_array($ext, ['html', 'php'])) return include $path; + if(!config::$storage_is_within_doc_root) return; + $src = get_url_path($path) . '?' . filemtime($path); + if($ext === 'js') echo ''; + if($ext === 'css') echo ''; +} + +// POST +if(post('action')){ + + // post action + $action = post('action'); + + // + new config(); + + // filemanager actions [beta] + if($action === 'fm') { + + // validate task + $task = post('task'); + if(empty($task) || !isset(config::$config['allow_' . $task]) || !config::$config['allow_' . $task]) json_error('invalid task'); + // demo_mode + if(config::$config['demo_mode']) json_error('Action not allowed in demo mode'); + + // valid path / path must be inside assigned root + $is_dir = post('is_dir'); + $post_path = post('path') ?: ''; + $path = valid_root_path($post_path, $is_dir); + if(empty($path)) json_error('invalid path ' . $post_path); + $path = real_path($path); // in case of symlink path + + // name_is_allowed / trim name, fail if empty or dodgy characters, mkfile, mkdir, rename, duplicate + function name_is_allowed($name){ + $name = $name ? trim($name) : false; // trim + // block empty / <>:"'/\|?*# chars / .. / endswith . + if(empty($name) || preg_match('/[<>:"\'\/\\\|?*#]|\.\.|\.$/', $name)) json_error('invalid name ' . $name); + return $name; // return valid trimmed name + } + + // filemanager json_toggle + function fm_json_toggle($success, $error){ + fm_json_exit($success, array_filter(array('success' => $success, 'error' => empty($success) ? $error : 0))); + } + // filemanager json_exit / includes feature to invalidate X3 cache if x3-plugin active + function fm_json_exit($success, $arr){ + $x3 = config::$x3_path && $success ? config::$x3_path . '/app/x3.inc.php' : false; + if($x3 && @file_exists($x3) && @is_writable($x3)) touch($x3); + json_exit($arr); + } + + // UPLOAD + if($task === 'upload'){ + // upload path must be dir + if(!$is_dir) json_error('invalid dir ' . $post_path); + // upload path must be writeable + if(!is_writable($path)) json_error('upload dir ' . $post_path . ' is not writeable'); + // get $_FILES['file'] + $file = isset($_FILES) && isset($_FILES['file']) && is_array($_FILES['file']) ? $_FILES['file'] : false; + // invalid $_FILES['file'] + if(empty($file) || !isset($file['error']) || is_array($file['error'])) json_error('invalid $_FILES[]'); + // PHP meaningful file upload errors / https://www.php.net/manual/en/features.file-upload.errors.php + if($file['error'] !== 0) { + $upload_errors = array( + 1 => 'Uploaded file exceeds upload_max_filesize directive in php.ini', + 2 => 'Uploaded file exceeds MAX_FILE_SIZE directive specified in the HTML form', + 3 => 'The uploaded file was only partially uploaded', + 4 => 'No file was uploaded', + 6 => 'Missing a temporary folder', + 7 => 'Failed to write file to disk.', + 8 => 'A PHP extension stopped the file upload.' + ); + json_error(isset($upload_errors[$file['error']]) ? $upload_errors[$file['error']] : 'unknown error'); + } + // invalid $file['size'] + if(!isset($file['size']) || empty($file['size'])) json_error('invalid file size'); + // $file['size'] must not exceed $config['upload_max_filesize'] + if(config::$config['upload_max_filesize'] && $file['size'] > config::$config['upload_max_filesize']) json_error('File size [' . $file['size'] . '] exceeds upload_max_filesize option [' . config::$config['upload_max_filesize'] . ']'); + // filename + $filename = $file['name']; + // security: slashes are never ever allowed in filenames / always basenamed() but just in case + if(strpos($filename, '/') !== false || strpos($filename, '\\') !== false) json_error('Illegal \slash/ in filename ' . $filename); + // allow only valid file types from config::$config['upload_allowed_file_types'] / 'image/*, .pdf, .mp4' + $allowed_file_types = !empty(config::$config['upload_allowed_file_types']) ? array_filter(array_map('trim', explode(',', config::$config['upload_allowed_file_types']))) : false; + if(!empty($allowed_file_types)){ + $mime = get_mime($file['tmp_name']) ?: $file['type']; // mime from PHP or upload[type] + $ext = strrchr(strtolower($filename), '.'); + $is_valid = false; + // check if extension match || wildcard match mime type image/* + foreach ($allowed_file_types as $allowed_file_type) if($ext === ('.'.ltrim($allowed_file_type, '.')) || fnmatch($allowed_file_type, $mime)) { + $is_valid = true; + break; + } + if(!$is_valid) json_error('invalid file type ' . $filename); + // extra security: check if image is image + if(function_exists('exif_imagetype') && in_array($ext, ['.gif', '.jpeg', '.jpg', '.png', '.swf', '.psd', '.bmp', '.tif', '.tiff', 'webp']) && !@exif_imagetype($file['tmp_name'])) json_error('invalid image type ' . $filename); + } + + // file naming if !overwrite and file exists + if(config::$config['upload_exists'] !== 'overwrite' && file_exists("$path/$filename")){ + + // fail if !increment / 'upload_exists' => 'fail' || false || '' empty + if(config::$config['upload_exists'] !== 'increment') json_error("$filename already exists"); + + // increment filename / 'upload_exists' => 'increment' + $name = pathinfo($filename, PATHINFO_FILENAME); + $ext = pathinfo($filename, PATHINFO_EXTENSION); + $inc = 1; + while(file_exists($path . '/' . $name . '-' . $inc . '.' . $ext)) $inc ++; + $filename = $name . '-' . $inc . '.' . $ext; + } + + // all is well! attempt to move_uploaded_file() + if(@move_uploaded_file($file['tmp_name'], "$path/$filename")) fm_json_exit(true, array( + 'success' => true, + 'filename' => $filename, // return filename in case it was incremented or renamed + 'url' => get_url_path("$path/$filename") // for usage with showLinkToFileUploadResult + )); + + // error if failed to move uploaded file + json_error('failed to move_uploaded_file()'); + + // DELETE + } else if($task === 'delete'){ + + // dir recursive + if($is_dir){ + + // success/fail count + $success = 0; + $fail = 0; + + // recursive rmdir + function rrmdir($dir, &$success, &$fail) { + //global $success, $fail; + if(!is_readable($dir)) return $fail ++; + $files = array_diff(scandir($dir), array('.','..')); + if(!empty($files)) foreach ($files as $file) { + is_dir("$dir/$file") ? rrmdir("$dir/$file", $success, $fail) : (@unlink("$dir/$file") ? $success++ : $fail++); + } + @rmdir($dir) ? $success ++ : $fail ++; + } + + // recursive rmdir start + rrmdir($path, $success, $fail); + + // response with partial success/fail count or error if there is !$success + fm_json_exit($success, array_filter(array('success' => $success, 'fail' => $fail, 'error' => (empty($success) ? 'Failed to delete dir' : 0)))); + + // single file + } else { + fm_json_toggle(@unlink($path), 'PHP unlink() failed'); + } + + // new_folder || new_file + } else if($task === 'new_folder' || $task === 'new_file'){ + if(!$is_dir) json_error('invalid dir ' . $post_path); // parent path must be dir + if(!is_writable($path)) json_error($post_path . ' is not writeable.'); // dir must be writeable + $name = name_is_allowed(post('name')); // trim and check valid + $file_path = $path . '/' . $name; + if(file_exists($file_path)) json_error($name . ' already exists'); + fm_json_toggle($task === 'new_folder' ? @mkdir($file_path) : @touch($file_path), $task . ' failed'); + + // rename $path (file or dir) + } else if($task === 'rename'){ + if(!is_writable($path)) json_error($post_path . ' is not writeable.'); // path must be writeable + $name = name_is_allowed(post('name')); // trim and check valid + $new_path = dirname($path) . '/' . $name; + if(file_exists($new_path)) json_error("$name already exists."); // new name exists + // security: prevent renaming 'file.html' to 'file.php' / file must already be *.php when renaming + if(!$is_dir && stripos($path, '.php') === false && stripos($name, '.php') !== false) json_error('cannot rename files to .php'); + fm_json_toggle(@rename($path, $new_path), 'PHP rename() failed'); + + // duplicate file + } else if($task === 'duplicate'){ + if($is_dir) json_error('Can\'t duplicate dir'); + $parent_dir = dirname($path); + if(!is_writable($parent_dir)) json_error(_basename($parent_dir) . ' is not writeable.'); // dir must be writeable + $name = name_is_allowed(post('name')); // trim and check valid + $copy_path = $parent_dir . '/' . $name; + if(file_exists($copy_path)) json_error($name . ' already exists.'); + fm_json_toggle(@copy($path, $copy_path), 'PHP copy() failed'); + + // text / code edit + } else if($task === 'text_edit'){ + if($is_dir) json_error('Can\'t write text to directory'); + if(!is_writeable($path) || !is_file($path)) json_error('File is not writeable'); + $success = isset($_POST['text']) && @file_put_contents($path, $_POST['text']) !== false ? 1 : 0; // text could be '' (empty) + if($success) @touch(dirname($path)); // invalidate any cache by updating parent dir mtime + fm_json_toggle($success, 'PHP file_put_contents() failed'); + } + + // dirs + } else if($action === 'dirs'){ + dirs(post('localstorage')); + + // files + } else if($action === 'files'){ + if(!isset($_POST['dir'])) json_error('Missing dir parameter'); + get_files(valid_root_path($_POST['dir'], true)); + + // file read + } else if($action === 'file'){ + + // valid path + $file = valid_root_path(post('file')); + if(!$file) error('Invalid file path'); + + // read text file + header('content-type:text/plain;charset=utf-8'); + if(@readfile(real_path($file)) === false) error('failed to read file ' . post('file'), 500); + + // check login + } else if($action === 'check_login'){ + json_success(true); + + // check updates + } else if($action === 'check_updates'){ + $json = @json_decode(@file_get_contents('https://data.jsdelivr.com/v1/package/npm/files.photo.gallery'), true); + $latest = !empty($json) && isset($json['versions'][0]) && version_compare($json['versions'][0], config::$version) > 0 ? $json['versions'][0] : false; + json_exit(array( + 'success' => $latest, + 'writeable' => $latest && is_writable(__FILE__) // only check writeable if $latest + )); + + // do update + } else if($action === 'do_update'){ + $version = post('version'); + if(!$version || version_compare($version, config::$version) <= 0 || !is_writable(__FILE__)) json_error(); // requirements + $get = @file_get_contents('https://cdn.jsdelivr.net/npm/files.photo.gallery@' . $version . '/index.php'); + if(empty($get) || strpos($get, ' @file_put_contents(__FILE__, $get))); + + // store license + } else if($action === 'license'){ + $key = post('key') ? trim(post('key')) : false; + json_exit(array( + 'success' => $key && config::$storage_config_realpath && config::save_config(array('license_key' => $key)), + 'md5' => $key ? md5($key) : false + )); + + // invalid action + } else { + json_error('invalid action: ' . $action); + } + +// GET +} else /*if($_SERVER['REQUEST_METHOD'] === 'GET')*/{ + + // download_dir_zip / download files in directory as zip file + if(get('download_dir_zip')) { + new config(); + + // check download_dir enabled + if(config::$config['download_dir'] !== 'zip') error('download_dir Zip disabled.', 403); + + // valid dir + $dir = valid_root_path(get('download_dir_zip'), true); + if(!$dir) error('Invalid download path ' . get('download_dir_zip') . '', 404); + $dir = real_path($dir); // in case of symlink path + + // create zip cache directly in dir (recommended, so that dir can be renamed while zip cache remains) + if(!config::$storage_path || config::$config['download_dir_cache'] === 'dir') { + if(!is_writable($dir)) error('Dir ' . _basename($dir) . ' is not writeable.', 500); + $zip_file_name = '_files.zip'; + $zip_file = $dir . '/' . $zip_file_name; + + // create zip file in storage _files/zip/$dirname.$md5.zip / + } else { + mkdir_or_error(config::$storage_path . '/zip'); + $zip_file_name = _basename($dir) . '.' . substr(md5($dir), 0, 6) . '.zip'; + $zip_file = config::$storage_path . '/zip/' . $zip_file_name; + } + + // cached / download_dir_cache && file_exists() && zip is not older than dir time + $cached = !empty(config::$config['download_dir_cache']) && file_exists($zip_file) && filemtime($zip_file) >= filemtime($dir); + + // create zip if !cached + if(!$cached){ + + // use shell zip command instead / probably faster and more robust than PHP / if use, comment out PHP ZipArchive method starting below + // exec('zip ' . $zip_file . ' ' . $dir . '/*.* -j -x _files*', $out, $res); + + // check that ZipArchive class exists + if(!class_exists('ZipArchive')) error('Missing PHP ZipArchive class.', 500); + + // glob files / must be readable / is_file / !symlink / !is_exclude + $files = array_filter(glob($dir. '/*', GLOB_NOSORT), function($file){ + return is_readable($file) && is_file($file) && !is_link($file) && !is_exclude($file, false); + }); + + // !no files available to zip + if(empty($files)) error('No files to zip!', 400); + + // new ZipArchive + $zip = new ZipArchive(); + + // create new $zip_file + if($zip->open($zip_file, ZipArchive::CREATE | ZIPARCHIVE::OVERWRITE) !== true) error('Failed to create ZIP file ' . $zip_file_name . '.', 500); + + // add files to zip / flatten with _basename() + foreach($files as $file) $zip->addFile($file, _basename($file)); + + // no files added (for some reason) + if(!$zip->numFiles) error('Could not add any files to ' . $zip_file_name . '.', 500); + + // close zip + $zip->close(); + + // make sure created zip file exists / just in case + if(!file_exists($zip_file)) error('Zip file ' . $zip_file_name . ' does not exist.', 500); + } + + // redirect instead of readfile() / might be useful if readfile() fails and/or for caching and performance + /*$zip_url = get_url_path($zip_file); + if($zip_url){ + header('Location:' . $zip_url . '?' . filemtime($dir), true, 302); + exit; + }*/ + + // output headers + if(config::$has_login) { + header('cache-control: must-revalidate, post-check=0, pre-check=0'); + header('cache-control: public'); + header('expires: 0'); + header('pragma: public'); + } else { + set_cache_headers(); + } + header('content-description: File Transfer'); + header('content-disposition: attachment; filename="' . addslashes(_basename($dir)) . '.zip"'); + $content_length = filesize($zip_file); + header('content-length: ' . $content_length); + header('content-transfer-encoding: binary'); + header('content-type: application/zip'); + header('files-msg: [' . $zip_file_name . '][' . ($cached ? 'cached' : 'created') . ']'); + + // ignore user abort so we can delete file also on download cancel + if(empty(config::$config['download_dir_cache'])) @ignore_user_abort(true); + + // clear output buffer for large files + while (ob_get_level()) ob_end_clean(); + + // output zip readfile() + if(!readfile($zip_file)) error('Failed to readfile(' . $zip_file_name . ').', 500); + + // delete temp zip file if cache disable + if(empty(config::$config['download_dir_cache'])) @unlink($zip_file); + + + // folder preview image + } else if(get('preview')){ + new config(); + + // allow only if only if folder_preview_image + load_images + image_resize_enabled + foreach (['folder_preview_image', 'load_images', 'image_resize_enabled'] as $key) if(!config::$config[$key]) error('[' .$key . '] disabled.', 400); + + // get real path and validate + $path = valid_root_path(get('preview'), true); // make sure is valid dir + if(!$path) error('Invalid directory.', 404); + + + // 1. first check for default '_filespreview.jpg' inside dir + $default = config::$config['folder_preview_default'] ? $path . '/' . config::$config['folder_preview_default'] : false; + if($default && file_exists($default)) { + header('files-preview: folder_preview_default found [' . config::$config['folder_preview_default'] . ']'); + resize_image($default, config::$config['image_resize_dimensions']); + } + + + // 2. check preview cache + $cache = config::$cache_path . '/images/preview.' . substr(md5($path), 0, 6) . '.jpg'; + + // cache file exists + if(file_exists($cache)) { + + // make sure cache file is valid (must be newer than dir updated time) + if(filemtime($cache) >= filemtime($path)) read_file($cache, null, 'preview image served from cache', null, true); + + // delete expired cache file if is older than dir updated time [silent] + @unlink($cache); + } + + + // 3. glob images / GLOB_BRACE may fail on some non GNU systems, like Solaris. + $images = @glob($path . '/*.{jpg,JPG,jpeg,JPEG,png,PNG,gif,GIF}', GLOB_NOSORT|GLOB_BRACE); + + // loop images to locate first match that is not excluded + if(!empty($images)) foreach ($images as $image) { + if(!is_exclude($image, false)) { + header('files-preview: glob() found [' . _basename($image) . ']'); + resize_image($image, config::$config['image_resize_dimensions'], $cache); // + clone into $cache + break; exit; // just in case + } + } + + + // 4. nothing found (no images in dir) + // create empty 1px in $cache, and output (so next check knows dir is empty or has no images, unless updated) + if(imagejpeg(imagecreate(1, 1), $cache)) read_file($cache, 'image/jpeg', '1px placeholder image created and cached', null, true); + + + // file/image + } else if(isset($_GET['file'])){ + new config(); + get_file(valid_root_path(get('file')), get('resize')); + + // download + } else if(isset($_GET['download'])){ + new config(); + + // valid download + $download = valid_root_path(get('download')); + if(!$download) error('Invalid download path ' . get('download') . '', 404); + $download = real_path($download); // in case of symlink path + + // required for some browsers + if(@ini_get('zlib.output_compression')) @ini_set('zlib.output_compression', 'Off'); + + // headers + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . _basename($download) . '"'); + header('Content-Transfer-Encoding: binary'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Pragma: public'); + header('Content-Length: ' . filesize($download)); + while (ob_get_level()) ob_end_clean(); + readfile($download); + + // tasks plugin + } else if(get('task')){ + + // attempt to load tasks plugin + new config(true); + get_include('plugins/files.tasks.php') || error('Can\'t find tasks plugin.', 404); + exit; + +// main document + } else { + +// new config, with tests +new config(true); + +// validate exclude regex +if(config::$config['files_exclude'] && @preg_match(config::$config['files_exclude'], '') === false) error('Invalid files_exclude regex ' . config::$config['files_exclude'] . ''); +if(config::$config['dirs_exclude'] && @preg_match(config::$config['dirs_exclude'], '') === false) error('Invalid dirs_exclude regex ' . config::$config['dirs_exclude'] . ''); + +// start path +$start_path = config::$config['start_path']; +if($start_path){ + $real_start_path = real_path($start_path); + if(!$real_start_path) error('start_path ' . $start_path . ' does not exist.'); + if(!is_within_root($real_start_path)) error('start_path ' . $start_path . ' is not within root dir ' . config::$config['root']); + $start_path = root_relative($real_start_path); +} + +// always get root_dirs for breadcrumbs and menu_cache (if menu_enabled) +$root_dirs = get_root_dirs(); + +// menu_exists if menu_enabled && $root_dirs +$menu_exists = config::$config['menu_enabled'] && !empty($root_dirs) ? true : false; + +// get menu cache hash +$menu_cache_hash = false; +$menu_cache_file = false; +if($menu_exists){ + $menu_cache_hash = get_menu_cache_hash($root_dirs); + // menu cache file (if cache, !menu_cache_validate, exists and is within doc root) + if(config::$storage_is_within_doc_root && config::$config['cache'] && !config::$config['menu_cache_validate']) { + $menu_cache_path = config::$cache_path . '/menu/' . $menu_cache_hash . '.json'; + $menu_cache_file = file_exists($menu_cache_path) ? get_url_path($menu_cache_path) : false; + if($menu_cache_file) $menu_cache_file .= '?' . filemtime($menu_cache_path); + } +} + +// init path +$query = config::$config['history'] && isset($_SERVER['QUERY_STRING']) && !empty($_SERVER['QUERY_STRING']) ? explode('&', $_SERVER['QUERY_STRING']) : false; +$query_path = $query && strpos($query[0], '=') === false ? rtrim(rawurldecode($query[0]), '/') : false; +$query_path_valid = $query_path ? valid_root_path($query_path, true) : false; +$init_path = $query_path ?: $start_path ?: ''; + +// init dirs, with files if cache +function get_dir_init($dir){ + $cache = get_dir_cache_path(real_path($dir)); + if($cache && file_exists($cache)) return json_decode(file_get_contents($cache), true); + return get_dir($dir); +} + +// get dirs for root and start path +$dirs = array('' => get_dir_init(config::$root)); +if($query_path){ + if($query_path_valid) $dirs[$query_path] = get_dir_init($query_path_valid); +} else if($start_path){ + $dirs[$start_path] = get_dir_init($real_start_path); +} + +// resize image types +$resize_image_types = array('jpeg', 'jpg', 'png', 'gif'); +if(version_compare(PHP_VERSION, '5.4.0') >= 0) { + $resize_image_types[] = 'webp'; + if(version_compare(PHP_VERSION, '7.2.0') >= 0) $resize_image_types[] = 'bmp'; +} + +// image resize memory limit / for Javascript detection +$image_resize_memory_limit = config::$config['image_resize_enabled'] && config::$config['image_resize_memory_limit'] && function_exists('ini_get') ? (int) @ini_get('memory_limit') : 0; +if($image_resize_memory_limit && function_exists('ini_set')) $image_resize_memory_limit = max($image_resize_memory_limit, config::$config['image_resize_memory_limit']); + +// wtc +$wtc = config::$config[base64_decode('bGljZW5zZV9rZXk')]; + +// look for custom language files _files/lang/*.json +function lang_custom() { + $dir = config::$storage_path ? config::$storage_path . '/lang' : false; + $files = $dir && file_exists($dir) ? glob($dir . '/*.json') : false; + if(empty($files)) return false; + $langs = array(); + foreach ($files as $path) { + $json = @file_get_contents($path); + $data = !empty($json) ? @json_decode($json, true) : false; + if(!empty($data)) $langs[strtok(_basename($path), '.')] = $data; + } + return !empty($langs) ? $langs : false; +} + +// exclude some user settings from frontend +$exclude = array_diff_key(config::$config, array_flip(array('root', 'start_path', 'image_resize_cache', 'image_resize_quality', 'image_resize_function', 'image_resize_cache_direct', 'menu_sort', 'menu_load_all', 'cache_key', 'storage_path', 'files_exclude', 'dirs_exclude', 'username', 'password', 'allow_tasks', 'allow_symlinks', 'menu_recursive_symlinks', 'image_resize_sharpen', 'get_mime_type', 'license_key', 'video_thumbs', 'video_ffmpeg_path', 'folder_preview_default', 'image_resize_dimensions_allowed', 'download_dir_cache'))); + +// json config +$json_config = array_replace($exclude, array( + 'script' => _basename(__FILE__), + 'menu_exists' => $menu_exists, + 'menu_cache_hash' => $menu_cache_hash, + 'menu_cache_file' => $menu_cache_file, + 'query_path' => $query_path, + 'query_path_valid' => $query_path_valid ? true : false, + 'init_path' => $init_path, + 'dirs' => $dirs, + 'dirs_hash' => config::$dirs_hash, + 'resize_image_types' => $resize_image_types, + 'image_cache_hash' => config::$config['load_images'] ? substr(md5(config::$doc_root . config::$root . config::$config['image_resize_function'] . config::$config['image_resize_quality']), 0, 6) : false, + 'image_resize_dimensions_retina' => config::$image_resize_dimensions_retina, + 'location_hash' => md5(config::$root), + 'has_login' => config::$has_login, + 'version' => config::$version, + 'index_html' => intval(get('index_html')), + 'server_exif' => function_exists('exif_read_data'), + 'image_resize_memory_limit' => $image_resize_memory_limit, + 'qrx' => $wtc && is_string($wtc) ? substr(md5($wtc), 0, strlen($wtc)) : false, + 'video_thumbs_enabled' => !!get_ffmpeg_path(), + 'lang_custom' => lang_custom(), + 'x3_path' => config::$x3_path ? get_url_path(config::$x3_path) : false, + 'assets' => config::$assets +)); + +// calculate bytes from PHP ini settings +function php_directive_value_to_bytes($directive) { + $val = function_exists('ini_get') ? @ini_get($directive) : false; + if (empty($val) || !is_string($val)) return 0; + preg_match('/^(?\d+)(?