<?php
/**
 * @brief		File Handler: File System
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		06 May 2013
 */

namespace IPS\File;

/* To prevent PHP errors (extending class does not exist) revealing path */

use DirectoryIterator;
use DomainException;
use FilesystemIterator;
use InvalidArgumentException;
use IPS\File;
use IPS\Http\Url;
use IPS\Log;
use IPS\Member;
use IPS\Request;
use IPS\Settings;
use LogicException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use function copy;
use function defined;
use function file_put_contents;
use function function_exists;
use function fwrite;
use function in_array;
use function rename;
use const IPS\IPS_FILE_PERMISSION;
use const IPS\IPS_FOLDER_PERMISSION;

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * File Handler: File System
 */
class FileSystem extends File
{
	/* !ACP Configuration */
	
	/**
	 * Settings
	 *
	 * @param	array	$configuration		Configuration if editing a setting, or array() if creating a setting.
	 * @return	array
	 */
	public static function settings( array $configuration=array() ) : array
	{
		$default = ( isset( $configuration['custom_url'] ) and ! empty( $configuration['custom_url'] ) ) ? TRUE : FALSE;
		
		return array(
			'dir'		 => array( 'type' => 'Text', 'default' => '{root}/uploads' ),
			'toggle'	 => array( 'type' => 'YesNo', 'default' => $default, 'options' => array(
				'togglesOn' => array( 'FileSystem_custom_url' )
			) ),
			'custom_url' => array( 'type' => 'Text', 'default' => '' ),
		);
	}
	
	/**
	 * Test Settings
	 *
	 * @param	array	$values	The submitted values
	 * @return	void
	 * @throws	LogicException
	 */
	public static function testSettings( array &$values ) : void
	{
		$values['dir'] = rtrim( $values['dir'], '\\/' );
		$testDir = str_replace( '{root}', \IPS\ROOT_PATH, $values['dir'] );
		$values['url'] = trim( str_replace( \IPS\ROOT_PATH, '', $testDir ), '\\/' );
		
		if ( empty( $values['toggle'] ) )
		{
			$values['custom_url'] = NULL;
		}

		if ( !$testDir )
		{
			throw new DomainException( Member::loggedIn()->language()->addToStack( 'dir_not_provided', FALSE ) );
		}
		if ( !is_dir( $testDir ) )
		{
			throw new DomainException( Member::loggedIn()->language()->addToStack( 'dir_does_not_exist', FALSE, array( 'sprintf' => array( $testDir ) ) ) );
		}
		if ( !is_writable( $testDir ) )
		{
			throw new DomainException( Member::loggedIn()->language()->addToStack( 'dir_is_not_writable', FALSE, array( 'sprintf' => array( $testDir ) ) ) );
		}
		
		if ( ! empty( $values['custom_url'] ) )
		{
			if ( mb_substr( $values['custom_url'], 0, 2 ) !== '//' AND mb_substr( $values['custom_url'], 0, 4 ) !== 'http' )
			{
				$values['custom_url'] = '//' . $values['custom_url'];
			}
			
			$test = $values['custom_url'];
			
			if ( mb_substr( $test, 0, 2 ) === '//' )
			{
				$test = 'http:' . $test;
			}
			
			if ( filter_var( $test, FILTER_VALIDATE_URL ) === false )
			{
				throw new DomainException( Member::loggedIn()->language()->addToStack( 'url_is_not_real', FALSE, array( 'sprintf' => array( $values['custom_url'] ) ) ) );
			}
		}
	}

	/**
	 * Determine if the change in configuration warrants a move process
	 *
	 * @param	array		$configuration	    New Storage configuration
	 * @param	array		$oldConfiguration   Existing Storage Configuration
	 * @return	boolean
	 */
	public static function moveCheck( array $configuration, array $oldConfiguration ) : bool
	{
		if ( str_replace( '{root}', \IPS\ROOT_PATH, preg_replace( '#/{1,}/#', '/', rtrim( $configuration['dir'], '/' ) ) ) !== str_replace( '{root}', \IPS\ROOT_PATH, preg_replace( '#/{1,}/#', '/', rtrim( $oldConfiguration['dir'], '/' ) ) ) )
		{
			return TRUE;
		}
		
		return FALSE;
	}

