0% found this document useful (0 votes)
5 views59 pages

Exp2 PHP

Uploaded by

WebElegan
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
5 views59 pages

Exp2 PHP

Uploaded by

WebElegan
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as TXT, PDF, TXT or read online on Scribd
You are on page 1/ 59

<?

php

/* Files Gallery 0.9.12


www.files.gallery | www.files.gallery/docs/ | www.files.gallery/docs/license/
---
This PHP file is only 10% of the application, used only to connect with the file
system. 90% of the codebase, including app logic, interface, design and layout is
managed by the app Javascript and CSS files.
---
class Config / load config with static methods to access config options
class Login / check and manage logins
class U / static utility functions
class Path / static functions to convert and validate file paths
class Json / JSON response functions
class X3 / helper functions when running Files Gallery alongside X3
www.photo.gallery
class Tests / outputs PHP, server and config diagnostics by url ?
action=tests
class FileResponse / outputs file, video preview image, resized image and proxies
any file by PHP
class ResizeImage / serves a resized image
class Dirs / outputs menu json from dir structure
class Dir / loads data array for a single dir
class File / returns data array for a single file
class Iptc / extract IPTC image data from images
class Exif / extract Exif image data from images
class Filemanager / functions that handle file operations on server
class Zipper / create and extract zip files
class Request / extract parameters for all actions
class Document / creates the main Files Gallery document response
*/

// class Config / constructor and static methods to access config options


