Major additions: - Storage abstraction layer with filesystem and in-memory implementations - Gitea API integration with MCP tools for repository management - Console dialog mode with interactive command execution - WireGuard VPN DNS fix implementation and documentation - HTTP client streaming response support - Router generic result type - Parameter type validator for framework core Framework enhancements: - Console command registry improvements - Console dialog components - Method signature analyzer updates - Route mapper refinements - MCP server and tool mapper updates - Queue job chain and dependency commands - Discovery tokenizer improvements Infrastructure: - Deployment architecture documentation - Ansible playbook updates for WireGuard client regeneration - Production environment configuration updates - Docker Compose local configuration updates - Remove obsolete docker-compose.yml (replaced by environment-specific configs) Documentation: - PERMISSIONS.md for access control guidelines - WireGuard DNS fix implementation details - Console dialog mode usage guide - Deployment architecture overview Testing: - Multi-purpose attribute tests - Gitea Actions integration tests (typed and untyped)
361 lines
12 KiB
PHP
361 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Storage;
|
|
|
|
use App\Framework\Core\ValueObjects\FileSize;
|
|
use App\Framework\Core\ValueObjects\Hash;
|
|
use App\Framework\Core\ValueObjects\HashAlgorithm;
|
|
use App\Framework\Core\ValueObjects\Timestamp;
|
|
use App\Framework\Filesystem\Storage;
|
|
use App\Framework\Http\CustomMimeType;
|
|
use App\Framework\Http\MimeType;
|
|
use App\Framework\Storage\Exceptions\ObjectNotFoundException;
|
|
use App\Framework\Storage\Exceptions\StorageOperationException;
|
|
use App\Framework\Storage\ValueObjects\BucketName;
|
|
use App\Framework\Storage\ValueObjects\ObjectKey;
|
|
use App\Framework\Storage\ValueObjects\ObjectMetadata;
|
|
use DateInterval;
|
|
|
|
/**
|
|
* Filesystem-based Object Storage implementation
|
|
*
|
|
* Maps S3-style buckets/keys to filesystem directories/files:
|
|
* - Buckets = directories
|
|
* - Keys = files within bucket directories
|
|
*/
|
|
final readonly class FilesystemObjectStorage implements ObjectStorage, StreamableObjectStorage
|
|
{
|
|
private string $basePath;
|
|
public function __construct(
|
|
private Storage $storage,
|
|
string $basePath = '/'
|
|
) {
|
|
// Normalize base path
|
|
$this->basePath = rtrim($basePath, '/');
|
|
}
|
|
|
|
public function put(string $bucket, string $key, string $body, array $opts = []): ObjectInfo
|
|
{
|
|
$bucketName = BucketName::fromString($bucket);
|
|
$objectKey = ObjectKey::fromString($key);
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
try {
|
|
// Ensure bucket directory exists
|
|
$bucketPath = $this->buildBucketPath($bucket);
|
|
if (! $this->storage->exists($bucketPath)) {
|
|
$this->storage->createDirectory($bucketPath);
|
|
}
|
|
|
|
// Store content
|
|
$this->storage->put($path, $body);
|
|
|
|
// Get file metadata
|
|
$size = FileSize::fromBytes($this->storage->size($path));
|
|
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
|
|
$mimeTypeString = $this->storage->getMimeType($path);
|
|
|
|
// Generate ETag (SHA256 hash of content)
|
|
$etag = Hash::sha256($body);
|
|
|
|
// Convert MIME type string to Value Object
|
|
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
|
|
|
|
$metadata = ObjectMetadata::fromArray($opts['metadata'] ?? []);
|
|
|
|
return new ObjectInfo(
|
|
bucket: $bucketName,
|
|
key: $objectKey,
|
|
etag: $etag,
|
|
size: $size,
|
|
contentType: $contentType,
|
|
lastModified: $lastModified,
|
|
metadata: $metadata,
|
|
versionId: null
|
|
);
|
|
} catch (\Throwable $e) {
|
|
throw StorageOperationException::for('put', $bucket, $key, $e->getMessage(), $e);
|
|
}
|
|
}
|
|
|
|
public function get(string $bucket, string $key): string
|
|
{
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
try {
|
|
if (! $this->storage->exists($path)) {
|
|
throw ObjectNotFoundException::for($bucket, $key);
|
|
}
|
|
|
|
return $this->storage->get($path);
|
|
} catch (ObjectNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
throw StorageOperationException::for('get', $bucket, $key, $e->getMessage(), $e);
|
|
}
|
|
}
|
|
|
|
public function stream(string $bucket, string $key)
|
|
{
|
|
// Backward compatibility: returns temporary stream
|
|
return $this->openReadStream($bucket, $key);
|
|
}
|
|
|
|
public function getToStream(string $bucket, string $key, $destination, array $opts = []): int
|
|
{
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
try {
|
|
if (! $this->storage->exists($path)) {
|
|
throw ObjectNotFoundException::for($bucket, $key);
|
|
}
|
|
|
|
// Validate destination stream
|
|
if (! is_resource($destination)) {
|
|
throw StorageOperationException::for('getToStream', $bucket, $key, 'Invalid destination stream');
|
|
}
|
|
|
|
// Use Storage's readStream if available
|
|
if (method_exists($this->storage, 'readStream')) {
|
|
$sourceStream = $this->storage->readStream($path);
|
|
$bufferSize = $opts['bufferSize'] ?? 8192;
|
|
|
|
try {
|
|
$bytesWritten = stream_copy_to_stream($sourceStream, $destination, null, $bufferSize);
|
|
if ($bytesWritten === false) {
|
|
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to copy stream');
|
|
}
|
|
|
|
return $bytesWritten;
|
|
} finally {
|
|
fclose($sourceStream);
|
|
}
|
|
}
|
|
|
|
// Fallback: read content and write to stream
|
|
$content = $this->storage->get($path);
|
|
$bytesWritten = fwrite($destination, $content);
|
|
if ($bytesWritten === false) {
|
|
throw StorageOperationException::for('getToStream', $bucket, $key, 'Failed to write to destination stream');
|
|
}
|
|
|
|
return $bytesWritten;
|
|
} catch (ObjectNotFoundException $e) {
|
|
throw $e;
|
|
} catch (StorageOperationException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
throw StorageOperationException::for('getToStream', $bucket, $key, $e->getMessage(), $e);
|
|
}
|
|
}
|
|
|
|
public function putFromStream(string $bucket, string $key, $source, array $opts = []): ObjectInfo
|
|
{
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
try {
|
|
// Validate source stream
|
|
if (! is_resource($source)) {
|
|
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Invalid source stream');
|
|
}
|
|
|
|
// Ensure bucket directory exists
|
|
$bucketPath = $this->buildBucketPath($bucket);
|
|
if (! $this->storage->exists($bucketPath)) {
|
|
$this->storage->createDirectory($bucketPath);
|
|
}
|
|
|
|
// Use Storage's putStream if available
|
|
if (method_exists($this->storage, 'putStream')) {
|
|
$this->storage->putStream($path, $source);
|
|
} else {
|
|
// Fallback: read stream content and use put()
|
|
$content = stream_get_contents($source);
|
|
if ($content === false) {
|
|
throw StorageOperationException::for('putFromStream', $bucket, $key, 'Failed to read from source stream');
|
|
}
|
|
|
|
$this->storage->put($path, $content);
|
|
}
|
|
|
|
// Reuse head() logic to get ObjectInfo
|
|
return $this->head($bucket, $key);
|
|
} catch (StorageOperationException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
throw StorageOperationException::for('putFromStream', $bucket, $key, $e->getMessage(), $e);
|
|
}
|
|
}
|
|
|
|
public function openReadStream(string $bucket, string $key)
|
|
{
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
try {
|
|
if (! $this->storage->exists($path)) {
|
|
throw ObjectNotFoundException::for($bucket, $key);
|
|
}
|
|
|
|
// Use Storage's readStream if available
|
|
if (method_exists($this->storage, 'readStream')) {
|
|
return $this->storage->readStream($path);
|
|
}
|
|
|
|
// Fallback: create stream from file content
|
|
$content = $this->storage->get($path);
|
|
$stream = fopen('php://temp', 'r+');
|
|
if ($stream === false) {
|
|
throw StorageOperationException::for('openReadStream', $bucket, $key, 'Failed to create stream');
|
|
}
|
|
|
|
fwrite($stream, $content);
|
|
rewind($stream);
|
|
|
|
return $stream;
|
|
} catch (ObjectNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
throw StorageOperationException::for('openReadStream', $bucket, $key, $e->getMessage(), $e);
|
|
}
|
|
}
|
|
|
|
public function head(string $bucket, string $key): ObjectInfo
|
|
{
|
|
$bucketName = BucketName::fromString($bucket);
|
|
$objectKey = ObjectKey::fromString($key);
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
try {
|
|
if (! $this->storage->exists($path)) {
|
|
throw ObjectNotFoundException::for($bucket, $key);
|
|
}
|
|
|
|
$size = FileSize::fromBytes($this->storage->size($path));
|
|
$lastModified = Timestamp::fromTimestamp($this->storage->lastModified($path));
|
|
$mimeTypeString = $this->storage->getMimeType($path);
|
|
|
|
// Read content to generate ETag (could be optimized to read only if needed)
|
|
$content = $this->storage->get($path);
|
|
$etag = Hash::sha256($content);
|
|
|
|
// Convert MIME type string to Value Object
|
|
$contentType = MimeType::tryFrom($mimeTypeString) ?? CustomMimeType::fromString($mimeTypeString);
|
|
|
|
return new ObjectInfo(
|
|
bucket: $bucketName,
|
|
key: $objectKey,
|
|
etag: $etag,
|
|
size: $size,
|
|
contentType: $contentType,
|
|
lastModified: $lastModified,
|
|
metadata: ObjectMetadata::empty(),
|
|
versionId: null
|
|
);
|
|
} catch (ObjectNotFoundException $e) {
|
|
throw $e;
|
|
} catch (\Throwable $e) {
|
|
throw StorageOperationException::for('head', $bucket, $key, $e->getMessage(), $e);
|
|
}
|
|
}
|
|
|
|
public function delete(string $bucket, string $key): void
|
|
{
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
try {
|
|
if (! $this->storage->exists($path)) {
|
|
// Object doesn't exist, but that's OK for delete operations
|
|
return;
|
|
}
|
|
|
|
$this->storage->delete($path);
|
|
} catch (\Throwable $e) {
|
|
throw StorageOperationException::for('delete', $bucket, $key, $e->getMessage(), $e);
|
|
}
|
|
}
|
|
|
|
public function exists(string $bucket, string $key): bool
|
|
{
|
|
$path = $this->buildPath($bucket, $key);
|
|
|
|
return $this->storage->exists($path);
|
|
}
|
|
|
|
public function url(string $bucket, string $key): ?string
|
|
{
|
|
// Filesystem storage doesn't have public URLs
|
|
return null;
|
|
}
|
|
|
|
public function temporaryUrl(string $bucket, string $key, DateInterval $ttl, array $opts = []): string
|
|
{
|
|
// Filesystem storage doesn't support presigned URLs
|
|
throw StorageOperationException::for(
|
|
'temporaryUrl',
|
|
$bucket,
|
|
$key,
|
|
'Temporary URLs are not supported for filesystem storage'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build filesystem path for bucket/key
|
|
*/
|
|
private function buildPath(string $bucket, string $key): string
|
|
{
|
|
// Sanitize bucket name (prevent path traversal)
|
|
$bucket = $this->sanitizeBucketName($bucket);
|
|
$key = ltrim($key, '/');
|
|
|
|
// Sanitize key (prevent path traversal)
|
|
$key = $this->sanitizeKey($key);
|
|
|
|
return $this->basePath . '/' . $bucket . '/' . $key;
|
|
}
|
|
|
|
/**
|
|
* Build filesystem path for bucket directory
|
|
*/
|
|
private function buildBucketPath(string $bucket): string
|
|
{
|
|
$bucket = $this->sanitizeBucketName($bucket);
|
|
|
|
return $this->basePath . '/' . $bucket;
|
|
}
|
|
|
|
/**
|
|
* Sanitize bucket name to prevent path traversal
|
|
*/
|
|
private function sanitizeBucketName(string $bucket): string
|
|
{
|
|
// Remove any path separators and dangerous characters
|
|
$bucket = str_replace(['/', '\\', '..'], '', $bucket);
|
|
$bucket = trim($bucket, '.');
|
|
|
|
if ($bucket === '' || $bucket === '.') {
|
|
throw StorageOperationException::for('sanitize', $bucket, '', 'Invalid bucket name');
|
|
}
|
|
|
|
return $bucket;
|
|
}
|
|
|
|
/**
|
|
* Sanitize key to prevent path traversal
|
|
*/
|
|
private function sanitizeKey(string $key): string
|
|
{
|
|
// Remove leading slashes but preserve internal structure
|
|
$key = ltrim($key, '/');
|
|
|
|
// Prevent directory traversal
|
|
if (str_contains($key, '..')) {
|
|
throw StorageOperationException::for('sanitize', '', $key, 'Path traversal detected in key');
|
|
}
|
|
|
|
return $key;
|
|
}
|
|
}
|
|
|