	/**
	 * Display name
	 *
	 * @param	array	$settings	Configuration settings
	 * @return	string
	 */
	public static function displayName( array $settings ) : string
	{
		return Member::loggedIn()->language()->addToStack( 'filehandler_display_name', FALSE, array( 'sprintf' => array( Member::loggedIn()->language()->addToStack('filehandler__FileSystem'), str_replace( '{root}', \IPS\ROOT_PATH, $settings['dir'] ) ) ) );
	}

	/* !File Handling */

	/**
	 * @brief	Does this storage method support chunked uploads?
	 */
	public static bool $supportsChunking = TRUE;
	
	/**
	 * Constructor
	 *
	 * @param	array	$configuration	Storage configuration
	 * @return	void
	 */
	public function __construct( array $configuration )
	{
		$this->container = 'monthly_' . date( 'Y' ) . '_' . date( 'm' );
		$configuration['dir'] = str_replace( '{root}', \IPS\ROOT_PATH, $configuration['dir'] );

		parent::__construct( $configuration );
	}

	/**
	 * @brief	Store the path to the file so we can just move it later
	 */
	protected ?string $temporaryFilePath	= NULL;
	
	/**
	 * Set the file
	 *
	 * @param	string	$filepath	The path to the file on disk
	 * @return  void
	 */
	public function setFile( string $filepath ) : void
	{
		$this->temporaryFilePath	= $filepath;
	}
	
	/**
	 * Return the base URL
	 *
	 * @return string
	 */
	public function baseUrl() : string
	{
		$url = ( empty( $this->configuration['custom_url'] ) ) ? rtrim( Settings::i()->base_url, '/' ) . '/' . ltrim( $this->configuration['url'], '/' ) : $this->configuration['custom_url'];
		if ( Request::i()->isSecure() )
		{
			$url = str_replace( 'http://', 'https://', $url );
		}
		return rtrim( $url, '/' );
	}
	
	/**
	 * Move file to a different storage location
	 *
	 * @param	int			$storageConfiguration	New storage configuration ID
	 * @param   int         $flags                  Bitwise Flags
	 * @return	File
	 */
	public function move( int $storageConfiguration, int $flags=0 ) : File
	{
		if ( $this->configurationId === $storageConfiguration and isset( $this->configuration['old_url'] ) )
		{
			/* We're just updating the URL, not actually moving the file */
			if ( mb_substr( $this->url, 0, mb_strlen( $this->configuration['old_url'] ) ) == $this->configuration['old_url'] )
			{
				$this->url = str_replace( $this->configuration['old_url'], $this->configuration['url'], $this->url );

				return $this;
			}

			/* Is this the new url, then? */
			if (  mb_substr( $this->url, 0, mb_strlen( $this->configuration['url'] ) ) != $this->configuration['url'] )
			{
				/* No? Something has gone wrong */
				throw new RuntimeException('url_update_incorrect_url');
			}
			else
			{
				return $this;
			}
		}
		else if ( static::$storageConfigurations[ $storageConfiguration ]['method'] === 'FileSystem' )
		{
			$newConfig = json_decode( static::$storageConfigurations[ $storageConfiguration ]['configuration'], TRUE );

			/* Do both configs have the same path? */
			if ( str_replace( '{root}', \IPS\ROOT_PATH, $newConfig['dir'] ) == $this->configuration['dir'] )
			{
				/* Don't move */
				return $this;
			}
		}

		return parent::move( $storageConfiguration );
	}

	/**
	 * Print the contents of the file
	 *
	 * @param	int|null	$start		Start point to print from (for ranges)
	 * @param	int|null	$length		Length to print to (for ranges)
	 * @param	int|null	$throttle	Throttle speed
	 * @return	void
	 */
	public function printFile( ?int $start=NULL, ?int $length=NULL, ?int $throttle=NULL ) : void
	{
		$file	= $this->configuration['dir'] . '/' . $this->container . '/' . $this->filename;

		$this->sendFile( $file, $start, $length, $throttle );
	}

