diff --git a/README.md b/README.md index 80f685a..e744961 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ composer require exelearning/elp-parser ## Usage +### Streams and uploads + +Projects can be parsed from PHP streams or in-memory bytes. Stream input is copied in chunks to a bounded temporary file because `ZipArchive` requires a filesystem path: + +```php +$stream = fopen($_FILES['project']['tmp_name'], 'rb'); +$parser = ELPParser::fromStream($stream, 'elpx'); + +$parserFromBytes = ELPParser::fromContents($bytes, 'elpx'); +``` + +Temporary files owned by parser instances are removed automatically. `inspectStream()` and `inspectContents()` provide the corresponding lightweight inspection APIs. + ### Lightweight inspection For cataloging or indexing, `inspect()` reads archive metadata and the project XML without normalizing pages, iDevices or assets: diff --git a/docs/api.md b/docs/api.md index 2655c95..3703cbe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,10 +12,26 @@ Create and immediately parse an eXeLearning project. Factory equivalent to the constructor. +#### `fromStream(mixed $stream, string $extension = 'elpx', ?ArchiveLimits $limits = null): ELPParser` + +Parse a readable PHP stream using bounded temporary-file spooling. + +#### `fromContents(string $contents, string $extension = 'elpx', ?ArchiveLimits $limits = null): ELPParser` + +Parse in-memory project bytes. + #### `inspect(string $filePath, ?ArchiveLimits $limits = null): array` Read core format/version/project metadata without fully normalizing pages, iDevices or assets. +#### `inspectStream(mixed $stream, string $extension = 'elpx', ?ArchiveLimits $limits = null): array` + +Lightweight inspection for stream input. + +#### `inspectContents(string $contents, string $extension = 'elpx', ?ArchiveLimits $limits = null): array` + +Lightweight inspection for in-memory project bytes. + ### Version and format - `getVersion(): int` — detected major version kept for backward compatibility. diff --git a/src/ELPParser.php b/src/ELPParser.php index b0df6d3..c1c5c57 100644 --- a/src/ELPParser.php +++ b/src/ELPParser.php @@ -24,6 +24,7 @@ use Exelearning\Parser\OdeParser; use Exelearning\Reference\InternalReferenceExtractor; use Exelearning\Support\ProjectInspector; +use Exelearning\Support\TemporaryProjectFile; use Exelearning\Support\VersionDetector; use Exelearning\Support\XmlLoader; use Exelearning\Validation\PackageValidator; @@ -99,6 +100,7 @@ class ELPParser implements JsonSerializable private ArchiveReader $archiveReader; private AssetReferenceExtractor $assetExtractor; private InternalReferenceExtractor $internalReferenceExtractor; + private ?string $ownedTemporaryFile = null; /** * Create a new parser instance. @@ -128,6 +130,70 @@ public static function fromFile(string $filePath, ?ArchiveLimits $limits = null) return new self($filePath, $limits); } + /** + * Create a parser from a readable PHP stream resource. + * + * @param mixed $stream Readable stream resource. + * @param string $extension Source extension used for format heuristics. + * @param ArchiveLimits|null $limits Optional archive safety limits. + * + * @return self + */ + public static function fromStream( + mixed $stream, + string $extension = 'elpx', + ?ArchiveLimits $limits = null + ): self { + $limits = $limits ?? new ArchiveLimits(); + $path = TemporaryProjectFile::fromStream( + $stream, + $extension, + $limits->maxTotalBytes + ); + + try { + $parser = new self($path, $limits); + $parser->ownedTemporaryFile = $path; + + return $parser; + } catch (\Throwable $exception) { + @unlink($path); + throw $exception; + } + } + + /** + * Create a parser from in-memory project bytes. + * + * @param string $contents Project bytes. + * @param string $extension Source extension used for format heuristics. + * @param ArchiveLimits|null $limits Optional archive safety limits. + * + * @return self + */ + public static function fromContents( + string $contents, + string $extension = 'elpx', + ?ArchiveLimits $limits = null + ): self { + $limits = $limits ?? new ArchiveLimits(); + $path = TemporaryProjectFile::fromContents( + $contents, + $extension, + $limits->maxTotalBytes + ); + + try { + $parser = new self($path, $limits); + $parser->ownedTemporaryFile = $path; + + return $parser; + } catch (\Throwable $exception) { + @unlink($path); + throw $exception; + } + } + /** * Inspect core project metadata without fully normalizing page content. * @@ -141,6 +207,62 @@ public static function inspect(string $filePath, ?ArchiveLimits $limits = null): return (new ProjectInspector($filePath, $limits))->inspect(); } + /** + * Inspect a project from a readable stream without full normalization. + * + * @param mixed $stream Readable stream resource. + * @param string $extension Source extension used for format heuristics. + * @param ArchiveLimits|null $limits Optional archive safety limits. + * + * @return array + */ + public static function inspectStream( + mixed $stream, + string $extension = 'elpx', + ?ArchiveLimits $limits = null + ): array { + $limits = $limits ?? new ArchiveLimits(); + $path = TemporaryProjectFile::fromStream( + $stream, + $extension, + $limits->maxTotalBytes + ); + + try { + return self::inspect($path, $limits); + } finally { + @unlink($path); + } + } + + /** + * Inspect in-memory project bytes without full normalization. + * + * @param string $contents Project bytes. + * @param string $extension Source extension used for format heuristics. + * @param ArchiveLimits|null $limits Optional archive safety limits. + * + * @return array + */ + public static function inspectContents( + string $contents, + string $extension = 'elpx', + ?ArchiveLimits $limits = null + ): array { + $limits = $limits ?? new ArchiveLimits(); + $path = TemporaryProjectFile::fromContents( + $contents, + $extension, + $limits->maxTotalBytes + ); + + try { + return self::inspect($path, $limits); + } finally { + @unlink($path); + } + } + /** * Detect the project format and parse its contents. * @@ -1538,4 +1660,15 @@ public function extract(string $destinationPath): void { $this->archiveReader->extract($destinationPath); } + + /** + * Remove any temporary project file owned by this parser instance. + */ + public function __destruct() + { + if ($this->ownedTemporaryFile !== null) { + @unlink($this->ownedTemporaryFile); + $this->ownedTemporaryFile = null; + } + } } diff --git a/src/Support/TemporaryProjectFile.php b/src/Support/TemporaryProjectFile.php new file mode 100644 index 0000000..61c3708 --- /dev/null +++ b/src/Support/TemporaryProjectFile.php @@ -0,0 +1,187 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\Support; + +use Exelearning\Exception\ElpParserException; +use Exelearning\Exception\ResourceLimitException; + +/** + * Spool project bytes to a bounded temporary file for ZipArchive-based parsing. + */ +final class TemporaryProjectFile +{ + private const CHUNK_BYTES = 1048576; + + /** + * Copy a readable stream to a temporary project file. + * + * @param mixed $stream Readable PHP stream resource. + * @param string $extension Desired temporary file extension. + * @param int $maxBytes Maximum compressed input bytes. + * + * @return string + */ + public static function fromStream( + mixed $stream, + string $extension, + int $maxBytes + ): string { + if (!is_resource($stream)) { + throw new ElpParserException('Project stream must be a readable resource.'); + } + + $metadata = stream_get_meta_data($stream); + if (($metadata['mode'] ?? '') === '') { + throw new ElpParserException('Unable to inspect project stream.'); + } + + $path = self::createPath($extension); + $target = fopen($path, 'wb'); + + if ($target === false) { + @unlink($path); + throw new ElpParserException('Unable to create temporary project file.'); + } + + $total = 0; + + try { + while (!feof($stream)) { + $chunk = fread($stream, self::CHUNK_BYTES); + + if ($chunk === false) { + throw new ElpParserException('Unable to read project stream.'); + } + + if ($chunk === '') { + continue; + } + + $total += strlen($chunk); + + if ($total > $maxBytes) { + throw new ResourceLimitException( + 'Project input exceeds the configured maximum input size.' + ); + } + + self::writeAll($target, $chunk); + } + } catch (\Throwable $exception) { + fclose($target); + @unlink($path); + throw $exception; + } + + fclose($target); + + return $path; + } + + /** + * Write in-memory bytes to a bounded temporary project file. + * + * @param string $contents Project bytes. + * @param string $extension Desired temporary file extension. + * @param int $maxBytes Maximum compressed input bytes. + * + * @return string + */ + public static function fromContents( + string $contents, + string $extension, + int $maxBytes + ): string { + if (strlen($contents) > $maxBytes) { + throw new ResourceLimitException( + 'Project input exceeds the configured maximum input size.' + ); + } + + $path = self::createPath($extension); + + if (file_put_contents($path, $contents) === false) { + @unlink($path); + throw new ElpParserException('Unable to write temporary project file.'); + } + + return $path; + } + + /** + * Normalize an extension for a temporary project filename. + * + * @param string $extension Requested extension. + * + * @return string + */ + private static function normalizeExtension(string $extension): string + { + $extension = strtolower(trim($extension)); + $extension = ltrim($extension, '.'); + $extension = preg_replace('/[^a-z0-9]+/', '', $extension) ?? ''; + + return $extension !== '' ? $extension : 'elpx'; + } + + /** + * Create a temporary project path with the requested extension. + * + * @param string $extension Requested extension. + * + * @return string + */ + private static function createPath(string $extension): string + { + $basePath = tempnam(sys_get_temp_dir(), 'elp-parser-'); + + if ($basePath === false) { + throw new ElpParserException('Unable to allocate temporary project file.'); + } + + $path = $basePath . '.' . self::normalizeExtension($extension); + + if (!rename($basePath, $path)) { + @unlink($basePath); + throw new ElpParserException('Unable to prepare temporary project file.'); + } + + return $path; + } + + /** + * Write an entire string to a stream, handling partial writes. + * + * @param resource $stream Target stream. + * @param string $data Bytes to write. + * + * @return void + */ + private static function writeAll($stream, string $data): void + { + $offset = 0; + $length = strlen($data); + + while ($offset < $length) { + $written = fwrite($stream, substr($data, $offset)); + + if ($written === false || $written === 0) { + throw new ElpParserException('Unable to write temporary project file.'); + } + + $offset += $written; + } + } +} diff --git a/tests/Unit/StreamInputTest.php b/tests/Unit/StreamInputTest.php new file mode 100644 index 0000000..80ddbc4 --- /dev/null +++ b/tests/Unit/StreamInputTest.php @@ -0,0 +1,105 @@ + + * @license MIT https://opensource.org/licenses/MIT + * @link https://github.com/exelearning/elp-parser + */ + +namespace Exelearning\ElpParser\Tests\Unit; + +use Exelearning\Archive\ArchiveLimits; +use Exelearning\ELPParser; +use Exelearning\Exception\ResourceLimitException; +use RuntimeException; + +it( + 'parses a project from in-memory bytes', + function () { + $path = __DIR__ . '/../Fixtures/propiedades.elpx'; + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException('Unable to read fixture.'); + } + + $parser = ELPParser::fromContents($contents, 'elpx'); + + expect($parser->getTitle())->toBe('propiedades'); + expect($parser->getSourceExtension())->toBe('elpx'); + expect($parser->getPackageProfile())->toBe('elpx-v4'); + } +); + +it( + 'parses a project from a readable stream', + function () { + $stream = fopen(__DIR__ . '/../Fixtures/04_La_Ilustracion.elp', 'rb'); + + if ($stream === false) { + throw new RuntimeException('Unable to open fixture stream.'); + } + + try { + $parser = ELPParser::fromStream($stream, 'elp'); + + expect($parser->getFormatFamily())->toBe('legacy'); + expect($parser->getSourceExtension())->toBe('elp'); + expect($parser->getVersion())->toBe(2); + } finally { + fclose($stream); + } + } +); + +it( + 'supports lightweight inspection from streams and contents', + function () { + $path = __DIR__ . '/../Fixtures/propiedades.elpx'; + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException('Unable to read fixture.'); + } + + $fromContents = ELPParser::inspectContents($contents, 'elpx'); + + $stream = fopen($path, 'rb'); + if ($stream === false) { + throw new RuntimeException('Unable to open fixture stream.'); + } + + try { + $fromStream = ELPParser::inspectStream($stream, 'elpx'); + } finally { + fclose($stream); + } + + expect($fromContents['title'])->toBe('propiedades'); + expect($fromStream['title'])->toBe('propiedades'); + expect($fromContents['packageProfile'])->toBe('elpx-v4'); + expect($fromStream['formatVersion'])->toBe('2.0'); + } +); + +it( + 'applies configured limits while spooling project input', + function () { + $path = __DIR__ . '/../Fixtures/propiedades.elpx'; + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException('Unable to read fixture.'); + } + + $limits = new ArchiveLimits(maxTotalBytes: 128); + + expect( + fn() => ELPParser::fromContents($contents, 'elpx', $limits) + )->toThrow(ResourceLimitException::class); + } +);