diff --git a/DependencyInjection/ChillDocStoreExtension.php b/DependencyInjection/ChillDocStoreExtension.php index 62ab5b480..e57851427 100644 --- a/DependencyInjection/ChillDocStoreExtension.php +++ b/DependencyInjection/ChillDocStoreExtension.php @@ -26,12 +26,15 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.yml'); + $loader->load('services/media.yml'); + } public function prepend(ContainerBuilder $container) { $this->prependRoute($container); $this->prependAuthorization($container); + $this->prependTwig($container); } protected function prependRoute(ContainerBuilder $container) @@ -40,7 +43,8 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf $container->prependExtensionConfig('chill_main', array( 'routing' => array( 'resources' => array( - '@ChillDocStoreBundle/Resources/config/routing.yml' + '@ChillDocStoreBundle/Resources/config/routing.yml', + '@ChampsLibresAsyncUploaderBundle/Resources/config/routing.yml' ) ) )); @@ -57,4 +61,12 @@ class ChillDocStoreExtension extends Extension implements PrependExtensionInterf ) )); } + + protected function prependTwig(ContainerBuilder $container) + { + $twigConfig = array( + 'form_themes' => array('@ChillDocStore/Form/fields.html.twig') + ); + $container->prependExtensionConfig('twig', $twigConfig); + } } diff --git a/Entity/Document.php b/Entity/Document.php index cbabcf850..1cfa94d8e 100644 --- a/Entity/Document.php +++ b/Entity/Document.php @@ -40,9 +40,12 @@ class Document implements HasScopeInterface private $category; /** - * @ORM\Column(type="text") + * @ORM\ManyToOne( + * targetEntity="Chill\DocStoreBundle\Entity\StoredObject", + * cascade={"persist"} + * ) */ - private $content; + private $object; /** * @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Scope") @@ -65,7 +68,7 @@ class Document implements HasScopeInterface { return $this->id; } - + public function getTitle(): ?string { return $this->title; @@ -157,4 +160,16 @@ class Document implements HasScopeInterface return $this; } + + public function getObject(): ?StoredObject + { + return $this->object; + } + + public function setObject(StoredObject $object) + { + $this->object = $object; + + return $this; + } } diff --git a/Entity/StoredObject.php b/Entity/StoredObject.php new file mode 100644 index 000000000..b89ec3754 --- /dev/null +++ b/Entity/StoredObject.php @@ -0,0 +1,164 @@ + + * + * @ORM\Entity() + * @ORM\Table("chill_doc.stored_object") + */ +class StoredObject implements AsyncFileInterface +{ + /** + * @ORM\Id() + * @ORM\GeneratedValue() + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\Column(type="text") + */ + private $filename; + + /** + * @ORM\Column(type="json_array", name="key") + * @var array + */ + private $keyInfos = array(); + + /** + * + * @var int[] + * @ORM\Column(type="json_array", name="iv") + */ + private $iv = array(); + + /** + * + * @var \DateTime + * @ORM\Column(type="datetime", name="creation_date") + */ + private $creationDate; + + /** + * + * @var string + * @ORM\Column(type="text", name="type") + */ + private $type = ''; + + /** + * + * @var array + * @ORM\Column(type="json_array", name="datas") + */ + private $datas = []; + + public function __construct() + { + $this->creationDate = new \DateTime(); + } + + public function getId() + { + return $this->id; + } + + public function getFilename() + { + return $this->filename; + } + + public function getCreationDate(): \DateTime + { + return $this->creationDate; + } + + public function getType() + { + return $this->type; + } + + public function getDatas() + { + return $this->datas; + } + + public function setFilename($filename) + { + $this->filename = $filename; + + return $this; + } + + public function setCreationDate(\DateTime $creationDate) + { + $this->creationDate = $creationDate; + return $this; + } + + public function setType($type) + { + $this->type = $type; + + return $this; + } + + public function setDatas(array $datas) + { + $this->datas = $datas; + + return $this; + } + + public function getObjectName() + { + return $this->getFilename(); + } + + public function setAsyncFile(/*AsyncFileInterface*/ $async) + { + dump($async); + //$this->setFilename($async->getObjectName()); + } + + public function getAsyncFile() + { + return $this; + } + + public function getKeyInfos() + { + return $this->keyInfos; + } + + public function getIv() + { + return $this->iv; + } + + public function setKeyInfos($keyInfos) + { + $this->keyInfos = $keyInfos; + + return $this; + } + + public function setIv($iv) + { + $this->iv = $iv; + + return $this; + } + + +} diff --git a/Form/PersonDocumentType.php b/Form/PersonDocumentType.php index 28cc582f5..ebd4c5d9a 100644 --- a/Form/PersonDocumentType.php +++ b/Form/PersonDocumentType.php @@ -65,7 +65,7 @@ class PersonDocumentType extends AbstractType ->add('description', TextareaType::class, [ 'required' => false ]) - ->add('content') + ->add('object', StoredObjectType::class) ->add('scope', ScopePickerType::class, [ 'center' => $options['center'], 'role' => $options['role'] diff --git a/Form/StoredObjectType.php b/Form/StoredObjectType.php new file mode 100644 index 000000000..1ae861362 --- /dev/null +++ b/Form/StoredObjectType.php @@ -0,0 +1,72 @@ + + */ +class StoredObjectType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('filename', AsyncUploaderType::class) + ->add('keyInfos', HiddenType::class) + ->add('iv', HiddenType::class) + ; + + $builder + ->get('keyInfos') + ->addModelTransformer(new CallbackTransformer( + [$this, 'transform'], [$this, 'reverseTransform'] + )) + ; + $builder + ->get('iv') + ->addModelTransformer(new CallbackTransformer( + [$this, 'transform'], [$this, 'reverseTransform'] + )) + ; + } + + public function getBlockPrefix() + { + return 'stored_object'; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver + ->setDefault('data_class', StoredObject::class) + ; + } + + public function reverseTransform($value) + { + if ($value === null) { + return null; + } + + return \json_decode($value, true); + } + + public function transform($object) + { + if ($object === null) { + return null; + } + + return \json_encode($object); + } +} diff --git a/Object/ObjectToAsyncFileTransformer.php b/Object/ObjectToAsyncFileTransformer.php new file mode 100644 index 000000000..5501be6ce --- /dev/null +++ b/Object/ObjectToAsyncFileTransformer.php @@ -0,0 +1,53 @@ + + */ +class ObjectToAsyncFileTransformer implements AsyncFileTransformerInterface +{ + /** + * + * @var EntityManagerInterface + */ + protected $em; + + public function __construct(EntityManagerInterface $em) + { + $this->em = $em; + } + + public function toAsyncFile($data) + { + dump($data); + + if ($data instanceof StoredObject) { + return $data; + } + } + + public function toData(AsyncFileInterface $asyncFile) + { + dump($asyncFile); + + $object = $this->em + ->getRepository(StoredObject::class) + ->findByFilename($asyncFile->getObjectName()) + ; + + return $object ?? (new StoredObject()) + ->setFilename($asyncFile->getObjectName()) + ; + } +} diff --git a/Object/PersistenceChecker.php b/Object/PersistenceChecker.php new file mode 100644 index 000000000..3e0c58189 --- /dev/null +++ b/Object/PersistenceChecker.php @@ -0,0 +1,40 @@ + + */ +class PersistenceChecker implements PersistenceCheckerInterface +{ + /** + * + * @var EntityManagerInterface + */ + protected $em; + + public function __construct(EntityManagerInterface $em) + { + $this->em = $em; + } + + + public function isPersisted($object_name): bool + { + $qb = $this->em->createQueryBuilder(); + $qb->select('COUNT(m)') + ->from(StoredObject::class, 'm') + ->where($qb->expr()->eq('m.filename', ':object_name')) + ->setParameter('object_name', $object_name) + ; + + return 1 === $qb->getQuery()->getSingleScalarResult(); + } +} diff --git a/Resources/config/services/media.yml b/Resources/config/services/media.yml new file mode 100644 index 000000000..e6afe2155 --- /dev/null +++ b/Resources/config/services/media.yml @@ -0,0 +1,9 @@ +services: + chill_doc_store.persistence_checker: + class: Chill\DocStoreBundle\Object\PersistenceChecker + arguments: + $em: '@Doctrine\ORM\EntityManagerInterface' + + Chill\DocStoreBundle\Object\ObjectToAsyncFileTransformer: + arguments: + $em: '@Doctrine\ORM\EntityManagerInterface' diff --git a/Resources/migrations/Version20180606133338.php b/Resources/migrations/Version20180606133338.php new file mode 100644 index 000000000..43dae7aa4 --- /dev/null +++ b/Resources/migrations/Version20180606133338.php @@ -0,0 +1,38 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('CREATE SEQUENCE chill_doc.stored_object_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_doc.stored_object (id INT NOT NULL, filename TEXT NOT NULL, key JSON NOT NULL, iv JSON NOT NULL, creation_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, type TEXT NOT NULL, datas JSON NOT NULL, PRIMARY KEY(id))'); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object.key IS \'(DC2Type:json_array)\''); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object.iv IS \'(DC2Type:json_array)\''); + $this->addSql('COMMENT ON COLUMN chill_doc.stored_object.datas IS \'(DC2Type:json_array)\''); + $this->addSql('ALTER TABLE chill_doc.person_document ADD object_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_doc.person_document DROP content'); + $this->addSql('ALTER TABLE chill_doc.person_document ADD CONSTRAINT FK_41DA53C232D562B FOREIGN KEY (object_id) REFERENCES chill_doc.stored_object (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_41DA53C232D562B ON chill_doc.person_document (object_id)'); + } + + public function down(Schema $schema) : void + { + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('ALTER TABLE chill_doc.person_document DROP CONSTRAINT FK_41DA53C232D562B'); + $this->addSql('DROP SEQUENCE chill_doc.stored_object_id_seq CASCADE'); + $this->addSql('DROP TABLE chill_doc.stored_object'); + $this->addSql('ALTER TABLE chill_doc.person_document ADD content TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_doc.person_document DROP object_id'); + } +} diff --git a/Resources/public/module/async_upload/downloader.js b/Resources/public/module/async_upload/downloader.js new file mode 100644 index 000000000..55f0a3550 --- /dev/null +++ b/Resources/public/module/async_upload/downloader.js @@ -0,0 +1,67 @@ +var algo = 'AES-CBC'; + +var initializeButtons = (root) => { + var + buttons = root.querySelectorAll('a[data-download-button]'); + + for (let i = 0; i < buttons.length; i ++) { + initialize(buttons[i]); + } +}; + +var initialize = (button) => { + button.addEventListener('click', onClick); +}; + +var onClick = e => download(e.target); + +var download = (button) => { + console.log(button.dataset.key); + var + keyData = JSON.parse(button.dataset.key), + //keyData, // = JSON.parse(keyString), + ivData = JSON.parse(button.dataset.iv), + iv = new Uint8Array(ivData), + url = button.dataset.tempUrlGet, + data, key + ; + console.log('keyData', keyData); + console.log('iv', iv); + console.log('url', url); + + window.crypto.subtle.importKey('jwk', keyData, { name: algo, iv: iv}, false, ['decrypt']) + .then(nKey => { + key = nKey; + console.log(key); + + return window.fetch(url); + }) + .then(r => { + if (r.ok) { + return r.arrayBuffer(); + } else { + console.log(r); + throw new Error(r.statusCode); + } + }) + .then(buffer => { + return window.crypto.subtle.decrypt({ name: algo, iv: iv }, key, buffer); + }) + .then(decrypted => { + var + blob = new Blob([decrypted]), + url = window.URL.createObjectURL(blob) + ; + button.href = url; + button.removeEventListener('click', onClick); + }) + .catch(error => { + console.log(error); + }) + ; +}; + +window.addEventListener('load', function(e) { + console.log('load'); + initializeButtons(e.target); +}); diff --git a/Resources/public/module/async_upload/index.js b/Resources/public/module/async_upload/index.js new file mode 100644 index 000000000..9fa7deeeb --- /dev/null +++ b/Resources/public/module/async_upload/index.js @@ -0,0 +1,2 @@ +require('./uploader.js'); +require('./downloader.js'); \ No newline at end of file diff --git a/Resources/public/module/async_upload/uploader.js b/Resources/public/module/async_upload/uploader.js new file mode 100644 index 000000000..6fdc7cb66 --- /dev/null +++ b/Resources/public/module/async_upload/uploader.js @@ -0,0 +1,183 @@ +var algo = 'AES-CBC'; + +var keyDefinition = { + name: algo, + length: 256 +}; + +var searchForZones = function(root) { + var zones = root.querySelectorAll('div[data-stored-object]'); + + for(let i=0; i < zones.length; i++) { + initialize(zones[i]); + } +}; + + +var initialize = function(zone) { + console.log('initialize zone'); + + var + dropZone = document.createElement('div'), + input = document.createElement('input') + ; + + input.type = 'file'; + input.addEventListener('change', function(e) { + handleInputFile(zone); + }); + + dropZone.classList.add('chill-doc__dropzone__drop'); + + zone.insertBefore(input, zone.firstChild); + zone.insertBefore(dropZone, zone.firstChild); +}; + +var handleInputFile = function (zone) { + console.log('handle file'); + + var + file = zone.querySelector('input[type="file"]').files[0], + + reader = new FileReader() + ; + + reader.onload = e => { + transmitArrayBuffer(zone, e.target.result); + }; + + reader.readAsArrayBuffer(file); +}; + +var createFilename = () => { + var text = ""; + var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (let i = 0; i < 7; i++) + text += possible.charAt(Math.floor(Math.random() * possible.length)); + + return text; +}; + +var transmitArrayBuffer = (zone, data) => { + var + iv = crypto.getRandomValues(new Uint8Array(16)), + generateTempUrlPost = zone.querySelector('input[data-async-file-upload]').dataset.generateTempUrlPost, + suffix = createFilename(), + jsKey, rawKey, encryptedData, uploadData + ; + + window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]) + .then(key => { + console.log('key', key); + console.log('iv', iv); + console.log('iv to string', iv.join(',')); + jsKey = key; + + // we register the key somwhere + return window.crypto.subtle.exportKey('jwk', key); + }).then(exportedKey => { + rawKey = exportedKey; + + console.log('rawkey', rawKey); + console.log('data', data); + // we start encryption + return window.crypto.subtle.encrypt({ name: algo, iv: iv}, jsKey, data); + }) + .then(encrypted => { + console.log('encrypted', encrypted); + + encryptedData = encrypted; + + // we get the url and parameters to upload document + return window.fetch(generateTempUrlPost); + }) + .then(response => response.json()) + .then(data => { + console.log(encryptedData); + console.log(data); + var + formData = new FormData(); + + uploadData = data; + + formData.append("redirect", data.redirect); + formData.append("max_file_size", data.max_file_size); + formData.append("max_file_count", data.max_file_count); + formData.append("expires", data.expires); + formData.append("signature", data.signature); + formData.append("file", new Blob([ encryptedData ]), suffix); + + console.log('filename', suffix); + console.log('formData', formData); + + return window.fetch(data.url, { + method: 'POST', + mode: 'cors', + body: formData + }); + }) + .then(response => { + if (response.ok) { + console.log('sent'); + + storeDataInForm(zone, suffix, rawKey, iv, uploadData); + + } else { + console.log('response', response); + throw new Error("error while sending data"); + } + }) + .catch(error => { + console.log(error); + }) + +// return window.crypto.subtle.importKey('jwk', rawKey, { name: algo, iv: iv }, false, [ "decrypt"]); +// +// .then(key => { +// console.log('decrypt'); +// console.log(key); +// +// return window.crypto.subtle.decrypt({ name: algo, iv: iv }, key, encryptedData); +// }) +// .then(decrypted => { +// console.log('decrypted'); +// decrypt(zone, decrypted, 'decrypted'); +// }) + + ; +}; + +var storeDataInForm = (zone, suffix, jskey, iv, uploaddata) => { + var + inputKey = zone.querySelector('input[data-stored-object-key]'), + inputIv = zone.querySelector('input[data-stored-object-iv]'), + inputObject = zone.querySelector('input[data-async-file-upload]') + ; + + inputKey.value = JSON.stringify(jskey); + inputIv.value = JSON.stringify(iv); + inputObject.value = uploaddata.prefix + suffix; +}; + +var decrypt = (zone, arraybuffer, name) => { + console.log('arraybuffer', arraybuffer); + var + link = document.createElement('a'), + data = new Blob([ arraybuffer ]) + ; + + link.innerHTML = name; + + link.href = window.URL.createObjectURL(data); + link.download = 'file'; + + zone.appendChild(link); +}; + + +window.addEventListener('load', function(e) { + searchForZones(document); +}); + + diff --git a/Resources/views/Form/fields.html.twig b/Resources/views/Form/fields.html.twig new file mode 100644 index 000000000..bb744282a --- /dev/null +++ b/Resources/views/Form/fields.html.twig @@ -0,0 +1,7 @@ +{% block stored_object_widget %} +
+ {{ form_widget(form.filename) }} + {{ form_widget(form.keyInfos, { 'attr': { 'data-stored-object-key': 1 } }) }} + {{ form_widget(form.iv, { 'attr': { 'data-stored-object-iv': 1 } }) }} +
+{% endblock %} diff --git a/Resources/views/Macro/macro.html.twig b/Resources/views/Macro/macro.html.twig new file mode 100644 index 000000000..7042998d1 --- /dev/null +++ b/Resources/views/Macro/macro.html.twig @@ -0,0 +1,3 @@ +{% macro download_button(storedObject) %} + {{ 'Download'|trans }} +{% endmacro %} diff --git a/Resources/views/PersonDocument/index.html.twig b/Resources/views/PersonDocument/index.html.twig index 50cd0579a..dc35ceae9 100644 --- a/Resources/views/PersonDocument/index.html.twig +++ b/Resources/views/PersonDocument/index.html.twig @@ -19,8 +19,14 @@ {% set activeRouteKey = '' %} +{% import "@ChillDocStore/Macro/macro.html.twig" as m %} + {% block title %}{{ 'Documents for %name%'|trans({ '%name%': person.firstName|capitalize ~ ' ' ~ person.lastName } )|capitalize }}{% endblock %} +{% block js %} + +{% endblock %} + {% block personcontent %}

{{ 'Document for %name%'|trans({ '%name%': person.firstName|capitalize ~ ' ' ~ person.lastName } )|capitalize }}

@@ -42,6 +48,9 @@ {{ form_end(form) }} {% endblock %} + +{% block js %} + +{% endblock %} diff --git a/chill.webpack.config.js b/chill.webpack.config.js new file mode 100644 index 000000000..e98df7a23 --- /dev/null +++ b/chill.webpack.config.js @@ -0,0 +1,5 @@ +module.exports = function(encore) { + let file = __dirname + '/Resources/public/module/async_upload/index.js'; + + encore.addEntry('async_upload', file); +};