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:
2025-10-05 10:59:15 +02:00
parent 03e5188644
commit 887847dde6
77 changed files with 3902 additions and 787 deletions

View 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;
}

View 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;
}
}

View 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]);
}
}

View 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'];
}
}

View 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
}
}

View 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;
}
}

View 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;
}

View 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 [];
}
}

View File

@@ -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 = []
) {
}
}

View File

@@ -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;
}
}

View File

@@ -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
) {
}
}

View File

@@ -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;
}
}

View 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
);
}
}

View 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);
}
}

View 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;
}
}

View 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
);
}
}

View 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;
}
}

View 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;
}

View 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;
}
}

View 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");
}
}