class Config {

// config defaults / https://www.files.gallery/docs/config/


// Only edit directly here if it is a temporary installation. Settings here will
be lost when updating!
// Instead, add options into external config file in your storage_path
_files/config/config.php (generated on first run)
private static $default = [
'root' => '',
'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' => 256,
'image_resize_max_pixels' => 60000000,
'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',
'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,
'allow_zip' => false,
'allow_unzip' => false,
'allow_move' => false,
'allow_copy' => false,
'allow_download' => true,
'allow_mass_download' => false,
'allow_mass_copy_links' => false,
'allow_check_updates' => false,
'allow_tests' => true,
'allow_tasks' => 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
];

// global application variables created on new Config()


public static $version = '0.9.12'; // Files Gallery version
public static $config = []; // config array merged from _filesconfig.php,
config.php and default config
public static $localconfigpath = '_filesconfig.php'; // optional config file in
current dir, useful when overriding shared configs
public static $localconfig = []; // config array from localconfigpath
public static $storagepath; // absolute storage path for cache, config,
plugins and more, normally _files dir
public static $storageconfigpath; // absolute path to storage config, normally
_files/config/config.php
public static $storageconfig = []; // config array from storage path, normally
_files/config/config.php
public static $cachepath; // absolute cache path shortcut
public static $__dir__; // absolute __DIR__ path with normalized OS
path
public static $__file__; // absolute __FILE__ path with normalized OS
path
public static $root; // absolute root path interpolated from
config root option, normally current dir
public static $document_root; // absolute server document root with
normalized OS path
public static $has_login; // detect if there application has login
public static $created = []; // checks what dirs and files get created by
config on ?action=tests

// config construct created static app vars and merge configs


public function __construct() {

// get absolute __DIR__ and __FILE__ paths with normalized OS paths


self::$__dir__ = Path::realpath(__DIR__);
self::$__file__ = Path::realpath(__FILE__);

// load local config _filesconfig.php if exists


self::$localconfig = $this->load(self::$localconfigpath);

// create initial config array from default and localconfig


self::$config = array_replace(self::$default, self::$localconfig);

// set absolute storagepath, create storage dirs if required, and load, create
or update storage config.php
$this->storage();

// assign public real root path


self::$root = Path::realpath(self::get('root'));

// error if root path does not exist


if(!self::$root) U::error('root dir <b>' . self::get('root') . '</b> does not
exist');

// storagepath can't be the same as root dir, because storage_path is excluded


if(self::$storagepath === self::$root) U::error('storage_path can\'t be the
same as root');

// get server document root with normalized OS path


self::$document_root = Path::realpath($_SERVER['DOCUMENT_ROOT']);

// assign public $has_login if username or password or X3 login (plugin)


self::$has_login = self::get('username') || self::get('password') ||
X3::login();
}

// public shortcut function to get config option Config::get('option')


public static function get($option){
return self::$config[$option];
}

// load a config file and trim values / returns empty array if file doesn't exist
private function load($path) {
if(empty($path) || !file_exists($path)) return [];
$config = include $path;
if(empty($config) || !is_array($config)) return [];
return array_map(function($v){
return is_string($v) ? trim($v) : $v;
}, $config);
}

// set storagepath from config, create dir if necessary


private function storage(){

// ignore storagepath and disable cache settings if storage_path is


specifically set to FALSE
if(self::get('storage_path') === false) {
foreach (['cache', 'image_resize_cache', 'folder_preview_image'] as $key)
self::$config[$key] = false;
return;
}

// shortcut to config storage_path


$path = rtrim(self::get('storage_path'), '\/');

// invalid config storage_path can't be empty or non-string


if(!$path || !is_string($path)) U::error('Invalid storage_path parameter');

// get request ?action if any, to determine if we attempt to make dirs and


files on config construct
$action = U::get('action');

// if ?action=tests, check what dirs and files will get created, for tests
output
if($action === 'tests') {
foreach (['', '/config', '/config/config.php', '/cache/images',
'/cache/folders', '/cache/menu'] as $key) {
if(!file_exists($path . $key)) self::$created[] = $path . $key;
}
}

// only make dirs and config if main document (no ?action, except action tests)
$make = !$action || $action === 'tests';
// make storage path dir if it doesn't exist or return error
if($make) U::mkdir($path);

// store absolute storagepath


self::$storagepath = Path::realpath($path);

// error in case storagepath still doesn't seem to exist from realpath()


if(!self::$storagepath) U::error('storage_path does not exist and can\'t be
created');

// absolute cache path shortcut


self::$cachepath = self::$storagepath . '/cache';

// assign storage config path (normally */_files/config/config.php), from where


we load config and save options
self::$storageconfigpath = self::$storagepath . '/config/config.php';

// load storage config (normally _files/config/config.php) or return empty


array
self::$storageconfig = $this->load(self::$storageconfigpath);

// if storage config is not empty, update config by merging default,


storageconfig and localconfig
if(!empty(self::$storageconfig)) self::$config = array_replace(self::$default,
self::$storageconfig, self::$localconfig);

// only make storage dirs and config.php if main document or ?action=tests


if(!$make) return;

// create required storage dirs if they don't exist / error on fail


foreach (['config', 'cache/images', 'cache/folders', 'cache/menu'] as $dir)
U::mkdir(self::$storagepath . '/' . $dir);

// create or update config file if older than index.php


if(!file_exists(self::$storageconfigpath) || filemtime(self::
$storageconfigpath) < filemtime(__FILE__)) self::save();
}

// save to config.php in storagepath (normally _files/config/config.php) or


create new config.php if file doesn't exist
public static function save($options = []){

// merge array of parameters with current storageconfig, and intersect with


default, to remove invalida/outdated options
$save = array_intersect_key(array_replace(self::$storageconfig, $options),
self::$default);

// create exported array string with save values merged into default values,
all commented out
$export = preg_replace("/ '/", " //'", var_export(array_replace(self::
$default, $save), true));

// loop save options and un-comment options where values differ from default
options (for convenience, only store differences)
foreach ($save as $key => $value) if($value !== self::$default[$key]) $export =
str_replace("//'" . $key, "'" . $key, $export);

// write formatted config array to config (normally _files/config/config.php)


return @file_put_contents(self::$storageconfigpath, '<?php ' . PHP_EOL .
PHP_EOL . '// CONFIG / https://www.files.gallery/docs/config/' . PHP_EOL . '//
Uncomment the parameters you want to edit.' . PHP_EOL . 'return ' . $export . ';');
}
}

// class Login / check and manage logins


class Login {

// vars
private $username; // config username
private $password; // config password
private $is_logout; // is_logout gets assigned when user logs out
private $client_hash; // hash unique to client and install location, must match
on login from form
private $login_hash; // unique hash for $_SESSION['login']
private $sidmd5; // encrypted session ID to compare on login

// start new login check


public function __construct() {

// assign $username and $password shortcuts from config


$this->username = Config::get('username');
$this->password = Config::get('password');

// make sure username or password is not empty


foreach (['username', 'password'] as $key) if(!$this->{$key}) U::error("$key
can't be empty");

// PHP session_start() or error


if(session_status() === PHP_SESSION_NONE && !session_start()) U::error('Failed
to initiate PHP session_start()', 500);

// create a unique client hash specific to install location, must match on


login from login form
$this->client_hash = md5($this->ip() . $this->server('HTTP_USER_AGENT') .
__FILE__ . $this->server('HTTP_HOST'));

// create a unique login hash used for $_SESSION['login']


$this->login_hash = md5($this->username . $this->password . $this-
>client_hash);

// return to app if user is already logged in


if($this->is_logged_in()) return;

// exit with error on ?action requests (is not login attempt, and don't show
login form)
if($this->unauthorized()) return;

// get md5() hashed version of session ID, to compare from login form on login
$this->sidmd5 = md5(session_id());

// verify login (may or may not be login attempt) or show form


if(!$this->verify_login()) return $this->form();

// on successful login, store $_SESSION['login'] as login_hash


return $_SESSION['login'] = $this->login_hash;
}

// get client IP for unique client_hash


private function ip(){

foreach(['HTTP_CLIENT_IP','HTTP_X_FORWARDED_FOR','HTTP_X_FORWARDED','HTTP_FORWARDED
_FOR','HTTP_FORWARDED','REMOTE_ADDR'] as $key){
$ip = explode(',', $this->server($key))[0];
if($ip && filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
return ''; // return empty string if nothing found
}

// get $_SERVER parameters or empty string


private function server($str){
return isset($_SERVER[$str]) ? $_SERVER[$str] : '';
}

// check if user session is already logged in


private function is_logged_in(){

// false if session does not match


if(!isset($_SESSION['login']) || $_SESSION['login'] != $this->login_hash)
return;

// logged in, if action is not logout


if(!U::get('logout')) return true;

// logout action, return false


$this->is_logout = true;
unset($_SESSION['login']);
return;
}

// exit with error on ?action request (is not login attempt, and don't show login
form)
private function unauthorized(){

// return to verify login if !action or action is tests


if(!U::get('action') || U::get('action') === 'tests') return;

// return json error if request is POST


if($_SERVER['REQUEST_METHOD'] === 'POST') return Json::error('login');

// login error with login link


U::error('Please <a href="' . strtok($_SERVER['REQUEST_URI'], '?') .
'">login</a> to continue', 401);
}

// verify a login attempt


private function verify_login(){

// false if client_hash from form does not match


if(U::post('client_hash') != $this->client_hash) return;

// false if sidmd5 from form does not match


if(U::post('sidmd5') != $this->sidmd5) return;

// false if username from form does not match


if($this->lower(trim(U::post('fusername'))) != $this->lower($this->username))
return;
// trim form password
$fpassword = trim(U::post('fpassword'));

// use password_verify() to verify, if password is encrypted with


password_hash() (most secure)
if(function_exists('password_needs_rehash') && !password_needs_rehash($this-
>password, PASSWORD_DEFAULT)) return password_verify($fpassword, $this->password);

// verify vs non-encrypted password or if password was stored with md5


return $fpassword === $this->password || md5($fpassword) === $this->password;
}

// lowercase username for case-insensitive username validation uses


mb_strtolower() if function exists
private function lower($str){
return function_exists('mb_strtolower') ? mb_strtolower($str) :
strtolower($str);
}

// login page / output form html and exit


private function form($login = false) {

// get login form page header


U::html_header('Login', 'page-login');

// login page html / block basic bots by injecting form via javascript
?><body class="page-login-body">
<article class="login-container"></article>
</body>
<script>
document.querySelector('.login-container').innerHTML = '\
<h1>Login</h1>\
<?php echo $this->form_alert(); ?>
<form class="login-form">\
<input type="text" class="input" name="fusername" placeholder="Username"
required autofocus spellcheck="false" autocorrect="off" autocapitalize="off"
autocomplete="off">\
<input type="password" class="input" name="fpassword"
placeholder="Password" required spellcheck="false" autocomplete="off">\
<input type="hidden" name="client_hash" value="<?php echo $this-
>client_hash; ?>">\
<input type="hidden" name="sidmd5" value="<?php echo $this->sidmd5; ?>">\
<button type="submit" class="button">Login</button>\
</form>';
document.querySelector('.login-form').addEventListener('submit', (e) => {
document.body.classList.add('form-loading');
e.currentTarget.action = '<?php echo U::get('logout') ?
strtok($_SERVER['REQUEST_URI'], '?') : $_SERVER['REQUEST_URI']; ?>';
e.currentTarget.method = 'post';
}, false);
</script>
</html><?php exit; // end form and exit
}

// outputs an alert in login form on logout, incorrect login or session ID


mismatch
private function form_alert(){

// logout alert if was logout operation


if($this->is_logout) return '<div class="alert alert-warning" role="alert">You
are now logged out</div>';

// no alert if is not a login attempt


if(!U::post('sidmd5')) return '';

// sidmd5 does not match


if(U::post('sidmd5') !== $this->sidmd5) return '<div class="alert alert-danger"
role="alert">PHP session ID mismatch</div>';

// incorrect login alert


return '<div class="alert alert-danger" role="alert">Incorrect login</div>';
}
}

// class U / static utility functions (short U because I want compact function


access)
class U {

// get file basename / basically just a wrapper in case it needs to be refined on


some servers
public static 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);
}

// get mime type for file


public static function mime($path){
if(function_exists('mime_content_type')) return mime_content_type($path);
if(function_exists('finfo_file')) return
finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path);
return false;
}

// get file extension with options to lowercase and include dot


public static function extension($path, $lowercase = false, $dot = false) {
$ext = pathinfo($path, PATHINFO_EXTENSION);
if(!$ext) return '';
if($lowercase) $ext = strtolower($ext);
if($dot) $ext = '.' . $ext;
return $ext;
}

// glob() wrapper for reading paths / escape [brackets] in folder names (it's
complicated)
public static function glob($path, $dirs_only = false){
if(preg_match('/\[.+]/', $path)) $path = str_replace(['[',']', '\[', '\]'], ['\
[','\]', '[[]', '[]]'], $path);
return @glob($path, $dirs_only ? GLOB_NOSORT|GLOB_ONLYDIR : GLOB_NOSORT);
}

// get $_POST parameter or false


public static function post($param){
return isset($_POST[$param]) && !empty($_POST[$param]) ? $_POST[$param] :
false;
}
// get $_GET parameter or false
public static function get($param){
return isset($_GET[$param]) && !empty($_GET[$param]) ? $_GET[$param] : false;
}

// make dir unless it already exists, error if fail


public static function mkdir($path){
if(!file_exists($path) && !mkdir($path, 0777, true)) U::error('Failed to create
' . $path, 500);
}

// helper function to check for and include various files html, php, css and js
from storage_path _files/*
public static function uinclude($file){
if(!Config::$storagepath) return;
$path = Config::$storagepath . '/' . $file;
if(!file_exists($path)) return;
$ext = U::extension($path);
if(in_array($ext, ['html', 'php'])) return include $path;
$src = Path::urlpath($path); // get urlpath for public resource
if(!$src) return; // return if storagepath is non-public (not inside document
root)
$src .= '?' . filemtime($path); // append modified time of file, so updated
resources don't get cached in browser
if($ext === 'js') echo '<script src="' . $src . '"></script>';
if($ext === 'css') echo '<link href="' . $src . '" rel="stylesheet">';
}

// attempt to ini_get($directive)
public static function ini_get($directive){
$val = function_exists('ini_get') ? @ini_get($directive) : false;
return is_string($val) ? trim($val) : $val;
}

// get php ini value to bytes


public static function ini_value_to_bytes($directive) {
$val = U::ini_get($directive);
if(empty($val) || !is_string($val)) return 0;
if(function_exists('ini_parse_quantity')) return @ini_parse_quantity($val) ?:
0;
if(!preg_match('/^(\d+)([G|M|K])?$/i', trim($val), $m)) return 0;
if(!isset($m[2])) return (int) $m[1];
return (int) $m[1] *= ['G' => 1024 * 1024 * 1024, 'M' => 1024 * 1024, 'K' =>
1024][strtoupper($m[2])];
}

// get memory limit in MB, if available, so we can calculate memory for image
resize operations
public static function get_memory_limit_mb() {
$val = U::ini_value_to_bytes('memory_limit');
return $val ? $val / 1024 / 1024 : 0; // convert bytes to M
}

// detect FFmpeg availability for video thumbnails and return path or false /
https://ffmpeg.org/
public static function ffmpeg_path(){

// below config options must be enabled for FFmpeg to apply


foreach (['video_thumbs', 'load_images', 'image_resize_cache',
'video_ffmpeg_path'] as $key) if(!Config::get($key)) return;

// exec() must be available to access command-line FFmpeg


if(!function_exists('exec')) return;

// path to ffmpeg in command-line is normally just 'ffmpeg', but


escapeshellarg() in case using absolute path
$path = escapeshellarg(Config::get('video_ffmpeg_path'));
//$path = '"' . str_replace('"', '\"', Config::get('video_ffmpeg_path')) . '"';
// <- if path contains Chinese chars

// attempt to run -version function on ffmpeg and return the path or false on
fail
return @exec($path . ' -version') ? $path : false;
}

// readfile() wrapper function to output file with tests, clone option and
headers
public static function readfile($path, $mime, $message = false, $cache = false,
$clone = false){
if(!$path || !file_exists($path)) return false;
if($clone && @copy($path, $clone)) U::message('cloned to ' .
U::basename($clone));
U::header($message, $cache, $mime, filesize($path), 'inline',
U::basename($path));
if(!is_readable($path) || readfile($path) === false) U::error('Failed to read
file ' . U::basename($path), 400);
exit;
}

// return an array of supported image resize types / used by Javascript to


determine what resized image types can be requested
public static function resize_image_types(){
return array_merge(['jpeg', 'jpg', 'png', 'gif'], array_filter(['webp', 'bmp',
'avif'], function($type){
return function_exists('imagecreatefrom' . $type);
}));
}

// common error response with response code, error message and json option
// 400 Bad Request, 403 Forbidden, 401 Unauthorized, 404 Not Found, 500 Internal
Server Error
public static function error($error = 'Error', $http_response_code = false,
$is_json = false){
if($is_json) return Json::error($error);
if($http_response_code) http_response_code($http_response_code);
U::header("[ERROR] $error", false);
exit("<h3>Error</h3>$error.");
}

// get dirs hash based on various options for cache paths and browser
localstorage / with cached response
private static $dirs_hash;
public static function dirs_hash(){
if(self::$dirs_hash) return self::$dirs_hash;
return self::$dirs_hash = substr(md5(Config::$document_root . Config::
$__dir__ . Config::$root . Config::$version . Config::get('cache_key') .
U::image_resize_cache_direct() . Config::get('files_exclude') .
Config::get('dirs_exclude')), 0, 6);
}

// check if image_resize_cache_direct is enabled for direct access to resized


image cache files / with cached response
private static $image_resize_cache_direct;
public static function image_resize_cache_direct(){
if(isset(self::$image_resize_cache_direct)) return self::
$image_resize_cache_direct;
return self::$image_resize_cache_direct =
Config::get('image_resize_cache_direct') && !Config::$has_login &&
Config::get('load_images') && Config::get('image_resize_cache') &&
Config::get('image_resize_enabled') && Path::is_within_docroot(Config::
$storagepath);
}

// image_resize_dimensions_retina (serve larger dimension resized images for


HiDPI screens) with cached response
private static $image_resize_dimensions_retina;
public static function image_resize_dimensions_retina(){
if(isset(self::$image_resize_dimensions_retina)) return self::
$image_resize_dimensions_retina;
$retina = intval(Config::get('image_resize_dimensions_retina'));
return self::$image_resize_dimensions_retina = $retina >
Config::get('image_resize_dimensions') ? $retina : false;
}

// get common html header for main document and login page
public static function html_header($title, $class){
?>
<!doctype html><!-- www.files.gallery -->
<html class="<?php echo $class; ?>" data-theme="contrast">
<script>
let theme = (() => {
try {
return localStorage.getItem('files:theme');
} catch (e) {
return false;
};
})() || (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' :
'contrast');
if(theme !== 'contrast') document.documentElement.dataset.theme = theme;
</script>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link rel="apple-touch-icon"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADABAMAAACg8nE0AAAAD1BMVEU
ui1f///9jqYHr9O+fyrIM/
O8AAAABIklEQVR42u3awRGCQBBE0ZY1ABUCADQAoEwAzT8nz1CyLLszB6p+B8CrZuDWujtHAAAAAAAAAAAA
AAAAAACOQPPp/2Y0AiZtJNgAjTYzmgDtNhAsgEkyrqDkApkVlsBDsq6wBIY4EIqBVuYVFkC98/
ycCkr8CbIr6MCNsyosgJvsKxwFQhEw7APqY3mN5cBOnt6AZm/
g6g2o8wYqb2B1BQcgeANXb0DuwOwNdKcHLgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeA20mArmB6Ug
g0NsCcP/9JS8GAKSlVZMBk8p1GRgM2R4jMHu51a/2G1ju7wfoNrYHyCtUY3zpOthc4MgdNy3N/0PruC/
JlVAwAAAAAAAAAAAAAAABwZuAHuVX4tWbMpKYAAAAASUVORK5CYII=">
<meta name="apple-mobile-web-app-capable" content="yes">
<title><?php echo $title; ?></title>
<?php U::uinclude('include/head.html'); ?>
<link href="<?php echo U::assetspath(); ?>files.photo.gallery@<?php echo
Config::$version ?>/css/files.css" rel="stylesheet">
<?php U::uinclude('css/custom.css'); ?>
</head>
<?php
}

// output file as download using correct headers and readfile() / used to


download zip and force download single files
public static function download($file, $message, $mime, $filename){
U::header($message, false, $mime, filesize($file), 'attachment', $filename);
while (ob_get_level()) ob_end_clean();
return readfile($file) !== false;
}

// assign assets url for plugins, Javascript, CSS and languages, defaults to CDN
https://www.jsdelivr.com/
// if you want to self-host assets: https://www.files.gallery/docs/self-hosted-
assets/
private static $assetspath;
public static function assetspath(){
if(self::$assetspath) return self::$assetspath;
return self::$assetspath = Config::get('assets') ? rtrim(Config::get('assets'),
'/') . '/' : 'https://cdn.jsdelivr.net/npm/';
}

// response headers

// cache time 1 year for cacheable assets / can be modified if you really need to
public static $cache_time = 31536000;

// array of messages to go into files-response header


private static $messages = [];

// add messages (string or array) to files-response header


public static function message($items = []){
self::$messages = array_merge(self::$messages, is_string($items) ? [$items] :
array_filter($items));
}

// set request response headers, including files-message header for diagnosing


response
public static function header($message, $cache = null, $type = false, $length =
0, $disposition = false, $filename = ''){

// prepend main $message to $messages array


if($message) array_unshift(self::$messages, $message);

// append PHP response time to $messages


if(isset($_SERVER['REQUEST_TIME_FLOAT'])) self::$messages[] =
round(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], 3) . 's';

// append memory usage to $messages


if(function_exists('memory_get_peak_usage')) self::$messages[] =
round(memory_get_peak_usage() / 1048576, 1) . 'M';

// assign files-message header with all $messages


header('files-response: ' . implode(' | ', self::$messages));

// cache response headers


if($cache){
$shared = Config::$has_login ? 'private' : 'public'; // private or shared
cache depending on login
header('expires: ' . gmdate('D, d M Y H:i:s \G\M\T', time() + self::
$cache_time));
header('cache-control: ' . $shared . ', max-age=' . self::$cache_time . ', s-
maxage=' . self::$cache_time . ', immutable');

// no cache response headers if specifically set to false (if null, don't do


anything)
} else if($cache === false){
header('cache-control: no-store, must-revalidate');
}

// assign content-type header


if($type) header("content-type: $type");

// assign content-length header / only assigned when reading actual files on


disk when we can get filesize()
if($length) header("content-length: $length");

// assign content-disposition when reading files on disk, assigned to either


'inline' or 'attachment'
if($disposition) header('content-disposition: ' . $disposition . '; filename="'
. addslashes($filename) . '"');
}
}

// class Path / various static functions to convert and validate file paths
class Path {

// returns resolved absolute paths and normalizes slashes across OS / returns


false if file does not exist
public static function realpath($path){
$realpath = realpath($path);
return $realpath ? str_replace('\\', '/', $realpath) : false;
}

// get absolute path by appending relative path to root path (does not resolve
symlinks)
public static function rootpath($relpath){
return Config::$root . (strlen($relpath) ? "/$relpath" : ''); // check paths
with strlen() in case dirname is '0'
}

// get relative path from full root path / used as internal reference and in
query ?path/path
public static function relpath($path){
return trim(substr($path, strlen(Config::$root)), '\/');
}

// get public url path relative to script or server document root


public static function urlpath($path){

// return if item is not within server document root, because it can't be


accessed by www url
if(!self::is_within_docroot($path)) return false;

// if item is within application dir, we can return relative path


if(self::is_within_path($path, Config::$__dir__)) return $path === Config::
$__dir__ ? '.' : substr($path, strlen(Config::$__dir__) + 1);

// return root-relative path


return $path === Config::$document_root ? '/' : substr($path, strlen(Config::
$document_root));
}

// determines if a path is equal to or inside another path / append slash so that


path/dirx/ does not match path/dir/
public static function is_within_path($path, $root){
return $path && strpos($path . '/', $root . '/') === 0;
}

// determines if path is within server document root (so we can determine if it's
accessible by URL)
public static function is_within_docroot($path){
return $path && self::is_within_path($path, Config::$document_root);
}

// calculate path for image resize cache


public static function imagecachepath($path, $image_resize_dimensions, $filesize,
$filemtime){
return Config::$cachepath . '/images/' . substr(md5($path), 0, 6) . ".
$filesize.$filemtime.$image_resize_dimensions.jpg";
}

// determines if relative path is valid, and returns full rootpath or false if


invalid
public static function valid_rootpath($relpath, $is_dir = false){

// invalid if path is false (might be previously unresolved)


if($relpath === false) return;

// invalid if is file and path is empty (path can be '' empty string for root
dir)
if(!$is_dir && empty($relpath)) return;

// relative path should never start or end with slash/


if(preg_match('/^\/|\/$/', $relpath)) return;

// get root path from relative path


$rootpath = self::rootpath($relpath);

// realpath may differ from rootpath if symlinked or if relpath contains parent


../ paths
$realpath = self::realpath($rootpath);

// invalid if file does not exist


if(!$realpath) return;

// additional security checks if realpath differs from rootpath, and realpath


is no longer within root
// blocks potential abuse of relative paths like ?path/../../../../dir
if($realpath !== $rootpath && !self::is_within_path($realpath, Config::$root))
{
if(strpos(($is_dir ? $relpath : dirname($relpath)), ':') !== false)
return; // dir may not contain ':'
if(strpos($relpath, '..') !== false) return; // path may not contain '..'
//if(self::is_exclude($realpath, $is_dir, true)) return; // check is_exclude
also on realpath / seems pointless ...
}

// is invalid
if(!is_readable($realpath)) return; // not readable
if($is_dir && !is_dir($realpath)) return; // invalid dir
if(!$is_dir && !is_file($realpath)) return;// invalid file
if(self::is_exclude($rootpath, $is_dir)) return; // rootpath is excluded

// return full path


return $rootpath;
}

// determine if path should be excluded from displaying in the gallery


public static function is_exclude($path = false, $is_dir = true, $symlinked =
false){

// is not excluded if empty or path is root


if(!$path || $path === Config::$root) return;

// exclude relative paths that start with _files* (reserved for hidden items)
if(strpos('/' . self::relpath($path), '/_files') !== false) return true;

// exclude Files Gallery PHP application name (normally "index.php" but could
be renamed)
if($path === Config::$__file__) return true;

// exclude symlinks if symlinks not allowed (symlinks might be sensitive)


if($symlinked && !Config::get('allow_symlinks')) return true;

// exclude Files Gallery storage_path (normally _files dir relative to PHP


file)
if(Config::$storagepath && self::is_within_path($path, Config::$storagepath))
return true;

// exclude if dir or file's parent dir is excluded by config dirs_exclude


if(Config::get('dirs_exclude')) {

// dir to check is path or parent dir if file


$dirname = $is_dir ? $path : dirname($path);

// check if dir matches dirs_exclude, unless dir is root (root dir can't be
excluded)
if($dirname !== Config::$root && preg_match(Config::get('dirs_exclude'),
self::relpath($dirname))) return true;
}

// exclude file
if(!$is_dir){

// get file name


$filename = U::basename($path);

// make sure file is not local config file


if($filename === Config::$localconfigpath) return true;

// exclude file name (not path) by files_exclude


if(Config::get('files_exclude') && preg_match(Config::get('files_exclude'),
$filename)) return true;
}
}
}

// class Json / JSON response functions


class Json {

// output json from array and exit


public static function jexit($arr = []){
header('content-type: application/json');
exit(json_encode($arr));
}

// json error with message


public static function error($error = 'Error'){
self::jexit(['error' => $error]);
}

// output json from array and cache as .json / used by class dirs and class dir
public static function cache($arr = [], $message = false, $cache = true){
$json = empty($arr) ? '{}' : @json_encode($arr, JSON_UNESCAPED_UNICODE|
JSON_UNESCAPED_SLASHES|JSON_PARTIAL_OUTPUT_ON_ERROR);
if(empty($json)) self::error(json_last_error() ? json_last_error_msg() :
'json_encode() error');
if($cache) @file_put_contents($cache, $json);
U::message(['cache ' . ($cache ? 'ON' : 'OFF') ]);
U::header($message, false, 'application/json;');
echo $json;
}
}

// class X3 / functions if running Files Gallery alongside X3 www.photo.gallery


class X3 {

// vars
private static $path; // cache absolute X3 path
private static $inc = '/app/x3.inc.php'; // relative path to the X3 include file
that is used for checking and invalidating cache

// checks if Files Gallery root points into X3 content and returns path to X3
root
public static function path(){
if(isset(self::$path)) return self::$path; // serve previously resolved path
// loop resolved path and original config path, in case resolved path was
symlinked content
foreach ([Config::$root, Config::get('root')] as $path) {
// match /content and check if /app/x3.inc.php exists in parent
if($path && preg_match('/(.+)\/content/', $path, $match)) return self::$path
= file_exists($match[1] . self::$inc) ? Path::realpath($match[1]) : false;
}
// nope
return self::$path = false;
}

// attempt to load x3-login if 1. root is X3 path, 2. there is no existing login,


3. files.x3-login.php exists
public static function login(){
return self::path() && U::uinclude('plugins/files.x3-login.php');
}

// get public url path of X3, used to render X3 thumbnails instead of thumbs
created by Files Gallery
public static function urlpath(){
return self::path() ? Path::urlpath(self::path()) : false;
}

// on Filemanager actions, invalidate X3 cache updating modified time of


x3.inc.php
public static function invalidate(){
if(self::path()) @touch(self::path() . self::$inc);
}
}

// class Tests / outputs PHP, server and config diagnostics by url ?action=tests
class Tests {

// html response
private $html = '';

// construct new Tests()


function __construct() {

// display all errors to catch anything unusual


ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);

// first let's check if new Config() created dirs and files in storagepath
$this->created();

// title, version, server name, PHP version and server software


$this->html .= '<h2>Files Gallery ' . Config::$version . '</h2>';
if(isset($_SERVER['SERVER_NAME'])) $this->prop('<b>'.
$_SERVER['SERVER_NAME'].'</b>');
$this->prop('<b>PHP ' . phpversion().'</b>');
if(isset($_SERVER['SERVER_SOFTWARE'])) $this->prop('<b>'.
$_SERVER['SERVER_SOFTWARE'].'</b>');

// check if paths root, storage_path and index.php exist and are writeable
$this->check_path(Config::$root, 'root');
$this->check_path(Config::$storagepath, 'storage_path');
$this->check_path(__FILE__, U::basename(__FILE__));

// check a few PHP extensions gd, exif and mbstring


if(function_exists('extension_loaded')) foreach (['gd', 'exif', 'mbstring'] as
$name) $this->prop($name, extension_loaded($name));

// check if ZipArchive class exists


$this->prop('ZipArchive', class_exists('ZipArchive'));

// check various PHP functions


foreach (['mime_content_type', 'finfo_file', 'iptcparse', 'exif_imagetype',
'session_start', 'ini_get', 'exec'] as $name) $this->prop($name . '()',
function_exists($name));

// check ffmpeg if exec is available, else don't check, because ffmpeg could be
enabled even if !exec()
if(function_exists('exec')) $this->prop('ffmpeg', !!U::ffmpeg_path());

// get various PHP ini values with ini_get()


if(function_exists('ini_get')) foreach (['memory_limit', 'file_uploads',
'upload_max_filesize', 'post_max_size', 'max_file_uploads'] as $name) $this-
>prop($name, 'neutral', @ini_get($name));

// validate regex for files_exclude and dirs_exclude config options


foreach (['files_exclude', 'dirs_exclude'] as $key) if(Config::get($key) &&
@preg_match(Config::get($key), '') === false) $this->prop("Invalid
<strong>$key</strong> regex", false);

// output merged config in readable format, with sensitive properties masked


out
$this->showconfig();

// output basic formatted tests in html format


$this->output();

// exit on tests output


exit;
}

// checks if new Config() created dirs and files in storagepath


// useful to run ?action=tests if you want to create config.php file before
executing Files Gallery
private function created(){
if(empty(Config::$created)) return;
$this->html .= '<p>Successfully created the following storage items:</p>';
foreach (Config::$created as $key) $this->prop($key, true);
}

// checks if a path exists and is writeable


private function check_path($path, $name){
if(!$path) return $this->prop($name, false);
if(!file_exists($path)) return $this->prop("$name does not exist", false);
if(!is_writable($path)) return $this->prop($name . ' is not writeable ' .
substr(sprintf('%o', fileperms($path)), -4) . ' [owner ' . fileowner($path) . ']',
false);
$this->prop($name, true);
}

// outputs and formats a property feature <div> element to html


private function prop($name, $success = 'neutral', $value = ''){
$class = is_string($success) ? $success : ($success ? ' success' : 'fail');
$this->html .= "<div class=\"test $class\">$name <b>$value</b></div>";
}

// output merged config in readable format, with sensitive properties masked out
private function showconfig(){

// copy config array


$arr = Config::$config;

// mask sensitive values


foreach (['root', 'storage_path', 'start_path', 'username', 'password',
'license_key', 'allow_tasks'] as $prop) if($arr[$prop]) $arr[$prop] = '***';

// create PHP array string that resembles config.php files


$php = '<?php' . PHP_EOL . PHP_EOL . 'return ' . var_export($arr, true) . ';';

// add to html response and highlight


$this->html .= '<h2>Config</h2>' . highlight_string($php, true);
}

// output basic formatted tests in html format


private function output(){
echo '<!doctype html><html><head><title>Files Gallery check system and
config.</title><meta name="robots" content="noindex,nofollow"><style>body{font-
family:system-ui;color:#444;line-height:1.6;margin:2vw 3vw;overflow:scroll}b{font-
weight:600}.test:before{display:inline-block;width:18px;text-align:center;margin-
right:5px}.neutral:before{color:#BBB}.success:before{color:#78a642}.success:before,
.neutral:before{content:"\2713"}.fail:before{content:"\2A09";color:firebrick}</
style></head><body>' . $this->html . '</body></html>';
}
}

// class FileResponse / outputs file, video preview image, resized image or proxies
any file by PHP
class FileResponse {

// vars
private $path;
private $mime;
private $resize;
private $clone;

// construct resize image, all processes in due order


public function __construct($path, $resize = false, $clone = false){

// exif if invalid $path


if(!$path) U::error('Invalid file request', 404);

// store path and resolve potential symlinks


$this->path = Path::realpath($path);

// get mime to check if requested video or image files are valid


$this->mime = U::mime($this->path);

// resize numeric value assigned if image resize, but could be set to 'video'
$this->resize = is_numeric($resize) ? intval($resize) : $resize;

// clone the file (used by folder preview action)


$this->clone = $clone;

// get FFmpeg video preview image


if($this->resize === 'video') return $this->get_video_preview();

// get resized image preview (convert resize parameter to number, else it will
return 0, not allowed)
if($this->resize) return $this->get_image_preview();

// get file proxied through PHP if it's not within document root
$this->get_file_proxied();
}

// get FFmpeg video preview image


private function get_video_preview(){
// image_resize_cache required
if(!Config::get('image_resize_cache')) U::error('image_resize_cache must be
enabled to create and store video thumbs', 400);

// requirements with diagnostics / only check $mime if $mime detected


if($this->mime && strtok($this->mime, '/') !== 'video') U::error('Unsupported
video type ' . $this->mime, 415);

// get cache path, where we will look for image or create it


$cache = Path::imagecachepath($this->path, 480, filesize($this->path),
filemtime($this->path));

// check for cached video thumbnail / clone if called from folder preview
if($cache) U::readfile($cache, 'image/jpeg', 'Video preview from cache', true,
$this->clone);

// get FFmpeg path `video_ffmpeg_path` or error


$ffmpeg_path = U::ffmpeg_path() ?: U::error('<a href="http://ffmpeg.org/"
target="_blank">FFmpeg</a> disabled. Check your <a href="' .
U::basename(__FILE__) . '?check=1" target="_blank">diagnostics</a>.', 400);

// ffmpeg command to create video preview in $cache path


$cmd = $ffmpeg_path . ' -ss 3 -t 1 -hide_banner -i "' . str_replace('"', '\"',
$this->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';

// attempt to execute FFmpeg command


exec($cmd, $output, $result_code);

// fail if result_code is anything else than 0


if($result_code) U::error("Error generating thumbnail for video (\$result_code
$result_code)", 500);

// if for some reason, the created $cache file does not exist
if(!file_exists($cache)) U::error('Cache file ' . U::basename($cache) . ' does
not exist', 404);

// fix for empty video previews that get created for extremely short videos (or
other unknown errors)
if(!filesize($cache) && imagejpeg(imagecreate(1, 1), $cache))
U::readfile($cache, 'image/jpeg', '1px placeholder image created and cached', true,
$this->clone);

// output created video thumbnail


U::readfile($cache, 'image/jpeg', 'Video preview image created', true, $this-
>clone);
}

// check if requested resize value is allowed


private function resize_allowed(){
if(empty($this->resize) || !is_numeric($this->resize)) return false;
if($this->resize === Config::get('image_resize_dimensions')) return true;
if($this->resize === Config::get('image_resize_dimensions_retina')) return
true;
// check image_resize_dimensions_allowed array
$allowed = Config::get('image_resize_dimensions_allowed') ?: [];
return in_array($this->resize, array_filter(array_map('intval',
is_array($allowed) ? $allowed : explode(',', $allowed))));
}

// get image preview resized image


private function get_image_preview(){

// only image mime types can be resized


if($this->mime && strtok($this->mime, '/') !== 'image') U::error('Unsupported
image type ' . $this->mime, 415);

// allow resize image only if config load_images and image_resize_enabled are


enabled
foreach (['load_images', 'image_resize_enabled'] as $key) if(!
Config::get($key)) U::error("Config $key disabled", 400);

// check if requested resize value is allowed


if(!$this->resize_allowed()) U::error("Resize parameter $this->resize is not
allowed", 400);

// get ResizeImage()
new ResizeImage($this->path, $this->resize, $this->clone);
}

// get file proxied through PHP if it's not within document root
private function get_file_proxied(){

// don't allow getting file by proxy if !load_files_proxy_php and the file is


available in document root
if(!Config::get('load_files_proxy_php') && Path::is_within_docroot($this-
>path)) U::error('File can\'t be proxied', 400);

// read file / $mime or 'application/octet-stream' if $mime is unknown (should


not happen unless missing functions)
U::readfile($this->path, ($this->mime ?: 'application/octet-stream'), 'File
proxied', true);
}
}

// class ResizeImage / serves a resized image


class ResizeImage {

// set a different fill color than black (default) for images with transparency /
disabled by default []
// only enable when strictly required, as it will assign fill color also for non-
transparent images
public static $fill_color = []; // white [255, 255, 255];

// class properties
private $path; // full path to image
private $rwidth; // calculated resize width
private $rheight; // calculated resize height
private $pixels; // used to check if pixels > max_pixels and to calculate
required memory
private $bits; // extracted from getimagesize() for use in
set_memory_limit()
private $channels; // extracted from getimagesize() for use in
set_memory_limit()
private $dst_image; // destination image GD resource with resize dimensions,
also used in sharpen() and exif_orientation()
// construct resize image, all processes in due order
public function __construct($path, $resize, $clone = false){

// vars
$this->path = $path;
$filesize = filesize($this->path);

// create local $short vars from config 'image_resize_*' options, because it's
much easier and more readable
foreach (['cache', 'types', 'quality', 'function', 'sharpen', 'memory_limit',
'max_pixels', 'min_ratio'] as $key) {
$$key = Config::get("image_resize_$key");
}

// add to response headers


U::message(['cache ' . ($cache ? 'ON' : 'OFF'), "resize $resize", "quality
$quality", $function]);

// get cache path for image (or null for imagejpeg())


$cache_path = $cache ? Path::imagecachepath($this->path, $resize, $filesize,
filemtime($this->path)) : null;

// attempt to load $cache_path / will simply fail if $cache_path does not exist
if($cache_path) U::readfile($cache_path, 'image/jpeg', 'Resized image from
cache', true, $clone);

// getimagesize / original dimensions, image type, bits, channels and mime


$imagesize = getimagesize($this->path);
if(empty($imagesize) || !is_array($imagesize)) U::error('Failed
getimagesize()', 500); // die!

// vars extrapolated from $imagesize


$width = (int) $imagesize[0]; // (int) because AVIF might return '0x0'
$height = (int) $imagesize[1]; // (int) because AVIF might return '0x0'
$ratio = max($width, $height) / $resize; // calculate resize ratio from image
longest side (width or height)
$this->rwidth = round($width / $ratio); // calculate resize width
$this->rheight = round($height / $ratio); // calculate resize height
$this->pixels = $width * $height; // used to check if pixels > max_pixels and
to calculate required memory
$type = $imagesize[2]; // returns one of the IMAGETYPE_XXX constants indicating
the type of the image.
$mime = isset($imagesize['mime']) && is_string($imagesize['mime']) ?
$imagesize['mime'] : false;
$this->bits = isset($imagesize['bits']) && is_numeric($imagesize['bits']) ?
$imagesize['bits'] : 8;
$this->channels = isset($imagesize['channels']) &&
is_numeric($imagesize['channels']) ? $imagesize['channels'] : 3;

// get image type extension or die / used to check if $ext is in


`image_resize_types` and for "imagecreatefrom$ext"() function
$ext = image_type_to_extension($type, false) ?: U::error("Invalid image type
$type");

// add more calculated values to response headers


U::message([$mime, $ext, "$width x $height", 'ratio ' . round($ratio, 2),
"$this->rwidth x $this->rheight"]);
// if image extension is not in `image_resize_types`, attempt to serve original
if filesize is <= `load_images_max_filesize`
if(!in_array($ext, $this->get_resize_types($types))){

// exit if original image exceeds `load_images_max_filesize`


if($filesize > Config::get('load_images_max_filesize')) U::error("Image $ext
is not in `image_resize_types`, and can't serve original because filesize $filesize
exceeds `load_images_max_filesize` " . Config::get('load_images_max_filesize') . '
bytes', 400);

// attempt to serve original or error


if(!U::readfile($this->path, $mime, 'Original image served because image is
not within image_resize_types', true, $clone)) U::error('File does not exist',
404);
}

// exit if image pixels (dimensions) exceeds 'image_resize_max_pixels' =>


60000000 (default)
if($max_pixels && $this->pixels > $max_pixels) U::error("Image pixels $this-
>pixels ($width x $height) exceeds `image_resize_max_pixels` $max_pixels", 400);

// serve original if resize ratio < min_ratio, but only if filesize <=
load_images_max_filesize
if($ratio < max($min_ratio, 1) && $filesize <=
Config::get('load_images_max_filesize') && !U::readfile($this->path, $mime,
"Original image served, because resize ratio $ratio < min_ratio $min_ratio", true,
$clone)) U::error('File does not exist', 404);

// prepare imagecreatefrom$EXT() to create image GD resource from path


$imagecreatefrom = "imagecreatefrom$ext";
if(!function_exists($imagecreatefrom)) U::error("Function $imagecreatefrom()
does not exist", 500);

// check if avaialble memory is sufficient to resize image, and attempt to


temporarily assign higher memory_limit
$this->set_memory_limit($memory_limit);

// create new source image GD resource from path


$src_image = $imagecreatefrom($this->path) ?: U::error("Function
$imagecreatefrom() failed", 500);

// create destination image GD resource with resize dimensions


$this->dst_image = imagecreatetruecolor($this->rwidth, $this->rheight) ?:
U::error('Function imagecreatetruecolor() failed', 500);

// set a different fill color than black (default) for images with transparency
/ disabled by default $fill_color = []
$this->set_fill_color($ext);

// imagecopyresampled() src_image to dst_image


if(!call_user_func($function, $this->dst_image, $src_image, 0, 0, 0, 0, $this-
>rwidth, $this->rheight, $width, $height)) U::error("Function $function() failed",
500);

// destroy src_image GD resource to free up memory


imagedestroy($src_image);

// rotate resized image according to exif image orientation if required


$this->exif_orientation();
// sharpen resized images, because default PHP imagecopyresized() make images
blurry ...
if($sharpen) $this->sharpen();

// add headers for direct output if !cache / missing content-length but that's
ok
if(!$cache_path) U::header('Resized image served', true, 'image/jpeg');

// create jpg image in cache path or output directly if !cache


if(!imagejpeg($this->dst_image, $cache_path, $quality)) U::error('PHP
imagejpeg() failed', 500);

// destroy dst_image resource to free up memory


imagedestroy($this->dst_image);

// cache readfile
if($cache_path && !U::readfile($cache_path, 'image/jpeg', 'Resized image
served', true, $clone)) U::error('Cache file does not exist', 404);

// always exit
exit;
}

// check if avaialble memory is sufficient to resize image, and attempt to


temporarily assign new memory_limit
private function set_memory_limit($memory_limit){
// config image_resize_memory_limit must be assigned
if(empty($memory_limit)) return;
// get memory_limit in MB
$current = U::get_memory_limit_mb();
// pointless to make any assumptions if we can't get default memory_limit, just
try to resize ...
if(empty($current)) return;
// calculate approximate required memory to resize image
$required = round(($this->pixels * $this->bits / 8 * $this->channels * 1.33 +
$this->rwidth * $this->rheight * 4) / 1048576, 1);
// get new memory_limit, assigned from config image_resize_memory_limit, if
higher than $current
$new = function_exists('ini_set') ? max($current, $memory_limit) : $current;
// error if required memory > available memory
if($required > $new) U::error("Resizing this image requires >= {$required}M.
Your PHP memory_limit is {$new}M", 400);
// assign $new memory from config image_resize_memory_limit if > $current
(default memory_limit)
if($new > $current && @ini_set('memory_limit', $new . 'M'))
U::message("{$current}M => {$new}M (min {$required}M)");
}

// get resize types array from config 'image_resize_types' => 'jpeg, png, gif,
webp, bmp, avif'
private function get_resize_types($types){
return array_filter(array_map(function($key){
$type = trim(strtolower($key));
return $type === 'jpg' ? 'jpeg' : $type;
}, explode(',', $types)));
}

// sharpen resized images, because default PHP imagecopyresized() make images


blurry ...
private function sharpen(){
$matrix = [
[-1, -1, -1],
[-1, 20, -1],
[-1, -1, -1],
];
$divisor = array_sum(array_map('array_sum', $matrix));
$offset = 0;
imageconvolution($this->dst_image, $matrix, $divisor, $offset);
}

// rotate resized image according to exif image orientation (no way we deal with
this in browser)
private function exif_orientation(){
// attempt to get image exif array
$exif = Exif::exif_data($this->path);
// exit if there is no exif orientation value
if(!$exif || !isset($exif['Orientation'])) return;
// assign $orientation
$orientation = $exif['Orientation'];
// array of orientation values to rotate (4, 5 and 7 will also be flipped)
$orientation_to_rotation = [3 => 180, 4 => 180, 5 => 270, 6 => 270, 7 => 90, 8
=> 90];
// return if orientation is not valid or is not in array (does not require
rotation)
if(!array_key_exists($orientation, $orientation_to_rotation)) return;
// rotate image according to exif $orientation, write back to already-resized
image destination resource
$this->dst_image = imagerotate($this->dst_image,
$orientation_to_rotation[$orientation], 0);
// after rotation, orientation values 4, 5 and 7 also need to be flipped in
place
if(in_array($orientation, [4, 5, 7]) && function_exists('imageflip'))
imageflip($this->dst_image, IMG_FLIP_HORIZONTAL);
// add header props
U::message("orientated from EXIF $orientation");
}

// sets a different fill color than black (default) for images with
transparency / disabled by default
private function set_fill_color($ext){
if(!is_array(self::$fill_color) || count(self::$fill_color) !== 3 || !
in_array($ext, ['png', 'gif', 'webp', 'avif'])) return;
$color = call_user_func_array('imagecolorallocate', array_merge([$this-
>dst_image], self::$fill_color));
if(imagefill($this->dst_image, 0, 0, $color)) U::message('Fill rgb(' . join(',
', self::$fill_color) . ')');
}
}

// class Dirs / outputs menu json from dir structure


class Dirs {

// vars
private $dirs = []; // array of dirs to output when re-creating
private $cache_file = false; // cache file path / gets assigned to a path if
cache is enabled
private $load_files = false; // load files into each menu dir if
Config::get('menu_load_all')

// construct Dirs
public function __construct(){

// first check and assign cache / returns cache json if valid


$this->check_cache();

// load files in each dir if config menu_load_all


$this->load_files = Config::get('menu_load_all');

// if not cached, get dirs starting from root dir


$this->get_dirs(Config::$root);

// outputs dirs json format and cache


Json::cache($this->dirs, 'Dirs reloaded', $this->cache_file);
}

// check cache for menu and return if valid


private function check_cache(){

// exit if cache disabled


if(!Config::get('cache')) return;

// get cache hash from POST menu_cache_hash so we can assign correct cache file
$hash = U::post('menu_cache_hash');

// validate $hash to make sure we check and create correct cache file names
(not strictly necessary, but just in case)
if(!$hash || !preg_match('/^.{6}\..{6}\..\d+$/', $hash)) Json::error('Invalid
menu cache hash');

// assign cache file when cache is enabled / check if file exists, or write to
this file when re-creating
$this->cache_file = Config::$cachepath . "/menu/$hash.json";

// return if cache file does not exist


if(!file_exists($this->cache_file)) return;

// get json from cache file


$json = @file_get_contents($this->cache_file);

// return if the file is empty (for some reason)


if(empty($json)) return;

// check if menu cache is valid by comparing folder modified dates


if(!$this->menu_cache_is_valid($json)) return;

// assign headers
U::header('Valid menu cache', null, 'application/json');

// if browser has valid menu cache stored, just confirm cache is valid // don't
use Json::exit, because we already set header
if(U::post('localstorage')) exit(json_encode(['localstorage' => true]));

// output json cache file


exit($json);
}
// check if json menu cache is valid by comparing folder dates (modified time)
private function menu_cache_is_valid($json){
if(!Config::get('menu_cache_validate')) return true; // don't validate deep
levels beyond 2
$arr = @json_decode($json, true); // create array to compare times
if(empty($arr)) return;
// loop dirs and compare modified-time to check if cache is valid / skip
shallow 1st level dirs
foreach ($arr as $val) {
if(strpos($val['path'], '/') !== false && $val['mtime'] !==
@filemtime(Path::rootpath($val['path']))) return;
}
return $json; // it's valid, because json folder dates match real folder dates
}

// get_dirs recursive directories


private function get_dirs($path, $depth = 0) {

// load data for dir / ignore depth 0 (root), because it's already loaded,
unless load_files
if($depth || $this->load_files) {

// return if dir is excluded


if(Path::is_exclude($path, true)) return;

// get array of data for dir, including files load_files (config


menu_load_all)
$data = (new Dir($path))->load($this->load_files);

// exit if empty / should not happen, but just on case


if(empty($data)) return;

// assign dir $data to array of $dirs


$this->dirs[] = $data;

// exit if current depth >= config menu_max_depth (don't get subdirs)


if(Config::get('menu_max_depth') && $depth >= Config::get('menu_max_depth'))
return;

// exit if item is symlink and don't follow symlinks (don't get subdirs)
if($data['is_link'] && !Config::get('menu_recursive_symlinks')) return;//
$arr;
}

// get subdirs from current path


$subdirs = U::glob("$path/*", true);

// sort subdirs and get data for each dir (including further subdirs)
if(!empty($subdirs)) foreach($this->sort($subdirs) as $subdir) $this-
>get_dirs($subdir, $depth + 1);
}

// sort subfolders
private function sort($dirs){
if(strpos(Config::get('menu_sort'), 'date') === 0){
usort($dirs, function($a, $b) {
return filemtime($a) - filemtime($b);
});
} else {
natcasesort($dirs);
}
return substr(Config::get('menu_sort'), -4) === 'desc' ? array_reverse($dirs) :
$dirs;
}
}

// class Dir / loads data array for a single dir with or without files
class Dir {

// vars
public $data; // array of public data to be returned / shared with File
public $path; // path of dir / shared with File
public $realpath; // dir realpath, normally the same as $path, unless $path
contains symlink
private $filemtime; // dir filemtime (modified time), used for cache validation
and data
private $filenames; // array of file names in dir
private $cache_path; // calculated json file cache path

// construct assign common vars


public function __construct($path){
$this->path = $path;
$this->realpath = $path ? Path::realpath($path) : false;
$this->filemtime = filemtime($this->realpath);
$this->cache_path = $this->get_cache_path();
}

// get dir json from cache, or reload / used by main dir files action request
public function json(){

// return json cache file if exists


if(U::readfile($this->cache_path, 'application/json', 'JSON served from
cache')) return;

// reload, encode as json, and store json cache file


Json::cache($this->load(true), 'JSON created', $this->cache_path);
}

// get dir array from cache or reload / used in Document class when getting dir
arrays for root and start path
public function get(){

// get cache if valid, also returns files[] array as bonus since it's already
cached
if($this->cache_is_valid()) return json_decode(file_get_contents($this-
>cache_path), true);

// reload dir without files (we don't want to delay Document with this, unless
cached)
return $this->load();
}

// load dir array / used when dir is not cached (always by menu get_dirs())
public function load($files = false){

// dir array
$this->data = [
'basename' => U::basename($this->path),
'fileperms' => substr(sprintf('%o', fileperms($this->realpath)), -4),
'filetype' => 'dir',
'is_readable' => is_readable($this->realpath),
'is_writeable' => is_writeable($this->realpath),
'is_link' => is_link($this->path),
'is_dir' => true,
'mime' => 'directory',
'mtime' => $this->filemtime,
'path' => Path::relpath($this->path), // if path is realpath and is
symlinked, it might be wrong
'files_count' => 0,
'dirsize' => 0,
'images_count' => 0,
'url_path' => Path::urlpath($this->path)
];

// get files[] array for dir


if($files) $this->get_files();

// assign direct url to json cache file for faster loading from javascript /
used by Dirs class (menu), only when !files
// won't work if you have blocked public web access to cache dir files / if so,
comment out the below line
if(!$files) $this->set_json_cache_url();

// return data array for this dir


return $this->data;
}

// get json cache path for dir (does not validate if cache file exists)
private function get_cache_path(){
if(!Config::get('cache') || !$this->realpath) return;
return Config::$cachepath . '/folders/' . U::dirs_hash() . '.' .
substr(md5($this->realpath), 0, 6) . '.' . $this->filemtime . '.json';
}

// used to check if json cache file exists, and therefore is valid


private function cache_is_valid(){
return $this->cache_path && file_exists($this->cache_path);
}

// assign direct url to json cache file for faster loading from javascript / used
by Dirs class (menu)
private function set_json_cache_url(){
// don't allow direct access if login or !public or !valid cache
if(Config::$has_login || !$this->cache_is_valid() || !
Path::is_within_docroot(Config::$storagepath)) return;
$this->data['json_cache'] = Path::urlpath($this->cache_path);
}

// optional get array of files from dir / only gets called if file data should be
loaded
private function get_files(){

// forget it if we can't read dir


if(!$this->data['is_readable']) return;

// start files array, even if empty (so we know it's an empty folder)
$this->data['files'] = [];
// scandir for filenames
$this->filenames = scandir($this->path, SCANDIR_SORT_NONE);

// exit if dir is empty


if(empty($this->filenames)) return;

// loop filenames add to $this->data['files']


foreach($this->filenames as $filename) {

// skip dots
if(in_array($filename, ['.', '..'])) continue;

// add file to $this->data['files'] array


new File($this, $filename);
}

// sort files by natural case, with dirs on top (already sorts in javascript,
but faster when pre-sorted in cache)
uasort($this->data['files'], function($a, $b){
if(!Config::get('sort_dirs_first') || $a['is_dir'] === $b['is_dir']) return
strnatcasecmp($a['basename'], $b['basename']);
return $b['is_dir'] ? 1 : -1;
});
}
}

// class File / returns data array for a single file


class File {

// vars
private $dir; // parent dir object
private $file; // file array
private $realpath; // realpath of item, in case symlinked, faster access for
various operations
private $image = []; // image data array, populated and assigned to $this-
>file['image'] if file is image
private $image_info; // image_info from getimagesize() to get IPTC

// public construct file


public function __construct($dir, $filename){

// parent dir object


$this->dir = $dir;

// assemble full path from parent dir path


$path = $this->dir->path . '/' . $filename;

// get resolved realpath, to check if file truly exists (symlink target could
be deaf), and for faster function access
$this->realpath = Path::realpath($path); // may differ from $path if symlinked

// exit if no realpath for some reason, for example symlink target is dead
if(!$this->realpath) return;

// path is symlinked if realpath differs from $path


$symlinked = $this->realpath !== $path;

// get filetype
$filetype = filetype($this->realpath);

// determine if file is dir


$is_dir = $filetype === 'dir' ? true : false;

// skip item if excluded


if(Path::is_exclude($path, $is_dir, $symlinked)) return;

// count file into parent dir


if(!$is_dir) $this->dir->data['files_count'] ++;

// check if file is symlink (only if realpath !== path)


$is_link = $symlinked ? is_link($path) : false;

// get filesize if !$is_dir


$filesize = $is_dir ? 0 : filesize($this->realpath);

// append filesize to parent dirsize


$this->dir->data['dirsize'] += $filesize;

// add properties to file array


$this->file = [
'basename' => $filename,
'ext' => $is_dir ? '' : U::extension($is_link ? $this->realpath : $filename,
true),
'fileperms' => substr(sprintf('%o', fileperms($this->realpath)), -4),
'filetype' => $filetype,
'filesize' => $filesize,
'is_readable' => is_readable($this->realpath),
'is_writeable' => is_writeable($this->realpath),
'is_link' => $is_link,
'is_dir' => $is_dir,
'mtime' => filemtime($this->realpath),
'path' => ltrim($this->dir->data['path'] . '/', '/') . $filename,
'url_path' => Path::urlpath($path)
];

// assign file mime type / will return null for most files unless config
get_mime_type = true (slow)
$this->file['mime'] = $this->mime();

// assign image data if file is image


$this->set_image_data();

// read .URL shortcut files and present as links /


https://fileinfo.com/extension/url
$this->set_file_url();

// add to dir files array with filename as key


$this->dir->data['files'][$filename] = $this->file;
}

// get file mime type if !extension of config get_mime_type is enabled


private function mime(){
if($this->file['is_dir']) return 'directory'; // directory
if(!$this->file['is_readable']) return null; // skip and return null
if(!$this->file['ext'] || $this->file['ext'] === 'ts' ||
Config::get('get_mime_type')) return U::mime($this->realpath);
return null; // don't check mime, mime will be detected from extension in
javascript
}

// assign image data if file is image


private function set_image_data(){

// first check if item seems like an image by checking mime or extension


if(!$this->is_image()) return;

// count image in dir, assuming it's some kind of image, even if !


getimagesize() or !readable
$this->dir->data['images_count'] ++;

// pre-assign image icon, assuming it's some kind of image, even if !


getimagesize() or !readable
$this->file['icon'] = 'image';

// getimagesize() wrapper, populates and re-formats $this->image / exit if


empty
if(!$this->getimagesize()) return;

// assign item mime from getimagesize() because it is more accurate and we


might not have file mime yet anyway
if(isset($this->image['mime'])) $this->file['mime'] = $this->image['mime'];

// get image Iptc


$this->image['iptc'] = Iptc::get($this->image_info);

// get image Exif


$this->image['exif'] = Exif::get($this->realpath);

// invert image width height if exif orientation is > 4 && < 9, because
dimensions should match browser-oriented image
$this->image_orientation_flip_dimensions();

// find optional panorama sizes `_files_{size}_{filename.jpg}` for


equirectangular 2/1 aspect panorama images
$this->image_panorama_sizes();

// get image resize cache for direct access by javascript if config


image_resize_cache_direct
$this->get_image_resize_cache();

// remove empty values and add image array to file output


$this->file['image'] = array_filter($this->image);
}

// check if file seems to be an image by means of mime type or extension


private function is_image(){
if($this->file['is_dir']) return;
if($this->file['mime']) return strpos($this->file['mime'], 'image/') === 0;
return in_array($this->file['ext'],
['gif','jpg','jpeg','jpc','jp2','jpx','jb2','png','swf','psd','bmp','tiff','tif','w
bmp','xbm','ico','webp','avif','svg']);
}

// getimagesize() wrapper validates and re-formats output into $this->image array


private function getimagesize(){
// exit
if(!$this->file['is_readable'] || $this->file['ext'] === 'svg') return;

// getimagesize()
$imagesize = @getimagesize($this->realpath, $this->image_info);

// exit on invalid $imagesize


if(empty($imagesize) || !is_array($imagesize)) return;

// re-format properties from getimagesize() into $this->image array


foreach ([
'width',
'height',
'type',
'bits' => 'bits',
'channels' => 'channels',
'mime' => 'mime'
] as $key => $name) if(isset($imagesize[$key])) $this->image[$name] =
$imagesize[$key];

// valid if array is !empty


return !empty($this->image);
}

// if image is oriented by some Exif orientation values, we need to flip width


and height properties to match browser orientation
private function image_orientation_flip_dimensions(){
if(!isset($this->image['exif']['Orientation']) || !in_array($this-
>image['exif']['Orientation'], [5, 6, 7, 8])) return;
list($this->image['width'], $this->image['height']) = [$this->image['height'],
$this->image['width']]; // flip width/height
}

// find optional panorama sizes `_files_{size}_{filename.jpg}` for


equirectangular 2/1 aspect panorama images
private function image_panorama_sizes(){
// must be public image (url_path), with >= 2024 and exactly 2:1 aspect ratio
(equirectangular)
if(!$this->file['url_path'] || !$this->image['width'] || $this->image['width']
<= 2048 || $this->image['width'] / $this->image['height'] !== 2) return;
$resized = [];
foreach ([2048, 4096, 8192] as $width) { // look for sizes 2048, 4096 and 8192
if($width >= $this->image['width']) break; // break loop if resized image
width >= original already
if(file_exists($this->dir->realpath . '/_files_' . $width . '_' . $this-
>file['basename'])) $resized[] = $width;
}
if(!empty($resized)) $this->file['panorama_resized'] = array_reverse($resized);
}

// get image resize cache for direct access by javascript if config


image_resize_cache_direct
private function get_image_resize_cache(){
if(!U::image_resize_cache_direct()) return;
foreach ([Config::get('image_resize_dimensions'),
U::image_resize_dimensions_retina()] as $resize) {
if(!$resize) continue;
$cache_file = Path::imagecachepath($this->realpath, $resize, $this-
>file['filesize'], $this->file['mtime']);
if(file_exists($cache_file)) $this->image["resize$resize"] =
Path::urlpath($cache_file);
}
}

// read and parse .URL shortcut files and present as links /


https://fileinfo.com/extension/url
private function set_file_url(){
if(!$this->file['is_readable'] || $this->file['ext'] !== 'url') return;
$lines = @file($this->realpath);
if(empty($lines) || !is_array($lines)) return;
foreach ($lines as $str) {
if(!preg_match('/^url\s*=\s*([\S\s]+)/i', trim($str), $matches) || !
isset($matches[1])) continue;
$this->file['url'] = $matches[1];
break;
}
}
}

// class Iptc / extract IPTC image data from images


class Iptc {

// array of iptc entries with their corresponding codes to extract from IPTC,
which otherwise contains tons of junk
public static $entries = [
'title' => '005',
'headline' => '105',
'description' => '120',
'creator' => '080',
'credit' => '110',
'copyright' => '116',
'keywords' => '025',
'city' => '090',
'sub-location' => '092',
'province-state' => '095'
];

// get iptc tag values, might be an array (keywords) or first array item (string)
/ attempts to fix invalid utf8
private static function tag($value){

// might be an array like 'keywords', just output array


if(count($value) > 1) return $value;

// get trimmed string value


$string = isset($value[0]) && is_string($value[0]) ? trim($value[0]) : false;

// invalid or empty string


if(empty($string)) return false;

// clamp string at 1000 chars, because some messed up images include a dump of
garbled junk
$clamped = function_exists('mb_substr') ? @mb_substr($string, 0, 1000) :
@substr($string, 0, 1000);

// return original string if the above didn't work for some ridiculous reason
if(!$clamped) return $string;
// return string if we can't detect valid utf-8 or string is already valid utf-
8
if(!function_exists('mb_convert_encoding') || preg_match('//u', $clamped))
return $clamped;

// attempt to convert the encoding


$converted = @mb_convert_encoding($clamped, 'UTF-8', @mb_list_encodings());

// return converted value if successful


return $converted ?: $clamped;
}

// get iptc from $image_info (getimagesize($path, $image_info))


public static function get($i){

// get iptc from $image_info (getimagesize($path, $image_info))


$iptc = !empty($i) && is_array($i) && isset($i['APP13']) &&
function_exists('iptcparse') ? @iptcparse($i['APP13']) : false;

// return empty array if falsy, error or empty[] response


if(empty($iptc)) return [];

// populate $entries from $iptc


$output = [];
foreach (self::$entries as $name => $code) {
$value = isset($iptc['2#' . $code]) ? $iptc['2#' . $code] : false;
$output[$name] = !empty($value) && is_array($value) ? self::tag($value) :
false;
}

// return array with all non-empty values


return array_filter($output);
}
}

// class Exif / extract Exif image data from images


class Exif {

// these are the values we want


public static $entries = [
'ApertureFNumber', // from exif COMPUTED values
//'CCDWidth' // from exif COMPUTED values
//'DateTime', // only used to detect original photo time, if
DateTimeOriginal is not defined
'DateTimeOriginal', // original photo taken time, which will replace the file's
date
'ExposureTime',
//'FNumber',
'FocalLength',
//'Make', // normally pointless when there is 'Model'
'Model',
'Orientation', // used only for Javascript to detect the orientation of
the image and display appropriately
'ISOSpeedRatings',
//'Software',
];

// returns the exif data array from an image, if function exists and exif array
is valid
public static function exif_data($path){
$exif = function_exists('exif_read_data') ? @exif_read_data($path) : false;
return !empty($exif) && is_array($exif) ? $exif : false;
}

// get Exif $entries for a specific image


public static function get($path){

// get exif
$exif = self::exif_data($path);
if(!$exif) return;

// start output array


$output = [];

// loop $entries, check in $exif and $exif['COMPUTED'] and add to ouput


foreach (self::$entries as $key) {
$output[$key] = isset($exif[$key]) ? $exif[$key] : (isset($exif['COMPUTED']
[$key]) ? $exif['COMPUTED'][$key] : false);
}

// get GPS coordinates


$output['gps'] = self::gps($exif);

// remove empty values and return array


return array_filter($output);
}

// get GPS coordinates in array


private static function gps($exif){

// prepare array for coordinates


$arr = [];

// loop to get coordinates


foreach (['GPSLatitude', 'GPSLongitude'] as $key) {

// invalid exif
if(!isset($exif[$key]) || !isset($exif[$key . 'Ref'])) return false;

// coordinate array
$coordinate = is_string($exif[$key]) ? array_map('trim', explode(',',
$exif[$key])) : $exif[$key];

// loop
for ($i = 0; $i < 3; $i++) {
$part = explode('/', $coordinate[$i]);
if(count($part) == 1) {
$coordinate[$i] = $part[0];
} else if (count($part) == 2) {
if(empty($part[1])) return false; // invalid GPS, $part[1] can't be 0
$coordinate[$i] = floatval($part[0]) / floatval($part[1]);
} else {
$coordinate[$i] = 0;
}
}

// output
list($degrees, $minutes, $seconds) = $coordinate;
$sign = in_array($exif[$key . 'Ref'], ['W', 'S']) ? -1 : 1;
$arr[] = $sign * ($degrees + $minutes / 60 + $seconds / 3600);
}

// return array
return !empty($arr) ? $arr : false;
}
}

// class Filemanager / functions that handle file operations on server


class Filemanager {

// success counter for multi-item actions


static $success = 0;
static $count = 0;

// file manager actions JSON response / accepts true/false or array with success
property
public static function json($res, $err){

// create $arr from boolean with $arr['success'] or pass through existing array
$arr = is_array($res) ? $res : ['success' => $res];

// assign complete error if action was !success (not even partially success)
if(!isset($arr['success']) || empty($arr['success'])) return Json::error($err);

// on success, invalidate X3 cache if x3-plugin active


X3::invalidate();

// output success / remove empty values, because javascript don't need em


Json::jexit(array_filter($arr));
}

// check if name is allowed and return trimmed value / duplicate, new_file,


new_folder, rename, zip
// for security and practical reasons, don't allow invalid characters <>:"'/\|?*#
or .. or ends with .
public static function name_is_allowed($name = false){
return !empty($name) && is_string($name) && !ctype_space($name) && !
preg_match('/[<>:"\'\/\\\|?*#]|\.\.|\.$/', $name);
}

// get unique incremental filename for functions like duplicate and zip / default
increment name starts at 2
public static function get_unique_filename($path, $i = 2) {

// die if already unique


if(!file_exists($path)) return $path;

// break path into filename and extension


$pathinfo = pathinfo($path);
$filename = $pathinfo['filename']; // file name without extension for numbering
$ext = !is_dir($path) && !empty($pathinfo['extension']) ? '.' .
$pathinfo['extension'] : ''; // extension append to filename

// check if file is numbered already like file-3.jpg, so we can assign to file-


4.jpg instead of file-3-2.jpg
$numbered_name = explode('-', $filename);
$current_count = array_pop($numbered_name);
if(count($numbered_name) && is_numeric($current_count)) {
$filename = join('-', $numbered_name);
$i = $current_count + 1;
}

// increment filename if file already exists / default start by filename-2.ext


while (file_exists($path)) {
$path = $pathinfo['dirname'] . '/' . $filename . '-' . $i . $ext;
$i++;
}

// return first available $path


return $path;
}

// recursive iterator for copy, delete, duplicate


protected static function iterator($path, $mode =
RecursiveIteratorIterator::SELF_FIRST){
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path,
RecursiveDirectoryIterator::SKIP_DOTS), $mode,
RecursiveIteratorIterator::CATCH_GET_CHILD);
self::$count += iterator_count($iterator);
return $iterator;
}

// delete single file or folder


private static function delete_file_or_folder($path){
return is_dir($path) ? @rmdir($path) : @unlink($path);
}

// delete single file or folder recursively


public static function delete($path){

// if dir, iterate recursively and attempt to delete all descendants


// check if_writeable() will skip dirs that are not writeable, because we can't
delete direct children. However, we may still be able to delete deep descendants,
so might as well try to delete what can be deleted.
if(is_dir($path)/* && is_writable($path)*/) foreach (self::iterator($path,
RecursiveIteratorIterator::CHILD_FIRST) as $item) self::$success +=
self::delete_file_or_folder($item->getPathname());

// delete file or folder after first deleting recursive items in folder


return self::delete_file_or_folder($path);
}

// non-recursive copy single file or folder / creates folder when necessary


private static function copy_file_or_folder($from, $to){
// if(Path::is_exclude($to, is_dir($from))) return false; // exclude copy $to
paths? kinda pointless
if(Path::is_within_path($to, $from)) return false; // don't allow copying files
or dirs into self or same location
//if(!is_readable($from)) return false; // already checked in valid_rootpath()
filter
if(is_dir($from)) return is_dir($to) || @mkdir($to, 0777, true); // is_dir
already or make new dir
if(!is_readable($from)) return false; // can't read file source / might be
recursive file
if(file_exists($to) && filemtime($to) >= filemtime($from)) return false; //
file already exists and is newer than source
if(!@copy($from, $to)) return false; // attempt to copy from to / overwrite
existing older files in $to location
@touch($to, filemtime($from)); // inherit file modified time
return true; // return success
}

// copy single file or folder recursively / kinda how the default php copy()
should have worked? Also used for duplicate
public static function copy($from, $to){
if(!self::copy_file_or_folder($from, $to)) return false; // only continue on
success
if(is_dir($from)) {
$iterator = self::iterator($from);
foreach ($iterator as $descendant) self::$success +=
self::copy_file_or_folder($descendant, $to . '/' . $iterator->getSubPathName());
}
return true;
}

// move file or folder / uses rename() wihch is recursive by default


private static function move($from, $to){
// if(Path::is_exclude($to, is_dir($from))) return false; // exclude move $to
paths? Kinda pointless
if(Path::is_within_path($to, $from)) return false; // don't allow moving files
or dirs into self or same location
if(file_exists($to) && filemtime($to) >= filemtime($from)) return false; // $to
already exists and is newer than $from
return @rename($from, $to); // can overwrite existing older files, but fails to
overwrite non-empty dirs, which is ok
}

// duplicate single file or folder recursively incrementing filename (if filename


provided, use copy($from, $to) instead)
public static function duplicate($path){

// copy item recursively with unique incremental file name


return self::copy($path, self::get_unique_filename($path));
}

// run action on array of files and folders / return succes/fail array


public static function items($action, $paths, $dir = false){

// count paths / recursive iterators may add to count


self::$count = count($paths);

// loops paths
foreach ($paths as $path) self::$success += self::$action($path, ($dir ? $dir .
'/' . U::basename($path) : false));

// return success and fail count


return ['success' => self::$success, 'fail' => self::$count - self::$success];
}

// return an array of downloadable files from within an array of $paths


public static function get_downloadables($paths){

// prepare downloadables array


$downloadables = [];
// loop dir $paths / only dir $paths are forwarded to check recursively, as JS
already knows the files
foreach ($paths as $dir) {
if(Path::is_exclude($dir, true)) continue; // shouldn't be necessary when
forwarded from frontend, but just in case
foreach (self::iterator($dir) as $item) { // loop dirs get all descendants
$path = $item->getPathname();
// create download list from readable, non-excluded files only (not dirs,
as we don't download a dir)
if(!is_readable($path) || is_dir($path) || Path::is_exclude($path, false))
continue;
// send to Javascript
$downloadables[] = [
'path' => Path::relpath($path),
'url_path' => Path::urlpath($path),
'basename' => U::basename($path),
'ext' => U::extension($path),
'filesize' => filesize($path)
];
}
}

// return downloadables array


return $downloadables;
}
}

