diff options
Diffstat (limited to 'apps')
-rw-r--r-- | apps/files_versioning/ajax/gethead.php | 12 | ||||
-rw-r--r-- | apps/files_versioning/ajax/sethead.php | 14 | ||||
-rw-r--r-- | apps/files_versioning/appinfo/app.php | 20 | ||||
-rw-r--r-- | apps/files_versioning/appinfo/info.xml | 14 | ||||
-rw-r--r-- | apps/files_versioning/css/settings.css | 3 | ||||
-rw-r--r-- | apps/files_versioning/js/settings.js | 25 | ||||
-rw-r--r-- | apps/files_versioning/lib_granite.php | 12 | ||||
-rw-r--r-- | apps/files_versioning/settings.php | 34 | ||||
-rw-r--r-- | apps/files_versioning/templates/settings.php | 12 | ||||
-rw-r--r-- | apps/files_versioning/versionstorage.php | 386 | ||||
-rw-r--r-- | apps/files_versioning/versionwrapper.php | 686 |
11 files changed, 1218 insertions, 0 deletions
diff --git a/apps/files_versioning/ajax/gethead.php b/apps/files_versioning/ajax/gethead.php new file mode 100644 index 00000000000..cc93b7a1d17 --- /dev/null +++ b/apps/files_versioning/ajax/gethead.php @@ -0,0 +1,12 @@ +<?php +/** + * Copyright (c) 2011 Craig Roberts craig0990@googlemail.com + * This file is licensed under the Affero General Public License version 3 or + * later. + */ +require_once('../../../lib/base.php'); + +OC_JSON::checkLoggedIn(); +// Fetch current commit (or HEAD if not yet set) +$head = OC_Preferences::getValue(OC_User::getUser(), 'files_versioning', 'head', 'HEAD'); +OC_JSON::encodedPrint(array("head" => $head)); diff --git a/apps/files_versioning/ajax/sethead.php b/apps/files_versioning/ajax/sethead.php new file mode 100644 index 00000000000..d1b2df9b00f --- /dev/null +++ b/apps/files_versioning/ajax/sethead.php @@ -0,0 +1,14 @@ +<?php +/** + * Copyright (c) 2011 Craig Roberts craig0990@googlemail.com + * This file is licensed under the Affero General Public License version 3 or + * later. + */ +require_once('../../../lib/base.php'); +OC_JSON::checkLoggedIn(); +if(isset($_POST["file_versioning_head"])){ + OC_Preferences::setValue(OC_User::getUser(), 'files_versioning', 'head', $_POST["file_versioning_head"]); + OC_JSON::success(); +}else{ + OC_JSON::error(); +} diff --git a/apps/files_versioning/appinfo/app.php b/apps/files_versioning/appinfo/app.php new file mode 100644 index 00000000000..24a8701dbb0 --- /dev/null +++ b/apps/files_versioning/appinfo/app.php @@ -0,0 +1,20 @@ +<?php + +// Include required files +require_once('apps/files_versioning/versionstorage.php'); +require_once('apps/files_versioning/versionwrapper.php'); +// Register streamwrapper for versioned:// paths +stream_wrapper_register('versioned', 'OC_VersionStreamWrapper'); + +// Add an entry in the app list for versioning and backup +OC_App::register( array( + 'order' => 10, + 'id' => 'files_versioning', + 'name' => 'Versioning and Backup' )); + +// Include stylesheets for the settings page +OC_Util::addStyle( 'files_versioning', 'settings' ); +OC_Util::addScript('files_versioning','settings'); + +// Register a settings section in the Admin > Personal page +OC_APP::registerPersonal('files_versioning','settings'); diff --git a/apps/files_versioning/appinfo/info.xml b/apps/files_versioning/appinfo/info.xml new file mode 100644 index 00000000000..d5546be54ae --- /dev/null +++ b/apps/files_versioning/appinfo/info.xml @@ -0,0 +1,14 @@ +<?xml version="1.0"?> +<info> + <id>files_versioning</id> + <name>Versioning and Backup</name> + <version>1.0.0</version> + <licence>GPLv2</licence> + <author>Craig Roberts</author> + <require>3</require> + <description>Versions files using Git repositories, providing a simple backup facility. Currently in *beta* and explicitly without warranty of any kind.</description> + <default_enable/> + <types> + <filesystem/> + </types> +</info> diff --git a/apps/files_versioning/css/settings.css b/apps/files_versioning/css/settings.css new file mode 100644 index 00000000000..afe2cd5508f --- /dev/null +++ b/apps/files_versioning/css/settings.css @@ -0,0 +1,3 @@ +#file_versioning_commit_chzn { + width: 15em; +} diff --git a/apps/files_versioning/js/settings.js b/apps/files_versioning/js/settings.js new file mode 100644 index 00000000000..8dd13bac033 --- /dev/null +++ b/apps/files_versioning/js/settings.js @@ -0,0 +1,25 @@ +$(document).ready(function(){ + $('#file_versioning_head').chosen(); + + $.getJSON(OC.filePath('files_versioning', 'ajax', 'gethead.php'), function(jsondata, status) { + + if (jsondata.head == 'HEAD') { + // Most recent commit, do nothing + } else { + $("#file_versioning_head").val(jsondata.head); + // Trigger the chosen update call + // See http://harvesthq.github.com/chosen/ + $("#file_versioning_head").trigger("liszt:updated"); + } + }); + + $('#file_versioning_head').change(function() { + + var data = $(this).serialize(); + $.post( OC.filePath('files_versioning', 'ajax', 'sethead.php'), data, function(data){ + if(data == 'error'){ + console.log('Saving new HEAD failed'); + } + }); + }); +}); diff --git a/apps/files_versioning/lib_granite.php b/apps/files_versioning/lib_granite.php new file mode 100644 index 00000000000..c69c62d9c45 --- /dev/null +++ b/apps/files_versioning/lib_granite.php @@ -0,0 +1,12 @@ +<?php + +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/blob.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/commit.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/repository.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/tag.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/tree.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/tree/node.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/object/index.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/object/raw.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/object/loose.php'); +require_once(OC::$SERVERROOT . '/3rdparty/granite/git/object/packed.php'); diff --git a/apps/files_versioning/settings.php b/apps/files_versioning/settings.php new file mode 100644 index 00000000000..94af587a215 --- /dev/null +++ b/apps/files_versioning/settings.php @@ -0,0 +1,34 @@ +<?php + +// Get the full path to the repository folder (FIXME: hard-coded to 'Backup') +$path = OC_Config::getValue('datadirectory', OC::$SERVERROOT.'/data') + . DIRECTORY_SEPARATOR + . OC_User::getUser() + . DIRECTORY_SEPARATOR + . 'files' + . DIRECTORY_SEPARATOR + . 'Backup' + . DIRECTORY_SEPARATOR + . '.git' + . DIRECTORY_SEPARATOR; + +$repository = new Granite\Git\Repository($path); + +$commits = array(); +// Fetch most recent 50 commits (FIXME - haven't tested this much) +$commit = $repository->head(); +for ($i = 0; $i < 50; $i++) { + $commits[] = $commit; + $parents = $commit->parents(); + if (count($parents) > 0) { + $parent = $parents[0]; + } else { + break; + } + + $commit = $repository->factory('commit', $parent); +} + +$tmpl = new OC_Template( 'files_versioning', 'settings'); +$tmpl->assign('commits', $commits); +return $tmpl->fetchPage(); diff --git a/apps/files_versioning/templates/settings.php b/apps/files_versioning/templates/settings.php new file mode 100644 index 00000000000..17f4cc7f77f --- /dev/null +++ b/apps/files_versioning/templates/settings.php @@ -0,0 +1,12 @@ +<fieldset id="status_list" class="personalblock"> + <strong>Versioning and Backup</strong><br> + <p><em>Please note: Backing up large files (around 16MB+) will cause your backup history to grow very large, very quickly.</em></p> + <label class="bold">Backup Folder</label> + <select name="file_versioning_head" id="file_versioning_head"> + <?php + foreach ($_['commits'] as $commit): + echo '<option value="' . $commit->sha() . '">' . $commit->message() . '</option>'; + endforeach; + ?> + </select> +</fieldset> diff --git a/apps/files_versioning/versionstorage.php b/apps/files_versioning/versionstorage.php new file mode 100644 index 00000000000..d083e623df9 --- /dev/null +++ b/apps/files_versioning/versionstorage.php @@ -0,0 +1,386 @@ +<?php +/** + * ownCloud file storage implementation for Git repositories + * @author Craig Roberts + * @copyright 2012 Craig Roberts craig0990@googlemail.com + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU AFFERO GENERAL PUBLIC LICENSE for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with this library. If not, see <http://www.gnu.org/licenses/>. + */ + +// Include Granite +require_once('lib_granite.php'); + +// Create a top-level 'Backup' directory if it does not already exist +$user = OC_User::getUser(); +if (OC_Filesystem::$loaded and !OC_Filesystem::is_dir('/Backup')) { + OC_Filesystem::mkdir('/Backup'); + OC_Preferences::setValue(OC_User::getUser(), 'files_versioning', 'head', 'HEAD'); +} + +// Generate the repository path (currently using 'full' repositories, as opposed to bare ones) +$repo_path = DIRECTORY_SEPARATOR + . OC_User::getUser() + . DIRECTORY_SEPARATOR + . 'files' + . DIRECTORY_SEPARATOR + . 'Backup'; + +// Mount the 'Backup' folder using the versioned storage provider below +OC_Filesystem::mount('OC_Filestorage_Versioned', array('repo'=>$repo_path), $repo_path . DIRECTORY_SEPARATOR); + +class OC_Filestorage_Versioned extends OC_Filestorage { + + /** + * Holds an instance of Granite\Git\Repository + */ + protected $repo; + + /** + * Constructs a new OC_Filestorage_Versioned instance, expects an associative + * array with a `repo` key set to the path of the repository's `.git` folder + * + * @param array $parameters An array containing the key `repo` pointing to the + * repository path. + */ + public function __construct($parameters) { + // Get the full path to the repository folder + $path = OC_Config::getValue('datadirectory', OC::$SERVERROOT.'/data') + . $parameters['repo'] + . DIRECTORY_SEPARATOR + . '.git' + . DIRECTORY_SEPARATOR; + + try { + // Attempt to load the repository + $this->repo = new Granite\Git\Repository($path); + } catch (InvalidArgumentException $e) { + // $path is not a valid Git repository, we must create one + Granite\Git\Repository::init($path); + + // Load the newly-initialised repository + $this->repo = new Granite\Git\Repository($path); + + /** + * Create an initial commit with a README file + * FIXME: This functionality should be transferred to the Granite library + */ + $blob = new Granite\Git\Blob($this->repo->path()); + $blob->content('Your Backup directory is now ready for use.'); + + // Create a new tree to hold the README file + $tree = $this->repo->factory('tree'); + // Create a tree node to represent the README blob + $tree_node = new Granite\Git\Tree\Node('README', '100644', $blob->sha()); + $tree->nodes(array($tree_node->name() => $tree_node)); + + // Create an initial commit + $commit = new Granite\Git\Commit($this->repo->path()); + $user_string = OC_User::getUser() . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message('Initial commit'); + $commit->tree($tree); + + // Write it all to disk + $blob->write(); + $tree->write(); + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + } + + // Update the class pointer to the HEAD + $head = OC_Preferences::getValue(OC_User::getUser(), 'files_versioning', 'head', 'HEAD'); + + // Load the most recent commit if the preference is not set + if ($head == 'HEAD') { + $this->head = $this->repo->head()->sha(); + } else { + $this->head = $head; + } + } + + public function mkdir($path) { + if (mkdir("versioned:/{$this->repo->path()}$path#{$this->head}")) { + $this->head = $this->repo->head()->sha(); + OC_Preferences::setValue(OC_User::getUser(), 'files_versioning', 'head', $head); + return true; + } + + return false; + } + + public function rmdir($path) { + + } + + /** + * Returns a directory handle to the requested path, or FALSE on failure + * + * @param string $path The directory path to open + * + * @return boolean|resource A directory handle, or FALSE on failure + */ + public function opendir($path) { + return opendir("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns TRUE if $path is a directory, or FALSE if not + * + * @param string $path The path to check + * + * @return boolean + */ + public function is_dir($path) { + return $this->filetype($path) == 'dir'; + } + + /** + * Returns TRUE if $path is a file, or FALSE if not + * + * @param string $path The path to check + * + * @return boolean + */ + public function is_file($path) { + return $this->filetype($path) == 'file'; + } + + public function stat($path) + { + return stat("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns the strings 'dir' or 'file', depending on the type of $path + * + * @param string $path The path to check + * + * @return string Returns 'dir' if a directory, 'file' otherwise + */ + public function filetype($path) { + if ($path == "" || $path == "/") { + return 'dir'; + } else { + if (substr($path, -1) == '/') { + $path = substr($path, 0, -1); + } + + $node = $this->tree_search($this->repo, $this->repo->factory('commit', $this->head)->tree(), $path); + + // Does it exist, or is it new? + if ($node == null) { + // New file + return 'file'; + } else { + // Is it a tree? + try { + $this->repo->factory('tree', $node); + return 'dir'; + } catch (InvalidArgumentException $e) { + // Nope, must be a blob + return 'file'; + } + } + } + } + + public function filesize($path) { + return filesize("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns a boolean value representing whether $path is readable + * + * @param string $path The path to check + *( + * @return boolean Whether or not the path is readable + */ + public function is_readable($path) { + return true; + } + + /** + * Returns a boolean value representing whether $path is writable + * + * @param string $path The path to check + *( + * @return boolean Whether or not the path is writable + */ + public function is_writable($path) { + + $head = OC_Preferences::getValue(OC_User::getUser(), 'files_versioning', 'head', 'HEAD'); + if ($head !== 'HEAD' && $head !== $this->repo->head()->sha()) { + // Cannot modify previous commits + return false; + } + return true; + } + + /** + * Returns a boolean value representing whether $path exists + * + * @param string $path The path to check + *( + * @return boolean Whether or not the path exists + */ + public function file_exists($path) { + return file_exists("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + /** + * Returns an integer value representing the inode change time + * (NOT IMPLEMENTED) + * + * @param string $path The path to check + *( + * @return int Timestamp of the last inode change + */ + public function filectime($path) { + return -1; + } + + /** + * Returns an integer value representing the file modification time + * + * @param string $path The path to check + *( + * @return int Timestamp of the last file modification + */ + public function filemtime($path) { + return filemtime("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + public function file_get_contents($path) { + return file_get_contents("versioned:/{$this->repo->path()}$path#{$this->head}"); + } + + public function file_put_contents($path, $data) { + $success = file_put_contents("versioned:/{$this->repo->path()}$path#{$this->head}", $data); + if ($success !== false) { + // Update the HEAD in the preferences + OC_Preferences::setValue(OC_User::getUser(), 'files_versioning', 'head', $this->repo->head()->sha()); + return $success; + } + + return false; + } + + public function unlink($path) { + + } + + public function rename($path1, $path2) { + + } + + public function copy($path1, $path2) { + + } + + public function fopen($path, $mode) { + return fopen("versioned:/{$this->repo->path()}$path#{$this->head}", $mode); + } + + public function getMimeType($path) { + if ($this->filetype($path) == 'dir') { + return 'httpd/unix-directory'; + } elseif ($this->filesize($path) == 0) { + // File's empty, returning text/plain allows opening in the web editor + return 'text/plain'; + } else { + $finfo = new finfo(FILEINFO_MIME_TYPE); + /** + * We need to represent the repository path, the file path, and the + * revision, which can be simply achieved with a convention of using + * `.git` in the repository directory (bare or not) and the '#part' + * segment of a URL to specify the revision. For example + * + * versioned://var/www/myrepo.git/docs/README.md#HEAD ('bare' repo) + * versioned://var/www/myrepo/.git/docs/README.md#HEAD ('full' repo) + * versioned://var/www/myrepo/.git/docs/README.md#6a8f...8a54 ('full' repo and SHA-1 commit ID) + */ + $mime = $finfo->buffer(file_get_contents("versioned:/{$this->repo->path()}$path#{$this->head}")); + return $mime; + } + } + + /** + * Generates a hash based on the file contents + * + * @param string $type The hashing algorithm to use (e.g. 'md5', 'sha256', etc.) + * @param string $path The file to be hashed + * @param boolean $raw Outputs binary data if true, lowercase hex digits otherwise + * + * @return string Hashed string representing the file contents + */ + public function hash($type, $path, $raw) { + return hash($type, file_get_contents($path), $raw); + } + + public function free_space($path) { + } + + public function search($query) { + + } + + public function touch($path, $mtime=null) { + + } + + + public function getLocalFile($path) { + } + + /** + * Recursively searches a tree for a path, returning FALSE if is not found + * or an SHA-1 id if it is found. + * + * @param string $repo The repository containing the tree object + * @param string $tree The tree object to search + * @param string $path The path to search for (relative to the tree) + * @param int $depth The depth of the current search (for recursion) + * + * @return string|boolean The SHA-1 id of the sub-tree + */ + private function tree_search($repo, $tree, $path, $depth = 0) + { + $paths = array_values(explode(DIRECTORY_SEPARATOR, $path)); + + $current_path = $paths[$depth]; + + $nodes = $tree->nodes(); + foreach ($nodes as $node) { + if ($node->name() == $current_path) { + + if (count($paths)-1 == $depth) { + // Stop, found it + return $node->sha(); + } + + // Recurse if necessary + if ($node->isDirectory()) { + $tree = $this->repo->factory('tree', $node->sha()); + return $this->tree_search($repo, $tree, $path, $depth + 1); + } + } + } + + return false; + } + +} diff --git a/apps/files_versioning/versionwrapper.php b/apps/files_versioning/versionwrapper.php new file mode 100644 index 00000000000..b83a4fd3b22 --- /dev/null +++ b/apps/files_versioning/versionwrapper.php @@ -0,0 +1,686 @@ +<?php + +final class OC_VersionStreamWrapper { + + /** + * Determines whether or not to log debug messages with `OC_Log::write()` + */ + private $debug = true; + + /** + * The name of the ".empty" files created in new directories + */ + const EMPTYFILE = '.empty'; + + /** + * Stores the current position for `readdir()` etc. calls + */ + private $dir_position = 0; + + /** + * Stores the current position for `fread()`, `fseek()` etc. calls + */ + private $file_position = 0; + + /** + * Stores the current directory tree for `readdir()` etc. directory traversal + */ + private $tree; + + /** + * Stores the current file for `fread()`, `fseek()`, etc. calls + */ + private $blob; + + /** + * Stores the current commit for `fstat()`, `stat()`, etc. calls + */ + private $commit; + + /** + * Stores the current path for `fwrite()`, `file_put_contents()` etc. calls + */ + private $path; + + /** + * Close directory handle + */ + public function dir_closedir() { + unset($this->tree); + return true; + } + + /** + * Open directory handle + */ + public function dir_opendir($path, $options) { + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + if ($repo_file == '' || $repo_file == '/') { + // Set the tree property for the future `readdir()` etc. calls + $this->tree = array_values($this->commit->tree()->nodes()); + return true; + } elseif ($this->tree_search($this->repo, $this->commit->tree(), $repo_file) !== false) { + // Something exists at this path, is it a directory though? + try { + $tree = $this->repo->factory( + 'tree', + $this->tree_search($this->repo, $this->commit->tree(), $repo_file) + ); + $this->tree = array_values($tree->nodes()); + return true; + } catch (InvalidArgumentException $e) { + // Trying to call `opendir()` on a file, return false below + } + } + + // Unable to find the directory, return false + return false; + } + + /** + * Read entry from directory handle + */ + public function dir_readdir() { + return isset($this->tree[$this->dir_position]) + ? $this->tree[$this->dir_position++]->name() + : false; + } + + /** + * Rewind directory handle + */ + public function dir_rewinddir() { + $this->dir_position = 0; + } + + /** + * Create a directory + * Git doesn't track empty directories, so a ".empty" file is added instead + */ + public function mkdir($path, $mode, $options) { + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + // Create an empty file for Git + $empty = new Granite\Git\Blob($this->repo->path()); + $empty->content(''); + $empty->write(); + + if (dirname($repo_file) == '.') { + // Adding a new directory to the root tree + $tree = $this->repo->head()->tree(); + } else { + $tree = $this->repo->factory('tree', $this->tree_search( + $this->repo, $this->repo->head()->tree(), dirname($repo_file) + ) + ); + } + + // Create our new tree, with our empty file + $dir = $this->repo->factory('tree'); + $nodes = array(); + $nodes[self::EMPTYFILE] = new Granite\Git\Tree\Node(self::EMPTYFILE, '100644', $empty->sha()); + $dir->nodes($nodes); + $dir->write(); + + // Add our new tree to its parent + $nodes = $tree->nodes(); + $nodes[basename($repo_file)] = new Granite\Git\Tree\Node(basename($repo_file), '040000', $dir->sha()); + $tree->nodes($nodes); + $tree->write(); + + // We need to recursively update each parent tree, since they are all + // hashed and the changes will cascade back up the chain + + // So, we're currently at the bottom-most directory + $current_dir = dirname($repo_file); + $previous_tree = $tree; + + if ($current_dir !== '.') { + do { + // Determine the parent directory + $previous_dir = $current_dir; + $current_dir = dirname($current_dir); + + $current_tree = $current_dir !== '.' + ? $this->repo->factory( + 'tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + $current_dir + ) + ) + : $this->repo->head()->tree(); + + $current_nodes = $current_tree->nodes(); + $current_nodes[basename($previous_dir)] = new Granite\Git\Tree\Node( + basename($previous_dir), '040000', $previous_tree->sha() + ); + $current_tree->nodes($current_nodes); + $current_tree->write(); + + $previous_tree = $current_tree; + } while ($current_dir !== '.'); + + $tree = $previous_tree; + } + + // Create a new commit to represent this write + $commit = $this->repo->factory('commit'); + $username = OC_User::getUser(); + $user_string = $username . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message("$username created the `$repo_file` directory, " . date('d F Y H:i', time()) . '.'); + $commit->parents(array($this->repo->head()->sha())); + $commit->tree($tree); + + // Write it to disk + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + + return true; + } + + /** + * Renames a file or directory + */ + public function rename($path_from, $path_to) { + + } + + /** + * Removes a directory + */ + public function rmdir($path, $options) { + + } + + /** + * Retrieve the underlaying resource (NOT IMPLEMENTED) + */ + public function stream_cast($cast_as) { + return false; + } + + /** + * Close a resource + */ + public function stream_close() { + unset($this->blob); + return true; + } + + /** + * Tests for end-of-file on a file pointer + */ + public function stream_eof() { + return !($this->file_position < strlen($this->blob)); + } + + /** + * Flushes the output (NOT IMPLEMENTED) + */ + public function stream_flush() { + return false; + } + + /** + * Advisory file locking (NOT IMPLEMENTED) + */ + public function stream_lock($operation) { + return false; + } + + /** + * Change stream options (NOT IMPLEMENTED) + * Called in response to `chgrp()`, `chown()`, `chmod()` and `touch()` + */ + public function stream_metadata($path, $option, $var) { + return false; + } + + /** + * Opens file or URL + */ + public function stream_open($path, $mode, $options, &$opened_path) { + // Store the path, so we can use it later in `stream_write()` if necessary + $this->path = $path; + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + $file = $this->tree_search($this->repo, $this->commit->tree(), $repo_file); + if ($file !== false) { + try { + $this->blob = $this->repo->factory('blob', $file)->content(); + return true; + } catch (InvalidArgumentException $e) { + // Trying to open a directory, return false below + } + } elseif ($mode !== 'r') { + // All other modes allow opening for reading and writing, clearly + // some 'write' files may not exist yet... + return true; + } + + // File could not be found or is not actually a file + return false; + } + + /** + * Read from stream + */ + public function stream_read($count) { + // Fetch the remaining set of bytes + $bytes = substr($this->blob, $this->file_position, $count); + + // If EOF or empty string, return false + if ($bytes == '' || $bytes == false) { + return false; + } + + // If $count does not extend past EOF, add $count to stream offset + if ($this->file_position + $count < strlen($this->blob)) { + $this->file_position += $count; + } else { + // Otherwise return all remaining bytes + $this->file_position = strlen($this->blob); + } + + return $bytes; + } + + /** + * Seeks to specific location in a stream + */ + public function stream_seek($offset, $whence = SEEK_SET) { + $new_offset = false; + + switch ($whence) + { + case SEEK_SET: + $new_offset = $offset; + break; + case SEEK_CUR: + $new_offset = $this->file_position += $offset; + break; + case SEEK_END: + $new_offset = strlen($this->blob) + $offset; + break; + } + + $this->file_position = $offset; + + return ($new_offset !== false); + } + + /** + * Change stream options (NOT IMPLEMENTED) + */ + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + /** + * Retrieve information about a file resource (NOT IMPLEMENTED) + */ + public function stream_stat() { + + } + + /** + * Retrieve the current position of a stream + */ + public function stream_tell() { + return $this->file_position; + } + + /** + * Truncate stream + */ + public function stream_truncate($new_size) { + + } + + /** + * Write to stream + * FIXME: Could use heavy refactoring + */ + public function stream_write($data) { + /** + * FIXME: This also needs to be added to Granite, in the form of `add()`, + * `rm()` and `commit()` calls + */ + + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($this->path); + + $node = $this->tree_search($this->repo, $this->commit->tree(), $repo_file); + + if ($node !== false) { + // File already exists, attempting modification of existing tree + try { + $this->repo->factory('blob', $node); + + // Create our new blob with the provided $data + $blob = $this->repo->factory('blob'); + $blob->content($data); + $blob->write(); + + // We know the tree exists, so strip the filename from the path and + // find it... + + if (dirname($repo_file) == '.' || dirname($repo_file) == '') { + // Root directory + $tree = $this->repo->head()->tree(); + } else { + // Sub-directory + $tree = $this->repo->factory('tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + dirname($repo_file) + ) + ); + } + + // Replace the old blob with our newly modified one + $tree_nodes = $tree->nodes(); + $tree_nodes[basename($repo_file)] = new Granite\Git\Tree\Node( + basename($repo_file), '100644', $blob->sha() + ); + $tree->nodes($tree_nodes); + $tree->write(); + + // We need to recursively update each parent tree, since they are all + // hashed and the changes will cascade back up the chain + + // So, we're currently at the bottom-most directory + $current_dir = dirname($repo_file); + $previous_tree = $tree; + + if ($current_dir !== '.') { + do { + // Determine the parent directory + $previous_dir = $current_dir; + $current_dir = dirname($current_dir); + + $current_tree = $current_dir !== '.' + ? $this->repo->factory( + 'tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + $current_dir + ) + ) + : $this->repo->head()->tree(); + + $current_nodes = $current_tree->nodes(); + $current_nodes[basename($previous_dir)] = new Granite\Git\Tree\Node( + basename($previous_dir), '040000', $previous_tree->sha() + ); + $current_tree->nodes($current_nodes); + $current_tree->write(); + + $previous_tree = $current_tree; + } while ($current_dir !== '.'); + } + + // Create a new commit to represent this write + $commit = $this->repo->factory('commit'); + $username = OC_User::getUser(); + $user_string = $username . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message("$username modified the `$repo_file` file, " . date('d F Y H:i', time()) . '.'); + $commit->parents(array($this->repo->head()->sha())); + $commit->tree($previous_tree); + + // Write it to disk + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + + // If we made it this far, write was successful - update the stream + // position and return the number of bytes written + $this->file_position += strlen($data); + return strlen($data); + + } catch (InvalidArgumentException $e) { + // Attempting to write to a directory or other error, fail + return 0; + } + } else { + // File does not exist, needs to be created + + // Create our new blob with the provided $data + $blob = $this->repo->factory('blob'); + $blob->content($data); + $blob->write(); + + if (dirname($repo_file) == '.') { + // Trying to add a new file to the root tree, nice and easy + $tree = $this->repo->head()->tree(); + $tree_nodes = $tree->nodes(); + $tree_nodes[basename($repo_file)] = new Granite\Git\Tree\Node( + basename($repo_file), '100644', $blob->sha() + ); + $tree->nodes($tree_nodes); + $tree->write(); + } else { + // Trying to add a new file to a subdirectory, try and find it + $tree = $this->repo->factory('tree', $this->tree_search( + $this->repo, $this->repo->head()->tree(), dirname($repo_file) + ) + ); + + // Add the blob to the tree + $nodes = $tree->nodes(); + $nodes[basename($repo_file)] = new Granite\Git\Tree\Node( + basename($repo_file), '100644', $blob->sha() + ); + $tree->nodes($nodes); + $tree->write(); + + // We need to recursively update each parent tree, since they are all + // hashed and the changes will cascade back up the chain + + // So, we're currently at the bottom-most directory + $current_dir = dirname($repo_file); + $previous_tree = $tree; + + if ($current_dir !== '.') { + do { + // Determine the parent directory + $previous_dir = $current_dir; + $current_dir = dirname($current_dir); + + $current_tree = $current_dir !== '.' + ? $this->repo->factory( + 'tree', $this->tree_search( + $this->repo, + $this->repo->head()->tree(), + $current_dir + ) + ) + : $this->repo->head()->tree(); + + $current_nodes = $current_tree->nodes(); + $current_nodes[basename($previous_dir)] = new Granite\Git\Tree\Node( + basename($previous_dir), '040000', $previous_tree->sha() + ); + $current_tree->nodes($current_nodes); + $current_tree->write(); + + $previous_tree = $current_tree; + } while ($current_dir !== '.'); + + $tree = $previous_tree; + } + } + + // Create a new commit to represent this write + $commit = $this->repo->factory('commit'); + $username = OC_User::getUser(); + $user_string = $username . ' ' . time() . ' +0000'; + $commit->author($user_string); + $commit->committer($user_string); + $commit->message("$username created the `$repo_file` file, " . date('d F Y H:i', time()) . '.'); + $commit->parents(array($this->repo->head()->sha())); + $commit->tree($tree); // Top-level tree (NOT the newly modified tree) + + // Write it to disk + $commit->write(); + + // Update the HEAD for the 'master' branch + $this->repo->head('master', $commit->sha()); + + // If we made it this far, write was successful - update the stream + // position and return the number of bytes written + $this->file_position += strlen($data); + return strlen($data); + } + + // Write failed + return 0; + } + + /** + * Delete a file + */ + public function unlink($path) { + + } + + /** + * Retrieve information about a file + */ + public function url_stat($path, $flags) { + // Parse the URL into a repository directory, file path and commit ID + list($this->repo, $repo_file, $this->commit) = $this->parse_url($path); + + $node = $this->tree_search($this->repo, $this->commit->tree(), $repo_file); + + if ($node == false && $this->commit->sha() == $this->repo->head()->sha()) { + // A new file - no information available + $size = 0; + $mtime = -1; + } else { + + // Is it a directory? + try { + $this->repo->factory('tree', $node); + $size = 4096; // FIXME + } catch (InvalidArgumentException $e) { + // Must be a file + $size = strlen(file_get_contents($path)); + } + + // Parse the timestamp from the commit message + preg_match('/[0-9]{10}+/', $this->commit->committer(), $matches); + $mtime = $matches[0]; + } + + $stat["dev"] = ""; + $stat["ino"] = ""; + $stat["mode"] = ""; + $stat["nlink"] = ""; + $stat["uid"] = ""; + $stat["gid"] = ""; + $stat["rdev"] = ""; + $stat["size"] = $size; + $stat["atime"] = $mtime; + $stat["mtime"] = $mtime; + $stat["ctime"] = $mtime; + $stat["blksize"] = ""; + $stat["blocks"] = ""; + + return $stat; + } + + /** + * Debug function for development purposes + */ + private function debug($message, $level = OC_Log::DEBUG) + { + if ($this->debug) { + OC_Log::write('files_versioning', $message, $level); + } + } + + /** + * Parses a URL of the form: + * `versioned://path/to/git/repository/.git/path/to/file#SHA-1-commit-id` + * FIXME: Will throw an InvalidArgumentException if $path is invaid + * + * @param string $path The path to parse + * + * @return array An array containing an instance of Granite\Git\Repository, + * the file path, and an instance of Granite\Git\Commit + * @throws InvalidArgumentException If the repository cannot be loaded + */ + private function parse_url($path) + { + preg_match('/\/([A-Za-z0-9\/]+\.git\/)([A-Za-z0-9\/\.\/]*)(#([A-Fa-f0-9]+))*/', $path, $matches); + + // Load up the repo + $repo = new \Granite\Git\Repository($matches[1]); + // Parse the filename (stripping any trailing slashes) + $repo_file = $matches[2]; + if (substr($repo_file, -1) == '/') { + $repo_file = substr($repo_file, 0, -1); + } + + // Default to HEAD if no commit is provided + $repo_commit = isset($matches[4]) + ? $matches[4] + : $repo->head()->sha(); + + // Load the relevant commit + $commit = $repo->factory('commit', $repo_commit); + + return array($repo, $repo_file, $commit); + } + + /** + * Recursively searches a tree for a path, returning FALSE if is not found + * or an SHA-1 id if it is found. + * + * @param string $repo The repository containing the tree object + * @param string $tree The tree object to search + * @param string $path The path to search for (relative to the tree) + * @param int $depth The depth of the current search (for recursion) + * + * @return string|boolean The SHA-1 id of the sub-tree + */ + private function tree_search($repo, $tree, $path, $depth = 0) + { + $paths = array_values(explode(DIRECTORY_SEPARATOR, $path)); + + $current_path = $paths[$depth]; + + $nodes = $tree->nodes(); + foreach ($nodes as $node) { + if ($node->name() == $current_path) { + + if (count($paths)-1 == $depth) { + // Stop, found it + return $node->sha(); + } + + // Recurse if necessary + if ($node->isDirectory()) { + $tree = $this->repo->factory('tree', $node->sha()); + return $this->tree_search($repo, $tree, $path, $depth + 1); + } + } + } + + return false; + } + +} |