diff --git a/src/IDF/Form/UploadArchive.php b/src/IDF/Form/UploadArchive.php new file mode 100644 index 0000000..671a0d3 --- /dev/null +++ b/src/IDF/Form/UploadArchive.php @@ -0,0 +1,171 @@ +user = $extra['user']; + $this->project = $extra['project']; + + $this->fields['archive'] = new Pluf_Form_Field_File( + array('required' => true, + 'label' => __('Archive file'), + 'initial' => '', + 'max_size' => Pluf::f('max_upload_archive_size', 20971520), + )); + } + + + 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.')); + } + return $this->cleaned_data['file']; + } + + /** + * Validate the interconnection in the form. + */ + public function clean() + { + $conf = new IDF_Conf(); + $conf->setProject($this->project); + $onemax = array(); + foreach (explode(',', $conf->getVal('labels_download_one_max', IDF_Form_UploadConf::init_one_max)) as $class) { + if (trim($class) != '') { + $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]; + } + 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.')); + } + } + return $this->cleaned_data; + } + + /** + * If we have uploaded a file, but the form failed remove it. + * + */ + 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']); + } + } + + /** + * Save the model in the database. + * + * @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]); + } + $tags[] = IDF_Tag::add($name, $this->project, $class); + } + } + // 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; + } +} + diff --git a/src/IDF/Views.php b/src/IDF/Views.php index 70bbdc3..65f016f 100644 --- a/src/IDF/Views.php +++ b/src/IDF/Views.php @@ -292,6 +292,22 @@ class IDF_Views } + /** + * Download archive FAQ. + */ + public function faqArchiveFormat($request, $match) + { + $title = __('InDefero Upload Archive Format'); + $projects = self::getProjects($request->user); + return Pluf_Shortcuts_RenderToResponse('idf/faq-archive-format.html', + array( + 'page_title' => $title, + 'projects' => $projects, + ), + $request); + + } + /** * API FAQ. */ diff --git a/src/IDF/Views/Download.php b/src/IDF/Views/Download.php index 5fb49e8..1444629 100644 --- a/src/IDF/Views/Download.php +++ b/src/IDF/Views/Download.php @@ -224,11 +224,11 @@ class IDF_Views_Download } /** - * Submit a new file for download. + * Create a new file for download. */ - public $submit_precond = array('IDF_Precondition::accessDownloads', + public $create_precond = array('IDF_Precondition::accessDownloads', 'IDF_Precondition::projectMemberOrOwner'); - public function submit($request, $match) + public function create($request, $match) { $prj = $request->project; $title = __('New Download'); @@ -250,7 +250,7 @@ class IDF_Views_Download array('project' => $prj, 'user' => $request->user)); } - return Pluf_Shortcuts_RenderToResponse('idf/downloads/submit.html', + return Pluf_Shortcuts_RenderToResponse('idf/downloads/create.html', array( 'auto_labels' => self::autoCompleteArrays($prj), 'page_title' => $title, @@ -259,6 +259,39 @@ class IDF_Views_Download $request); } + /** + * Create new downloads from an uploaded archive. + */ + public $createFromArchive_precond = array('IDF_Precondition::accessDownloads', + 'IDF_Precondition::projectMemberOrOwner'); + public function createFromArchive($request, $match) + { + $prj = $request->project; + $title = __('New Downloads from Archive'); + if ($request->method == 'POST') { + $form = new IDF_Form_UploadArchive(array_merge($request->POST, $request->FILES), + array('project' => $prj, + 'user' => $request->user)); + if ($form->isValid()) { + $upload = $form->save(); + $request->user->setMessage(__('The archive has been uploaded and processed.')); + $url = Pluf_HTTP_URL_urlForView('IDF_Views_Download::index', + array($prj->shortname)); + return new Pluf_HTTP_Response_Redirect($url); + } + } else { + $form = new IDF_Form_UploadArchive(null, + array('project' => $prj, + 'user' => $request->user)); + } + return Pluf_Shortcuts_RenderToResponse('idf/downloads/createFromArchive.html', + array( + 'page_title' => $title, + 'form' => $form, + ), + $request); + } + /** * Create the autocomplete arrays for the little AJAX stuff. */ diff --git a/src/IDF/conf/idf.php-dist b/src/IDF/conf/idf.php-dist index 62fe1ce..d3293fe 100644 --- a/src/IDF/conf/idf.php-dist +++ b/src/IDF/conf/idf.php-dist @@ -495,6 +495,11 @@ $cfg['idf_strong_key_check'] = false; # always have precedence. # $cfg['max_upload_size'] = 2097152; // Size in bytes +# If a download archive is uploaded, the size of the archive is limited to 20MB. +# The php.ini upload_max_filesize and post_max_size configuration setting will +# always have precedence. +# $cfg['max_upload_archive_size'] = 20971520; // Size in bytes + # Older versions of Indefero submitted a POST request to a configured # post-commit web hook when new revisions arrived, whereas a PUT request # would have been more appropriate. Also, the payload's HMAC digest was diff --git a/src/IDF/conf/urls.php b/src/IDF/conf/urls.php index 3e2288b..8376683 100644 --- a/src/IDF/conf/urls.php +++ b/src/IDF/conf/urls.php @@ -304,6 +304,11 @@ $ctl[] = array('regex' => '#^/p/([\-\w]+)/page/(.*)/$#', // ---------- Downloads ------------------------------------ +$ctl[] = array('regex' => '#^/help/archive-format/$#', + 'base' => $base, + 'model' => 'IDF_Views', + 'method' => 'faqArchiveFormat'); + $ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/$#', 'base' => $base, 'model' => 'IDF_Views_Download', @@ -332,7 +337,12 @@ $ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/(\d+)/get/$#', $ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/create/$#', 'base' => $base, 'model' => 'IDF_Views_Download', - 'method' => 'submit'); + 'method' => 'create'); + +$ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/create/archive/$#', + 'base' => $base, + 'model' => 'IDF_Views_Download', + 'method' => 'createFromArchive'); $ctl[] = array('regex' => '#^/p/([\-\w]+)/downloads/(\d+)/delete/$#', 'base' => $base, diff --git a/src/IDF/templates/idf/downloads/base.html b/src/IDF/templates/idf/downloads/base.html index c7e046a..248bf73 100644 --- a/src/IDF/templates/idf/downloads/base.html +++ b/src/IDF/templates/idf/downloads/base.html @@ -2,6 +2,10 @@ {block tabdownloads} class="active"{/block} {block subtabs}
-{trans 'Downloads'} {if $isOwner or $isMember}| {trans 'New Download'} {/if} +{trans 'Downloads'} +{if $isOwner or $isMember} +| {trans 'New Download'} +| {trans 'Upload Archive'} +{/if}
{/block} diff --git a/src/IDF/templates/idf/downloads/submit.html b/src/IDF/templates/idf/downloads/create.html similarity index 97% rename from src/IDF/templates/idf/downloads/submit.html rename to src/IDF/templates/idf/downloads/create.html index ed7b44b..c032004 100644 --- a/src/IDF/templates/idf/downloads/submit.html +++ b/src/IDF/templates/idf/downloads/create.html @@ -1,5 +1,5 @@ {extends "idf/downloads/base.html"} -{block docclass}yui-t3{assign $inSubmit=true}{/block} +{block docclass}yui-t3{assign $inCreate=true}{/block} {block body} {if $form.errors}
diff --git a/src/IDF/templates/idf/downloads/createFromArchive.html b/src/IDF/templates/idf/downloads/createFromArchive.html new file mode 100644 index 0000000..50db50f --- /dev/null +++ b/src/IDF/templates/idf/downloads/createFromArchive.html @@ -0,0 +1,38 @@ +{extends "idf/downloads/base.html"} +{block docclass}yui-t3{assign $inCreateFromArchive=true}{/block} +{block body} +{if $form.errors} +
+

{trans 'The form contains some errors. Please correct them to submit the archive.'}

+{if $form.get_top_errors} +{$form.render_top_errors|unsafe} +{/if} +
+{/if} + +
+ + + + + + + + + +
{$form.f.archive.labelTag}:{if $form.f.archive.errors}{$form.f.archive.fieldErrors}{/if} +{$form.f.archive|unsafe} +
  | {trans 'Cancel'} +
+
+{/block} +{block context} +
+

{trans 'Instructions'}

+ +

{blocktrans}The archive must include a manifest.xml file with meta information about the +files to process inside the archive. All processed files must be unique or replace existing files explicitely.{/blocktrans}

+{aurl 'url', 'IDF_Views::faqArchiveFormat'} +

{blocktrans}You can learn more about the archive format here.{/blocktrans}

+
+{/block} diff --git a/src/IDF/templates/idf/downloads/index.html b/src/IDF/templates/idf/downloads/index.html index 9a25555..97e76b3 100644 --- a/src/IDF/templates/idf/downloads/index.html +++ b/src/IDF/templates/idf/downloads/index.html @@ -3,7 +3,7 @@ {block body} {$downloads.render} {if $isOwner or $isMember} -{aurl 'url', 'IDF_Views_Download::submit', array($project.shortname)} +{aurl 'url', 'IDF_Views_Download::create', array($project.shortname)}

+ {trans 'New Download'}

{/if} {/block} diff --git a/src/IDF/templates/idf/faq-archive-format.html b/src/IDF/templates/idf/faq-archive-format.html new file mode 100644 index 0000000..f2274fe --- /dev/null +++ b/src/IDF/templates/idf/faq-archive-format.html @@ -0,0 +1,100 @@ +{extends "idf/base-simple.html"} +{block docclass}yui-t3{/block} +{block body} +

At the moment, this documentation is only available in English.

+ + + +

Motivation

+ +

+Adding multiple, individual downloads to a project for a release can be a tedious task if +one has to select each file manually, and then has to fill in the summary and correct labels +for each of these downloads individually. +

+ +

+InDefero therefor supports the upload of "archives" that contain multiple downloadable +files. These archives are standard PKZIP files with only one special property - they +contain an additional manifest file which describes the files that should be published. +

+ +

+Once such an archive has been uploaded and validated by InDefero, its files are extracted +and individual downloads are created for each of them. If the archive contains files +that should deprecate existing downloads, then InDefero takes care of this as well - +automatically. +

+ +

+An archive file and its manifest file can easily be compiled, either by hand with the help +of a text editor, or through an automated build system with the help of your build tool of +choice, such as Apache Ant. +

+ +

The manifest format

+ +

+The manifest is an XML file that follows a simple syntax. As it is always easier to look +at an example, here you have one: +

+ +
+<?xml version="1.0" encoding="UTF-8" ?>
+<manifest>
+  <file>
+    <name>foo-1.2.tar.gz</name>
+    <summary>Tarball</summary>
+    <replaces>foo-1.1.tar.gz</replaces>
+    <tags>
+       <tag>Type:Archive</tag>
+    </tags>
+  </file>
+  <file>
+    <name>foo-1.2-installer.exe</name>
+    <summary>Windows MSI Installer</summary>
+    <description>This installer needs Windows XP SP2 or later.</description>
+    <tags>
+       <tag>Type:Installer</tag>
+       <tag>OpSys:Windows</tag>
+    </tags>
+  </file>
+</manifest>
+
+ +

+This is the DTD for the format: +

+ +
+<!DOCTYPE manifest [
+<!ELEMENT manifest (file+)>
+<!ELEMENT file (name,summary,replaces?,description?,tags?)>
+<!ELEMENT name (#PCDATA)>
+<!ELEMENT summary (#PCDATA)>
+<!ELEMENT replaces (#PCDATA)>
+<!ELEMENT description (#PCDATA)>
+<!ELEMENT tags (tag+)>
+<!ELEMENT tag (#PCDATA)>
+]>
+
+ +

+The format is more or less self-explaining, all fields map to properties of a single download. +One special element has been introduced though, replaces. If this optional element +is given, InDefero looks for a file with that name in the project and deprecates it by attaching +the label Other:Deprecated to it. If no such file is found, the element is simply +ignored. +

+{/block} + +{block context} +

{trans 'Here we are, just to help you.'}

+

{trans 'Projects'}

+ +{/block} diff --git a/src/IDF/templates/idf/faq.html b/src/IDF/templates/idf/faq.html index db821da..5a3dc5d 100644 --- a/src/IDF/templates/idf/faq.html +++ b/src/IDF/templates/idf/faq.html @@ -5,6 +5,7 @@
  • {trans 'What are the keyboard shortcuts?'}
  • {trans 'How to mark an issue as duplicate?'}
  • {trans 'How can I display my head next to my comments?'}
  • +
  • {trans 'What is this "Upload Archive" functionality about?'}
  • {trans 'What is the API and how is it used?'}
  • @@ -48,11 +49,27 @@

    {blocktrans}You need to create an account on Gravatar, this takes about 5 minutes and is free.{/blocktrans}

    + +

    {trans 'What is this "Upload Archive" functionality about?'}

    + +{blocktrans}

    If you have to publish many files at once for a new release, it is a very tedious task +to upload them one after another and enter meta information like a summary, a description or additional +labels for each of them.

    +

    InDefero therefor supports a special archive format that is basically a standard zip file which comes with +some meta information. These meta information are kept in a special manifest file, which is distinctly kept from +the rest of the files in the archive that should be published.

    +

    Once this archive has been uploaded, InDefero reads in the meta information, unpacks the other files from +the archive and creates new individual downloads for each of them.

    {/blocktrans} + +{aurl 'url', 'IDF_Views::faqArchiveFormat'} +

    {blocktrans}Learn more about the archive format.{/blocktrans}

    +

    {trans 'What is the API and how is it used?'}

    -

    {blocktrans}The API (Application Programming Interface) is used to interact with InDefero with another program. For example, this can be used to create a desktop program to submit new tickets easily.{/blocktrans}

    {aurl 'url', 'IDF_Views::faqApi'} +

    {blocktrans}The API (Application Programming Interface) is used to interact with InDefero with another program. For example, this can be used to create a desktop program to submit new tickets easily.{/blocktrans}

    +{aurl 'url', 'IDF_Views::faqApi'}

    {blocktrans}Learn more about the API.{/blocktrans}

    - + {/block} {block context}

    {trans 'Here we are, just to help you.'}