<?php
namespace MedBrief\MSR\EventListener;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use Exception;
use MedBrief\MSR\Entity\BatchDocument;
use MedBrief\MSR\Entity\BatchRequest;
use MedBrief\MSR\Entity\Collection;
use MedBrief\MSR\Entity\Disc;
use MedBrief\MSR\Entity\Document;
use MedBrief\MSR\Entity\ProjectUser;
use MedBrief\MSR\Entity\User;
use MedBrief\MSR\Repository\BatchRequestRepository;
use MedBrief\MSR\Repository\CollectionRepository;
use MedBrief\MSR\Service\ArchiveProcessor\BatchDocumentArchiveProcessorService;
use MedBrief\MSR\Service\ArchiveProcessor\MedicalRecordArchiveProcessorService;
use MedBrief\MSR\Service\FilesizeHelperService;
use MedBrief\MSR\Service\Notification\UserNotificationService;
use MedBrief\MSR\Service\VirusScan\VirusScannerService;
use Oneup\UploaderBundle\Event\PostPersistEvent;
use Oneup\UploaderBundle\Event\PreUploadEvent;
use Oneup\UploaderBundle\Event\ValidationEvent;
use Oneup\UploaderBundle\Uploader\Exception\ValidationException;
use RuntimeException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Listener responsible for taking uploaded Archive files and assigning them to
* the appropriate Disc. The setup of this Listener is described in the
* OneupUploaderBundle documentation
* Listener responsible for handling custom processing for all files uploaded
* via Oneup uploader. Implemented as per:
* https://github.com/1up-lab/OneupUploaderBundle/blob/master/Resources/doc/custom_logic.md
*/
class OneupUploadListener
{
/**
* @var TokenStorageInterface
*/
public $securityToken;
/**
* @var EntityManagerInterface
*/
public $em;
/**
* @var VirusScannerService|object|null
*/
public $virusScanner;
// accepted mimetypes
// Alphabetized and trimmed this because it would have been wasting resources when we do checks with it - Stuart
private array $avMimeTypes = [
'audio/aiff',
'audio/amr',
'audio/basic',
'audio/it',
'audio/m4a',
'audio/make',
'audio/make.my.funk',
'audio/mid',
'audio/midi',
'audio/mod',
'audio/mp3',
'audio/mpeg',
'audio/mpeg3',
'audio/nspaudio',
'audio/s3m',
'audio/tsp-audio',
'audio/tsplayer',
'audio/vnd.qcelp',
'audio/voc',
'audio/voxware',
'audio/wav',
'audio/wma',
'audio/x-adpcm',
'audio/x-aiff',
'audio/x-au',
'audio/x-gsm',
'audio/x-jam',
'audio/x-liveaudio',
'audio/x-mid',
'audio/x-midi',
'audio/x-mod',
'audio/x-mpeg',
'audio/x-mpeg-3',
'audio/x-mpequrl',
'audio/x-nspaudio',
'audio/x-pn-realaudio',
'audio/x-pn-realaudio-plugin',
'audio/x-psid',
'audio/x-realaudio',
'audio/x-twinvq',
'audio/x-twinvq-plugin',
'audio/x-vnd.audioexplosion.mjuicemediafile',
'audio/x-voc',
'audio/x-wav',
'audio/xm',
'music/crescendo',
'music/x-karaoke',
'video/3gp',
'video/animaflex',
'video/avi',
'video/avs-video',
'video/dl',
'video/fli',
'video/flv',
'video/gl',
'video/m4v',
'video/mov',
'video/mp4',
'video/mpeg',
'video/mpg',
'video/msvideo',
'video/quicktime',
'video/vdo',
'video/vivo',
'video/vnd.rn-realvideo',
'video/vnd.vivo',
'video/vosaic',
'video/wmv',
'video/x-amt-demorun',
'video/x-amt-showrun',
'video/x-atomic3d-feature',
'video/x-dl',
'video/x-dv',
'video/x-fli',
'video/x-gl',
'video/x-isvideo',
'video/x-matroska',
'video/x-motion-jpeg',
'video/x-mpeg',
'video/x-mpeq2a',
'video/x-ms-asf',
'video/x-ms-asf-plugin',
'video/x-msvideo',
'video/x-qtc',
'video/x-scm',
'video/x-sgi-movie',
'video/x-ms-dvr',
];
// Accepted document types
// Adding this here because the above was just gross! - Stuart
private array $documentMimeTypes = [
'application/msword',
'application/pdf',
'application/vnd.ms-excel',
'application/vnd.ms-office',
'application/vnd.ms-powerpoint',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/x-pdf',
'application/encrypted',
];
// Accepted archive mime types
private array $archiveMimeTypes = [
'application/x-7z-compressed',
'application/zip',
// 'application/x-rar', We currently don't support RAR file uploads
];
// Accepted image mime types
private array $imageMimeTypes = [
'image/bmp',
'image/x-windows-bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/tiff',
'image/x-tiff',
'image/jpg',
];
// Accepted text or html mime types
private array $textOrHtmlMimeTypes = [
'text/html',
'text/htm',
'text/plain',
];
// Accepted eml or msg mime types
private array $emlOrMsgMimeTypes = [
'message/rfc822',
'application/vnd.ms-outlook',
'application/octet-stream',
];
private readonly CollectionRepository $collectionRepository;
private readonly BatchRequestRepository $batchRequestRepository;
public function __construct(
EntityManagerInterface $doctrine,
TokenStorageInterface $securityToken,
VirusScannerService $virusScanner,
private readonly MedicalRecordArchiveProcessorService $medicalRecordsArchiveProcessor,
private readonly FilesizeHelperService $filesizeHelper,
private readonly UserNotificationService $userNotificationService,
private readonly BatchDocumentArchiveProcessorService $batchDocumentArchiveProcessor,
EventDispatcherInterface $eventDispatcher,
private readonly AuthorizationCheckerInterface $authorizationChecker,
private readonly string $maxFileSize
) {
$this->em = $doctrine;
$this->securityToken = $securityToken;
$this->virusScanner = $virusScanner;
$this->collectionRepository = $this->em->getRepository(Collection::class);
$this->batchRequestRepository = $this->em->getRepository(BatchRequest::class);
}
/**
* This function is fired prior to the upload doing some stuff.
*
*
*
*
* @param PreUploadEvent $event
*
* @throws Exception
*/
public function preUpload(PreUploadEvent $event): void
{
// we grab the mapping, then based on the value we invoke the
// appropriate logic
$mapping = $event->getType();
if ($mapping === 'discArchive') {
$this->preUploadDiscArchive($event);
return;
}
if ($mapping === 'medicalRecord') {
$this->preUploadMedicalRecord($event);
return;
}
if ($mapping === 'batchDocument') {
$this->preUploadMedicalRecord($event);
return;
}
// This listener does not handle this type of file upload
}
/**
* Handle some pre upload processing for a zip archive uploaded to a disc
*
*
*
* @param PreUploadEvent $event
*
* @throws OptimisticLockException
*/
public function preUploadDiscArchive(PreUploadEvent $event): void
{
$request = $event->getRequest();
$uploadedFile = $event->getFile();
// grab the original filename of the uploaded file
$uploadName = $uploadedFile->getClientOriginalName();
// Strip the UUID
$strippedName = preg_filter('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}_/i', '', (string) $uploadName);
// If we have a string, use it, otherwise fall back to the uploaded filename
$originalFilename = $strippedName ?: $uploadName;
// set the original filename on the request, so we can use it in the onUpload post hook
$request->request->set('originalFilename', $originalFilename);
// grab the discID
$discId = $request->get('discId');
if (!$discId) {
throw new RuntimeException('Disc id is required.');
}
// grab the disc
/** @var Disc $disc */
$disc = $this->em->getRepository(Disc::class)->findOneById($discId);
if (!($disc instanceof Disc)) {
throw new RuntimeException('Disc id is required.');
}
// Check if the user is authorized to upload this disc
if ($this->authorizationChecker->isGranted('RADIOLOGY_ADMINISTRATION', $disc) === false && $this->authorizationChecker->isGranted('ADMINISTRATION', $disc) === false) {
throw new AccessDeniedException('User does not have permission to upload this disc.');
}
// set the original archive name on the disc - we need to do this because
// the vich uploader doesnt keep track or the original filename
$disc->setArchiveOriginalName($originalFilename);
$this->em->persist($disc);
$this->em->flush();
}
/**
* Handle some preprocessing for medical record PDF's uploaded to a collection
*
* @param PreUploadEvent $event
*/
public function preUploadMedicalRecord(PreUploadEvent $event): void
{
$request = $event->getRequest();
$uploadedFile = $event->getFile();
$collectionId = $request->get('collectionId');
if ($collectionId) {
$collection = $this->collectionRepository->find($collectionId);
if ($collection === null) {
throw new RuntimeException('Collection not found');
}
if (
$this->authorizationChecker->isGranted('MEDICAL_RECORDS_ADMINISTRATION', $collection->getProject()) === false
&& $this->authorizationChecker->isGranted('UPDATE', $collection) === false) {
throw new AccessDeniedException('User does not have permissions to upload to this collection.');
}
}
$batchRequestId = $request->get('batchRequestId');
if ($batchRequestId) {
$batchRequest = $this->batchRequestRepository->find($batchRequestId);
if ($batchRequest === null) {
throw new RuntimeException('BatchRequest not found');
}
if ($this->authorizationChecker->isGranted('UPLOAD_DOCUMENT', $batchRequest) === false) {
throw new AccessDeniedException('User does not have permissions to upload to this batch request.');
}
}
// grab the original filename of the uploaded file
$uploadName = $uploadedFile->getClientOriginalName();
// Strip the UUID
$strippedName = preg_filter('/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}_/i', '', (string) $uploadName);
// If we have a string, use it, otherwise fall back to the uploaded filename
$originalFilename = $strippedName ?: $uploadName;
// set the original filename on the request, so we can use it in the onUpload post hook
$request->request->set('originalFilename', $originalFilename);
}
/**
* @todo Ensure that this method is secure I.E. only users with appropriate
* authorization are able to upload disc archives.
* After a Zip archive has been successfully uploaded for a disc, we assign
* it to the Disc and update the status of the disk
*
* @param PostPersistEvent $event
*
* @throws Exception
*/
public function onUpload(PostPersistEvent $event): void
{
// grab the request so we can get the original file name
$request = $event->getRequest();
// prep an array that we will return
/** @var File $file */
$file = $event->getFile();
$fileResponse = [
'name' => $request->get('originalFilename'),
'size' => $file->getSize(),
];
// we grab the mapping, then based on the value we invoke the
// appropriate logic
$mapping = $event->getType();
// Only scan medical records on upload, discs are scanned offline to prevent delays for large uploads.
if ($mapping === 'medicalRecord') {
// Scan for viruses
$virusScanResult = $this->virusScanner->scanFile($file->getRealPath());
// Check if the file is infected. If the virus scan fails, the isFail flag will be true and logged.
// We will need to check for failed virus scans, but not prevent the upload in this case.
if ($virusScanResult->isInfected()) {
$fileResponse['status'] = 'fail';
$fileResponse['message'] = 'This file could be harmful to the system, and could not be uploaded. Please contact your MedBrief system administrator.';
unlink($file->getRealPath());
}
}
// If we already have a fileResponse status, the file has been set to fail
// by the virus scanner, and we don't want to process the file.
if (!isset($fileResponse['status'])) {
if ($mapping === 'discArchive') {
// At the moment we don't do any mimetype checking on zip files, but we should
$this->onUploadDiscArchive($event);
$fileResponse['status'] = 'success';
$fileResponse['message'] = 'Uploaded';
} elseif ($mapping === 'medicalRecord') {
// If the file uploaded is a zip archive, we process the the entire zip archive.
if (in_array($file->getMimeType(), $this->archiveMimeTypes)) {
if ($this->onUploadMedicalRecordArchive($event)) {
$fileResponse['status'] = 'success';
$fileResponse['message'] = 'Uploaded';
// We gots to do a bit here to get the right stuff for sending a notification
// @todo: make this a function
$uploadingUser = $this->securityToken->getToken()->getUser();
$collectionId = $request->get('collectionId');
/** @var Collection $collection */
$collection = $this->em->getRepository(Collection::class)->findOneById($collectionId);
$project = $collection->getProject();
$projectUser = $this->em->getRepository(ProjectUser::class)
->findOneBy([
'project' => $project,
'user' => $uploadingUser,
])
;
//checks if there is a project user and whether they have a private folder in the medical records page
if ($projectUser && $projectUser->getPrivateSandboxCollection() && $projectUser->getPrivateSandboxCollection()->containsCollectionRecursive($collection)) {
$this->userNotificationService->generateFileUploadByUserNotification($project, $uploadingUser);
}
} else {
$fileResponse['status'] = 'fail';
if ($this->medicalRecordsArchiveProcessor->hasError()) {
$fileResponse['message'] = $this->medicalRecordsArchiveProcessor->getError();
} else {
$fileResponse['message'] = 'There was a problem processing the ZIP file you uploaded.';
}
unlink($file->getRealPath());
}
} elseif (in_array($file->getMimeType(), $this->getMedicalRecordsMimeTypes())) {
// Also accepting the file if the mimetype appears in our accepted mimetypes
$this->onUploadMedicalRecord($event);
$fileResponse['status'] = 'success';
$fileResponse['message'] = 'Uploaded';
} else {
$fileResponse['status'] = 'fail';
$fileResponse['message'] = 'This file format is not supported. (File type detected: ' . $file->getMimeType() . ')';
unlink($file->getRealPath());
}
} elseif ($mapping === 'batchDocument') {
// If the file uploaded is a zip archive, we process the the entire zip archive.
if (in_array($file->getMimeType(), $this->archiveMimeTypes)) {
if ($this->onUploadBatchDocumentArchive($event)) {
$fileResponse['status'] = 'success';
$fileResponse['message'] = 'Uploaded';
} else {
$fileResponse['status'] = 'fail';
if ($this->batchDocumentArchiveProcessor->hasError()) {
$fileResponse['message'] = $this->batchDocumentArchiveProcessor->getError();
} else {
$fileResponse['message'] = 'There was a problem processing the ZIP file you uploaded.';
}
unlink($file->getRealPath());
}
} elseif (in_array($file->getMimeType(), $this->getBatchDocumentsMimeTypes())) {
// Also accepting the file if the mimetype appears in our accepted mimetypes
$this->onUploadBatchDocument($event);
$fileResponse['status'] = 'success';
$fileResponse['message'] = 'Uploaded';
} else {
$fileResponse['status'] = 'fail';
$fileResponse['message'] = 'File is not a valid document or image file. (File type detected: ' . $file->getMimeType() . ')';
unlink($file->getRealPath());
}
} else {
// This listener does not handle this type of file upload
$fileResponse['status'] = 'fail';
$fileResponse['message'] = 'File is not a valid PDF, audio, video or supported zip file. (File type detected: ' . $file->getMimeType() . ')';
unlink($file->getRealPath());
}
}
// Ensuring some JSON data is returned for each file upload call
// as per: https://github.com/1up-lab/OneupUploaderBundle/issues/42
// Note we should do this before any logic that will remove this file
$response = $event->getResponse();
$response['files'] = [$fileResponse];
}
/**
* Handle some post processing on zip archive uploaded to a disc
*
*
*
* @param PostPersistEvent $event
*
* @throws OptimisticLockException
*/
public function onUploadDiscArchive(PostPersistEvent $event): void
{
$request = $event->getRequest();
$discId = $request->get('discId');
// grab the original filename of the uploaded file
$originalFilename = $request->get('originalFilename');
// @todo Fail if no Original filename
if (!$discId) {
// @todo throw an error that disc id is required
}
// get the symfony File
/** @var File $uploadedFile */
$uploadedFile = $event->getFile();
// @todo throw an error if this is not a file
// find the disc
/** @var Disc $disc */
$disc = $this->em->getRepository(Disc::class)->findOneById($discId);
// @todo throw an error if we cannot find a disc
// create a new file to inject into the Disc
$fileToInject = new UploadedFile(
$uploadedFile->getRealPath(),
$originalFilename,
$uploadedFile->getMimeType(),
$uploadedFile->getSize(),
UPLOAD_ERR_OK,
false
);
$disc->setArchiveFile($fileToInject);
$disc->setUpdated(new DateTime('now')); // Rowan we only do this because we used to do it in the setArchiveFile() method, but now we dont.
$disc->setStatus(Disc::STATUS_PENDING_PROCESSING); // update the disc stats to say it is pending processing
// Use our filesize helper to determine the archive's filesize, as it handles
// files > 2GB correctly.
$archiveFilesize = $this->filesizeHelper->getFilesize($uploadedFile->getRealPath());
if ($archiveFilesize) {
$disc->setArchiveFilesize($archiveFilesize);
}
$this->em->persist($disc);
$this->em->flush();
// get rid of the original file provided that the injection went ok
unlink($uploadedFile->getRealPath());
}
/**
* Handles the upload of a single medical records file.
*
*
*
* @param PostPersistEvent $event
*
* @throws OptimisticLockException
* @throws Exception
*/
public function onUploadMedicalRecord(PostPersistEvent $event): void
{
$request = $event->getRequest();
$collectionId = $request->get('collectionId');
// grab the original filename of the uploaded file
$originalFilename = $request->get('originalFilename');
if (!$collectionId || empty($originalFilename)) {
throw new Exception('Collection or Filename not found for uploaded file');
}
// get the symfony File
$uploadedFile = $event->getFile();
// @todo throw an error if this is not a file
// find the collection
$collection = $this->em->getRepository(Collection::class)->findOneById($collectionId);
// @todo: We need to update the below because of SecurityContext being deprecated
// Let's grab the uploading user to a) set as the document creator and b) we need to see if they are uploading
// to their own folder.
$uploadingUser = $this->securityToken->getToken()->getUser();
$document = $this->medicalRecordsArchiveProcessor->createDocument(
$originalFilename,
$uploadedFile,
$collection,
$uploadingUser
);
$project = $document->getProject();
$projectUser = $this->em->getRepository(ProjectUser::class)
->findOneBy([
'project' => $project,
'user' => $uploadingUser,
])
;
if ($projectUser && $projectUser->getPrivateSandboxCollection()->containsDocumentRecursive($document)) {
$this->userNotificationService->generateFileUploadByUserNotification($project, $uploadingUser);
}
}
/**
* Handles the upload of a Medical Record Zip archive
*
*
*
*
* @param PostPersistEvent $event
*
* @throws Exception
*/
public function onUploadMedicalRecordArchive(PostPersistEvent $event): bool
{
$request = $event->getRequest();
$collectionId = $request->get('collectionId');
$originalFilename = $request->get('originalFilename');
if (!$collectionId || empty($originalFilename)) {
throw new Exception('Collection or Filename not found for uploaded file');
}
// Get the collection we are uploading to
$collection = $this->em->getRepository(Collection::class)->findOneById($collectionId);
// Process the zip archive
// !NB! We don't create activity feed items for Documents created via zip archive upload
// as activity feed functionality will be removed in a future release.
return $this->medicalRecordsArchiveProcessor->processArchive(
$event->getFile(),
$collection,
$this->securityToken->getToken()->getUser(),
$this->getMedicalRecordsMimeTypes()
);
}
/**
* Handles the upload of BatchDocument
*
*
*
*
* @param PostPersistEvent $event
*
* @throws Exception
*/
public function onUploadBatchDocument(PostPersistEvent $event): bool
{
$request = $event->getRequest();
$batchRequestId = $request->get('batchRequestId');
$batchRequest = $this->em->getRepository(BatchRequest::class)->find($batchRequestId);
// grab the original filename of the uploaded file
$originalFilename = $request->get('originalFilename');
if (empty($originalFilename)) {
throw new Exception('Collection or Filename not found for uploaded file');
}
// get the symfony File
$uploadedFile = $event->getFile();
$batchDocument = new BatchDocument();
$document = $this->medicalRecordsArchiveProcessor->createDocument(
$originalFilename,
$uploadedFile,
null,
$this->securityToken->getToken()->getUser(),
$batchRequest->getProject(),
true
);
$batchDocument->setDocument($document);
$batchDocument->setBatchRequest($batchRequest);
$this->em->persist($batchDocument);
$this->em->flush();
return true;
}
/**
* Handles the upload of a Batch Document Zip archive
*
*
*
*
* @param PostPersistEvent $event
*
* @throws Exception
*/
public function onUploadBatchDocumentArchive(PostPersistEvent $event): bool
{
$request = $event->getRequest();
$batchRequestId = $request->get('batchRequestId');
$batchRequest = $this->em->getRepository(BatchRequest::class)->find($batchRequestId);
$originalFilename = $request->get('originalFilename');
if (empty($originalFilename)) {
throw new Exception('Collection or Filename not found for uploaded file');
}
// Process the zip archive
return $this->batchDocumentArchiveProcessor->processArchive(
$event->getFile(),
$this->securityToken->getToken()->getUser(),
$this->getBatchDocumentsMimeTypes(),
$batchRequest
);
}
/**
* This function is fired prior to the upload doing some stuff.
*
* @param ValidationEvent $event
*/
public function onValidate(ValidationEvent $event): void
{
$event->getConfig();
$file = $event->getFile();
$event->getType();
$event->getRequest();
// check if the file has a valid size
$maxFileSize = $this->maxFileSize;
if ($file->getSize() > $maxFileSize) {
throw new ValidationException('medbrief.maxsize:' . $file->getClientOriginalName());
}
}
/**
* Returns an array of all the Mime Types accepted for Medical Record uploads
*/
private function getMedicalRecordsMimeTypes(): array
{
return array_merge($this->avMimeTypes, $this->documentMimeTypes, $this->imageMimeTypes, $this->textOrHtmlMimeTypes, $this->emlOrMsgMimeTypes);
}
/**
* Returns an array of all the Mime Types accepted for Batch Document uploads
*/
private function getBatchDocumentsMimeTypes(): array
{
return array_merge($this->documentMimeTypes, $this->imageMimeTypes);
}
}