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:
Julien Fastré 2024-10-08 15:15:15 +02:00
parent 5c0f3cb317
commit 118ae291e2
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
10 changed files with 162 additions and 13 deletions

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<template>
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open($event)" title="T&#233;l&#233;charger">
<a v-if="!state.is_ready" :class="props.classes" @click="download_and_open()" title="T&#233;l&#233;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>

View File

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

View File

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

View File

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

View File

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

View File

@ -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, [

View File

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

View File

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