feat: add comprehensive framework features and deployment improvements

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)
This commit is contained in:
2025-11-04 20:39:48 +01:00
parent 700fe8118b
commit 3ed2685e74
80 changed files with 9891 additions and 850 deletions

View File

@@ -0,0 +1,360 @@
<?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;
}
}