Update DropFile to handle object versioning

This commit is contained in:
Julien Fastré 2024-09-02 16:24:23 +02:00
parent b6edbb3eed
commit 3d49c959e0
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
42 changed files with 857 additions and 539 deletions

View File

@ -122,7 +122,7 @@ unit_tests:
- php tests/console chill:db:sync-views --env=test
- php -d memory_limit=2G tests/console cache:clear --env=test
- php -d memory_limit=3G tests/console doctrine:fixtures:load -n --env=test
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive
- php -d memory_limit=4G bin/phpunit --colors=never --exclude-group dbIntensive --exclude-group openstack-integration
artifacts:
expire_in: 1 day
paths:

View File

@ -182,21 +182,19 @@ final readonly class TempUrlOpenstackGenerator implements TempUrlGeneratorInterf
return \hash_hmac('sha512', $body, $this->key, false);
}
private function generateSignature($method, $url, \DateTimeImmutable $expires)
private function generateSignature(string $method, $url, \DateTimeImmutable $expires)
{
if ('POST' === $method) {
return $this->generateSignaturePost($url, $expires);
}
$path = \parse_url((string) $url, PHP_URL_PATH);
$body = sprintf(
"%s\n%s\n%s",
$method,
strtoupper($method),
$expires->format('U'),
$path
)
;
);
$this->logger->debug(
'generate signature GET',

View File

@ -15,6 +15,7 @@ use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\MainBundle\Entity\User;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@ -99,8 +100,15 @@ final readonly class AsyncUploadController
$request->query->has('expires_delay') ? $request->query->getInt('expires_delay') : null
);
$user = $this->security->getUser();
$userId = match ($user instanceof User) {
true => $user->getId(),
false => $user->getUserIdentifier(),
};
$this->chillLogger->notice('[Privacy Event] a request to see a document has been granted', [
'doc_uuid' => $storedObject->getUuid(),
'doc_uuid' => $storedObject->getUuid()->toString(),
'user_id' => $userId,
]);
return new JsonResponse(

View File

@ -23,6 +23,7 @@ use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Random\RandomException;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represent a document stored in an object store.
@ -37,7 +38,6 @@ use Symfony\Component\Serializer\Annotation as Serializer;
*/
#[ORM\Entity]
#[ORM\Table('stored_object', schema: 'chill_doc')]
#[AsyncFileExists(message: 'The file is not stored properly')]
class StoredObject implements Document, TrackCreationInterface
{
use TrackCreationTrait;
@ -91,7 +91,7 @@ class StoredObject implements Document, TrackCreationInterface
/**
* @var Collection<int, StoredObjectVersion>
*/
#[ORM\OneToMany(targetEntity: StoredObjectVersion::class, cascade: ['persist'], mappedBy: 'storedObject', orphanRemoval: true)]
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
private Collection $versions;
/**
@ -127,6 +127,8 @@ class StoredObject implements Document, TrackCreationInterface
return \DateTime::createFromImmutable($this->createdAt);
}
#[AsyncFileExists(message: 'The file is not stored properly')]
#[Assert\NotNull(message: 'The store object version must be present')]
public function getCurrentVersion(): ?StoredObjectVersion
{
$maxVersion = null;
@ -350,20 +352,7 @@ class StoredObject implements Document, TrackCreationInterface
/**
* @deprecated
*/
public function saveHistory(): void
{
if ('' === $this->getFilename()) {
return;
}
$this->datas['history'][] = [
'filename' => $this->getFilename(),
'iv' => $this->getIv(),
'key_infos' => $this->getKeyInfos(),
'type' => $this->getType(),
'before' => (new \DateTimeImmutable('now'))->getTimestamp(),
];
}
public function saveHistory(): void {}
public static function generatePrefix(): string
{

View File

@ -86,7 +86,7 @@ class StoredObjectVersion implements TrackCreationInterface
{
try {
$suffix = base_convert(bin2hex(random_bytes(8)), 16, 36);
} catch (RandomException $e) {
} catch (RandomException) {
$suffix = uniqid(more_entropy: true);
}

View File

@ -55,15 +55,8 @@ class StoredObjectDataMapper implements DataMapperInterface
return;
}
/** @var StoredObject $viewData */
if ($viewData->getFilename() !== $forms['stored_object']->getData()['filename']) {
$viewData->registerVersion(
$forms['stored_object']->getData()['iv'],
$forms['stored_object']->getData()['keyInfos'],
$forms['stored_object']->getData()['type'],
$forms['stored_object']->getData()['filename'],
);
}
/* @var StoredObject $viewData */
$viewData = $forms['stored_object']->getData();
if (array_key_exists('title', $forms)) {
$viewData->setTitle($forms['title']->getData());

View File

@ -19,7 +19,7 @@ use Symfony\Component\Serializer\SerializerInterface;
class StoredObjectDataTransformer implements DataTransformerInterface
{
public function __construct(
private readonly SerializerInterface $serializer
private readonly SerializerInterface $serializer,
) {}
public function transform(mixed $value): mixed
@ -41,6 +41,6 @@ class StoredObjectDataTransformer implements DataTransformerInterface
return null;
}
return json_decode((string) $value, true, 10, JSON_THROW_ON_ERROR);
return $this->serializer->deserialize($value, StoredObject::class, 'json');
}
}

View File

@ -64,6 +64,11 @@ final readonly class StoredObjectRepository implements StoredObjectRepositoryInt
return $qb->getQuery()->toIterable(hydrationMode: Query::HYDRATE_OBJECT);
}
public function findOneByUUID(string $uuid): ?StoredObject
{
return $this->repository->findOneBy(['uuid' => $uuid]);
}
public function getClassName(): string
{
return StoredObject::class;

View File

@ -23,4 +23,6 @@ interface StoredObjectRepositoryInterface extends ObjectRepository
* @return iterable<StoredObject>
*/
public function findByExpired(\DateTimeImmutable $expiredAtDate): iterable;
public function findOneByUUID(string $uuid): ?StoredObject;
}

View File

@ -1,5 +1,5 @@
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import {PostStoreObjectSignature} from "../../types";
import {PostStoreObjectSignature, StoredObject} from "../../types";
const algo = 'AES-CBC';
@ -21,11 +21,22 @@ const createFilename = (): string => {
return text;
};
export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
/**
* Fetches a new stored object from the server.
*
* @async
* @function fetchNewStoredObject
* @returns {Promise<StoredObject>} A Promise that resolves to the newly created StoredObject.
*/
export const fetchNewStoredObject = async (): Promise<StoredObject> => {
return makeFetch("POST", '/api/1.0/doc-store/stored-object/create', null);
}
export const uploadVersion = async (uploadFile: ArrayBuffer, storedObject: StoredObject): Promise<string> => {
const params = new URLSearchParams();
params.append('expires_delay', "180");
params.append('submit_delay', "180");
const asyncData: PostStoreObjectSignature = await makeFetch("GET", URL_POST + "?" + params.toString());
const asyncData: PostStoreObjectSignature = await makeFetch("GET", `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/post` + "?" + params.toString());
const suffix = createFilename();
const filename = asyncData.prefix + suffix;
const formData = new FormData();
@ -50,7 +61,6 @@ export const uploadFile = async (uploadFile: ArrayBuffer): Promise<string> => {
}
export const encryptFile = async (originalFile: ArrayBuffer): Promise<[ArrayBuffer, Uint8Array, JsonWebKey]> => {
console.log('encrypt', originalFile);
const iv = crypto.getRandomValues(new Uint8Array(16));
const key = await window.crypto.subtle.generateKey(keyDefinition, true, [ "encrypt", "decrypt" ]);
const exportedKey = await window.crypto.subtle.exportKey('jwk', key);

View File

@ -1,7 +1,7 @@
import {CollectionEventPayload} from "../../../../../ChillMainBundle/Resources/public/module/collection";
import {createApp} from "vue";
import DropFileWidget from "../../vuejs/DropFileWidget/DropFileWidget.vue"
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject, StoredObjectVersion} from "../../types";
import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
const i18n = _createI18n({});
@ -30,15 +30,17 @@ const startApp = (divElement: HTMLDivElement, collectionEntry: null|HTMLLIElemen
DropFileWidget,
},
methods: {
addDocument: function(object: StoredObjectCreated): void {
console.log('object added', object);
this.$data.existingDoc = object;
input_stored_object.value = JSON.stringify(object);
addDocument: function({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
console.log('object added', stored_object);
console.log('version added', stored_object_version);
this.$data.existingDoc = stored_object;
this.$data.existingDoc.currentVersion = stored_object_version;
input_stored_object.value = JSON.stringify(this.$data.existingDoc);
},
removeDocument: function(object: StoredObject): void {
console.log('catch remove document', object);
input_stored_object.value = "";
this.$data.existingDoc = null;
this.$data.existingDoc = undefined;
console.log('collectionEntry', collectionEntry);
if (null !== collectionEntry) {

View File

@ -4,11 +4,11 @@ export type StoredObjectStatus = "empty"|"ready"|"failure"|"pending";
export interface StoredObject {
id: number,
title: string,
title: string|null,
uuid: string,
prefix: string,
status: StoredObjectStatus,
currentVersion: null|StoredObjectVersion,
currentVersion: null|StoredObjectVersionCreated|StoredObjectVersionPersisted,
totalVersions: number,
datas: object,
/** @deprecated */
@ -32,21 +32,20 @@ export interface StoredObjectVersion {
* filename of the object in the object storage
*/
filename: string,
version: number,
id: number,
iv: number[],
keyInfos: object,
keyInfos: JsonWebKey,
type: string,
createdAt: DateTime|null,
createdBy: User|null,
}
export interface StoredObjectCreated {
status: "stored_object_created",
filename: string,
iv: Uint8Array,
keyInfos: object,
type: string,
export interface StoredObjectVersionCreated extends StoredObjectVersion {
persisted: false,
}
export interface StoredObjectVersionPersisted extends StoredObjectVersionCreated {
version: number,
id: number,
createdAt: DateTime|null,
createdBy: User|null,
}
export interface StoredObjectStatusChange {

View File

@ -1,20 +1,20 @@
<template>
<div v-if="'ready' === props.storedObject.status || 'stored_object_created' === props.storedObject.status" class="btn-group">
<div v-if="isButtonGroupDisplayable" class="btn-group">
<button :class="Object.assign({'btn': true, 'btn-outline-primary': true, 'dropdown-toggle': true, 'btn-sm': props.small})" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.storedObject.status !== 'stored_object_created'">
<li v-if="isEditableOnline">
<wopi-edit-button :stored-object="props.storedObject" :classes="{'dropdown-item': true}" :execute-before-leave="props.executeBeforeLeave"></wopi-edit-button>
</li>
<li v-if="props.canEdit && is_extension_editable(props.storedObject.type) && props.davLink !== undefined && props.davLinkExpiration !== undefined">
<li v-if="isEditableOnDesktop">
<desktop-edit-button :classes="{'dropdown-item': true}" :edit-link="props.davLink" :expiration-link="props.davLinkExpiration"></desktop-edit-button>
</li>
<li v-if="props.storedObject.type != 'application/pdf' && is_extension_viewable(props.storedObject.type) && props.canConvertPdf && props.storedObject.status !== 'stored_object_created'">
<li v-if="isConvertibleToPdf">
<convert-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></convert-button>
</li>
<li v-if="props.canDownload">
<download-button :stored-object="props.storedObject" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
<li v-if="isDownloadable">
<download-button :stored-object="props.storedObject" :at-version="props.storedObject.currentVersion" :filename="filename" :classes="{'dropdown-item': true}"></download-button>
</li>
</ul>
</div>
@ -29,20 +29,20 @@
<script lang="ts" setup>
import {onMounted} from "vue";
import {computed, onMounted} from "vue";
import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
import {is_extension_editable, is_extension_viewable, is_object_ready} from "./StoredObjectButton/helpers";
import {
StoredObject, StoredObjectCreated,
StoredObjectStatusChange,
StoredObject,
StoredObjectStatusChange, StoredObjectVersion,
WopiEditButtonExecutableBeforeLeaveFunction
} from "../types";
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
interface DocumentActionButtonsGroupConfig {
storedObject: StoredObject|StoredObjectCreated,
storedObject: StoredObject,
small?: boolean,
canEdit?: boolean,
canDownload?: boolean,
@ -95,11 +95,44 @@ let tryiesForReady = 0;
*/
const maxTryiesForReady = 120;
const isButtonGroupDisplayable = computed<boolean>(() => {
return isDownloadable.value || isEditableOnline.value || isEditableOnDesktop.value || isConvertibleToPdf.value;
});
const isDownloadable = computed<boolean>(() => {
return props.storedObject.status === 'ready'
// happens when the stored object version is just added, but not persisted
|| (props.storedObject.currentVersion !== null && props.storedObject.status === 'empty')
});
const isEditableOnline = computed<boolean>(() => {
return props.storedObject.status === 'ready'
&& props.storedObject._permissions.canEdit
&& props.canEdit
&& props.storedObject.currentVersion !== null
&& is_extension_editable(props.storedObject.currentVersion.type)
&& props.storedObject.currentVersion.persisted !== false;
});
const isEditableOnDesktop = computed<boolean>(() => {
return isEditableOnline.value;
});
const isConvertibleToPdf = computed<boolean>(() => {
return props.storedObject.status === 'ready'
&& props.storedObject._permissions.canSee
&& props.canConvertPdf
&& props.storedObject.currentVersion !== null
&& is_extension_viewable(props.storedObject.currentVersion.type)
&& props.storedObject.currentVersion.type !== 'application/pdf'
&& props.storedObject.currentVersion.persisted !== false;
})
const checkForReady = function(): void {
if (
'ready' === props.storedObject.status
|| 'empty' === props.storedObject.status
|| 'failure' === props.storedObject.status
|| 'stored_object_created' === props.storedObject.status
// stop reloading if the page stays opened for a long time
|| tryiesForReady > maxTryiesForReady
) {

View File

@ -132,7 +132,6 @@ console.log(PdfWorker); // incredible but this is needed
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {
build_download_info_link,
download_and_decrypt_doc,
} from "../StoredObjectButton/helpers";
@ -157,7 +156,6 @@ declare global {
const $toast = useToast();
const signature = window.signature;
const urlInfo = build_download_info_link(signature.storedObject.filename);
const mountPdf = async (url: string) => {
const loadingTask = pdfjsLib.getDocument(url);
@ -189,11 +187,7 @@ const setPage = async (page: number) => {
async function downloadAndOpen(): Promise<Blob> {
let raw;
try {
raw = await download_and_decrypt_doc(
urlInfo,
signature.storedObject.keyInfos,
new Uint8Array(signature.storedObject.iv)
);
raw = await download_and_decrypt_doc(signature.storedObject, signature.storedObject.currentVersion);
} catch (e) {
console.error("error while downloading and decrypting document", e);
throw e;

View File

@ -1,17 +1,17 @@
<script setup lang="ts">
import {StoredObject, StoredObjectCreated} from "../../types";
import {encryptFile, uploadFile} from "../_components/helper";
import {StoredObject, StoredObjectVersionCreated} from "../../types";
import {encryptFile, fetchNewStoredObject, uploadVersion} from "../../js/async-upload/uploader";
import {computed, ref, Ref} from "vue";
interface DropFileConfig {
existingDoc?: StoredObjectCreated|StoredObject,
existingDoc?: StoredObject,
}
const props = defineProps<DropFileConfig>();
const props = withDefaults(defineProps<DropFileConfig>(), {existingDoc: null});
const emit = defineEmits<{
(e: 'addDocument', stored_object: StoredObjectCreated): void,
(e: 'addDocument', {stored_object_version: StoredObjectVersionCreated, stored_object: StoredObject}): void,
}>();
const is_dragging: Ref<boolean> = ref(false);
@ -34,7 +34,6 @@ const onDragLeave = (e: Event) => {
}
const onDrop = (e: DragEvent) => {
console.log('on drop', e);
e.preventDefault();
const files = e.dataTransfer?.files;
@ -64,7 +63,6 @@ const onZoneClick = (e: Event) => {
const onFileChange = async (event: Event): Promise<void> => {
const input = event.target as HTMLInputElement;
console.log('event triggered', input);
if (input.files && input.files[0]) {
console.log('file added', input.files[0]);
@ -80,21 +78,28 @@ const onFileChange = async (event: Event): Promise<void> => {
const handleFile = async (file: File): Promise<void> => {
uploading.value = true;
const type = file.type;
const buffer = await file.arrayBuffer();
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
const filename = await uploadFile(encrypted);
console.log(iv, jsonWebKey);
const storedObject: StoredObjectCreated = {
filename: filename,
iv,
keyInfos: jsonWebKey,
type: type,
status: "stored_object_created",
// create a stored_object if not exists
let stored_object;
if (null === props.existingDoc) {
stored_object = await fetchNewStoredObject();
} else {
stored_object = props.existingDoc;
}
emit('addDocument', storedObject);
const buffer = await file.arrayBuffer();
const [encrypted, iv, jsonWebKey] = await encryptFile(buffer);
const filename = await uploadVersion(encrypted, stored_object);
const stored_object_version: StoredObjectVersionCreated = {
filename: filename,
iv: Array.from(iv),
keyInfos: jsonWebKey,
type: type,
persisted: false,
}
emit('addDocument', {stored_object, stored_object_version});
uploading.value = false;
}
@ -138,6 +143,11 @@ const handleFile = async (file: File): Promise<void> => {
flex-direction: column;
justify-content: center;
align-items: center;
p {
// require for display in DropFileModal
text-align: center;
}
}
& > .area {
@ -148,8 +158,4 @@ const handleFile = async (file: File): Promise<void> => {
}
}
}
div.chill-collection ul.list-entry li.entry:nth-child(2n) {
}
</style>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import {StoredObject, StoredObjectVersion} from "../../types";
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
import {computed, reactive} from "vue";
import {useToast} from 'vue-toast-notification';
interface DropFileConfig {
allowRemove: boolean,
existingDoc?: StoredObject,
}
const props = withDefaults(defineProps<DropFileConfig>(), {
allowRemove: false,
});
const emit = defineEmits<{
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
(e: 'removeDocument'): void
}>();
const $toast = useToast();
const state = reactive({showModal: false});
const modalClasses = {"modal-dialog-centered": true, "modal-md": true};
const buttonState = computed<'add'|'replace'>(() => {
if (props.existingDoc === undefined || props.existingDoc === null) {
return 'add';
}
return 'replace';
})
function onAddDocument({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void {
const message = buttonState.value === 'add' ? "Document ajouté" : "Document remplacé";
$toast.success(message);
emit('addDocument', {stored_object_version, stored_object});
state.showModal = false;
}
function onRemoveDocument(): void {
emit('removeDocument');
}
function openModal(): void {
state.showModal = true;
}
function closeModal(): void {
state.showModal = false;
}
</script>
<template>
<button v-if="buttonState === 'add'" @click="openModal" class="btn btn-create">Ajouter un document</button>
<button v-else @click="openModal" class="btn btn-edit">Remplacer le document</button>
<modal v-if="state.showModal" :modal-dialog-class="modalClasses" @close="closeModal">
<template v-slot:body>
<drop-file-widget :existing-doc="existingDoc" :allow-remove="allowRemove" @add-document="onAddDocument" @remove-document="onRemoveDocument" ></drop-file-widget>
</template>
</modal>
</template>
<style scoped lang="scss">
</style>

View File

@ -1,13 +1,13 @@
<script setup lang="ts">
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject, StoredObjectVersion} from "../../types";
import {computed, ref, Ref} from "vue";
import DropFile from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFile.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface DropFileConfig {
allowRemove: boolean,
existingDoc?: StoredObjectCreated|StoredObject,
existingDoc?: StoredObject,
}
const props = withDefaults(defineProps<DropFileConfig>(), {
@ -15,8 +15,8 @@ const props = withDefaults(defineProps<DropFileConfig>(), {
});
const emit = defineEmits<{
(e: 'addDocument', stored_object: StoredObjectCreated): void,
(e: 'removeDocument', stored_object: null): void
(e: 'addDocument', {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void,
(e: 'removeDocument'): void
}>();
const has_existing_doc = computed<boolean>(() => {
@ -45,14 +45,14 @@ const dav_link_href = computed<string|undefined>(() => {
return props.existingDoc._links?.dav_link?.href;
})
const onAddDocument = (s: StoredObjectCreated): void => {
emit('addDocument', s);
const onAddDocument = ({stored_object, stored_object_version}: {stored_object: StoredObject, stored_object_version: StoredObjectVersion}): void => {
emit('addDocument', {stored_object, stored_object_version});
}
const onRemoveDocument = (e: Event): void => {
e.stopPropagation();
e.preventDefault();
emit('removeDocument', null);
emit('removeDocument');
}
</script>

View File

@ -10,7 +10,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime";
import {reactive} from "vue";
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject} from "../../types";
interface ConvertButtonConfig {
storedObject: StoredObject,
@ -45,7 +45,7 @@ async function download_and_open(event: Event): Promise<void> {
</script>
<style scoped lang="sass">
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}

View File

@ -63,4 +63,7 @@ const editionUntilFormatted = computed<string>(() => {
.desktop-edit {
text-align: center;
}
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}
</style>

View File

@ -11,12 +11,13 @@
<script lang="ts" setup>
import {reactive, ref, nextTick, onMounted} from "vue";
import {build_download_info_link, download_and_decrypt_doc} from "./helpers";
import {download_and_decrypt_doc} from "./helpers";
import mime from "mime";
import {StoredObject, StoredObjectCreated} from "../../types";
import {StoredObject, StoredObjectVersion} from "../../types";
interface DownloadButtonConfig {
storedObject: StoredObject|StoredObjectCreated,
storedObject: StoredObject,
atVersion: StoredObjectVersion,
classes: { [k: string]: boolean },
filename?: string,
}
@ -33,8 +34,9 @@ const state: DownloadButtonState = reactive({is_ready: false, is_running: false,
const open_button = ref<HTMLAnchorElement | null>(null);
function buildDocumentName(): string {
const document_name = props.filename || 'document';
const ext = mime.getExtension(props.storedObject.type);
const document_name = props.filename ?? props.storedObject.title ?? 'document';
const ext = mime.getExtension(props.atVersion.type);
if (null !== ext) {
return document_name + '.' + ext;
@ -58,38 +60,26 @@ async function download_and_open(event: Event): Promise<void> {
return;
}
const urlInfo = build_download_info_link(props.storedObject.filename);
let raw;
try {
raw = await download_and_decrypt_doc(urlInfo, props.storedObject.keyInfos, new Uint8Array(props.storedObject.iv));
raw = await download_and_decrypt_doc(props.storedObject, props.atVersion);
} catch (e) {
console.error("error while downloading and decrypting document");
console.error(e);
throw e;
}
console.log('document downloading (and decrypting) successfully');
console.log('creating the url')
state.href_url = window.URL.createObjectURL(raw);
console.log('url created', state.href_url);
state.is_running = false;
state.is_ready = true;
console.log('new button marked as ready');
console.log('will click on button');
console.log('openbutton is now', open_button.value);
await nextTick();
console.log('next tick actions');
console.log('openbutton after next tick', open_button.value);
open_button.value?.click();
console.log('open button should have been clicked');
}
</script>
<style scoped lang="sass">
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}

View File

@ -8,7 +8,7 @@
<script lang="ts" setup>
import WopiEditButton from "./WopiEditButton.vue";
import {build_wopi_editor_link} from "./helpers";
import {StoredObject, StoredObjectCreated, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
import {StoredObject, WopiEditButtonExecutableBeforeLeaveFunction} from "../../types";
interface WopiEditButtonConfig {
storedObject: StoredObject,
@ -22,7 +22,6 @@ const props = defineProps<WopiEditButtonConfig>();
let executed = false;
async function beforeLeave(event: Event): Promise<true> {
console.log(executed);
if (props.executeBeforeLeave === undefined || executed === true) {
return Promise.resolve(true);
}
@ -39,7 +38,7 @@ async function beforeLeave(event: Event): Promise<true> {
}
</script>
<style scoped lang="sass">
<style scoped lang="scss">
i.fa::before {
color: var(--bs-dropdown-link-hover-color);
}

View File

@ -1,4 +1,5 @@
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange} from "../../types";
import {StoredObject, StoredObjectStatus, StoredObjectStatusChange, StoredObjectVersion} from "../../types";
import {makeFetch} from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
const MIMES_EDIT = new Set([
'application/vnd.ms-powerpoint',
@ -97,6 +98,13 @@ const MIMES_VIEW = new Set([
]
])
export interface SignedUrlGet {
method: 'GET'|'HEAD',
url: string,
expires: number,
object_name: string,
}
function is_extension_editable(mimeType: string): boolean {
return MIMES_EDIT.has(mimeType);
}
@ -109,8 +117,20 @@ function build_convert_link(uuid: string) {
return `/chill/wopi/convert/${uuid}`;
}
function build_download_info_link(object_name: string) {
return `/asyncupload/temp_url/generate/GET?object_name=${object_name}`;
function build_download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): string {
const url = `/api/1.0/doc-store/async-upload/temp_url/${storedObject.uuid}/generate/get`;
if (null !== atVersion) {
const params = new URLSearchParams({version: atVersion.filename});
return url + '?' + params.toString();
}
return url;
}
async function download_info_link(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<SignedUrlGet> {
return makeFetch('GET', build_download_info_link(storedObject, atVersion));
}
function build_wopi_editor_link(uuid: string, returnPath?: string) {
@ -131,43 +151,39 @@ function download_doc(url: string): Promise<Blob> {
});
}
async function download_and_decrypt_doc(urlGenerator: string, keyData: JsonWebKey, iv: Uint8Array): Promise<Blob>
async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: null|StoredObjectVersion): Promise<Blob>
{
const algo = 'AES-CBC';
// get an url to download the object
const downloadInfoResponse = await window.fetch(urlGenerator);
if (!downloadInfoResponse.ok) {
throw new Error("error while downloading url " + downloadInfoResponse.status + " " + downloadInfoResponse.statusText);
const atVersionToDownload = atVersion ?? storedObject.currentVersion;
if (null === atVersionToDownload) {
throw new Error("no version associated to stored object");
}
const downloadInfo = await downloadInfoResponse.json() as {url: string};
const downloadInfo= await download_info_link(storedObject, atVersionToDownload);
const rawResponse = await window.fetch(downloadInfo.url);
if (!rawResponse.ok) {
throw new Error("error while downloading raw file " + rawResponse.status + " " + rawResponse.statusText);
}
if (iv.length === 0) {
console.log('returning document immediatly');
if (atVersionToDownload.iv.length === 0) {
return rawResponse.blob();
}
console.log('start decrypting doc');
const rawBuffer = await rawResponse.arrayBuffer();
try {
const key = await window.crypto.subtle
.importKey('jwk', keyData, { name: algo }, false, ['decrypt']);
console.log('key created');
.importKey('jwk', atVersionToDownload.keyInfos, { name: algo }, false, ['decrypt']);
const iv = Uint8Array.from(atVersionToDownload.iv);
const decrypted = await window.crypto.subtle
.decrypt({ name: algo, iv: iv }, key, rawBuffer);
console.log('doc decrypted');
return Promise.resolve(new Blob([decrypted]));
} catch (e) {
console.error('get error while keys and decrypt operations');
console.error('encounter error while keys and decrypt operations');
console.error(e);
throw e;
@ -188,7 +204,6 @@ async function is_object_ready(storedObject: StoredObject): Promise<StoredObject
export {
build_convert_link,
build_download_info_link,
build_wopi_editor_link,
download_and_decrypt_doc,
download_doc,

View File

@ -1,174 +0,0 @@
<template>
<a :class="btnClasses" :title="$t(buttonTitle)" @click="openModal">
<span>{{ $t(buttonTitle) }}</span>
</a>
<teleport to="body">
<div>
<modal v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
@close="modal.showModal = false">
<template v-slot:header>
{{ $t('upload_a_document') }}
</template>
<template v-slot:body>
<div id="dropZoneWrapper" ref="dropZoneWrapper">
<div
data-stored-object="data-stored-object"
:data-label-preparing="$t('data_label_preparing')"
:data-label-quiet-button="$t('data_label_quiet_button')"
:data-label-ready="$t('data_label_ready')"
:data-dict-file-too-big="$t('data_dict_file_too_big')"
:data-dict-default-message="$t('data_dict_default_message')"
:data-dict-remove-file="$t('data_dict_remove_file')"
:data-dict-max-files-exceeded="$t('data_dict_max_files_exceeded')"
:data-dict-cancel-upload="$t('data_dict_cancel_upload')"
:data-dict-cancel-upload-confirm="$t('data_dict_cancel_upload_confirm')"
:data-dict-upload-canceled="$t('data_dict_upload_canceled')"
:data-dict-remove="$t('data_dict_remove')"
:data-allow-remove="!options.required"
data-temp-url-generator="/asyncupload/temp_url/generate/GET">
<input
type="hidden"
data-async-file-upload="data-async-file-upload"
data-generate-temp-url-post="/asyncupload/temp_url/generate/post?expires_delay=180&amp;submit_delay=3600"
data-temp-url-get="/asyncupload/temp_url/generate/GET"
:data-max-files="options.maxFiles"
:data-max-post-size="options.maxPostSize"
:v-model="dataAsyncFileUpload"
>
<input
type="hidden"
data-stored-object-key="1"
>
<input
type="hidden"
data-stored-object-iv="1"
>
<input
type="hidden"
data-async-file-type="1"
>
</div>
</div>
</template>
<template v-slot:footer>
<button class="btn btn-create"
@click.prevent="saveDocument">
{{ $t('action.add')}}
</button>
</template>
</modal>
</div>
</teleport>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
import { searchForZones } from '../../module/async_upload/uploader';
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
const i18n = {
messages: {
fr: {
upload_a_document: "Téléversez un document",
data_label_preparing: "Chargement...",
data_label_quiet_button: "Téléchargez le fichier existant",
data_label_ready: "Prêt à montrer",
data_dict_file_too_big: "Fichier trop volumineux",
data_dict_default_message: "Glissez votre fichier ou cliquez ici",
data_dict_remove_file: "Enlevez votre fichier pour en téléversez un autre",
data_dict_max_files_exceeded: "Nombre maximum de fichiers atteint. Enlevez les fichiers précédents",
data_dict_cancel_upload: "Annulez le téléversement",
data_dict_cancel_upload_confirm: "Êtes-vous sûr·e de vouloir annuler ce téléversement?",
data_dict_upload_canceled: "Téléversement annulé",
data_dict_remove: "Enlevez le fichier existant",
}
}
};
export default {
name: "AddAsyncUpload",
components: {
Modal
},
i18n,
props: {
buttonTitle: {
type: String,
default: 'Ajouter un document',
},
options: {
type: Object,
default: {
maxFiles: 1,
maxPostSize: 262144000, // 250MB
required: false,
}
},
btnClasses: {
type: Object,
default: {
btn: true,
'btn-create': true
}
}
},
emits: ['addDocument'],
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-dialog-centered modal-md"
},
}
},
updated() {
if (this.modal.showModal){
searchForZones(this.$refs.dropZoneWrapper);
}
},
methods: {
openModal() {
this.modal.showModal = true;
},
saveDocument() {
const dropzone = this.$refs.dropZoneWrapper;
if (dropzone) {
const inputKey = dropzone.querySelector('input[data-stored-object-key]');
const inputIv = dropzone.querySelector('input[data-stored-object-iv]');
const inputObject = dropzone.querySelector('input[data-async-file-upload]');
const inputType = dropzone.querySelector('input[data-async-file-type]');
const url = '/api/1.0/docstore/stored-object.json';
const body = {
filename: inputObject.value,
keyInfos: JSON.parse(inputKey.value),
iv: JSON.parse(inputIv.value),
type: inputType.value,
};
makeFetch('POST', url, body)
.then(r => {
this.$emit("addDocument", r);
this.modal.showModal = false;
})
.catch((error) => {
if (error.name === 'ValidationException') {
for (let v of error.violations) {
this.$toast.open({message: v });
}
} else {
console.error(error);
this.$toast.open({message: 'An error occurred'});
}
});
} else {
this.$toast.open({message: 'An error occurred - drop zone not found'});
}
}
}
}
</script>

View File

@ -1,45 +0,0 @@
<template>
<a
class="btn btn-download"
:title="$t(buttonTitle)"
:data-key=JSON.stringify(storedObject.keyInfos)
:data-iv=JSON.stringify(storedObject.iv)
:data-mime-type=storedObject.type
:data-label-preparing="$t('dataLabelPreparing')"
:data-label-ready="$t('dataLabelReady')"
:data-temp-url-get-generator="url"
@click.once="downloadDocument">
</a>
</template>
<script>
import { download } from '../../module/async_upload/downloader';
const i18n = {
messages: {
fr: {
dataLabelPreparing: "Chargement...",
dataLabelReady: "",
}
}
};
export default {
name: "AddAsyncUploadDownloader",
i18n,
props: [
'buttonTitle',
'storedObject'
],
computed: {
url() {
return `/asyncupload/temp_url/generate/GET?object_name=${this.storedObject.filename}`;
}
},
methods: {
downloadDocument(e) {
download(e.target);
}
}
}
</script>

View File

@ -12,37 +12,75 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepository;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
/**
* Implements the DenormalizerInterface and is responsible for denormalizing data into StoredObject objects.
*
* If a new StoredObjectVersion has been added to the StoredObject, the version is created here and registered
* to the StoredObject.
*/
class StoredObjectDenormalizer implements DenormalizerInterface
{
use ObjectToPopulateTrait;
public function __construct(private readonly StoredObjectRepository $storedObjectRepository) {}
public function __construct(private readonly StoredObjectRepositoryInterface $storedObjectRepository) {}
public function denormalize($data, $type, $format = null, array $context = [])
public function denormalize($data, $type, $format = null, array $context = []): ?StoredObject
{
$object = $this->extractObjectToPopulate(StoredObject::class, $context);
$storedObject = $this->extractObjectToPopulate(StoredObject::class, $context);
if (null !== $object) {
return $object;
if (null === $storedObject) {
if (array_key_exists('uuid', $data)) {
$storedObject = $this->storedObjectRepository->findOneByUUID($data['uuid']);
} else {
$storedObject = $this->storedObjectRepository->find($data['id']);
}
if (null === $storedObject) {
throw new LogicException('Object not found');
}
}
return $this->storedObjectRepository->find($data['id']);
$storedObject->setTitle($data['title'] ?? $storedObject->getTitle());
if (true === ($data['currentVersion']['persisted'] ?? true)) {
// nothing has change, stop here
return $storedObject;
}
if ([] !== $diff = array_diff(['filename', 'iv', 'keyInfos', 'type'], array_keys($data['currentVersion']))) {
throw new TransformationFailedException(sprintf('missing some keys in currentVersion: %s', implode(', ', $diff)));
}
$storedObject->registerVersion(
$data['currentVersion']['iv'],
$data['currentVersion']['keyInfos'],
$data['currentVersion']['type'],
$data['currentVersion']['filename']
);
return $storedObject;
}
public function supportsDenormalization($data, $type, $format = null)
public function supportsDenormalization($data, $type, $format = null): bool
{
if (false === \is_array($data)) {
if (StoredObject::class !== $type) {
return false;
}
if (false === \array_key_exists('id', $data)) {
if (false === is_array($data)) {
return false;
}
return StoredObject::class === $type;
if (array_key_exists('id', $data) || array_key_exists('uuid', $data)) {
return true;
}
return false;
}
}

View File

@ -89,7 +89,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
],
UrlGeneratorInterface::ABSOLUTE_URL,
),
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->format('U'),
'expiration' => $this->JWTDavTokenProvider->getTokenExpiration($accessToken)->getTimestamp(),
],
];
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class StoredObjectVersionNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, ?string $format = null, array $context = [])
{
if (!$object instanceof StoredObjectVersion) {
throw new \InvalidArgumentException('The object must be an instance of '.StoredObjectVersion::class);
}
return [
'id' => $object->getId(),
'filename' => $object->getFilename(),
'version' => $object->getVersion(),
'iv' => array_values($object->getIv()),
'keyInfos' => $object->getKeyInfos(),
'type' => $object->getType(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
];
}
public function supportsNormalization($data, ?string $format = null, array $context = [])
{
return $data instanceof StoredObjectVersion;
}
}

View File

@ -101,6 +101,37 @@ final class StoredObjectManager implements StoredObjectManagerInterface
return strlen($this->read($document));
}
/**
* @throws TransportExceptionInterface
* @throws StoredObjectManagerException
*/
public function exists(StoredObject|StoredObjectVersion $document): bool
{
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
if ($this->hasCache($version)) {
return true;
}
try {
$response = $this
->client
->request(
Request::METHOD_HEAD,
$this
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$version->getFilename()
)
->url
);
return 200 === $response->getStatusCode();
} catch (TransportExceptionInterface $exception) {
throw StoredObjectManagerException::errorDuringHttpRequest($exception);
}
}
public function etag(StoredObject|StoredObjectVersion $document): string
{
$version = $document instanceof StoredObject ? $document->getCurrentVersion() : $document;
@ -117,7 +148,7 @@ final class StoredObjectManager implements StoredObjectManagerInterface
->tempUrlGenerator
->generate(
Request::METHOD_HEAD,
$document->getFilename()
$version->getFilename()
)
->url
);
@ -214,6 +245,8 @@ final class StoredObjectManager implements StoredObjectManagerInterface
throw StoredObjectManagerException::invalidStatusCode($response->getStatusCode());
}
$this->clearCache();
return $version;
}

View File

@ -14,6 +14,7 @@ namespace Chill\DocStoreBundle\Service;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
interface StoredObjectManagerInterface
{
@ -27,6 +28,11 @@ interface StoredObjectManagerInterface
*/
public function getContentLength(StoredObject|StoredObjectVersion $document): int;
/**
* @throws TransportExceptionInterface
*/
public function exists(StoredObject|StoredObjectVersion $document): bool;
/**
* Get the content of a StoredObject.
*

View File

@ -14,19 +14,37 @@ namespace AsyncUpload\Driver\OpenstackObjectStore;
use Chill\DocStoreBundle\AsyncUpload\Driver\OpenstackObjectStore\TempUrlOpenstackGenerator;
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
use Chill\DocStoreBundle\AsyncUpload\SignedUrlPost;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* @internal
*
* @coversNothing
*/
class TempUrlOpenstackGeneratorTest extends TestCase
class TempUrlOpenstackGeneratorTest extends KernelTestCase
{
private ParameterBagInterface $parameterBag;
private HttpClientInterface $client;
private const TESTING_OBJECT_NAME_PREFIX = 'test-prefix-o0o008wk404gcos40k8s4s4c44cgwwos4k4o8k/';
private const TESTING_OBJECT_NAME = 'object-name-4fI0iAtq';
private function setUpIntegration(): void
{
self::bootKernel();
$this->parameterBag = self::getContainer()->get(ParameterBagInterface::class);
$this->client = self::getContainer()->get(HttpClientInterface::class);
}
/**
* @dataProvider dataProviderGenerate
*/
@ -175,4 +193,62 @@ class TempUrlOpenstackGeneratorTest extends TestCase
];
}
}
/**
* @group openstack-integration
*/
public function testGeneratePostIntegration(): void
{
$this->setUpIntegration();
$generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag);
$signedUrl = $generator->generatePost(object_name: self::TESTING_OBJECT_NAME_PREFIX);
$formData = new FormDataPart([
'redirect', $signedUrl->redirect,
'max_file_size' => (string) $signedUrl->max_file_size,
'max_file_count' => (string) $signedUrl->max_file_count,
'expires' => (string) $signedUrl->expires->getTimestamp(),
'signature' => $signedUrl->signature,
self::TESTING_OBJECT_NAME => DataPart::fromPath(
__DIR__.'/file-to-upload.txt',
self::TESTING_OBJECT_NAME
),
]);
$response = $this->client
->request(
'POST',
$signedUrl->url,
[
'body' => $formData->bodyToString(),
'headers' => $formData->getPreparedHeaders()->toArray(),
]
);
self::assertEquals(201, $response->getStatusCode());
}
/**
* @group openstack-integration
*
* @depends testGeneratePostIntegration
*/
public function testGenerateGetIntegration(): void
{
$this->setUpIntegration();
$generator = new TempUrlOpenstackGenerator(new NullLogger(), new EventDispatcher(), new MockClock(), $this->parameterBag);
$signedUrl = $generator->generate('GET', self::TESTING_OBJECT_NAME_PREFIX.self::TESTING_OBJECT_NAME);
$response = $this->client->request('GET', $signedUrl->url);
self::assertEquals(200, $response->getStatusCode());
try {
$content = $response->getContent();
self::assertEquals(file_get_contents(__DIR__.'/file-to-upload.txt'), $content);
} catch (HttpExceptionInterface $exception) {
$this->fail('could not retrieve file content: '.$exception->getMessage());
}
}
}

View File

@ -13,6 +13,7 @@ namespace Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\WebdavController;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\Argument;
@ -417,30 +418,32 @@ class WebdavControllerTest extends KernelTestCase
class MockedStoredObjectManager implements StoredObjectManagerInterface
{
public function getLastModified(StoredObject|\Chill\DocStoreBundle\Entity\StoredObjectVersion $document): \DateTimeInterface
public function getLastModified(StoredObject|StoredObjectVersion $document): \DateTimeInterface
{
return new \DateTimeImmutable('2023-09-13T14:15', new \DateTimeZone('+02:00'));
}
public function getContentLength(StoredObject|\Chill\DocStoreBundle\Entity\StoredObjectVersion $document): int
public function getContentLength(StoredObject|StoredObjectVersion $document): int
{
return 5;
}
public function read(StoredObject|\Chill\DocStoreBundle\Entity\StoredObjectVersion $document): string
public function read(StoredObject|StoredObjectVersion $document): string
{
return 'abcde';
}
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): \Chill\DocStoreBundle\Entity\StoredObjectVersion
public function write(StoredObject $document, string $clearContent, ?string $contentType = null): StoredObjectVersion
{
return $document->registerVersion();
}
public function etag(StoredObject|\Chill\DocStoreBundle\Entity\StoredObjectVersion $document): string
public function etag(StoredObject|StoredObjectVersion $document): string
{
return 'ab56b4d92b40713acc5af89985d4b786';
}
public function clearCache(): void {}
public function delete(StoredObjectVersion $storedObjectVersion): void {}
}

View File

@ -21,40 +21,6 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/
class StoredObjectTest extends KernelTestCase
{
public function testSaveHistory(): void
{
$storedObject = new StoredObject();
$storedObject
->registerVersion(
[2, 4, 6, 8],
['key' => ['data0' => 'data0']],
'text/html',
'test_0',
);
$storedObject->saveHistory();
$storedObject
->registerVersion(
[8, 10, 12],
['key' => ['data1' => 'data1']],
'text/text',
'test_1',
);
$storedObject->saveHistory();
self::assertEquals('test_0', $storedObject->getDatas()['history'][0]['filename']);
self::assertEquals([2, 4, 6, 8], $storedObject->getDatas()['history'][0]['iv']);
self::assertEquals(['key' => ['data0' => 'data0']], $storedObject->getDatas()['history'][0]['key_infos']);
self::assertEquals('text/html', $storedObject->getDatas()['history'][0]['type']);
self::assertEquals('test_1', $storedObject->getDatas()['history'][1]['filename']);
self::assertEquals([8, 10, 12], $storedObject->getDatas()['history'][1]['iv']);
self::assertEquals(['key' => ['data1' => 'data1']], $storedObject->getDatas()['history'][1]['key_infos']);
self::assertEquals('text/text', $storedObject->getDatas()['history'][1]['type']);
}
public function testRegisterVersion(): void
{
$object = new StoredObject();
@ -63,6 +29,9 @@ class StoredObjectTest extends KernelTestCase
['key' => ['some key']],
'text/html',
);
self::assertSame($firstVersion, $object->getCurrentVersion());
$version = $object->registerVersion(
[1, 2, 3, 4],
$k = ['key' => ['data0' => 'data0']],
@ -70,6 +39,8 @@ class StoredObjectTest extends KernelTestCase
'abcde',
);
self::assertSame($version, $object->getCurrentVersion());
self::assertCount(2, $object->getVersions());
self::assertEquals('abcde', $object->getFilename());
self::assertEquals([1, 2, 3, 4], $object->getIv());

View File

@ -15,11 +15,17 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Form\DataMapper\StoredObjectDataMapper;
use Chill\DocStoreBundle\Form\DataTransformer\StoredObjectDataTransformer;
use Chill\DocStoreBundle\Form\StoredObjectType;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectDenormalizer;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
use Chill\MainBundle\Templating\Entity\UserRender;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Form\PreloadedExtension;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@ -36,33 +42,41 @@ class StoredObjectTypeTest extends TypeTestCase
{
use ProphecyTrait;
private StoredObject $model;
protected function setUp(): void
{
$this->model = new StoredObject();
$this->model->registerVersion();
parent::setUp();
}
public function testChangeTitleValue(): void
{
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
{"datas":[],"filename":"","id":null,"iv":[],"keyInfos":[],"title":"","type":"","uuid":"3c6a28fe-f913-40b9-a201-5eccc4f2d312","status":"ready","createdAt":null,"createdBy":null,"creationDate":null,"_links":{"dav_link":{"href":"http:\/\/url\/fake","expiration":"1716889578"}}}
{"uuid":"9855d676-690b-11ef-88d3-9f5a4129a7b7"}
JSON];
$model = new StoredObject();
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
$form = $this->factory->create(StoredObjectType::class, $this->model, ['has_title' => true]);
$form->submit($formData);
$this->assertTrue($form->isSynchronized());
$this->assertEquals($newTitle, $model->getTitle());
$this->assertEquals($newTitle, $this->model->getTitle());
}
public function testReplaceByAnotherObject(): void
{
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","status":"object_store_created"}
{"uuid":"9855d676-690b-11ef-88d3-9f5a4129a7b7","currentVersion":{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html","persisted": false}}
JSON];
$model = new StoredObject();
$originalObjectId = spl_object_hash($model);
$form = $this->factory->create(StoredObjectType::class, $model, ['has_title' => true]);
$originalObjectId = spl_object_hash($this->model);
$form = $this->factory->create(StoredObjectType::class, $this->model, ['has_title' => true]);
$form->submit($formData);
$this->assertTrue($form->isSynchronized());
$model = $form->getData();
$this->assertEquals($originalObjectId, spl_object_hash($model));
$this->assertEquals('abcdef', $model->getFilename());
@ -71,6 +85,29 @@ class StoredObjectTypeTest extends TypeTestCase
$this->assertEquals($newTitle, $model->getTitle());
}
public function testNothingIsChanged(): void
{
$formData = ['title' => $newTitle = 'new title', 'stored_object' => <<<'JSON'
{"uuid":"9855d676-690b-11ef-88d3-9f5a4129a7b7","currentVersion":{"filename":"abcdef","iv":[10, 15, 20, 30],"keyInfos":[],"type":"text/html"}}
JSON];
$originalObjectId = spl_object_hash($this->model);
$originalVersion = $this->model->getCurrentVersion();
$originalFilename = $originalVersion->getFilename();
$originalKeyInfos = $originalVersion->getKeyInfos();
$form = $this->factory->create(StoredObjectType::class, $this->model, ['has_title' => true]);
$form->submit($formData);
$this->assertTrue($form->isSynchronized());
$model = $form->getData();
$this->assertEquals($originalObjectId, spl_object_hash($model));
$this->assertSame($originalVersion, $model->getCurrentVersion());
$this->assertEquals($originalFilename, $model->getCurrentVersion()->getFilename());
$this->assertEquals($originalKeyInfos, $model->getCurrentVersion()->getKeyInfos());
}
protected function getExtensions()
{
$jwtTokenProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
@ -84,6 +121,12 @@ class StoredObjectTypeTest extends TypeTestCase
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::cetera())->willReturn(true);
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$storedObjectRepository->findOneByUUID(Argument::type('string'))
->willReturn($this->model);
$userRender = $this->prophesize(UserRender::class);
$serializer = new Serializer(
[
new StoredObjectNormalizer(
@ -91,6 +134,9 @@ class StoredObjectTypeTest extends TypeTestCase
$urlGenerator->reveal(),
$security->reveal()
),
new StoredObjectDenormalizer($storedObjectRepository->reveal()),
new StoredObjectVersionNormalizer(),
new UserNormalizer($userRender->reveal(), new MockClock()),
],
[
new JsonEncoder(),

View File

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\StoredObjectRepositoryInterface;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectDenormalizer;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectDenormalizerTest extends TestCase
{
use ProphecyTrait;
public function testDenormalizeWithoutObjectToPopulateWithUUID(): void
{
$storedObject = new StoredObject();
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$storedObjectRepository->findOneByUUID($uuid = $storedObject->getUUID()->toString())
->shouldBeCalledOnce()
->willReturn($storedObject);
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
$actual = $denormalizer->denormalize(['uuid' => $uuid], 'json');
self::assertSame($storedObject, $actual);
}
public function testDenormalizeWithoutObjectToPopulateWithId(): void
{
$storedObject = new StoredObject();
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$storedObjectRepository->find($id = 1)
->shouldBeCalledOnce()
->willReturn($storedObject);
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
$actual = $denormalizer->denormalize(['id' => $id], 'json');
self::assertSame($storedObject, $actual);
}
public function testDenormalizeTitle(): void
{
$storedObject = new StoredObject();
$storedObject->setTitle('foo');
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
$actual = $denormalizer->denormalize([], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
self::assertEquals('foo', $actual->getTitle(), 'the title should remains the same');
$actual = $denormalizer->denormalize(['title' => 'bar'], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
self::assertEquals('bar', $actual->getTitle(), 'the title should have been updated');
}
public function testDenormalizeNoNewVersion(): void
{
$storedObject = new StoredObject();
$version = $storedObject->registerVersion();
$iv = $version->getIv();
$keyInfos = $version->getKeyInfos();
$type = $version->getType();
$filename = $version->getFilename();
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
$actual = $denormalizer->denormalize([
'currentVersion' => [
'iv' => $iv,
'keyInfos' => $keyInfos,
'type' => $type,
'filename' => $filename,
],
], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
self::assertSame($storedObject, $actual);
self::assertSame($version, $storedObject->getCurrentVersion());
self::assertEquals($iv, $version->getIv());
self::assertEquals($keyInfos, $version->getKeyInfos());
self::assertEquals($type, $version->getType());
self::assertEquals($filename, $version->getFilename());
}
public function testDenormalizeNewVersion(): void
{
$storedObject = new StoredObject();
$version = $storedObject->registerVersion();
$iv = ['1, 2, 3'];
$keyInfos = ['some-key' => 'some'];
$type = 'text/html';
$filename = 'Foo-Bar';
$storedObjectRepository = $this->prophesize(StoredObjectRepositoryInterface::class);
$denormalizer = new StoredObjectDenormalizer($storedObjectRepository->reveal());
$actual = $denormalizer->denormalize([
'currentVersion' => [
'iv' => $iv,
'keyInfos' => $keyInfos,
'type' => $type,
'filename' => $filename,
// this is the required key for new versions
'persisted' => false,
],
], StoredObject::class, 'json', [AbstractNormalizer::OBJECT_TO_POPULATE => $storedObject]);
self::assertSame($storedObject, $actual);
self::assertNotSame($version, $storedObject->getCurrentVersion());
$version = $storedObject->getCurrentVersion();
self::assertEquals($iv, $version->getIv());
self::assertEquals($keyInfos, $version->getKeyInfos());
self::assertEquals($type, $version->getType());
self::assertEquals($filename, $version->getFilename());
}
}

View File

@ -9,25 +9,32 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
namespace ChillDocStoreBundle\Tests\Serializer\Normalizer;
use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectVersionNormalizer;
use Chill\MainBundle\Serializer\Normalizer\UserNormalizer;
use Chill\MainBundle\Templating\Entity\UserRender;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class StoredObjectVersionNormalizerTest extends KernelTestCase
class StoredObjectVersionNormalizerTest extends TestCase
{
private NormalizerInterface $normalizer;
protected function setUp(): void
{
self::bootKernel();
$this->normalizer = self::getContainer()->get(NormalizerInterface::class);
$userRender = $this->createMock(UserRender::class);
$userRender->method('renderString')->willReturn('user');
$this->normalizer = new StoredObjectVersionNormalizer();
$this->normalizer->setNormalizer(new Serializer([new UserNormalizer($userRender, new MockClock())]));
}
public function testNormalize(): void
@ -58,4 +65,14 @@ class StoredObjectVersionNormalizerTest extends KernelTestCase
$actual
);
}
public function testNormalizeUnsupportedObject(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The object must be an instance of Chill\DocStoreBundle\Entity\StoredObjectVersion');
$unsupportedObject = new \stdClass();
$this->normalizer->normalize($unsupportedObject, 'json', ['groups' => ['read']]);
}
}

View File

@ -11,66 +11,43 @@ declare(strict_types=1);
namespace Chill\DocStoreBundle\Validator\Constraints;
use Chill\DocStoreBundle\AsyncUpload\Exception\BadCallToRemoteServer;
use Chill\DocStoreBundle\AsyncUpload\Exception\TempUrlRemoteServerException;
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class AsyncFileExistsValidator extends ConstraintValidator
{
public function __construct(
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
private readonly HttpClientInterface $client
private readonly StoredObjectManagerInterface $storedObjectManager,
) {}
public function validate($value, Constraint $constraint): void
{
if ($value instanceof StoredObject) {
$this->validateObject($value->getFilename(), $constraint);
} elseif (is_string($value)) {
$this->validateObject($value, $constraint);
} else {
throw new UnexpectedValueException($value, StoredObject::class.' or string');
}
}
protected function validateObject(string $file, Constraint $constraint): void
{
if (!$constraint instanceof AsyncFileExists) {
throw new UnexpectedTypeException($constraint, AsyncFileExists::class);
}
$urlHead = $this->tempUrlGenerator->generate(
'HEAD',
$file,
30
);
if (null === $value) {
return;
}
if ($value instanceof StoredObjectVersion) {
$this->validateObject($value, $constraint);
} elseif ($value instanceof StoredObject) {
$this->validateObject($value->getCurrentVersion(), $constraint);
} else {
throw new \Symfony\Component\Form\Exception\UnexpectedTypeException($value, StoredObjectVersion::class);
}
}
try {
$response = $this->client->request('HEAD', $urlHead->url);
if (404 === $status = $response->getStatusCode()) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ filename }}', $file)
->addViolation();
} elseif (500 <= $status) {
throw new TempUrlRemoteServerException($response->getStatusCode());
} elseif (400 <= $status) {
throw new BadCallToRemoteServer($response->getContent(false), $response->getStatusCode());
}
} catch (HttpExceptionInterface $exception) {
if (404 !== $exception->getResponse()->getStatusCode()) {
throw $exception;
}
} catch (TransportExceptionInterface $e) {
throw new TempUrlRemoteServerException(0, previous: $e);
protected function validateObject(StoredObjectVersion $file, AsyncFileExists $constraint): void
{
if (!$this->storedObjectManager->exists($file)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ filename }}', $file->getFilename())
->addViolation();
}
}
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Twig\Environment;
final class WorkflowAddSignatureController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly StoredObjectManagerInterface $storedObjectManager,
private readonly PDFSignatureZoneParser $PDFSignatureZoneParser,
private readonly NormalizerInterface $normalizer,
private readonly Environment $twig
) {}
#[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/sign', name: 'chill_main_workflow_signature', methods: 'GET')]
public function __invoke(int $signature_id, Request $request, WorkflowController $workflowController): Response
{
$signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id);
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
throw new NotFoundHttpException('No stored object found');
}
$zones = [];
$content = $this->storedObjectManager->read($storedObject);
if (null != $content) {
$zones = $this->PDFSignatureZoneParser->findSignatureZones($content);
}
$signatureClient = [];
$signatureClient['id'] = $signature->getId();
$signatureClient['storedObject'] = $this->normalizer->normalize($storedObject, 'json');
$signatureClient['zones'] = $zones;
return new Response($this->twig->render(
'@ChillMain/Workflow/_signature_sign.html.twig',
['signature' => $signatureClient]
));
}
}

View File

@ -11,8 +11,6 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\DocStoreBundle\Service\Signature\PDFSignatureZoneParser;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
@ -34,7 +32,6 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Registry;
@ -47,7 +44,6 @@ class WorkflowController extends AbstractController
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly EntityWorkflowRepository $entityWorkflowRepository,
private readonly ValidatorInterface $validator,
private readonly StoredObjectManagerInterface $storedObjectManagerInterface,
private readonly PaginatorFactory $paginatorFactory,
private readonly Registry $registry,
private readonly EntityManagerInterface $entityManager,
@ -55,7 +51,6 @@ class WorkflowController extends AbstractController
private readonly ChillSecurity $security,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
private readonly ClockInterface $clock,
private readonly PDFSignatureZoneParser $PDFSignatureZoneParser,
) {}
#[Route(path: '/{_locale}/main/workflow/create', name: 'chill_main_workflow_create')]
@ -417,36 +412,4 @@ class WorkflowController extends AbstractController
]
);
}
#[Route(path: '/{_locale}/main/workflow/signature/{signature_id}/sign', name: 'chill_main_workflow_signature', methods: 'GET')]
public function addSignature(int $signature_id, Request $request): Response
{
$signature = $this->entityManager->getRepository(EntityWorkflowStepSignature::class)->find($signature_id);
$entityWorkflow = $signature->getStep()->getEntityWorkflow();
$storedObject = $this->entityWorkflowManager->getAssociatedStoredObject($entityWorkflow);
if (null === $storedObject) {
throw new NotFoundHttpException('No stored object found');
}
$zones = [];
$content = $this->storedObjectManagerInterface->read($storedObject);
if (null != $content) {
$zones = $this->PDFSignatureZoneParser->findSignatureZones($content);
}
$signatureClient = [];
$signatureClient['id'] = $signature->getId();
$signatureClient['storedObject'] = [
'filename' => $storedObject->getFilename(),
'iv' => $storedObject->getIv(),
'keyInfos' => $storedObject->getKeyInfos(),
];
$signatureClient['zones'] = $zones;
return $this->render(
'@ChillMain/Workflow/_signature_sign.html.twig',
['signature' => $signatureClient]
);
}
}

View File

@ -140,14 +140,8 @@
@on-stored-object-status-change="onStatusDocumentChanged"
></document-action-buttons-group>
</li>
<li>
<add-async-upload
:buttonTitle="$t('replace')"
:options="asyncUploadOptions"
:btnClasses="{'btn': true, 'btn-edit': true}"
@addDocument="(arg) => replaceDocument(d, arg)"
>
</add-async-upload>
<li v-if="d.storedObject._permissions.canEdit">
<drop-file-modal :existing-doc="d.storedObject" :allow-remove="false" @add-document="(arg) => replaceDocument(d, arg.stored_object, arg.stored_object_version)"></drop-file-modal>
</li>
<li v-if="d.workflows.length === 0">
<a class="btn btn-delete" @click="removeDocument(d)">
@ -177,12 +171,7 @@
<label class="col-form-label">{{ $t('document_upload') }}</label>
<ul class="record_actions document-upload">
<li>
<add-async-upload
:buttonTitle="$t('browse')"
:options="asyncUploadOptions"
@addDocument="addDocument"
>
</add-async-upload>
<drop-file-modal :allow-remove="false" @add-document="addDocument"></drop-file-modal>
</li>
</ul>
</div>
@ -199,12 +188,11 @@ import ClassicEditor from 'ChillMainAssets/module/ckeditor5/editor_config';
import { mapGetters, mapState } from 'vuex';
import PickTemplate from 'ChillDocGeneratorAssets/vuejs/_components/PickTemplate.vue';
import {buildLink} from 'ChillDocGeneratorAssets/lib/document-generator';
import AddAsyncUpload from 'ChillDocStoreAssets/vuejs/_components/AddAsyncUpload.vue';
import AddAsyncUploadDownloader from 'ChillDocStoreAssets/vuejs/_components/AddAsyncUploadDownloader.vue';
import ListWorkflowModal from 'ChillMainAssets/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue';
import {buildLinkCreate} from 'ChillMainAssets/lib/entity-workflow/api.js';
import {buildLinkCreate as buildLinkCreateNotification} from 'ChillMainAssets/lib/entity-notification/api';
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import DropFileModal from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileModal.vue";
const i18n = {
messages: {
@ -243,10 +231,9 @@ export default {
name: "FormEvaluation",
props: ['evaluation', 'docAnchorId'],
components: {
DropFileModal,
ckeditor: CKEditor.component,
PickTemplate,
AddAsyncUpload,
AddAsyncUploadDownloader,
ListWorkflowModal,
DocumentActionButtonsGroup,
},
@ -380,21 +367,29 @@ export default {
const title = event.target.value;
this.$store.commit('updateDocumentTitle', {id: id, key: key, evaluationKey: this.evaluation.key, title: title});
},
addDocument(storedObject) {
addDocument({stored_object, stored_object_version}) {
let document = {
type: 'accompanying_period_work_evaluation_document',
storedObject: storedObject,
storedObject: stored_object,
title: 'Nouveau document',
};
this.$store.commit('addDocument', {key: this.evaluation.key, document: document});
this.$store.commit('addDocument', {key: this.evaluation.key, document, stored_object_version});
},
replaceDocument(oldDocument, storedObject) {
/**
* Replaces a document in the store with a new document.
*
* @param {Object} oldDocument - The document to be replaced.
* @param {StoredObject} storedObject - The stored object of the new document.
* @param {StoredObjectVersion} storedObjectVersion - The new version of the document
* @return {void}
*/
replaceDocument(oldDocument, storedObject, storedObjectVersion) {
let document = {
type: 'accompanying_period_work_evaluation_document',
storedObject: storedObject,
title: oldDocument.title
};
this.$store.commit('replaceDocument', {key: this.evaluation.key, document: document, oldDocument: oldDocument});
this.$store.commit('replaceDocument', {key: this.evaluation.key, document, oldDocument: oldDocument, stored_object_version: storedObjectVersion});
},
removeDocument(document) {
if (window.confirm("Êtes-vous sûr·e de vouloir supprimer le document qui a pour titre \"" + document.title +"\" ?")) {

View File

@ -219,7 +219,11 @@ const store = createStore({
found.results = found.results.filter(r => r.id !== result.id);
},
addDocument(state, payload) {
// associate version to stored object
payload.document.storedObject.currentVersion = payload.stored_object_version;
let evaluation = state.evaluationsPicked.find(e => e.key === payload.key);
evaluation.documents.push(Object.assign(
payload.document, {
key: evaluation.documents.length + 1,
@ -234,6 +238,13 @@ const store = createStore({
}
evaluation.documents = evaluation.documents.filter(d => d.key !== document.key);
},
/**
* Replaces a document in the state with a new document.
*
* @param {object} state - The current state of the application.
* @param {{key: number, oldDocument: {key: number}, stored_object_version: StoredObjectVersion}} payload - The object containing the information about the document to be replaced.
* @return {void} - returns nothing.
*/
replaceDocument(state, payload) {
let evaluation = state.evaluationsPicked.find(e => e.key === payload.key);
if (evaluation === undefined) {
@ -244,9 +255,10 @@ const store = createStore({
if (typeof doc === 'undefined') {
console.error('doc not found');
return;
}
doc.storedObject = payload.document.storedObject;
doc.storedObject.currentVersion = payload.stored_object_version;
return;
let newDocument = Object.assign(
payload.document, {

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"compilerOptions": {
"paths": {
"ChillMainAssets": ["./src/Bundle/ChillMainBundle/Resources/public"],
"ChillDocStoreAssets": ["./src/Bundle/ChillDocStoreBundle/Resources/public"]
},
"lib": [
"es2020",
"dom"
],
"module": "es6",
"moduleResolution": "node",
"isolatedModules": true,
"allowJs": false,
"checkJs": false,
"importHelpers": true,
"allowSyntheticDefaultImports": true,
"types": [
"node"
],
"sourceMap": true
},
"includes": [
"./src/**/*.ts",
"./src/**/*.vue"
],
"exclude": [
"./docs/*",
]
}