From a3656604c00ff26deb908da84a7030c2a9b4d434 Mon Sep 17 00:00:00 2001
From: Erik Thuning <boooink@gmail.com>
Date: Tue, 19 Nov 2019 15:06:22 +0100
Subject: [PATCH] Implemented attachments for products

---
 config.php.example      |   7 +++
 html/fragments.html     |  30 +++++++----
 include/Ajax.php        |  33 ++++++++++++
 include/Attachment.php  | 109 ++++++++++++++++++++++++++++++++++++++++
 include/Download.php    |  25 +++++++++
 include/Product.php     |  16 ++++--
 include/ProductPage.php |   9 ++--
 include/Responder.php   |  30 +++++++----
 include/functions.php   |   2 +
 script.js               | 100 +++++++++++++++++++++++++++---------
 style.css               |   4 ++
 11 files changed, 312 insertions(+), 53 deletions(-)
 create mode 100644 include/Attachment.php
 create mode 100644 include/Download.php

diff --git a/config.php.example b/config.php.example
index e972ecf..982e551 100644
--- a/config.php.example
+++ b/config.php.example
@@ -18,4 +18,11 @@ $error_address = 'root@example.com';
 #$notify_discard = 'inventory-tracking@example.com';
 $discard_notify = false;
 
+# Directory to save attachments to
+# Interpreted as absolute path if beginning with /,
+# otherwise assumed to be relative to the application root.
+# Must be writable by the web server.
+# Does not need to be within the web root.
+$files_dir = 'attachments';
+
 ?>
diff --git a/html/fragments.html b/html/fragments.html
index 5da87eb..e167cce 100644
--- a/html/fragments.html
+++ b/html/fragments.html
@@ -280,12 +280,6 @@
     </button>
   </form>
 </div>
-<div id="product-history"
-     class="¤hidden¤">
-  <h2>Artikelhistorik</h2>
-  ¤history¤
-</div>
-<!--
 <div id="product-attachments"
      class="¤hidden¤">
   <h2>Bilagor</h2>
@@ -298,17 +292,21 @@
     <input id="uploadfile"
            name="uploadfile"
            type="file"
-           onchange="show_file(event)"/>
+           onchange="showFile(event)"/>
     <input id="filename"
            name="filename"
            type="text"
            placeholder="Välj en fil..."
-           onclick="select_file(event)"
+           onclick="selectFile(event)"
            readonly />
     <button>Ladda upp</button>
   </form>
 </div>
--->
+<div id="product-history"
+     class="¤hidden¤">
+  <h2>Artikelhistorik</h2>
+  ¤history¤
+</div>
 <div id="product-label"
      class="¤hidden¤">
   <h2>Etikett</h2>
@@ -316,13 +314,23 @@
 </div>
 
 ¤¤ attachment_list ¤¤
-<ul>
+<ul class="attachment-list">
   ¤attachments¤
 </ul>
 
 ¤¤ attachment ¤¤
 <li>
-  <strong>¤name¤</strong>: <a href="./?page=dl&id=¤id¤">Ladda ner</a>
+  <strong>¤name¤</strong> (¤date¤): <a href="./?page=dl&id=¤id¤">Ladda ner</a>
+  <br/>
+  <form onSubmit="JavaScript:deleteAttachment(event)">
+    <input type="hidden"
+           name="id"
+           value="¤id¤" />
+    <input type="hidden"
+           name="name"
+           value="¤name¤" />
+    <button>Ta bort</button>
+  </form>
 </li>
 
 ¤¤ product_label ¤¤
diff --git a/include/Ajax.php b/include/Ajax.php
index 841fa35..8ddadd3 100644
--- a/include/Ajax.php
+++ b/include/Ajax.php
@@ -59,6 +59,13 @@ class Ajax extends Responder {
                 break;
             case 'toggleservice':
                 $out = $this->toggle_service();
+                break;
+            case 'addattachment':
+                $out = $this->add_attachment();
+                break;
+            case 'deleteattachment':
+                $out = $this->delete_attachment();
+                break;
         }
         print($out->toJson());
     }
@@ -381,5 +388,31 @@ class Ajax extends Responder {
                               .'på den här artikeln nu.');
         }
     }
+
+    private function add_attachment() {
+        try {
+            $product = new Product($_POST['id']);
+            $uploadfile = $_FILES['uploadfile'];
+            $attach = Attachment::create($uploadfile, $product->get_id());
+            $date = format_date($attach->get_uploadtime());
+            $fragment = replace(array('name' => $attach->get_filename(),
+                                      'id' => $attach->get_id(),
+                                      'date' => $date),
+                                $this->fragments['attachment']);
+            return new Success($fragment);
+        } catch(Exception $e) {
+            return new Failure($e->getMessage());
+        }
+    }
+
+    private function delete_attachment() {
+        $attach = new Attachment($_POST['id']);
+        try {
+            $attach->delete();
+            return new Success('');
+        } catch(Exception $e) {
+            return new Failure($e->getMessage());
+        }
+    }
 }
 ?>
