docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -2,21 +2,21 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\Smartlinks\TokenAction;
use App\Framework\MagicLinks\TokenAction;
interface ActionRegistry
{
/**
* Register an action
*/
public function register(SmartlinkAction $action): void;
public function register(MagicLinkAction $action): void;
/**
* Get an action by name
*/
public function get(TokenAction $action): ?SmartlinkAction;
public function get(TokenAction $action): ?MagicLinkAction;
/**
* Check if an action is registered

View File

@@ -2,35 +2,46 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\MagicLinks\ValueObjects\ActionResultData;
use App\Framework\MagicLinks\ValueObjects\ErrorCollection;
final readonly class ActionResult
{
public function __construct(
public bool $success,
public string $message = '',
public array $data = [],
public ActionResultData $data = new ActionResultData([]),
public ?string $redirectUrl = null,
public array $errors = []
public ErrorCollection $errors = new ErrorCollection([])
) {
}
public static function success(string $message = '', array $data = [], ?string $redirectUrl = null): self
{
public static function success(
string $message = '',
?ActionResultData $data = null,
?string $redirectUrl = null
): self {
return new self(
success: true,
message: $message,
data: $data,
redirectUrl: $redirectUrl
data: $data ?? ActionResultData::empty(),
redirectUrl: $redirectUrl,
errors: ErrorCollection::empty()
);
}
public static function failure(string $message, array $errors = []): self
{
public static function failure(
string $message,
?ErrorCollection $errors = null
): self {
return new self(
success: false,
message: $message,
errors: $errors
data: ActionResultData::empty(),
redirectUrl: null,
errors: $errors ?? ErrorCollection::empty()
);
}
@@ -43,4 +54,9 @@ final readonly class ActionResult
{
return $this->redirectUrl !== null;
}
public function hasErrors(): bool
{
return $this->errors->hasErrors();
}
}

View File

@@ -2,21 +2,21 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\Smartlinks\TokenAction;
use App\Framework\MagicLinks\TokenAction;
final class DefaultActionRegistry implements ActionRegistry
{
/** @var SmartlinkAction[] */
/** @var MagicLinkAction[] */
private array $actions = [];
public function register(SmartlinkAction $action): void
public function register(MagicLinkAction $action): void
{
$this->actions[$action->getName()] = $action;
}
public function get(TokenAction $action): ?SmartlinkAction
public function get(TokenAction $action): ?MagicLinkAction
{
return $this->actions[$action->name] ?? null;
}

View File

@@ -2,12 +2,15 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\Smartlinks\SmartlinkData;
use App\Framework\Smartlinks\TokenConfig;
use App\Framework\Http\ValueObjects\IpPatternCollection;
use App\Framework\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\MagicLinks\ValueObjects\ActionResultData;
use App\Framework\MagicLinks\ValueObjects\Metadata;
final readonly class DocumentAccessAction implements SmartlinkAction
final readonly class DocumentAccessAction implements MagicLinkAction
{
public function getName(): string
{
@@ -20,7 +23,9 @@ final readonly class DocumentAccessAction implements SmartlinkAction
expiryHours: 72, // 3 days
oneTimeUse: false, // Can be accessed multiple times
maxUses: 10,
requireSecureContext: false
requireSecureContext: false,
allowedIpRanges: IpPatternCollection::empty(),
metadata: Metadata::empty()
);
}
@@ -30,19 +35,19 @@ final readonly class DocumentAccessAction implements SmartlinkAction
in_array($payload['access_level'], ['read', 'download', 'edit']);
}
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult
{
$payload = $smartlinkData->payload;
$payload = $magiclinkData->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'),
]
data: ActionResultData::fromArray([
'document_id' => $payload->get('document_id'),
'access_level' => $payload->get('access_level'),
'download_url' => "/documents/{$payload->get('document_id')}/download",
'expires_at' => $magiclinkData->expiresAt->format('Y-m-d H:i:s'),
])
);
}

View File

