feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View File

@@ -7,6 +7,7 @@ namespace App\Application\OAuth;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\OAuth\OAuthService;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
@@ -20,6 +21,7 @@ final readonly class OAuthController
{
public function __construct(
private OAuthService $oauthService,
private SessionInterface $session,
) {
}
@@ -41,7 +43,9 @@ final readonly class OAuthController
// Generate state for CSRF protection
$state = bin2hex(random_bytes(16));
// TODO: Store state in session for verification
// Store state in session for verification
$this->session->set("oauth_state_{$provider}", $state);
$options['state'] = $state;
@@ -77,11 +81,40 @@ final readonly class OAuthController
);
}
// TODO: Verify state against session to prevent CSRF
// Verify state against session to prevent CSRF
$sessionKey = "oauth_state_{$provider}";
$expectedState = $this->session->get($sessionKey);
// Get current user ID (from session/auth)
// For demo purposes, using hardcoded user
$userId = 'user_123'; // TODO: Get from session
if ($expectedState === null) {
return new Redirect(
'/oauth/error',
flashMessage: 'OAuth state not found. Please try again.'
);
}
if (!hash_equals($expectedState, $state ?? '')) {
// Clear the state to prevent reuse
$this->session->remove($sessionKey);
return new Redirect(
'/oauth/error',
flashMessage: 'Invalid OAuth state. Possible CSRF attack detected.'
);
}
// Clear the state after successful verification (one-time use)
$this->session->remove($sessionKey);
// Get current user ID from session
// If no user is logged in, we need to handle this case
$userId = $this->session->get('user_id');
if ($userId === null) {
return new Redirect(
'/login',
flashMessage: 'Please login first before connecting ' . ucfirst($provider)
);
}
try {
$storedToken = $this->oauthService->handleCallback(
@@ -109,8 +142,15 @@ final readonly class OAuthController
#[Route('/oauth/providers', Method::GET)]
public function providers(HttpRequest $request): JsonResult
{
// TODO: Get from session
$userId = 'user_123';
// Get user ID from session
$userId = $this->session->get('user_id');
if ($userId === null) {
return new JsonResult([
'success' => false,
'error' => 'User not authenticated',
], 401);
}
$providers = $this->oauthService->getUserProviders($userId);
@@ -135,8 +175,15 @@ final readonly class OAuthController
#[Route('/oauth/{provider}/revoke', Method::POST)]
public function revoke(string $provider, HttpRequest $request): JsonResult
{
// TODO: Get from session
$userId = 'user_123';
// Get user ID from session
$userId = $this->session->get('user_id');
if ($userId === null) {
return new JsonResult([
'success' => false,
'error' => 'User not authenticated',
], 401);
}
try {
$this->oauthService->revokeToken($userId, $provider);
@@ -159,8 +206,15 @@ final readonly class OAuthController
#[Route('/oauth/{provider}/profile', Method::GET)]
public function profile(string $provider, HttpRequest $request): JsonResult
{
// TODO: Get from session
$userId = 'user_123';
// Get user ID from session
$userId = $this->session->get('user_id');
if ($userId === null) {
return new JsonResult([
'success' => false,
'error' => 'User not authenticated',
], 401);
}
try {
$profile = $this->oauthService->getUserProfile($userId, $provider);

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Application\Security\Services;
use App\Application\Security\Events\File\SuspiciousFileUploadEvent;
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Core\Events\EventDispatcherInterface;
use App\Framework\Http\UploadedFile;
final class FileUploadSecurityService
@@ -29,7 +29,7 @@ final class FileUploadSecurityService
];
public function __construct(
private EventDispatcher $eventDispatcher
private EventDispatcherInterface $eventDispatcher
) {
}
@@ -61,22 +61,24 @@ final class FileUploadSecurityService
// MIME-Type prüfen
$mimeType = $file->getMimeType();
if (! in_array($mimeType, self::ALLOWED_MIME_TYPES)) {
$this->dispatchSuspiciousUpload($file->name, $mimeType, $file->size, 'forbidden_mime_type', $userEmail);
$mimeTypeString = $mimeType->getValue();
if (! in_array($mimeTypeString, self::ALLOWED_MIME_TYPES)) {
$this->dispatchSuspiciousUpload($file->name, $mimeTypeString, $file->size, 'forbidden_mime_type', $userEmail);
return false;
}
// Dateiinhalt auf Malware-Signaturen prüfen
if ($this->containsMalwareSignatures($file->tmpName)) {
$this->dispatchSuspiciousUpload($file->name, $mimeType, $file->size, 'malware_signatures_detected', $userEmail);
$this->dispatchSuspiciousUpload($file->name, $mimeTypeString, $file->size, 'malware_signatures_detected', $userEmail);
return false;
}
// Double-Extension prüfen (z.B. file.jpg.php)
if ($this->hasDoubleExtension($file->name)) {
$this->dispatchSuspiciousUpload($file->name, $mimeType, $file->size, 'double_extension', $userEmail);
$this->dispatchSuspiciousUpload($file->name, $mimeTypeString, $file->size, 'double_extension', $userEmail);
return false;
}

View File

@@ -12,6 +12,7 @@ use App\Domain\SmartLink\ValueObjects\SmartLinkId;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
final readonly class AddLinkDestination
@@ -40,6 +41,6 @@ final readonly class AddLinkDestination
return new JsonResult([
'message' => 'Destination added successfully',
'destination' => $destination->toArray(),
], 201);
], Status::CREATED);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Application\SmartLink\Api;
use App\Domain\SmartLink\Services\ClickStatisticsService;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
/**
* Get Analytics Overview Statistics
*
* Returns aggregated statistics for the analytics dashboard:
* - Total clicks, unique clicks, conversions
* - Conversion rate
*/
final readonly class GetAnalyticsOverview
{
public function __construct(
private ClickStatisticsService $statisticsService
) {
}
#[Route(path: '/api/analytics/overview', method: Method::GET)]
public function __invoke(HttpRequest $request): JsonResult
{
// Parse optional time range parameter
$since = $this->parseTimeRange($request->queryParameters->get('range', '24h'));
$stats = $this->statisticsService->getOverviewStats($since);
return new JsonResult([
'success' => true,
'data' => $stats,
'timestamp' => Timestamp::now()->format('Y-m-d H:i:s'),
]);
}
private function parseTimeRange(string $range): ?Timestamp
{
$now = Timestamp::now();
return match ($range) {
'1h' => $now->subtractHours(1),
'24h' => $now->subtractHours(24),
'7d' => $now->subtractDays(7),
'30d' => $now->subtractDays(30),
'all' => null,
default => $now->subtractHours(24),
};
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Application\SmartLink\Api;
use App\Domain\SmartLink\Services\ClickStatisticsService;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
/**
* Get Click Time-Series Data for Charts
*
* Returns hourly click data for visualization
*/
final readonly class GetClickTimeSeries
{
public function __construct(
private ClickStatisticsService $statisticsService
) {
}
#[Route(path: '/api/analytics/time-series', method: Method::GET)]
public function __invoke(HttpRequest $request): JsonResult
{
$hours = (int) $request->queryParameters->get('hours', '24');
$hours = min(max($hours, 1), 168); // Limit to 1-168 hours (7 days)
$timeSeries = $this->statisticsService->getClickTimeSeries($hours);
return new JsonResult([
'success' => true,
'data' => $timeSeries,
'meta' => [
'hours' => $hours,
'data_points' => count($timeSeries),
],
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Application\SmartLink\Api;
use App\Domain\SmartLink\Services\ClickStatisticsService;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
/**
* Get Device Type Distribution Statistics
*
* Returns click distribution by device type (mobile, desktop, tablet)
*/
final readonly class GetDeviceStatistics
{
public function __construct(
private ClickStatisticsService $statisticsService
) {
}
#[Route(path: '/api/analytics/devices', method: Method::GET)]
public function __invoke(HttpRequest $request): JsonResult
{
$range = $request->queryParameters->get('range', '24h');
$since = $this->parseTimeRange($range);
$distribution = $this->statisticsService->getDeviceDistribution($since);
return new JsonResult([
'success' => true,
'data' => $distribution,
'meta' => [
'range' => $range,
],
]);
}
private function parseTimeRange(string $range): ?Timestamp
{
$now = Timestamp::now();
return match ($range) {
'1h' => $now->subtractHours(1),
'24h' => $now->subtractHours(24),
'7d' => $now->subtractDays(7),
'30d' => $now->subtractDays(30),
'all' => null,
default => $now->subtractHours(24),
};
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Application\SmartLink\Api;
use App\Domain\SmartLink\Services\ClickStatisticsService;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
/**
* Get Geographic Distribution of Clicks
*
* Returns click distribution by country
*/
final readonly class GetGeographicDistribution
{
public function __construct(
private ClickStatisticsService $statisticsService
) {
}
#[Route(path: '/api/analytics/geographic', method: Method::GET)]
public function __invoke(HttpRequest $request): JsonResult
{
$range = $request->queryParameters->get('range', '24h');
$since = $this->parseTimeRange($range);
$distribution = $this->statisticsService->getGeographicDistribution($since);
return new JsonResult([
'success' => true,
'data' => $distribution,
'meta' => [
'range' => $range,
'countries' => count($distribution),
],
]);
}
private function parseTimeRange(string $range): ?Timestamp
{
$now = Timestamp::now();
return match ($range) {
'1h' => $now->subtractHours(1),
'24h' => $now->subtractHours(24),
'7d' => $now->subtractDays(7),
'30d' => $now->subtractDays(30),
'all' => null,
default => $now->subtractHours(24),
};
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Application\SmartLink\Api;
use App\Domain\SmartLink\Services\ClickStatisticsService;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
/**
* Get Top Performing SmartLinks
*
* Returns list of top performing links by click count
*/
final readonly class GetTopPerformingLinks
{
public function __construct(
private ClickStatisticsService $statisticsService
) {
}
#[Route(path: '/api/analytics/top-links', method: Method::GET)]
public function __invoke(HttpRequest $request): JsonResult
{
$limit = (int) $request->queryParameters->get('limit', '10');
$limit = min(max($limit, 1), 100); // Limit to 1-100
$range = $request->queryParameters->get('range', '24h');
$since = $this->parseTimeRange($range);
$topLinks = $this->statisticsService->getTopPerformingLinks($limit, $since);
return new JsonResult([
'success' => true,
'data' => $topLinks,
'meta' => [
'limit' => $limit,
'range' => $range,
'count' => count($topLinks),
],
]);
}
private function parseTimeRange(string $range): ?Timestamp
{
$now = Timestamp::now();
return match ($range) {
'1h' => $now->subtractHours(1),
'24h' => $now->subtractHours(24),
'7d' => $now->subtractDays(7),
'30d' => $now->subtractDays(30),
'all' => null,
default => $now->subtractHours(24),
};
}
}

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace App\Application\SmartLink\Dashboard;
use App\Domain\SmartLink\Services\ClickStatisticsService;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\LiveComponents\Polling\PollableClosure;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
/**
* SmartLink Analytics Dashboard - Main Entry Point
*
* Displays comprehensive analytics for SmartLinks including:
* - Real-time click tracking (auto-updates via PollableClosure)
* - Geographic distribution
* - Device/Browser statistics
* - Top-performing links
* - Conversion metrics
*/
final readonly class AnalyticsDashboard
{
public function __construct(
private ClickStatisticsService $statisticsService
) {
}
#[Route(path: '/dashboard/analytics', method: Method::GET)]
public function __invoke(): ViewResult
{
// Create PollableClosures for real-time updates (interval in milliseconds)
$overviewStats = new PollableClosure(
closure: fn () => $this->renderOverviewStats(),
interval: 5000 // 5 seconds
);
$deviceStats = new PollableClosure(
closure: fn () => $this->renderDeviceStats(),
interval: 10000 // 10 seconds
);
$topLinks = new PollableClosure(
closure: fn () => $this->renderTopLinks(),
interval: 10000 // 10 seconds
);
return new ViewResult(
template: 'dashboard/analytics/index',
metaData: MetaData::create(
title: 'SmartLink Analytics Dashboard',
description: 'Real-time analytics and performance metrics for your SmartLinks'
),
data: [
'page_title' => 'SmartLink Analytics Dashboard',
'active_nav' => 'analytics',
'overview_stats' => $overviewStats,
'device_stats' => $deviceStats,
'top_links' => $topLinks,
]
);
}
private function renderOverviewStats(): string
{
$stats = $this->statisticsService->getOverviewStats();
return <<<HTML
<article class="stat-card">
<div class="stat-icon-wrapper">
<div class="stat-icon">📊</div>
</div>
<div class="stat-content">
<h3 class="stat-label">Total Clicks</h3>
<p class="stat-value">{$this->formatNumber($stats['total_clicks'])}</p>
<span class="stat-change positive">+{$stats['conversion_rate']}% conversion</span>
</div>
</article>
<article class="stat-card">
<div class="stat-icon-wrapper">
<div class="stat-icon">👥</div>
</div>
<div class="stat-content">
<h3 class="stat-label">Unique Visitors</h3>
<p class="stat-value">{$this->formatNumber($stats['unique_clicks'])}</p>
<span class="stat-change neutral">{$this->calculateUniquePercentage($stats)}% unique</span>
</div>
</article>
<article class="stat-card">
<div class="stat-icon-wrapper">
<div class="stat-icon">✅</div>
</div>
<div class="stat-content">
<h3 class="stat-label">Conversions</h3>
<p class="stat-value">{$this->formatNumber($stats['conversions'])}</p>
<span class="stat-change positive">{$stats['conversion_rate']}% rate</span>
</div>
</article>
<article class="stat-card">
<div class="stat-icon-wrapper">
<div class="stat-icon">📈</div>
</div>
<div class="stat-content">
<h3 class="stat-label">Active Links</h3>
<p class="stat-value">{$this->getActiveLinkCount()}</p>
<span class="stat-change neutral">All time</span>
</div>
</article>
HTML;
}
private function renderDeviceStats(): string
{
$distribution = $this->statisticsService->getDeviceDistribution();
return <<<HTML
<div class="device-stat">
<div class="device-info">
<span class="device-icon">📱</span>
<span class="device-name">Mobile</span>
</div>
<div class="device-bar">
<div class="device-fill" style="width: {$distribution['mobile_percentage']}%"></div>
</div>
<span class="device-percentage">{$distribution['mobile_percentage']}%</span>
</div>
<div class="device-stat">
<div class="device-info">
<span class="device-icon">💻</span>
<span class="device-name">Desktop</span>
</div>
<div class="device-bar">
<div class="device-fill" style="width: {$distribution['desktop_percentage']}%"></div>
</div>
<span class="device-percentage">{$distribution['desktop_percentage']}%</span>
</div>
<div class="device-stat">
<div class="device-info">
<span class="device-icon">📟</span>
<span class="device-name">Tablet</span>
</div>
<div class="device-bar">
<div class="device-fill" style="width: {$distribution['tablet_percentage']}%"></div>
</div>
<span class="device-percentage">{$distribution['tablet_percentage']}%</span>
</div>
<div class="device-stat">
<div class="device-info">
<span class="device-icon">🌐</span>
<span class="device-name">Chrome</span>
</div>
<div class="device-bar">
<div class="device-fill" style="width: 45%"></div>
</div>
<span class="device-percentage">45%</span>
</div>
<div class="device-stat">
<div class="device-info">
<span class="device-icon">🧭</span>
<span class="device-name">Safari</span>
</div>
<div class="device-bar">
<div class="device-fill" style="width: 30%"></div>
</div>
<span class="device-percentage">30%</span>
</div>
<div class="device-stat">
<div class="device-info">
<span class="device-icon">🦊</span>
<span class="device-name">Firefox</span>
</div>
<div class="device-bar">
<div class="device-fill" style="width: 15%"></div>
</div>
<span class="device-percentage">15%</span>
</div>
HTML;
}
private function renderTopLinks(): string
{
$topLinks = $this->statisticsService->getTopPerformingLinks(5);
if (empty($topLinks)) {
return <<<HTML
<tr>
<td colspan="4" class="empty-state">No link data available yet</td>
</tr>
HTML;
}
$rows = '';
foreach ($topLinks as $link) {
$linkId = htmlspecialchars($link['link_id']);
$clicks = $this->formatNumber($link['clicks']);
$uniqueClicks = $this->formatNumber($link['unique_clicks']);
$conversionRate = $this->calculateLinkConversionRate($link);
$rows .= <<<HTML
<tr>
<td>
<div class="link-info">
<span class="link-name">Link {$linkId}</span>
<span class="link-url">/{$linkId}</span>
</div>
</td>
<td class="text-center">
<div class="click-stats">
<span class="total-clicks">{$clicks}</span>
<span class="unique-clicks">({$uniqueClicks} unique)</span>
</div>
</td>
<td class="text-center">
<span class="conversion-rate">{$conversionRate}%</span>
</td>
<td class="text-center">
<span class="status-badge status-active">Active</span>
</td>
</tr>
HTML;
}
return $rows;
}
// Helper methods
private function formatNumber(int $number): string
{
if ($number >= 1000000) {
return number_format($number / 1000000, 1) . 'M';
}
if ($number >= 1000) {
return number_format($number / 1000, 1) . 'K';
}
return (string) $number;
}
private function calculateUniquePercentage(array $stats): float
{
if ($stats['total_clicks'] === 0) {
return 0.0;
}
return round(($stats['unique_clicks'] / $stats['total_clicks']) * 100, 1);
}
private function getActiveLinkCount(): string
{
// TODO: Implement actual active link count from SmartLinkRepository
return '5';
}
private function calculateLinkConversionRate(array $link): string
{
// TODO: Add conversion data to top links query
return '12.5';
}
}