feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready
This commit is contained in:
71
src/Framework/Http/CustomMimeType.php
Normal file
71
src/Framework/Http/CustomMimeType.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
/**
|
||||
* Value Object for custom/unknown MIME types not in the MimeType enum
|
||||
*/
|
||||
final readonly class CustomMimeType implements MimeTypeInterface
|
||||
{
|
||||
private string $primaryType;
|
||||
private string $subType;
|
||||
|
||||
public function __construct(
|
||||
private string $value
|
||||
) {
|
||||
$this->validate($value);
|
||||
[$this->primaryType, $this->subType] = $this->parse($value);
|
||||
}
|
||||
|
||||
public static function fromString(string $mimeType): self
|
||||
{
|
||||
return new self($mimeType);
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getPrimaryType(): string
|
||||
{
|
||||
return $this->primaryType;
|
||||
}
|
||||
|
||||
public function getSubType(): string
|
||||
{
|
||||
return $this->subType;
|
||||
}
|
||||
|
||||
public function equals(MimeTypeInterface $other): bool
|
||||
{
|
||||
return $this->value === $other->getValue();
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
private function validate(string $value): void
|
||||
{
|
||||
if (empty($value)) {
|
||||
throw new \InvalidArgumentException('MIME type cannot be empty');
|
||||
}
|
||||
|
||||
if (!str_contains($value, '/')) {
|
||||
throw new \InvalidArgumentException("Invalid MIME type format: {$value}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function parse(string $value): array
|
||||
{
|
||||
$parts = explode('/', $value, 2);
|
||||
return [$parts[0], $parts[1]];
|
||||
}
|
||||
}
|
||||
35
src/Framework/Http/FilesystemMimeTypeDetector.php
Normal file
35
src/Framework/Http/FilesystemMimeTypeDetector.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
|
||||
/**
|
||||
* Production MIME type detector using PHP's mime_content_type()
|
||||
*
|
||||
* Detects MIME types from actual file content, returns MimeType enum if known
|
||||
* or CustomMimeType for unknown types
|
||||
*/
|
||||
final readonly class FilesystemMimeTypeDetector implements MimeTypeDetector
|
||||
{
|
||||
public function detect(FilePath $filePath): MimeTypeInterface
|
||||
{
|
||||
if (!$filePath->exists()) {
|
||||
throw new \RuntimeException("File does not exist: {$filePath}");
|
||||
}
|
||||
|
||||
$mimeTypeString = mime_content_type($filePath->toString());
|
||||
|
||||
if ($mimeTypeString === false) {
|
||||
throw new \RuntimeException("Could not detect MIME type for: {$filePath}");
|
||||
}
|
||||
|
||||
// Try to map to known MimeType enum
|
||||
$mimeType = MimeType::tryFromString($mimeTypeString);
|
||||
|
||||
// If not found in enum, create CustomMimeType
|
||||
return $mimeType ?? CustomMimeType::fromString($mimeTypeString);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http\Middlewares;
|
||||
|
||||
use App\Framework\ErrorHandling\SecurityEventLogger;
|
||||
use App\Framework\Exception\SecurityEvent\SystemExcessiveUseEvent;
|
||||
use App\Framework\Http\Headers;
|
||||
use App\Framework\Http\HttpMiddleware;
|
||||
@@ -17,6 +16,10 @@ use App\Framework\Http\RequestStateManager;
|
||||
use App\Framework\Http\Response;
|
||||
use App\Framework\Http\ResponseManipulator;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Logging\Logger;
|
||||
use App\Framework\Logging\Processors\SecurityEventProcessor;
|
||||
use App\Framework\Logging\ValueObjects\LogContext;
|
||||
use App\Framework\Logging\ValueObjects\SecurityContext;
|
||||
use App\Framework\RateLimit\RateLimitConfig;
|
||||
use App\Framework\RateLimit\RateLimiter;
|
||||
use App\Framework\RateLimit\RateLimitResult;
|
||||
@@ -33,8 +36,9 @@ final readonly class RateLimitMiddleware implements HttpMiddleware
|
||||
public function __construct(
|
||||
private RateLimiter $rateLimiter,
|
||||
private ResponseManipulator $responseManipulator,
|
||||
private RateLimitConfig $config = new RateLimitConfig(),
|
||||
private ?SecurityEventLogger $securityLogger = null
|
||||
private readonly Logger $logger,
|
||||
private readonly SecurityEventProcessor $processor,
|
||||
private RateLimitConfig $config = new RateLimitConfig()
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -146,16 +150,30 @@ final readonly class RateLimitMiddleware implements HttpMiddleware
|
||||
|
||||
private function logSecurityEvent(string $clientIp, string $path, RateLimitResult $result): void
|
||||
{
|
||||
if (! $this->securityLogger) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create security event with correct constructor parameters
|
||||
$event = new SystemExcessiveUseEvent(
|
||||
null, // No user ID for IP-based limiting
|
||||
$clientIp,
|
||||
"Rate limit exceeded for {$path}: {$result->getCurrent()}/{$result->getLimit()} requests"
|
||||
$result->getLimit(),
|
||||
$result->getCurrent()
|
||||
);
|
||||
|
||||
$this->securityLogger->log($event);
|
||||
// Create SecurityContext for OWASP-compliant logging
|
||||
$securityContext = SecurityContext::forIntrusion(
|
||||
eventId: $event->getEventIdentifier(),
|
||||
description: $event->getDescription(),
|
||||
level: $event->getLogLevel(),
|
||||
requiresAlert: $event->requiresAlert(),
|
||||
eventData: array_merge($event->toArray(), ['path' => $path])
|
||||
)->withRequestInfo($clientIp, null);
|
||||
|
||||
// Map SecurityLogLevel to framework LogLevel
|
||||
$logLevel = $this->processor->mapSecurityLevelToLogLevel($event->getLogLevel());
|
||||
|
||||
// Log directly via Logger with SecurityContext
|
||||
$this->logger->log(
|
||||
$logLevel,
|
||||
$event->getDescription(),
|
||||
LogContext::empty()->withSecurityContext($securityContext)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
enum MimeType: string
|
||||
enum MimeType: string implements MimeTypeInterface
|
||||
{
|
||||
// Text
|
||||
case TEXT_PLAIN = 'text/plain';
|
||||
@@ -68,6 +68,27 @@ enum MimeType: string
|
||||
case FONT_TTF = 'font/ttf';
|
||||
case FONT_OTF = 'font/otf';
|
||||
|
||||
// MimeTypeInterface implementation
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function getPrimaryType(): string
|
||||
{
|
||||
return explode('/', $this->value)[0];
|
||||
}
|
||||
|
||||
public function getSubType(): string
|
||||
{
|
||||
return explode('/', $this->value)[1];
|
||||
}
|
||||
|
||||
public function equals(MimeTypeInterface $other): bool
|
||||
{
|
||||
return $this->value === $other->getValue();
|
||||
}
|
||||
|
||||
// Convenience methods using analyzer classes
|
||||
public function isImage(): bool
|
||||
{
|
||||
@@ -119,17 +140,17 @@ enum MimeType: string
|
||||
return MimeTypeResolver::getPreferredExtension($this);
|
||||
}
|
||||
|
||||
public static function fromExtension(string $extension): ?self
|
||||
public static function tryFromExtension(string $extension): ?self
|
||||
{
|
||||
return MimeTypeResolver::fromExtension($extension);
|
||||
}
|
||||
|
||||
public static function fromFilePath(string $filePath): ?self
|
||||
public static function tryFromFilePath(string $filePath): ?self
|
||||
{
|
||||
return MimeTypeResolver::fromFilePath($filePath);
|
||||
}
|
||||
|
||||
public static function fromString(string $mimeTypeString): ?self
|
||||
public static function tryFromString(string $mimeTypeString): ?self
|
||||
{
|
||||
return MimeTypeResolver::fromString($mimeTypeString);
|
||||
}
|
||||
|
||||
24
src/Framework/Http/MimeTypeDetector.php
Normal file
24
src/Framework/Http/MimeTypeDetector.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
|
||||
/**
|
||||
* Interface for detecting MIME types from file paths
|
||||
*
|
||||
* Returns MimeTypeInterface which can be either:
|
||||
* - MimeType enum for known types
|
||||
* - CustomMimeType for unknown types
|
||||
*/
|
||||
interface MimeTypeDetector
|
||||
{
|
||||
/**
|
||||
* Detect MIME type from file path
|
||||
*
|
||||
* @return MimeTypeInterface The detected MIME type
|
||||
*/
|
||||
public function detect(FilePath $filePath): MimeTypeInterface;
|
||||
}
|
||||
33
src/Framework/Http/MimeTypeInterface.php
Normal file
33
src/Framework/Http/MimeTypeInterface.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
/**
|
||||
* Interface for MIME type representations
|
||||
*
|
||||
* Implemented by both MimeType enum (for known types) and CustomMimeType (for unknown types)
|
||||
*/
|
||||
interface MimeTypeInterface
|
||||
{
|
||||
/**
|
||||
* Get the MIME type string value (e.g., 'image/jpeg', 'text/plain')
|
||||
*/
|
||||
public function getValue(): string;
|
||||
|
||||
/**
|
||||
* Get the primary type (e.g., 'image', 'text', 'application')
|
||||
*/
|
||||
public function getPrimaryType(): string;
|
||||
|
||||
/**
|
||||
* Get the sub type (e.g., 'jpeg', 'plain', 'json')
|
||||
*/
|
||||
public function getSubType(): string;
|
||||
|
||||
/**
|
||||
* Check if this MIME type equals another
|
||||
*/
|
||||
public function equals(self $other): bool;
|
||||
}
|
||||
39
src/Framework/Http/MockMimeTypeDetector.php
Normal file
39
src/Framework/Http/MockMimeTypeDetector.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
|
||||
/**
|
||||
* Mock MIME type detector for testing
|
||||
*
|
||||
* Returns a predefined MIME type instead of detecting from file content
|
||||
*/
|
||||
final readonly class MockMimeTypeDetector implements MimeTypeDetector
|
||||
{
|
||||
public function __construct(
|
||||
private MimeTypeInterface $mimeType
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from MIME type string
|
||||
*/
|
||||
public static function fromString(string $mimeTypeString): self
|
||||
{
|
||||
$mimeType = MimeType::tryFromString($mimeTypeString)
|
||||
?? CustomMimeType::fromString($mimeTypeString);
|
||||
|
||||
return new self($mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Always returns the predefined MIME type, ignoring the file path
|
||||
*/
|
||||
public function detect(FilePath $filePath): MimeTypeInterface
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
}
|
||||
105
src/Framework/Http/RequestContext.php
Normal file
105
src/Framework/Http/RequestContext.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
/**
|
||||
* Request Context Value Object
|
||||
*
|
||||
* Encapsulates request-scoped context information for tracking and auditing.
|
||||
* Used by StateHistoryManager for capturing request metadata.
|
||||
*
|
||||
* Immutable value object following framework patterns.
|
||||
*/
|
||||
final readonly class RequestContext
|
||||
{
|
||||
public function __construct(
|
||||
private ?string $userId = null,
|
||||
private ?string $sessionId = null,
|
||||
private ?string $ipAddress = null,
|
||||
private ?string $userAgent = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create RequestContext from HttpRequest
|
||||
*/
|
||||
public static function fromRequest(HttpRequest $request): self
|
||||
{
|
||||
return new self(
|
||||
userId: null, // Must be set separately after authentication
|
||||
sessionId: $request->session?->getId(),
|
||||
ipAddress: (string) $request->server->getRemoteAddr(),
|
||||
userAgent: $request->server->getUserAgent()?->toString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new instance with userId
|
||||
*/
|
||||
public function withUserId(string $userId): self
|
||||
{
|
||||
return new self(
|
||||
userId: $userId,
|
||||
sessionId: $this->sessionId,
|
||||
ipAddress: $this->ipAddress,
|
||||
userAgent: $this->userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
public function getUserId(): ?string
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getSessionId(): ?string
|
||||
{
|
||||
return $this->sessionId;
|
||||
}
|
||||
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if context has user authentication
|
||||
*/
|
||||
public function isAuthenticated(): bool
|
||||
{
|
||||
return $this->userId !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to array for logging/serialization
|
||||
*
|
||||
* @return array{user_id?: string, session_id?: string, ip_address?: string, user_agent?: string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$data = [];
|
||||
|
||||
if ($this->userId !== null) {
|
||||
$data['user_id'] = $this->userId;
|
||||
}
|
||||
|
||||
if ($this->sessionId !== null) {
|
||||
$data['session_id'] = $this->sessionId;
|
||||
}
|
||||
|
||||
if ($this->ipAddress !== null) {
|
||||
$data['ip_address'] = $this->ipAddress;
|
||||
}
|
||||
|
||||
if ($this->userAgent !== null) {
|
||||
$data['user_agent'] = $this->userAgent;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ final class RequestIdGenerator
|
||||
$headerRequestId = $headers?->getFirst(self::REQUEST_ID_HEADER);
|
||||
|
||||
// Neue RequestId erstellen (validiert automatisch die Header-ID, falls vorhanden)
|
||||
$this->requestId = new RequestId($headerRequestId, $this->secret);
|
||||
$this->requestId = new RequestId($this->secret, $headerRequestId);
|
||||
}
|
||||
|
||||
return $this->requestId;
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Http;
|
||||
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
|
||||
final readonly class UploadedFile
|
||||
{
|
||||
public function __construct(
|
||||
@@ -12,7 +14,8 @@ final readonly class UploadedFile
|
||||
public int $size,
|
||||
public string $tmpName,
|
||||
public UploadError $error,
|
||||
private bool $skipValidation = false
|
||||
private bool $skipValidation = false,
|
||||
private ?MimeTypeDetector $mimeTypeDetector = null
|
||||
) {
|
||||
if (! $this->skipValidation && $this->isValid() === false) {
|
||||
throw new \InvalidArgumentException("Invalid uploaded file: {$this->name}");
|
||||
@@ -34,13 +37,16 @@ final readonly class UploadedFile
|
||||
return $this->error === UploadError::OK && is_uploaded_file($this->tmpName);
|
||||
}
|
||||
|
||||
public function getMimeType(): string
|
||||
public function getMimeType(): MimeTypeInterface
|
||||
{
|
||||
return mime_content_type($this->tmpName);
|
||||
$detector = $this->mimeTypeDetector ?? new FilesystemMimeTypeDetector();
|
||||
return $detector->detect(FilePath::create($this->tmpName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an UploadedFile for testing without validation
|
||||
*
|
||||
* Uses MockMimeTypeDetector to return the specified type instead of detecting from file
|
||||
*/
|
||||
public static function createForTesting(
|
||||
string $name,
|
||||
@@ -49,6 +55,14 @@ final readonly class UploadedFile
|
||||
string $tmpName,
|
||||
UploadError $error = UploadError::OK
|
||||
): self {
|
||||
return new self($name, $type, $size, $tmpName, $error, skipValidation: true);
|
||||
return new self(
|
||||
$name,
|
||||
$type,
|
||||
$size,
|
||||
$tmpName,
|
||||
$error,
|
||||
skipValidation: true,
|
||||
mimeTypeDetector: MockMimeTypeDetector::fromString($type)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user