<?php
/**
 * VFS implementation for a filesystem.
 *
 * Copyright 2002-2017 Horde LLC (http://www.horde.org/)
 *
 * See the enclosed file COPYING for license information (LGPL). If you
 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
 *
 * @author  Chuck Hagenbuch
 * @package Vfs
 */
class Horde_Vfs_File extends Horde_Vfs_Base
{
    /**
     * List of permissions and if they can be changed in this VFS backend.
     *
     * @var array
     */
    protected $_permissions = array(
        'owner' => array(
            'read' => true,
            'write' => true,
            'execute' => true
        ),
        'group' => array(
            'read' => true,
            'write' => true,
            'execute' => true
        ),
        'all' => array(
            'read' => true,
            'write' => true,
            'execute' => true
        )
    );

    /**
     * List of features that the VFS driver supports.
     *
     * @var array
     */
    protected $_features = array(
        'readByteRange' => true,
    );

    /**
     * Constructs a new Filesystem based VFS object.
     *
     * @param array $params  A hash containing connection parameters. REQUIRED
     *                       parameters:
     *   - vfsroot: (string) The root path.
     *              Note: The user that your webserver runs as MUST have
     *              read/write permission to this directory.
     */
    public function __construct($params = array())
    {
        parent::__construct($params);

        if (!empty($this->_params['vfsroot']) &&
            ((substr($this->_params['vfsroot'], -1) == '/') ||
             (substr($this->_params['vfsroot'], -1) == '\\'))) {
            $this->_params['vfsroot'] = substr($this->_params['vfsroot'], 0, strlen($this->_params['vfsroot']) - 1);
            if (!strlen($this->_params['vfsroot'])) {
                $this->_params['vfsroot'] = '/';
            }
        }

        $this->_connect();
    }

    /**
     * Retrieves the filesize from the VFS.
     *
     * @param string $path  The pathname to the file.
     * @param string $name  The filename to retrieve.
     *
     * @return integer  The file size.
     * @throws Horde_Vfs_Exception
     */
    public function size($path, $name)
    {
        if (($size = @filesize($this->_getNativePath($path, $name))) === false) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to check file size of "%s/%s".', $path, $name)));
        }