	/**
	 * Get Contents
	 *
	 * @param	bool	$refresh	If TRUE, will fetch again
	 * @return	string
	 * @throws  RuntimeException
	 */
	public function contents( bool $refresh=FALSE ) : string
	{
		if ( $this->contents === NULL or $refresh === TRUE )
		{
			if( file_exists( $this->configuration['dir'] . '/' . $this->container . '/' . $this->filename ) )
			{
				$this->contents = @file_get_contents( $this->configuration['dir'] . '/' . $this->container . '/' . $this->filename );
				
				if ( $this->contents === FALSE )
				{
					throw new Exception( $this->container . '/' . $this->filename, Exception::CANNOT_OPEN, $this->originalFilename );
				}
			}
			else
			{
				throw new Exception( $this->container . '/' . $this->filename, Exception::DOES_NOT_EXIST, $this->originalFilename );
			}
		}

		return $this->contents;
	}

	/**
	 * If the file is an image, get the dimensions
	 *
	 * @return	array
	 * @throws	DomainException
	 * @throws  RuntimeException
	 * @throws	InvalidArgumentException
	 */
	public function getImageDimensions() : array
	{
		if( !$this->isImage() )
		{
			throw new DomainException;
		}

		$file	= $this->configuration['dir'] . '/' . $this->container . '/' . $this->filename;
		
		if ( ! file_exists( $file ) )
		{
			throw new RuntimeException;
		}
		
		if( ( $image = getimagesize( $file ) ) === FALSE )
		{
			return parent::getImageDimensions();
		}

		return array( $image[0], $image[1] );
	}
		
	/**
	 * Save File
	 *
	 * @return	void
	 * @throws	RuntimeException
	 */
	public function save() : void
	{
		/* Make the folder */
		$folder = $this->configuration['dir'] . '/' . $this->getFolder();
				
		/* Save the file */
		if( $this->temporaryFilePath )
		{
			if( static::$copyFiles === TRUE )
			{
				if( !@copy( $this->temporaryFilePath, "{$folder}/{$this->filename}" ) )
				{
					Log::log( "Could not copy file {$folder}/{$this->filename}" , 'FileSystem' );
					throw new Exception( "{$folder}/{$this->filename}", Exception::CANNOT_COPY, $this->originalFilename );
				}
			}
			else
			{
				if( !@rename( $this->temporaryFilePath, "{$folder}/{$this->filename}" ) )
				{
					Log::log( "Could not move file {$folder}/{$this->filename}" , 'FileSystem' );
					throw new Exception( "{$folder}/{$this->filename}", Exception::CANNOT_MOVE, $this->originalFilename );
				}
			}
		}
		else
		{
			if ( $contents = $this->contents() )
			{				
				if ( !@file_put_contents( "{$folder}/{$this->filename}", $contents ) )
				{					
					Log::log( "Could not write file {$folder}/{$this->filename}" , 'FileSystem' );
					throw new Exception( "{$folder}/{$this->filename}", Exception::CANNOT_WRITE, $this->originalFilename );
				}
			}
			else
			{
				$return = touch( "{$folder}/{$this->filename}" );
			}
		}

		@chmod( "{$folder}/{$this->filename}", IPS_FILE_PERMISSION );
		
		/* Clear zend opcache if enabled */
		if ( function_exists( 'opcache_invalidate' ) )
		{
			@opcache_invalidate( "{$folder}/{$this->filename}" );
		}
		
		/* Set the URL */
		$this->url = Url::createFromString( $this->fullyQualifiedUrl( "{$this->container}/{$this->filename}" ), FALSE );
	}
		
