<?php declare(strict_types=1); /* * Copyright (C) 2015-2017 IRSTEA * All rights reserved. */ namespace Irstea\FileUploadBundle\Controller; use Irstea\FileUploadBundle\Entity\UploadedFile; use Irstea\FileUploadBundle\Exception\RejectedFileException; use Irstea\FileUploadBundle\Http\UploadedFileResponse; use Irstea\FileUploadBundle\Model\FileManagerInterface; use Irstea\FileUploadBundle\Model\UploadedFileInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Component\Templating\EngineInterface; /** * @Route("/api/files", service="irstea_file_upload.upload_controller") */ class UploadController extends Controller { public const CSRF_INTENTION = 'uploaded_file'; /** * @var FileManagerInterface */ protected $fileManager; /** * @var UrlGeneratorInterface */ protected $urlGenerator; /** * @var CsrfTokenManagerInterface */ protected $csrfTokenManager; /** * @var TokenStorageInterface */ protected $tokenStorage; /** * @var EngineInterface */ protected $templating; /** * UploadController constructor. * * @param FileManagerInterface $fileManager * @param UrlGeneratorInterface $urlGenerator * @param CsrfTokenManagerInterface $csrfTokenManager * @param TokenStorageInterface $tokenStorage * @param EngineInterface $templating */ public function __construct( FileManagerInterface $fileManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, TokenStorageInterface $tokenStorage, EngineInterface $templating ) { $this->fileManager = $fileManager; $this->urlGenerator = $urlGenerator; $this->csrfTokenManager = $csrfTokenManager; $this->tokenStorage = $tokenStorage; $this->templating = $templating; } /** * @Route("", name="file_upload_create") * @Method("POST") * @param Request $request * * @return JsonResponse */ public function createAction(Request $request) { try { $data = $request->request->get('file'); $token = $this->tokenStorage->getToken(); $file = $this->fileManager->create( $data['name'], $data['size'], $data['type'], $data['lastModified'] ?? null, null !== $token ? $token->getUsername() : null, $request->getClientIp() ); $parameters = ['id' => $file->getId()]; $deleteUrl = $this->urlGenerator->generate('file_upload_delete', $parameters); return $this->createResponse( Response::HTTP_CREATED, 'New file created', array_merge( $file->toArray(), [ 'put_url' => $this->urlGenerator->generate('file_upload_put_content', $parameters), 'delete_type' => 'DELETE', 'delete_url' => $deleteUrl, ] ), // On a pas de get pour l'instant, le DELETE et ce qui y ressemble le plus ['Location' => $deleteUrl] ); } catch (\Exception $ex) { return $this->createExceptionResponse($ex); } } /** * @Route("/{id}/content", name="file_upload_put_content") * @Method("PUT") * @param Request $request * @param UploadedFile $file */ public function putContentAction(Request $request, UploadedFile $file) { try { $this->validateCsrfToken($request); list($offset, $maxlen, $complete) = $this->handleRangeHeader($request); // Demande un filehandle plutôt que charger le contenu en mémoire $input = $request->getContent(true); $file->copyFrom($input, $maxlen, $offset); fclose($input); if ($complete) { return $this->completeUpload($file); } return $this->createResponse(Response::HTTP_OK, 'Chunk received'); } catch (\Exception $ex) { return $this->createExceptionResponse($ex); } } /** * @param Request $request * * @return array */ protected function handleRangeHeader(Request $request) { if (null === $range = $request->headers->get('Content-Range', null)) { return [0, PHP_INT_MAX, true]; } $matches = []; if (!preg_match('@^bytes (\d+)-(\d+)/(\d+)$@', $range, $matches)) { throw new BadRequestHttpException('Invalid Content-Range'); } $start = intval($matches[1]); $end = intval($matches[2]); $total = intval($matches[3]); if ($start < 0 || $start >= $end || $end >= $total) { throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE); } return [$start, 1 + ($end - $start), $end === ($total - 1)]; } /** * @param UploadedFileInterface $file * * @return Response */ protected function completeUpload(UploadedFileInterface $file) { try { $this->fileManager->completed($file); } catch (RejectedFileException $ex) { return $this->createResponse(Response::HTTP_FORBIDDEN, 'File rejected: ' . $ex->getMessage()); } $parameters = ['id' => $file->getId()]; $fileData = array_merge( $file->toArray(), [ 'url' => $this->urlGenerator->generate('file_upload_get_content', $parameters), 'delete_type' => 'DELETE', 'delete_url' => $this->urlGenerator->generate('file_upload_delete', $parameters), 'repr' => $this->templating->render( 'IrsteaFileUploadBundle:Extension:uploaded_file.html.twig', ['file' => $file->toArray()] ), ] ); return $this->createResponse(Response::HTTP_OK, 'File uploaded.', ['files' => [$fileData]]); } /** * @Route("/{id}/content", name="file_upload_get_content") * @Method("GET") * @param Request $request * @param UploadedFile $file */ public function getContentAction(Request $request, UploadedFile $file) { $this->validateCsrfToken($request); if (!$file->isValid()) { throw new NotFoundHttpException(); } $response = UploadedFileResponse::create($file, 200, [], false, ResponseHeaderBag::DISPOSITION_ATTACHMENT); $response->isNotModified($request); return $response; } /** * @Route("/{id}", name="file_upload_delete") * @Method("DELETE") * @param Request $request * @param UploadedFile $file */ public function deleteAction(Request $request, UploadedFile $file) { try { $this->validateCsrfToken($request); $this->fileManager->delete($file); return $this->createResponse(); } catch (\Exception $ex) { return $this->createExceptionResponse($ex); } } /** * @param Request $request * * @throws HttpException */ protected function validateCsrfToken(Request $request) { $token = $this->csrfTokenManager->getToken($request->query->get('token', null)); if (!$this->csrfTokenManager->isTokenValid($token)) { throw new HttpException(Response::HTTP_FORBIDDEN, 'Invalid CSRF token'); } } /** * @param int $status * @param string $message * @param array $data * @param array $headers * * @return JsonResponse */ protected function createResponse($status = Response::HTTP_OK, $message = 'OK', array $data = [], array $headers = []) { $data['status'] = $status; $data['message'] = $message; $response = new JsonResponse($data, $status, $headers); $response->setStatusCode($status, $message); return $response; } /** * @param \Exception $ex * * @return JsonResponse */ protected function createExceptionResponse(\Exception $ex) { return $this->createResponse( $ex instanceof HttpException ? $ex->getStatusCode() : Response::HTTP_INTERNAL_SERVER_ERROR, preg_replace("/[\n\r]+/", ' ', $ex->getMessage()), [ 'exception' => [ 'class' => get_class($ex), 'file' => $ex->getFile(), 'line' => $ex->getLine(), 'code' => $ex->getCode(), 'trace' => $ex->getTrace(), ], ] ); } }