@@ -2,13 +2,16 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\Smartlinks\SmartlinkData;
use App\Framework\Smartlinks\TokenConfig;
use App\Framework\Http\ValueObjects\IpPatternCollection;
use App\Framework\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\MagicLinks\ValueObjects\ActionResultData;
use App\Framework\MagicLinks\ValueObjects\Metadata;
use DateTimeImmutable;
final readonly class EmailVerificationAction implements SmartlinkAction
final readonly class EmailVerificationAction implements MagicLinkAction
{
public function getName(): string
{
@@ -21,7 +24,9 @@ final readonly class EmailVerificationAction implements SmartlinkAction
expiryHours: 24,
oneTimeUse: true,
maxUses: 1,
requireSecureContext: true
requireSecureContext: true,
allowedIpRanges: IpPatternCollection::empty(),
metadata: Metadata::empty()
);
}
@@ -32,20 +37,20 @@ final readonly class EmailVerificationAction implements SmartlinkAction
filter_var($payload['email'], FILTER_VALIDATE_EMAIL);
}
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult
{
$payload = $smartlinkData->payload;
$payload = $magiclinkData->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'],
message: "Email {$payload->get('email')} successfully verified",
data: ActionResultData::fromArray([
'user_id' => $payload->get('user_id'),
'email' => $payload->get('email'),
'verified_at' => new DateTimeImmutable(),
],
]),
redirectUrl: '/dashboard'
);
}

View File