// class Zipper / extends Filemanager / create and extract zip files


class Zipper extends Filemanager {

// vars
private $zip;
private $is_json;

// construct check class_exists('ZipArchive') and create new ZipArchive


public function __construct($is_json = false){
$this->is_json = $is_json;
if(!class_exists('ZipArchive')) U::error('Missing PHP ZipArchive class', 500,
$this->is_json);
$this->zip = new ZipArchive();
}

// generic open zip with errors


private function open($dest, $flags = null){

// open zip
$res = @$this->zip->open($dest, $flags);

// return error type messages /


https://www.php.net/manual/en/ziparchive.open.php
$zip_open_errors = [
4 => 'Seek error.',
5 => 'Read error.',
9 => 'No such file.',
11 => 'Can\'t open file.',
10 => 'File already exists.',
14 => 'Malloc failure.',
18 => 'Invalid argument.',
19 => 'Not a zip archive.',
21 => 'Zip archive inconsistent.'
];

// die if error
if($res !== true) U::error($res && isset($zip_open_errors[$res]) ?
$zip_open_errors[$res] : 'Unknown zip error', 500, $this->is_json);
}

// extract $zip_file into $dir (optional) / $dir is parent of zip if not set
public function extract($zip_file, $dir = false){

// check valid zip


if(!is_file($zip_file) || U::extension($zip_file, true) !== 'zip' ||
empty(filesize($zip_file))) U::error('Invalid zip file', 400, $this->is_json);

// $dir is parent of zip if not set


if(!$dir) $dir = dirname($zip_file);

// check target_dir writeable


if(!is_writable($dir)) U::error('Target dir is not writeable', 403, $this-
>is_json);

// open zip file


$this->open($zip_file);

// extract to target_dir
$success = @$this->zip->extractTo($dir);

// return always close() and $success


return @$this->zip->close() && $success;
}

// create a new $zip_file from multiple $paths


public function create($paths, $zip_file = false){

//
$first_path = reset($paths);

// create zip root from first array path, so we can create relative local paths
inside zip
$root = dirname($first_path) . '/';

// unique incremental 'archive.zip' if multiple paths or filename.jpg.zip


(don't append .zip if extension already zip)
if(!$zip_file) $zip_file = self::get_unique_filename($root . (count($paths) > 1
? 'archive.zip' : U::basename($first_path) . (U::extension($first_path, true) !==
'zip' ? '.zip' : '')));

// create new zip file or die


$this->open($zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE);

// loop $paths to add files


foreach ($paths as $path) {

// add file or dir / if added and is_dir, add file or dir recursively
if($this->add_file_or_dir($path, $root) && is_dir($path))
foreach(self::iterator($path) as $file) $this->add_file_or_dir($file, $root);
}
// detect files count in zip
$num_files = version_compare(PHP_VERSION, '7.2.0') >= 0 ? $this->zip->count() :
$this->zip->numFiles;

// success only if close() and has files and zip file exists in tar
return $this->zip->close() && !empty($num_files) && file_exists($zip_file);
}

// add_file_or_dir
private function add_file_or_dir($path, $root){
if(Path::is_exclude($path, is_dir($path)) || !is_readable($path)) return false;
// file excluded, continue
$local_path = str_replace($root, '', $path); // local path relative to root
return is_dir($path) ? @$this->zip->addEmptyDir($local_path) : @$this->zip-
>addFile($path, $local_path);
}
}

// class Request / extract parameters for all actions


class Request {

// vars
public $action;
public $params;
private $is_post;

// construct
public function __construct(){
$this->action = U::get('action');
$this->is_post = $_SERVER['REQUEST_METHOD'] === 'POST';
// check that request method matches action, so we can't make POST requests
from GET / this should be improved
if($this->is_post === in_array($this->action, ['download_dir_zip', 'preview',
'file', 'download', 'tasks', 'tests'])) $this->error('Invalid request method ' .
$_SERVER['REQUEST_METHOD']);
$this->params = $this->get_request_data();
if(!is_array($this->params)) $this->error('Invalid parameters');
}

// get request data parameters


public function get_request_data(){
if(!$this->is_post) return $_GET;
if(isset($_POST) && !empty($_POST)) return $_POST;
$php_input = @json_decode(@trim(@file_get_contents('php://input')), true); //
javascript fetch()
return $php_input ?: [];
}

// get specific string value parameter from data (dir, file path etc)
public function param($param){
if(!isset($this->params[$param])) return false;
if(!is_string($this->params[$param])) $this->error("Invalid $param parameter");
// must be string if exists
return trim($this->params[$param]); // trim it
}

// error response based on request type / 400 Bad Request default / 401, 403,
404, 500
public function error($err, $code = 400){
if($this->is_post) return Json::error($err);
U::error($err, $code);
}
}

// class Document / creates the main Files Gallery document response


class Document {

// private Document class vars


private $start_path = ''; // start_path extracted and validated from query or
$config['start_path']
private $absolute_start_path = false; // absolute path of start_path, for
validation and dirs preload
private $dirs = []; // array of dirs to be preloaded, normally root and query or
start_path (if not same as root)
private $menu_exists = false; // determines if menu exists from config and checks
for dirs in root
private $menu_cache_hash = false; // assign a menu cache hash so menu cache can
be validated on load
private $menu_cache_file = false; // assign direct access to menu json cache file
when menu_cache_validate is disabled

// document construct tasks


public function __construct(){

// first we must get and validate start_path from ?query or config start_path
$this->get_start_path();

// always get root dir array (outputs as json, for javascript)


$this->dirs[''] = (new Dir(Config::$root))->get();

// get start path dir (if valid and not same as root, in case symlinked)
if($this->absolute_start_path && Path::realpath($this->absolute_start_path) !==
Config::$root) $this->dirs[$this->start_path] = (new Dir($this-
>absolute_start_path))->get();

// prepare menu variables menu_exists, menu_cache_hash and menu_cache_file


$this->prepare_menu();

// output main Files Gallery document HTML


$this->HTML();
}

// get start_path from ?query or from config start_path (if set)


private function get_start_path(){

// first check if we have a query path


$query_path = $this->get_query_path();

// we have a query path (although it's not necessarily a valid dir)


if($query_path){

// assign query_path as start_path


$this->start_path = $query_path;

// check and return valid root path from query path


$this->absolute_start_path = Path::valid_rootpath($this->start_path, true);

// start path from config with error response invalid (path must exist, non-
excluded and must be inside root)
} else if(Config::get('start_path')) {

// get realpath from config start_path


$this->absolute_start_path = Path::realpath(Config::get('start_path'));

// error if path does not exist or !is within root or is_exclude


if(!$this->absolute_start_path || !Path::is_within_path($this-
>absolute_start_path, Config::$root) || Path::is_exclude($this-
>absolute_start_path)) U::error('Invalid start_path ' . Config::get('start_path'));

// assign root-relative start_path to forward to javascript


$this->start_path = Path::relpath($this->absolute_start_path);
}
}

// parse query_string and get first ?parameter to be considered path


private function get_query_path(){
if(!Config::get('history') || empty($_SERVER['QUERY_STRING'])) return; // only
if history and QUERY_STRING
$path = explode('&', $_SERVER['QUERY_STRING'])[0]; // get first parameter in
QUERY_STRING for path
if(!$path || strpos($path, '=') !== false) return; // make sure path exists and
is not assigned parameter=value
return trim(rawurldecode($path), '/'); // trime and decode
}

// prepare main menu variables


private function prepare_menu(){

// exit if !menu_enabled
if(!Config::get('menu_enabled')) return;

// get root dirs / used to decide if menu_exists, breadcrumbs and to generate


shallow menu_cache_hash
$root_dirs = array_filter(glob(Config::$root . '/*', GLOB_ONLYDIR|GLOB_NOSORT),
function($dir){
return !Path::is_exclude($dir, true, is_link($dir));
});

// menu exists only if root_dirs is not empty


$this->menu_exists = !empty($root_dirs);

// exit if !menu_exists
if(!$this->menu_exists) return;

// get menu_cache_hash used to validate first level shallow menu cache and when
!menu_cache_validate
$this->get_menu_cache_hash($root_dirs);

// get JSON menu_cache_file to forward to Javascript if menu_cache_validate is


disabled
$this->get_menu_cache_file();
}

// menu_cache_hash used to validate first level shallow menu cache (no validation
required) and when !menu_cache_validate
private function get_menu_cache_hash($root_dirs){
$mtime_count = filemtime(Config::$root);
foreach ($root_dirs as $root_dir) $mtime_count += filemtime($root_dir);
// create hash based on various parameters that may affect the menu
$this->menu_cache_hash = substr(md5(Config::$document_root . Config::
$__dir__ . Config::$root), 0, 6) . '.' . substr(md5(Config::$version .
Config::get('cache_key') . Config::get('menu_max_depth') .
Config::get('menu_load_all') . (Config::get('menu_load_all') ?
Config::get('files_exclude') . U::image_resize_cache_direct() : '') . Config::
$has_login . Config::get('dirs_exclude') . Config::get('menu_sort')), 0, 6) . '.' .
$mtime_count;
}

