<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Controller;

use Contao\CoreBundle\Filesystem\VirtualFilesystemException;
use Contao\CoreBundle\Filesystem\VirtualFilesystemInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpFoundation\UriSigner;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;

#[Route(path: '/storage')]
final class StorageFileController extends AbstractController
{
    /**
     * MIME types that browsers may execute/render inline (script-capable).
     * These are always served as a download, never inline, to avoid stored
     * XSS via uploaded files (e.g. an SVG carrying inline <script>).
     */
    private const FORCE_DOWNLOAD_MIME_TYPES = [
        'image/svg+xml',
        'text/html',
        'text/xml',
        'application/xml',
        'application/xhtml+xml',
        'text/javascript',
        'application/javascript',
    ];

    public function __construct(
        private readonly ContainerInterface $locator,
        private readonly UriSigner $signer,
    ) {
    }

    #[Route(path: '/{storage}/{path}', name: 'storage_public_path', requirements: ['path' => '.+'])]
    public function __invoke(string $storage, string $path, #[MapQueryParameter('t')] string $validThrough, Request $request): Response
    {
        if (!$this->signer->checkRequest($request)) {
            throw $this->createAccessDeniedException('Hash invalid');
        }

        if (new \DateTimeImmutable($validThrough) < new \DateTimeImmutable()) {
            throw $this->createNotFoundException('Time invalid');
        }

        try {
            $storage = $this->getStorage($storage);
        } catch (ContainerExceptionInterface) {
            throw $this->createNotFoundException('Storage not found');
        }

        try {
            $file = $storage->get($path);
            // readStream must be inside the guard too: a DBAFS record can exist
            // while the underlying bytes are missing (e.g. the public media
            // symlink), which throws UnableToReadFile -> 500 otherwise.
            $stream = null === $file ? false : $storage->readStream($path);
        } catch (VirtualFilesystemException) {
            throw $this->createNotFoundException('File not found');
        }

        if (null === $file || false === $stream) {
            throw $this->createNotFoundException('File not found');
        }

        $response = new StreamedResponse(
            static function () use ($stream) {
                $outputStream = fopen('php://output', 'w');

                stream_copy_to_stream($stream, $outputStream);
            }
        );

        $mimeType = $file->getMimeType();

        $response->headers->set('Content-Transfer-Encoding', 'binary');
        $response->headers->set('Content-Type', $mimeType);
        $response->headers->set('Content-Length', (string) fstat($stream)['size']);
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
        // Prevent MIME sniffing so the declared Content-Type is authoritative.
        $response->headers->set('X-Content-Type-Options', 'nosniff');

        // Never render script-capable file types inline; force a download so a
        // malicious uploaded file cannot execute in the victim's session.
        if (\in_array(strtolower((string) $mimeType), self::FORCE_DOWNLOAD_MIME_TYPES, true)) {
            $filename = basename($path);
            $fallback = preg_replace('/[^\x20-\x7e]|[%\/\\\\]/', '_', $filename) ?: 'download';
            $response->headers->set('Content-Disposition', HeaderUtils::makeDisposition(
                HeaderUtils::DISPOSITION_ATTACHMENT,
                $filename,
                $fallback,
            ));
        }

        return $response;
    }

    /**
     * @throws ContainerExceptionInterface
     */
    private function getStorage(string $storage): VirtualFilesystemInterface
    {
        return $this->locator->get($storage);
    }
}
