upload and retrieve document

This commit is contained in:
Julien Fastré 2018-06-06 22:19:54 +02:00
parent eda8f2c033
commit b5bf8b8884
17 changed files with 688 additions and 5 deletions

View File

@ -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);
}
}

View File

@ -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;
}
}

164
Entity/StoredObject.php Normal file
View File

@ -0,0 +1,164 @@
<?php
/*
*
*/
namespace Chill\DocStoreBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use ChampsLibres\AsyncUploaderBundle\Model\AsyncFileInterface;
/**
* Represent a document stored in an object store
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*
* @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;
}
}

View File

@ -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']

72
Form/StoredObjectType.php Normal file
View File

@ -0,0 +1,72 @@
<?php
/*
*/
namespace Chill\DocStoreBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use ChampsLibres\AsyncUploaderBundle\Form\Type\AsyncUploaderType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Form\CallbackTransformer;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
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);
}
}

View File

@ -0,0 +1,53 @@
<?php
/*
*/
namespace Chill\DocStoreBundle\Object;
use ChampsLibres\AsyncUploaderBundle\Form\AsyncFileTransformer\AsyncFileTransformerInterface;
use ChampsLibres\AsyncUploaderBundle\Model\AsyncFileInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Doctrine\ORM\EntityManagerInterface;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
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())
;
}
}

View File

@ -0,0 +1,40 @@
<?php
/*
*/
namespace Chill\DocStoreBundle\Object;
use ChampsLibres\AsyncUploaderBundle\Persistence\PersistenceCheckerInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Doctrine\ORM\EntityManagerInterface;
/**
*
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
*/
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();
}
}

View File

@ -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'

View File

@ -0,0 +1,38 @@
<?php declare(strict_types=1);
namespace Application\Migrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Add schema for stored object
*/
final class Version20180606133338 extends AbstractMigration
{
public function up(Schema $schema) : void
{
$this->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');
}
}

View File

@ -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);
});

View File

@ -0,0 +1,2 @@
require('./uploader.js');
require('./downloader.js');

View File

@ -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);
});

View File

@ -0,0 +1,7 @@
{% block stored_object_widget %}
<div data-stored-object="data-stored-object">
{{ form_widget(form.filename) }}
{{ form_widget(form.keyInfos, { 'attr': { 'data-stored-object-key': 1 } }) }}
{{ form_widget(form.iv, { 'attr': { 'data-stored-object-iv': 1 } }) }}
</div>
{% endblock %}

View File

@ -0,0 +1,3 @@
{% macro download_button(storedObject) %}
<a class="sc-button bt-download" data-download-button data-key="{{ storedObject.keyInfos|json_encode|escape('html_attr') }}" data-iv="{{ storedObject.iv|json_encode|escape('html_attr') }}" data-temp-url-get="{{ storedObject|file_url }}">{{ 'Download'|trans }}</a>
{% endmacro %}

View File

@ -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 %}
<script src="{{ asset('build/async_upload.js') }}" type="text/javascript"></script>
{% endblock %}
{% block personcontent %}
<h1>{{ 'Document for %name%'|trans({ '%name%': person.firstName|capitalize ~ ' ' ~ person.lastName } )|capitalize }}</h1>
@ -42,6 +48,9 @@
<td>
<ul class="record_actions">
{% if is_granted('CHILL_PERSON_DOCUMENT_SEE_DETAILS', document) %}
<li>
{{ m.download_button(document.object) }}
</li>
<li>
<a href="{{ path('person_document_show', {'person': person.id, 'id': document.id}) }}" class="sc-button">
{{ 'See'|trans }}

View File

@ -38,3 +38,7 @@
</ul>
{{ form_end(form) }}
{% endblock %}
{% block js %}
<script src="{{ asset('build/async_upload.js') }}" type="text/javascript"></script>
{% endblock %}

5
chill.webpack.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = function(encore) {
let file = __dirname + '/Resources/public/module/async_upload/index.js';
encore.addEntry('async_upload', file);
};