// get JSON menu_cache_file to forward to Javascript if menu_cache_validate is


disabled
private function get_menu_cache_file(){

// exit if menu_cache_validate or !cache or !storage is_within_doc_root


if(Config::get('menu_cache_validate') || !Config::get('cache') || !
Path::is_within_docroot(Config::$storagepath)) return;

// check if valid menu json cache file exists


$path = Config::$cachepath . '/menu/' . $this->menu_cache_hash . '.json';
$url_path = file_exists($path) ? Path::urlpath($path) : false;
if($url_path) $this->menu_cache_file = $url_path . '?' . filemtime($path);
}

// output main Files Gallery document HTML


private function HTML(){

// main document, output version, request time and memory


U::header('Version ' . Config::$version);

// main document html start


U::html_header($this->start_path ? U::basename($this->start_path) : './',
'menu-' . ($this->menu_exists ? 'enabled' : 'disabled sidebar-closed'));
?>
<body class="body-loading">
<main id="main">
<nav id="topbar"<?php if(Config::get('topbar_sticky')) echo '
class="topbar-sticky"'; ?>>
<div id="topbar-top">
<div id="search-container"><input id="search" class="input"
type="search" placeholder="search" size="1" spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="off" disabled></div>
<div id="change-layout" class="dropdown"></div>
<div id="change-sort" class="dropdown"></div>
</div>
<div id="topbar-breadcrumbs"></div>
<div id="files-sortbar"></div>
<div class="topbar-select"></div>
<div id="topbar-info" class="info-hidden"></div>
</nav>
<div id="files-container"><div id="files" class="list files-<?php echo
Config::get('layout'); ?>"></div></div>
</main>

<?php if($this->menu_exists) { ?>


<aside id="sidebar">
<button id="sidebar-toggle" type="button" class="button-icon"></button>
<div id="sidebar-inner">
<div id="sidebar-topbar"></div>
<div id="sidebar-menu"></div>
</div>
</aside>
<div id="sidebar-bg"></div>
<?php } ?>

<div id="contextmenu" class="dropdown-menu" tabindex="-1"></div>

<?php U::uinclude('include/footer.html'); ?>

<!-- javascript -->


<script>
const _c = <?php echo json_encode($this->get_javascript_config(), JSON_PRETTY_PRINT
| JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR); ?>;
var CodeMirror = {};
</script>
<?php

// load _files/js/custom.js if the file exists


U::uinclude('js/custom.js');

// preload all Javascript assets


foreach (array_filter([
'[email protected]/src/toastify.min.js',
'[email protected]/dist/sweetalert2.min.js',
'[email protected]/lib/anime.min.js',
'[email protected]/dist/yall.min.js',
'[email protected]/lib/filesize.min.js',
'[email protected]/dist/screenfull.min.js',
'[email protected]/dayjs.min.js',
'[email protected]/plugin/localizedFormat.js',
'[email protected]/plugin/relativeTime.js',
(in_array(Config::get('download_dir'), ['zip', 'files']) ? 'js-file-
[email protected]/dist/js-file-downloader.min.js' : false),
'[email protected]/dist/FileSaver.min.js',
'[email protected]/dist/jszip.min.js',
'[email protected]/mode/meta.js',
'files.photo.gallery@' . Config::$version . '/js/files.js'
]) as $key) echo '<script src="' . U::assetspath() . $key . '"></script>' .
PHP_EOL;
?></body></html><?php
// end HTML
}

// get Javascript config array / includes config properties and calculated values
specifically for Javascript
private function get_javascript_config(){

// exclude config user settings for frontend (Javascript) when sensitive and/or
not used in frontend
$exclude = [
'root',
'start_path',
'image_resize_cache',
'image_resize_quality',
'image_resize_function',
'image_resize_cache_direct',
'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'
];

// create config array without excluded items


$config = array_diff_key(Config::$config, array_flip($exclude));

// return Javascript config array, merged (some values overridden) with main
$config
return array_replace($config, [
'script' => U::basename(__FILE__), // so JS knows where to post
'menu_exists' => $this->menu_exists, // so JS knows if menu exists
'menu_cache_hash' => $this->menu_cache_hash, // hash to post from JS when
loading menu to check cache
'menu_cache_file' => $this->menu_cache_file, // direct url to JSON menu cache
file if !menu_cache_validate
'start_path' => $this->start_path, // assign calculated start_path for first
JS load
'query_path_invalid' => $this->start_path && !$this->absolute_start_path, //
invalid query path forward to JS
'dirs' => $this->dirs, // preload dirs array for Javascript, will be served
as json
'dirs_hash' => U::dirs_hash(), // dirs_hash to manage JS localStorage
'resize_image_types' => U::resize_image_types(), // let JS know what image
types can be resized
'image_cache_hash' => $this->get_image_cache_hash(), // image cache hash to
prevent expired cached/proxy images
'image_resize_dimensions_retina' => U::image_resize_dimensions_retina(), //
calculated retina
'location_hash' => md5(Config::$root), // so JS can assume localStorage for
relative paths like menu items open
'has_login' => Config::$has_login, // for logout interface
'version' => Config::$version, // forward version to JS
'index_html' => intval(U::get('index_html')), // popuplated when index.html
is published by plugins/files.tasks.php
'server_exif' => function_exists('exif_read_data'), // so images can be
oriented from exif orientation if detected
'image_resize_memory_limit' => $this->get_image_resize_memory_limit(), // so
JS can calculate what images can be resized
'md5' => $this->get_md5('6c6963656e73655f6b6579'), // calculate md5 hash
'video_thumbs_enabled' => !!U::ffmpeg_path(), // so JS can attempt to load
video preview images
'lang_custom' => $this->lang_custom(), // get custom language files
_files/lang/*.json
'x3_path' => X3::urlpath(), // in case of used with X3, forward X3 url path
for thumbnails
'userx' => isset($_SERVER['USERX']) ? $_SERVER['USERX'] : false, // forward
USERX from server (if set)
'assets' => U::assetspath(), // calculated assets path (Javascript and CSS
files from CDN or local)
'watermark_files' => $this->get_watermark_files(), // get uploaded watermark
files (font, image) from _files/watermark/*
'ZipArchive_enabled' => class_exists('ZipArchive'), // required for zip and
unzip functions on server
'upload_max_filesize' => $this->get_upload_max_filesize() // let the upload
interface know upload_max_filesize
]);
}

// get image cache hash from settings, used by JS when loading images, to prevent
expired images from being served by cache/proxy
private function get_image_cache_hash(){
if(!Config::get('load_images')) return false; // exit
return substr(md5(Config::$document_root . Config::$root .
Config::get('image_resize_function') . Config::get('image_resize_quality')), 0, 6);
}

// get image resize memory_limit so JS can calculate at what dimensions images


can be resized
private function get_image_resize_memory_limit(){
if(!function_exists('ini_set')) return U::get_memory_limit_mb();
return (int) max(U::get_memory_limit_mb(),
Config::get('image_resize_memory_limit'));
}

// calculate md5 hash from string


private function get_md5($str){
$str = Config::get(hex2bin($str));
return $str ? md5($str) : false;
}

// look for custom language files in _files/lang/*.json and forward to Javascript


private function lang_custom() {
if(!Config::$storagepath) return false;
$dir = Config::$storagepath . '/lang'; // custom languages path
$files = is_dir($dir) ? glob($dir . '/*.json') : false; // get language json
files
if(empty($files)) return false; // exit
$langs = []; // start languages array
foreach ($files as $path) { // loop language files
$json = @file_get_contents($path);
$data = !empty($json) ? @json_decode($json, true) : false;
if(!empty($data)) $langs[strtok(U::basename($path), '.')] = $data; // assign
language as array
}
return !empty($langs) ? $langs : false; // return array of languages with
values
}

// search for watermark files (font, image) in _files/watermark/* for Uppy


Compressor
private function get_watermark_files() {
if(!Config::get('allow_upload') || !Config::$storagepath || !
Path::is_within_docroot(Config::$storagepath)) return false;
$dir = Config::$storagepath . '/watermark'; // _files/watermark
if(!file_exists($dir) || !is_readable($dir)) return false; // exit
$files = @glob($dir . '/*', GLOB_NOSORT); // get files in _files/watermark/*
return array_filter(array_map(function($file){
return Path::urlpath($file); // map results to relative url's loadable from
Javascript
}, $files ?: [])); // default to empty array [] just in case there was some
error
}

// get upload_max_filesize for uploader interface, limited by PHP


upload_max_filesize, post_max_size and config upload_max_filesize
private function get_upload_max_filesize(){
if(!Config::get('allow_upload')) return 0; // just return 0 if upload is
disabled
$arr = array_filter([U::ini_value_to_bytes('upload_max_filesize'),
U::ini_value_to_bytes('post_max_size'), Config::get('upload_max_filesize')]); //
don't include falsy values
return empty($arr) ? 0 : min($arr);
}
}

/* Files Gallery application logic starts here */

// set UTF-8 locale so that basename() and other string functions work correctly
with multi-byte strings.
setlocale(LC_ALL, 'en_US.UTF-8');

// start new Config()


new Config();

// start new Login()


if(Config::$has_login) new Login();

// process actions ?action=


if(U::get('action')){

// start new request


$request = new Request();

// action shortcut
$action = $request->action;

// only allow valid actions


if(!in_array($action, ['files', 'dirs', 'load_text_file', 'check_updates',
'do_update', 'save_license', 'delete', 'text_edit', 'unzip', 'rename', 'new_file',
'new_folder', 'zip', 'copy', 'move', 'duplicate', 'get_downloadables', 'upload',
'download_dir_zip', 'preview', 'file', 'download', 'tasks', 'tests'])) $request-
>error("Invalid action '$action'");

// check if actions with config allow_{$ACTION} (most write actions) are allowed
if(isset(Config::$config['allow_' . $action]) && !Config::get('allow_' .
$action)) $request->error("$action not allowed");

// block all write actions in demo mode (that's what demo_mode option is for)
if(Config::get('demo_mode') && in_array($action, ['upload', 'delete', 'rename',
'new_folder', 'new_file', 'duplicate', 'text_edit', 'zip', 'unzip', 'move',
'copy'])) $request->error("$action not allowed in demo mode");

// block all download actions [get_downloadables, download_dir_zip, download]


if !allow_download
if(!Config::get('allow_download') && strpos($action, 'download') !== false)
$request->error('Download not allowed');

// block all mass download actions [get_downloadables, download_dir_zip] if !


allow_mass_download
if(!Config::get('allow_mass_download') && in_array($action, ['get_downloadables',
'download_dir_zip'])) $request->error('Mass download not allowed');

// prepare and validate $dir (full) from ?file parameter (if isset) for various
actions
$dir = $request->param('dir');
if($dir !== false){ // explicitly check !== false because $dir could be valid ''
empty (root)
$dir = Path::valid_rootpath($dir, true);
if(!$dir) $request->error('Invalid dir path');
// some actions require $dir to be writeable
if(in_array($action, ['copy', 'move', 'unzip', 'upload']) && !
is_writable($dir)) $request->error('Dir is not writeable');
// actions that strictly require $dir (it's optional for some actions)
} else if(in_array($action, ['files', 'copy', 'move', 'upload',
'download_dir_zip', 'preview'])){
$request->error('Missing dir parameter');
}

// prepare and validate $file (full) from ?file parameter (if isset) for various
actions
$file = $request->param('file');
if($file){
$file = Path::valid_rootpath($file);
if(!$file) $request->error('Invalid file path');
// actions that strictly require $file
} else if(in_array($action, ['load_text_file', 'file', 'download'])){
$request->error('Missing file parameter');
}

// validate items for Filemanager actions (all but upload)


if(isset($request->params['items'])){

// assign $items
$items = $request->params['items'];

// invalid $items if false, empty array or !array


if(empty($items) || !is_array($items)) $request->error('Invalid items
parameter');

// assign [paths] / make sure each item.path exists, is valid, not excluded,
and relative to Config::$root
$paths = array_values(array_filter(array_map(function($item){
return Path::valid_rootpath($item['path'], $item['is_dir']);
}, $items)));

// no valid item paths


if(empty($paths)) $request->error('Invalid item paths');
// shortcut because many actions only apply to a single item
$first_path = reset($paths);

// only actions new_file and new_file are allowed on root dir / other actions
don't make sense for root
if($first_path === Config::$root && !in_array($action, ['new_file',
'new_folder'])) $request->error("Can't $action root directory");

// prepare $new_path for actions rename (required), new_file, new_folder, zip


and duplicate
$new_path = false; // instantiate var because it's optional for all actions
except rename
$name = $request->param('name'); // get ?name parameter
if($name !== false) {

// check if $name is allowed


if(!Filemanager::name_is_allowed($name)) $request->error(trim("Invalid name
$name"));

// get parent dir / if new_folder or new_file, use selected item path, else
dirname() of selected item path
$parent_dir = in_array($action, ['new_folder', 'new_file']) ? $first_path :
dirname($first_path);
if(!is_dir($parent_dir)) $request->error('Not a directory'); // parent path
must be dir
if(!is_writable($parent_dir)) $request->error('Dir is not writeable'); // dir
must be writeable

// assign $new_path from $name


$new_path = $parent_dir . '/' . $name;
if(file_exists($new_path)) $request->error((is_dir($new_path) ? 'Dir' :
'File') . ' already exists');
}

// actions that require items parameter


} else if(in_array($action, ['copy', 'delete', 'duplicate', 'get_downloadables',
'move', 'new_file', 'new_folder', 'rename', 'text_edit', 'unzip', 'zip'])){
$request->error('Missing items parameter');
}

/* ACTIONS */

// get files from dir_target


if($action === 'files') {

// output dir array in json format (checks json cache first)


(new Dir($dir))->json();

// get dirs for menu


} else if($action=== 'dirs'){
new Dirs();

// read text file


} else if($action === 'load_text_file'){
if(filesize($file) > Config::get('code_max_load')) U::error('File size exceeds
`code_max_load`', 400);
header('content-type: text/plain; charset=UTF-8');
if(@readfile($file) === false) U::error('failed to read file', 500);
// check Files Gallery updates JSON file from jsdelivr.com repository
} 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::jexit([
'success' => $latest,
'writeable' => $latest && is_writable(__FILE__) // only check if __FILE__ is
writeable if $latest
]);

// attempt to update Files Gallery index.php to latest version via remote


repository jsdelivr.com
} else if($action === 'do_update'){
// various requirements, which would normally be satisfied if accessed from the
interface
$version = $request->param('version');
if(!$version || !Config::get('allow_check_updates') ||
version_compare($version, Config::$version) <= 0 || !is_writable(__FILE__))
$request->error('Error');
$get = @file_get_contents('https://cdn.jsdelivr.net/npm/files.photo.gallery@' .
$version . '/index.php');
if(empty($get) || strpos($get, '<?php') !== 0 || !@file_put_contents(__FILE__,
$get)) Json::error('failed to update');
Json::jexit(['success' => true]);

// save input license key to user config


} else if($action === 'save_license'){
$key = $request->param('key');
Json::jexit([
'success' => $key && Config::$storageconfigpath &&
Config::save(['license_key' => $key]),
'md5' => $key ? md5($key) : false
]);

// delete items
} else if($action === 'delete') {
Filemanager::json(Filemanager::items('delete', $paths), 'failed to delete
items');

// text_edit write to file


} else if($action === 'text_edit'){
if(!isset($request->params['text']) || !is_string($request->params['text']))
$request->error('Invalid text parameter');
if(!is_writeable($first_path)) $request->error('File is not writeable');
if(!is_file($first_path)) $request->error('Not a file');
if(@file_put_contents($first_path, $request->params['text']) === false)
$request->error('failed to write to file', 500);
@touch(dirname($first_path)); // invalidate cache by updating parent dir mtime
Filemanager::json(true, 'failed to write to file');

// unzip zip file


} else if($action === 'unzip'){

// extract single zip file to $dir / if !$dir, it uses zip file parent
Filemanager::json((new Zipper(true))->extract($first_path, $dir), 'Failed to
extract zip file');
// rename
} else if($action === 'rename'){

// new_path (derrved from $name) is required for rename action


if(!$new_path) $request->error('Missing name parameter');

// attempt to rename single file


Filemanager::json(@rename($first_path, $new_path), 'Rename failed');

// new_file
} else if($action === 'new_file'){

// attempt to create new file from $new_path or assign unique incremental


filename "untitled-file.txt"

Filemanager::json(@touch($new_path?:Filemanager::get_unique_filename($first_path .
'/untitled-file.txt')), 'Create new file failed');

// new_folder
} else if($action === 'new_folder'){

// attempt to create new directory from $new_path or assign unique incremental


folder name from "untitled-folder"

Filemanager::json(@mkdir($new_path?:Filemanager::get_unique_filename($first_path .
'/untitled-folder')), 'Create new folder failed');

// zip items / $new_path is optional, will create auto-named zip in current dir
if empty
} else if($action === 'zip') {
Filemanager::json((new Zipper(true))->create($paths, $new_path), 'Failed to zip
items');

// copy or move use identical pre-process


} else if(in_array($action, ['copy', 'move'])) {

// don't allow copy/move items over themselves copy/move dirs into themselves
(may cause infinite recursion)
// this is already blocked in copy function, but better detect up front for
items array and respond appropriately
$valid_copy_move_paths = array_filter($paths, function($path) use ($dir){
return !Path::is_within_path($dir . '/' . U::basename($path), $path);
});

// can't copy/move into self error


if(empty($valid_copy_move_paths)) $request->error("can't $action into self");

// response
Filemanager::json(Filemanager::items($action, $valid_copy_move_paths, $dir),
$action . ' failed');

// duplicate / really just a shortcut for copy into same dir


} else if($action === 'duplicate') {

// duplicates a single item with provided $name (pre-assigned to $new_path)


if($new_path) Filemanager::json(Filemanager::copy($first_path, $new_path),
'duplicate failed');
// duplicates an array of files and dirs, automatically incrementing file names
Filemanager::json(Filemanager::items('duplicate', $paths), 'duplicate failed');

// get_downloadables returns an array of downloadable files recursively from an


array of directories
} else if($action === 'get_downloadables'){

// return an array of downloadable files from within an array of $paths


Json::jexit(Filemanager::get_downloadables($paths));

// upload
} else if($action === 'upload'){

// get $_FILES['file'] array


$upload = isset($_FILES) && isset($_FILES['file']) && is_array($_FILES['file'])
? $_FILES['file'] : false;

// invalid $_FILES['file']
if(empty($upload) || !isset($upload['error']) || is_array($upload['error']))
$request->error('Invalid $_FILES[]');

// PHP meaningful file upload errors /


https://www.php.net/manual/en/features.file-upload.errors.php
if($upload['error'] !== 0) {
$upload_errors = [
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.'
];
$request->error(isset($upload_errors[$upload['error']]) ?
$upload_errors[$upload['error']] : 'unknown error');
}

// invalid $upload['size']
if(!isset($upload['size']) || empty($upload['size'])) $request->error('Invalid
file size');

// $upload['size'] must not exceed $config['upload_max_filesize']


if(Config::get('upload_max_filesize') && $upload['size'] >
Config::get('upload_max_filesize')) $request->error('File size [' . $upload['size']
. '] exceeds upload_max_filesize option [' . Config::get('upload_max_filesize') .
']');

// get filename
$filename = $upload['name'];

// for security reasons, slashes are never allowed in file names


if(strpos($filename, '/') !== false || strpos($filename, '\\') !== false)
$request->error('Illegal \slash/ in filename ' . $filename);

// get allowed_file_types / 'image/*, .pdf, .mp4'


$allowed_file_types = Config::get('upload_allowed_file_types') ?
array_filter(array_map('trim', explode(',',
Config::get('upload_allowed_file_types')))) : false;
// check allowed_file_types
if(!empty($allowed_file_types)){
$mime = U::mime($upload['tmp_name']) ?: $upload['type']; // mime from PHP or
upload[type]
$ext = U::extension($filename, true, true); // get extension lowercase
starting with .dot
$is_valid = false; // default !is_valid until validated
// 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;
}

// invalid file type


if(!$is_valid) $request->error("Invalid file type $filename");

// for additional security, check if uploaded image is an actual image with


exif_imagetype() function
if(function_exists('exif_imagetype') && in_array($ext, ['.gif', '.jpeg',
'.jpg', '.png', '.swf', '.psd', '.bmp', '.tif', '.tiff', 'webp', 'avif']) && !
@exif_imagetype($upload['tmp_name'])) $request->error("Invalid image type
$filename");
}

// create subdirs when relativePath exists (keeps folder structure from drag
and drop)
$relative_path = $request->param('relativePath');
if(!empty($relative_path) && $relative_path != 'null' && $relative_path !=
$filename && strpos($relative_path, '/') !== false){
$new_dir = dirname("$dir/$relative_path");
if(file_exists($new_dir) || @mkdir($new_dir, 0777, true)) $dir = $new_dir;
}

// assign move to path


$move_path = "$dir/$filename";

// fail if config upload_exists === false


if(Config::get('upload_exists') === 'fail' && file_exists($move_path))
$request->error("$filename already exists");

// increment file name if file name already exists


if(Config::get('upload_exists') === 'increment') $move_path =
Filemanager::get_unique_filename($move_path);

// all is well! attempt to move_uploaded_file() / JSON RESPONSE


Filemanager::json([
'success' => @move_uploaded_file($upload['tmp_name'], $move_path),
'filename' => $filename, // return filename in case it was incremented or
renamed
'url' => Path::urlpath($move_path) // for usage with
showLinkToFileUploadResult
], 'failed to move_uploaded_file()');

// $_GET download_dir_zip / download files in directory as zip file


} else if($action === 'download_dir_zip'){

// check download_dir enabled


if(Config::get('download_dir') !== 'zip') $request->error('download_dir zip
disabled');

// create zip cache directly in dir (recommended, so that dir can be renamed
while zip cache remains)
if(!Config::$storagepath || Config::get('download_dir_cache') === 'dir') {
if(!is_writable($dir)) $request->error('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 {
U::mkdir(Config::$storagepath . '/zip');
$zip_file_name = U::basename($dir) . '.' . substr(md5($dir), 0, 6) . '.zip';
$zip_file = Config::$storagepath . '/zip/' . $zip_file_name;
}

// cached / download_dir_cache && file_exists() && zip is not older than dir
time
$cached = Config::get('download_dir_cache') && file_exists($zip_file) &&
filemtime($zip_file) >= filemtime($dir);

// create zip if !cached


if(!$cached && !(new Zipper())->create([$dir], $zip_file)) $request-
>error('Failed to create ZIP file', 500);

// ignore user abort so we can delete file also on download cancel


if(!Config::get('download_dir_cache')) @ignore_user_abort(true);

// output zip file as download using correct headers and readfile()


U::download($zip_file, $zip_file_name . ($cached ? ' cached' : ' created'),
'application/zip', U::basename($dir) . '.zip');

// delete temporary zip file if cache disabled


if(!Config::get('download_dir_cache')) @unlink($zip_file);

// $_GET folder preview from images/video inside dir


} else if($action === 'preview'){

// allow folder preview image only if folder_preview_image, load_images,


image_resize_enabled and cache
foreach (['folder_preview_image', 'load_images', 'image_resize_enabled',
'image_resize_cache'] as $key) if(!Config::get($key)) $request->error("Config
option $key disabled", 403);

// 1. first check if default folder_preview_default '_filespreview.jpg' exists


in dir / must be resized
$default = Config::get('folder_preview_default') ? $dir . '/' .
Config::get('folder_preview_default') : false;
if($default && file_exists($default)) {
U::message(Config::get('folder_preview_default'));
new ResizeImage($default, Config::get('image_resize_dimensions')); // default
resize for small preview images
}

// 2. assign cache path


$cache = Config::$cachepath . '/images/preview.' . substr(md5($dir), 0, 6) .
'.jpg';
// check if preview cache file exists / _files/cache/images/preview.HASH.jpg
if(file_exists($cache)) {

// make sure cache file is valid (must be newer than dir updated time)
if(filemtime($cache) >= filemtime($dir)) U::readfile($cache, 'image/jpeg',
'Preview image from cache', true);

// delete expired cache file if is older than dir updated time [silent]
@unlink($cache);
}

// 3. glob files to look for images and video


$files = U::glob("$dir/*");

// files found
if(!empty($files)) {

// prepare arrays of supported image and video formats


$image_types = U::resize_image_types();
$video_types = U::ffmpeg_path() ? ['mp4', 'm4v', 'm4p', 'webm', 'ogv', 'mkv',
'avi', 'mov', 'wmv'] : [];

// loop files to locate first match


foreach ($files as $file) {

// get extension lowercase


$ext = U::extension($file, true);
if(empty($ext)) continue; // skip if no extension

// match image or video, return target resize_dimensions if image


$match = in_array($ext, $image_types) ?
Config::get('image_resize_dimensions') : (in_array($ext, $video_types) ? 'video' :
false);
if(!$match) continue; // skip if extension not supported

// skip if is_exclude or !readable


if(Path::is_exclude($file, false) || !is_readable($file)) continue;

// get preview image ro video, and clone into preview $cache for faster
access on next request for dir
new FileResponse($file, $match, $cache);
break; exit; // just in case, although new FileResponse() will exit on
U::readfile()
}
}

// 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)) U::readfile($cache, 'image/jpeg', '1px
placeholder image created and cached', true);

// $_GET file / resize parameter for preview images, else will proxy any file
} else if($action === 'file'){
new FileResponse($file, U::get('resize'));

// $_GET force download single file by PHP


} else if($action === 'download'){
// output file as download using correct headers and readfile()
U::download($file, 'Download ' . U::basename($file), U::mime($file) ?:
'application/octet-stream', U::basename($file));

// $_GET tasks plugin (for pre-caching or clearing cache, not official plugin yet
...)
} else if($action === 'tasks'){
if(!U::uinclude('plugins/files.tasks.php')) $request->error('Can\'t find tasks
plugin', 404);

// output PHP and server features by url ?action=tests / for diagnostics only
} else if($action === 'tests'){
new Tests();

// invalid action 400


} else {
$request->error("Invalid action $action");
}

// output main Files Gallery document html if !action


} else {
new Document();
}

// THE END!

You might also like