diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 49283fa..1e5d10d 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -60,7 +60,14 @@ class FileController extends Controller { return new DataResponse(['error' => 'Invalid file'], Http::STATUS_BAD_REQUEST); } - $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]); + $mimeType = $file->getMimeType(); + + // Support Range requests for media files (video/audio) to enable seeking + if (str_starts_with($mimeType, 'video/') || str_starts_with($mimeType, 'audio/')) { + return $this->serveWithRangeSupport($file); + } + + $response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $mimeType]); $response->addHeader('Cache-Control', 'public, max-age=3600'); return $response; @@ -119,4 +126,74 @@ class FileController extends Controller { return new DataResponse(['error' => 'Error loading preview'], Http::STATUS_INTERNAL_SERVER_ERROR); } } + + /** + * Stream a media file with HTTP Range support for seeking + * + * Bypasses Nextcloud's response framework to stream directly, + * enabling proper range request handling for video/audio seeking. + * + * @return never + */ + private function serveWithRangeSupport(\OCP\Files\File $file): never { + $fileSize = $file->getSize(); + $mimeType = $file->getMimeType(); + + $rangeHeader = $this->request->getHeader('Range'); + $start = 0; + $end = $fileSize - 1; + $statusCode = Http::STATUS_OK; + + if ($rangeHeader && preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { + $start = (int)$matches[1]; + $end = $matches[2] !== '' ? (int)$matches[2] : $end; + $statusCode = Http::STATUS_PARTIAL_CONTENT; + } + + $length = $end - $start + 1; + + // Clear any previous output buffers + while (ob_get_level() > 0) { + ob_end_clean(); + } + + http_response_code($statusCode); + header('Content-Type: ' . $mimeType); + header('Content-Length: ' . $length); + header('Accept-Ranges: bytes'); + header('Cache-Control: public, max-age=3600'); + header('Content-Disposition: inline; filename="' . basename($file->getName()) . '"'); + + if ($statusCode === Http::STATUS_PARTIAL_CONTENT) { + header("Content-Range: bytes $start-$end/$fileSize"); + } + + // Stream the file in chunks + $handle = $file->fopen('r'); + if ($handle === false) { + http_response_code(500); + exit; + } + + if ($start > 0) { + fseek($handle, $start); + } + + $remaining = $length; + $chunkSize = 1024 * 1024; // 1MB chunks + + while ($remaining > 0 && !feof($handle)) { + $readSize = min($chunkSize, $remaining); + $data = fread($handle, $readSize); + if ($data === false) { + break; + } + echo $data; + flush(); + $remaining -= strlen($data); + } + + fclose($handle); + exit; + } } diff --git a/lib/Service/BBCodeService.php b/lib/Service/BBCodeService.php index 3a120a9..671edb2 100644 --- a/lib/Service/BBCodeService.php +++ b/lib/Service/BBCodeService.php @@ -462,7 +462,7 @@ class BBCodeService { return sprintf( '
' - . '
', diff --git a/tests/unit/Service/BBCodeServiceTest.php b/tests/unit/Service/BBCodeServiceTest.php index bd575bd..94aca92 100644 --- a/tests/unit/Service/BBCodeServiceTest.php +++ b/tests/unit/Service/BBCodeServiceTest.php @@ -252,6 +252,208 @@ class BBCodeServiceTest extends TestCase { $this->assertStringContainsString('Red text', $result); } + public function testParseYoutubeTagGeneratesCorrectIframe(): void { + $content = '[youtube]dQw4w9WgXcQ[/youtube]'; + + $result = $this->service->parse($content, []); + + $this->assertStringContainsString('assertStringContainsString('src="https://www.youtube.com/embed/dQw4w9WgXcQ"', $result); + $this->assertStringContainsString('allowfullscreen', $result); + $this->assertStringContainsString('class="youtube-player"', $result); + $this->assertStringContainsString('allow="accelerometer', $result); + } + + public function testParseYoutubeTagEscapesVideoId(): void { + $content = '[youtube][/youtube]'; + + $result = $this->service->parse($content, []); + + $this->assertStringNotContainsString('[/url]'; + $result = $this->service->parse($content, []); + + $this->assertStringNotContainsString('[/code]'; + $result = $this->service->parse($content, []); + + $this->assertStringNotContainsString('[/spoiler]'; + $result = $this->service->parse($content, []); + + $this->assertStringNotContainsString('[/youtube]'; + $result = $this->service->parse($content, []); + + $this->assertStringNotContainsString('[/raw]'; + $result = $this->service->parse($content, [$customTag]); + + $this->assertStringNotContainsString('', + 'file:///etc/passwd', + ]; + + foreach ($protocols as $protocol) { + $content = "[link=$protocol]click[/link]"; + $result = $this->service->parse($content, [$customTag]); + $this->assertStringContainsString('href=""', $result, "Dangerous protocol not blocked: $protocol"); + } + } + + public function testXssCssInjectionInColorParamIsStripped(): void { + $colorCode = $this->createBBCode('customcolor', '{content}', true, true); + $content = '[customcolor=red;font-weight:bold]text[/customcolor]'; + $result = $this->service->parse($content, [$colorCode]); + + $this->assertStringNotContainsString('font-weight:bold', $result); + } + private function createBBCode(string $tag, string $replacement, bool $enabled, bool $parseInner): BBCode { $bbCode = new BBCode(); $bbCode->setTag($tag);