diff --git a/INSTALL.mdtext b/INSTALL.mdtext index 7827f25..f5aeba2 100644 --- a/INSTALL.mdtext +++ b/INSTALL.mdtext @@ -6,9 +6,15 @@ the installation of InDefero by itself. ## PHP modules for indefero -Indefero need the GD module for PHP. It's named "php5-gd" in debian. +Indefero needs additional PHP modules to function correctly, namely - $ apt-get install php5-gd + - gd (for graphic operations) + - zip (for upload archive processing) + +The package names of these modules might vary between distributions, +for Debian they are + + $ apt-get install php5-gd php5-zip ## Recommended Layout of the Files diff --git a/NEWS.mdtext b/NEWS.mdtext index 16c4965..fb43272 100644 --- a/NEWS.mdtext +++ b/NEWS.mdtext @@ -11,6 +11,7 @@ by the friendly folks from Scilab ! `$cfg['webhook_processing']` flag to "compat", we urge you to change the implementations of this web hook as this setting is likely to be removed in future versions of Indefero. +- Indefero now needs PHP's zip module which is not enabled by default. ## New Features @@ -19,6 +20,9 @@ by the friendly folks from Scilab ! - It is now possible to configure a web hook that informs an external URL about new and updated downloads for a specific project, similar to the available post-commit web hook +- One can now upload multiple files at once by using a special archive format + which Indefero processes in the background and for which individual upload + records are created # InDefero 1.2 - xxx xxx xx xx:xx 2011 UTC diff --git a/src/IDF/Form/UploadArchive.php b/src/IDF/Form/UploadArchive.php index 671a0d3..3699d51 100644 --- a/src/IDF/Form/UploadArchive.php +++ b/src/IDF/Form/UploadArchive.php @@ -29,6 +29,7 @@ class IDF_Form_UploadArchive extends Pluf_Form { public $user = null; public $project = null; + private $archiveHelper = null; public function initFields($extra=array()) { @@ -40,26 +41,34 @@ class IDF_Form_UploadArchive extends Pluf_Form 'label' => __('Archive file'), 'initial' => '', 'max_size' => Pluf::f('max_upload_archive_size', 20971520), - )); + 'move_function_params' => array( + 'upload_path' => Pluf::f('upload_path').'/'.$this->project->shortname.'/archives', + 'upload_path_create' => true, + 'upload_overwrite' => true, + ))); } public function clean_archive() { - $extra = strtolower(implode('|', explode(' ', Pluf::f('idf_extra_upload_ext')))); - if (strlen($extra)) $extra .= '|'; - if (!preg_match('/\.('.$extra.'png|jpg|jpeg|gif|bmp|psd|tif|aiff|asf|avi|bz2|css|doc|eps|gz|jar|mdtext|mid|mov|mp3|mpg|ogg|pdf|ppt|ps|qt|ra|ram|rm|rtf|sdd|sdw|sit|sxi|sxw|swf|tgz|txt|wav|xls|xml|war|wmv|zip)$/i', $this->cleaned_data['file'])) { - @unlink(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file']); - throw new Pluf_Form_Invalid(__('For security reasons, you cannot upload a file with this extension.')); + $this->archiveHelper = new IDF_Form_UploadArchiveHelper( + Pluf::f('upload_path').'/'.$this->project->shortname.'/archives/'.$this->cleaned_data['archive']); + + // basic archive validation + $this->archiveHelper->validate(); + + // extension validation + $names = $this->archiveHelper->getEntryNames(); + foreach ($names as $name) { + $extra = strtolower(implode('|', explode(' ', Pluf::f('idf_extra_upload_ext')))); + if (strlen($extra)) $extra .= '|'; + if (!preg_match('/\.('.$extra.'png|jpg|jpeg|gif|bmp|psd|tif|aiff|asf|avi|bz2|css|doc|eps|gz|jar|mdtext|mid|mov|mp3|mpg|ogg|pdf|ppt|ps|qt|ra|ram|rm|rtf|sdd|sdw|sit|sxi|sxw|swf|tgz|txt|wav|xls|xml|war|wmv|zip)$/i', $name)) { + @unlink(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file']); + throw new Pluf_Form_Invalid(sprintf(__('For security reasons, you cannot upload a file (%s) with this extension.'), $name)); + } } - return $this->cleaned_data['file']; - } - /** - * Validate the interconnection in the form. - */ - public function clean() - { + // label and file name validation $conf = new IDF_Conf(); $conf->setProject($this->project); $onemax = array(); @@ -68,26 +77,39 @@ class IDF_Form_UploadArchive extends Pluf_Form $onemax[] = mb_strtolower(trim($class)); } } - $count = array(); - for ($i=1;$i<7;$i++) { - $this->cleaned_data['label'.$i] = trim($this->cleaned_data['label'.$i]); - if (strpos($this->cleaned_data['label'.$i], ':') !== false) { - list($class, $name) = explode(':', $this->cleaned_data['label'.$i], 2); - list($class, $name) = array(mb_strtolower(trim($class)), - trim($name)); - } else { - $class = 'other'; - $name = $this->cleaned_data['label'.$i]; + + foreach ($names as $name) { + $meta = $this->archiveHelper->getMetaData($name); + $count = array(); + foreach ($meta['labels'] as $label) { + $label = trim($label); + if (strpos($label, ':') !== false) { + list($class, $name) = explode(':', $label, 2); + list($class, $name) = array(mb_strtolower(trim($class)), + trim($name)); + } else { + $class = 'other'; + $name = $label; + } + if (!isset($count[$class])) $count[$class] = 1; + else $count[$class] += 1; + if (in_array($class, $onemax) and $count[$class] > 1) { + throw new Pluf_Form_Invalid( + sprintf(__('You cannot provide more than label from the %s class to a download (%s).'), $class, $name) + ); + } } - if (!isset($count[$class])) $count[$class] = 1; - else $count[$class] += 1; - if (in_array($class, $onemax) and $count[$class] > 1) { - if (!isset($this->errors['label'.$i])) $this->errors['label'.$i] = array(); - $this->errors['label'.$i][] = sprintf(__('You cannot provide more than label from the %s class to an issue.'), $class); - throw new Pluf_Form_Invalid(__('You provided an invalid label.')); + + $sql = new Pluf_SQL('file=%s AND project=%s', array($name, $this->project->id)); + $upload = Pluf::factory('IDF_Upload')->getOne(array('filter' => $sql->gen())); + + if ($upload) { + throw new Pluf_Form_Invalid( + sprintf(__('A file with the name "%s" has already been uploaded.'), $name)); } } - return $this->cleaned_data; + + return $this->cleaned_data['archive']; } /** @@ -96,9 +118,9 @@ class IDF_Form_UploadArchive extends Pluf_Form */ function failed() { - if (!empty($this->cleaned_data['file']) - and file_exists(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file'])) { - @unlink(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file']); + if (!empty($this->cleaned_data['archive']) + and file_exists(Pluf::f('upload_path').'/'.$this->project->shortname.'/archives/'.$this->cleaned_data['archive'])) { + @unlink(Pluf::f('upload_path').'/'.$this->project->shortname.'/archives/'.$this->cleaned_data['archive']); } } @@ -107,65 +129,93 @@ class IDF_Form_UploadArchive extends Pluf_Form * * @param bool Commit in the database or not. If not, the object * is returned but not saved in the database. - * @return Object Model with data set from the form. */ function save($commit=true) { if (!$this->isValid()) { throw new Exception(__('Cannot save the model from an invalid form.')); } - // Add a tag for each label - $tags = array(); - for ($i=1;$i<7;$i++) { - if (strlen($this->cleaned_data['label'.$i]) > 0) { - if (strpos($this->cleaned_data['label'.$i], ':') !== false) { - list($class, $name) = explode(':', $this->cleaned_data['label'.$i], 2); - list($class, $name) = array(trim($class), trim($name)); - } else { - $class = 'Other'; - $name = trim($this->cleaned_data['label'.$i]); + + $uploadDir = Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'; + $fileNames = $this->archiveHelper->getEntryNames(); + + foreach ($fileNames as $fileName) { + $meta = $this->archiveHelper->getMetaData($fileName); + + // add a tag for each label + $tags = array(); + foreach ($meta['labels'] as $label) { + $label = trim($label); + if (strlen($label) > 0) { + if (strpos($label, ':') !== false) { + list($class, $name) = explode(':', $label, 2); + list($class, $name) = array(trim($class), trim($name)); + } else { + $class = 'Other'; + $name = $label; + } + $tags[] = IDF_Tag::add($name, $this->project, $class); } - $tags[] = IDF_Tag::add($name, $this->project, $class); } + + // extract the file + $this->archiveHelper->extract($fileName, $uploadDir); + + // create the upload + $upload = new IDF_Upload(); + $upload->project = $this->project; + $upload->submitter = $this->user; + $upload->summary = trim($meta['summary']); + $upload->changelog = trim($meta['description']); + $upload->file = $fileName; + $upload->filesize = filesize($uploadDir.$fileName); + $upload->downloads = 0; + $upload->create(); + foreach ($tags as $tag) { + $upload->setAssoc($tag); + } + + // process a possible replacement + if (!empty($meta['replaces'])) { + $sql = new Pluf_SQL('file=%s AND project=%s', array($meta['replaces'], $this->project->id)); + $oldUpload = Pluf::factory('IDF_Upload')->getOne(array('filter' => $sql->gen())); + if ($oldUpload) { + $tags = $this->project->getTagsFromConfig('labels_download_predefined', + IDF_Form_UploadConf::init_predefined); + // the deprecate tag is - by definition - always the last one + $deprecatedTag = array_pop($tags); + $oldUpload->setAssoc($deprecatedTag); + } + } + + // send the notification + $upload->notify($this->project->getConf()); + /** + * [signal] + * + * IDF_Upload::create + * + * [sender] + * + * IDF_Form_Upload + * + * [description] + * + * This signal allows an application to perform a set of tasks + * just after the upload of a file and after the notification run. + * + * [parameters] + * + * array('upload' => $upload); + * + */ + $params = array('upload' => $upload); + Pluf_Signal::send('IDF_Upload::create', 'IDF_Form_Upload', + $params); } - // Create the upload - $upload = new IDF_Upload(); - $upload->project = $this->project; - $upload->submitter = $this->user; - $upload->summary = trim($this->cleaned_data['summary']); - $upload->changelog = trim($this->cleaned_data['changelog']); - $upload->file = $this->cleaned_data['file']; - $upload->filesize = filesize(Pluf::f('upload_path').'/'.$this->project->shortname.'/files/'.$this->cleaned_data['file']); - $upload->downloads = 0; - $upload->create(); - foreach ($tags as $tag) { - $upload->setAssoc($tag); - } - // Send the notification - $upload->notify($this->project->getConf()); - /** - * [signal] - * - * IDF_Upload::create - * - * [sender] - * - * IDF_Form_Upload - * - * [description] - * - * This signal allows an application to perform a set of tasks - * just after the upload of a file and after the notification run. - * - * [parameters] - * - * array('upload' => $upload); - * - */ - $params = array('upload' => $upload); - Pluf_Signal::send('IDF_Upload::create', 'IDF_Form_Upload', - $params); - return $upload; + + // finally unlink the uploaded archive + @unlink(Pluf::f('upload_path').'/'.$this->project->shortname.'/archives/'.$this->cleaned_data['archive']); } } diff --git a/src/IDF/Form/UploadArchiveHelper.php b/src/IDF/Form/UploadArchiveHelper.php new file mode 100644 index 0000000..907ca50 --- /dev/null +++ b/src/IDF/Form/UploadArchiveHelper.php @@ -0,0 +1,152 @@ +file = $file; + } + + /** + * Validates the archive; throws a invalid form exception in case the + * archive contains invalid data or cannot be read. + */ + public function validate() + { + if (!file_exists($this->file)) { + throw new Pluf_Form_Invalid(__('The archive does not exist.')); + } + + $za = new ZipArchive(); + $res = $za->open($this->file); + if ($res !== true) { + throw new Pluf_Form_Invalid( + sprintf(__('The archive could not be read (code %d).'), $res)); + } + + $manifest = $za->getFromName('manifest.xml'); + if ($manifest === false) { + throw new Pluf_Form_Invalid(__('The archive does not contain a manifest.xml.')); + } + + libxml_use_internal_errors(true); + $xml = @simplexml_load_string($manifest); + if ($xml === false) { + $error = libxml_get_last_error(); + throw new Pluf_Form_Invalid( + sprintf(__('The archive\'s manifest is invalid: %s'), $error->message)); + } + + foreach (@$xml->file as $idx => $file) + { + $entry = array( + 'name' => (string)@$file->name, + 'summary' => (string)@$file->summary, + 'description' => (string)@$file->description, + 'replaces' => (string)@$file->replaces, + 'labels' => array(), + 'stream' => null + ); + + if (empty($entry['name'])) { + throw new Pluf_Form_Invalid( + sprintf(__('The entry %d in the manifest is missing a file name.'), $idx)); + } + + if (empty($entry['summary'])) { + throw new Pluf_Form_Invalid( + sprintf(__('The entry %d in the manifest is missing a summary.'), $idx)); + } + + if ($entry['name'] === 'manifest.xml') { + throw new Pluf_Form_Invalid(__('The manifest must not reference itself.')); + } + + if ($za->locateName($entry['name']) === false) { + throw new Pluf_Form_Invalid( + sprintf(__('The entry %s in the manifest does not exist in the archive.'), $entry['name'])); + } + + if (in_array($entry['name'], $this->entries)) { + throw new Pluf_Form_Invalid( + sprintf(__('The entry %s in the manifest is referenced more than once.'), $entry['name'])); + } + + if ($file->labels) { + foreach (@$file->labels->label as $label) { + $entry['labels'][] = (string)$label; + } + } + + $this->entries[$entry['name']] = $entry; + } + + $za->close(); + } + + /** + * Returns all entry names + * + * @return array of string + */ + public function getEntryNames() + { + return array_keys($this->entries); + } + + /** + * Returns meta data for the given entry + * + * @param string $name + * @throws Exception + */ + public function getMetaData($name) + { + if (!array_key_exists($name, $this->entries)) { + throw new Exception('unknown file ' . $name); + } + return $this->entries[$name]; + } + + /** + * Extracts the file entry $name at $path + * + * @param string $name + * @param string $path + * @throws Exception + */ + public function extract($name, $path) + { + if (!array_key_exists($name, $this->entries)) { + throw new Exception('unknown file ' . $name); + } + $za = new ZipArchive(); + $za->open($this->file); + $za->extractTo($path, $name); + $za->close(); + } +}