Exp2 PHP
Exp2 PHP
php
// set absolute storagepath, create storage dirs if required, and load, create
or update storage config.php
$this->storage();
// 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);
}
// 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);
// 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);
// 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
// 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());
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
}
// exit with error on ?action request (is not login attempt, and don't show login
form)
private function unauthorized(){
// 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
}
// 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);
}
// 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 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(){
// 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;
}
// 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);
}
// 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
}
// 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;
// class Path / various static functions to convert and validate file paths
class Path {
// 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)), '\/');
}
// 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);
}
// invalid if is file and path is empty (path can be '' empty string for root
dir)
if(!$is_dir && empty($relpath)) return;
// 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
// 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;
// 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){
// 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;
}
}
// 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;
}
// 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;
}
// class Tests / outputs PHP, server and config diagnostics by url ?action=tests
class Tests {
// html response
private $html = '';
// first let's check if new Config() created dirs and files in storagepath
$this->created();
// 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 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());
// output merged config in readable format, with sensitive properties masked out
private function showconfig(){
// 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;
// resize numeric value assigned if image resize, but could be set to 'video'
$this->resize = is_numeric($resize) ? intval($resize) : $resize;
// 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();
}
// 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);
// 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);
// 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(){
// 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");
}
// 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);
// 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);
// set a different fill color than black (default) for images with transparency
/ disabled by default $fill_color = []
$this->set_fill_color($ext);
// 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');
// 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;
}
// 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)));
}
// 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) . ')');
}
}
// 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(){
// 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";
// 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]));
// load data for dir / ignore depth 0 (root), because it's already loaded,
unless load_files
if($depth || $this->load_files) {
// 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;
}
// 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
// get dir json from cache, or reload / used by main dir files action request
public function json(){
// 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)
];
// 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();
// 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';
}
// 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(){
// 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);
// skip dots
if(in_array($filename, ['.', '..'])) continue;
// 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;
});
}
}
// 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
// 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;
// get filetype
$filetype = filetype($this->realpath);
// assign file mime type / will return null for most files unless config
get_mime_type = true (slow)
$this->file['mime'] = $this->mime();
// invert image width height if exif orientation is > 4 && < 9, because
dimensions should match browser-oriented image
$this->image_orientation_flip_dimensions();
// getimagesize()
$imagesize = @getimagesize($this->realpath, $this->image_info);
// 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){
// 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;
// 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
$exif = self::exif_data($path);
if(!$exif) return;
// 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;
}
}
// 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);
// 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) {
// 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;
}
// loops paths
foreach ($paths as $path) self::$success += self::$action($path, ($dir ? $dir .
'/' . U::basename($path) : false));
// vars
private $zip;
private $is_json;
// open zip
$res = @$this->zip->open($dest, $flags);
// 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){
// extract to target_dir
$success = @$this->zip->extractTo($dir);
//
$first_path = reset($paths);
// create zip root from first array path, so we can create relative local paths
inside zip
$root = dirname($first_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);
}
}
// 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 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);
}
}
// first we must get and validate start_path from ?query or config start_path
$this->get_start_path();
// 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();
// start path from config with error response invalid (path must exist, non-
excluded and must be inside root)
} else if(Config::get('start_path')) {
// exit if !menu_enabled
if(!Config::get('menu_enabled')) return;
// 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);
// 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 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'
];
// 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);
}
// set UTF-8 locale so that basename() and other string functions work correctly
with multi-byte strings.
setlocale(LC_ALL, 'en_US.UTF-8');
// action shortcut
$action = $request->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");
// 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');
}
// assign $items
$items = $request->params['items'];
// 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)));
// 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");
// 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
/* ACTIONS */
// delete items
} else if($action === 'delete') {
Filemanager::json(Filemanager::items('delete', $paths), 'failed to delete
items');
// 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_file
} else if($action === 'new_file'){
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'){
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');
// 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);
});
// response
Filemanager::json(Filemanager::items($action, $valid_copy_move_paths, $dir),
$action . ' failed');
// upload
} else if($action === 'upload'){
// invalid $_FILES['file']
if(empty($upload) || !isset($upload['error']) || is_array($upload['error']))
$request->error('Invalid $_FILES[]');
// invalid $upload['size']
if(!isset($upload['size']) || empty($upload['size'])) $request->error('Invalid
file size');
// get filename
$filename = $upload['name'];
// 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;
}
// 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;
// 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);
// 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);
}
// files found
if(!empty($files)) {
// 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()
}
}
// $_GET file / resize parameter for preview images, else will proxy any file
} else if($action === 'file'){
new FileResponse($file, U::get('resize'));
// $_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();
// THE END!