diff --git a/README.md b/README.md index 35bd161..bb19308 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ Then open http://localhost:8080 in your browser #### Dropped configuration options - TITLE +- UPLOAD_FORM_LOCATION +- ALLOW_BLOATING #### File name in URL In Picthsare v3 we have changed the requirement for the image to be the last part of the URL. While with older PictShare Versions you could put the file name at any place of the URL, we now require the last part of the URL to be the file name. diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 13b7f59..525b673 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -5,7 +5,6 @@ services: context: . dockerfile: docker/Dockerfile environment: - - SERVER_NAME=:80 - URL=http://localhost:8080/ - MAX_UPLOAD_SIZE=1000 # Security settings @@ -43,8 +42,8 @@ services: - LOG_VIEWS=true # caching - REDIS_CACHING=true - - REDIS_SERVER=/run/redis/redis.sock - - REDIS_PORT=0 + - REDIS_SERVER=localhost + - REDIS_PORT=6379 ports: - 8080:80 volumes: @@ -52,4 +51,5 @@ services: - ./logs:/app/public/logs - ./src:/app/public/src - ./web:/app/public/web + - ./tmp:/app/public/tmp - ./tmp/redis:/var/lib/redis \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3b7c914..ef5a834 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,9 +40,8 @@ services: - LOG_VIEWS=false # caching - REDIS_CACHING=true - - REDIS_SERVER=/run/redis/redis.sock - - REDIS_PORT=0 - - SERVER_NAME=:80 # required by caddy + - REDIS_SERVER=localhost + - REDIS_PORT=6379 ports: - 8080:80 volumes: diff --git a/docker/rootfs/start.sh b/docker/rootfs/start.sh index 9c9506f..0ae7c04 100644 --- a/docker/rootfs/start.sh +++ b/docker/rootfs/start.sh @@ -29,11 +29,9 @@ _buildConfig() { echo "define('CONTENTCONTROLLERS', '${CONTENTCONTROLLERS:-}');" echo "define('MASTER_DELETE_CODE', '${MASTER_DELETE_CODE:-}');" echo "define('MASTER_DELETE_IP', '${MASTER_DELETE_IP:-}');" - echo "define('UPLOAD_FORM_LOCATION', '${UPLOAD_FORM_LOCATION:-}');" echo "define('UPLOAD_CODE', '${UPLOAD_CODE:-}');" echo "define('LOG_UPLOADER', ${LOG_UPLOADER:-false});" echo "define('MAX_RESIZED_IMAGES',${MAX_RESIZED_IMAGES:--1});" - echo "define('ALLOW_BLOATING', ${ALLOW_BLOATING:-false});" echo "define('SHOW_ERRORS', ${SHOW_ERRORS:-false});" echo "define('JPEG_COMPRESSION', ${JPEG_COMPRESSION:-90});" echo "define('PNG_COMPRESSION', ${PNG_COMPRESSION:-6});" diff --git a/src/content-controllers/identicon/identicon.controller.php b/src/content-controllers/identicon/identicon.controller.php index 894e990..d06ff77 100644 --- a/src/content-controllers/identicon/identicon.controller.php +++ b/src/content-controllers/identicon/identicon.controller.php @@ -12,7 +12,7 @@ class IdenticonController implements ContentController //returns all extensions registered by this type of content public function getRegisteredExtensions(){return array('identicon');} - public function handleHash($hash,$url) + public function handleHash($hash,$url,$path=false) { unset($url[array_search('identicon',$url)]); $url = array_values($url); @@ -29,7 +29,7 @@ class IdenticonController implements ContentController echo $icon; } - public function handleUpload($tmpfile,$hash=false) + public function handleUpload($tmpfile,$hash=false,$passthrough=false) { return array('status'=>'err','hash'=>$hash,'reason'=>'Cannot upload to Identicons'); } diff --git a/src/content-controllers/image/image.controller.php b/src/content-controllers/image/image.controller.php index cff23b8..1f31caf 100644 --- a/src/content-controllers/image/image.controller.php +++ b/src/content-controllers/image/image.controller.php @@ -23,7 +23,7 @@ class ImageController implements ContentController public function getRegisteredExtensions(){return array('png','bmp','gif','jpg','jpeg','x-png','webp');} - public function handleUpload($tmpfile,$hash=false) + public function handleUpload($tmpfile,$hash=false,$passthrough=false) { $type = exif_imagetype($tmpfile); //http://www.php.net/manual/en/function.exif-imagetype.php switch($type) @@ -91,14 +91,16 @@ class ImageController implements ContentController return array('status'=>'err','hash'=>$hash,'reason'=>'Custom hash already exists'); } - storeFile($tmpfile,$hash,true); + if($passthrough===false) + storeFile($tmpfile,$hash,true); return array('status'=>'ok','hash'=>$hash,'url'=>getURL().$hash); } - public function handleHash($hash,$url) + public function handleHash($hash,$url,$path=false) { - $path = getDataDir().DS.$hash.DS.$hash; + if($path===false) + $path = getDataDir().DS.$hash.DS.$hash; $type = getExtensionOfFilename($hash); //get all our sub files where all the good functions lie @@ -151,7 +153,7 @@ class ImageController implements ContentController //so if we take all parameters in key=>value form and hash it //we get one nice little hash for every eventuality $modhash = md5(http_build_query($modifiers,'',',')); - $newpath = getDataDir().DS.$hash.DS.$modhash.'_'.$hash; + $newpath = dirname($path).DS.$modhash.'_'.$hash; $im = $this->getObjOfImage($path); $f = new Filter(); @@ -183,7 +185,7 @@ class ImageController implements ContentController break; case 'mp4': - $mp4path = getDataDir().DS.$hash.DS.$hash.'mp4'; + $mp4path = dirname($path).DS.$hash.'mp4'; if(!file_exists($mp4path)) $this->gifToMP4($path,$mp4path); $path = $mp4path; @@ -199,7 +201,7 @@ class ImageController implements ContentController } header ("Content-type: image/jpeg"); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$preview)); + serveFile($preview); exit; } else if(in_array('download',$url)) @@ -212,7 +214,7 @@ class ImageController implements ContentController header('Cache-Control: must-revalidate'); header('Pragma: public'); header('Content-Length: ' . filesize($path)); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); exit; } } @@ -244,7 +246,7 @@ class ImageController implements ContentController header ("Last-Modified: ".gmdate('D, d M Y H:i:s ', filemtime($path)) . 'GMT'); header ("ETag: $hash"); header('Cache-control: public, max-age=31536000'); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); break; case 'png': @@ -252,7 +254,7 @@ class ImageController implements ContentController header ("Last-Modified: ".gmdate('D, d M Y H:i:s ', filemtime($path)) . 'GMT'); header ("ETag: $hash"); header('Cache-control: public, max-age=31536000'); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); break; @@ -261,7 +263,7 @@ class ImageController implements ContentController header ("Last-Modified: ".gmdate('D, d M Y H:i:s ', filemtime($path)) . 'GMT'); header ("ETag: $hash"); header('Cache-control: public, max-age=31536000'); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); break; case 'webp': @@ -269,7 +271,7 @@ class ImageController implements ContentController header ("Last-Modified: ".gmdate('D, d M Y H:i:s ', filemtime($path)) . 'GMT'); header ("ETag: $hash"); header('Cache-control: public, max-age=31536000'); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); break; } } diff --git a/src/content-controllers/placeholder/placeholder.controller.php b/src/content-controllers/placeholder/placeholder.controller.php index 5df4f85..957d26f 100644 --- a/src/content-controllers/placeholder/placeholder.controller.php +++ b/src/content-controllers/placeholder/placeholder.controller.php @@ -7,7 +7,7 @@ class PlaceholderController implements ContentController //returns all extensions registered by this type of content public function getRegisteredExtensions(){return array('placeholder');} - public function handleHash($hash,$url) + public function handleHash($hash,$url,$path=false) { $path = getDataDir().DS.$hash.DS.$hash; @@ -43,7 +43,7 @@ class PlaceholderController implements ContentController imagejpeg($img,null,(defined('JPEG_COMPRESSION')?JPEG_COMPRESSION:90)); } - public function handleUpload($tmpfile,$hash=false) + public function handleUpload($tmpfile,$hash=false,$passthrough=false) { return array('status'=>'err','hash'=>$hash,'reason'=>'Cannot upload to placeholder image'); } diff --git a/src/content-controllers/text/text.controller.php b/src/content-controllers/text/text.controller.php index 7ddba4b..d291d3e 100644 --- a/src/content-controllers/text/text.controller.php +++ b/src/content-controllers/text/text.controller.php @@ -13,7 +13,7 @@ class TextController implements ContentController //returns all extensions registered by this type of content public function getRegisteredExtensions(){return array('txt','text','csv');} - public function handleHash($hash,$url) + public function handleHash($hash,$url,$path=false) { $path = getDataDir().DS.$hash.DS.$hash; @@ -32,7 +32,7 @@ class TextController implements ContentController header('Cache-Control: must-revalidate'); header('Pragma: public'); header('Content-Length: ' . filesize($path)); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); exit; } } @@ -40,7 +40,7 @@ class TextController implements ContentController return renderTemplate('text.html.php',array('hash'=>$hash,'content'=>htmlentities(file_get_contents($path)))); } - public function handleUpload($tmpfile,$hash=false) + public function handleUpload($tmpfile,$hash=false,$passthrough=false) { if($hash===false) { @@ -54,7 +54,8 @@ class TextController implements ContentController return array('status'=>'err','hash'=>$hash,'reason'=>'Custom hash already exists'); } - storeFile($tmpfile,$hash,true); + if($passthrough===false) + storeFile($tmpfile,$hash,true); return array('status'=>'ok','hash'=>$hash,'url'=>getURL().$hash); } diff --git a/src/content-controllers/url/url.controller.php b/src/content-controllers/url/url.controller.php index 8f382be..cde89d4 100644 --- a/src/content-controllers/url/url.controller.php +++ b/src/content-controllers/url/url.controller.php @@ -6,6 +6,6 @@ class UrlController implements ContentController //returns all extensions registered by this type of content public function getRegisteredExtensions(){return array('url');} - public function handleHash($hash,$url){} - public function handleUpload($tmpfile,$hash=false){} + public function handleHash($hash,$url,$path=false){} + public function handleUpload($tmpfile,$hash=false,$passthrough=false){} } \ No newline at end of file diff --git a/src/content-controllers/video/video.controller.php b/src/content-controllers/video/video.controller.php index 94c4f8e..dffa4cd 100644 --- a/src/content-controllers/video/video.controller.php +++ b/src/content-controllers/video/video.controller.php @@ -12,9 +12,10 @@ class VideoController implements ContentController //returns all extensions registered by this type of content public function getRegisteredExtensions(){return array('mp4');} - public function handleHash($hash,$url) + public function handleHash($hash,$url,$path=false) { - $path = getDataDir().DS.$hash.DS.$hash; + if($path===false) + $path = getDataDir().DS.$hash.DS.$hash; //@todo: - resize by changing $path @@ -44,7 +45,7 @@ class VideoController implements ContentController } header ("Content-type: image/jpeg"); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$preview)); + serveFile($preview); } else if(in_array('download',$url)) @@ -57,7 +58,7 @@ class VideoController implements ContentController header('Cache-Control: must-revalidate'); header('Pragma: public'); header('Content-Length: ' . filesize($path)); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); exit; } } @@ -69,7 +70,7 @@ class VideoController implements ContentController } } - public function handleUpload($tmpfile,$hash=false) + public function handleUpload($tmpfile,$hash=false,$passthrough=false) { if($hash===false) $hash = getNewHash('mp4',6); @@ -80,10 +81,13 @@ class VideoController implements ContentController return array('status'=>'err','hash'=>$hash,'reason'=>'Custom hash already exists'); } - $file = storeFile($tmpfile,$hash,true); + if($passthrough===false) + { + $file = storeFile($tmpfile,$hash,true); - if(!$this->rightEncodedMP4($file)) - system("nohup php ".ROOT.DS.'tools'.DS.'re-encode_mp4.php force '.$hash." > /dev/null 2> /dev/null &"); + if(!$this->rightEncodedMP4($file)) + system("nohup php ".ROOT.DS.'tools'.DS.'re-encode_mp4.php force '.$hash." > /dev/null 2> /dev/null &"); + } return array('status'=>'ok','hash'=>$hash,'url'=>getURL().$hash); } @@ -96,7 +100,7 @@ class VideoController implements ContentController header ("Last-Modified: ".gmdate('D, d M Y H:i:s ', filemtime($path)) . 'GMT'); header ("ETag: ".sha1_file($path)); header('Cache-control: public, max-age=31536000'); - header('X-Accel-Redirect: '.str_replace(getDataDir().DS,'',$path)); + serveFile($path); exit(); } diff --git a/src/inc/api.class.php b/src/inc/api.class.php index 4c65613..1d1bd32 100644 --- a/src/inc/api.class.php +++ b/src/inc/api.class.php @@ -17,6 +17,7 @@ class API } return match ($this->url[0]) { + 'passthrough' => $this->passthrough(), 'upload' => $this->upload(), 'delete' => $this->delete(), 'info' => $this->info(), @@ -25,6 +26,39 @@ class API }; } + public function passthrough(){ + $type = $this->url[1]; + array_shift($this->url); //remove the first element which is 'passthrough' + array_shift($this->url); //remove the second element which is the type so now what's left is the modifiers + $tmpfile = $_FILES['file']['tmp_name'] ?? false; + if (!$tmpfile || !is_file($tmpfile)) + { + header('HTTP/1.1 400 Bad Request'); + return array('status' => 'err', 'reason' => 'No file uploaded'); + } + $tmp_target = $tmpfile; + //$tmp_target = ROOT.DS.'tmp'.DS.'pt-'.md5(rand(1,9999)).basename($_FILES['file']['name']); + //move_uploaded_file($tmpfile, $tmp_target); + + switch($type) { + case 'image': + $controller = new ImageController(); + $upload = $controller->handleUpload($tmp_target, false, true); //only to validate the file + if ($upload['status'] == 'ok') { + $controller->handleHash($upload['hash'], $this->url, $tmp_target); + unlink($tmp_target); //remove the temporary file + exit(); + } + else { + header('HTTP/1.1 400 Bad Request'); + return $upload; //error + } + break; + default: + return array('status' => 'err', 'reason' => 'Unknown passthrough type'); + } + } + public function debug() { diff --git a/src/inc/core.php b/src/inc/core.php index 7bd2f01..bddf455 100644 --- a/src/inc/core.php +++ b/src/inc/core.php @@ -18,12 +18,9 @@ if(!defined('FFMPEG_BINARY')) function architect($u) { - //if there is no info in the URL, don't even bother checking with the controllers - //just show the site - if( ( (!defined('UPLOAD_FORM_LOCATION') || (defined('UPLOAD_FORM_LOCATION') && !UPLOAD_FORM_LOCATION)) && count($u)==0) || (defined('UPLOAD_FORM_LOCATION') && UPLOAD_FORM_LOCATION && '/'.implode('/',$u)==UPLOAD_FORM_LOCATION) ) + // check if client address is allowed + if($u[0] == '' || $u[0] == '/') { - - // check if client address is allowed $forbidden = false; if(defined('ALLOWED_SUBNET') && ALLOWED_SUBNET != '' && !isIPInRange( getUserIP(), ALLOWED_SUBNET )) { @@ -99,6 +96,7 @@ function architect($u) //check all parts of the URL for a valid hash $hash = false; + $sc = getStorageControllers(); foreach($u as $el) { if(isExistingHash($el)) @@ -111,8 +109,6 @@ function architect($u) // if we don't have a hash yet but the element looks like it could be a hash if($hash === false && mightBeAHash($el)) { - if(!$sc) - $sc = getStorageControllers(); foreach($sc as $contr) { $c = new $contr(); @@ -363,69 +359,6 @@ function sizeStringToWidthHeight($size) return array('width'=>$maxwidth,'height'=>$maxheight); } -// -// from: https://stackoverflow.com/questions/25975943/php-serve-mp4-chrome-provisional-headers-are-shown-request-is-not-finished-ye -// -function serveFile($filename, $filename_output = false, $mime = 'application/octet-stream') -{ - $buffer_size = 8192; - $expiry = 90; //days - if(!file_exists($filename)) - { - throw new Exception('File not found: ' . $filename); - } - if(!is_readable($filename)) - { - throw new Exception('File not readable: ' . $filename); - } - header_remove('Cache-Control'); - header_remove('Pragma'); - $byte_offset = 0; - $filesize_bytes = $filesize_original = filesize($filename); - header('Accept-Ranges: bytes', true); - header('Content-Type: ' . $mime, true); - - header("Content-Disposition: inline;"); - // Content-Range header for byte offsets - if (isset($_SERVER['HTTP_RANGE']) && preg_match('%bytes=(\d+)-(\d+)?%i', $_SERVER['HTTP_RANGE'], $match)) - { - $byte_offset = (int) $match[1];//Offset signifies where we should begin to read the file - if (isset($match[2]))//Length is for how long we should read the file according to the browser, and can never go beyond the file size - { - $filesize_bytes = min((int) $match[2], $filesize_bytes - $byte_offset); - } - header("HTTP/1.1 206 Partial content"); - header(sprintf('Content-Range: bytes %d-%d/%d', $byte_offset, $filesize_bytes - 1, $filesize_original)); ### Decrease by 1 on byte-length since this definition is zero-based index of bytes being sent - } - $byte_range = $filesize_bytes - $byte_offset; - header('Content-Length: ' . $byte_range); - header('Expires: ' . date('D, d M Y H:i:s', time() + 60 * 60 * 24 * $expiry) . ' GMT'); - $buffer = ''; - $bytes_remaining = $byte_range; - $handle = fopen($filename, 'r'); - if(!$handle) - { - throw new Exception("Could not get handle for file: " . $filename); - } - if (fseek($handle, $byte_offset, SEEK_SET) == -1) - { - throw new Exception("Could not seek to byte offset %d", $byte_offset); - } - while ($bytes_remaining > 0) - { - $chunksize_requested = min($buffer_size, $bytes_remaining); - $buffer = fread($handle, $chunksize_requested); - $chunksize_real = strlen($buffer); - if ($chunksize_real == 0) - { - break; - } - $bytes_remaining -= $chunksize_real; - echo $buffer; - flush(); - } -} - function sanatizeString($string) { return preg_replace("/[^a-zA-Z0-9._\-]+/", "", $string); @@ -1276,4 +1209,39 @@ function getFileMimeType($file) $mimeType = shell_exec('file --mime-type -b ' . escapeshellarg($file)); return trim($mimeType); } +} + +function getRelativeToDataPath(string $path): string +{ + // Resolve real paths + $realBase = realpath(ROOT.DS.'data'); + $realPath = realpath($path); + + if ($realBase === false || $realPath === false) { + throw new InvalidArgumentException("Invalid path or base directory: $path, ".ROOT.DS.'data'); + } + + $baseParts = explode(DIRECTORY_SEPARATOR, trim($realBase, DIRECTORY_SEPARATOR)); + $pathParts = explode(DIRECTORY_SEPARATOR, trim($realPath, DIRECTORY_SEPARATOR)); + + // Find common path length + $i = 0; + while (isset($baseParts[$i], $pathParts[$i]) && $baseParts[$i] === $pathParts[$i]) { + $i++; + } + + // How many directories to go up from base + $upDirs = count($baseParts) - $i; + $relativeParts = array_merge(array_fill(0, $upDirs, '..'), array_slice($pathParts, $i)); + + return implode('/', $relativeParts); +} + +function serveFile($path){ + $relativePath = getRelativeToDataPath($path); + //since x-accel-redirect does not support paths outside its root, we need to check if the path is relative or absolute + if(startsWith($relativePath,'..')) + readfile($path); + else + header('X-Accel-Redirect: '. $relativePath); } \ No newline at end of file diff --git a/src/interfaces/contentcontroller.interface.php b/src/interfaces/contentcontroller.interface.php index c51307c..682eace 100644 --- a/src/interfaces/contentcontroller.interface.php +++ b/src/interfaces/contentcontroller.interface.php @@ -31,7 +31,7 @@ interface ContentController * @param string $hash the hash (with extension eg '5saB2.pdf') of the file this controller will work with * @param array $url contains all URL elements exploded with '/' so you can do your magic. */ - public function handleHash($hash,$url); + public function handleHash($hash,$url,$path=false); /** This method will be called if the upload script detects the content of a newly uploaded file as one of the * extensions registered at "getRegisteredExtensions". @@ -41,5 +41,5 @@ interface ContentController * @param string $tmpfile is the location on disk of the temp file that was uploaded. It is your job to put it somewhere, your handleHash method will find it again * @param array $hash (optional) if you want your upload to have a certain hash then add it here. This allows for user chosen hashes */ - public function handleUpload($tmpfile,$hash=false); + public function handleUpload($tmpfile,$hash=false,$passthrough=false); } \ No newline at end of file diff --git a/src/templates/main.html.php b/src/templates/main.html.php index 5122d93..f3e44fb 100644 --- a/src/templates/main.html.php +++ b/src/templates/main.html.php @@ -156,7 +156,26 @@ if (file_exists(ROOT . DS . 'notice.txt'))
+

On-the-fly modifications to images

+

You can modify images on-the-fly (without saving them to the server) by specifying additional parameters in the API call to use Pictshare to "clean up" or "normalize" images uploaded to your app. The passthrough endpoint allows you to use the normal image modifiers in the URL. It won't save the image on the Server and will respond with the modified image directly.

+ + API call +
/passthrough/image/[modifiers]
+ +

For example to force an uploaded image to 400x400 and have it grayscale you can do this:

+ + CURL example +
curl -s -F "file=@myphoto.jpg" "api/passthrough/image/400x400/forcesize/gray" > result.jpg
+ + If the status code is anything other than "200", it will return an error message in JSON format. +

+{
+    "status": "err",
+    "reason": "Not a valid image"
+}
+ +If the status code is "200", it will return the modified image directly.
\ No newline at end of file