summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorFrédéric Guillot <fred@kanboard.net>2014-05-22 20:58:21 -0400
committerFrédéric Guillot <fred@kanboard.net>2014-05-22 20:58:21 -0400
commit40917992e775bd21a280eb267241c452e04e5ade (patch)
treec1bf82ab83564bc9ca749f026f54342cbd35374f /app
parent2230dd4e6b148346c0ec596b9e3e12996a762ed8 (diff)
Add files upload
Diffstat (limited to 'app')
-rw-r--r--app/Controller/Base.php1
-rw-r--r--app/Controller/Task.php178
-rw-r--r--app/Core/Response.php5
-rw-r--r--app/Locales/es_ES/translations.php7
-rw-r--r--app/Locales/fr_FR/translations.php7
-rw-r--r--app/Locales/pl_PL/translations.php7
-rw-r--r--app/Locales/pt_BR/translations.php7
-rw-r--r--app/Model/Acl.php23
-rw-r--r--app/Model/File.php176
-rw-r--r--app/Model/Task.php1
-rw-r--r--app/Schema/Mysql.php17
-rw-r--r--app/Schema/Sqlite.php16
-rw-r--r--app/Templates/board_show.php6
-rw-r--r--app/Templates/task_close.php4
-rw-r--r--app/Templates/task_layout.php3
-rw-r--r--app/Templates/task_open.php24
-rw-r--r--app/Templates/task_open_file.php6
-rw-r--r--app/Templates/task_remove.php4
-rw-r--r--app/Templates/task_remove_file.php14
-rw-r--r--app/Templates/task_show.php17
-rw-r--r--app/Templates/task_sidebar.php4
-rw-r--r--app/Templates/task_upload.php10
22 files changed, 484 insertions, 53 deletions
diff --git a/app/Controller/Base.php b/app/Controller/Base.php
index bb9add4f..183b9395 100644
--- a/app/Controller/Base.php
+++ b/app/Controller/Base.php
@@ -216,6 +216,7 @@ abstract class Base
'task' => $task,
'columns_list' => $this->board->getColumnsList($task['project_id']),
'colors_list' => $this->task->getColors(),
+ 'files' => $this->file->getAll($task['id']),
'menu' => 'tasks',
'title' => $task['title'],
)));
diff --git a/app/Controller/Task.php b/app/Controller/Task.php
index 2291ad43..1b67b6a0 100644
--- a/app/Controller/Task.php
+++ b/app/Controller/Task.php
@@ -3,6 +3,7 @@
namespace Controller;
use Model\Project;
+use Model\File;
/**
* Task controller
@@ -12,6 +13,19 @@ use Model\Project;
*/
class Task extends Base
{
+ private function getTask()
+ {
+ $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
+
+ if (! $task) {
+ $this->notfound();
+ }
+
+ $this->checkProjectPermissions($task['project_id']);
+
+ return $task;
+ }
+
/**
* Webhook to create a task (useful for external software)
*
@@ -57,12 +71,7 @@ class Task extends Base
*/
public function show()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
-
- $this->showTask($task);
+ $this->showTask($this->getTask());
}
/**
@@ -247,10 +256,7 @@ class Task extends Base
*/
public function close()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if ($this->task->close($task['id'])) {
$this->session->flash(t('Task closed successfully.'));
@@ -268,10 +274,7 @@ class Task extends Base
*/
public function confirmClose()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
$this->response->html($this->taskLayout('task_close', array(
'task' => $task,
@@ -287,10 +290,7 @@ class Task extends Base
*/
public function open()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if ($this->task->open($task['id'])) {
$this->session->flash(t('Task opened successfully.'));
@@ -308,10 +308,7 @@ class Task extends Base
*/
public function confirmOpen()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
$this->response->html($this->taskLayout('task_open', array(
'task' => $task,
@@ -327,10 +324,7 @@ class Task extends Base
*/
public function remove()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if ($this->task->remove($task['id'])) {
$this->session->flash(t('Task removed successfully.'));
@@ -348,10 +342,7 @@ class Task extends Base
*/
public function confirmRemove()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'), true);
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
$this->response->html($this->taskLayout('task_remove', array(
'task' => $task,
@@ -367,10 +358,7 @@ class Task extends Base
*/
public function duplicate()
{
- $task = $this->task->getById($this->request->getIntegerParam('task_id'));
-
- if (! $task) $this->notfound();
- $this->checkProjectPermissions($task['project_id']);
+ $task = $this->getTask();
if (! empty($task['date_due'])) {
$task['date_due'] = date(t('m/d/Y'), $task['date_due']);
@@ -394,4 +382,126 @@ class Task extends Base
'title' => t('New task')
)));
}
+
+ /**
+ * File upload form
+ *
+ * @access public
+ */
+ public function file()
+ {
+ $task = $this->getTask();
+
+ $this->response->html($this->taskLayout('task_upload', array(
+ 'task' => $task,
+ 'menu' => 'tasks',
+ 'title' => t('Attach a document')
+ )));
+ }
+
+ /**
+ * File upload (save files)
+ *
+ * @access public
+ */
+ public function upload()
+ {
+ $task = $this->getTask();
+ $this->file->upload($task['project_id'], $task['id'], 'files');
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id'].'#attachments');
+ }
+
+ /**
+ * File download
+ *
+ * @access public
+ */
+ public function download()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+ $filename = File::BASE_PATH.$file['path'];
+
+ if ($file['task_id'] == $task['id'] && file_exists($filename)) {
+ $this->response->forceDownload($file['name']);
+ $this->response->binary(file_get_contents($filename));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ /**
+ * Open a file (show the content in a popover)
+ *
+ * @access public
+ */
+ public function openFile()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ if ($file['task_id'] == $task['id']) {
+ $this->response->html($this->template->load('task_open_file', array(
+ 'file' => $file
+ )));
+ }
+ }
+
+ /**
+ * Return the file content (work only for images)
+ *
+ * @access public
+ */
+ public function image()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+ $filename = File::BASE_PATH.$file['path'];
+
+ if ($file['task_id'] == $task['id'] && file_exists($filename)) {
+ $metadata = getimagesize($filename);
+
+ if (isset($metadata['mime'])) {
+ $this->response->contentType($metadata['mime']);
+ readfile($filename);
+ }
+ }
+ }
+
+ /**
+ * Remove a file
+ *
+ * @access public
+ */
+ public function removeFile()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ if ($file['task_id'] == $task['id'] && $this->file->remove($file['id'])) {
+ $this->session->flash(t('File removed successfully.'));
+ } else {
+ $this->session->flashError(t('Unable to remove this file.'));
+ }
+
+ $this->response->redirect('?controller=task&action=show&task_id='.$task['id']);
+ }
+
+ /**
+ * Confirmation dialog before removing a file
+ *
+ * @access public
+ */
+ public function confirmRemoveFile()
+ {
+ $task = $this->getTask();
+ $file = $this->file->getById($this->request->getIntegerParam('file_id'));
+
+ $this->response->html($this->taskLayout('task_remove_file', array(
+ 'task' => $task,
+ 'file' => $file,
+ 'menu' => 'tasks',
+ 'title' => t('Remove a file')
+ )));
+ }
}
diff --git a/app/Core/Response.php b/app/Core/Response.php
index a5f0e4dc..ee98c9ed 100644
--- a/app/Core/Response.php
+++ b/app/Core/Response.php
@@ -4,6 +4,11 @@ namespace Core;
class Response
{
+ public function contentType($mimetype)
+ {
+ header('Content-Type: '.$mimetype);
+ }
+
public function forceDownload($filename)
{
header('Content-Disposition: attachment; filename="'.$filename.'"');
diff --git a/app/Locales/es_ES/translations.php b/app/Locales/es_ES/translations.php
index ce797972..7374f6b6 100644
--- a/app/Locales/es_ES/translations.php
+++ b/app/Locales/es_ES/translations.php
@@ -331,4 +331,11 @@ return array(
// 'All categories' => '',
// 'No category' => '',
// 'The name is required' => '',
+ // 'Remove a file' => '',
+ // 'Unable to remove this file.' => '',
+ // 'File removed successfully.' => '',
+ // 'Attach a document' => '',
+ // 'Do you really want to remove this file: "%s"?' => '',
+ // 'open' => '',
+ // 'Attachments' => '',
);
diff --git a/app/Locales/fr_FR/translations.php b/app/Locales/fr_FR/translations.php
index c93a83ae..26fee468 100644
--- a/app/Locales/fr_FR/translations.php
+++ b/app/Locales/fr_FR/translations.php
@@ -331,4 +331,11 @@ return array(
'All categories' => 'Toutes les catégories',
'No category' => 'Aucune catégorie',
'The name is required' => 'Le nom est requis',
+ 'Remove a file' => 'Supprimer un fichier',
+ 'Unable to remove this file.' => 'Impossible de supprimer ce fichier.',
+ 'File removed successfully.' => 'Fichier supprimé avec succès.',
+ 'Attach a document' => 'Joindre un document',
+ 'Do you really want to remove this file: "%s"?' => 'Voulez-vous vraiment supprimer ce fichier « %s » ?',
+ 'open' => 'ouvrir',
+ 'Attachments' => 'Pièces-jointes',
);
diff --git a/app/Locales/pl_PL/translations.php b/app/Locales/pl_PL/translations.php
index 81ecaf01..43adb330 100644
--- a/app/Locales/pl_PL/translations.php
+++ b/app/Locales/pl_PL/translations.php
@@ -336,4 +336,11 @@ return array(
// 'All categories' => '',
// 'No category' => '',
// 'The name is required' => '',
+ // 'Remove a file' => '',
+ // 'Unable to remove this file.' => '',
+ // 'File removed successfully.' => '',
+ // 'Attach a document' => '',
+ // 'Do you really want to remove this file: "%s"?' => '',
+ // 'open' => '',
+ // 'Attachments' => '',
);
diff --git a/app/Locales/pt_BR/translations.php b/app/Locales/pt_BR/translations.php
index 7c9a6c17..0b4765d1 100644
--- a/app/Locales/pt_BR/translations.php
+++ b/app/Locales/pt_BR/translations.php
@@ -332,4 +332,11 @@ return array(
// 'All categories' => '',
// 'No category' => '',
// 'The name is required' => '',
+ // 'Remove a file' => '',
+ // 'Unable to remove this file.' => '',
+ // 'File removed successfully.' => '',
+ // 'Attach a document' => '',
+ // 'Do you really want to remove this file: "%s"?' => '',
+ // 'open' => '',
+ // 'Attachments' => '',
);
diff --git a/app/Model/Acl.php b/app/Model/Acl.php
index ad2118f4..be32196a 100644
--- a/app/Model/Acl.php
+++ b/app/Model/Acl.php
@@ -32,10 +32,31 @@ class Acl extends Base
'app' => array('index'),
'board' => array('index', 'show', 'assign', 'assigntask', 'save', 'check'),
'project' => array('tasks', 'index', 'forbidden', 'search'),
- 'task' => array('show', 'create', 'save', 'edit', 'update', 'close', 'confirmclose', 'open', 'confirmopen', 'description', 'duplicate', 'remove', 'confirmremove'),
'comment' => array('save', 'confirm', 'remove', 'update', 'edit'),
'user' => array('index', 'edit', 'update', 'forbidden', 'logout', 'index', 'unlinkgoogle'),
'config' => array('index', 'removeremembermetoken'),
+ 'task' => array(
+ 'show',
+ 'create',
+ 'save',
+ 'edit',
+ 'update',
+ 'close',
+ 'confirmclose',
+ 'open',
+ 'confirmopen',
+ 'description',
+ 'duplicate',
+ 'remove',
+ 'confirmremove',
+ 'file',
+ 'upload',
+ 'download',
+ 'openfile',
+ 'image',
+ 'removefile',
+ 'confirmremovefile',
+ ),
);
/**
diff --git a/app/Model/File.php b/app/Model/File.php
new file mode 100644
index 00000000..1e2e1432
--- /dev/null
+++ b/app/Model/File.php
@@ -0,0 +1,176 @@
+<?php
+
+namespace Model;
+
+use SimpleValidator\Validator;
+use SimpleValidator\Validators;
+
+/**
+ * File model
+ *
+ * @package model
+ * @author Frederic Guillot
+ */
+class File extends Base
+{
+ /**
+ * SQL table name
+ *
+ * @var string
+ */
+ const TABLE = 'task_has_files';
+
+ /**
+ * Directory where are stored files
+ *
+ * @var string
+ */
+ const BASE_PATH = 'data/files/';
+
+ /**
+ * Get a file by the id
+ *
+ * @access public
+ * @param integer $file_id File id
+ * @return array
+ */
+ public function getById($file_id)
+ {
+ return $this->db->table(self::TABLE)->eq('id', $file_id)->findOne();
+ }
+
+ /**
+ * Remove a file
+ *
+ * @access public
+ * @param integer $file_id File id
+ * @return bool
+ */
+ public function remove($file_id)
+ {
+ $file = $this->getbyId($file_id);
+
+ if (! empty($file) && @unlink(self::BASE_PATH.$file['path'])) {
+ return $this->db->table(self::TABLE)->eq('id', $file_id)->remove();
+ }
+
+ return false;
+ }
+
+ /**
+ * Create a file entry in the database
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @param string $name Filename
+ * @param string $path Path on the disk
+ * @param bool $is_image Image or not
+ * @return bool
+ */
+ public function create($task_id, $name, $path, $is_image)
+ {
+ return $this->db->table(self::TABLE)->save(array(
+ 'task_id' => $task_id,
+ 'name' => $name,
+ 'path' => $path,
+ 'is_image' => $is_image ? '1' : '0',
+ ));
+ }
+
+ /**
+ * Get all files for a given task
+ *
+ * @access public
+ * @param integer $task_id Task id
+ * @return array
+ */
+ public function getAll($task_id)
+ {
+ return $listing = $this->db->table(self::TABLE)
+ ->eq('task_id', $task_id)
+ ->asc('name')
+ ->findAll();
+ }
+
+ /**
+ * Check if a filename is an image
+ *
+ * @access public
+ * @param string $filename Filename
+ * @return bool
+ */
+ public function isImage($filename)
+ {
+ return getimagesize($filename) !== false;
+ }
+
+ /**
+ * Generate the path for a new filename
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $task_id Task id
+ * @param string $filename Filename
+ * @return bool
+ */
+ public function generatePath($project_id, $task_id, $filename)
+ {
+ return $project_id.DIRECTORY_SEPARATOR.$task_id.DIRECTORY_SEPARATOR.hash('sha1', $filename.time());
+ }
+
+ /**
+ * Check if the base directory is created correctly
+ *
+ * @access public
+ */
+ public function setup()
+ {
+ if (! is_dir(self::BASE_PATH)) {
+ if (! mkdir(self::BASE_PATH, 0755, true)) {
+ die('Unable to create the upload directory: "'.self::BASE_PATH.'"');
+ }
+ }
+
+ if (! is_writable(self::BASE_PATH)) {
+ die('The directory "'.self::BASE_PATH.'" must be writeable by your webserver user');
+ }
+ }
+
+ /**
+ * Handle file upload
+ *
+ * @access public
+ * @param integer $project_id Project id
+ * @param integer $task_id Task id
+ * @param string $form_name File form name
+ */
+ public function upload($project_id, $task_id, $form_name)
+ {
+ $this->setup();
+
+ if (! empty($_FILES[$form_name])) {
+
+ foreach ($_FILES[$form_name]['error'] as $key => $error) {
+
+ if ($error == UPLOAD_ERR_OK && $_FILES[$form_name]['size'][$key] > 0) {
+
+ $original_filename = basename($_FILES[$form_name]['name'][$key]);
+ $uploaded_filename = $_FILES[$form_name]['tmp_name'][$key];
+ $destination_filename = $this->generatePath($project_id, $task_id, $original_filename);
+
+ @mkdir(self::BASE_PATH.dirname($destination_filename), 0755, true);
+
+ if (@move_uploaded_file($uploaded_filename, self::BASE_PATH.$destination_filename)) {
+
+ $this->create(
+ $task_id,
+ $original_filename,
+ $destination_filename,
+ $this->isImage(self::BASE_PATH.$destination_filename)
+ );
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/Model/Task.php b/app/Model/Task.php
index bd67d272..f31594c9 100644
--- a/app/Model/Task.php
+++ b/app/Model/Task.php
@@ -139,6 +139,7 @@ class Task extends Base
->table(self::TABLE)
->columns(
'(SELECT count(*) FROM comments WHERE task_id=tasks.id) AS nb_comments',
+ '(SELECT count(*) FROM task_has_files WHERE task_id=tasks.id) AS nb_files',
'tasks.id',
'tasks.title',
'tasks.description',
diff --git a/app/Schema/Mysql.php b/app/Schema/Mysql.php
index 6764ad5d..d3b111f9 100644
--- a/app/Schema/Mysql.php
+++ b/app/Schema/Mysql.php
@@ -2,7 +2,22 @@
namespace Schema;
-const VERSION = 16;
+const VERSION = 17;
+
+function version_17($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE task_has_files (
+ id INT NOT NULL AUTO_INCREMENT,
+ name VARCHAR(50),
+ path VARCHAR(255),
+ is_image TINYINT(1) DEFAULT 0,
+ task_id INT,
+ PRIMARY KEY (id),
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB CHARSET=utf8"
+ );
+}
function version_16($pdo)
{
diff --git a/app/Schema/Sqlite.php b/app/Schema/Sqlite.php
index 0bb4de8d..94ef0316 100644
--- a/app/Schema/Sqlite.php
+++ b/app/Schema/Sqlite.php
@@ -2,7 +2,21 @@
namespace Schema;
-const VERSION = 16;
+const VERSION = 17;
+
+function version_17($pdo)
+{
+ $pdo->exec("
+ CREATE TABLE task_has_files (
+ id INTEGER PRIMARY KEY,
+ name TEXT COLLATE NOCASE,
+ path TEXT,
+ is_image INTEGER DEFAULT 0,
+ task_id INTEGER,
+ FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
+ )"
+ );
+}
function version_16($pdo)
{
diff --git a/app/Templates/board_show.php b/app/Templates/board_show.php
index 719e3bdd..78f9dd50 100644
--- a/app/Templates/board_show.php
+++ b/app/Templates/board_show.php
@@ -59,7 +59,7 @@
</div>
<?php endif ?>
- <?php if (! empty($task['date_due']) || ! empty($task['nb_comments']) || ! empty($task['description'])): ?>
+ <?php if (! empty($task['date_due']) || ! empty($task['nb_files']) || ! empty($task['nb_comments']) || ! empty($task['description'])): ?>
<div class="task-footer">
<?php if (! empty($task['date_due'])): ?>
@@ -69,6 +69,10 @@
<?php endif ?>
<div class="task-icons">
+ <?php if (! empty($task['nb_files'])): ?>
+ <?= $task['nb_files'] ?> <i class="fa fa-paperclip"></i>
+ <?php endif ?>
+
<?php if (! empty($task['nb_comments'])): ?>
<?= $task['nb_comments'] ?> <i class="fa fa-comment-o" title="<?= p($task['nb_comments'], t('%d comment', $task['nb_comments']), t('%d comments', $task['nb_comments'])) ?>"></i>
<?php endif ?>
diff --git a/app/Templates/task_close.php b/app/Templates/task_close.php
index 3531b37d..6843c2f6 100644
--- a/app/Templates/task_close.php
+++ b/app/Templates/task_close.php
@@ -1,3 +1,7 @@
+<div class="page-header">
+ <h2><?= t('Close a task') ?></h2>
+</div>
+
<div class="confirm">
<p class="alert alert-info">
<?= t('Do you really want to close this task: "%s"?', Helper\escape($task['title'])) ?>
diff --git a/app/Templates/task_layout.php b/app/Templates/task_layout.php
index 9a6bbd00..ce5f36c5 100644
--- a/app/Templates/task_layout.php
+++ b/app/Templates/task_layout.php
@@ -13,4 +13,5 @@
<?= $task_content_for_layout ?>
</div>
</section>
-</section> \ No newline at end of file
+</section>
+<script type="text/javascript" src="assets/js/task.js"></script> \ No newline at end of file
diff --git a/app/Templates/task_open.php b/app/Templates/task_open.php
index 54cc11f0..59ea0b54 100644
--- a/app/Templates/task_open.php
+++ b/app/Templates/task_open.php
@@ -1,16 +1,14 @@
-<section id="main">
- <div class="page-header">
- <h2><?= t('Open a task') ?></h2>
- </div>
+<div class="page-header">
+ <h2><?= t('Open a task') ?></h2>
+</div>
- <div class="confirm">
- <p class="alert alert-info">
- <?= t('Do you really want to open this task: "%s"?', Helper\escape($task['title'])) ?>
- </p>
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to open this task: "%s"?', Helper\escape($task['title'])) ?>
+ </p>
- <div class="form-actions">
- <a href="?controller=task&amp;action=open&amp;task_id=<?= $task['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
- <?= t('or') ?> <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('cancel') ?></a>
- </div>
+ <div class="form-actions">
+ <a href="?controller=task&amp;action=open&amp;task_id=<?= $task['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('cancel') ?></a>
</div>
-</section> \ No newline at end of file
+</div> \ No newline at end of file
diff --git a/app/Templates/task_open_file.php b/app/Templates/task_open_file.php
new file mode 100644
index 00000000..e0817f01
--- /dev/null
+++ b/app/Templates/task_open_file.php
@@ -0,0 +1,6 @@
+<div class="page-header">
+ <h2><?= Helper\escape($file['name']) ?></h2>
+ <div class="task-file-viewer">
+ <img src="?controller=task&amp;action=image&amp;file_id=<?= $file['id'] ?>&amp;task_id=<?= $file['task_id'] ?>" alt="<?= Helper\escape($file['name']) ?>"/>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Templates/task_remove.php b/app/Templates/task_remove.php
index 1aa9503b..60e4e8e7 100644
--- a/app/Templates/task_remove.php
+++ b/app/Templates/task_remove.php
@@ -1,3 +1,7 @@
+<div class="page-header">
+ <h2><?= t('Remove a task') ?></h2>
+</div>
+
<div class="confirm">
<p class="alert alert-info">
<?= t('Do you really want to remove this task: "%s"?', Helper\escape($task['title'])) ?>
diff --git a/app/Templates/task_remove_file.php b/app/Templates/task_remove_file.php
new file mode 100644
index 00000000..9687b602
--- /dev/null
+++ b/app/Templates/task_remove_file.php
@@ -0,0 +1,14 @@
+<div class="page-header">
+ <h2><?= t('Remove a file') ?></h2>
+</div>
+
+<div class="confirm">
+ <p class="alert alert-info">
+ <?= t('Do you really want to remove this file: "%s"?', Helper\escape($file['name'])) ?>
+ </p>
+
+ <div class="form-actions">
+ <a href="?controller=task&amp;action=removeFile&amp;task_id=<?= $task['id'] ?>&amp;file_id=<?= $file['id'] ?>" class="btn btn-red"><?= t('Yes') ?></a>
+ <?= t('or') ?> <a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('cancel') ?></a>
+ </div>
+</div> \ No newline at end of file
diff --git a/app/Templates/task_show.php b/app/Templates/task_show.php
index a5b79359..56f6cba5 100644
--- a/app/Templates/task_show.php
+++ b/app/Templates/task_show.php
@@ -64,6 +64,23 @@
</form>
<?php endif ?>
+<?php if (! empty($files)): ?>
+ <h2 id="attachments"><?= t('Attachments') ?></h2>
+ <ul class="task-show-files">
+ <?php foreach ($files as $file): ?>
+ <li>
+ <a href="?controller=task&amp;action=download&amp;file_id=<?= $file['id'] ?>&amp;task_id=<?= $task['id'] ?>"><?= Helper\escape($file['name']) ?></a>
+ <span class="task-show-file-actions">
+ <?php if ($file['is_image']): ?>
+ <a href="?controller=task&amp;action=openFile&amp;file_id=<?= $file['id'] ?>&amp;task_id=<?= $task['id'] ?>" class="popover"><?= t('open') ?></a>,
+ <?php endif ?>
+ <a href="?controller=task&amp;action=confirmRemoveFile&amp;file_id=<?= $file['id'] ?>&amp;task_id=<?= $task['id'] ?>"><?= t('remove') ?></a>
+ </span>
+ </li>
+ <?php endforeach ?>
+ </ul>
+<?php endif ?>
+
<h2><?= t('Comments') ?></h2>
<?php if ($comments): ?>
<ul id="comments">
diff --git a/app/Templates/task_sidebar.php b/app/Templates/task_sidebar.php
index 314d5214..9dbc1a8c 100644
--- a/app/Templates/task_sidebar.php
+++ b/app/Templates/task_sidebar.php
@@ -2,8 +2,10 @@
<h2><?= t('Actions') ?></h2>
<div class="task-show-actions">
<ul>
- <li><a href="?controller=task&amp;action=duplicate&amp;project_id=<?= $task['project_id'] ?>&amp;task_id=<?= $task['id'] ?>"><?= t('Duplicate') ?></a></li>
+ <li><a href="?controller=task&amp;action=show&amp;task_id=<?= $task['id'] ?>"><?= t('Description') ?></a></li>
<li><a href="?controller=task&amp;action=edit&amp;task_id=<?= $task['id'] ?>"><?= t('Edit') ?></a></li>
+ <li><a href="?controller=task&amp;action=file&amp;task_id=<?= $task['id'] ?>"><?= t('Attach a document') ?></a></li>
+ <li><a href="?controller=task&amp;action=duplicate&amp;project_id=<?= $task['project_id'] ?>&amp;task_id=<?= $task['id'] ?>"><?= t('Duplicate') ?></a></li>
<li>
<?php if ($task['is_active'] == 1): ?>
<a href="?controller=task&amp;action=confirmClose&amp;task_id=<?= $task['id'] ?>"><?= t('Close this task') ?></a>
diff --git a/app/Templates/task_upload.php b/app/Templates/task_upload.php
new file mode 100644
index 00000000..7100ab31
--- /dev/null
+++ b/app/Templates/task_upload.php
@@ -0,0 +1,10 @@
+<div class="page-header">
+ <h2><?= t('Attach a document') ?></h2>
+</div>
+
+<form action="?controller=task&amp;action=upload&amp;task_id=<?= $task['id'] ?>" method="post" enctype="multipart/form-data">
+ <input type="file" name="files[]" multiple />
+ <div class="form-actions">
+ <input type="submit" value="<?= t('Save') ?>" class="btn btn-blue"/>
+ </div>
+</form> \ No newline at end of file