diff --git a/include/Attachment.php b/include/Attachment.php
new file mode 100644
index 0000000..0450bcc
--- /dev/null
+++ b/include/Attachment.php
@@ -0,0 +1,109 @@
+<?php
+class Attachment {
+    private $id;
+    private $product;
+    private $filename;
+    private $uploadtime;
+
+    public static function create($file, $prodid) {
+        begin_trans();
+        try {
+            if(!isset($file['error']) || is_array($file['error'])) {
+                throw new Exception('Ogiltigt anrop.');
+            }
+            switch($file['error']) {
+                case UPLOAD_ERR_OK:
+                    break;
+                case UPLOAD_ERR_NO_FILE:
+                    throw new Exception('Ingen fil skickades.');
+                case UPLOAD_ERR_INI_SIZE:
+                case UPLOAD_ERR_FORM_SIZE:
+                    throw new Exception('Filen är för stor.');
+                default:
+                    throw new Exception('Ett okänt fel har inträffat.');
+            }
+            $filename = $file['name'];
+            $insert = prepare('insert into `attachment`
+                                   (`product`, `filename`, `uploadtime`)
+                                   values (?, ?, ?)');
+            bind($insert, 'isi', $prodid, $filename, time());
+            execute($insert);
+            $attachid = $insert->insert_id;
+            global $files_dir;
+            $savepath = $files_dir;
+            if(substr($savepath, -1) !== '/') {
+                $savepath .= '/';
+            }
+            $savepath .= $attachid;
+            $tmp_name = $file['tmp_name'];
+            if(file_exists($savepath)) {
+                throw new Exception('Filens plats är upptagen. '
+                                   .'Det här borde aldrig inträffa.');
+            }
+            if(!move_uploaded_file($tmp_name, $savepath)) {
+                throw new Exception('Filen kunde inte sparas.');
+            }
+            commit_trans();
+            return new Attachment($attachid);
+        } catch(Exception $e) {
+            revert_trans();
+            throw $e;
+        }
+    }
+
+    public function __construct($id) {
+        $search = prepare('select * from `attachment` where `id`=?');
+        bind($search, 'i', $id);
+        execute($search);
+        $result = result_single($search);
+        if($result === null) {
+            throw new Exception('Attachment does not exist.');
+        }
+        $this->id = $result['id'];
+        $this->product = $result['product'];
+        $this->filename = $result['filename'];
+        $this->uploadtime = $result['uploadtime'];
+    }
+
+    public function delete() {
+        $delete = prepare('update `attachment` set `deletetime`=?
+                           where `id`=?');
+        bind($delete, 'ii', time(), $this->get_id());
+        execute($delete);
+        $path = $this->get_filepath();
+        if(file_exists($path)) {
+            unlink($path);
+        }
+        return true;
+    }
+
+    public function get_id() {
+        return $this->id;
+    }
+    
+    public function get_product() {
+        return new Product($this->product);
+    }
+
+    public function get_filename() {
+        return $this->filename;
+    }
+
+    public function get_uploadtime() {
+        return $this->uploadtime;
+    }
+
+    public function get_filepath() {
+        global $files_dir;
+        $path = $files_dir;
+        if(substr($path, -1) !== '/') {
+            $path .= '/';
+        }
+        $path .= $this->get_id();
+        if(!file_exists($path)) {
+            throw new Exception('Filen har försvunnit.');
+        }
+        return $path;
+    }
+}
+?>
diff --git a/include/Download.php b/include/Download.php
new file mode 100644
index 0000000..3f99ee6
--- /dev/null
+++ b/include/Download.php
@@ -0,0 +1,25 @@
+<?php
+class Download extends Responder {
+    private $attachment;
+    
+    public function __construct() {
+        parent::__construct();
+        if(isset($_GET['id'])) {
+            $this->attachment = new Attachment($_GET['id']);
+        }
+    }
+
+    public function render() {
+        $filename = $this->attachment->get_filename();
+        $filepath = $this->attachment->get_filepath();
+        header('Content-Description: File Transfer');
+        header('Content-Type: application/octet-stream');
+        header('Content-Disposition: attachment; filename="'.$filename.'"');
+        header('Expires: 0');
+        header('Cache-Control: no-cache');
+        header('Content-length: '.filesize($filepath));
+        readfile($filepath);
+        exit(0);
+    }
+}
+?>
diff --git a/include/Product.php b/include/Product.php
index 8725079..7c0a0e6 100644
--- a/include/Product.php
+++ b/include/Product.php
@@ -21,8 +21,8 @@ class Product {
         $now = time();
         begin_trans();
         try {
-            $stmt = 'insert into
-                         `product`(`brand`, `name`, `invoice`, `serial`, `createtime`)
+            $stmt = 'insert into `product`
+                         (`brand`, `name`, `invoice`, `serial`, `createtime`)
                          values (?, ?, ?, ?, ?)';
             $ins_prod = prepare($stmt);
             bind($ins_prod, 'ssssi', $brand, $name, $invoice, $serial, $now);
@@ -403,7 +403,17 @@ class Product {
     }
 
     public function get_attachments() {
-        return array();
+        $out = array();
+        $find = prepare('select `id` from `attachment`
+                         where `product`=? and `deletetime` is NULL
+                             order by `uploadtime` asc');
+        bind($find, 'i', $this->id);
+        execute($find);
+        $items = result_list($find);
+        foreach($items as $item) {
+            $out[] = new Attachment($item['id']);
+        }
+        return $out;
     }
 }
 ?>
diff --git a/include/ProductPage.php b/include/ProductPage.php
index c5fe86f..78237a1 100644
--- a/include/ProductPage.php
+++ b/include/ProductPage.php
@@ -89,7 +89,6 @@ class ProductPage extends Page {
                         'service'     => 'Starta service',
                         'history'     => $history,
                         'attachments' => $attachments);
-        $attachments = $this->product->get_attachments();
         if(class_exists('QRcode')) {
             $fields['label'] = replace($fields,
                                        $this->fragments['product_label']);
@@ -144,12 +143,14 @@ class ProductPage extends Page {
 
     private function build_attachment_list($attachments) {
         if(!$attachments) {
-            return 'Inga bilagor.';
+            return '<p>Inga bilagor.</p>';
         }
         $items = '';
         foreach($attachments as $attachment) {
-            $items .= replace(array('name' => $attachment->get_name(),
-                                    'id'   => $attachments->get_id()),
+            $date = format_date($attachment->get_uploadtime());
+            $items .= replace(array('name' => $attachment->get_filename(),
+                                    'id'   => $attachment->get_id(),
+                                    'date' => $date),
                               $this->fragments['attachment']);
         }
         return replace(array('attachments' => $items),
diff --git a/include/Responder.php b/include/Responder.php
index 80a4953..6d05f51 100644
--- a/include/Responder.php
+++ b/include/Responder.php
@@ -5,27 +5,35 @@ abstract class Responder {
     public function __construct() {
         $this->fragments = get_fragments('./html/fragments.html');
     }
-
+    
     final protected function escape_tags($tags) {
         foreach($tags as $key => $tag) {
-            $tags[$key] = str_replace(array("'",
-                                            '"'),
-                                      array('&#39;',
-                                            '&#34;'),
-                                      strtolower($tag));
+            $tags[$key] = $this->escape_string(strtolower($tag));
         }
         return $tags;
     }
     
     final protected function unescape_tags($tags) {
         foreach($tags as $key => $tag) {
-            $tags[$key] = str_replace(array('&#39;',
-                                            '&#34;'),
-                                      array("'",
-                                            '"'),
-                                      strtolower($tag));
+            $tags[$key] = $this->unescape_string(strtolower($tag));
         }
         return $tags;
     }
+    
+    final protected function escape_string($string) {
+        return str_replace(array("'",
+                                 '"'),
+                           array('&#39;',
+                                 '&#34;'),
+                           $string);
+    }
+
+    final protected function unescape_string($string) {
+        return str_replace(array('&#39;',
+                                 '&#34;'),
+                           array("'",
+                                 '"'),
+                           $string);
+    }
 }
 ?>
diff --git a/include/functions.php b/include/functions.php
index 50520a4..ffb6c39 100644
--- a/include/functions.php
+++ b/include/functions.php
@@ -97,6 +97,8 @@ function make_page($page) {
             return new QR();
         case 'print':
             return new Printer();
+        case 'dl':
+            return new Download();
     }
 }
 
diff --git a/script.js b/script.js
index cb77bb2..4ac5bfe 100644
--- a/script.js
+++ b/script.js
@@ -2,18 +2,7 @@ function ajaxRequest(action, datalist, callback) {
     var request = false
     request = new XMLHttpRequest()
     request.open('POST', "./?page=ajax&action=" + action, true)
-    request.setRequestHeader('Content-Type',
-                             'application/x-www-form-urlencoded')
-    var datastring = ''
-    var first = true
-    for(let [key, value] of datalist) {
-        if(!first) {
-            datastring += '&'
-        }
-        datastring += key + '=' + encodeURIComponent(value)
-        first = false
-    }
-    request.send(datastring)
+    request.send(datalist)
 
     request.onreadystatechange = function() {
         if (request.readyState == 4) {
@@ -83,12 +72,18 @@ function fixDuplicateInputNames(fields) {
 }
 
 function dataListFromForm(form, filter = function(field) {return true}) {
-    var out = []
+    var out = new FormData()
     var fields = form.querySelectorAll('input,textarea')
     fixDuplicateInputNames(fields)
     for(var i = 0; i < fields.length; i++) {
-        if(filter(fields[i])) {
-            out.push([fields[i].name, fields[i].value])
+        var field = fields[i]
+        if(filter(field)) {
+            if(field.type == 'file') {
+                var file = field.files[0]
+                out.append(field.name, file, file.name)
+            } else {
+                out.append(field.name, field.value)
+            }
         }
     }
     return out
@@ -103,9 +98,9 @@ function getFragment(name, callback) {
         }
     }
     
-    ajaxRequest('getfragment',
-                [['fragment', name]],
-                unpack)
+    var data = new FormData()
+    data.append('fragment', name)
+    ajaxRequest('getfragment', data, unpack)
 }
 
 function replace(fragment, replacements) {
@@ -158,12 +153,12 @@ function extendLoan(event) {
 
 function startInventory(event) {
     event.preventDefault()
-    ajaxRequest('startinventory', [], reloadOrError)
+    ajaxRequest('startinventory', new FormData(), reloadOrError)
 }
 
 function endInventory(event) {
     event.preventDefault()
-    ajaxRequest('endinventory', [], reloadOrError)
+    ajaxRequest('endinventory', new FormData(), reloadOrError)
 }
 
 function inventoryProduct(event) {
@@ -220,7 +215,9 @@ function suggest(input, type) {
             suggestlist.appendChild(next)
         }
     }
-    ajaxRequest('suggest', [['type', type]], render)
+    data = new FormData()
+    data.append('type', type)
+    ajaxRequest('suggest', data, render)
 }
 
 function suggestContent(input) {
@@ -236,7 +233,9 @@ function suggestContent(input) {
             suggestlist.appendChild(next)
         }
     }
-    ajaxRequest('suggestcontent', [['fieldname', input.name]], render)
+    data = new FormData()
+    data.append('fieldname', input.name)
+    ajaxRequest('suggestcontent', data, render)
 }
 
 function addField(event) {
@@ -414,6 +413,59 @@ function updateUser(event) {
     ajaxRequest('updateuser', dataListFromForm(form), reloadOrError)
 }
 
+function uploadAttachment(event) {
+    event.preventDefault()
+    var form = event.currentTarget
+    var render = function(result) {
+        if(result.type != 'success') {
+            showResult(result)
+            return
+        }
+        var classvalue = 'attachment-list'
+        var list = form.parentNode.querySelector('.'+classvalue)
+        if(list == null) {
+            list = document.createElement('ul')
+            list.classList.add(classvalue)
+            var p = form.parentNode.querySelector('p')
+            p.replaceWith(list)
+        }
+        var temp = document.createElement('template')
+        temp.innerHTML = result.message
+        list.appendChild(temp.content.firstChild)
+    }
+    var filter = function(input) {
+        if(input.name == 'filename') {
+            return false;
+        }
+        return true;
+    }
+    ajaxRequest('addattachment', dataListFromForm(form, filter), render)
+}
+
+function deleteAttachment(event) {
+    event.preventDefault()
+    var form = event.currentTarget
+    var node = form.parentNode
+    var name = form.name.value
+    if(window.confirm("Är du säker på att du vill ta bort bilagan '"
+                      +name+"'?")) {
+        var render = function(result) {
+            if(result.type == 'success') {
+                var list = node.parentNode
+                list.removeChild(node)
+                if(list.childElementCount == 0) {
+                    var p = document.createElement('p')
+                    p.append('Inga bilagor.')
+                    list.replaceWith(p)
+                }
+            } else {
+                showResult(result);
+            }
+        }
+        ajaxRequest('deleteattachment', dataListFromForm(form), render)
+    }
+}
+
 function productDataList(form) {
     var filter = function(input) {
         var name = input.name
@@ -513,11 +565,11 @@ function removeTerm(event) {
 }
 
 function selectFile(event) {
-    var fileinput = document.getElementById("uploadfile")
+    var fileinput = event.currentTarget.parentNode.uploadfile
     fileinput.click()
 }
 
 function showFile(event) {
-    var filefield = document.getElementById("filename")
+    var filefield = event.currentTarget.parentNode.filename
     filefield.value = event.currentTarget.files[0].name
 }
diff --git a/style.css b/style.css
index 2baa6f2..c1ec303 100644
--- a/style.css
+++ b/style.css
@@ -2,6 +2,10 @@ textarea {
     height: 80px;
 }
 
+ul {
+    padding-left: 15px;
+}
+
 #message {
    position: absolute;
     top: 5px;