refactor: reorganize project structure for better maintainability
- Move 45 debug/test files from root to organized scripts/ directories - Secure public/ directory by removing debug files (security improvement) - Create structured scripts organization: • scripts/debug/ (20 files) - Framework debugging tools • scripts/test/ (18 files) - Test and validation scripts • scripts/maintenance/ (5 files) - Maintenance utilities • scripts/dev/ (2 files) - Development tools Security improvements: - Removed all debug/test files from public/ directory - Only production files remain: index.php, health.php Root directory cleanup: - Reduced from 47 to 2 PHP files in root - Only essential production files: console.php, worker.php This improves: ✅ Security (no debug code in public/) ✅ Organization (clear separation of concerns) ✅ Maintainability (easy to find and manage scripts) ✅ Professional structure (clean root directory)
This commit is contained in:
35
src/Framework/MagicLinks/Actions/ActionRegistry.php
Normal file
35
src/Framework/MagicLinks/Actions/ActionRegistry.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
use App\Framework\Smartlinks\TokenAction;
|
||||
|
||||
interface ActionRegistry
|
||||
{
|
||||
/**
|
||||
* Register an action
|
||||
*/
|
||||
public function register(SmartlinkAction $action): void;
|
||||
|
||||
/**
|
||||
* Get an action by name
|
||||
*/
|
||||
public function get(TokenAction $action): ?SmartlinkAction;
|
||||
|
||||
/**
|
||||
* Check if an action is registered
|
||||
*/
|
||||
public function has(TokenAction $action): bool;
|
||||
|
||||
/**
|
||||
* Get all registered actions
|
||||
*/
|
||||
public function getAll(): array;
|
||||
|
||||
/**
|
||||
* Unregister an action
|
||||
*/
|
||||
public function unregister(TokenAction $action): void;
|
||||
}
|
||||
46
src/Framework/MagicLinks/Actions/ActionResult.php
Normal file
46
src/Framework/MagicLinks/Actions/ActionResult.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
final readonly class ActionResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $success,
|
||||
public string $message = '',
|
||||
public array $data = [],
|
||||
public ?string $redirectUrl = null,
|
||||
public array $errors = []
|
||||
) {
|
||||
}
|
||||
|
||||
public static function success(string $message = '', array $data = [], ?string $redirectUrl = null): self
|
||||
{
|
||||
return new self(
|
||||
success: true,
|
||||
message: $message,
|
||||
data: $data,
|
||||
redirectUrl: $redirectUrl
|
||||
);
|
||||
}
|
||||
|
||||
public static function failure(string $message, array $errors = []): self
|
||||
{
|
||||
return new self(
|
||||
success: false,
|
||||
message: $message,
|
||||
errors: $errors
|
||||
);
|
||||
}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->success;
|
||||
}
|
||||
|
||||
public function hasRedirect(): bool
|
||||
{
|
||||
return $this->redirectUrl !== null;
|
||||
}
|
||||
}
|
||||
38
src/Framework/MagicLinks/Actions/DefaultActionRegistry.php
Normal file
38
src/Framework/MagicLinks/Actions/DefaultActionRegistry.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
use App\Framework\Smartlinks\TokenAction;
|
||||
|
||||
final class DefaultActionRegistry implements ActionRegistry
|
||||
{
|
||||
/** @var SmartlinkAction[] */
|
||||
private array $actions = [];
|
||||
|
||||
public function register(SmartlinkAction $action): void
|
||||
{
|
||||
$this->actions[$action->getName()] = $action;
|
||||
}
|
||||
|
||||
public function get(TokenAction $action): ?SmartlinkAction
|
||||
{
|
||||
return $this->actions[$action->name] ?? null;
|
||||
}
|
||||
|
||||
public function has(TokenAction $action): bool
|
||||
{
|
||||
return isset($this->actions[$action->name]);
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->actions;
|
||||
}
|
||||
|
||||
public function unregister(TokenAction $action): void
|
||||
{
|
||||
unset($this->actions[$action->name]);
|
||||
}
|
||||
}
|
||||
53
src/Framework/MagicLinks/Actions/DocumentAccessAction.php
Normal file
53
src/Framework/MagicLinks/Actions/DocumentAccessAction.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
|
||||
final readonly class DocumentAccessAction implements SmartlinkAction
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'document_access';
|
||||
}
|
||||
|
||||
public function getDefaultConfig(): TokenConfig
|
||||
{
|
||||
return new TokenConfig(
|
||||
expiryHours: 72, // 3 days
|
||||
oneTimeUse: false, // Can be accessed multiple times
|
||||
maxUses: 10,
|
||||
requireSecureContext: false
|
||||
);
|
||||
}
|
||||
|
||||
public function validatePayload(array $payload): bool
|
||||
{
|
||||
return ! empty($payload['document_id']) &&
|
||||
in_array($payload['access_level'], ['read', 'download', 'edit']);
|
||||
}
|
||||
|
||||
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
|
||||
{
|
||||
$payload = $smartlinkData->payload;
|
||||
|
||||
// Document access logic
|
||||
return ActionResult::success(
|
||||
message: "Document access granted",
|
||||
data: [
|
||||
'document_id' => $payload['document_id'],
|
||||
'access_level' => $payload['access_level'],
|
||||
'download_url' => "/documents/{$payload['document_id']}/download",
|
||||
'expires_at' => $smartlinkData->expiresAt->format('Y-m-d H:i:s'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getRequiredPermissions(): array
|
||||
{
|
||||
return ['documents.access'];
|
||||
}
|
||||
}
|
||||
57
src/Framework/MagicLinks/Actions/EmailVerificationAction.php
Normal file
57
src/Framework/MagicLinks/Actions/EmailVerificationAction.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class EmailVerificationAction implements SmartlinkAction
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'email_verification';
|
||||
}
|
||||
|
||||
public function getDefaultConfig(): TokenConfig
|
||||
{
|
||||
return new TokenConfig(
|
||||
expiryHours: 24,
|
||||
oneTimeUse: true,
|
||||
maxUses: 1,
|
||||
requireSecureContext: true
|
||||
);
|
||||
}
|
||||
|
||||
public function validatePayload(array $payload): bool
|
||||
{
|
||||
return ! empty($payload['email']) &&
|
||||
! empty($payload['user_id']) &&
|
||||
filter_var($payload['email'], FILTER_VALIDATE_EMAIL);
|
||||
}
|
||||
|
||||
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
|
||||
{
|
||||
$payload = $smartlinkData->payload;
|
||||
|
||||
// Email verification logic would go here
|
||||
// For now, just a simple success response
|
||||
|
||||
return ActionResult::success(
|
||||
message: "Email {$payload['email']} successfully verified",
|
||||
data: [
|
||||
'user_id' => $payload['user_id'],
|
||||
'email' => $payload['email'],
|
||||
'verified_at' => new DateTimeImmutable(),
|
||||
],
|
||||
redirectUrl: '/dashboard'
|
||||
);
|
||||
}
|
||||
|
||||
public function getRequiredPermissions(): array
|
||||
{
|
||||
return []; // No special permissions needed for email verification
|
||||
}
|
||||
}
|
||||
48
src/Framework/MagicLinks/Actions/GenericDataAccessAction.php
Normal file
48
src/Framework/MagicLinks/Actions/GenericDataAccessAction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
|
||||
final readonly class GenericDataAccessAction implements SmartlinkAction
|
||||
{
|
||||
public function __construct(
|
||||
private string $actionName,
|
||||
private TokenConfig $config,
|
||||
private array $requiredPermissions = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->actionName;
|
||||
}
|
||||
|
||||
public function getDefaultConfig(): TokenConfig
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
public function validatePayload(array $payload): bool
|
||||
{
|
||||
// Generic validation - just check that payload is not empty
|
||||
return ! empty($payload);
|
||||
}
|
||||
|
||||
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
|
||||
{
|
||||
// Generic execution - just return the payload data
|
||||
return ActionResult::success(
|
||||
message: "Action '{$this->actionName}' executed successfully",
|
||||
data: $smartlinkData->payload
|
||||
);
|
||||
}
|
||||
|
||||
public function getRequiredPermissions(): array
|
||||
{
|
||||
return $this->requiredPermissions;
|
||||
}
|
||||
}
|
||||
36
src/Framework/MagicLinks/Actions/MagicLinkAction.php
Normal file
36
src/Framework/MagicLinks/Actions/MagicLinkAction.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
|
||||
interface SmartlinkAction
|
||||
{
|
||||
/**
|
||||
* Get the action name
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Get default configuration for this action
|
||||
*/
|
||||
public function getDefaultConfig(): TokenConfig;
|
||||
|
||||
/**
|
||||
* Validate the payload for this action
|
||||
*/
|
||||
public function validatePayload(array $payload): bool;
|
||||
|
||||
/**
|
||||
* Execute the action
|
||||
*/
|
||||
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult;
|
||||
|
||||
/**
|
||||
* Get required permissions/roles for this action
|
||||
*/
|
||||
public function getRequiredPermissions(): array;
|
||||
}
|
||||
56
src/Framework/MagicLinks/Actions/PasswordResetAction.php
Normal file
56
src/Framework/MagicLinks/Actions/PasswordResetAction.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Actions;
|
||||
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
|
||||
final readonly class PasswordResetAction implements SmartlinkAction
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'password_reset';
|
||||
}
|
||||
|
||||
public function getDefaultConfig(): TokenConfig
|
||||
{
|
||||
return new TokenConfig(
|
||||
expiryHours: 1, // Short expiry for security
|
||||
oneTimeUse: true,
|
||||
maxUses: 1,
|
||||
requireSecureContext: true
|
||||
);
|
||||
}
|
||||
|
||||
public function validatePayload(array $payload): bool
|
||||
{
|
||||
return ! empty($payload['user_id']) &&
|
||||
! empty($payload['email']) &&
|
||||
filter_var($payload['email'], FILTER_VALIDATE_EMAIL);
|
||||
}
|
||||
|
||||
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
|
||||
{
|
||||
$payload = $smartlinkData->payload;
|
||||
|
||||
// Password reset form would be shown here
|
||||
// Return data needed for the password reset form
|
||||
|
||||
return ActionResult::success(
|
||||
message: "Password reset form ready",
|
||||
data: [
|
||||
'user_id' => $payload['user_id'],
|
||||
'email' => $payload['email'],
|
||||
'form_action' => '/password/reset/submit',
|
||||
'csrf_token' => $context['csrf_token'] ?? null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function getRequiredPermissions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Commands;
|
||||
|
||||
use App\Framework\Smartlinks\SmartLinkToken;
|
||||
|
||||
final readonly class ExecuteSmartlinkCommand
|
||||
{
|
||||
public function __construct(
|
||||
public SmartlinkToken $token,
|
||||
public array $context = []
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Commands;
|
||||
|
||||
use App\Framework\CommandBus\CommandHandler;
|
||||
use App\Framework\Smartlinks\Actions\ActionRegistry;
|
||||
use App\Framework\Smartlinks\Actions\ActionResult;
|
||||
use App\Framework\Smartlinks\Services\SmartlinkService;
|
||||
|
||||
final readonly class ExecuteSmartlinkHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SmartlinkService $smartlinkService,
|
||||
private ActionRegistry $actionRegistry
|
||||
) {
|
||||
}
|
||||
|
||||
#[CommandHandler]
|
||||
public function handle(ExecuteSmartlinkCommand $command): ActionResult
|
||||
{
|
||||
// Validate token
|
||||
$smartlinkData = $this->smartlinkService->validate($command->token);
|
||||
if (! $smartlinkData) {
|
||||
return ActionResult::failure('Invalid or expired smartlink');
|
||||
}
|
||||
|
||||
// Get action
|
||||
$action = $this->actionRegistry->get($smartlinkData->action);
|
||||
if (! $action) {
|
||||
return ActionResult::failure('Unknown action: ' . $smartlinkData->action);
|
||||
}
|
||||
|
||||
// Execute action
|
||||
$result = $action->execute($smartlinkData, $command->context);
|
||||
|
||||
// Mark as used if one-time use
|
||||
if ($smartlinkData->oneTimeUse && $result->isSuccess()) {
|
||||
$this->smartlinkService->markAsUsed($command->token);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Commands;
|
||||
|
||||
use App\Framework\Smartlinks\TokenAction;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
|
||||
final readonly class GenerateSmartlinkCommand
|
||||
{
|
||||
public function __construct(
|
||||
public TokenAction $action,
|
||||
public array $payload,
|
||||
public ?TokenConfig $config = null,
|
||||
public ?string $baseUrl = null,
|
||||
public ?string $createdByIp = null,
|
||||
public ?string $userAgent = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Commands;
|
||||
|
||||
use App\Framework\CommandBus\CommandHandler;
|
||||
use App\Framework\Smartlinks\Actions\ActionRegistry;
|
||||
use App\Framework\Smartlinks\Services\SmartlinkService;
|
||||
|
||||
final readonly class GenerateSmartlinkHandler
|
||||
{
|
||||
public function __construct(
|
||||
private SmartlinkService $smartlinkService,
|
||||
private ActionRegistry $actionRegistry
|
||||
) {
|
||||
}
|
||||
|
||||
#[CommandHandler]
|
||||
public function handle(GenerateSmartlinkCommand $command): string
|
||||
{
|
||||
// Validate action exists
|
||||
$action = $this->actionRegistry->get($command->action);
|
||||
if (! $action) {
|
||||
throw new \InvalidArgumentException("Unknown action: {$command->action}");
|
||||
}
|
||||
|
||||
// Validate payload
|
||||
if (! $action->validatePayload($command->payload)) {
|
||||
throw new \InvalidArgumentException("Invalid payload for action: {$command->action}");
|
||||
}
|
||||
|
||||
// Use action's default config if none provided
|
||||
$config = $command->config ?? $action->getDefaultConfig();
|
||||
|
||||
// Generate token
|
||||
$token = $this->smartlinkService->generate(
|
||||
action: $command->action,
|
||||
payload: $command->payload,
|
||||
config: $config,
|
||||
createdByIp: $command->createdByIp,
|
||||
userAgent: $command->userAgent
|
||||
);
|
||||
|
||||
// Build full URL
|
||||
$baseUrl = $command->baseUrl ?? '';
|
||||
|
||||
return rtrim($baseUrl, '/') . '/smartlink/' . $token;
|
||||
}
|
||||
}
|
||||
60
src/Framework/MagicLinks/MagicLinkData.php
Normal file
60
src/Framework/MagicLinks/MagicLinkData.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class SmartlinkData
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public TokenAction $action,
|
||||
public array $payload,
|
||||
public DateTimeImmutable $expiresAt,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public bool $oneTimeUse = false,
|
||||
public ?string $createdByIp = null,
|
||||
public ?string $userAgent = null,
|
||||
public bool $isUsed = false,
|
||||
public ?DateTimeImmutable $usedAt = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expiresAt <= new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return ! $this->isExpired() && (! $this->oneTimeUse || ! $this->isUsed);
|
||||
}
|
||||
|
||||
public function getRemainingTime(): \DateInterval
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
if ($this->isExpired()) {
|
||||
return new \DateInterval('P0D');
|
||||
}
|
||||
|
||||
return $now->diff($this->expiresAt);
|
||||
}
|
||||
|
||||
public function withUsed(DateTimeImmutable $usedAt): self
|
||||
{
|
||||
return new self(
|
||||
id: $this->id,
|
||||
action: $this->action,
|
||||
payload: $this->payload,
|
||||
expiresAt: $this->expiresAt,
|
||||
createdAt: $this->createdAt,
|
||||
oneTimeUse: $this->oneTimeUse,
|
||||
createdByIp: $this->createdByIp,
|
||||
userAgent: $this->userAgent,
|
||||
isUsed: true,
|
||||
usedAt: $usedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/Framework/MagicLinks/MagicLinkInitializer.php
Normal file
37
src/Framework/MagicLinks/MagicLinkInitializer.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks;
|
||||
|
||||
use App\Framework\DI\Container;
|
||||
use App\Framework\DI\Initializer;
|
||||
use App\Framework\Smartlinks\Actions\ActionRegistry;
|
||||
use App\Framework\Smartlinks\Actions\DefaultActionRegistry;
|
||||
use App\Framework\Smartlinks\Actions\EmailVerificationAction;
|
||||
use App\Framework\Smartlinks\Services\CacheSmartLinkService;
|
||||
use App\Framework\Smartlinks\Services\SmartlinkService;
|
||||
|
||||
final readonly class SmartlinkInitializer
|
||||
{
|
||||
public function __construct(
|
||||
private Container $container
|
||||
) {
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function __invoke(): ActionRegistry
|
||||
{
|
||||
$registry = new DefaultActionRegistry();
|
||||
|
||||
$registry->register(new EmailVerificationAction());
|
||||
|
||||
return $registry;
|
||||
}
|
||||
|
||||
#[Initializer]
|
||||
public function init(): SmartlinkService
|
||||
{
|
||||
return $this->container->get(CacheSmartLinkService::class);
|
||||
}
|
||||
}
|
||||
30
src/Framework/MagicLinks/MagicLinkToken.php
Normal file
30
src/Framework/MagicLinks/MagicLinkToken.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks;
|
||||
|
||||
final readonly class SmartLinkToken
|
||||
{
|
||||
public function __construct(
|
||||
public string $value
|
||||
) {
|
||||
if (empty($value)) {
|
||||
throw new \InvalidArgumentException('Token value cannot be empty');
|
||||
}
|
||||
|
||||
if (strlen($value) < 16) {
|
||||
throw new \InvalidArgumentException('Token must be at least 16 characters long');
|
||||
}
|
||||
}
|
||||
|
||||
public function equals(SmartLinkToken $other): bool
|
||||
{
|
||||
return hash_equals($this->value, $other->value);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
155
src/Framework/MagicLinks/Services/CacheMagicLinkService.php
Normal file
155
src/Framework/MagicLinks/Services/CacheMagicLinkService.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Services;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\SmartLinkToken;
|
||||
use App\Framework\Smartlinks\TokenAction;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
use App\Framework\Ulid\UlidGenerator;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class CacheSmartLinkService implements SmartlinkService
|
||||
{
|
||||
public function __construct(
|
||||
private Cache $cache,
|
||||
private UlidGenerator $ulidGenerator,
|
||||
private Clock $clock
|
||||
) {
|
||||
}
|
||||
|
||||
public function generate(
|
||||
TokenAction $action,
|
||||
array $payload,
|
||||
?TokenConfig $config = null,
|
||||
?string $createdByIp = null,
|
||||
?string $userAgent = null
|
||||
): SmartLinkToken {
|
||||
$config ??= new TokenConfig();
|
||||
$id = $this->ulidGenerator->generate($this->clock);
|
||||
$now = new DateTimeImmutable();
|
||||
|
||||
$smartlinkData = new SmartlinkData(
|
||||
id: $id,
|
||||
action: $action,
|
||||
payload: $payload,
|
||||
expiresAt: $config->getExpiryDateTime(),
|
||||
createdAt: $now,
|
||||
oneTimeUse: $config->oneTimeUse,
|
||||
createdByIp: $createdByIp,
|
||||
userAgent: $userAgent
|
||||
);
|
||||
|
||||
$token = new SmartlinkToken($id);
|
||||
|
||||
// In Cache speichern
|
||||
$this->cache->set(
|
||||
CacheKey::fromString("smartlink:{$id}"),
|
||||
$this->serializeData($smartlinkData),
|
||||
Duration::fromSeconds($config->expiryHours * 3600)
|
||||
);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate(SmartlinkToken $token): ?SmartlinkData
|
||||
{
|
||||
$data = $this->cache->get(CacheKey::fromString("smartlink:{$token->value}"));
|
||||
|
||||
if (! $data->isHit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$smartlinkData = $this->deserializeData($data->value);
|
||||
|
||||
return $smartlinkData->isValid() ? $smartlinkData : null;
|
||||
}
|
||||
|
||||
public function markAsUsed(SmartlinkToken $token): void
|
||||
{
|
||||
$data = $this->validate($token);
|
||||
if ($data) {
|
||||
$updatedData = $data->withUsed(new DateTimeImmutable());
|
||||
$this->cache->set(
|
||||
CacheKey::fromString("smartlink:{$token->value}"),
|
||||
$this->serializeData($updatedData),
|
||||
Duration::fromSeconds($data->getRemainingTime()->h * 3600)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function revoke(SmartlinkToken $token): void
|
||||
{
|
||||
$this->cache->forget(CacheKey::fromString("smartlink:{$token->value}"));
|
||||
}
|
||||
|
||||
public function exists(SmartlinkToken $token): bool
|
||||
{
|
||||
return $this->cache->has(CacheKey::fromString("smartlink:{$token->value}"));
|
||||
}
|
||||
|
||||
public function getActiveTokens(int $limit = 100): array
|
||||
{
|
||||
// Cache-Keys durchsuchen
|
||||
$pattern = "smartlink:*";
|
||||
$keys = $this->cache->getKeys($pattern) ?? [];
|
||||
|
||||
$tokens = [];
|
||||
foreach (array_slice($keys, 0, $limit) as $key) {
|
||||
$data = $this->cache->get(CacheKey::fromString($key));
|
||||
if ($data->isHit) {
|
||||
$smartlinkData = $this->deserializeData($data->value);
|
||||
;
|
||||
if ($smartlinkData->isValid()) {
|
||||
$tokens[] = $smartlinkData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
public function cleanupExpired(): int
|
||||
{
|
||||
// Bei Cache-basierter Implementation werden expired Items automatisch entfernt
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function serializeData(SmartlinkData $data): array
|
||||
{
|
||||
return [
|
||||
'id' => $data->id,
|
||||
'action' => $data->action->name,
|
||||
'payload' => $data->payload,
|
||||
'expires_at' => $data->expiresAt->format('Y-m-d H:i:s'),
|
||||
'created_at' => $data->createdAt->format('Y-m-d H:i:s'),
|
||||
'one_time_use' => $data->oneTimeUse,
|
||||
'created_by_ip' => $data->createdByIp,
|
||||
'user_agent' => $data->userAgent,
|
||||
'is_used' => $data->isUsed,
|
||||
'used_at' => $data->usedAt?->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
private function deserializeData(array $data): SmartlinkData
|
||||
{
|
||||
return new SmartlinkData(
|
||||
id: $data['id'],
|
||||
action: new TokenAction($data['action']),
|
||||
payload: $data['payload'],
|
||||
expiresAt: new DateTimeImmutable($data['expires_at']),
|
||||
createdAt: new DateTimeImmutable($data['created_at']),
|
||||
oneTimeUse: $data['one_time_use'],
|
||||
createdByIp: $data['created_by_ip'],
|
||||
userAgent: $data['user_agent'],
|
||||
isUsed: $data['is_used'],
|
||||
usedAt: $data['used_at'] ? new DateTimeImmutable($data['used_at']) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
108
src/Framework/MagicLinks/Services/InMemoryMagicLinkService.php
Normal file
108
src/Framework/MagicLinks/Services/InMemoryMagicLinkService.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Services;
|
||||
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\SmartLinkToken;
|
||||
use App\Framework\Smartlinks\TokenAction;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
use App\Framework\Ulid\UlidGenerator;
|
||||
|
||||
final class InMemorySmartLinkService implements SmartlinkService
|
||||
{
|
||||
private array $tokens = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly UlidGenerator $ulidGenerator,
|
||||
private readonly Clock $clock
|
||||
) {
|
||||
}
|
||||
|
||||
public function generate(
|
||||
TokenAction $action,
|
||||
array $payload,
|
||||
?TokenConfig $config = null,
|
||||
?string $createdByIp = null,
|
||||
?string $userAgent = null
|
||||
): SmartlinkToken {
|
||||
$config ??= new TokenConfig();
|
||||
$id = $this->ulidGenerator->generate($this->clock);
|
||||
;
|
||||
$now = new \DateTimeImmutable();
|
||||
|
||||
$smartlinkData = new SmartlinkData(
|
||||
id: $id,
|
||||
action: $action,
|
||||
payload: $payload,
|
||||
expiresAt: $config->getExpiryDateTime(),
|
||||
createdAt: $now,
|
||||
oneTimeUse: $config->oneTimeUse,
|
||||
createdByIp: $createdByIp,
|
||||
userAgent: $userAgent
|
||||
);
|
||||
|
||||
$token = new SmartlinkToken($id);
|
||||
$this->tokens[$id] = $smartlinkData;
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function validate(SmartlinkToken $token): ?SmartlinkData
|
||||
{
|
||||
if (! isset($this->tokens[$token->value])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->tokens[$token->value];
|
||||
|
||||
if (! $data->isValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function markAsUsed(SmartlinkToken $token): void
|
||||
{
|
||||
if (isset($this->tokens[$token->value])) {
|
||||
$data = $this->tokens[$token->value];
|
||||
$this->tokens[$token->value] = $data->withUsed(new \DateTimeImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
public function revoke(SmartlinkToken $token): void
|
||||
{
|
||||
unset($this->tokens[$token->value]);
|
||||
}
|
||||
|
||||
public function exists(SmartlinkToken $token): bool
|
||||
{
|
||||
return isset($this->tokens[$token->value]) &&
|
||||
$this->tokens[$token->value]->isValid();
|
||||
}
|
||||
|
||||
public function getActiveTokens(int $limit = 100): array
|
||||
{
|
||||
return array_slice(
|
||||
array_filter($this->tokens, fn ($data) => $data->isValid()),
|
||||
0,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
public function cleanupExpired(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->tokens as $id => $data) {
|
||||
if ($data->isExpired()) {
|
||||
unset($this->tokens[$id]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
54
src/Framework/MagicLinks/Services/MagicLinkService.php
Normal file
54
src/Framework/MagicLinks/Services/MagicLinkService.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks\Services;
|
||||
|
||||
use App\Framework\Smartlinks\SmartlinkData;
|
||||
use App\Framework\Smartlinks\SmartLinkToken;
|
||||
use App\Framework\Smartlinks\TokenAction;
|
||||
use App\Framework\Smartlinks\TokenConfig;
|
||||
|
||||
interface SmartlinkService
|
||||
{
|
||||
/**
|
||||
* Generate a new smartlink token
|
||||
*/
|
||||
public function generate(
|
||||
TokenAction $action,
|
||||
array $payload,
|
||||
?TokenConfig $config = null,
|
||||
?string $createdByIp = null,
|
||||
?string $userAgent = null
|
||||
): SmartlinkToken;
|
||||
|
||||
/**
|
||||
* Validate and retrieve smartlink data
|
||||
*/
|
||||
public function validate(SmartlinkToken $token): ?SmartlinkData;
|
||||
|
||||
/**
|
||||
* Mark a token as used (for one-time use tokens)
|
||||
*/
|
||||
public function markAsUsed(SmartlinkToken $token): void;
|
||||
|
||||
/**
|
||||
* Revoke/invalidate a token
|
||||
*/
|
||||
public function revoke(SmartlinkToken $token): void;
|
||||
|
||||
/**
|
||||
* Check if a token exists and is valid
|
||||
*/
|
||||
public function exists(SmartlinkToken $token): bool;
|
||||
|
||||
/**
|
||||
* Get all active tokens for debugging/admin purposes
|
||||
*/
|
||||
public function getActiveTokens(int $limit = 100): array;
|
||||
|
||||
/**
|
||||
* Clean up expired tokens
|
||||
*/
|
||||
public function cleanupExpired(): int;
|
||||
}
|
||||
30
src/Framework/MagicLinks/TokenAction.php
Normal file
30
src/Framework/MagicLinks/TokenAction.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks;
|
||||
|
||||
final readonly class TokenAction
|
||||
{
|
||||
public function __construct(
|
||||
public string $name
|
||||
) {
|
||||
if (empty($name)) {
|
||||
throw new \InvalidArgumentException('Action name cannot be empty');
|
||||
}
|
||||
|
||||
if (! preg_match('/^[a-z_]+$/', $name)) {
|
||||
throw new \InvalidArgumentException('Action name must contain only lowercase letters and underscores');
|
||||
}
|
||||
}
|
||||
|
||||
public function equals(TokenAction $other): bool
|
||||
{
|
||||
return $this->name === $other->name;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
33
src/Framework/MagicLinks/TokenConfig.php
Normal file
33
src/Framework/MagicLinks/TokenConfig.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Smartlinks;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class TokenConfig
|
||||
{
|
||||
public function __construct(
|
||||
public int $expiryHours = 24,
|
||||
public bool $oneTimeUse = false,
|
||||
public int $maxUses = 1,
|
||||
public bool $requireSecureContext = false,
|
||||
public array $allowedIpRanges = [],
|
||||
public array $metadata = []
|
||||
) {
|
||||
if ($expiryHours <= 0) {
|
||||
throw new InvalidArgumentException('Expiry hours must be positive');
|
||||
}
|
||||
|
||||
if ($maxUses <= 0) {
|
||||
throw new InvalidArgumentException('Max uses must be positive');
|
||||
}
|
||||
}
|
||||
|
||||
public function getExpiryDateTime(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable("+{$this->expiryHours} hours");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user