Files
michaelschiemer/src/Framework/Filesystem/FileStorage.php
Michael Schiemer 3085739e34 feat(filesystem): introduce FileOwnership and ProcessUser value objects
- Add `FileOwnership` to encapsulate file owner and group information.
- Add `ProcessUser` to represent and manage system process user details.
- Enhance ownership matching and debugging with structured data objects.
- Include new documentation on file ownership handling and permission improvements.
- Prepare infrastructure for enriched error handling in filesystem operations.
2025-11-04 00:56:49 +01:00

445 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\Filesystem;
use App\Framework\Async\FiberManager;
use App\Framework\Core\PathProvider;
use App\Framework\DateTime\SystemClock;
use App\Framework\DateTime\SystemTimer;
use App\Framework\Filesystem\Exceptions\DirectoryCreateException;
use App\Framework\Filesystem\Exceptions\DirectoryListException;
use App\Framework\Filesystem\Exceptions\FileCopyException;
use App\Framework\Filesystem\Exceptions\FileDeleteException;
use App\Framework\Filesystem\Exceptions\FileMetadataException;
use App\Framework\Filesystem\Exceptions\FileNotFoundException;
use App\Framework\Filesystem\Exceptions\FilePermissionException;
use App\Framework\Filesystem\Exceptions\FileReadException;
use App\Framework\Filesystem\Exceptions\FileWriteException;
use App\Framework\Filesystem\Traits\AppendableStorageTrait;
use App\Framework\Filesystem\ValueObjects\FileMetadata;
use App\Framework\Filesystem\Traits\AtomicStorageTrait;
use App\Framework\Filesystem\Traits\CompressibleStorageTrait;
use App\Framework\Filesystem\Traits\LockableStorageTrait;
use App\Framework\Filesystem\Traits\StreamableStorageTrait;
use App\Framework\Core\ValueObjects\FileSize;
use App\Framework\Filesystem\ValueObjects\FileOperation;
use App\Framework\Filesystem\ValueObjects\FileOperationContext;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
final readonly class FileStorage implements Storage, AtomicStorage, AppendableStorage, StreamableStorage, LockableStorage, CompressibleStorage
{
use StorageTrait;
use AtomicStorageTrait;
use AppendableStorageTrait;
use StreamableStorageTrait;
use LockableStorageTrait;
use CompressibleStorageTrait;
public PermissionChecker $permissions;
public FiberManager $fiberManager;
public ?FileValidator $validator;
public ?Logger $logger;
public function __construct(
private string $basePath = '/',
?FiberManager $fiberManager = null,
?FileValidator $validator = null,
?Logger $logger = null,
?PathProvider $pathProvider = null
) {
// PathProvider aus DI Container nutzen, Fallback auf basePath
$pathProvider = $pathProvider ?? new PathProvider($this->basePath);
$this->permissions = new PermissionChecker($pathProvider);
$this->fiberManager = $fiberManager ?? $this->createDefaultFiberManager();
$this->validator = $validator;
$this->logger = $logger;
}
private function createDefaultFiberManager(): FiberManager
{
// Create minimal dependencies for FiberManager
$clock = new SystemClock();
$timer = new SystemTimer($clock);
return new FiberManager($clock, $timer);
}
private function resolvePath(string $path): string
{
// Absolute path bleibt unverändert
if (str_starts_with($path, '/')) {
return $path;
}
// Relative paths werden an basePath gehängt
return rtrim($this->basePath, '/') . '/' . ltrim($path, '/');
}
/**
* Log FileOperation context wenn Logger verfügbar
*/
private function logOperation(FileOperationContext $context): void
{
if ($this->logger === null) {
return;
}
$logContext = LogContext::fromArray($context->toArray());
if ($context->isHighSeverity()) {
$this->logger->framework->warning($context->toString(), $logContext);
} elseif ($context->isLargeOperation()) {
$this->logger->framework->info($context->toString(), $logContext);
} else {
$this->logger->framework->debug($context->toString(), $logContext);
}
}
public function get(string $path): string
{
$resolvedPath = $this->resolvePath($path);
// Validate with FileValidator if available
if ($this->validator !== null) {
$this->validator->validateRead($resolvedPath);
}
if (! is_file($resolvedPath)) {
throw new FileNotFoundException($path);
}
if (! is_readable($resolvedPath)) {
throw FilePermissionException::read($path, 'File is not readable', $this->permissions);
}
$content = @file_get_contents($resolvedPath);
if ($content === false) {
$error = error_get_last();
if ($error && str_contains($error['message'], 'Permission denied')) {
throw FilePermissionException::read($path, $error['message'], $this->permissions);
}
throw new FileReadException($path);
}
// Log successful read operation
$this->logOperation(FileOperationContext::forRead(
path: $resolvedPath,
bytesRead: FileSize::fromBytes(strlen($content))
));
return $content;
}
public function put(string $path, string $content): void
{
$resolvedPath = $this->resolvePath($path);
$dir = dirname($resolvedPath);
// Validate with FileValidator if available
if ($this->validator !== null) {
$fileSize = FileSize::fromBytes(strlen($content));
$this->validator->validateWrite($resolvedPath, $fileSize);
}
// Clear stat cache before directory operations to ensure fresh info
clearstatcache(true, $dir);
// Prüfe Directory-Permissions
if (! is_dir($dir)) {
if (! @mkdir($dir, 0777, true) && ! is_dir($dir)) {
$error = error_get_last();
if ($error && str_contains($error['message'], 'Permission denied')) {
throw FilePermissionException::createDirectory($dir, $error['message'], $this->permissions);
}
throw new DirectoryCreateException($dir);
}
} elseif (! is_writable($dir)) {
throw FilePermissionException::write($path, 'Directory is not writable: ' . $dir, $this->permissions);
}
// Prüfe File-Permissions wenn Datei bereits existiert
if (is_file($resolvedPath) && ! is_writable($resolvedPath)) {
throw FilePermissionException::write($path, 'File is not writable', $this->permissions);
}
if (@file_put_contents($resolvedPath, $content) === false) {
$error = error_get_last();
if ($error && str_contains($error['message'], 'Permission denied')) {
throw FilePermissionException::write($path, $error['message'], $this->permissions);
}
throw new FileWriteException($path);
}
// Set readable permissions for files (0644 = owner rw, group r, others r)
// This ensures cache files and other storage files are always readable
$this->permissions->setPermissions(
$path,
ValueObjects\FilePermissions::readWriteOwnerReadAll()
);
// Log successful write operation
$this->logOperation(FileOperationContext::forWrite(
path: $resolvedPath,
bytesWritten: FileSize::fromBytes(strlen($content))
));
}
public function exists(string $path): bool
{
return is_file($this->resolvePath($path));
}
public function delete(string $path): void
{
$resolvedPath = $this->resolvePath($path);
// Validate with FileValidator if available
if ($this->validator !== null) {
$this->validator->validatePath($resolvedPath);
$this->validator->validateExists($resolvedPath);
}
if (! is_file($resolvedPath)) {
throw new FileNotFoundException($path);
}
// Prüfe Directory-Permissions
// WICHTIG: Für unlink() braucht man nur Schreibrechte im Verzeichnis, nicht auf der Datei selbst!
$dir = dirname($resolvedPath);
if (! is_writable($dir)) {
throw FilePermissionException::delete($path, 'Directory is not writable: ' . $dir);
}
// ENTFERNT: Prüfe File-Permissions
// Für unlink() benötigt man nur Schreibrechte im Parent-Verzeichnis, nicht auf der Datei selbst.
// Dies ist ein Unix-Standard-Verhalten: unlink() entfernt einen Directory-Eintrag, nicht die Datei selbst.
// if (! is_writable($resolvedPath)) {
// throw FilePermissionException::delete($path, 'File is not writable');
// }
if (! @unlink($resolvedPath)) {
$error = error_get_last();
if ($error && str_contains($error['message'], 'Permission denied')) {
throw FilePermissionException::delete($path, $error['message'], $this->permissions);
}
throw new FileDeleteException($path);
}
// Log successful delete operation
$this->logOperation(FileOperationContext::forOperation(
operation: FileOperation::DELETE,
path: $resolvedPath
));
}
public function copy(string $source, string $destination): void
{
$resolvedSource = $this->resolvePath($source);
$resolvedDestination = $this->resolvePath($destination);
// Validate source with FileValidator if available
if ($this->validator !== null) {
$this->validator->validateRead($resolvedSource);
}
// Validate destination with FileValidator if available
if ($this->validator !== null) {
$this->validator->validatePath($resolvedDestination);
$this->validator->validateExtension($resolvedDestination);
}
if (! is_file($resolvedSource)) {
throw new FileNotFoundException($source);
}
// Stelle sicher, dass das Zielverzeichnis existiert
$dir = dirname($resolvedDestination);
if (! is_dir($dir)) {
$this->createDirectory($destination);
}
if (! @copy($resolvedSource, $resolvedDestination)) {
throw new FileCopyException($source, $destination);
}
// Log successful copy operation
$this->logOperation(FileOperationContext::forOperationWithDestination(
operation: FileOperation::COPY,
sourcePath: $resolvedSource,
destinationPath: $resolvedDestination
));
}
public function size(string $path): int
{
$resolvedPath = $this->resolvePath($path);
if (! is_file($resolvedPath)) {
throw new FileNotFoundException($path);
}
$size = @filesize($resolvedPath);
if ($size === false) {
throw new FileReadException($path);
}
return $size;
}
public function lastModified(string $path): int
{
$resolvedPath = $this->resolvePath($path);
if (! is_file($resolvedPath)) {
throw new FileNotFoundException($path);
}
$time = @filemtime($resolvedPath);
if ($time === false) {
throw new FileReadException($path);
}
return $time;
}
public function getMimeType(string $path): string
{
$resolvedPath = $this->resolvePath($path);
if (! is_file($resolvedPath)) {
throw new FileNotFoundException($path);
}
$mimeType = @mime_content_type($resolvedPath);
if ($mimeType === false) {
throw new FileMetadataException($path);
}
return $mimeType;
}
public function isReadable(string $path): bool
{
$resolvedPath = $this->resolvePath($path);
if (! is_file($resolvedPath)) {
throw new FileNotFoundException($path);
}
return is_readable($resolvedPath);
}
public function isWritable(string $path): bool
{
$resolvedPath = $this->resolvePath($path);
if (! is_file($resolvedPath)) {
throw new FileNotFoundException($path);
}
return is_writable($resolvedPath);
}
public function listDirectory(string $directory): array
{
$resolvedDirectory = $this->resolvePath($directory);
if (! is_dir($resolvedDirectory)) {
throw new DirectoryCreateException($directory);
}
$files = @scandir($resolvedDirectory);
if ($files === false) {
throw new DirectoryListException($directory);
}
// Entferne . und .. Einträge
$files = array_filter($files, function ($file) {
return $file !== '.' && $file !== '..';
});
// Relative Pfade zurückgeben (ohne basePath)
$result = [];
foreach ($files as $file) {
$fullPath = $resolvedDirectory . DIRECTORY_SEPARATOR . $file;
// Entferne basePath für relativen Pfad
$relativePath = str_starts_with($fullPath, $this->basePath)
? substr($fullPath, strlen(rtrim($this->basePath, '/')) + 1)
: $fullPath;
$result[] = $relativePath;
}
return array_values($result);
}
public function createDirectory(string $path, int $permissions = 0755, bool $recursive = true): void
{
$resolvedPath = $this->resolvePath($path);
if (is_dir($resolvedPath)) {
return; // Verzeichnis existiert bereits
}
// Prüfe Parent-Directory Permissions
$parentDir = dirname($resolvedPath);
if (is_dir($parentDir) && ! is_writable($parentDir)) {
throw FilePermissionException::createDirectory($path, 'Parent directory is not writable: ' . $parentDir, $this->permissions);
}
if (! @mkdir($resolvedPath, $permissions, $recursive) && ! is_dir($resolvedPath)) {
$error = error_get_last();
if ($error && str_contains($error['message'], 'Permission denied')) {
throw FilePermissionException::createDirectory($path, $error['message'], $this->permissions);
}
throw new DirectoryCreateException($path);
}
}
// Batch-Operationen für explizite Parallelverarbeitung
public function batch(array $operations): array
{
return $this->fiberManager->batch($operations);
}
public function getMultiple(array $paths): array
{
$operations = [];
foreach ($paths as $path) {
$operations[$path] = fn () => $this->get($path);
}
return $this->batch($operations);
}
public function putMultiple(array $files): void
{
$operations = [];
foreach ($files as $path => $content) {
$operations[$path] = fn () => $this->put($path, $content);
}
$this->batch($operations);
}
public function getMetadataMultiple(array $paths): array
{
$operations = [];
foreach ($paths as $path) {
$operations[$path] = fn () => new FileMetadata(
size: $this->size($path),
lastModified: $this->lastModified($path),
mimeType: $this->getMimeType($path),
isReadable: $this->isReadable($path),
isWritable: $this->isWritable($path)
);
}
return $this->batch($operations);
}
}