mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 09:18:24 +00:00 
			
		
		
		
	Add direct download link feature with new button implementation
Introduce a new feature that allows for direct download links by integrating TempUrlGeneratorInterface. Added new DOWNLOAD_LINK_ONLY group and corresponding logic to generate download links in StoredObjectNormalizer. Implement a new Twig filter and Vue component for rendering the download button. Updated tests to cover the new functionality.
This commit is contained in:
		| @@ -0,0 +1,27 @@ | ||||
| import {_createI18n} from "../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n"; | ||||
| import {createApp} from "vue"; | ||||
| import DocumentActionButtonsGroup from "../../vuejs/DocumentActionButtonsGroup.vue"; | ||||
| import {StoredObject, StoredObjectStatusChange} from "../../types"; | ||||
| import {defineComponent} from "vue"; | ||||
| import DownloadButton from "../../vuejs/StoredObjectButton/DownloadButton.vue"; | ||||
| import ToastPlugin from "vue-toast-notification"; | ||||
|  | ||||
|  | ||||
|  | ||||
| const i18n = _createI18n({}); | ||||
|  | ||||
| window.addEventListener('DOMContentLoaded', function (e) { | ||||
|     document.querySelectorAll<HTMLDivElement>('div[data-download-button-single]').forEach((el) => { | ||||
|         const storedObject = JSON.parse(el.dataset.storedObject as string) as StoredObject; | ||||
|         const title = el.dataset.title as string; | ||||
|         const app = createApp({ | ||||
|             components: {DownloadButton}, | ||||
|             data() { | ||||
|                 return {storedObject, title, classes: {btn: true, "btn-outline-primary": true}}; | ||||
|             }, | ||||
|             template: '<download-button :stored-object="storedObject" :at-version="storedObject.currentVersion" :classes="classes" :filename="title" :direct-download="true"></download-button>', | ||||
|         }); | ||||
|  | ||||
|         app.use(i18n).use(ToastPlugin).mount(el); | ||||
|     }); | ||||
| }); | ||||
| @@ -2,6 +2,7 @@ import { | ||||
|     DateTime, | ||||
|     User, | ||||
| } from "../../../ChillMainBundle/Resources/public/types"; | ||||
| import {SignedUrlGet} from "./vuejs/StoredObjectButton/helpers"; | ||||
|  | ||||
| export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending"; | ||||
|  | ||||
| @@ -30,6 +31,7 @@ export interface StoredObject { | ||||
|             href: string; | ||||
|             expiration: number; | ||||
|         }; | ||||
|         downloadLink?: SignedUrlGet; | ||||
|     }; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|     <a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)" title="Télécharger"> | ||||
|     <a v-if="!state.is_ready" :class="props.classes" @click="download_and_open()" title="Télécharger"> | ||||
|         <i class="fa fa-download"></i> | ||||
|         <template v-if="displayActionStringInButton">Télécharger</template> | ||||
|     </a> | ||||
| @@ -20,7 +20,15 @@ interface DownloadButtonConfig { | ||||
|     atVersion: StoredObjectVersion, | ||||
|     classes: { [k: string]: boolean }, | ||||
|     filename?: string, | ||||
|     /** | ||||
|      * if true, display the action string into the button. If false, displays only | ||||
|      * the icon | ||||
|      */ | ||||
|     displayActionStringInButton: boolean, | ||||
|     /** | ||||
|      * if true, will download directly the file on load | ||||
|      */ | ||||
|     directDownload: boolean, | ||||
| } | ||||
|  | ||||
| interface DownloadButtonState { | ||||
| @@ -29,13 +37,17 @@ interface DownloadButtonState { | ||||
|     href_url: string, | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true}); | ||||
| const props = withDefaults(defineProps<DownloadButtonConfig>(), {displayActionStringInButton: true, directDownload: false}); | ||||
| const state: DownloadButtonState = reactive({is_ready: false, is_running: false, href_url: "#"}); | ||||
|  | ||||
| const open_button = ref<HTMLAnchorElement | null>(null); | ||||
|  | ||||
| function buildDocumentName(): string { | ||||
|     const document_name = props.filename ?? props.storedObject.title ?? 'document'; | ||||
|     let document_name = props.filename ?? props.storedObject.title; | ||||
|  | ||||
|     if ('' === document_name) { | ||||
|         document_name = 'document'; | ||||
|     } | ||||
|  | ||||
|     const ext = mime.getExtension(props.atVersion.type); | ||||
|  | ||||
| @@ -46,9 +58,7 @@ function buildDocumentName(): string { | ||||
|     return document_name; | ||||
| } | ||||
|  | ||||
| async function download_and_open(event: Event): Promise<void> { | ||||
|     const button = event.target as HTMLAnchorElement; | ||||
|  | ||||
| async function download_and_open(): Promise<void> { | ||||
|     if (state.is_running) { | ||||
|         console.log('state is running, aborting'); | ||||
|         return; | ||||
| @@ -75,11 +85,13 @@ async function download_and_open(event: Event): Promise<void> { | ||||
|     state.is_running = false; | ||||
|     state.is_ready = true; | ||||
|  | ||||
|     await nextTick(); | ||||
|     open_button.value?.click(); | ||||
|     console.log('open button should have been clicked'); | ||||
|     if (!props.directDownload) { | ||||
|         await nextTick(); | ||||
|         open_button.value?.click(); | ||||
|  | ||||
|     const timer = setTimeout(reset_state, 45000); | ||||
|         console.log('open button should have been clicked'); | ||||
|         setTimeout(reset_state, 45000); | ||||
|     } | ||||
| } | ||||
|  | ||||
| function reset_state(): void { | ||||
| @@ -87,10 +99,19 @@ function reset_state(): void { | ||||
|     state.is_ready = false; | ||||
|     state.is_running = false; | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|     if (props.directDownload) { | ||||
|         download_and_open(); | ||||
|     } | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| i.fa::before { | ||||
|    color: var(--bs-dropdown-link-hover-color); | ||||
| } | ||||
| i.fa { | ||||
|     margin-right: 0.5rem; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -161,7 +161,14 @@ async function download_and_decrypt_doc(storedObject: StoredObject, atVersion: n | ||||
|        throw new Error("no version associated to stored object"); | ||||
|    } | ||||
|  | ||||
|    const downloadInfo= await download_info_link(storedObject, atVersionToDownload); | ||||
|    // sometimes, the downloadInfo may be embedded into the storedObject | ||||
|     console.log('storedObject', storedObject); | ||||
|     let downloadInfo; | ||||
|     if (typeof storedObject._links !== 'undefined' && typeof storedObject._links.downloadLink !== 'undefined') { | ||||
|        downloadInfo = storedObject._links.downloadLink; | ||||
|     } else { | ||||
|        downloadInfo = await download_info_link(storedObject, atVersionToDownload); | ||||
|     } | ||||
|  | ||||
|    const rawResponse = await window.fetch(downloadInfo.url); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1 @@ | ||||
| <div data-download-button-single="data-download-button-single" data-stored-object="{{ document_json|json_encode|escape('html_attr') }}" data-title="{{ title|escape('html_attr') }}"></div> | ||||
| @@ -11,6 +11,7 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; | ||||
| @@ -30,10 +31,17 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa | ||||
| { | ||||
|     use NormalizerAwareTrait; | ||||
|  | ||||
|     /** | ||||
|      * when added to the groups, a download link is included in the normalization, | ||||
|      * and no webdav links are generated | ||||
|      */ | ||||
|     public const DOWNLOAD_LINK_ONLY = 'read:download-link-only'; | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly JWTDavTokenProviderInterface $JWTDavTokenProvider, | ||||
|         private readonly UrlGeneratorInterface $urlGenerator, | ||||
|         private readonly Security $security, | ||||
|         private readonly TempUrlGeneratorInterface $tempUrlGenerator, | ||||
|     ) {} | ||||
|  | ||||
|     public function normalize($object, ?string $format = null, array $context = []) | ||||
| @@ -55,6 +63,24 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa | ||||
|         // deprecated property | ||||
|         $datas['creationDate'] = $datas['createdAt']; | ||||
|  | ||||
|         if (array_key_exists(AbstractNormalizer::GROUPS, $context)) { | ||||
|             $groupsNormalized = is_array($context[AbstractNormalizer::GROUPS]) ? $context[AbstractNormalizer::GROUPS] : [$context[AbstractNormalizer::GROUPS]]; | ||||
|         } else { | ||||
|             $groupsNormalized = []; | ||||
|         } | ||||
|  | ||||
|         if (in_array(self::DOWNLOAD_LINK_ONLY, $groupsNormalized, true)) { | ||||
|             $datas['_permissions'] = [ | ||||
|                 'canSee' => true, | ||||
|                 'canEdit' => false, | ||||
|             ]; | ||||
|             $datas['_links'] = [ | ||||
|                 'downloadLink' => $this->normalizer->normalize($this->tempUrlGenerator->generate('GET', $object->getCurrentVersion()->getFilename(), 180), $format, [AbstractNormalizer::GROUPS => ['read']]), | ||||
|             ]; | ||||
|  | ||||
|             return $datas; | ||||
|         } | ||||
|  | ||||
|         $canSee = $this->security->isGranted(StoredObjectRoleEnum::SEE->value, $object); | ||||
|         $canEdit = $this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $object); | ||||
|  | ||||
|   | ||||
| @@ -28,6 +28,10 @@ class WopiEditTwigExtension extends AbstractExtension | ||||
|                 'needs_environment' => true, | ||||
|                 'is_safe' => ['html'], | ||||
|             ]), | ||||
|             new TwigFilter('chill_document_download_only_button', [WopiEditTwigExtensionRuntime::class, 'renderDownloadButton'], [ | ||||
|                 'needs_environment' => true, | ||||
|                 'is_safe' => ['html'], | ||||
|             ]), | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -15,6 +15,7 @@ use ChampsLibres\WopiLib\Contract\Service\Discovery\DiscoveryInterface; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; | ||||
| use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| @@ -177,6 +178,17 @@ final readonly class WopiEditTwigExtensionRuntime implements RuntimeExtensionInt | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|     public function renderDownloadButton(Environment $environment, StoredObject $storedObject, string $title = ''): string | ||||
|     { | ||||
|         return $environment->render( | ||||
|             '@ChillDocStore/Button/button_download.html.twig', | ||||
|             [ | ||||
|                 'document_json' => $this->normalizer->normalize($storedObject, 'json', [AbstractNormalizer::GROUPS => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]]), | ||||
|                 'title' => $title, | ||||
|             ] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function renderEditButton(Environment $environment, StoredObject $document, ?array $options = null): string | ||||
|     { | ||||
|         return $environment->render(self::TEMPLATE, [ | ||||
|   | ||||
| @@ -11,6 +11,8 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\DocStoreBundle\AsyncUpload\SignedUrl; | ||||
| use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; | ||||
| use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface; | ||||
| @@ -70,7 +72,9 @@ class StoredObjectNormalizerTest extends TestCase | ||||
|                 return ['sub' => 'sub']; | ||||
|             }); | ||||
|  | ||||
|         $normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security); | ||||
|         $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class); | ||||
|  | ||||
|         $normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator); | ||||
|         $normalizer->setNormalizer($globalNormalizer); | ||||
|  | ||||
|         $actual = $normalizer->normalize($storedObject, 'json'); | ||||
| @@ -95,4 +99,48 @@ class StoredObjectNormalizerTest extends TestCase | ||||
|         self::assertArrayHasKey('dav_link', $actual['_links']); | ||||
|         self::assertEqualsCanonicalizing(['href' => $davLink, 'expiration' => $d->getTimestamp()], $actual['_links']['dav_link']); | ||||
|     } | ||||
|  | ||||
|     public function testWithDownloadLinkOnly(): void | ||||
|     { | ||||
|         $storedObject = new StoredObject(); | ||||
|         $storedObject->registerVersion(); | ||||
|         $storedObject->setTitle('test'); | ||||
|         $reflection = new \ReflectionClass(StoredObject::class); | ||||
|         $idProperty = $reflection->getProperty('id'); | ||||
|         $idProperty->setValue($storedObject, 1); | ||||
|  | ||||
|         $jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class); | ||||
|         $jwtProvider->expects($this->never())->method('createToken')->withAnyParameters(); | ||||
|  | ||||
|         $urlGenerator = $this->createMock(UrlGeneratorInterface::class); | ||||
|         $urlGenerator->expects($this->never())->method('generate'); | ||||
|  | ||||
|         $security = $this->createMock(Security::class); | ||||
|         $security->expects($this->never())->method('isGranted'); | ||||
|  | ||||
|         $globalNormalizer = $this->createMock(NormalizerInterface::class); | ||||
|         $globalNormalizer->expects($this->exactly(4))->method('normalize') | ||||
|             ->withAnyParameters() | ||||
|             ->willReturnCallback(function (?object $object, string $format, array $context) { | ||||
|                 if (null === $object) { | ||||
|                     return null; | ||||
|                 } | ||||
|  | ||||
|                 return ['sub' => 'sub']; | ||||
|             }); | ||||
|  | ||||
|         $tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class); | ||||
|         $tempUrlGenerator->expects($this->once())->method('generate')->with('GET', $storedObject->getCurrentVersion()->getFilename(), $this->isType('int')) | ||||
|             ->willReturn(new SignedUrl('GET', 'https://some-link/test', new \DateTimeImmutable('300 seconds'), $storedObject->getCurrentVersion()->getFilename())); | ||||
|  | ||||
|         $normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator); | ||||
|         $normalizer->setNormalizer($globalNormalizer); | ||||
|  | ||||
|         $actual = $normalizer->normalize($storedObject, 'json', ['groups' => ['read', 'read:download-link-only']]); | ||||
|  | ||||
|         self::assertIsArray($actual); | ||||
|         self::arrayHasKey('_links', $actual); | ||||
|         self::assertArrayHasKey('downloadLink', $actual['_links']); | ||||
|         self::assertEquals(['sub' => 'sub'], $actual['_links']['downloadLink']); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -5,5 +5,6 @@ module.exports = function(encore) | ||||
|     }); | ||||
|     encore.addEntry('mod_async_upload', __dirname + '/Resources/public/module/async_upload/index.ts'); | ||||
|     encore.addEntry('mod_document_action_buttons_group', __dirname + '/Resources/public/module/document_action_buttons_group/index'); | ||||
|     encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index.ts'); | ||||
|     encore.addEntry('mod_document_download_button', __dirname + '/Resources/public/module/button_download/index'); | ||||
|     encore.addEntry('vue_document_signature', __dirname + '/Resources/public/vuejs/DocumentSignature/index'); | ||||
| }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user