	/**
	 * Delete
	 *
	 */
	public function delete() : void
	{		
		/* Log deletion request */
		$immediateCaller = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 1 );
		$debug = array_map( function( $row ) {
			return array_filter( $row, function( $key ) {
				return in_array( $key, array( 'class', 'function', 'line' ) );
			}, ARRAY_FILTER_USE_KEY );
		}, debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) );
		$this->log( "file_deletion", 'delete', $debug, 'log' );

		if( file_exists( "{$this->configuration['dir']}/{$this->container}/{$this->filename}" ) )
		{
			$result = @unlink( "{$this->configuration['dir']}/{$this->container}/{$this->filename}" );

			/* Clear zend opcache if enabled */
			if ( function_exists( 'opcache_invalidate' ) )
			{
				@opcache_invalidate( "{$this->configuration['dir']}/{$this->container}/{$this->filename}" );
			}
		}
	}
	
	/**
	 * Delete Container
	 *
	 * @param	string	$container	Key
	 * @param	bool	$skipClear	Skip clearing the opcache, used for recursive calls since the parent call will do it
	 * @return	void
	 */
	public function deleteContainer( string $container, bool $skipClear = FALSE ) : void
	{
		$dir = $this->configuration['dir'] . '/' . $container;

		if ( is_dir( $dir ) )
		{
			$files	= array();
			$dirs	= array();

			foreach ( new DirectoryIterator( $dir ) as $f )
			{
				if ( !$f->isDot() )
				{
					if( $f->isDir() )
					{
						$dirs[]	= $container . '/' . $f->getFilename();
					}
					else
					{
						$files[]	= $f->getPathname();
					}
				}
			}

			/* If we have any directories to delete - delete them */
			foreach( $dirs as $directory )
			{
				$this->deleteContainer( $directory, true );
			}

			/* And now delete the files */
			foreach( $files as $file )
			{
				unlink( $file );

				/* Clear zend opcache if enabled */
				if( $skipClear === FALSE and function_exists( 'opcache_invalidate' ) )
				{
					@opcache_invalidate( $file );
				}
			}

			/* And finally, remove the directory we're currently working with */
			rmdir( $dir );
		}

		/* Log deletion request */
		$realContainer = $this->container;
		$this->container = $container;
		$this->log( "container_deletion", 'delete', $_REQUEST, 'log' );
		$this->container = $realContainer;
	}
	
	/**
	 * Initiate a chunked upload
	 *
	 * @param	string		$filename	The desired filename
	 * @param	string|null	$container	Key to identify container for storage
	 * @param	bool		$obscure		Controls if an md5 hash should be added to the filename
	 * @return	array					A reference to be passed to chunkProcess() and chunkFinish()
	 * @throws	RuntimeException
	 */
	public function chunkInit( string $filename, ?string $container = '', bool $obscure = TRUE ) : array
	{
		/* If we don't have a chunk folder, create one */
		$tempFolder = $this->getFolder( 'chunks' );

		/* Get the final folder name */
		$folder = $this->getFolder( $container );

		$this->setFilename( $filename, $obscure );

		$return = array( 'temp' => "{$this->configuration['dir']}/{$tempFolder}/chunk-" . md5( uniqid() ) . '.txt', 'final' => "{$folder}/" . $this->filename, 'original' => $this->originalFilename );
		touch( $return['temp'] );
		return $return;
	}
	
	/**
	 * Append more contents in a chunked upload
	 *
	 * @param	array	$ref							The reference for this upload as returned by chunkInit()
	 * @param	string	$temporaryFileOrContents		The contents to write, or the path to the temporary file on disk with the contents
	 * @param	int		$chunkNumber					Which chunk this is (0 for the first chunk, 1 for the second, etc)
	 * @param	bool	$isContents					If TRUE, $temporaryFileOrContents is treated as raw contents. If FALSE, is path to file
	 * @return	array								Updated reference fore future chunkProcess() calls and chunkFinish()
	 * @throws	RuntimeException
	 */
	public function chunkProcess( array $ref, string $temporaryFileOrContents, int $chunkNumber, bool $isContents = FALSE ) : array
	{
		$file = fopen( $ref['temp'], 'ab' );
		if ( $isContents )
		{
			fwrite( $file, $temporaryFileOrContents );
		}
		else
		{
			$_chunk = fopen( $temporaryFileOrContents, 'rb' );
			while ( $buffer = fread( $_chunk, 4096 ) )
			{
				fwrite( $file, $buffer );
			}
			@unlink( $temporaryFileOrContents );
		}
		fclose( $file );
		
		if ( function_exists( 'opcache_invalidate' ) )
		{
			@opcache_invalidate( $ref['temp'] );
		}
		
		return $ref;
	}

	/**
	 * Finalize a chunked upload
	 *
	 * @param   array       $ref                    The reference for this upload as returned by chunkInit()
	 * @param   string      $storageConfiguration   Storage configuration name
	 * @return  File                           The file object just created
	 * @throws  RuntimeException
	 */
	public function chunkFinish( array $ref, string $storageConfiguration ): File
	{
		rename( $ref['temp'], "{$this->configuration['dir']}/{$ref['final']}" );

		$fileObj = File::get( $storageConfiguration,$ref['final'] );

		/* This isn't preserved for chunk uploads, so we need to set it so apps can get the real value */
		if( !empty( $ref['original'] ) )
		{
			$fileObj->originalFilename = $ref['original'];
		}

		return $fileObj;
	}
	
	/* !File System Utility Methods */
	
	/**
	 * Get the path to the folder
	 *
	 * @param	string|null	$folderName	Folder name - if NULL, a monthly name will be used
	 * @return	string
	 */
	protected function getFolder( ?string $folderName=NULL ) : string
	{
		$folderName = $folderName ?: $this->container;
		$folder = $this->configuration['dir'] . '/' . $folderName;
		if( !is_dir( $folder ) )
		{
			if( @mkdir( $folder, IPS_FOLDER_PERMISSION, TRUE ) === FALSE or @chmod( $folder, IPS_FOLDER_PERMISSION ) === FALSE )
			{
				throw new Exception( $folder, Exception::CANNOT_MAKE_DIR );
			}
			@file_put_contents( $folder . '/index.html', '' );
		}
		
		return $folderName;
	}

	/**
	 * Remove orphaned files
	 *
	 * @param	int		$fileIndex	The file offset to start at in a listing
	 * @param	array	$engines	All file storage engine extension objects
	 * @return	array
	 */
	public function removeOrphanedFiles( int $fileIndex, array $engines ) : array
	{
		/* Start off our results array */
		$results	= array(
			'_done'				=> FALSE,
			'fileIndex'			=> $fileIndex,
		);

		/* Some basic init */
		$checked	= 0;
		$skipped	= 0;

		/* We need to open our storage directory and start looping over it */
		$dir = $this->configuration['dir'];

		if ( is_dir( $dir ) )
		{
			$iterator	= new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS ) );

			foreach ( $iterator as $f )
			{
				/* We aren't checking directories */
				if( $f->isDir() OR $f->getFilename() == 'index.html' OR mb_substr( $f->getFilename(), 0, 1 ) === '.' OR mb_substr( $f->getSubPathname(), 0, 5 ) === 'logs/' )
				{
					continue;
				}

				/* Have we hit our limit?  If so we need to stop. */
				if( $checked >= 100 )
				{
					break;
				}

				/* Is there an offset?  If so we need to skip */
				if( $fileIndex > 0 AND $fileIndex > $skipped )
				{
					$skipped++;
					continue;
				}

				$checked++;

				/* Next we will have to loop through each storage engine type and call it to see if the file is valid */
				foreach( $engines as $engine )
				{
					/* If this file is valid for the engine, skip to the next file */
					try
					{
						if( $engine->isValidFile( $engine->getSubPathname() ) )
						{
							continue 2;
						}
					}
					catch( InvalidArgumentException $e )
					{
						continue 2;
					}
				}
								
				/* If we are still here, the file was not valid */
				$this->logOrphanedFile( $f->getSubPathname() );
			}
		}

		$results['fileIndex'] += $checked;

		/* Are we done? */
		if( !$checked OR $checked < 100 )
		{
			$results['_done']	= TRUE;
		}

		return $results;
	}

	/**
	 * Get filesize (in bytes)
	 *
	 * @return	int|bool
	 */
	public function filesize() : int|bool
	{
		if( $this->_cachedFilesize !== NULL )
		{
			return $this->_cachedFilesize;
		}

		$this->_cachedFilesize = file_exists( $this->configuration['dir'] . '/' . $this->container . '/' . $this->filename ) ? @filesize( $this->configuration['dir'] . '/' . $this->container . '/' . $this->filename ) : FALSE;
		return $this->_cachedFilesize;
	}
}