@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\Smartlinks\SmartlinkData;
use App\Framework\Smartlinks\TokenConfig;
use App\Framework\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\MagicLinks\ValueObjects\ActionResultData;
final readonly class GenericDataAccessAction implements SmartlinkAction
final readonly class GenericDataAccessAction implements MagicLinkAction
{
public function __construct(
private string $actionName,
@@ -32,12 +33,12 @@ final readonly class GenericDataAccessAction implements SmartlinkAction
return ! empty($payload);
}
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult
{
// Generic execution - just return the payload data
return ActionResult::success(
message: "Action '{$this->actionName}' executed successfully",
data: $smartlinkData->payload
data: ActionResultData::fromArray($magiclinkData->payload->toArray())
);
}

View File

@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\Smartlinks\SmartlinkData;
use App\Framework\Smartlinks\TokenConfig;
use App\Framework\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\TokenConfig;
interface SmartlinkAction
interface MagicLinkAction
{
/**
* Get the action name
@@ -27,7 +27,7 @@ interface SmartlinkAction
/**
* Execute the action
*/
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult;
public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult;
/**
* Get required permissions/roles for this action

View File

@@ -2,12 +2,15 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Actions;
namespace App\Framework\MagicLinks\Actions;
use App\Framework\Smartlinks\SmartlinkData;
use App\Framework\Smartlinks\TokenConfig;
use App\Framework\Http\ValueObjects\IpPatternCollection;
use App\Framework\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\MagicLinks\ValueObjects\ActionResultData;
use App\Framework\MagicLinks\ValueObjects\Metadata;
final readonly class PasswordResetAction implements SmartlinkAction
final readonly class PasswordResetAction implements MagicLinkAction
{
public function getName(): string
{
@@ -20,7 +23,9 @@ final readonly class PasswordResetAction implements SmartlinkAction
expiryHours: 1, // Short expiry for security
oneTimeUse: true,
maxUses: 1,
requireSecureContext: true
requireSecureContext: true,
allowedIpRanges: IpPatternCollection::empty(),
metadata: Metadata::empty()
);
}
@@ -31,21 +36,21 @@ final readonly class PasswordResetAction implements SmartlinkAction
filter_var($payload['email'], FILTER_VALIDATE_EMAIL);
}
public function execute(SmartlinkData $smartlinkData, array $context = []): ActionResult
public function execute(MagicLinkData $magiclinkData, array $context = []): ActionResult
{
$payload = $smartlinkData->payload;
$payload = $magiclinkData->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'],
data: ActionResultData::fromArray([
'user_id' => $payload->get('user_id'),
'email' => $payload->get('email'),
'form_action' => '/password/reset/submit',
'csrf_token' => $context['csrf_token'] ?? null,
]
])
);
}

View File

@@ -2,15 +2,21 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Commands;
namespace App\Framework\MagicLinks\Commands;
use App\Framework\Smartlinks\SmartLinkToken;
use App\Framework\MagicLinks\MagicLinkToken;
use App\Framework\MagicLinks\ValueObjects\ExecutionContext;
final readonly class ExecuteSmartlinkCommand
final readonly class ExecuteMagicLinkCommand
{
public function __construct(
public SmartlinkToken $token,
public array $context = []
public MagicLinkToken $token,
public ExecutionContext $context
) {
}
public static function withToken(MagicLinkToken $token): self
{
return new self($token, ExecutionContext::empty());
}
}

View File

@@ -2,42 +2,42 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Commands;
namespace App\Framework\MagicLinks\Commands;
use App\Framework\CommandBus\CommandHandler;
use App\Framework\Smartlinks\Actions\ActionRegistry;
use App\Framework\Smartlinks\Actions\ActionResult;
use App\Framework\Smartlinks\Services\SmartlinkService;
use App\Framework\MagicLinks\Actions\ActionRegistry;
use App\Framework\MagicLinks\Actions\ActionResult;
use App\Framework\MagicLinks\Services\MagicLinkService;
final readonly class ExecuteSmartlinkHandler
final readonly class ExecuteMagicLinkHandler
{
public function __construct(
private SmartlinkService $smartlinkService,
private MagicLinkService $magiclinkService,
private ActionRegistry $actionRegistry
) {
}
#[CommandHandler]
public function handle(ExecuteSmartlinkCommand $command): ActionResult
public function handle(ExecuteMagicLinkCommand $command): ActionResult
{
// Validate token
$smartlinkData = $this->smartlinkService->validate($command->token);
if (! $smartlinkData) {
return ActionResult::failure('Invalid or expired smartlink');
$magiclinkData = $this->magiclinkService->validate($command->token);
if (! $magiclinkData) {
return ActionResult::failure('Invalid or expired magiclink');
}
// Get action
$action = $this->actionRegistry->get($smartlinkData->action);
$action = $this->actionRegistry->get($magiclinkData->action);
if (! $action) {
return ActionResult::failure('Unknown action: ' . $smartlinkData->action);
return ActionResult::failure('Unknown action: ' . $magiclinkData->action);
}
// Execute action
$result = $action->execute($smartlinkData, $command->context);
$result = $action->execute($magiclinkData, $command->context->toArray());
// Mark as used if one-time use
if ($smartlinkData->oneTimeUse && $result->isSuccess()) {
$this->smartlinkService->markAsUsed($command->token);
if ($magiclinkData->oneTimeUse && $result->isSuccess()) {
$this->magiclinkService->markAsUsed($command->token);
}
return $result;

View File

@@ -2,16 +2,17 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Commands;
namespace App\Framework\MagicLinks\Commands;
use App\Framework\Smartlinks\TokenAction;
use App\Framework\Smartlinks\TokenConfig;
use App\Framework\MagicLinks\TokenAction;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\MagicLinks\ValueObjects\MagicLinkPayload;
final readonly class GenerateSmartlinkCommand
final readonly class GenerateMagicLinkCommand
{
public function __construct(
public TokenAction $action,
public array $payload,
public MagicLinkPayload $payload,
public ?TokenConfig $config = null,
public ?string $baseUrl = null,
public ?string $createdByIp = null,

View File

@@ -2,22 +2,22 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Commands;
namespace App\Framework\MagicLinks\Commands;
use App\Framework\CommandBus\CommandHandler;
use App\Framework\Smartlinks\Actions\ActionRegistry;
use App\Framework\Smartlinks\Services\SmartlinkService;
use App\Framework\MagicLinks\Actions\ActionRegistry;
use App\Framework\MagicLinks\Services\MagicLinkService;
final readonly class GenerateSmartlinkHandler
final readonly class GenerateMagicLinkHandler
{
public function __construct(
private SmartlinkService $smartlinkService,
private MagicLinkService $magiclinkService,
private ActionRegistry $actionRegistry
) {
}
#[CommandHandler]
public function handle(GenerateSmartlinkCommand $command): string
public function handle(GenerateMagicLinkCommand $command): string
{
// Validate action exists
$action = $this->actionRegistry->get($command->action);
@@ -26,7 +26,7 @@ final readonly class GenerateSmartlinkHandler
}
// Validate payload
if (! $action->validatePayload($command->payload)) {
if (! $action->validatePayload($command->payload->toArray())) {
throw new \InvalidArgumentException("Invalid payload for action: {$command->action}");
}
@@ -34,9 +34,9 @@ final readonly class GenerateSmartlinkHandler
$config = $command->config ?? $action->getDefaultConfig();
// Generate token
$token = $this->smartlinkService->generate(
$token = $this->magiclinkService->generate(
action: $command->action,
payload: $command->payload,
payload: $command->payload->toArray(),
config: $config,
createdByIp: $command->createdByIp,
userAgent: $command->userAgent
@@ -45,6 +45,6 @@ final readonly class GenerateSmartlinkHandler
// Build full URL
$baseUrl = $command->baseUrl ?? '';
return rtrim($baseUrl, '/') . '/smartlink/' . $token;
return rtrim($baseUrl, '/') . '/magiclink/' . $token;
}
}

View File

@@ -2,16 +2,17 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks;
namespace App\Framework\MagicLinks;
use App\Framework\MagicLinks\ValueObjects\MagicLinkPayload;
use DateTimeImmutable;
final readonly class SmartlinkData
final readonly class MagicLinkData
{
public function __construct(
public string $id,
public TokenAction $action,
public array $payload,
public MagicLinkPayload $payload,
public DateTimeImmutable $expiresAt,
public DateTimeImmutable $createdAt,
public bool $oneTimeUse = false,

View File

@@ -2,17 +2,17 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks;
namespace App\Framework\MagicLinks;
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;
use App\Framework\MagicLinks\Actions\ActionRegistry;
use App\Framework\MagicLinks\Actions\DefaultActionRegistry;
use App\Framework\MagicLinks\Actions\EmailVerificationAction;
use App\Framework\MagicLinks\Services\CacheMagicLinkService;
use App\Framework\MagicLinks\Services\MagicLinkService;
final readonly class SmartlinkInitializer
final readonly class MagicLinkInitializer
{
public function __construct(
private Container $container
@@ -30,8 +30,8 @@ final readonly class SmartlinkInitializer
}
#[Initializer]
public function init(): SmartlinkService
public function init(): MagicLinkService
{
return $this->container->get(CacheSmartLinkService::class);
return $this->container->get(CacheMagicLinkService::class);
}
}

View File

@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks;
namespace App\Framework\MagicLinks;
final readonly class SmartLinkToken
final readonly class MagicLinkToken
{
public function __construct(
public string $value
@@ -18,7 +18,7 @@ final readonly class SmartLinkToken
}
}
public function equals(SmartLinkToken $other): bool
public function equals(MagicLinkToken $other): bool
{
return hash_equals($this->value, $other->value);
}

View File

@@ -2,20 +2,21 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Services;
namespace App\Framework\MagicLinks\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\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\MagicLinkToken;
use App\Framework\MagicLinks\TokenAction;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\MagicLinks\ValueObjects\MagicLinkPayload;
use App\Framework\Ulid\UlidGenerator;
use DateTimeImmutable;
final readonly class CacheSmartLinkService implements SmartlinkService
final readonly class CacheMagicLinkService implements MagicLinkService
{
public function __construct(
private Cache $cache,
@@ -30,15 +31,15 @@ final readonly class CacheSmartLinkService implements SmartlinkService
?TokenConfig $config = null,
?string $createdByIp = null,
?string $userAgent = null
): SmartLinkToken {
): MagicLinkToken {
$config ??= new TokenConfig();
$id = $this->ulidGenerator->generate($this->clock);
$now = new DateTimeImmutable();
$smartlinkData = new SmartlinkData(
$magiclinkData = new MagicLinkData(
id: $id,
action: $action,
payload: $payload,
payload: MagicLinkPayload::fromArray($payload),
expiresAt: $config->getExpiryDateTime(),
createdAt: $now,
oneTimeUse: $config->oneTimeUse,
@@ -46,68 +47,68 @@ final readonly class CacheSmartLinkService implements SmartlinkService
userAgent: $userAgent
);
$token = new SmartlinkToken($id);
$token = new MagicLinkToken($id);
// In Cache speichern
$this->cache->set(
CacheKey::fromString("smartlink:{$id}"),
$this->serializeData($smartlinkData),
CacheKey::fromString("magiclink:{$id}"),
$this->serializeData($magiclinkData),
Duration::fromSeconds($config->expiryHours * 3600)
);
return $token;
}
public function validate(SmartlinkToken $token): ?SmartlinkData
public function validate(MagicLinkToken $token): ?MagicLinkData
{
$data = $this->cache->get(CacheKey::fromString("smartlink:{$token->value}"));
$data = $this->cache->get(CacheKey::fromString("magiclink:{$token->value}"));
if (! $data->isHit) {
return null;
}
$smartlinkData = $this->deserializeData($data->value);
$magiclinkData = $this->deserializeData($data->value);
return $smartlinkData->isValid() ? $smartlinkData : null;
return $magiclinkData->isValid() ? $magiclinkData : null;
}
public function markAsUsed(SmartlinkToken $token): void
public function markAsUsed(MagicLinkToken $token): void
{
$data = $this->validate($token);
if ($data) {
$updatedData = $data->withUsed(new DateTimeImmutable());
$this->cache->set(
CacheKey::fromString("smartlink:{$token->value}"),
CacheKey::fromString("magiclink:{$token->value}"),
$this->serializeData($updatedData),
Duration::fromSeconds($data->getRemainingTime()->h * 3600)
);
}
}
public function revoke(SmartlinkToken $token): void
public function revoke(MagicLinkToken $token): void
{
$this->cache->forget(CacheKey::fromString("smartlink:{$token->value}"));
$this->cache->forget(CacheKey::fromString("magiclink:{$token->value}"));
}
public function exists(SmartlinkToken $token): bool
public function exists(MagicLinkToken $token): bool
{
return $this->cache->has(CacheKey::fromString("smartlink:{$token->value}"));
return $this->cache->has(CacheKey::fromString("magiclink:{$token->value}"));
}
public function getActiveTokens(int $limit = 100): array
{
// Cache-Keys durchsuchen
$pattern = "smartlink:*";
$pattern = "magiclink:*";
$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);
$magiclinkData = $this->deserializeData($data->value);
;
if ($smartlinkData->isValid()) {
$tokens[] = $smartlinkData;
if ($magiclinkData->isValid()) {
$tokens[] = $magiclinkData;
}
}
}
@@ -121,12 +122,12 @@ final readonly class CacheSmartLinkService implements SmartlinkService
return 0;
}
private function serializeData(SmartlinkData $data): array
private function serializeData(MagicLinkData $data): array
{
return [
'id' => $data->id,
'action' => $data->action->name,
'payload' => $data->payload,
'payload' => $data->payload->toArray(),
'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,
@@ -137,12 +138,12 @@ final readonly class CacheSmartLinkService implements SmartlinkService
];
}
private function deserializeData(array $data): SmartlinkData
private function deserializeData(array $data): MagicLinkData
{
return new SmartlinkData(
return new MagicLinkData(
id: $data['id'],
action: new TokenAction($data['action']),
payload: $data['payload'],
payload: MagicLinkPayload::fromArray($data['payload']),
expiresAt: new DateTimeImmutable($data['expires_at']),
createdAt: new DateTimeImmutable($data['created_at']),
oneTimeUse: $data['one_time_use'],

View File

@@ -2,16 +2,17 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Services;
namespace App\Framework\MagicLinks\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\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\MagicLinkToken;
use App\Framework\MagicLinks\TokenAction;
use App\Framework\MagicLinks\TokenConfig;
use App\Framework\MagicLinks\ValueObjects\MagicLinkPayload;
use App\Framework\Ulid\UlidGenerator;
final class InMemorySmartLinkService implements SmartlinkService
final class InMemoryMagicLinkService implements MagicLinkService
{
private array $tokens = [];
@@ -27,16 +28,16 @@ final class InMemorySmartLinkService implements SmartlinkService
?TokenConfig $config = null,
?string $createdByIp = null,
?string $userAgent = null
): SmartlinkToken {
): MagicLinkToken {
$config ??= new TokenConfig();
$id = $this->ulidGenerator->generate($this->clock);
;
$now = new \DateTimeImmutable();
$smartlinkData = new SmartlinkData(
$magiclinkData = new MagicLinkData(
id: $id,
action: $action,
payload: $payload,
payload: MagicLinkPayload::fromArray($payload),
expiresAt: $config->getExpiryDateTime(),
createdAt: $now,
oneTimeUse: $config->oneTimeUse,
@@ -44,13 +45,13 @@ final class InMemorySmartLinkService implements SmartlinkService
userAgent: $userAgent
);
$token = new SmartlinkToken($id);
$this->tokens[$id] = $smartlinkData;
$token = new MagicLinkToken($id);
$this->tokens[$id] = $magiclinkData;
return $token;
}
public function validate(SmartlinkToken $token): ?SmartlinkData
public function validate(MagicLinkToken $token): ?MagicLinkData
{
if (! isset($this->tokens[$token->value])) {
return null;
@@ -65,7 +66,7 @@ final class InMemorySmartLinkService implements SmartlinkService
return $data;
}
public function markAsUsed(SmartlinkToken $token): void
public function markAsUsed(MagicLinkToken $token): void
{
if (isset($this->tokens[$token->value])) {
$data = $this->tokens[$token->value];
@@ -73,12 +74,12 @@ final class InMemorySmartLinkService implements SmartlinkService
}
}
public function revoke(SmartlinkToken $token): void
public function revoke(MagicLinkToken $token): void
{
unset($this->tokens[$token->value]);
}
public function exists(SmartlinkToken $token): bool
public function exists(MagicLinkToken $token): bool
{
return isset($this->tokens[$token->value]) &&
$this->tokens[$token->value]->isValid();

View File

@@ -2,17 +2,17 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks\Services;
namespace App\Framework\MagicLinks\Services;
use App\Framework\Smartlinks\SmartlinkData;
use App\Framework\Smartlinks\SmartLinkToken;
use App\Framework\Smartlinks\TokenAction;
use App\Framework\Smartlinks\TokenConfig;
use App\Framework\MagicLinks\MagicLinkData;
use App\Framework\MagicLinks\MagicLinkToken;
use App\Framework\MagicLinks\TokenAction;
use App\Framework\MagicLinks\TokenConfig;
interface SmartlinkService
interface MagicLinkService
{
/**
* Generate a new smartlink token
* Generate a new magiclink token
*/
public function generate(
TokenAction $action,
@@ -20,27 +20,27 @@ interface SmartlinkService
?TokenConfig $config = null,
?string $createdByIp = null,
?string $userAgent = null
): SmartlinkToken;
): MagicLinkToken;
/**
* Validate and retrieve smartlink data
* Validate and retrieve magiclink data
*/
public function validate(SmartlinkToken $token): ?SmartlinkData;
public function validate(MagicLinkToken $token): ?MagicLinkData;
/**
* Mark a token as used (for one-time use tokens)
*/
public function markAsUsed(SmartlinkToken $token): void;
public function markAsUsed(MagicLinkToken $token): void;
/**
* Revoke/invalidate a token
*/
public function revoke(SmartlinkToken $token): void;
public function revoke(MagicLinkToken $token): void;
/**
* Check if a token exists and is valid
*/
public function exists(SmartlinkToken $token): bool;
public function exists(MagicLinkToken $token): bool;
/**
* Get all active tokens for debugging/admin purposes

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks;
namespace App\Framework\MagicLinks;
final readonly class TokenAction
{

View File

@@ -2,8 +2,10 @@
declare(strict_types=1);
namespace App\Framework\Smartlinks;
namespace App\Framework\MagicLinks;
use App\Framework\Http\ValueObjects\IpPatternCollection;
use App\Framework\MagicLinks\ValueObjects\Metadata;
use DateTimeImmutable;
use InvalidArgumentException;
@@ -14,8 +16,8 @@ final readonly class TokenConfig
public bool $oneTimeUse = false,
public int $maxUses = 1,
public bool $requireSecureContext = false,
public array $allowedIpRanges = [],
public array $metadata = []
public ?IpPatternCollection $allowedIpRanges = null,
public ?Metadata $metadata = null
) {
if ($expiryHours <= 0) {
throw new InvalidArgumentException('Expiry hours must be positive');
@@ -26,6 +28,24 @@ final readonly class TokenConfig
}
}
public static function default(): self
{
return new self(
allowedIpRanges: IpPatternCollection::empty(),
metadata: Metadata::empty()
);
}
public function getAllowedIpRanges(): IpPatternCollection
{
return $this->allowedIpRanges ?? IpPatternCollection::empty();
}
public function getMetadata(): Metadata
{
return $this->metadata ?? Metadata::empty();
}
public function getExpiryDateTime(): DateTimeImmutable
{
return new DateTimeImmutable("+{$this->expiryHours} hours");

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Framework\MagicLinks\ValueObjects;
final readonly class ActionResultData
{
private function __construct(
private array $data
) {
}
public static function empty(): self
{
return new self([]);
}
public static function fromArray(array $data): self
{
return new self($data);
}
public function with(string $key, mixed $value): self
{
$data = $this->data;
$data[$key] = $value;
return new self($data);
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function has(string $key): bool
{
return isset($this->data[$key]);
}
public function toArray(): array
{
return $this->data;
}
public function isEmpty(): bool
{
return empty($this->data);
}
public function merge(self $other): self
{
return new self(array_merge($this->data, $other->data));
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Framework\MagicLinks\ValueObjects;
final readonly class ErrorCollection
{
/** @param array<string> $errors */
private function __construct(
private array $errors
) {
}
public static function empty(): self
{
return new self([]);
}
public static function fromArray(array $errors): self
{
return new self($errors);
}
public static function single(string $error): self
{
return new self([$error]);
}
public function add(string $error): self
{
$errors = $this->errors;
$errors[] = $error;
return new self($errors);
}
public function addMultiple(array $newErrors): self
{
return new self(array_merge($this->errors, $newErrors));
}
public function hasErrors(): bool
{
return !empty($this->errors);
}
public function isEmpty(): bool
{
return empty($this->errors);
}
public function toArray(): array
{
return $this->errors;
}
public function count(): int
{
return count($this->errors);
}
public function first(): ?string
{
return $this->errors[0] ?? null;
}
public function toString(string $separator = ', '): string
{
return implode($separator, $this->errors);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Framework\MagicLinks\ValueObjects;
final readonly class ExecutionContext
{
private function __construct(
private array $data
) {
}
public static function empty(): self
{
return new self([]);
}
public static function fromArray(array $data): self
{
return new self($data);
}
public function with(string $key, mixed $value): self
{
$data = $this->data;
$data[$key] = $value;
return new self($data);
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function has(string $key): bool
{
return isset($this->data[$key]);
}
public function toArray(): array
{
return $this->data;
}
public function isEmpty(): bool
{
return empty($this->data);
}
public function merge(self $other): self
{
return new self(array_merge($this->data, $other->data));
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\MagicLinks\ValueObjects;
final readonly class MagicLinkPayload
{
private function __construct(
private array $data
) {
if (empty($data)) {
throw new \InvalidArgumentException('Payload cannot be empty');
}
}
public static function fromArray(array $data): self
{
return new self($data);
}
public function toArray(): array
{
return $this->data;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function has(string $key): bool
{
return isset($this->data[$key]);
}
public function with(string $key, mixed $value): self
{
$data = $this->data;
$data[$key] = $value;
return new self($data);
}
public function without(string $key): self
{
$data = $this->data;
unset($data[$key]);
if (empty($data)) {
throw new \InvalidArgumentException('Payload cannot be empty after removal');
}
return new self($data);
}
public function keys(): array
{
return array_keys($this->data);
}
public function values(): array
{
return array_values($this->data);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Framework\MagicLinks\ValueObjects;
final readonly class Metadata
{
private function __construct(
private array $data
) {
}
public static function empty(): self
{
return new self([]);
}
public static function fromArray(array $data): self
{
return new self($data);
}
public function with(string $key, mixed $value): self
{
$data = $this->data;
$data[$key] = $value;
return new self($data);
}
public function without(string $key): self
{
$data = $this->data;
unset($data[$key]);
return new self($data);
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function has(string $key): bool
{
return isset($this->data[$key]);
}
public function toArray(): array
{
return $this->data;
}
public function isEmpty(): bool
{
return empty($this->data);
}
public function merge(self $other): self
{
return new self(array_merge($this->data, $other->data));
}
public function keys(): array
{
return array_keys($this->data);
}
}