        return $size;
    }

    /**
     * Retrieve a file from the VFS.
     *
     * @param string $path  The pathname to the file.
     * @param string $name  The filename to retrieve.
     *
     * @return string  The file data.
     * @throws Horde_Vfs_Exception
     */
    public function read($path, $name)
    {
        if (($data = @file_get_contents($this->_getNativePath($path, $name))) === false) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to open VFS file %s/%s.', $path, $name)));
        }

        return $data;
    }

    /**
     * Retrieves a file from the VFS as an on-disk local file.
     *
     * This function provides a file on local disk with the data of a VFS file
     * in it. This file <em>cannot</em> be modified! The behavior if you do
     * modify it is undefined. It will be removed at the end of the request.
     *
     * @param string $path  The pathname to the file.
     * @param string $name  The filename to retrieve.
     *
     * @return string A local filename.
     */
    public function readFile($path, $name)
    {
        return $this->_getNativePath($path, $name);
    }

    /**
     * Open a read-only stream to a file in the VFS.
     *
     * @param string $path  The pathname to the file.
     * @param string $name  The filename to retrieve.
     *
     * @return resource  The stream.
     * @throws Horde_Vfs_Exception
     */
    public function readStream($path, $name)
    {
        $stream = @fopen($this->_getNativePath($path, $name), 'rb');
        if (!is_resource($stream)) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to open VFS file %s/%s.', $path, $name)));
        }
        return $stream;
    }

    /**
     * Retrieves a part of a file from the VFS. Particularly useful when
     * reading large files which would exceed the PHP memory limits if they
     * were stored in a string.
     *
     * @param string  $path       The pathname to the file.
     * @param string  $name       The filename to retrieve.
     * @param integer $offset     The offset of the part. (The new offset will
     *                            be stored in here).
     * @param integer $length     The length of the part. If the length = -1,
     *                            the whole part after the offset is retrieved.
     *                            If more bytes are given as exists after the
     *                            given offset. Only the available bytes are
     *                            read.
     * @param integer $remaining  The bytes that are left, after the part that
     *                            is retrieved.
     *
     * @return string  The file data.
     * @throws Horde_Vfs_Exception
     */
    public function readByteRange($path, $name, &$offset, $length, &$remaining)
    {
        if ($offset < 0) {
            throw new Horde_Vfs_Exception(sprintf('Wrong offset %d while reading VFS file %s/%s.', $offset, $path, $name));
        }

        // Calculate how many bytes MUST be read, so the remainging
        // bytes and the new offset can be calculated correctly.
        $file = $this->_getNativePath($path, $name);
        $size = filesize ($file);
        if (($length == -1) || (($length + $offset) > $size)) {
            $length = $size - $offset;
        }
        if ($remaining < 0) {
            $remaining = 0;
        }

        $fp = @fopen($file, 'rb');
        if (!$fp) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to open VFS file %s/%s.', $path, $name)));
        }
        fseek($fp, $offset);
        $data = fread($fp, $length);
        $offset = ftell($fp);
        $remaining = $size - $offset;

        fclose($fp);

        return $data;
    }

    /**
     * Store a file in the VFS, with the data copied from a temporary
     * file.
     *
     * @param string $path         The path to store the file in.
     * @param string $name         The filename to use.
     * @param string $tmpFile      The temporary file containing the data to be
     *                             stored.
     * @param boolean $autocreate  Automatically create directories?
     *
     * @throws Horde_Vfs_Exception
     */
    public function write($path, $name, $tmpFile, $autocreate = false)
    {
        if (!@is_dir($this->_getNativePath($path))) {
            if ($autocreate) {
                $this->autocreatePath($path);
            } else {
                throw new Horde_Vfs_Exception(sprintf('VFS directory %s does not exist.', $path));
            }
        }

        $this->_checkQuotaWrite('file', $tmpFile, $path, $name);

        // Since we already have the data in a file, don't read it
        // into PHP's memory at all - just copy() it to the new
        // location. We leave it to the caller to clean up the
        // temporary file, so we don't use rename().
        if (!@copy($tmpFile, $this->_getNativePath($path, $name))) {
            throw new Horde_Vfs_Exception(sprintf('Unable to write VFS file %s/%s (copy() failed).', $path, $name));
        }
    }

    /**
     * Moves a file in the database and the file system.
     *
     * @param string $path         The path to store the file in.
     * @param string $name         The filename to use.
     * @param string $dest         The destination of the file.
     * @param boolean $autocreate  Automatically create directories?
     *
     * @throws Horde_Vfs_Exception
     */
    public function move($path, $name, $dest, $autocreate = false)
    {
        $orig = $this->_getNativePath($path, $name);
        if (preg_match('|^' . preg_quote($orig) . '/?$|', $dest)) {
            throw new Horde_Vfs_Exception(sprintf('Unable to move VFS file %s/%s - destination is within source.', $path, $name));
        }

        if ($autocreate) {
            $this->autocreatePath($dest);
        }

        foreach ($this->listFolder($dest, false) as $file) {
            if ($file['name'] == $name) {
                throw new Horde_Vfs_Exception(sprintf('Unable to move VFS file %s/%s - destination exists already.', $path, $name));
            }
        }

        if (!@rename($orig, $this->_getNativePath($dest, $name))) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to move VFS file %s/%s.', $path, $name)));
        }
    }

    /**
     * Copies a file through the backend.
     *
     * @param string $path         The path to store the file in.
     * @param string $name         The filename to use.
     * @param string $dest         The destination of the file.
     * @param boolean $autocreate  Automatically create directories?
     *
     * @throws Horde_Vfs_Exception
     */
    public function copy($path, $name, $dest, $autocreate = false)
    {
        $this->_checkDestination($path, $dest);

        if ($autocreate) {
            $this->autocreatePath($dest);
        }

        foreach ($this->listFolder($dest, false) as $file) {
            if ($file['name'] == $name) {
                throw new Horde_Vfs_Exception('Unable to copy VFS file.');
            }
        }

        $orig = $this->_getNativePath($path, $name);
        $this->_checkQuotaWrite('file', $orig, $dest, $name);

        if (!@copy($orig, $this->_getNativePath($dest, $name))) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to copy VFS file %s/%s.', $path, $name)));
        }
    }

    /**
     * Store a file in the VFS from raw data.
     *
     * @param string $path           The path to store the file in.
     * @param string $name           The filename to use.
     * @param string|resource $data  The data as a string or stream resource.
     *                               Resources allowed  @since  2.4.0
     * @param boolean $autocreate    Automatically create directories?
     *
     * @throws Horde_Vfs_Exception
     */
    public function writeData($path, $name, $data, $autocreate = false)
    {
        if (!@is_dir($this->_getNativePath($path))) {
            if ($autocreate) {
                $this->autocreatePath($path);
            } else {
                throw new Horde_Vfs_Exception(sprintf('VFS directory %s does not exist.', $path));
            }
        }

        // Ensure we have either a string or seekable stream.
        $data = $this->_ensureSeekable($data);

        // Treat an attempt to write an empty file as a touch() call,
        // since otherwise the file will not be created at all.
        if (!$this->_getDataSize($data)) {
            if (@touch($this->_getNativePath($path, $name))) {
                return;
            }
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to create empty VFS file %s/%s.', $path, $name)));
        }

        $this->_checkQuotaWrite('string', $data, $path, $name);

        // Otherwise we go ahead and try to write out the file.
        if (!@file_put_contents($this->_getNativePath($path, $name), $data)) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to write data to VFS file %s/%s.', $path, $name)));
        }
    }

    /**
     * Delete a file from the VFS.
     *
     * @param string $path  The path to store the file in.
     * @param string $name  The filename to use.
     *
     * @throws Horde_Vfs_Exception
     */
    public function deleteFile($path, $name)
    {
        $this->_checkQuotaDelete($path, $name);

        if (!@unlink($this->_getNativePath($path, $name))) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to delete VFS file %s/%s.', $path, $name)));
        }
    }

    /**
     * Delete a folder from the VFS.
     *
     * @param string $path        The path to delete the folder from.
     * @param string $name        The foldername to use.
     * @param boolean $recursive  Force a recursive delete?
     *
     * @throws Horde_Vfs_Exception
     */
    public function deleteFolder($path, $name, $recursive = false)
    {
        if ($recursive) {
            $this->emptyFolder($path . '/' . $name);
        } else {
            $list = $this->listFolder($path . '/' . $name);
            if (count($list)) {
                throw new Horde_Vfs_Exception(sprintf('Unable to delete %s/%s, the directory is not empty', $path, $name));
            }
        }

        if (!@rmdir($this->_getNativePath($path, $name))) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to delete VFS directory %s/%s.', $path, $name)));
        }
    }

    /**
     * Creates a folder on the VFS.
     *
     * @param string $path  The path to create the folder in.
     * @param string $name  The foldername to use.
     *
     * @throws Horde_Vfs_Exception
     */
    public function createFolder($path, $name)
    {
        if (!@mkdir($this->_getNativePath($path, $name))) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to create VFS directory %s/%s.', $path, $name)));
        }
    }

    /**
     * Check if a given pathname is a folder.
     *
     * @param string $path  The path to the folder.
     * @param string $name  The file/folder name.
     *
     * @return boolean  True if it is a folder, false otherwise.
     */
    public function isFolder($path, $name)
    {
        return @is_dir($this->_getNativePath($path, $name));
    }

    /**
     * Changes permissions for an item in the VFS.
     *
     * @param string $path        The path of directory of the item.
     * @param string $name        The name of the item.
     * @param string $permission  The permission to set in octal notation.
     *
     * @throws Horde_Vfs_Exception
     */
    public function changePermissions($path, $name, $permission)
    {
        if (!@chmod($this->_getNativePath($path, $name), base_convert($permission, 8, 10))) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to change permission for VFS file %s/%s.', $path, $name)));
        }
    }

    /**
     * Returns an unsorted file list of the specified directory.
     *
     * @param string $path          The path of the directory.
     * @param string|array $filter  Regular expression(s) to filter
     *                              file/directory name on.
     * @param boolean $dotfiles     Show dotfiles?
     * @param boolean $dironly      Show only directories?
     *
     * @return array  File list.
     * @throws Horde_Vfs_Exception
     */
    protected function _listFolder($path, $filter = null, $dotfiles = true,
                                   $dironly = false)
    {
        $files = array();
        $dir = $this->_getNativePath(strlen($path) ? $path : '');

        if (!@is_dir($dir)) {
            throw new Horde_Vfs_Exception(sprintf('%s is not a VFS directory', $path));
        }

        $d = @dir($dir);
        if (!$d) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to access VFS directory %s.', $path)));
        }

        while (($entry = $d->read()) !== false) {
            // Filter out '.' and '..' entries.
            if ($entry == '.' || $entry == '..') {
                continue;
            }

            // Filter out dotfiles if they aren't wanted.
            if (!$dotfiles && substr($entry, 0, 1) == '.') {
                continue;
            }

            // File name
            $file['name'] = $entry;
            $path = $dir . '/' . $entry;

            // Unix style file permissions
            $file['perms'] = $this->_getUnixPerms(fileperms($path));

            // Owner
            $file['owner'] = fileowner($path);
            if (function_exists('posix_getpwuid')) {
                $owner = posix_getpwuid($file['owner']);
                $file['owner'] = $owner['name'];
            }

            // Group
            $file['group'] = filegroup($path);
            if (function_exists('posix_getgrgid')) {
                $group = posix_getgrgid($file['group']);
                $file['group'] = $group['name'];
            }

            // Size
            $file['size'] = filesize($path);

            // Date
            $file['date'] = filemtime($path);

            // Type
            if (@is_dir($path) && !is_link($path)) {
                $file['perms'] = 'd' . $file['perms'];
                $file['type'] = '**dir';
                $file['size'] = -1;
            } elseif (is_link($path)) {
                $file['perms'] = 'l' . $file['perms'];
                $file['type'] = '**sym';
                $file['link'] = readlink($path);
                $file['linktype'] = '**none';
                if (file_exists($file['link'])) {
                    if (is_dir($file['link'])) {
                        $file['linktype'] = '**dir';
                    } elseif (is_link($file['link'])) {
                        $file['linktype'] = '**sym';
                    } elseif (is_file($file['link'])) {
                        $ext = explode('.', $file['link']);
                        if (!(count($ext) == 1 || ($ext[0] === '' && count($ext) == 2))) {
                            $file['linktype'] = Horde_String::lower($ext[count($ext) - 1]);
                        }
                    }
                } else {
                    $file['linktype'] = '**broken';
                }
            } elseif (is_file($path)) {
                $file['perms'] = '-' . $file['perms'];
                $ext = explode('.', $entry);

                if (count($ext) == 1 || (substr($file['name'], 0, 1) === '.' && count($ext) == 2)) {
                    $file['type'] = '**none';
                } else {
                    $file['type'] = Horde_String::lower($ext[count($ext) - 1]);
                }
            } else {
                $file['type'] = '**none';
                if ((fileperms($path) & 0xC000) == 0xC000) {
                    $file['perms'] = 's' . $file['perms'];
                } elseif ((fileperms($path) & 0x6000) == 0x6000) {
                    $file['perms'] = 'b' . $file['perms'];
                } elseif ((fileperms($path) & 0x2000) == 0x2000) {
                    $file['perms'] = 'c' . $file['perms'];
                } elseif ((fileperms($path) & 0x1000) == 0x1000) {
                    $file['perms'] = 'p' . $file['perms'];
                } else {
                    $file['perms'] = '?' . $file['perms'];
                }
            }

            // Filtering.
            if ($this->_filterMatch($filter, $file['name'])) {
                unset($file);
                continue;
            }
            if ($dironly && $file['type'] !== '**dir') {
                unset($file);
                continue;
            }

            $files[$file['name']] = $file;
            unset($file);
        }

        $d->close();

        return $files;
    }

    /**
     * Return Unix style perms.
     *
     * @param integer $perms  The permissions to set.
     *
     * @return string  Unix style perms.
     */
    protected function _getUnixPerms($perms)
    {
        // Determine permissions
        $owner['read']    = ($perms & 00400) ? 'r' : '-';
        $owner['write']   = ($perms & 00200) ? 'w' : '-';
        $owner['execute'] = ($perms & 00100) ? 'x' : '-';
        $group['read']    = ($perms & 00040) ? 'r' : '-';
        $group['write']   = ($perms & 00020) ? 'w' : '-';
        $group['execute'] = ($perms & 00010) ? 'x' : '-';
        $world['read']    = ($perms & 00004) ? 'r' : '-';
        $world['write']   = ($perms & 00002) ? 'w' : '-';
        $world['execute'] = ($perms & 00001) ? 'x' : '-';

        // Adjust for SUID, SGID and sticky bit
        if ($perms & 0x800) {
            $owner['execute'] = ($owner['execute'] == 'x') ? 's' : 'S';
        }
        if ($perms & 0x400) {
            $group['execute'] = ($group['execute'] == 'x') ? 's' : 'S';
        }
        if ($perms & 0x200) {
            $world['execute'] = ($world['execute'] == 'x') ? 't' : 'T';
        }

        return $owner['read'] . $owner['write'] . $owner['execute'] .
               $group['read'] . $group['write'] . $group['execute'] .
               $world['read'] . $world['write'] . $world['execute'];
    }

    /**
     * Rename a file or folder in the VFS.
     *
     * @param string $oldpath  The old path to the file.
     * @param string $oldname  The old filename.
     * @param string $newpath  The new path of the file.
     * @param string $newname  The new filename.
     *
     * @throws Horde_Vfs_Exception
     */
    public function rename($oldpath, $oldname, $newpath, $newname)
    {
        if (!@is_dir($this->_getNativePath($newpath))) {
            $this->autocreatePath($newpath);
        }

        if (!@rename($this->_getNativePath($oldpath, $oldname),
                     $this->_getNativePath($newpath, $newname))) {
            $this->_throwException(new Horde_Vfs_Exception(sprintf('Unable to rename VFS file %s/%s.', $oldpath, $oldname)));
        }
    }

    /**
     * Returns if a given file or folder exists in a folder.
     *
     * @param string $path  The path to the folder.
     * @param string $name  The file or folder name.
     *
     * @return boolean  True if it exists, false otherwise.
     */
    public function exists($path, $name)
    {
        return file_exists($this->_getNativePath($path, $name));
    }

    /**
     * Return a full filename on the native filesystem, from a VFS
     * path and name.
     *
     * @param string $path  The VFS file path.
     * @param string $name  The VFS filename.
     *
     * @return string  The full native filename.
     */
    protected function _getNativePath($path = '', $name = '')
    {
        $name = basename($name);
        if (strlen($name)) {
            if ($name == '..') {
                $name = '';
            }
            if (substr($name, 0, 1) != '/') {
                $name = '/' . $name;
            }
        }

        if (strlen($path)) {
            if (isset($this->_params['home']) &&
                preg_match('|^~/?(.*)$|', $path, $matches)) {
                $path = $this->_params['home'] . '/' . $matches[1];
            }

            $path = str_replace('..', '', $path);
            if (substr($path, 0, 1) == '/') {
                return $this->_params['vfsroot'] . rtrim($path, '/') . $name;
            } else {
                return $this->_params['vfsroot'] . '/' . rtrim($path, '/') . $name;
            }
        }

        return $this->_params['vfsroot'] . $name;
    }

    /**
     * Make sure that the vfsroot is readable.
     *
     * @throws Horde_Vfs_Exception
     */
    protected function _connect()
    {
        if (!@is_dir($this->_params['vfsroot'])) {
            @mkdir($this->_params['vfsroot']);
        }

        if (!is_readable($this->_params['vfsroot'])) {
            throw new Horde_Vfs_Exception('Unable to read the vfsroot directory.');
        }
    }

    /**
     * Completes an exception with a detailed error message and throws it.
     *
     * @param Exception $e  The prepared exception.
     *
     * @throws Exception  The exception $e with error details attached.
     */
    protected function _throwException(Exception $e)
    {
        if ($error = error_get_last()) {
            $e->details = $error['message'];
        }
        throw $e;
    }
}
